From 1b450da6b766e88937af7e0ce43593240928fd25 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 31 Jul 2024 17:51:19 +0000 Subject: [PATCH 01/28] feat: initial commit custom roles --- .../CustomRolesPage/CustomRolesPage.tsx | 89 +++++++ .../CustomRolesPage/CustomRolesPageView.tsx | 240 ++++++++++++++++++ site/src/router.tsx | 5 + 3 files changed, 334 insertions(+) create mode 100644 site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx create mode 100644 site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx new file mode 100644 index 0000000000000..badb13997b69a --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx @@ -0,0 +1,89 @@ +import GroupAdd from "@mui/icons-material/GroupAddOutlined"; +import Button from "@mui/material/Button"; +import { type FC, useEffect } from "react"; +import { Helmet } from "react-helmet-async"; +import { useQuery } from "react-query"; +import { + Navigate, + Link as RouterLink, + useLocation, + useParams, +} from "react-router-dom"; +import { getErrorMessage } from "api/errors"; +import { organizationRoles } from "api/queries/roles"; +import type { Organization } from "api/typesGenerated"; +import { displayError } from "components/GlobalSnackbar/utils"; +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; +import { useAuthenticated } from "contexts/auth/RequireAuth"; +import { useDashboard } from "modules/dashboard/useDashboard"; +import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; +import { pageTitle } from "utils/page"; +import CustomRolesPageView from "./CustomRolesPageView"; + +export const CustomRolesPage: FC = () => { + const { permissions } = useAuthenticated(); + const { createGroup: canCreateGroup } = permissions; + const { + multiple_organizations: organizationsEnabled, + template_rbac: isTemplateRBACEnabled, + } = useFeatureVisibility(); + const { experiments } = useDashboard(); + const location = useLocation(); + const { organization = "default" } = useParams() as { organization: string }; + const organizationRolesQuery = useQuery(organizationRoles(organization)); + + useEffect(() => { + if (organizationRolesQuery.error) { + displayError( + getErrorMessage( + organizationRolesQuery.error, + "Error loading custom roles.", + ), + ); + } + }, [organizationRolesQuery.error]); + + // if ( + // organizationsEnabled && + // experiments.includes("multi-organization") && + // location.pathname === "/deployment/groups" + // ) { + // const defaultName = + // getOrganizationNameByDefault(organizations) ?? "default"; + // return ; + // } + + return ( + <> + + {pageTitle("Groups")} + + + + {canCreateGroup && isTemplateRBACEnabled && ( + + )} + + } + > + Custom Roles + + + + + ); +}; + +export default CustomRolesPage; diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx new file mode 100644 index 0000000000000..be5e5ebdda82c --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx @@ -0,0 +1,240 @@ +import { css } from "@emotion/css"; +import type { Interpolation, Theme } from "@emotion/react"; +import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight"; +import PersonAdd from "@mui/icons-material/PersonAdd"; +import { LoadingButton } from "@mui/lab"; +import { Table, TableBody, TableContainer, TextField } from "@mui/material"; +import Autocomplete, { createFilterOptions } from "@mui/material/Autocomplete"; +import AvatarGroup from "@mui/material/AvatarGroup"; +import Skeleton from "@mui/material/Skeleton"; +import TableCell from "@mui/material/TableCell"; +import TableRow from "@mui/material/TableRow"; +import { useState, type FC } from "react"; +import { Link as RouterLink, useNavigate } from "react-router-dom"; +import { RBACResourceActions } from "api/rbacresources_gen"; +import type { Group, Role } from "api/typesGenerated"; +import { AvatarData } from "components/AvatarData/AvatarData"; +import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton"; +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"; +import { EmptyState } from "components/EmptyState/EmptyState"; +import { GroupAvatar } from "components/GroupAvatar/GroupAvatar"; +import { Paywall } from "components/Paywall/Paywall"; +import { Stack } from "components/Stack/Stack"; +import { + TableLoader, + TableLoaderSkeleton, + TableRowSkeleton, +} from "components/TableLoader/TableLoader"; +import { UserAvatar } from "components/UserAvatar/UserAvatar"; +import { permissionsToCheck } from "contexts/auth/permissions"; +import { useClickableTableRow } from "hooks"; +import { docs } from "utils/docs"; + +export type CustomRolesPageViewProps = { + roles: Role[] | undefined; + canCreateGroup: boolean; + isTemplateRBACEnabled: boolean; +}; + +const filter = createFilterOptions(); + +export const CustomRolesPageView: FC = ({ + roles, + canCreateGroup, + isTemplateRBACEnabled, +}) => { + const isLoading = Boolean(roles === undefined); + const isEmpty = Boolean(roles && roles.length === 0); + const [selectedRole, setSelectedRole] = useState(null); + console.log({ selectedRole }); + + return ( + <> + + + + + + + { + console.log("onChange: ", newValue); + if (typeof newValue === "string") { + console.log("0"); + setSelectedRole({ + name: newValue, + display_name: newValue, + site_permissions: [], + organization_permissions: [], + user_permissions: [], + }); + } else if (newValue && newValue.display_name) { + console.log("1"); + // Create a new value from the user input + // setSelectedRole({ ...newValue, display_name: newValue.name }); + setSelectedRole(newValue); + } else { + console.log("2"); + setSelectedRole(newValue); + } + }} + isOptionEqualToValue={(option: Role, value: Role) => + option.name === value.name + } + filterOptions={(options, params) => { + const filtered = filter(options, params); + + const { inputValue } = params; + // Suggest the creation of a new value + const isExisting = options.some( + (option) => inputValue === option.display_name, + ); + if (inputValue !== "" && !isExisting) { + filtered.push({ + name: inputValue, + display_name: `Add ${inputValue}`, + site_permissions: [], + organization_permissions: [], + user_permissions: [], + }); + } + + return filtered; + }} + selectOnFocus + clearOnBlur + handleHomeEndKeys + id="custom-role" + options={roles || []} + getOptionLabel={(option) => { + // console.log("getOptionLabel: ", option); + // Value selected with enter, right from the input + if (typeof option === "string") { + return option; + } + // Add "xxx" option created dynamically + if (option.name) { + return option.name; + } + // Regular option + return option.display_name; + }} + renderOption={(props, option) => { + const { key, ...optionProps } = props; + return ( +
  • + {option.display_name} +
  • + ); + }} + sx={{ width: 300 }} + renderInput={(params) => ( + + )} + /> + + } + loading={isLoading} + > + Save Custom Role + +
    + + + + + + + + + + + + + + + + + + + {Object.entries(RBACResourceActions).map(([key, value]) => { + return ( + + +
  • + {key} +
      + {Object.entries(value).map(([key, value]) => { + return ( +
    • + + {key} + {" "} + -{" "} + + {value} + +
    • + ); + })} +
    +
  • +
    +
    + ); + })} +
    +
    +
    +
    +
    +
    +
    + + ); +}; + +const styles = { + rolesDropdown: { + marginBottom: 20, + }, + checkBoxes: { + margin: 0, + listStyleType: "none", + }, + actionText: (theme) => ({ + color: theme.palette.text.primary, + }), + actionDescription: (theme) => ({ + color: theme.palette.text.secondary, + }), +} satisfies Record>; + +export default CustomRolesPageView; diff --git a/site/src/router.tsx b/site/src/router.tsx index 615d12969e184..69ddbce49ed55 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -242,6 +242,10 @@ const OrganizationGroupSettingsPage = lazy( const OrganizationMembersPage = lazy( () => import("./pages/ManagementSettingsPage/OrganizationMembersPage"), ); +const OrganizationCustomRolesPage = lazy( + () => + import("./pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage"), +); const TemplateEmbedPage = lazy( () => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"), ); @@ -376,6 +380,7 @@ export const router = createBrowserRouter( } /> } /> {groupsRouter()} + } /> } /> From e3647cb4174b386e85323929293cd747fecff4bf Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 31 Jul 2024 21:17:49 +0000 Subject: [PATCH 02/28] feat: add page to create and edit custom roles --- site/src/api/api.ts | 26 +- site/src/api/queries/roles.ts | 36 +++ .../CustomRolesPage/CreateEditRolePage.tsx | 35 +++ .../CreateEditRolePageView.tsx | 147 ++++++++++++ .../CustomRolesPage/CustomRolesPageView.tsx | 227 ++++++------------ site/src/router.tsx | 10 +- 6 files changed, 321 insertions(+), 160 deletions(-) create mode 100644 site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx create mode 100644 site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 8dcef31bf676e..796f4675b2389 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -187,8 +187,7 @@ export const watchBuildLogsByTemplateVersionId = ( const proto = location.protocol === "https:" ? "wss:" : "ws:"; const socket = new WebSocket( - `${proto}//${ - location.host + `${proto}//${location.host }/api/v2/templateversions/${versionId}/logs?${searchParams.toString()}`, ); @@ -270,8 +269,7 @@ export const watchBuildLogsByBuildId = ( } const proto = location.protocol === "https:" ? "wss:" : "ws:"; const socket = new WebSocket( - `${proto}//${ - location.host + `${proto}//${location.host }/api/v2/workspacebuilds/${buildId}/logs?${searchParams.toString()}`, ); socket.binaryType = "blob"; @@ -394,7 +392,7 @@ export class MissingBuildParameters extends Error { * lexical scope. */ class ApiMethods { - constructor(protected readonly axios: AxiosInstance) {} + constructor(protected readonly axios: AxiosInstance) { } login = async ( email: string, @@ -600,6 +598,24 @@ class ApiMethods { return response.data; }; + patchOrganizationRole = async ( + organizationId: string, + role: TypesGen.Role, + ): Promise => { + const response = await this.axios.patch( + `/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 */ diff --git a/site/src/api/queries/roles.ts b/site/src/api/queries/roles.ts index e51805e72c527..944a4fd4f85f4 100644 --- a/site/src/api/queries/roles.ts +++ b/site/src/api/queries/roles.ts @@ -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), + ), + }; +}; diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx new file mode 100644 index 0000000000000..3b551229047bc --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx @@ -0,0 +1,35 @@ +import type { FC } from "react"; +import { Helmet } from "react-helmet-async"; +import { useMutation, useQueryClient } from "react-query"; +import { useNavigate, useParams } from "react-router-dom"; +import { patchOrganizationRole } from "api/queries/roles"; +import { pageTitle } from "utils/page"; +import CreateEditRolePageView from "./CreateEditRolePageView"; + +export const CreateGroupPage: FC = () => { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { organization } = useParams() as { organization: string }; + const patchOrganizationRoleMutation = useMutation( + patchOrganizationRole(queryClient, organization ?? "default"), + ); + + return ( + <> + + {pageTitle("Create Custom Role")} + + { + const newRole = await patchOrganizationRoleMutation.mutateAsync(data); + console.log({ newRole }); + navigate(`/organizations/${organization}/roles`); + }} + error={patchOrganizationRoleMutation.error} + isLoading={patchOrganizationRoleMutation.isLoading} + /> + + ); +}; + +export default CreateGroupPage; diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx new file mode 100644 index 0000000000000..e1989c85690cb --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx @@ -0,0 +1,147 @@ +import type { Interpolation, Theme } from "@emotion/react"; +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 TableRow from "@mui/material/TableRow"; +import TextField from "@mui/material/TextField"; +import { useFormik } from "formik"; +import 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 } from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { + FormFields, + FormFooter, + FormSection, + HorizontalForm, +} from "components/Form/Form"; +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; +import { getFormHelpers } from "utils/formUtils"; + +const validationSchema = Yup.object({ + name: Yup.string().required().label("Name"), +}); + +export type CreateEditRolePageViewProps = { + onSubmit: (data: Role) => void; + error?: unknown; + isLoading: boolean; +}; + +export const CreateEditRolePageView: FC = ({ + onSubmit, + error, + isLoading, +}) => { + const navigate = useNavigate(); + const form = useFormik({ + initialValues: { + name: "", + display_name: "", + site_permissions: [], + organization_permissions: [], + user_permissions: [], + }, + validationSchema, + onSubmit, + }); + const getFieldHelpers = getFormHelpers(form, error); + const onCancel = () => navigate(-1); + + return ( + <> + + Create custom role + + + + + {Boolean(error) && !isApiValidationError(error) && ( + + )} + + + + + + + + + + ); +}; + +interface ActionCheckboxesProps { + permissions: Permissions[]; +} + +const ActionCheckboxes: FC = ({ permissions }) => { + return ( + + + + {Object.entries(RBACResourceActions).map(([key, value]) => { + return ( + + +
  • + {key} +
      + {Object.entries(value).map(([key, value]) => { + return ( +
    • + + {key} + {" "} + -{" "} + {value} +
    • + ); + })} +
    +
  • +
    +
    + ); + })} +
    +
    +
    + ); +}; + +const styles = { + rolesDropdown: { + marginBottom: 20, + }, + checkBoxes: { + margin: 0, + listStyleType: "none", + }, + actionText: (theme) => ({ + color: theme.palette.text.primary, + }), + actionDescription: (theme) => ({ + color: theme.palette.text.secondary, + }), +} satisfies Record>; + +export default CreateEditRolePageView; diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx index be5e5ebdda82c..1059d708ebcc8 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx @@ -1,23 +1,21 @@ -import { css } from "@emotion/css"; import type { Interpolation, Theme } from "@emotion/react"; +import AddOutlined from "@mui/icons-material/AddOutlined"; import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight"; -import PersonAdd from "@mui/icons-material/PersonAdd"; import { LoadingButton } from "@mui/lab"; -import { Table, TableBody, TableContainer, TextField } from "@mui/material"; -import Autocomplete, { createFilterOptions } from "@mui/material/Autocomplete"; -import AvatarGroup from "@mui/material/AvatarGroup"; +import { createFilterOptions } from "@mui/material/Autocomplete"; +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 { useState, type FC } from "react"; import { Link as RouterLink, useNavigate } from "react-router-dom"; -import { RBACResourceActions } from "api/rbacresources_gen"; -import type { Group, Role } from "api/typesGenerated"; -import { AvatarData } from "components/AvatarData/AvatarData"; -import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton"; +import type { Role } from "api/typesGenerated"; import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"; import { EmptyState } from "components/EmptyState/EmptyState"; -import { GroupAvatar } from "components/GroupAvatar/GroupAvatar"; import { Paywall } from "components/Paywall/Paywall"; import { Stack } from "components/Stack/Stack"; import { @@ -25,7 +23,6 @@ import { TableLoaderSkeleton, TableRowSkeleton, } from "components/TableLoader/TableLoader"; -import { UserAvatar } from "components/UserAvatar/UserAvatar"; import { permissionsToCheck } from "contexts/auth/permissions"; import { useClickableTableRow } from "hooks"; import { docs } from "utils/docs"; @@ -36,7 +33,7 @@ export type CustomRolesPageViewProps = { isTemplateRBACEnabled: boolean; }; -const filter = createFilterOptions(); +// const filter = createFilterOptions(); export const CustomRolesPageView: FC = ({ roles, @@ -45,8 +42,7 @@ export const CustomRolesPageView: FC = ({ }) => { const isLoading = Boolean(roles === undefined); const isEmpty = Boolean(roles && roles.length === 0); - const [selectedRole, setSelectedRole] = useState(null); - console.log({ selectedRole }); + // const [selectedRole, setSelectedRole] = useState(null); return ( <> @@ -59,109 +55,16 @@ export const CustomRolesPageView: FC = ({ /> - - { - console.log("onChange: ", newValue); - if (typeof newValue === "string") { - console.log("0"); - setSelectedRole({ - name: newValue, - display_name: newValue, - site_permissions: [], - organization_permissions: [], - user_permissions: [], - }); - } else if (newValue && newValue.display_name) { - console.log("1"); - // Create a new value from the user input - // setSelectedRole({ ...newValue, display_name: newValue.name }); - setSelectedRole(newValue); - } else { - console.log("2"); - setSelectedRole(newValue); - } - }} - isOptionEqualToValue={(option: Role, value: Role) => - option.name === value.name - } - filterOptions={(options, params) => { - const filtered = filter(options, params); - - const { inputValue } = params; - // Suggest the creation of a new value - const isExisting = options.some( - (option) => inputValue === option.display_name, - ); - if (inputValue !== "" && !isExisting) { - filtered.push({ - name: inputValue, - display_name: `Add ${inputValue}`, - site_permissions: [], - organization_permissions: [], - user_permissions: [], - }); - } - - return filtered; - }} - selectOnFocus - clearOnBlur - handleHomeEndKeys - id="custom-role" - options={roles || []} - getOptionLabel={(option) => { - // console.log("getOptionLabel: ", option); - // Value selected with enter, right from the input - if (typeof option === "string") { - return option; - } - // Add "xxx" option created dynamically - if (option.name) { - return option.name; - } - // Regular option - return option.display_name; - }} - renderOption={(props, option) => { - const { key, ...optionProps } = props; - return ( -
  • - {option.display_name} -
  • - ); - }} - sx={{ width: 300 }} - renderInput={(params) => ( - - )} - /> - - } - loading={isLoading} - > - Save Custom Role - -
    - + + + Name + Display Name + Permissions + + + @@ -172,11 +75,23 @@ export const CustomRolesPageView: FC = ({ } + variant="contained" + > + Create group + + ) } /> @@ -184,32 +99,9 @@ export const CustomRolesPageView: FC = ({ - {Object.entries(RBACResourceActions).map(([key, value]) => { - return ( - - -
  • - {key} -
      - {Object.entries(value).map(([key, value]) => { - return ( -
    • - - {key} - {" "} - -{" "} - - {value} - -
    • - ); - })} -
    -
  • -
    -
    - ); - })} + {roles?.map((role) => ( + + ))}
    @@ -221,18 +113,45 @@ export const CustomRolesPageView: FC = ({ ); }; +interface RoleRowProps { + role: Role; +} + +const RoleRow: FC = ({ role }) => { + const navigate = useNavigate(); + const rowProps = useClickableTableRow({ + onClick: () => navigate(role.name), + }); + + return ( + + {role.name} + + {role.display_name} + + + {role.organization_permissions.length} + + + +
    + +
    +
    +
    + ); +}; + const styles = { - rolesDropdown: { - marginBottom: 20, - }, - checkBoxes: { - margin: 0, - listStyleType: "none", - }, - actionText: (theme) => ({ - color: theme.palette.text.primary, + arrowRight: (theme) => ({ + color: theme.palette.text.secondary, + width: 20, + height: 20, }), - actionDescription: (theme) => ({ + arrowCell: { + display: "flex", + }, + secondary: (theme) => ({ color: theme.palette.text.secondary, }), } satisfies Record>; diff --git a/site/src/router.tsx b/site/src/router.tsx index 69ddbce49ed55..b6d1c43f103d6 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -246,6 +246,10 @@ const OrganizationCustomRolesPage = lazy( () => import("./pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage"), ); +const CreateEditRolePage = lazy( + () => + import("./pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage"), +); const TemplateEmbedPage = lazy( () => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"), ); @@ -380,7 +384,11 @@ export const router = createBrowserRouter( } /> } /> {groupsRouter()} - } /> + + } /> + } /> + } /> + } /> From 14f7bfc3b7ed552460e70722623fcd0eb4b93715 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 1 Aug 2024 04:35:07 +0000 Subject: [PATCH 03/28] feat: add assign org role permission --- site/src/contexts/auth/permissions.tsx | 7 + .../CustomRolesPage/CustomRolesPage.tsx | 10 +- .../CustomRolesPage/CustomRolesPageView.tsx | 22 +- site/src/testHelpers/entities.ts | 277 +++++++++--------- 4 files changed, 162 insertions(+), 154 deletions(-) diff --git a/site/src/contexts/auth/permissions.tsx b/site/src/contexts/auth/permissions.tsx index c130e0a57e891..20ca42636ab1b 100644 --- a/site/src/contexts/auth/permissions.tsx +++ b/site/src/contexts/auth/permissions.tsx @@ -17,6 +17,7 @@ export const checks = { viewAnyGroup: "viewAnyGroup", createGroup: "createGroup", viewAllLicenses: "viewAllLicenses", + assignOrgRole: "assignOrgRole", } as const; export const permissionsToCheck = { @@ -131,6 +132,12 @@ export const permissionsToCheck = { }, action: "read", }, + [checks.assignOrgRole]: { + object: { + resource_type: "assign_org_role", + }, + action: "create", + }, } as const; export type Permissions = Record; diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx index badb13997b69a..71de6183dc173 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx @@ -22,10 +22,10 @@ import CustomRolesPageView from "./CustomRolesPageView"; export const CustomRolesPage: FC = () => { const { permissions } = useAuthenticated(); - const { createGroup: canCreateGroup } = permissions; + const { assignOrgRole: canAssignOrgRole } = permissions; const { multiple_organizations: organizationsEnabled, - template_rbac: isTemplateRBACEnabled, + custom_roles: isCustomRolesEnabled, } = useFeatureVisibility(); const { experiments } = useDashboard(); const location = useLocation(); @@ -62,7 +62,7 @@ export const CustomRolesPage: FC = () => { - {canCreateGroup && isTemplateRBACEnabled && ( + {canAssignOrgRole && isCustomRolesEnabled && ( ) } diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 1c12784a3c84f..6ef4bbf8fad6a 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -416,18 +416,18 @@ export const MockOrganizationMember: TypesGen.OrganizationMemberWithUserData = { }; export const MockOrganizationMember2: TypesGen.OrganizationMemberWithUserData = - { - organization_id: MockOrganization.id, - user_id: MockUser2.id, - username: MockUser2.username, - email: MockUser2.email, - created_at: "", - updated_at: "", - name: MockUser2.name, - avatar_url: MockUser2.avatar_url, - global_roles: MockUser2.roles, - roles: [], - }; +{ + organization_id: MockOrganization.id, + user_id: MockUser2.id, + username: MockUser2.username, + email: MockUser2.email, + created_at: "", + updated_at: "", + name: MockUser2.name, + avatar_url: MockUser2.avatar_url, + global_roles: MockUser2.roles, + roles: [], +}; export const MockProvisioner: TypesGen.ProvisionerDaemon = { created_at: "2022-05-17T17:39:01.382927298Z", @@ -531,9 +531,9 @@ You can add instructions here }; export const MockTemplateVersionWithMarkdownMessage: TypesGen.TemplateVersion = - { - ...MockTemplateVersion, - message: ` +{ + ...MockTemplateVersion, + message: ` # Abiding Grace ## Enchantment At the beginning of your end step, choose one — @@ -542,7 +542,7 @@ At the beginning of your end step, choose one — - Return target creature card with mana value 1 from your graveyard to the battlefield. `, - }; +}; export const MockTemplate: TypesGen.Template = { id: "test-template", @@ -987,16 +987,16 @@ export const MockWorkspaceContainerResource: TypesGen.WorkspaceResource = { }; export const MockWorkspaceAutostartDisabled: TypesGen.UpdateWorkspaceAutostartRequest = - { - schedule: "", - }; +{ + schedule: "", +}; export const MockWorkspaceAutostartEnabled: TypesGen.UpdateWorkspaceAutostartRequest = - { - // Runs at 9:30am Monday through Friday using Canada/Eastern - // (America/Toronto) time - schedule: "CRON_TZ=Canada/Eastern 30 9 * * 1-5", - }; +{ + // Runs at 9:30am Monday through Friday using Canada/Eastern + // (America/Toronto) time + schedule: "CRON_TZ=Canada/Eastern 30 9 * * 1-5", +}; export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = { build_number: 1, @@ -1249,12 +1249,12 @@ export const MockDormantOutdatedWorkspace: TypesGen.Workspace = { }; export const MockOutdatedRunningWorkspaceRequireActiveVersion: TypesGen.Workspace = - { - ...MockWorkspace, - id: "test-outdated-workspace-require-active-version", - outdated: true, - template_require_active_version: true, - }; +{ + ...MockWorkspace, + id: "test-outdated-workspace-require-active-version", + outdated: true, + template_require_active_version: true, +}; export const MockOutdatedRunningWorkspaceAlwaysUpdate: TypesGen.Workspace = { ...MockWorkspace, @@ -1268,13 +1268,13 @@ export const MockOutdatedRunningWorkspaceAlwaysUpdate: TypesGen.Workspace = { }; export const MockOutdatedStoppedWorkspaceRequireActiveVersion: TypesGen.Workspace = - { - ...MockOutdatedRunningWorkspaceRequireActiveVersion, - latest_build: { - ...MockWorkspaceBuild, - status: "stopped", - }, - }; +{ + ...MockOutdatedRunningWorkspaceRequireActiveVersion, + latest_build: { + ...MockWorkspaceBuild, + status: "stopped", + }, +}; export const MockOutdatedStoppedWorkspaceAlwaysUpdate: TypesGen.Workspace = { ...MockOutdatedRunningWorkspaceAlwaysUpdate, @@ -1311,82 +1311,82 @@ export const MockWorkspacesResponseWithDeletions = { }; export const MockTemplateVersionParameter1: TypesGen.TemplateVersionParameter = - { - name: "first_parameter", - type: "string", - description: "This is first parameter", - description_plaintext: "Markdown: This is first parameter", - default_value: "abc", - mutable: true, - icon: "/icon/folder.svg", - options: [], - required: true, - ephemeral: false, - }; +{ + name: "first_parameter", + type: "string", + description: "This is first parameter", + description_plaintext: "Markdown: This is first parameter", + default_value: "abc", + mutable: true, + icon: "/icon/folder.svg", + options: [], + required: true, + ephemeral: false, +}; export const MockTemplateVersionParameter2: TypesGen.TemplateVersionParameter = - { - name: "second_parameter", - type: "number", - description: "This is second parameter", - description_plaintext: "Markdown: This is second parameter", - default_value: "2", - mutable: true, - icon: "/icon/folder.svg", - options: [], - validation_min: 1, - validation_max: 3, - validation_monotonic: "increasing", - required: true, - ephemeral: false, - }; +{ + name: "second_parameter", + type: "number", + description: "This is second parameter", + description_plaintext: "Markdown: This is second parameter", + default_value: "2", + mutable: true, + icon: "/icon/folder.svg", + options: [], + validation_min: 1, + validation_max: 3, + validation_monotonic: "increasing", + required: true, + ephemeral: false, +}; export const MockTemplateVersionParameter3: TypesGen.TemplateVersionParameter = - { - name: "third_parameter", - type: "string", - description: "This is third parameter", - description_plaintext: "Markdown: This is third parameter", - default_value: "aaa", - mutable: true, - icon: "/icon/database.svg", - options: [], - validation_error: "No way!", - validation_regex: "^[a-z]{3}$", - required: true, - ephemeral: false, - }; +{ + name: "third_parameter", + type: "string", + description: "This is third parameter", + description_plaintext: "Markdown: This is third parameter", + default_value: "aaa", + mutable: true, + icon: "/icon/database.svg", + options: [], + validation_error: "No way!", + validation_regex: "^[a-z]{3}$", + required: true, + ephemeral: false, +}; export const MockTemplateVersionParameter4: TypesGen.TemplateVersionParameter = - { - name: "fourth_parameter", - type: "string", - description: "This is fourth parameter", - description_plaintext: "Markdown: This is fourth parameter", - default_value: "def", - mutable: false, - icon: "/icon/database.svg", - options: [], - required: true, - ephemeral: false, - }; +{ + name: "fourth_parameter", + type: "string", + description: "This is fourth parameter", + description_plaintext: "Markdown: This is fourth parameter", + default_value: "def", + mutable: false, + icon: "/icon/database.svg", + options: [], + required: true, + ephemeral: false, +}; export const MockTemplateVersionParameter5: TypesGen.TemplateVersionParameter = - { - name: "fifth_parameter", - type: "number", - description: "This is fifth parameter", - description_plaintext: "Markdown: This is fifth parameter", - default_value: "5", - mutable: true, - icon: "/icon/folder.svg", - options: [], - validation_min: 1, - validation_max: 10, - validation_monotonic: "decreasing", - required: true, - ephemeral: false, - }; +{ + name: "fifth_parameter", + type: "number", + description: "This is fifth parameter", + description_plaintext: "Markdown: This is fifth parameter", + default_value: "5", + mutable: true, + icon: "/icon/folder.svg", + options: [], + validation_min: 1, + validation_max: 10, + validation_monotonic: "decreasing", + required: true, + ephemeral: false, +}; export const MockTemplateVersionVariable1: TypesGen.TemplateVersionVariable = { name: "first_variable", @@ -1445,16 +1445,16 @@ export const MockWorkspaceRequest: TypesGen.CreateWorkspaceRequest = { }; export const MockWorkspaceRichParametersRequest: TypesGen.CreateWorkspaceRequest = - { - name: "test", - template_version_id: "test-template-version", - rich_parameter_values: [ - { - name: MockTemplateVersionParameter1.name, - value: MockTemplateVersionParameter1.default_value, - }, - ], - }; +{ + name: "test", + template_version_id: "test-template-version", + rich_parameter_values: [ + { + name: MockTemplateVersionParameter1.name, + value: MockTemplateVersionParameter1.default_value, + }, + ], +}; export const MockUserAgent = { browser: "Chrome 99.0.4844", @@ -2501,6 +2501,7 @@ export const MockPermissions: Permissions = { viewAnyGroup: true, createGroup: true, viewAllLicenses: true, + assignOrgRole: true, }; export const MockDeploymentConfig: DeploymentConfig = { @@ -2545,24 +2546,24 @@ export const MockWorkspaceBuildParameter5: TypesGen.WorkspaceBuildParameter = { }; export const MockTemplateVersionExternalAuthGithub: TypesGen.TemplateVersionExternalAuth = - { - id: "github", - type: "github", - authenticate_url: "https://example.com/external-auth/github", - authenticated: false, - display_icon: "/icon/github.svg", - display_name: "GitHub", - }; +{ + id: "github", + type: "github", + authenticate_url: "https://example.com/external-auth/github", + authenticated: false, + display_icon: "/icon/github.svg", + display_name: "GitHub", +}; export const MockTemplateVersionExternalAuthGithubAuthenticated: TypesGen.TemplateVersionExternalAuth = - { - id: "github", - type: "github", - authenticate_url: "https://example.com/external-auth/github", - authenticated: true, - display_icon: "/icon/github.svg", - display_name: "GitHub", - }; +{ + id: "github", + type: "github", + authenticate_url: "https://example.com/external-auth/github", + authenticated: true, + display_icon: "/icon/github.svg", + display_name: "GitHub", +}; export const MockDeploymentStats: TypesGen.DeploymentStats = { aggregated_from: "2023-03-06T19:08:55.211625Z", @@ -3459,13 +3460,13 @@ export const MockHealth: TypesGen.HealthcheckReport = { }; export const MockListeningPortsResponse: TypesGen.WorkspaceAgentListeningPortsResponse = - { - ports: [ - { process_name: "webb", network: "", port: 30000 }, - { process_name: "gogo", network: "", port: 8080 }, - { process_name: "", network: "", port: 8081 }, - ], - }; +{ + ports: [ + { process_name: "webb", network: "", port: 30000 }, + { process_name: "gogo", network: "", port: 8080 }, + { process_name: "", network: "", port: 8081 }, + ], +}; export const MockSharedPortsResponse: TypesGen.WorkspaceAgentPortShares = { shares: [ From 928de7e5b9fa5a45c7caa490b1100b1e4dc09efc Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 2 Aug 2024 01:00:10 +0000 Subject: [PATCH 04/28] feat: wip --- .../CustomRolesPage/CreateEditRolePage.tsx | 32 ++++-- .../CreateEditRolePageView.tsx | 103 ++++++++++++++---- .../CustomRolesPage/CustomRolesPageView.tsx | 20 +--- 3 files changed, 110 insertions(+), 45 deletions(-) diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx index 3b551229047bc..4f89f51b71b77 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx @@ -1,29 +1,47 @@ import type { FC } from "react"; import { Helmet } from "react-helmet-async"; -import { useMutation, useQueryClient } from "react-query"; +import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate, useParams } from "react-router-dom"; -import { patchOrganizationRole } from "api/queries/roles"; +import { getErrorMessage } from "api/errors"; +import { patchOrganizationRole, organizationRoles } from "api/queries/roles"; +import { displayError } from "components/GlobalSnackbar/utils"; import { pageTitle } from "utils/page"; import CreateEditRolePageView from "./CreateEditRolePageView"; export const CreateGroupPage: FC = () => { const queryClient = useQueryClient(); const navigate = useNavigate(); - const { organization } = useParams() as { organization: string }; + const { organization, roleName } = useParams() as { + organization: string; + roleName: string; + }; const patchOrganizationRoleMutation = useMutation( patchOrganizationRole(queryClient, organization ?? "default"), ); + const { data } = useQuery(organizationRoles(organization)); + const role = data?.find((role) => role.name === roleName); + const pageTitleText = + role !== undefined ? "Edit Custom Role" : "Create Custom Role"; return ( <> - {pageTitle("Create Custom Role")} + {pageTitle(pageTitleText)} { - const newRole = await patchOrganizationRoleMutation.mutateAsync(data); - console.log({ newRole }); - navigate(`/organizations/${organization}/roles`); + try { + console.log({ data }); + await patchOrganizationRoleMutation.mutateAsync(data); + navigate(`/organizations/${organization}/roles`); + } catch (error) { + console.log({ error }); + displayError( + getErrorMessage(error, "Failed to update custom role"), + ); + } }} error={patchOrganizationRoleMutation.error} isLoading={patchOrganizationRoleMutation.isLoading} diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx index e1989c85690cb..4b965dd94bb4f 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx @@ -1,17 +1,24 @@ import type { Interpolation, Theme } from "@emotion/react"; +import Checkbox from "@mui/material/Checkbox"; 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 TableRow from "@mui/material/TableRow"; import TextField from "@mui/material/TextField"; -import { useFormik } from "formik"; -import type { FC } from "react"; +import { type FormikValues, useFormik } from "formik"; +import { type ChangeEvent, useState, type FC, useEffect } 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 } from "api/typesGenerated"; +import type { + Role, + Permission, + AssignableRoles, + RBACResource, + RBACAction, +} from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { FormFields, @@ -27,12 +34,16 @@ const validationSchema = Yup.object({ }); export type CreateEditRolePageViewProps = { + role: AssignableRoles | undefined; + organization: string; onSubmit: (data: Role) => void; error?: unknown; isLoading: boolean; }; export const CreateEditRolePageView: FC = ({ + role, + organization, onSubmit, error, isLoading, @@ -40,11 +51,12 @@ export const CreateEditRolePageView: FC = ({ const navigate = useNavigate(); const form = useFormik({ initialValues: { - name: "", - display_name: "", - site_permissions: [], - organization_permissions: [], - user_permissions: [], + name: role?.name || "", + organization_id: role?.organization_id || organization, + display_name: role?.display_name || "", + site_permissions: role?.site_permissions || [], + organization_permissions: role?.organization_permissions || [], + user_permissions: role?.user_permissions || [], }, validationSchema, onSubmit, @@ -55,12 +67,14 @@ export const CreateEditRolePageView: FC = ({ return ( <> - Create custom role + + {role ? "Edit" : "Create"} custom role + {Boolean(error) && !isApiValidationError(error) && ( @@ -80,7 +94,10 @@ export const CreateEditRolePageView: FC = ({ fullWidth label="Display Name" /> - + @@ -90,26 +107,69 @@ export const CreateEditRolePageView: FC = ({ }; interface ActionCheckboxesProps { - permissions: Permissions[]; + permissions: readonly Permission[] | undefined; + form: ReturnType> & { values: Role }; } -const ActionCheckboxes: FC = ({ permissions }) => { +const ActionCheckboxes: FC = ({ permissions, form }) => { + const [checkedActions, setIsChecked] = useState(permissions); + + const handleCheckChange = async ( + e: ChangeEvent, + form: ReturnType> & { 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, + ); + + setIsChecked(newPermissions); + await form.setFieldValue("organization_permissions", checkedActions); + }; + + // useEffect(() => { + // setIsChecked(permissions); + // }, [permissions]); + return (
    - {Object.entries(RBACResourceActions).map(([key, value]) => { + {Object.entries(RBACResourceActions).map(([resourceKey, value]) => { return ( - + -
  • - {key} +
  • + {resourceKey}
      - {Object.entries(value).map(([key, value]) => { + {Object.entries(value).map(([actionKey, value]) => { return ( -
    • +
    • - {key} + + p.resource_type === resourceKey && + (p.action.toString() === "*" || + p.action === actionKey), + ) || false + } + onChange={(e) => handleCheckChange(e, form)} + /> + {actionKey} {" "} -{" "} {value} @@ -129,9 +189,6 @@ const ActionCheckboxes: FC = ({ permissions }) => { }; const styles = { - rolesDropdown: { - marginBottom: 20, - }, checkBoxes: { margin: 0, listStyleType: "none", diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx index f0f0a53c158e4..1424844d9a140 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx @@ -1,8 +1,6 @@ import type { Interpolation, Theme } from "@emotion/react"; import AddOutlined from "@mui/icons-material/AddOutlined"; import KeyboardArrowRight from "@mui/icons-material/KeyboardArrowRight"; -import { LoadingButton } from "@mui/lab"; -import { createFilterOptions } from "@mui/material/Autocomplete"; import Button from "@mui/material/Button"; import Skeleton from "@mui/material/Skeleton"; import Table from "@mui/material/Table"; @@ -11,19 +9,17 @@ 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 { useState, type FC } from "react"; +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 { Stack } from "components/Stack/Stack"; import { TableLoader, TableLoaderSkeleton, TableRowSkeleton, } from "components/TableLoader/TableLoader"; -import { permissionsToCheck } from "contexts/auth/permissions"; import { useClickableTableRow } from "hooks"; import { docs } from "utils/docs"; @@ -33,8 +29,6 @@ export type CustomRolesPageViewProps = { isCustomRolesEnabled: boolean; }; -// const filter = createFilterOptions(); - export const CustomRolesPageView: FC = ({ roles, canAssignOrgRole, @@ -42,8 +36,7 @@ export const CustomRolesPageView: FC = ({ }) => { const isLoading = Boolean(roles === undefined); const isEmpty = Boolean(roles && roles.length === 0); - // const [selectedRole, setSelectedRole] = useState(null); - console.log({ roles }); + return ( <> @@ -59,9 +52,8 @@ export const CustomRolesPageView: FC = ({
  • - Name - Display Name - Permissions + Name + Permissions @@ -125,9 +117,7 @@ const RoleRow: FC = ({ role }) => { return ( - {role.name} - - {role.display_name} + {role.display_name || role.name} {role.organization_permissions.length} From 8ebfee789d8d1f1168749bbd918eb95d7c26d9f2 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 2 Aug 2024 03:46:04 +0000 Subject: [PATCH 05/28] feat: cleanup --- .../CustomRolesPage/CreateEditRolePage.tsx | 21 ++++++++++++------- .../CreateEditRolePageView.tsx | 14 +++++-------- .../CustomRolesPage/CustomRolesPage.tsx | 15 ++++++------- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx index 4f89f51b71b77..bec8f7d41f51c 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx @@ -5,6 +5,7 @@ import { useNavigate, useParams } from "react-router-dom"; import { getErrorMessage } from "api/errors"; import { patchOrganizationRole, organizationRoles } from "api/queries/roles"; import { displayError } from "components/GlobalSnackbar/utils"; +import { Loader } from "components/Loader/Loader"; import { pageTitle } from "utils/page"; import CreateEditRolePageView from "./CreateEditRolePageView"; @@ -18,26 +19,32 @@ export const CreateGroupPage: FC = () => { const patchOrganizationRoleMutation = useMutation( patchOrganizationRole(queryClient, organization ?? "default"), ); - const { data } = useQuery(organizationRoles(organization)); - const role = data?.find((role) => role.name === roleName); - const pageTitleText = - role !== undefined ? "Edit Custom Role" : "Create Custom Role"; + const { data: roleData, isLoading } = useQuery( + organizationRoles(organization), + ); + const role = roleData?.find((role) => role.name === roleName); + + if (isLoading) { + return ; + } return ( <> - {pageTitle(pageTitleText)} + + {pageTitle( + role !== undefined ? "Edit Custom Role" : "Create Custom Role", + )} + { try { - console.log({ data }); await patchOrganizationRoleMutation.mutateAsync(data); navigate(`/organizations/${organization}/roles`); } catch (error) { - console.log({ error }); displayError( getErrorMessage(error, "Failed to update custom role"), ); diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx index 4b965dd94bb4f..91e50ba0c398c 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx @@ -6,8 +6,8 @@ import TableCell from "@mui/material/TableCell"; import TableContainer from "@mui/material/TableContainer"; import TableRow from "@mui/material/TableRow"; import TextField from "@mui/material/TextField"; -import { type FormikValues, useFormik } from "formik"; -import { type ChangeEvent, useState, type FC, useEffect } from "react"; +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"; @@ -112,7 +112,7 @@ interface ActionCheckboxesProps { } const ActionCheckboxes: FC = ({ permissions, form }) => { - const [checkedActions, setIsChecked] = useState(permissions); + const [checkedActions, setCheckActions] = useState(permissions); const handleCheckChange = async ( e: ChangeEvent, @@ -134,14 +134,10 @@ const ActionCheckboxes: FC = ({ permissions, form }) => { (p) => p.resource_type !== resource_type || p.action !== action, ); - setIsChecked(newPermissions); - await form.setFieldValue("organization_permissions", checkedActions); + setCheckActions(newPermissions); + await form.setFieldValue("organization_permissions", newPermissions); }; - // useEffect(() => { - // setIsChecked(permissions); - // }, [permissions]); - return (
    diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx index 71de6183dc173..db219612a3d9b 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx @@ -3,15 +3,9 @@ import Button from "@mui/material/Button"; import { type FC, useEffect } from "react"; import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; -import { - Navigate, - Link as RouterLink, - useLocation, - useParams, -} from "react-router-dom"; +import { Link as RouterLink, useLocation, useParams } from "react-router-dom"; import { getErrorMessage } from "api/errors"; import { organizationRoles } from "api/queries/roles"; -import type { Organization } from "api/typesGenerated"; import { displayError } from "components/GlobalSnackbar/utils"; import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; import { useAuthenticated } from "contexts/auth/RequireAuth"; @@ -31,6 +25,9 @@ export const CustomRolesPage: FC = () => { const location = useLocation(); const { organization = "default" } = useParams() as { organization: string }; const organizationRolesQuery = useQuery(organizationRoles(organization)); + const filteredRoleData = organizationRolesQuery.data?.filter( + (role) => role.built_in === false, + ); useEffect(() => { if (organizationRolesQuery.error) { @@ -56,7 +53,7 @@ export const CustomRolesPage: FC = () => { return ( <> - {pageTitle("Groups")} + {pageTitle("Custom Roles")} { From cb870e1d49bda583a48706adc80f37aac19816a5 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 2 Aug 2024 17:41:25 +0000 Subject: [PATCH 06/28] fix: role name is disabled when editing the role --- .../CustomRolesPage/CreateEditRolePageView.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx index 91e50ba0c398c..900a1721d7be6 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx @@ -85,6 +85,7 @@ export const CreateEditRolePageView: FC = ({ {...getFieldHelpers("name")} autoFocus fullWidth + disabled={role !== undefined} label="Name" /> Date: Fri, 2 Aug 2024 17:51:24 +0000 Subject: [PATCH 07/28] fix: assign role context menu falls back to name when no display_name --- .../pages/ManagementSettingsPage/UserTable/EditRolesButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/ManagementSettingsPage/UserTable/EditRolesButton.tsx b/site/src/pages/ManagementSettingsPage/UserTable/EditRolesButton.tsx index a3c9286fe8362..a1a0b14514390 100644 --- a/site/src/pages/ManagementSettingsPage/UserTable/EditRolesButton.tsx +++ b/site/src/pages/ManagementSettingsPage/UserTable/EditRolesButton.tsx @@ -138,7 +138,7 @@ export const EditRolesButton: FC = ({ onChange={handleChange} isChecked={selectedRoleNames.has(role.name)} value={role.name} - name={role.display_name} + name={role.display_name || role.name} description={roleDescriptions[role.name] ?? ""} /> ))} From 688ab79146e666a8fc8d1b187aad5835be4ba897 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 2 Aug 2024 18:02:52 +0000 Subject: [PATCH 08/28] feat: add helper text to let users know that role name is immutable --- .../CustomRolesPage/CreateEditRolePageView.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx index 900a1721d7be6..5b03a06a715f5 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx @@ -82,7 +82,10 @@ export const CreateEditRolePageView: FC = ({ )} Date: Fri, 2 Aug 2024 18:03:39 +0000 Subject: [PATCH 09/28] fix: format --- site/src/api/api.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 796f4675b2389..1061c15e22b57 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -187,7 +187,8 @@ export const watchBuildLogsByTemplateVersionId = ( const proto = location.protocol === "https:" ? "wss:" : "ws:"; const socket = new WebSocket( - `${proto}//${location.host + `${proto}//${ + location.host }/api/v2/templateversions/${versionId}/logs?${searchParams.toString()}`, ); @@ -269,7 +270,8 @@ export const watchBuildLogsByBuildId = ( } const proto = location.protocol === "https:" ? "wss:" : "ws:"; const socket = new WebSocket( - `${proto}//${location.host + `${proto}//${ + location.host }/api/v2/workspacebuilds/${buildId}/logs?${searchParams.toString()}`, ); socket.binaryType = "blob"; @@ -392,7 +394,7 @@ export class MissingBuildParameters extends Error { * lexical scope. */ class ApiMethods { - constructor(protected readonly axios: AxiosInstance) { } + constructor(protected readonly axios: AxiosInstance) {} login = async ( email: string, From fbea545f3bde7c0aad569bc0cec609246ddb89b5 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 2 Aug 2024 18:32:36 +0000 Subject: [PATCH 10/28] feat: - hide custom roles tab if experiment is not enabled --- .../CustomRolesPage/CustomRolesPage.tsx | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx index db219612a3d9b..b6298adc66048 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx @@ -3,13 +3,12 @@ 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, useLocation, useParams } from "react-router-dom"; +import { Link as RouterLink, useParams } from "react-router-dom"; import { getErrorMessage } from "api/errors"; import { organizationRoles } from "api/queries/roles"; import { displayError } from "components/GlobalSnackbar/utils"; import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; import { useAuthenticated } from "contexts/auth/RequireAuth"; -import { useDashboard } from "modules/dashboard/useDashboard"; import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; import { pageTitle } from "utils/page"; import CustomRolesPageView from "./CustomRolesPageView"; @@ -17,12 +16,8 @@ import CustomRolesPageView from "./CustomRolesPageView"; export const CustomRolesPage: FC = () => { const { permissions } = useAuthenticated(); const { assignOrgRole: canAssignOrgRole } = permissions; - const { - multiple_organizations: organizationsEnabled, - custom_roles: isCustomRolesEnabled, - } = useFeatureVisibility(); - const { experiments } = useDashboard(); - const location = useLocation(); + const { custom_roles: isCustomRolesEnabled } = useFeatureVisibility(); + const { organization = "default" } = useParams() as { organization: string }; const organizationRolesQuery = useQuery(organizationRoles(organization)); const filteredRoleData = organizationRolesQuery.data?.filter( @@ -40,16 +35,6 @@ export const CustomRolesPage: FC = () => { } }, [organizationRolesQuery.error]); - // if ( - // organizationsEnabled && - // experiments.includes("multi-organization") && - // location.pathname === "/deployment/groups" - // ) { - // const defaultName = - // getOrganizationNameByDefault(organizations) ?? "default"; - // return ; - // } - return ( <> From b4a460fabe0baabe31122b35894f81345524929c Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 2 Aug 2024 18:43:51 +0000 Subject: [PATCH 11/28] fix: use custom TableLoader --- .../CustomRolesPage/CustomRolesPageView.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx index 1424844d9a140..83419b322df67 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx @@ -16,7 +16,6 @@ import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"; import { EmptyState } from "components/EmptyState/EmptyState"; import { Paywall } from "components/Paywall/Paywall"; import { - TableLoader, TableLoaderSkeleton, TableRowSkeleton, } from "components/TableLoader/TableLoader"; @@ -132,6 +131,24 @@ const RoleRow: FC = ({ role }) => { ); }; +const TableLoader = () => { + return ( + + + + + + + + + + + + + + ); +}; + const styles = { arrowRight: (theme) => ({ color: theme.palette.text.secondary, From 832573f3001a019d1bc8ddfbb7e7d14fbb3b8ce2 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 2 Aug 2024 18:56:41 +0000 Subject: [PATCH 12/28] fix: fix custom roles text --- .../CustomRolesPage/CustomRolesPageView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx index 83419b322df67..c58984d320fda 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.tsx @@ -42,7 +42,7 @@ export const CustomRolesPageView: FC = ({ @@ -66,7 +66,7 @@ export const CustomRolesPageView: FC = ({ Date: Fri, 2 Aug 2024 19:45:46 +0000 Subject: [PATCH 13/28] fix: use PatchRoleRequest --- .../CustomRolesPage/CreateEditRolePage.tsx | 1 - .../CustomRolesPage/CreateEditRolePageView.tsx | 6 ++---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx index bec8f7d41f51c..171100d92e4d6 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx @@ -39,7 +39,6 @@ export const CreateGroupPage: FC = () => { { try { await patchOrganizationRoleMutation.mutateAsync(data); diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx index 5b03a06a715f5..f5016b83c509a 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx @@ -14,6 +14,7 @@ import { isApiValidationError } from "api/errors"; import { RBACResourceActions } from "api/rbacresources_gen"; import type { Role, + PatchRoleRequest, Permission, AssignableRoles, RBACResource, @@ -35,7 +36,6 @@ const validationSchema = Yup.object({ export type CreateEditRolePageViewProps = { role: AssignableRoles | undefined; - organization: string; onSubmit: (data: Role) => void; error?: unknown; isLoading: boolean; @@ -43,16 +43,14 @@ export type CreateEditRolePageViewProps = { export const CreateEditRolePageView: FC = ({ role, - organization, onSubmit, error, isLoading, }) => { const navigate = useNavigate(); - const form = useFormik({ + const form = useFormik({ initialValues: { name: role?.name || "", - organization_id: role?.organization_id || organization, display_name: role?.display_name || "", site_permissions: role?.site_permissions || [], organization_permissions: role?.organization_permissions || [], From 8db1da06e42f7d6615d94957252ff6199637fcad Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 2 Aug 2024 19:56:48 +0000 Subject: [PATCH 14/28] fix: use addIcon to create roles --- .../CustomRolesPage/CustomRolesPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx index b6298adc66048..c4b745e098005 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx @@ -1,4 +1,4 @@ -import GroupAdd from "@mui/icons-material/GroupAddOutlined"; +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"; @@ -47,7 +47,7 @@ export const CustomRolesPage: FC = () => { {canAssignOrgRole && isCustomRolesEnabled && ( + + + ) + } + > + { - try { - await patchOrganizationRoleMutation.mutateAsync(data); - navigate(`/organizations/${organization}/roles`); - } catch (error) { - displayError( - getErrorMessage(error, "Failed to update custom role"), - ); - } - }} + form={form} error={patchOrganizationRoleMutation.error} isLoading={patchOrganizationRoleMutation.isLoading} /> @@ -56,4 +106,4 @@ export const CreateGroupPage: FC = () => { ); }; -export default CreateGroupPage; +export default CreateEditRolePage; diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx index f5016b83c509a..11c6a06fda5f4 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx @@ -6,10 +6,9 @@ import TableCell from "@mui/material/TableCell"; import TableContainer from "@mui/material/TableContainer"; import TableRow from "@mui/material/TableRow"; import TextField from "@mui/material/TextField"; -import { useFormik } from "formik"; +import type { 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 { @@ -30,35 +29,20 @@ import { import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; import { getFormHelpers } from "utils/formUtils"; -const validationSchema = Yup.object({ - name: Yup.string().required().label("Name"), -}); - export type CreateEditRolePageViewProps = { role: AssignableRoles | undefined; - onSubmit: (data: Role) => void; + form: ReturnType>; error?: unknown; isLoading: boolean; }; export const CreateEditRolePageView: FC = ({ role, - onSubmit, + form, error, isLoading, }) => { const navigate = useNavigate(); - const form = useFormik({ - 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(form, error); const onCancel = () => navigate(-1); @@ -102,7 +86,11 @@ export const CreateEditRolePageView: FC = ({ /> - + ); From a17f5798214d3056b40d5aed4a23f4b9744ce0cd Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Sun, 4 Aug 2024 23:18:06 +0000 Subject: [PATCH 16/28] fix: use nameValidator for name --- .../CustomRolesPage/CreateEditRolePage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx index 796f89a0d35fb..bd215336d309e 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx @@ -12,6 +12,7 @@ import { displayError } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; import { PageHeader } from "components/PageHeader/PageHeader"; import { useAuthenticated } from "contexts/auth/RequireAuth"; +import { nameValidator } from "utils/formUtils"; import { pageTitle } from "utils/page"; import CreateEditRolePageView from "./CreateEditRolePageView"; @@ -33,7 +34,7 @@ export const CreateEditRolePage: FC = () => { const role = roleData?.find((role) => role.name === roleName); const validationSchema = Yup.object({ - name: Yup.string().required().label("Name"), + name: nameValidator("Name"), }); const onSubmit = async (data: PatchRoleRequest) => { From e9af2f95fd6b0948123ce84513272243f1df8d1b Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 5 Aug 2024 21:39:37 +0000 Subject: [PATCH 17/28] chore: cleanup --- .../CustomRolesPage/CreateEditRolePage.tsx | 12 ++++++--- .../CreateEditRolePageView.tsx | 27 ++++++++++--------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx index bd215336d309e..9b1b377c8f895 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx @@ -10,7 +10,7 @@ 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 { PageHeader } from "components/PageHeader/PageHeader"; +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; import { useAuthenticated } from "contexts/auth/RequireAuth"; import { nameValidator } from "utils/formUtils"; import { pageTitle } from "utils/page"; @@ -20,13 +20,13 @@ export const CreateEditRolePage: FC = () => { const { permissions } = useAuthenticated(); const queryClient = useQueryClient(); const navigate = useNavigate(); - const { organization, roleName } = useParams() as { + const { organization = "default", roleName } = useParams() as { organization: string; roleName: string; }; const { assignOrgRole: canAssignOrgRole } = permissions; const patchOrganizationRoleMutation = useMutation( - patchOrganizationRole(queryClient, organization ?? "default"), + patchOrganizationRole(queryClient, organization), ); const { data: roleData, isLoading } = useQuery( organizationRoles(organization), @@ -95,7 +95,11 @@ export const CreateEditRolePage: FC = () => { ) } - > + > + + {role ? "Edit" : "Create"} custom role + + = ({ return ( <> - - - {role ? "Edit" : "Create"} custom role - - > & { values: Role }; } +const ResourceActionComparator = ( + p: Permission, + resource: string, + action: string, +) => + p.resource_type === resource && + (p.action.toString() === "*" || p.action === action); + const ActionCheckboxes: FC = ({ permissions, form }) => { const [checkedActions, setCheckActions] = useState(permissions); @@ -146,18 +148,19 @@ const ActionCheckboxes: FC = ({ permissions, form }) => { - p.resource_type === resourceKey && - (p.action.toString() === "*" || - p.action === actionKey), + checkedActions?.some((p) => + ResourceActionComparator( + p, + resourceKey, + actionKey, + ), ) || false } onChange={(e) => handleCheckChange(e, form)} /> {actionKey} {" "} - -{" "} + –{" "} {value} ); From e40f0bf85f0c6d3dab59d38b140b8668109d96c4 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 6 Aug 2024 17:18:40 +0000 Subject: [PATCH 18/28] feat: add show all permissions checkbox --- .../CreateEditRolePageView.tsx | 144 ++++++++++++------ 1 file changed, 100 insertions(+), 44 deletions(-) diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx index e83eeb797dec4..8861bc8a9d673 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx @@ -1,9 +1,11 @@ import type { Interpolation, Theme } from "@emotion/react"; 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 TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import TextField from "@mui/material/TextField"; import type { useFormik } from "formik"; @@ -103,10 +105,28 @@ const ResourceActionComparator = ( 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 = ({ permissions, form }) => { const [checkedActions, setCheckActions] = useState(permissions); + const [showAllResources, setShowAllResources] = useState(false); - const handleCheckChange = async ( + const handleActionCheckChange = async ( e: ChangeEvent, form: ReturnType> & { values: Role }, ) => { @@ -130,50 +150,86 @@ const ActionCheckboxes: FC = ({ permissions, form }) => { await form.setFieldValue("organization_permissions", newPermissions); }; + const resourceActions = showAllResources + ? RBACResourceActions + : filteredRBACResourceActions; + return ( - -
    - - {Object.entries(RBACResourceActions).map(([resourceKey, value]) => { - return ( - - -
  • - {resourceKey} -
      - {Object.entries(value).map(([actionKey, value]) => { - return ( -
    • - - - ResourceActionComparator( - p, - resourceKey, - actionKey, - ), - ) || false - } - onChange={(e) => handleCheckChange(e, form)} - /> - {actionKey} - {" "} - –{" "} - {value} -
    • - ); - })} -
    -
  • -
    -
    - ); - })} -
    -
    -
    + <> + + + + + + + setShowAllResources(e.currentTarget.checked) + } + /> + } + label={ + Show all permissions + } + /> + + + + + {Object.entries(resourceActions).map(([resourceKey, value]) => { + return ( + + +
  • + {resourceKey} +
      + {Object.entries(value).map(([actionKey, value]) => { + return ( +
    • + + + ResourceActionComparator( + p, + resourceKey, + actionKey, + ), + ) || false + } + onChange={(e) => + handleActionCheckChange(e, form) + } + /> + {actionKey} + {" "} + –{" "} + + {value} + +
    • + ); + })} +
    +
  • +
    +
    + ); + })} +
    +
    +
    + ); }; From add45fb5fe1ae650015e889d203577516e31203e Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 6 Aug 2024 17:45:59 +0000 Subject: [PATCH 19/28] fix: update sidebar for roles --- site/src/api/queries/organizations.ts | 7 +++++++ site/src/pages/ManagementSettingsPage/SidebarView.tsx | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 65cb843c08e2e..9befb55e7da28 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -177,6 +177,13 @@ export const organizationPermissions = (organizationId: string | undefined) => { }, action: "read", }, + assignOrgRole: { + object: { + resource_type: "assign_org_role", + organization_id: organizationId, + }, + action: "create", + }, }, }), }; diff --git a/site/src/pages/ManagementSettingsPage/SidebarView.tsx b/site/src/pages/ManagementSettingsPage/SidebarView.tsx index d9738563aa9de..87a11e43329c6 100644 --- a/site/src/pages/ManagementSettingsPage/SidebarView.tsx +++ b/site/src/pages/ManagementSettingsPage/SidebarView.tsx @@ -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 ( <> )} + {props.permissions.assignOrgRole && + experiments.includes("custom-roles") && ( + + Roles + + )} {/* 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. */} From dfe82b17900cdb4b36ff3d96b3c6aec7d19ead1c Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 6 Aug 2024 17:57:52 +0000 Subject: [PATCH 20/28] fix: fix format --- site/src/testHelpers/entities.ts | 276 +++++++++++++++---------------- 1 file changed, 138 insertions(+), 138 deletions(-) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 6ef4bbf8fad6a..133aafca5f4c1 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -416,18 +416,18 @@ export const MockOrganizationMember: TypesGen.OrganizationMemberWithUserData = { }; export const MockOrganizationMember2: TypesGen.OrganizationMemberWithUserData = -{ - organization_id: MockOrganization.id, - user_id: MockUser2.id, - username: MockUser2.username, - email: MockUser2.email, - created_at: "", - updated_at: "", - name: MockUser2.name, - avatar_url: MockUser2.avatar_url, - global_roles: MockUser2.roles, - roles: [], -}; + { + organization_id: MockOrganization.id, + user_id: MockUser2.id, + username: MockUser2.username, + email: MockUser2.email, + created_at: "", + updated_at: "", + name: MockUser2.name, + avatar_url: MockUser2.avatar_url, + global_roles: MockUser2.roles, + roles: [], + }; export const MockProvisioner: TypesGen.ProvisionerDaemon = { created_at: "2022-05-17T17:39:01.382927298Z", @@ -531,9 +531,9 @@ You can add instructions here }; export const MockTemplateVersionWithMarkdownMessage: TypesGen.TemplateVersion = -{ - ...MockTemplateVersion, - message: ` + { + ...MockTemplateVersion, + message: ` # Abiding Grace ## Enchantment At the beginning of your end step, choose one — @@ -542,7 +542,7 @@ At the beginning of your end step, choose one — - Return target creature card with mana value 1 from your graveyard to the battlefield. `, -}; + }; export const MockTemplate: TypesGen.Template = { id: "test-template", @@ -987,16 +987,16 @@ export const MockWorkspaceContainerResource: TypesGen.WorkspaceResource = { }; export const MockWorkspaceAutostartDisabled: TypesGen.UpdateWorkspaceAutostartRequest = -{ - schedule: "", -}; + { + schedule: "", + }; export const MockWorkspaceAutostartEnabled: TypesGen.UpdateWorkspaceAutostartRequest = -{ - // Runs at 9:30am Monday through Friday using Canada/Eastern - // (America/Toronto) time - schedule: "CRON_TZ=Canada/Eastern 30 9 * * 1-5", -}; + { + // Runs at 9:30am Monday through Friday using Canada/Eastern + // (America/Toronto) time + schedule: "CRON_TZ=Canada/Eastern 30 9 * * 1-5", + }; export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = { build_number: 1, @@ -1249,12 +1249,12 @@ export const MockDormantOutdatedWorkspace: TypesGen.Workspace = { }; export const MockOutdatedRunningWorkspaceRequireActiveVersion: TypesGen.Workspace = -{ - ...MockWorkspace, - id: "test-outdated-workspace-require-active-version", - outdated: true, - template_require_active_version: true, -}; + { + ...MockWorkspace, + id: "test-outdated-workspace-require-active-version", + outdated: true, + template_require_active_version: true, + }; export const MockOutdatedRunningWorkspaceAlwaysUpdate: TypesGen.Workspace = { ...MockWorkspace, @@ -1268,13 +1268,13 @@ export const MockOutdatedRunningWorkspaceAlwaysUpdate: TypesGen.Workspace = { }; export const MockOutdatedStoppedWorkspaceRequireActiveVersion: TypesGen.Workspace = -{ - ...MockOutdatedRunningWorkspaceRequireActiveVersion, - latest_build: { - ...MockWorkspaceBuild, - status: "stopped", - }, -}; + { + ...MockOutdatedRunningWorkspaceRequireActiveVersion, + latest_build: { + ...MockWorkspaceBuild, + status: "stopped", + }, + }; export const MockOutdatedStoppedWorkspaceAlwaysUpdate: TypesGen.Workspace = { ...MockOutdatedRunningWorkspaceAlwaysUpdate, @@ -1311,82 +1311,82 @@ export const MockWorkspacesResponseWithDeletions = { }; export const MockTemplateVersionParameter1: TypesGen.TemplateVersionParameter = -{ - name: "first_parameter", - type: "string", - description: "This is first parameter", - description_plaintext: "Markdown: This is first parameter", - default_value: "abc", - mutable: true, - icon: "/icon/folder.svg", - options: [], - required: true, - ephemeral: false, -}; + { + name: "first_parameter", + type: "string", + description: "This is first parameter", + description_plaintext: "Markdown: This is first parameter", + default_value: "abc", + mutable: true, + icon: "/icon/folder.svg", + options: [], + required: true, + ephemeral: false, + }; export const MockTemplateVersionParameter2: TypesGen.TemplateVersionParameter = -{ - name: "second_parameter", - type: "number", - description: "This is second parameter", - description_plaintext: "Markdown: This is second parameter", - default_value: "2", - mutable: true, - icon: "/icon/folder.svg", - options: [], - validation_min: 1, - validation_max: 3, - validation_monotonic: "increasing", - required: true, - ephemeral: false, -}; + { + name: "second_parameter", + type: "number", + description: "This is second parameter", + description_plaintext: "Markdown: This is second parameter", + default_value: "2", + mutable: true, + icon: "/icon/folder.svg", + options: [], + validation_min: 1, + validation_max: 3, + validation_monotonic: "increasing", + required: true, + ephemeral: false, + }; export const MockTemplateVersionParameter3: TypesGen.TemplateVersionParameter = -{ - name: "third_parameter", - type: "string", - description: "This is third parameter", - description_plaintext: "Markdown: This is third parameter", - default_value: "aaa", - mutable: true, - icon: "/icon/database.svg", - options: [], - validation_error: "No way!", - validation_regex: "^[a-z]{3}$", - required: true, - ephemeral: false, -}; + { + name: "third_parameter", + type: "string", + description: "This is third parameter", + description_plaintext: "Markdown: This is third parameter", + default_value: "aaa", + mutable: true, + icon: "/icon/database.svg", + options: [], + validation_error: "No way!", + validation_regex: "^[a-z]{3}$", + required: true, + ephemeral: false, + }; export const MockTemplateVersionParameter4: TypesGen.TemplateVersionParameter = -{ - name: "fourth_parameter", - type: "string", - description: "This is fourth parameter", - description_plaintext: "Markdown: This is fourth parameter", - default_value: "def", - mutable: false, - icon: "/icon/database.svg", - options: [], - required: true, - ephemeral: false, -}; + { + name: "fourth_parameter", + type: "string", + description: "This is fourth parameter", + description_plaintext: "Markdown: This is fourth parameter", + default_value: "def", + mutable: false, + icon: "/icon/database.svg", + options: [], + required: true, + ephemeral: false, + }; export const MockTemplateVersionParameter5: TypesGen.TemplateVersionParameter = -{ - name: "fifth_parameter", - type: "number", - description: "This is fifth parameter", - description_plaintext: "Markdown: This is fifth parameter", - default_value: "5", - mutable: true, - icon: "/icon/folder.svg", - options: [], - validation_min: 1, - validation_max: 10, - validation_monotonic: "decreasing", - required: true, - ephemeral: false, -}; + { + name: "fifth_parameter", + type: "number", + description: "This is fifth parameter", + description_plaintext: "Markdown: This is fifth parameter", + default_value: "5", + mutable: true, + icon: "/icon/folder.svg", + options: [], + validation_min: 1, + validation_max: 10, + validation_monotonic: "decreasing", + required: true, + ephemeral: false, + }; export const MockTemplateVersionVariable1: TypesGen.TemplateVersionVariable = { name: "first_variable", @@ -1445,16 +1445,16 @@ export const MockWorkspaceRequest: TypesGen.CreateWorkspaceRequest = { }; export const MockWorkspaceRichParametersRequest: TypesGen.CreateWorkspaceRequest = -{ - name: "test", - template_version_id: "test-template-version", - rich_parameter_values: [ - { - name: MockTemplateVersionParameter1.name, - value: MockTemplateVersionParameter1.default_value, - }, - ], -}; + { + name: "test", + template_version_id: "test-template-version", + rich_parameter_values: [ + { + name: MockTemplateVersionParameter1.name, + value: MockTemplateVersionParameter1.default_value, + }, + ], + }; export const MockUserAgent = { browser: "Chrome 99.0.4844", @@ -2546,24 +2546,24 @@ export const MockWorkspaceBuildParameter5: TypesGen.WorkspaceBuildParameter = { }; export const MockTemplateVersionExternalAuthGithub: TypesGen.TemplateVersionExternalAuth = -{ - id: "github", - type: "github", - authenticate_url: "https://example.com/external-auth/github", - authenticated: false, - display_icon: "/icon/github.svg", - display_name: "GitHub", -}; + { + id: "github", + type: "github", + authenticate_url: "https://example.com/external-auth/github", + authenticated: false, + display_icon: "/icon/github.svg", + display_name: "GitHub", + }; export const MockTemplateVersionExternalAuthGithubAuthenticated: TypesGen.TemplateVersionExternalAuth = -{ - id: "github", - type: "github", - authenticate_url: "https://example.com/external-auth/github", - authenticated: true, - display_icon: "/icon/github.svg", - display_name: "GitHub", -}; + { + id: "github", + type: "github", + authenticate_url: "https://example.com/external-auth/github", + authenticated: true, + display_icon: "/icon/github.svg", + display_name: "GitHub", + }; export const MockDeploymentStats: TypesGen.DeploymentStats = { aggregated_from: "2023-03-06T19:08:55.211625Z", @@ -3460,13 +3460,13 @@ export const MockHealth: TypesGen.HealthcheckReport = { }; export const MockListeningPortsResponse: TypesGen.WorkspaceAgentListeningPortsResponse = -{ - ports: [ - { process_name: "webb", network: "", port: 30000 }, - { process_name: "gogo", network: "", port: 8080 }, - { process_name: "", network: "", port: 8081 }, - ], -}; + { + ports: [ + { process_name: "webb", network: "", port: 30000 }, + { process_name: "gogo", network: "", port: 8080 }, + { process_name: "", network: "", port: 8081 }, + ], + }; export const MockSharedPortsResponse: TypesGen.WorkspaceAgentPortShares = { shares: [ From f6233565b343c88a51383a35be05a77a14af71d6 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 6 Aug 2024 18:06:47 +0000 Subject: [PATCH 21/28] fix: custom roles is not needed outside orgs --- .../CustomRolesPage/CreateEditRolePage.tsx | 2 +- .../ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx index 9b1b377c8f895..b6dee6117eaae 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx @@ -20,7 +20,7 @@ export const CreateEditRolePage: FC = () => { const { permissions } = useAuthenticated(); const queryClient = useQueryClient(); const navigate = useNavigate(); - const { organization = "default", roleName } = useParams() as { + const { organization, roleName } = useParams() as { organization: string; roleName: string; }; diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx index c4b745e098005..4ddb5acb700f4 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx @@ -18,7 +18,7 @@ export const CustomRolesPage: FC = () => { const { assignOrgRole: canAssignOrgRole } = permissions; const { custom_roles: isCustomRolesEnabled } = useFeatureVisibility(); - const { organization = "default" } = useParams() as { organization: string }; + const { organization } = useParams() as { organization: string }; const organizationRolesQuery = useQuery(organizationRoles(organization)); const filteredRoleData = organizationRolesQuery.data?.filter( (role) => role.built_in === false, From f7860aafa2d59269a607498fe33cb1429d3d0c88 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 6 Aug 2024 20:35:44 +0000 Subject: [PATCH 22/28] fix: fix sidebar stories --- site/src/pages/ManagementSettingsPage/SidebarView.stories.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/site/src/pages/ManagementSettingsPage/SidebarView.stories.tsx b/site/src/pages/ManagementSettingsPage/SidebarView.stories.tsx index 5490b4df68b49..c0d9ea18e9325 100644 --- a/site/src/pages/ManagementSettingsPage/SidebarView.stories.tsx +++ b/site/src/pages/ManagementSettingsPage/SidebarView.stories.tsx @@ -4,11 +4,13 @@ import { MockOrganization2, MockPermissions, } from "testHelpers/entities"; +import { withDashboardProvider } from "testHelpers/storybook"; import { SidebarView } from "./SidebarView"; const meta: Meta = { 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, }, }, }; From 764b15fc603a04456b08eaadfde5e8b53f082043 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 6 Aug 2024 21:08:43 +0000 Subject: [PATCH 23/28] feat: add custom roles page stories --- .../CustomRolesPageView.stories.tsx | 51 +++++++++++++++ site/src/testHelpers/entities.ts | 65 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx new file mode 100644 index 0000000000000..4875b44f1646b --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { MockRole } from "testHelpers/entities"; +import { CustomRolesPageView } from "./CustomRolesPageView"; + +const meta: Meta = { + title: "pages/OrganizationCustomRolesPage", + component: CustomRolesPageView, +}; + +export default meta; +type Story = StoryObj; + +export const NotEnabled: Story = { + args: { + roles: [MockRole], + canAssignOrgRole: true, + isCustomRolesEnabled: false, + }, +}; + +export const Enabled: Story = { + args: { + roles: [MockRole], + canAssignOrgRole: true, + isCustomRolesEnabled: true, + }, +}; + +export const EmptyDisplayName: Story = { + args: { + roles: [{ ...MockRole, 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, + }, +}; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 133aafca5f4c1..3cd18d7971f2a 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -2425,6 +2425,71 @@ export const MockWorkspaceQuota: TypesGen.WorkspaceQuota = { budget: 100, }; +export const MockRole: 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: [], +}; + export const MockGroup: TypesGen.Group = { id: "fbd2116a-8961-4954-87ae-e4575bd29ce0", name: "Front-End", From 124090bb81afc8d2265205cdf55189a77f047e34 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 7 Aug 2024 18:14:44 +0000 Subject: [PATCH 24/28] fix: use organization permissions --- site/src/contexts/auth/permissions.tsx | 7 - .../CustomRolesPage/CreateEditRolePage.tsx | 22 +-- .../CreateEditRolePageView.tsx | 130 ++++++++---------- .../CustomRolesPage/CustomRolesPage.tsx | 25 ++-- .../CustomRolesPage/CustomRolesPageView.tsx | 2 +- site/src/testHelpers/entities.ts | 1 - 6 files changed, 92 insertions(+), 95 deletions(-) diff --git a/site/src/contexts/auth/permissions.tsx b/site/src/contexts/auth/permissions.tsx index 20ca42636ab1b..c130e0a57e891 100644 --- a/site/src/contexts/auth/permissions.tsx +++ b/site/src/contexts/auth/permissions.tsx @@ -17,7 +17,6 @@ export const checks = { viewAnyGroup: "viewAnyGroup", createGroup: "createGroup", viewAllLicenses: "viewAllLicenses", - assignOrgRole: "assignOrgRole", } as const; export const permissionsToCheck = { @@ -132,12 +131,6 @@ export const permissionsToCheck = { }, action: "read", }, - [checks.assignOrgRole]: { - object: { - resource_type: "assign_org_role", - }, - action: "create", - }, } as const; export type Permissions = Record; diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx index b6dee6117eaae..f97aa4520e984 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePage.tsx @@ -6,32 +6,35 @@ import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate, useParams } from "react-router-dom"; import * as Yup from "yup"; 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 { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; -import { useAuthenticated } from "contexts/auth/RequireAuth"; import { nameValidator } from "utils/formUtils"; import { pageTitle } from "utils/page"; +import { useOrganizationSettings } from "../ManagementSettingsLayout"; import CreateEditRolePageView from "./CreateEditRolePageView"; export const CreateEditRolePage: FC = () => { - const { permissions } = useAuthenticated(); const queryClient = useQueryClient(); const navigate = useNavigate(); - const { organization, roleName } = useParams() as { + const { organization: organizationName, roleName } = useParams() as { organization: string; roleName: string; }; - const { assignOrgRole: canAssignOrgRole } = permissions; + const { organizations } = useOrganizationSettings(); + const organization = organizations?.find((o) => o.name === organizationName); + const permissionsQuery = useQuery(organizationPermissions(organization?.id)); const patchOrganizationRoleMutation = useMutation( - patchOrganizationRole(queryClient, organization), + patchOrganizationRole(queryClient, organizationName), ); const { data: roleData, isLoading } = useQuery( - organizationRoles(organization), + organizationRoles(organizationName), ); const role = roleData?.find((role) => role.name === roleName); + const permissions = permissionsQuery.data; const validationSchema = Yup.object({ name: nameValidator("Name"), @@ -40,7 +43,7 @@ export const CreateEditRolePage: FC = () => { const onSubmit = async (data: PatchRoleRequest) => { try { await patchOrganizationRoleMutation.mutateAsync(data); - navigate(`/organizations/${organization}/roles`); + navigate(`/organizations/${organizationName}/roles`); } catch (error) { displayError(getErrorMessage(error, "Failed to update custom role")); } @@ -74,11 +77,12 @@ export const CreateEditRolePage: FC = () => { - - - ) - } - > - - {role ? "Edit" : "Create"} custom role - - - { + 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} /> ); diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.stories.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.stories.tsx new file mode 100644 index 0000000000000..4afd5969e5750 --- /dev/null +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.stories.tsx @@ -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 = { + title: "pages/OrganizationCreateEditRolePage", + component: CreateEditRolePageView, +}; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx index f573eb71bb58b..841500e8e97c2 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx @@ -1,4 +1,5 @@ import type { Interpolation, Theme } from "@emotion/react"; +import Button from "@mui/material/Button"; import Checkbox from "@mui/material/Checkbox"; import FormControlLabel from "@mui/material/FormControlLabel"; import Table from "@mui/material/Table"; @@ -8,9 +9,10 @@ import TableContainer from "@mui/material/TableContainer"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import TextField from "@mui/material/TextField"; -import type { useFormik } from "formik"; +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 { @@ -28,71 +30,129 @@ import { FormSection, HorizontalForm, } from "components/Form/Form"; -import { getFormHelpers } from "utils/formUtils"; +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; +import { getFormHelpers, nameValidator } from "utils/formUtils"; + +const validationSchema = Yup.object({ + name: nameValidator("Name"), +}); export type CreateEditRolePageViewProps = { role: AssignableRoles | undefined; - form: ReturnType>; + onSubmit: (data: PatchRoleRequest) => void; error?: unknown; isLoading: boolean; + organizationName: string; + canAssignOrgRole: boolean; + allResources?: boolean; }; export const CreateEditRolePageView: FC = ({ role, - form, + onSubmit, error, isLoading, + organizationName, + canAssignOrgRole, + allResources = false, }) => { const navigate = useNavigate(); - const getFieldHelpers = getFormHelpers(form, error); const onCancel = () => navigate(-1); + const form = useFormik({ + 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(form, error); + return ( - - + + + + + ) + } > - - {Boolean(error) && !isApiValidationError(error) && ( - - )} - - - - + {role ? "Edit" : "Create"} custom role + + + + + + {Boolean(error) && !isApiValidationError(error) && ( + + )} + + + + + + + {canAssignOrgRole && ( + - - - - + )} + + ); }; interface ActionCheckboxesProps { permissions: readonly Permission[] | undefined; form: ReturnType> & { values: Role }; + allResources: boolean; } const ResourceActionComparator = ( @@ -120,9 +180,13 @@ const filteredRBACResourceActions = Object.fromEntries( ), ); -const ActionCheckboxes: FC = ({ permissions, form }) => { +const ActionCheckboxes: FC = ({ + permissions, + form, + allResources, +}) => { const [checkedActions, setCheckActions] = useState(permissions); - const [showAllResources, setShowAllResources] = useState(false); + const [showAllResources, setShowAllResources] = useState(allResources); const handleActionCheckChange = async ( e: ChangeEvent, diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx index 4875b44f1646b..52b2045fbba17 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPageView.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { MockRole } from "testHelpers/entities"; +import { MockRoleWithOrgPermissions } from "testHelpers/entities"; import { CustomRolesPageView } from "./CustomRolesPageView"; const meta: Meta = { @@ -12,7 +12,7 @@ type Story = StoryObj; export const NotEnabled: Story = { args: { - roles: [MockRole], + roles: [MockRoleWithOrgPermissions], canAssignOrgRole: true, isCustomRolesEnabled: false, }, @@ -20,7 +20,7 @@ export const NotEnabled: Story = { export const Enabled: Story = { args: { - roles: [MockRole], + roles: [MockRoleWithOrgPermissions], canAssignOrgRole: true, isCustomRolesEnabled: true, }, @@ -28,7 +28,13 @@ export const Enabled: Story = { export const EmptyDisplayName: Story = { args: { - roles: [{ ...MockRole, name: "my-custom-role", display_name: "" }], + roles: [ + { + ...MockRoleWithOrgPermissions, + name: "my-custom-role", + display_name: "", + }, + ], canAssignOrgRole: true, isCustomRolesEnabled: true, }, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index e23d5998c5de6..f2ad7362111b8 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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( @@ -2425,71 +2490,6 @@ export const MockWorkspaceQuota: TypesGen.WorkspaceQuota = { budget: 100, }; -export const MockRole: 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: [], -}; - export const MockGroup: TypesGen.Group = { id: "fbd2116a-8961-4954-87ae-e4575bd29ce0", name: "Front-End", From e18bf46bf413093b7bb17f6fc0a271576efd7768 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 8 Aug 2024 18:10:03 +0000 Subject: [PATCH 26/28] fix: design improvements for the create edit role form --- .../CustomRolesPage/CreateEditRolePageView.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx index 841500e8e97c2..4356583cc7170 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx @@ -1,4 +1,6 @@ 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"; @@ -28,7 +30,7 @@ import { FormFields, FormFooter, FormSection, - HorizontalForm, + VerticalForm, } from "components/Form/Form"; import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; import { getFormHelpers, nameValidator } from "utils/formUtils"; @@ -103,7 +105,7 @@ export const CreateEditRolePageView: FC = ({ {role ? "Edit" : "Create"} custom role - + = ({ submitLabel={role !== undefined ? "Save" : "Create Role"} /> )} - + ); }; @@ -222,6 +224,7 @@ const ActionCheckboxes: FC = ({ + Permission = ({ onChange={(e) => setShowAllResources(e.currentTarget.checked) } + checkedIcon={} + icon={} /> } label={ @@ -258,6 +263,7 @@ const ActionCheckboxes: FC = ({
  • ResourceActionComparator( @@ -279,6 +285,7 @@ const ActionCheckboxes: FC = ({
  • +
    ); })} From d455c4ea1a33a6b6ae83398cfaf98cdefc0b7c87 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 9 Aug 2024 00:17:20 +0000 Subject: [PATCH 27/28] feat: add show all resources checkbox to bottom of table --- .../CreateEditRolePageView.tsx | 171 ++++++++++-------- 1 file changed, 97 insertions(+), 74 deletions(-) diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx index 4356583cc7170..cca229e2cd1b1 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx @@ -8,6 +8,7 @@ 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"; @@ -219,80 +220,102 @@ const ActionCheckboxes: FC = ({ : filteredRBACResourceActions; return ( - <> - -
    - - - Permission - - - setShowAllResources(e.currentTarget.checked) - } - checkedIcon={} - icon={} - /> - } - label={ - Show all permissions - } - /> - - - - - {Object.entries(resourceActions).map(([resourceKey, value]) => { - return ( - - -
  • - {resourceKey} -
      - {Object.entries(value).map(([actionKey, value]) => ( -
    • - - - ResourceActionComparator( - p, - resourceKey, - actionKey, - ), - )} - onChange={(e) => - handleActionCheckChange(e, form) - } - /> - {actionKey} - {" "} - –{" "} - {value} -
    • - ))} -
    -
  • -
    - -
    - ); - })} -
    -
    - - + + + + + Permission + + + + + + + {Object.entries(resourceActions).map(([resourceKey, value]) => { + return ( + + +
  • + {resourceKey} +
      + {Object.entries(value).map(([actionKey, value]) => ( +
    • + + + ResourceActionComparator( + p, + resourceKey, + actionKey, + ), + )} + onChange={(e) => handleActionCheckChange(e, form)} + /> + {actionKey} + {" "} + –{" "} + {value} +
    • + ))} +
    +
  • +
    +
    + ); + })} +
    + + + + + + + +
    +
    + ); +}; + +interface ShowAllResourcesCheckboxProps { + showAllResources: boolean; + setShowAllResources: React.Dispatch>; +} + +const ShowAllResourcesCheckbox: FC = ({ + showAllResources, + setShowAllResources, +}) => { + return ( + setShowAllResources(e.currentTarget.checked)} + checkedIcon={} + icon={} + /> + } + label={Show all permissions} + /> ); }; From 72f1bab81b387b939ccddd4fe583936264877df2 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 9 Aug 2024 00:42:44 +0000 Subject: [PATCH 28/28] feat: improve spacing --- .../CreateEditRolePageView.tsx | 73 +++++++++---------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx index cca229e2cd1b1..8e58f19ac5954 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CreateEditRolePageView.tsx @@ -27,13 +27,12 @@ import type { RBACAction, } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { FormFields, FormFooter, VerticalForm } from "components/Form/Form"; import { - FormFields, - FormFooter, - FormSection, - VerticalForm, -} from "components/Form/Form"; -import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; + PageHeader, + PageHeaderSubtitle, + PageHeaderTitle, +} from "components/PageHeader/PageHeader"; import { getFormHelpers, nameValidator } from "utils/formUtils"; const validationSchema = Yup.object({ @@ -105,41 +104,39 @@ export const CreateEditRolePageView: FC = ({ {role ? "Edit" : "Create"} custom role + + {"Set a name and permissions for this role."} + - - - {Boolean(error) && !isApiValidationError(error) && ( - - )} + + {Boolean(error) && !isApiValidationError(error) && ( + + )} - - - - - + + + + {canAssignOrgRole && (