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
This commit is contained in:
Jaayden Halko
2024-08-08 21:05:20 -04:00
committed by GitHub
parent 238e9956f4
commit 2e05329111
14 changed files with 921 additions and 1 deletions
+18
View File
@@ -600,6 +600,24 @@ class ApiMethods {
return response.data;
};
patchOrganizationRole = async (
organizationId: string,
role: TypesGen.Role,
): Promise<TypesGen.Role> => {
const response = await this.axios.patch<TypesGen.Role>(
`/api/v2/organizations/${organizationId}/members/roles`,
role,
);
return response.data;
};
deleteOrganizationRole = async (organizationId: string, roleName: string) => {
await this.axios.delete(
`/api/v2/organizations/${organizationId}/members/roles/${roleName}`,
);
};
/**
* @param organization Can be the organization's ID or name
*/
+7
View File
@@ -177,6 +177,13 @@ export const organizationPermissions = (organizationId: string | undefined) => {
},
action: "read",
},
assignOrgRole: {
object: {
resource_type: "assign_org_role",
organization_id: organizationId,
},
action: "create",
},
},
}),
};
+36
View File
@@ -1,4 +1,13 @@
import type { QueryClient } from "react-query";
import { API } from "api/api";
import type { Role } from "api/typesGenerated";
const getRoleQueryKey = (organizationId: string, roleName: string) => [
"organization",
organizationId,
"role",
roleName,
];
export const roles = () => {
return {
@@ -13,3 +22,30 @@ export const organizationRoles = (organizationId: string) => {
queryFn: () => API.getOrganizationRoles(organizationId),
};
};
export const patchOrganizationRole = (
queryClient: QueryClient,
organizationId: string,
) => {
return {
mutationFn: (request: Role) =>
API.patchOrganizationRole(organizationId, request),
onSuccess: async (updatedRole: Role) =>
await queryClient.invalidateQueries(
getRoleQueryKey(organizationId, updatedRole.name),
),
};
};
export const deleteRole = (
queryClient: QueryClient,
organizationId: string,
) => {
return {
mutationFn: API.deleteOrganizationRole,
onSuccess: async (_: void, roleName: string) =>
await queryClient.invalidateQueries(
getRoleQueryKey(organizationId, roleName),
),
};
};
@@ -0,0 +1,69 @@
import type { FC } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate, useParams } from "react-router-dom";
import { getErrorMessage } from "api/errors";
import { organizationPermissions } from "api/queries/organizations";
import { patchOrganizationRole, organizationRoles } from "api/queries/roles";
import type { PatchRoleRequest } from "api/typesGenerated";
import { displayError } from "components/GlobalSnackbar/utils";
import { Loader } from "components/Loader/Loader";
import { pageTitle } from "utils/page";
import { useOrganizationSettings } from "../ManagementSettingsLayout";
import CreateEditRolePageView from "./CreateEditRolePageView";
export const CreateEditRolePage: FC = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { organization: organizationName, roleName } = useParams() as {
organization: string;
roleName: string;
};
const { organizations } = useOrganizationSettings();
const organization = organizations?.find((o) => o.name === organizationName);
const permissionsQuery = useQuery(organizationPermissions(organization?.id));
const patchOrganizationRoleMutation = useMutation(
patchOrganizationRole(queryClient, organizationName),
);
const { data: roleData, isLoading } = useQuery(
organizationRoles(organizationName),
);
const role = roleData?.find((role) => role.name === roleName);
const permissions = permissionsQuery.data;
if (isLoading || !permissions) {
return <Loader />;
}
return (
<>
<Helmet>
<title>
{pageTitle(
role !== undefined ? "Edit Custom Role" : "Create Custom Role",
)}
</title>
</Helmet>
<CreateEditRolePageView
role={role}
onSubmit={async (data: PatchRoleRequest) => {
try {
await patchOrganizationRoleMutation.mutateAsync(data);
navigate(`/organizations/${organizationName}/roles`);
} catch (error) {
displayError(
getErrorMessage(error, "Failed to update custom role"),
);
}
}}
error={patchOrganizationRoleMutation.error}
isLoading={patchOrganizationRoleMutation.isLoading}
organizationName={organizationName}
canAssignOrgRole={permissions.assignOrgRole}
/>
</>
);
};
export default CreateEditRolePage;
@@ -0,0 +1,63 @@
import type { Meta, StoryObj } from "@storybook/react";
import {
mockApiError,
MockRoleWithOrgPermissions,
assignableRole,
} from "testHelpers/entities";
import { CreateEditRolePageView } from "./CreateEditRolePageView";
const meta: Meta<typeof CreateEditRolePageView> = {
title: "pages/OrganizationCreateEditRolePage",
component: CreateEditRolePageView,
};
export default meta;
type Story = StoryObj<typeof CreateEditRolePageView>;
export const Default: Story = {
args: {
role: assignableRole(MockRoleWithOrgPermissions, true),
onSubmit: () => null,
error: undefined,
isLoading: false,
organizationName: "my-org",
canAssignOrgRole: true,
},
};
export const WithError: Story = {
args: {
role: assignableRole(MockRoleWithOrgPermissions, true),
onSubmit: () => null,
error: mockApiError({
message: "A role named new-role already exists.",
validations: [{ field: "name", detail: "Role names must be unique" }],
}),
isLoading: false,
organizationName: "my-org",
canAssignOrgRole: true,
},
};
export const CannotEdit: Story = {
args: {
role: assignableRole(MockRoleWithOrgPermissions, true),
onSubmit: () => null,
error: undefined,
isLoading: false,
organizationName: "my-org",
canAssignOrgRole: false,
},
};
export const ShowAllResources: Story = {
args: {
role: assignableRole(MockRoleWithOrgPermissions, true),
onSubmit: () => null,
error: undefined,
isLoading: false,
organizationName: "my-org",
canAssignOrgRole: true,
allResources: true,
},
};
@@ -0,0 +1,332 @@
import type { Interpolation, Theme } from "@emotion/react";
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import Button from "@mui/material/Button";
import Checkbox from "@mui/material/Checkbox";
import FormControlLabel from "@mui/material/FormControlLabel";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableFooter from "@mui/material/TableFooter";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import TextField from "@mui/material/TextField";
import { useFormik } from "formik";
import { type ChangeEvent, useState, type FC } from "react";
import { useNavigate } from "react-router-dom";
import * as Yup from "yup";
import { isApiValidationError } from "api/errors";
import { RBACResourceActions } from "api/rbacresources_gen";
import type {
Role,
PatchRoleRequest,
Permission,
AssignableRoles,
RBACResource,
RBACAction,
} from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { FormFields, FormFooter, VerticalForm } from "components/Form/Form";
import {
PageHeader,
PageHeaderSubtitle,
PageHeaderTitle,
} from "components/PageHeader/PageHeader";
import { getFormHelpers, nameValidator } from "utils/formUtils";
const validationSchema = Yup.object({
name: nameValidator("Name"),
});
export type CreateEditRolePageViewProps = {
role: AssignableRoles | undefined;
onSubmit: (data: PatchRoleRequest) => void;
error?: unknown;
isLoading: boolean;
organizationName: string;
canAssignOrgRole: boolean;
allResources?: boolean;
};
export const CreateEditRolePageView: FC<CreateEditRolePageViewProps> = ({
role,
onSubmit,
error,
isLoading,
organizationName,
canAssignOrgRole,
allResources = false,
}) => {
const navigate = useNavigate();
const onCancel = () => navigate(-1);
const form = useFormik<PatchRoleRequest>({
initialValues: {
name: role?.name || "",
display_name: role?.display_name || "",
site_permissions: role?.site_permissions || [],
organization_permissions: role?.organization_permissions || [],
user_permissions: role?.user_permissions || [],
},
validationSchema,
onSubmit,
});
const getFieldHelpers = getFormHelpers<Role>(form, error);
return (
<>
<PageHeader
actions={
canAssignOrgRole && (
<>
<Button
onClick={() => {
navigate(`/organizations/${organizationName}/roles`);
}}
>
Cancel
</Button>
<Button
variant="contained"
color="primary"
onClick={() => {
form.handleSubmit();
}}
>
{role !== undefined ? "Save" : "Create Role"}
</Button>
</>
)
}
>
<PageHeaderTitle>
{role ? "Edit" : "Create"} custom role
</PageHeaderTitle>
<PageHeaderSubtitle>
{"Set a name and permissions for this role."}
</PageHeaderSubtitle>
</PageHeader>
<VerticalForm onSubmit={form.handleSubmit}>
<FormFields>
{Boolean(error) && !isApiValidationError(error) && (
<ErrorAlert error={error} />
)}
<TextField
{...getFieldHelpers("name", {
helperText:
"The role name cannot be modified after the role is created.",
})}
autoFocus
fullWidth
disabled={role !== undefined}
label="Name"
/>
<TextField
{...getFieldHelpers("display_name", {
helperText: "Optional: keep empty to default to the name.",
})}
fullWidth
label="Display Name"
/>
<ActionCheckboxes
permissions={role?.organization_permissions || []}
form={form}
allResources={allResources}
/>
</FormFields>
{canAssignOrgRole && (
<FormFooter
onCancel={onCancel}
isLoading={isLoading}
submitLabel={role !== undefined ? "Save" : "Create Role"}
/>
)}
</VerticalForm>
</>
);
};
interface ActionCheckboxesProps {
permissions: readonly Permission[] | undefined;
form: ReturnType<typeof useFormik<Role>> & { values: Role };
allResources: boolean;
}
const ResourceActionComparator = (
p: Permission,
resource: string,
action: string,
) =>
p.resource_type === resource &&
(p.action.toString() === "*" || p.action === action);
const DEFAULT_RESOURCES = [
"audit_log",
"group",
"template",
"organization_member",
"provisioner_daemon",
"workspace",
];
const resources = new Set(DEFAULT_RESOURCES);
const filteredRBACResourceActions = Object.fromEntries(
Object.entries(RBACResourceActions).filter(([resource]) =>
resources.has(resource),
),
);
const ActionCheckboxes: FC<ActionCheckboxesProps> = ({
permissions,
form,
allResources,
}) => {
const [checkedActions, setCheckActions] = useState(permissions);
const [showAllResources, setShowAllResources] = useState(allResources);
const handleActionCheckChange = async (
e: ChangeEvent<HTMLInputElement>,
form: ReturnType<typeof useFormik<Role>> & { values: Role },
) => {
const { name, checked } = e.currentTarget;
const [resource_type, action] = name.split(":");
const newPermissions = checked
? [
...(checkedActions ?? []),
{
negate: false,
resource_type: resource_type as RBACResource,
action: action as RBACAction,
},
]
: checkedActions?.filter(
(p) => p.resource_type !== resource_type || p.action !== action,
);
setCheckActions(newPermissions);
await form.setFieldValue("organization_permissions", newPermissions);
};
const resourceActions = showAllResources
? RBACResourceActions
: filteredRBACResourceActions;
return (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Permission</TableCell>
<TableCell
align="right"
sx={{ paddingTop: 0.4, paddingBottom: 0.4 }}
>
<ShowAllResourcesCheckbox
showAllResources={showAllResources}
setShowAllResources={setShowAllResources}
/>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(resourceActions).map(([resourceKey, value]) => {
return (
<TableRow key={resourceKey}>
<TableCell sx={{ paddingLeft: 2 }} colSpan={2}>
<li key={resourceKey} css={styles.checkBoxes}>
{resourceKey}
<ul css={styles.checkBoxes}>
{Object.entries(value).map(([actionKey, value]) => (
<li key={actionKey}>
<span css={styles.actionText}>
<Checkbox
size="small"
name={`${resourceKey}:${actionKey}`}
checked={checkedActions?.some((p) =>
ResourceActionComparator(
p,
resourceKey,
actionKey,
),
)}
onChange={(e) => handleActionCheckChange(e, form)}
/>
{actionKey}
</span>{" "}
&ndash;{" "}
<span css={styles.actionDescription}>{value}</span>
</li>
))}
</ul>
</li>
</TableCell>
</TableRow>
);
})}
</TableBody>
<TableFooter>
<TableRow>
<TableCell
align="right"
colSpan={2}
sx={{ paddingTop: 0.4, paddingBottom: 0.4, paddingRight: 4 }}
>
<ShowAllResourcesCheckbox
showAllResources={showAllResources}
setShowAllResources={setShowAllResources}
/>
</TableCell>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
);
};
interface ShowAllResourcesCheckboxProps {
showAllResources: boolean;
setShowAllResources: React.Dispatch<React.SetStateAction<boolean>>;
}
const ShowAllResourcesCheckbox: FC<ShowAllResourcesCheckboxProps> = ({
showAllResources,
setShowAllResources,
}) => {
return (
<FormControlLabel
sx={{ marginRight: 1 }}
control={
<Checkbox
size="small"
id="show_all_permissions"
name="show_all_permissions"
checked={showAllResources}
onChange={(e) => setShowAllResources(e.currentTarget.checked)}
checkedIcon={<VisibilityOutlinedIcon />}
icon={<VisibilityOffOutlinedIcon />}
/>
}
label={<span style={{ fontSize: 12 }}>Show all permissions</span>}
/>
);
};
const styles = {
checkBoxes: {
margin: 0,
listStyleType: "none",
},
actionText: (theme) => ({
color: theme.palette.text.primary,
}),
actionDescription: (theme) => ({
color: theme.palette.text.secondary,
}),
} satisfies Record<string, Interpolation<Theme>>;
export default CreateEditRolePageView;
@@ -0,0 +1,80 @@
import AddIcon from "@mui/icons-material/AddOutlined";
import Button from "@mui/material/Button";
import { type FC, useEffect } from "react";
import { Helmet } from "react-helmet-async";
import { useQuery } from "react-query";
import { Link as RouterLink, useParams } from "react-router-dom";
import { getErrorMessage } from "api/errors";
import { organizationPermissions } from "api/queries/organizations";
import { organizationRoles } from "api/queries/roles";
import { displayError } from "components/GlobalSnackbar/utils";
import { Loader } from "components/Loader/Loader";
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import { pageTitle } from "utils/page";
import { useOrganizationSettings } from "../ManagementSettingsLayout";
import CustomRolesPageView from "./CustomRolesPageView";
export const CustomRolesPage: FC = () => {
const { custom_roles: isCustomRolesEnabled } = useFeatureVisibility();
const { organization: organizationName } = useParams() as {
organization: string;
};
const { organizations } = useOrganizationSettings();
const organization = organizations?.find((o) => o.name === organizationName);
const permissionsQuery = useQuery(organizationPermissions(organization?.id));
const organizationRolesQuery = useQuery(organizationRoles(organizationName));
const filteredRoleData = organizationRolesQuery.data?.filter(
(role) => role.built_in === false,
);
const permissions = permissionsQuery.data;
useEffect(() => {
if (organizationRolesQuery.error) {
displayError(
getErrorMessage(
organizationRolesQuery.error,
"Error loading custom roles.",
),
);
}
}, [organizationRolesQuery.error]);
if (!permissions) {
return <Loader />;
}
return (
<>
<Helmet>
<title>{pageTitle("Custom Roles")}</title>
</Helmet>
<PageHeader
actions={
<>
{permissions.assignOrgRole && isCustomRolesEnabled && (
<Button
component={RouterLink}
startIcon={<AddIcon />}
to="create"
>
Create custom role
</Button>
)}
</>
}
>
<PageHeaderTitle>Custom Roles</PageHeaderTitle>
</PageHeader>
<CustomRolesPageView
roles={filteredRoleData}
canAssignOrgRole={permissions.assignOrgRole}
isCustomRolesEnabled={isCustomRolesEnabled}
/>
</>
);
};
export default CustomRolesPage;
@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from "@storybook/react";
import { MockRoleWithOrgPermissions } from "testHelpers/entities";
import { CustomRolesPageView } from "./CustomRolesPageView";
const meta: Meta<typeof CustomRolesPageView> = {
title: "pages/OrganizationCustomRolesPage",
component: CustomRolesPageView,
};
export default meta;
type Story = StoryObj<typeof CustomRolesPageView>;
export const NotEnabled: Story = {
args: {
roles: [MockRoleWithOrgPermissions],
canAssignOrgRole: true,
isCustomRolesEnabled: false,
},
};
export const Enabled: Story = {
args: {
roles: [MockRoleWithOrgPermissions],
canAssignOrgRole: true,
isCustomRolesEnabled: true,
},
};
export const EmptyDisplayName: Story = {
args: {
roles: [
{
...MockRoleWithOrgPermissions,
name: "my-custom-role",
display_name: "",
},
],
canAssignOrgRole: true,
isCustomRolesEnabled: true,
},
};
export const EmptyRoleWithoutPermission: Story = {
args: {
roles: [],
canAssignOrgRole: false,
isCustomRolesEnabled: true,
},
};
export const EmptyRoleWithPermission: Story = {
args: {
roles: [],
canAssignOrgRole: true,
isCustomRolesEnabled: true,
},
};
@@ -0,0 +1,166 @@
import type { Interpolation, Theme } from "@emotion/react";
import AddOutlined from "@mui/icons-material/AddOutlined";
import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight";
import Button from "@mui/material/Button";
import Skeleton from "@mui/material/Skeleton";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import type { FC } from "react";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import type { Role } from "api/typesGenerated";
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import { EmptyState } from "components/EmptyState/EmptyState";
import { Paywall } from "components/Paywall/Paywall";
import {
TableLoaderSkeleton,
TableRowSkeleton,
} from "components/TableLoader/TableLoader";
import { useClickableTableRow } from "hooks";
import { docs } from "utils/docs";
export type CustomRolesPageViewProps = {
roles: Role[] | undefined;
canAssignOrgRole: boolean;
isCustomRolesEnabled: boolean;
};
export const CustomRolesPageView: FC<CustomRolesPageViewProps> = ({
roles,
canAssignOrgRole,
isCustomRolesEnabled,
}) => {
const isLoading = roles === undefined;
const isEmpty = Boolean(roles && roles.length === 0);
return (
<>
<ChooseOne>
<Cond condition={!isCustomRolesEnabled}>
<Paywall
message="Custom Roles"
description="Create custom roles to assign a specific set of permissions to a user. You need an Enterprise license to use this feature."
documentationLink={docs("/admin/groups")}
/>
</Cond>
<Cond>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="50%">Name</TableCell>
<TableCell width="49%">Permissions</TableCell>
<TableCell width="1%"></TableCell>
</TableRow>
</TableHead>
<TableBody>
<ChooseOne>
<Cond condition={isLoading}>
<TableLoader />
</Cond>
<Cond condition={isEmpty}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="No custom roles yet"
description={
canAssignOrgRole
? "Create your first custom role"
: "You don't have permission to create a custom role"
}
cta={
canAssignOrgRole && (
<Button
component={RouterLink}
to="create"
startIcon={<AddOutlined />}
variant="contained"
>
Create custom role
</Button>
)
}
/>
</TableCell>
</TableRow>
</Cond>
<Cond>
{roles?.map((role) => (
<RoleRow key={role.name} role={role} />
))}
</Cond>
</ChooseOne>
</TableBody>
</Table>
</TableContainer>
</Cond>
</ChooseOne>
</>
);
};
interface RoleRowProps {
role: Role;
}
const RoleRow: FC<RoleRowProps> = ({ role }) => {
const navigate = useNavigate();
const rowProps = useClickableTableRow({
onClick: () => navigate(role.name),
});
return (
<TableRow data-testid={`role-${role.name}`} {...rowProps}>
<TableCell>{role.display_name || role.name}</TableCell>
<TableCell css={styles.secondary}>
{role.organization_permissions.length}
</TableCell>
<TableCell>
<div css={styles.arrowCell}>
<KeyboardArrowRight css={styles.arrowRight} />
</div>
</TableCell>
</TableRow>
);
};
const TableLoader = () => {
return (
<TableLoaderSkeleton>
<TableRowSkeleton>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
<TableCell>
<Skeleton variant="text" width="25%" />
</TableCell>
</TableRowSkeleton>
</TableLoaderSkeleton>
);
};
const styles = {
arrowRight: (theme) => ({
color: theme.palette.text.secondary,
width: 20,
height: 20,
}),
arrowCell: {
display: "flex",
},
secondary: (theme) => ({
color: theme.palette.text.secondary,
}),
} satisfies Record<string, Interpolation<Theme>>;
export default CustomRolesPageView;
@@ -4,11 +4,13 @@ import {
MockOrganization2,
MockPermissions,
} from "testHelpers/entities";
import { withDashboardProvider } from "testHelpers/storybook";
import { SidebarView } from "./SidebarView";
const meta: Meta<typeof SidebarView> = {
title: "components/MultiOrgSidebarView",
component: SidebarView,
decorators: [withDashboardProvider],
args: {
activeOrganization: undefined,
activeOrgPermissions: undefined,
@@ -88,6 +90,7 @@ export const SelectedOrgAdmin: Story = {
viewMembers: true,
viewGroups: true,
auditOrganization: true,
assignOrgRole: true,
},
},
};
@@ -10,6 +10,7 @@ import { Sidebar as BaseSidebar } from "components/Sidebar/Sidebar";
import { Stack } from "components/Stack/Stack";
import { UserAvatar } from "components/UserAvatar/UserAvatar";
import { type ClassName, useClassName } from "hooks/useClassName";
import { useDashboard } from "modules/dashboard/useDashboard";
import { linkToAuditing, linkToUsers, withFilter } from "modules/navigation";
interface SidebarProps {
@@ -184,6 +185,8 @@ interface OrganizationSettingsNavigationProps {
const OrganizationSettingsNavigation: FC<
OrganizationSettingsNavigationProps
> = (props) => {
const { experiments } = useDashboard();
return (
<>
<SidebarNavItem
@@ -225,6 +228,14 @@ const OrganizationSettingsNavigation: FC<
Groups
</SidebarNavSubItem>
)}
{props.permissions.assignOrgRole &&
experiments.includes("custom-roles") && (
<SidebarNavSubItem
href={urlForSubpage(props.organization.name, "roles")}
>
Roles
</SidebarNavSubItem>
)}
{/* For now redirect to the site-wide audit page with the organization
pre-filled into the filter. Based on user feedback we might want
to serve a copy of the audit page or even delete this link. */}
@@ -138,7 +138,7 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
onChange={handleChange}
isChecked={selectedRoleNames.has(role.name)}
value={role.name}
name={role.display_name}
name={role.display_name || role.name}
description={roleDescriptions[role.name] ?? ""}
/>
))}
+13
View File
@@ -242,6 +242,14 @@ const OrganizationGroupSettingsPage = lazy(
const OrganizationMembersPage = lazy(
() => import("./pages/ManagementSettingsPage/OrganizationMembersPage"),
);
const OrganizationCustomRolesPage = lazy(
() =>
import("./pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage"),
);
const CreateEditRolePage = lazy(
() =>
import("./pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage"),
);
const TemplateEmbedPage = lazy(
() => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"),
);
@@ -376,6 +384,11 @@ export const router = createBrowserRouter(
<Route index element={<OrganizationSettingsPage />} />
<Route path="members" element={<OrganizationMembersPage />} />
{groupsRouter()}
<Route path="roles">
<Route index element={<OrganizationCustomRolesPage />} />
<Route path="create" element={<CreateEditRolePage />} />
<Route path=":roleName" element={<CreateEditRolePage />} />
</Route>
<Route path="auditing" element={<></>} />
</Route>
</Route>
+65
View File
@@ -326,6 +326,71 @@ export const MockOrganizationAuditorRole: TypesGen.Role = {
organization_id: MockOrganization.id,
};
export const MockRoleWithOrgPermissions: TypesGen.Role = {
name: "my-role-1",
display_name: "My Role 1",
organization_id: MockOrganization.id,
site_permissions: [],
organization_permissions: [
{
negate: false,
resource_type: "organization_member",
action: "create",
},
{
negate: false,
resource_type: "organization_member",
action: "delete",
},
{
negate: false,
resource_type: "organization_member",
action: "read",
},
{
negate: false,
resource_type: "organization_member",
action: "update",
},
{
negate: false,
resource_type: "template",
action: "create",
},
{
negate: false,
resource_type: "template",
action: "delete",
},
{
negate: false,
resource_type: "template",
action: "read",
},
{
negate: false,
resource_type: "template",
action: "update",
},
{
negate: false,
resource_type: "template",
action: "view_insights",
},
{
negate: false,
resource_type: "audit_log",
action: "create",
},
{
negate: false,
resource_type: "audit_log",
action: "read",
},
],
user_permissions: [],
};
// assignableRole takes a role and a boolean. The boolean implies if the
// actor can assign (add/remove) the role from other users.
export function assignableRole(