Skip to content

Commit 1b450da

Browse files
committed
feat: initial commit custom roles
1 parent fab1960 commit 1b450da

File tree

3 files changed

+334
-0
lines changed

3 files changed

+334
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import GroupAdd from "@mui/icons-material/GroupAddOutlined";
2+
import Button from "@mui/material/Button";
3+
import { type FC, useEffect } from "react";
4+
import { Helmet } from "react-helmet-async";
5+
import { useQuery } from "react-query";
6+
import {
7+
Navigate,
8+
Link as RouterLink,
9+
useLocation,
10+
useParams,
11+
} from "react-router-dom";
12+
import { getErrorMessage } from "api/errors";
13+
import { organizationRoles } from "api/queries/roles";
14+
import type { Organization } from "api/typesGenerated";
15+
import { displayError } from "components/GlobalSnackbar/utils";
16+
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
17+
import { useAuthenticated } from "contexts/auth/RequireAuth";
18+
import { useDashboard } from "modules/dashboard/useDashboard";
19+
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
20+
import { pageTitle } from "utils/page";
21+
import CustomRolesPageView from "./CustomRolesPageView";
22+
23+
export const CustomRolesPage: FC = () => {
24+
const { permissions } = useAuthenticated();
25+
const { createGroup: canCreateGroup } = permissions;
26+
const {
27+
multiple_organizations: organizationsEnabled,
28+
template_rbac: isTemplateRBACEnabled,
29+
} = useFeatureVisibility();
30+
const { experiments } = useDashboard();
31+
const location = useLocation();
32+
const { organization = "default" } = useParams() as { organization: string };
33+
const organizationRolesQuery = useQuery(organizationRoles(organization));
34+
35+
useEffect(() => {
36+
if (organizationRolesQuery.error) {
37+
displayError(
38+
getErrorMessage(
39+
organizationRolesQuery.error,
40+
"Error loading custom roles.",
41+
),
42+
);
43+
}
44+
}, [organizationRolesQuery.error]);
45+
46+
// if (
47+
// organizationsEnabled &&
48+
// experiments.includes("multi-organization") &&
49+
// location.pathname === "/deployment/groups"
50+
// ) {
51+
// const defaultName =
52+
// getOrganizationNameByDefault(organizations) ?? "default";
53+
// return <Navigate to={`/organizations/${defaultName}/groups`} replace />;
54+
// }
55+
56+
return (
57+
<>
58+
<Helmet>
59+
<title>{pageTitle("Groups")}</title>
60+
</Helmet>
61+
62+
<PageHeader
63+
actions={
64+
<>
65+
{canCreateGroup && isTemplateRBACEnabled && (
66+
<Button
67+
component={RouterLink}
68+
startIcon={<GroupAdd />}
69+
to="create"
70+
>
71+
Create custom role
72+
</Button>
73+
)}
74+
</>
75+
}
76+
>
77+
<PageHeaderTitle>Custom Roles</PageHeaderTitle>
78+
</PageHeader>
79+
80+
<CustomRolesPageView
81+
roles={organizationRolesQuery.data}
82+
canCreateGroup={canCreateGroup}
83+
isTemplateRBACEnabled={isTemplateRBACEnabled}
84+
/>
85+
</>
86+
);
87+
};
88+
89+
export default CustomRolesPage;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import { css } from "@emotion/css";
2+
import type { Interpolation, Theme } from "@emotion/react";
3+
import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight";
4+
import PersonAdd from "@mui/icons-material/PersonAdd";
5+
import { LoadingButton } from "@mui/lab";
6+
import { Table, TableBody, TableContainer, TextField } from "@mui/material";
7+
import Autocomplete, { createFilterOptions } from "@mui/material/Autocomplete";
8+
import AvatarGroup from "@mui/material/AvatarGroup";
9+
import Skeleton from "@mui/material/Skeleton";
10+
import TableCell from "@mui/material/TableCell";
11+
import TableRow from "@mui/material/TableRow";
12+
import { useState, type FC } from "react";
13+
import { Link as RouterLink, useNavigate } from "react-router-dom";
14+
import { RBACResourceActions } from "api/rbacresources_gen";
15+
import type { Group, Role } from "api/typesGenerated";
16+
import { AvatarData } from "components/AvatarData/AvatarData";
17+
import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton";
18+
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
19+
import { EmptyState } from "components/EmptyState/EmptyState";
20+
import { GroupAvatar } from "components/GroupAvatar/GroupAvatar";
21+
import { Paywall } from "components/Paywall/Paywall";
22+
import { Stack } from "components/Stack/Stack";
23+
import {
24+
TableLoader,
25+
TableLoaderSkeleton,
26+
TableRowSkeleton,
27+
} from "components/TableLoader/TableLoader";
28+
import { UserAvatar } from "components/UserAvatar/UserAvatar";
29+
import { permissionsToCheck } from "contexts/auth/permissions";
30+
import { useClickableTableRow } from "hooks";
31+
import { docs } from "utils/docs";
32+
33+
export type CustomRolesPageViewProps = {
34+
roles: Role[] | undefined;
35+
canCreateGroup: boolean;
36+
isTemplateRBACEnabled: boolean;
37+
};
38+
39+
const filter = createFilterOptions<Role>();
40+
41+
export const CustomRolesPageView: FC<CustomRolesPageViewProps> = ({
42+
roles,
43+
canCreateGroup,
44+
isTemplateRBACEnabled,
45+
}) => {
46+
const isLoading = Boolean(roles === undefined);
47+
const isEmpty = Boolean(roles && roles.length === 0);
48+
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
49+
console.log({ selectedRole });
50+
51+
return (
52+
<>
53+
<ChooseOne>
54+
<Cond condition={!isTemplateRBACEnabled}>
55+
<Paywall
56+
message="Custom Roles"
57+
description="Organize users into groups with restricted access to templates. You need an Enterprise license to use this feature."
58+
documentationLink={docs("/admin/groups")}
59+
/>
60+
</Cond>
61+
<Cond>
62+
<Stack
63+
direction="row"
64+
alignItems="center"
65+
spacing={1}
66+
css={styles.rolesDropdown}
67+
>
68+
<Autocomplete
69+
value={selectedRole}
70+
onChange={(_, newValue) => {
71+
console.log("onChange: ", newValue);
72+
if (typeof newValue === "string") {
73+
console.log("0");
74+
setSelectedRole({
75+
name: newValue,
76+
display_name: newValue,
77+
site_permissions: [],
78+
organization_permissions: [],
79+
user_permissions: [],
80+
});
81+
} else if (newValue && newValue.display_name) {
82+
console.log("1");
83+
// Create a new value from the user input
84+
// setSelectedRole({ ...newValue, display_name: newValue.name });
85+
setSelectedRole(newValue);
86+
} else {
87+
console.log("2");
88+
setSelectedRole(newValue);
89+
}
90+
}}
91+
isOptionEqualToValue={(option: Role, value: Role) =>
92+
option.name === value.name
93+
}
94+
filterOptions={(options, params) => {
95+
const filtered = filter(options, params);
96+
97+
const { inputValue } = params;
98+
// Suggest the creation of a new value
99+
const isExisting = options.some(
100+
(option) => inputValue === option.display_name,
101+
);
102+
if (inputValue !== "" && !isExisting) {
103+
filtered.push({
104+
name: inputValue,
105+
display_name: `Add ${inputValue}`,
106+
site_permissions: [],
107+
organization_permissions: [],
108+
user_permissions: [],
109+
});
110+
}
111+
112+
return filtered;
113+
}}
114+
selectOnFocus
115+
clearOnBlur
116+
handleHomeEndKeys
117+
id="custom-role"
118+
options={roles || []}
119+
getOptionLabel={(option) => {
120+
// console.log("getOptionLabel: ", option);
121+
// Value selected with enter, right from the input
122+
if (typeof option === "string") {
123+
return option;
124+
}
125+
// Add "xxx" option created dynamically
126+
if (option.name) {
127+
return option.name;
128+
}
129+
// Regular option
130+
return option.display_name;
131+
}}
132+
renderOption={(props, option) => {
133+
const { key, ...optionProps } = props;
134+
return (
135+
<li key={key} {...optionProps}>
136+
{option.display_name}
137+
</li>
138+
);
139+
}}
140+
sx={{ width: 300 }}
141+
renderInput={(params) => (
142+
<TextField
143+
{...params}
144+
label="Display Name"
145+
InputLabelProps={{
146+
shrink: true,
147+
}}
148+
/>
149+
)}
150+
/>
151+
152+
<LoadingButton
153+
loadingPosition="start"
154+
// disabled={!selectedUser}
155+
type="submit"
156+
startIcon={<PersonAdd />}
157+
loading={isLoading}
158+
>
159+
Save Custom Role
160+
</LoadingButton>
161+
</Stack>
162+
163+
<TableContainer>
164+
<Table>
165+
<TableBody>
166+
<ChooseOne>
167+
<Cond condition={isLoading}>
168+
<TableLoader />
169+
</Cond>
170+
171+
<Cond condition={isEmpty}>
172+
<TableRow>
173+
<TableCell colSpan={999}>
174+
<EmptyState
175+
message="No custom roles yet"
176+
description={
177+
canCreateGroup
178+
? "Create your first custom role"
179+
: "You don't have permission to create a custom role"
180+
}
181+
/>
182+
</TableCell>
183+
</TableRow>
184+
</Cond>
185+
186+
<Cond>
187+
{Object.entries(RBACResourceActions).map(([key, value]) => {
188+
return (
189+
<TableRow key={key}>
190+
<TableCell>
191+
<li key={key} css={styles.checkBoxes}>
192+
<input type="checkbox" /> {key}
193+
<ul css={styles.checkBoxes}>
194+
{Object.entries(value).map(([key, value]) => {
195+
return (
196+
<li key={key}>
197+
<span css={styles.actionText}>
198+
<input type="checkbox" /> {key}
199+
</span>{" "}
200+
-{" "}
201+
<span css={styles.actionDescription}>
202+
{value}
203+
</span>
204+
</li>
205+
);
206+
})}
207+
</ul>
208+
</li>
209+
</TableCell>
210+
</TableRow>
211+
);
212+
})}
213+
</Cond>
214+
</ChooseOne>
215+
</TableBody>
216+
</Table>
217+
</TableContainer>
218+
</Cond>
219+
</ChooseOne>
220+
</>
221+
);
222+
};
223+
224+
const styles = {
225+
rolesDropdown: {
226+
marginBottom: 20,
227+
},
228+
checkBoxes: {
229+
margin: 0,
230+
listStyleType: "none",
231+
},
232+
actionText: (theme) => ({
233+
color: theme.palette.text.primary,
234+
}),
235+
actionDescription: (theme) => ({
236+
color: theme.palette.text.secondary,
237+
}),
238+
} satisfies Record<string, Interpolation<Theme>>;
239+
240+
export default CustomRolesPageView;

site/src/router.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,10 @@ const OrganizationGroupSettingsPage = lazy(
242242
const OrganizationMembersPage = lazy(
243243
() => import("./pages/ManagementSettingsPage/OrganizationMembersPage"),
244244
);
245+
const OrganizationCustomRolesPage = lazy(
246+
() =>
247+
import("./pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage"),
248+
);
245249
const TemplateEmbedPage = lazy(
246250
() => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"),
247251
);
@@ -376,6 +380,7 @@ export const router = createBrowserRouter(
376380
<Route index element={<OrganizationSettingsPage />} />
377381
<Route path="members" element={<OrganizationMembersPage />} />
378382
{groupsRouter()}
383+
<Route path="roles" element={<OrganizationCustomRolesPage />} />
379384
<Route path="auditing" element={<></>} />
380385
</Route>
381386
</Route>

0 commit comments

Comments
 (0)