mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: display builtin roles alongside custom roles (#14843)
Since its currently not possible to update or delete built-in roles. The purpose of this PR is to display the built-in roles so that users know they exist and what permissions each role contains. <img width="1185" alt="Screenshot 2024-09-26 at 9 18 05 PM" src="https://github.com/user-attachments/assets/017a51d7-ec98-409c-9c8e-b66ac7abb948">
This commit is contained in:
@@ -162,6 +162,7 @@ const DEFAULT_RESOURCES = [
|
||||
"organization_member",
|
||||
"provisioner_daemon",
|
||||
"workspace",
|
||||
"idpsync_settings",
|
||||
];
|
||||
|
||||
const resources = new Set(DEFAULT_RESOURCES);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import AddIcon from "@mui/icons-material/AddOutlined";
|
||||
import Button from "@mui/material/Button";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import { organizationPermissions } from "api/queries/organizations";
|
||||
import { deleteOrganizationRole, organizationRoles } from "api/queries/roles";
|
||||
@@ -13,7 +11,7 @@ import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { Link as RouterLink, useParams } from "react-router-dom";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { pageTitle } from "utils/page";
|
||||
import { useOrganizationSettings } from "../ManagementSettingsLayout";
|
||||
import CustomRolesPageView from "./CustomRolesPageView";
|
||||
@@ -32,8 +30,11 @@ export const CustomRolesPage: FC = () => {
|
||||
);
|
||||
const [roleToDelete, setRoleToDelete] = useState<Role>();
|
||||
const organizationRolesQuery = useQuery(organizationRoles(organizationName));
|
||||
const filteredRoleData = organizationRolesQuery.data?.filter(
|
||||
(role) => role.built_in === false,
|
||||
const builtInRoles = organizationRolesQuery.data?.filter(
|
||||
(role) => role.built_in,
|
||||
);
|
||||
const customRoles = organizationRolesQuery.data?.filter(
|
||||
(role) => !role.built_in,
|
||||
);
|
||||
const permissions = permissionsQuery.data;
|
||||
|
||||
@@ -64,18 +65,14 @@ export const CustomRolesPage: FC = () => {
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<SettingsHeader
|
||||
title="Custom Roles"
|
||||
description="Manage custom roles for this organization."
|
||||
title="Roles"
|
||||
description="Manage roles for this organization."
|
||||
/>
|
||||
{permissions.assignOrgRole && isCustomRolesEnabled && (
|
||||
<Button component={RouterLink} startIcon={<AddIcon />} to="create">
|
||||
Create custom role
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<CustomRolesPageView
|
||||
roles={filteredRoleData}
|
||||
builtInRoles={builtInRoles}
|
||||
customRoles={customRoles}
|
||||
onDeleteRole={setRoleToDelete}
|
||||
canAssignOrgRole={permissions.assignOrgRole}
|
||||
isCustomRolesEnabled={isCustomRolesEnabled}
|
||||
|
||||
+14
-7
@@ -15,7 +15,8 @@ type Story = StoryObj<typeof CustomRolesPageView>;
|
||||
|
||||
export const NotEnabled: Story = {
|
||||
args: {
|
||||
roles: [MockRoleWithOrgPermissions],
|
||||
builtInRoles: [MockRoleWithOrgPermissions],
|
||||
customRoles: [MockRoleWithOrgPermissions],
|
||||
canAssignOrgRole: true,
|
||||
isCustomRolesEnabled: false,
|
||||
},
|
||||
@@ -23,7 +24,8 @@ export const NotEnabled: Story = {
|
||||
|
||||
export const NotEnabledEmptyTable: Story = {
|
||||
args: {
|
||||
roles: [],
|
||||
builtInRoles: [MockRoleWithOrgPermissions],
|
||||
customRoles: [],
|
||||
canAssignOrgRole: true,
|
||||
isCustomRolesEnabled: false,
|
||||
},
|
||||
@@ -31,7 +33,8 @@ export const NotEnabledEmptyTable: Story = {
|
||||
|
||||
export const Enabled: Story = {
|
||||
args: {
|
||||
roles: [MockRoleWithOrgPermissions],
|
||||
builtInRoles: [MockRoleWithOrgPermissions],
|
||||
customRoles: [MockRoleWithOrgPermissions],
|
||||
canAssignOrgRole: true,
|
||||
isCustomRolesEnabled: true,
|
||||
},
|
||||
@@ -39,7 +42,8 @@ export const Enabled: Story = {
|
||||
|
||||
export const RoleWithoutPermissions: Story = {
|
||||
args: {
|
||||
roles: [MockOrganizationAuditorRole],
|
||||
builtInRoles: [MockOrganizationAuditorRole],
|
||||
customRoles: [MockOrganizationAuditorRole],
|
||||
canAssignOrgRole: true,
|
||||
isCustomRolesEnabled: true,
|
||||
},
|
||||
@@ -47,13 +51,14 @@ export const RoleWithoutPermissions: Story = {
|
||||
|
||||
export const EmptyDisplayName: Story = {
|
||||
args: {
|
||||
roles: [
|
||||
customRoles: [
|
||||
{
|
||||
...MockRoleWithOrgPermissions,
|
||||
name: "my-custom-role",
|
||||
display_name: "",
|
||||
},
|
||||
],
|
||||
builtInRoles: [MockRoleWithOrgPermissions],
|
||||
canAssignOrgRole: true,
|
||||
isCustomRolesEnabled: true,
|
||||
},
|
||||
@@ -61,7 +66,8 @@ export const EmptyDisplayName: Story = {
|
||||
|
||||
export const EmptyTableUserWithoutPermission: Story = {
|
||||
args: {
|
||||
roles: [],
|
||||
builtInRoles: [MockRoleWithOrgPermissions],
|
||||
customRoles: [],
|
||||
canAssignOrgRole: false,
|
||||
isCustomRolesEnabled: true,
|
||||
},
|
||||
@@ -69,7 +75,8 @@ export const EmptyTableUserWithoutPermission: Story = {
|
||||
|
||||
export const EmptyTableUserWithPermission: Story = {
|
||||
args: {
|
||||
roles: [],
|
||||
builtInRoles: [MockRoleWithOrgPermissions],
|
||||
customRoles: [],
|
||||
canAssignOrgRole: true,
|
||||
isCustomRolesEnabled: true,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Interpolation, Theme } from "@emotion/react";
|
||||
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
|
||||
import AddIcon from "@mui/icons-material/AddOutlined";
|
||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||
import Button from "@mui/material/Button";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
@@ -8,7 +9,7 @@ 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 { Role } from "api/typesGenerated";
|
||||
import type { AssignableRoles, Role } from "api/typesGenerated";
|
||||
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import {
|
||||
@@ -30,20 +31,21 @@ import { docs } from "utils/docs";
|
||||
import { PermissionPillsList } from "./PermissionPillsList";
|
||||
|
||||
interface CustomRolesPageViewProps {
|
||||
roles: Role[] | undefined;
|
||||
builtInRoles: AssignableRoles[] | undefined;
|
||||
customRoles: AssignableRoles[] | undefined;
|
||||
onDeleteRole: (role: Role) => void;
|
||||
canAssignOrgRole: boolean;
|
||||
isCustomRolesEnabled: boolean;
|
||||
}
|
||||
|
||||
export const CustomRolesPageView: FC<CustomRolesPageViewProps> = ({
|
||||
roles,
|
||||
builtInRoles,
|
||||
customRoles,
|
||||
onDeleteRole,
|
||||
canAssignOrgRole,
|
||||
isCustomRolesEnabled,
|
||||
}) => {
|
||||
const isLoading = roles === undefined;
|
||||
const isEmpty = Boolean(roles && roles.length === 0);
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
{!isCustomRolesEnabled && (
|
||||
@@ -53,53 +55,112 @@ export const CustomRolesPageView: FC<CustomRolesPageViewProps> = ({
|
||||
documentationLink={docs("/admin/groups")}
|
||||
/>
|
||||
)}
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width="40%">Name</TableCell>
|
||||
<TableCell width="59%">Permissions</TableCell>
|
||||
<TableCell width="1%" />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<ChooseOne>
|
||||
<Cond condition={isLoading}>
|
||||
<TableLoader />
|
||||
</Cond>
|
||||
<Stack
|
||||
alignItems="baseline"
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<span>
|
||||
<h2 css={styles.tableHeader}>Custom Roles</h2>
|
||||
<span css={styles.tableDescription}>
|
||||
Create custom roles to grant users a tailored set of granular
|
||||
permissions.
|
||||
</span>
|
||||
</span>
|
||||
{canAssignOrgRole && isCustomRolesEnabled && (
|
||||
<Button component={RouterLink} startIcon={<AddIcon />} to="create">
|
||||
Create custom role
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
<RoleTable
|
||||
roles={customRoles}
|
||||
isCustomRolesEnabled={isCustomRolesEnabled}
|
||||
canAssignOrgRole={canAssignOrgRole}
|
||||
onDeleteRole={onDeleteRole}
|
||||
/>
|
||||
<span>
|
||||
<h2 css={styles.tableHeader}>Built-In Roles</h2>
|
||||
<span css={styles.tableDescription}>
|
||||
Built-in roles have predefined permissions. You cannot edit or delete
|
||||
built-in roles.
|
||||
</span>
|
||||
</span>
|
||||
<RoleTable
|
||||
roles={builtInRoles}
|
||||
isCustomRolesEnabled={isCustomRolesEnabled}
|
||||
canAssignOrgRole={canAssignOrgRole}
|
||||
onDeleteRole={onDeleteRole}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
<Cond condition={isEmpty}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message="No custom roles yet"
|
||||
description={
|
||||
canAssignOrgRole && isCustomRolesEnabled
|
||||
? "Create your first custom role"
|
||||
: !isCustomRolesEnabled
|
||||
? "Upgrade to a premium license to create a custom role"
|
||||
: "You don't have permission to create a custom role"
|
||||
}
|
||||
cta={
|
||||
canAssignOrgRole &&
|
||||
isCustomRolesEnabled && (
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="create"
|
||||
startIcon={<AddOutlined />}
|
||||
variant="contained"
|
||||
>
|
||||
Create custom role
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Cond>
|
||||
interface RoleTableProps {
|
||||
roles: AssignableRoles[] | undefined;
|
||||
isCustomRolesEnabled: boolean;
|
||||
canAssignOrgRole: boolean;
|
||||
onDeleteRole: (role: Role) => void;
|
||||
}
|
||||
|
||||
<Cond>
|
||||
{roles?.map((role) => (
|
||||
const RoleTable: FC<RoleTableProps> = ({
|
||||
roles,
|
||||
isCustomRolesEnabled,
|
||||
onDeleteRole,
|
||||
canAssignOrgRole,
|
||||
}) => {
|
||||
const isLoading = roles === undefined;
|
||||
const isEmpty = Boolean(roles && roles.length === 0);
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width="40%">Name</TableCell>
|
||||
<TableCell width="59%">Permissions</TableCell>
|
||||
<TableCell width="1%" />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<ChooseOne>
|
||||
<Cond condition={isLoading}>
|
||||
<TableLoader />
|
||||
</Cond>
|
||||
|
||||
<Cond condition={isEmpty}>
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message="No custom roles yet"
|
||||
description={
|
||||
canAssignOrgRole && isCustomRolesEnabled
|
||||
? "Create your first custom role"
|
||||
: !isCustomRolesEnabled
|
||||
? "Upgrade to a premium license to create a custom role"
|
||||
: "You don't have permission to create a custom role"
|
||||
}
|
||||
cta={
|
||||
canAssignOrgRole &&
|
||||
isCustomRolesEnabled && (
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="create"
|
||||
startIcon={<AddOutlined />}
|
||||
variant="contained"
|
||||
>
|
||||
Create custom role
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Cond>
|
||||
|
||||
<Cond>
|
||||
{roles
|
||||
?.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((role) => (
|
||||
<RoleRow
|
||||
key={role.name}
|
||||
role={role}
|
||||
@@ -107,17 +168,16 @@ export const CustomRolesPageView: FC<CustomRolesPageViewProps> = ({
|
||||
onDelete={() => onDeleteRole(role)}
|
||||
/>
|
||||
))}
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Stack>
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
|
||||
interface RoleRowProps {
|
||||
role: Role;
|
||||
role: AssignableRoles;
|
||||
onDelete: () => void;
|
||||
canAssignOrgRole: boolean;
|
||||
}
|
||||
@@ -134,25 +194,27 @@ const RoleRow: FC<RoleRowProps> = ({ role, onDelete, canAssignOrgRole }) => {
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<MoreMenu>
|
||||
<MoreMenuTrigger>
|
||||
<ThreeDotsButton />
|
||||
</MoreMenuTrigger>
|
||||
<MoreMenuContent>
|
||||
<MoreMenuItem
|
||||
onClick={() => {
|
||||
navigate(role.name);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</MoreMenuItem>
|
||||
{canAssignOrgRole && (
|
||||
<MoreMenuItem danger onClick={onDelete}>
|
||||
Delete…
|
||||
{!role.built_in && (
|
||||
<MoreMenu>
|
||||
<MoreMenuTrigger>
|
||||
<ThreeDotsButton />
|
||||
</MoreMenuTrigger>
|
||||
<MoreMenuContent>
|
||||
<MoreMenuItem
|
||||
onClick={() => {
|
||||
navigate(role.name);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</MoreMenuItem>
|
||||
)}
|
||||
</MoreMenuContent>
|
||||
</MoreMenu>
|
||||
{canAssignOrgRole && (
|
||||
<MoreMenuItem danger onClick={onDelete}>
|
||||
Delete…
|
||||
</MoreMenuItem>
|
||||
)}
|
||||
</MoreMenuContent>
|
||||
</MoreMenu>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -180,6 +242,15 @@ const styles = {
|
||||
secondary: (theme) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
}),
|
||||
tableHeader: () => ({
|
||||
marginBottom: 0,
|
||||
fontSize: 18,
|
||||
}),
|
||||
tableDescription: (theme) => ({
|
||||
fontSize: 14,
|
||||
color: theme.palette.text.secondary,
|
||||
lineHeight: "160%",
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
export default CustomRolesPageView;
|
||||
|
||||
@@ -318,19 +318,23 @@ export const MockOrganizationTemplateAdminRole: TypesGen.Role = {
|
||||
organization_id: MockOrganization.id,
|
||||
};
|
||||
|
||||
export const MockOrganizationAuditorRole: TypesGen.Role = {
|
||||
export const MockOrganizationAuditorRole: TypesGen.AssignableRoles = {
|
||||
name: "organization-auditor",
|
||||
display_name: "Organization Auditor",
|
||||
assignable: true,
|
||||
built_in: false,
|
||||
site_permissions: [],
|
||||
organization_permissions: [],
|
||||
user_permissions: [],
|
||||
organization_id: MockOrganization.id,
|
||||
};
|
||||
|
||||
export const MockRoleWithOrgPermissions: TypesGen.Role = {
|
||||
export const MockRoleWithOrgPermissions: TypesGen.AssignableRoles = {
|
||||
name: "my-role-1",
|
||||
display_name: "My Role 1",
|
||||
organization_id: MockOrganization.id,
|
||||
assignable: true,
|
||||
built_in: false,
|
||||
site_permissions: [],
|
||||
organization_permissions: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user