Skip to content

Commit 2e05329

Browse files
authored
feat: add custom roles (#14069)
* feat: initial commit custom roles * feat: add page to create and edit custom roles * feat: add assign org role permission * feat: wip * feat: cleanup * fix: role name is disabled when editing the role * fix: assign role context menu falls back to name when no display_name * feat: add helper text to let users know that role name is immutable * fix: format * feat: - hide custom roles tab if experiment is not enabled * fix: use custom TableLoader * fix: fix custom roles text * fix: use PatchRoleRequest * fix: use addIcon to create roles * feat: add cancel and save buttons to top of page * fix: use nameValidator for name * chore: cleanup * feat: add show all permissions checkbox * fix: update sidebar for roles * fix: fix format * fix: custom roles is not needed outside orgs * fix: fix sidebar stories * feat: add custom roles page stories * fix: use organization permissions * feat: add stories for CreateEditRolePageView * fix: design improvements for the create edit role form * feat: add show all resources checkbox to bottom of table * feat: improve spacing
1 parent 238e995 commit 2e05329

14 files changed

+921
-1
lines changed

site/src/api/api.ts

+18
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,24 @@ class ApiMethods {
600600
return response.data;
601601
};
602602

603+
patchOrganizationRole = async (
604+
organizationId: string,
605+
role: TypesGen.Role,
606+
): Promise<TypesGen.Role> => {
607+
const response = await this.axios.patch<TypesGen.Role>(
608+
`/api/v2/organizations/${organizationId}/members/roles`,
609+
role,
610+
);
611+
612+
return response.data;
613+
};
614+
615+
deleteOrganizationRole = async (organizationId: string, roleName: string) => {
616+
await this.axios.delete(
617+
`/api/v2/organizations/${organizationId}/members/roles/${roleName}`,
618+
);
619+
};
620+
603621
/**
604622
* @param organization Can be the organization's ID or name
605623
*/

site/src/api/queries/organizations.ts

+7
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,13 @@ export const organizationPermissions = (organizationId: string | undefined) => {
177177
},
178178
action: "read",
179179
},
180+
assignOrgRole: {
181+
object: {
182+
resource_type: "assign_org_role",
183+
organization_id: organizationId,
184+
},
185+
action: "create",
186+
},
180187
},
181188
}),
182189
};

site/src/api/queries/roles.ts

+36
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1+
import type { QueryClient } from "react-query";
12
import { API } from "api/api";
3+
import type { Role } from "api/typesGenerated";
4+
5+
const getRoleQueryKey = (organizationId: string, roleName: string) => [
6+
"organization",
7+
organizationId,
8+
"role",
9+
roleName,
10+
];
211

312
export const roles = () => {
413
return {
@@ -13,3 +22,30 @@ export const organizationRoles = (organizationId: string) => {
1322
queryFn: () => API.getOrganizationRoles(organizationId),
1423
};
1524
};
25+
26+
export const patchOrganizationRole = (
27+
queryClient: QueryClient,
28+
organizationId: string,
29+
) => {
30+
return {
31+
mutationFn: (request: Role) =>
32+
API.patchOrganizationRole(organizationId, request),
33+
onSuccess: async (updatedRole: Role) =>
34+
await queryClient.invalidateQueries(
35+
getRoleQueryKey(organizationId, updatedRole.name),
36+
),
37+
};
38+
};
39+
40+
export const deleteRole = (
41+
queryClient: QueryClient,
42+
organizationId: string,
43+
) => {
44+
return {
45+
mutationFn: API.deleteOrganizationRole,
46+
onSuccess: async (_: void, roleName: string) =>
47+
await queryClient.invalidateQueries(
48+
getRoleQueryKey(organizationId, roleName),
49+
),
50+
};
51+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { FC } from "react";
2+
import { Helmet } from "react-helmet-async";
3+
import { useMutation, useQuery, useQueryClient } from "react-query";
4+
import { useNavigate, useParams } from "react-router-dom";
5+
import { getErrorMessage } from "api/errors";
6+
import { organizationPermissions } from "api/queries/organizations";
7+
import { patchOrganizationRole, organizationRoles } from "api/queries/roles";
8+
import type { PatchRoleRequest } from "api/typesGenerated";
9+
import { displayError } from "components/GlobalSnackbar/utils";
10+
import { Loader } from "components/Loader/Loader";
11+
import { pageTitle } from "utils/page";
12+
import { useOrganizationSettings } from "../ManagementSettingsLayout";
13+
import CreateEditRolePageView from "./CreateEditRolePageView";
14+
15+
export const CreateEditRolePage: FC = () => {
16+
const queryClient = useQueryClient();
17+
const navigate = useNavigate();
18+
const { organization: organizationName, roleName } = useParams() as {
19+
organization: string;
20+
roleName: string;
21+
};
22+
const { organizations } = useOrganizationSettings();
23+
const organization = organizations?.find((o) => o.name === organizationName);
24+
const permissionsQuery = useQuery(organizationPermissions(organization?.id));
25+
const patchOrganizationRoleMutation = useMutation(
26+
patchOrganizationRole(queryClient, organizationName),
27+
);
28+
const { data: roleData, isLoading } = useQuery(
29+
organizationRoles(organizationName),
30+
);
31+
const role = roleData?.find((role) => role.name === roleName);
32+
const permissions = permissionsQuery.data;
33+
34+
if (isLoading || !permissions) {
35+
return <Loader />;
36+
}
37+
38+
return (
39+
<>
40+
<Helmet>
41+
<title>
42+
{pageTitle(
43+
role !== undefined ? "Edit Custom Role" : "Create Custom Role",
44+
)}
45+
</title>
46+
</Helmet>
47+
48+
<CreateEditRolePageView
49+
role={role}
50+
onSubmit={async (data: PatchRoleRequest) => {
51+
try {
52+
await patchOrganizationRoleMutation.mutateAsync(data);
53+
navigate(`/organizations/${organizationName}/roles`);
54+
} catch (error) {
55+
displayError(
56+
getErrorMessage(error, "Failed to update custom role"),
57+
);
58+
}
59+
}}
60+
error={patchOrganizationRoleMutation.error}
61+
isLoading={patchOrganizationRoleMutation.isLoading}
62+
organizationName={organizationName}
63+
canAssignOrgRole={permissions.assignOrgRole}
64+
/>
65+
</>
66+
);
67+
};
68+
69+
export default CreateEditRolePage;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import {
3+
mockApiError,
4+
MockRoleWithOrgPermissions,
5+
assignableRole,
6+
} from "testHelpers/entities";
7+
import { CreateEditRolePageView } from "./CreateEditRolePageView";
8+
9+
const meta: Meta<typeof CreateEditRolePageView> = {
10+
title: "pages/OrganizationCreateEditRolePage",
11+
component: CreateEditRolePageView,
12+
};
13+
14+
export default meta;
15+
type Story = StoryObj<typeof CreateEditRolePageView>;
16+
17+
export const Default: Story = {
18+
args: {
19+
role: assignableRole(MockRoleWithOrgPermissions, true),
20+
onSubmit: () => null,
21+
error: undefined,
22+
isLoading: false,
23+
organizationName: "my-org",
24+
canAssignOrgRole: true,
25+
},
26+
};
27+
28+
export const WithError: Story = {
29+
args: {
30+
role: assignableRole(MockRoleWithOrgPermissions, true),
31+
onSubmit: () => null,
32+
error: mockApiError({
33+
message: "A role named new-role already exists.",
34+
validations: [{ field: "name", detail: "Role names must be unique" }],
35+
}),
36+
isLoading: false,
37+
organizationName: "my-org",
38+
canAssignOrgRole: true,
39+
},
40+
};
41+
42+
export const CannotEdit: Story = {
43+
args: {
44+
role: assignableRole(MockRoleWithOrgPermissions, true),
45+
onSubmit: () => null,
46+
error: undefined,
47+
isLoading: false,
48+
organizationName: "my-org",
49+
canAssignOrgRole: false,
50+
},
51+
};
52+
53+
export const ShowAllResources: Story = {
54+
args: {
55+
role: assignableRole(MockRoleWithOrgPermissions, true),
56+
onSubmit: () => null,
57+
error: undefined,
58+
isLoading: false,
59+
organizationName: "my-org",
60+
canAssignOrgRole: true,
61+
allResources: true,
62+
},
63+
};

0 commit comments

Comments
 (0)