mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: improve custom roles create/edit page (#14456)
* fix: improve show/hide checkbox text * feat: add parent checkbox for grouped resource permissions * fix: align action list item to a grid * chore: add additional tests * fix: format
This commit is contained in:
+26
@@ -1,5 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { expect, userEvent, within } from "@storybook/test";
|
||||
import {
|
||||
MockRole2WithOrgPermissions,
|
||||
MockRoleWithOrgPermissions,
|
||||
assignableRole,
|
||||
mockApiError,
|
||||
@@ -25,6 +27,16 @@ export const Default: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const CheckboxIndeterminate: Story = {
|
||||
args: {
|
||||
role: assignableRole(MockRole2WithOrgPermissions, true),
|
||||
onSubmit: () => null,
|
||||
isLoading: false,
|
||||
organizationName: "my-org",
|
||||
canAssignOrgRole: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
role: assignableRole(MockRoleWithOrgPermissions, true),
|
||||
@@ -61,3 +73,17 @@ export const ShowAllResources: Story = {
|
||||
allResources: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const ToggleParentCheckbox: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const user = userEvent.setup();
|
||||
const canvas = within(canvasElement);
|
||||
const checkbox = await canvas
|
||||
.getByTestId("audit_log")
|
||||
.getElementsByTagName("input")[0];
|
||||
await user.click(checkbox);
|
||||
await expect(checkbox).toBeChecked();
|
||||
await user.click(checkbox);
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -146,12 +146,6 @@ export const CreateEditRolePageView: FC<CreateEditRolePageViewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface ActionCheckboxesProps {
|
||||
permissions: readonly Permission[] | undefined;
|
||||
form: ReturnType<typeof useFormik<Role>> & { values: Role };
|
||||
allResources: boolean;
|
||||
}
|
||||
|
||||
const ResourceActionComparator = (
|
||||
p: Permission,
|
||||
resource: string,
|
||||
@@ -160,6 +154,7 @@ const ResourceActionComparator = (
|
||||
p.resource_type === resource &&
|
||||
(p.action.toString() === "*" || p.action === action);
|
||||
|
||||
// the subset of resources that are useful for most users
|
||||
const DEFAULT_RESOURCES = [
|
||||
"audit_log",
|
||||
"group",
|
||||
@@ -177,6 +172,12 @@ const filteredRBACResourceActions = Object.fromEntries(
|
||||
),
|
||||
);
|
||||
|
||||
interface ActionCheckboxesProps {
|
||||
permissions: readonly Permission[];
|
||||
form: ReturnType<typeof useFormik<Role>> & { values: Role };
|
||||
allResources: boolean;
|
||||
}
|
||||
|
||||
const ActionCheckboxes: FC<ActionCheckboxesProps> = ({
|
||||
permissions,
|
||||
form,
|
||||
@@ -185,6 +186,10 @@ const ActionCheckboxes: FC<ActionCheckboxesProps> = ({
|
||||
const [checkedActions, setCheckActions] = useState(permissions);
|
||||
const [showAllResources, setShowAllResources] = useState(allResources);
|
||||
|
||||
const resourceActions = showAllResources
|
||||
? RBACResourceActions
|
||||
: filteredRBACResourceActions;
|
||||
|
||||
const handleActionCheckChange = async (
|
||||
e: ChangeEvent<HTMLInputElement>,
|
||||
form: ReturnType<typeof useFormik<Role>> & { values: Role },
|
||||
@@ -194,7 +199,7 @@ const ActionCheckboxes: FC<ActionCheckboxesProps> = ({
|
||||
|
||||
const newPermissions = checked
|
||||
? [
|
||||
...(checkedActions ?? []),
|
||||
...checkedActions,
|
||||
{
|
||||
negate: false,
|
||||
resource_type: resource_type as RBACResource,
|
||||
@@ -209,9 +214,36 @@ const ActionCheckboxes: FC<ActionCheckboxesProps> = ({
|
||||
await form.setFieldValue("organization_permissions", newPermissions);
|
||||
};
|
||||
|
||||
const resourceActions = showAllResources
|
||||
? RBACResourceActions
|
||||
: filteredRBACResourceActions;
|
||||
const handleResourceCheckChange = async (
|
||||
e: ChangeEvent<HTMLInputElement>,
|
||||
form: ReturnType<typeof useFormik<Role>> & { values: Role },
|
||||
indeterminate: boolean,
|
||||
) => {
|
||||
const { name, checked } = e.currentTarget;
|
||||
const resource = name as RBACResource;
|
||||
|
||||
const resourceActionsForResource = resourceActions[resource] || {};
|
||||
|
||||
const newCheckedActions =
|
||||
!checked || indeterminate
|
||||
? checkedActions?.filter((p) => p.resource_type !== resource)
|
||||
: checkedActions;
|
||||
|
||||
const newPermissions =
|
||||
checked || indeterminate
|
||||
? [
|
||||
...newCheckedActions,
|
||||
...Object.keys(resourceActionsForResource).map((resourceKey) => ({
|
||||
negate: false,
|
||||
resource_type: resource as RBACResource,
|
||||
action: resourceKey as RBACAction,
|
||||
})),
|
||||
]
|
||||
: [...newCheckedActions];
|
||||
|
||||
setCheckActions(newPermissions);
|
||||
await form.setFieldValue("organization_permissions", newPermissions);
|
||||
};
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
@@ -233,36 +265,17 @@ const ActionCheckboxes: FC<ActionCheckboxesProps> = ({
|
||||
<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>
|
||||
<PermissionCheckboxGroup
|
||||
key={resourceKey}
|
||||
checkedActions={checkedActions?.filter(
|
||||
(a) => a.resource_type === resourceKey,
|
||||
)}
|
||||
resourceKey={resourceKey}
|
||||
value={value}
|
||||
form={form}
|
||||
handleActionCheckChange={handleActionCheckChange}
|
||||
handleResourceCheckChange={handleResourceCheckChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
@@ -285,6 +298,77 @@ const ActionCheckboxes: FC<ActionCheckboxesProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface PermissionCheckboxGroupProps {
|
||||
checkedActions: readonly Permission[];
|
||||
resourceKey: string;
|
||||
value: Partial<Record<RBACAction, string>>;
|
||||
form: ReturnType<typeof useFormik<Role>> & { values: Role };
|
||||
handleActionCheckChange: (
|
||||
e: ChangeEvent<HTMLInputElement>,
|
||||
form: ReturnType<typeof useFormik<Role>> & { values: Role },
|
||||
) => Promise<void>;
|
||||
handleResourceCheckChange: (
|
||||
e: ChangeEvent<HTMLInputElement>,
|
||||
form: ReturnType<typeof useFormik<Role>> & { values: Role },
|
||||
indeterminate: boolean,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
const PermissionCheckboxGroup: FC<PermissionCheckboxGroupProps> = ({
|
||||
checkedActions,
|
||||
resourceKey,
|
||||
value,
|
||||
form,
|
||||
handleActionCheckChange,
|
||||
handleResourceCheckChange,
|
||||
}) => {
|
||||
return (
|
||||
<TableRow key={resourceKey}>
|
||||
<TableCell sx={{ paddingLeft: 2 }} colSpan={2}>
|
||||
<li key={resourceKey} css={styles.checkBoxes}>
|
||||
<Checkbox
|
||||
size="small"
|
||||
name={`${resourceKey}`}
|
||||
checked={checkedActions.length === Object.keys(value).length}
|
||||
indeterminate={
|
||||
checkedActions.length > 0 &&
|
||||
checkedActions.length < Object.keys(value).length
|
||||
}
|
||||
data-testid={`${resourceKey}`}
|
||||
onChange={(e) =>
|
||||
handleResourceCheckChange(
|
||||
e,
|
||||
form,
|
||||
checkedActions.length > 0 &&
|
||||
checkedActions.length < Object.keys(value).length,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{resourceKey}
|
||||
<ul css={styles.checkBoxes}>
|
||||
{Object.entries(value).map(([actionKey, value]) => (
|
||||
<li key={actionKey} css={styles.actionItem}>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
interface ShowAllResourcesCheckboxProps {
|
||||
showAllResources: boolean;
|
||||
setShowAllResources: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@@ -308,7 +392,13 @@ const ShowAllResourcesCheckbox: FC<ShowAllResourcesCheckboxProps> = ({
|
||||
icon={<VisibilityOffOutlinedIcon />}
|
||||
/>
|
||||
}
|
||||
label={<span style={{ fontSize: 12 }}>Show all permissions</span>}
|
||||
label={
|
||||
<span style={{ fontSize: 12 }}>
|
||||
{showAllResources
|
||||
? "Hide advanced permissions"
|
||||
: "Show advanced permissions"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -323,7 +413,12 @@ const styles = {
|
||||
}),
|
||||
actionDescription: (theme) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
paddingTop: 6,
|
||||
}),
|
||||
actionItem: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "270px 1fr",
|
||||
},
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
export default CreateEditRolePageView;
|
||||
|
||||
@@ -392,6 +392,21 @@ export const MockRoleWithOrgPermissions: TypesGen.Role = {
|
||||
user_permissions: [],
|
||||
};
|
||||
|
||||
export const MockRole2WithOrgPermissions: TypesGen.Role = {
|
||||
name: "my-role-1",
|
||||
display_name: "My Role 1",
|
||||
organization_id: MockOrganization.id,
|
||||
site_permissions: [],
|
||||
organization_permissions: [
|
||||
{
|
||||
negate: false,
|
||||
resource_type: "audit_log",
|
||||
action: "create",
|
||||
},
|
||||
],
|
||||
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