mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -177,6 +177,13 @@ export const organizationPermissions = (organizationId: string | undefined) => {
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
assignOrgRole: {
|
||||
object: {
|
||||
resource_type: "assign_org_role",
|
||||
organization_id: organizationId,
|
||||
},
|
||||
action: "create",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
+63
@@ -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>{" "}
|
||||
–{" "}
|
||||
<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] ?? ""}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user