From eb8f16da8dd1759c5da660e040beb1d29ebef28e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Mon, 19 Jun 2023 09:41:40 +0100 Subject: [PATCH] feat: roles unification (#3999) https://linear.app/unleash/issue/2-1137/roles-unification-on-the-ui Root and project roles should be managed in a similar manner, which means using the same roles route and tab for both. Additionally, this includes a big revamp to the project roles to align them more closely with the modern and standardized custom root roles that were recently developed. They mostly use the same components. There are still more things we want to improve and unify, but we've left some of that out of this PR due to PR size concerns. --- frontend/src/component/admin/Admin.tsx | 11 +- .../src/component/admin/menu/AdminMenu.tsx | 12 +- .../CreateProjectRole/CreateProjectRole.tsx | 108 ------- .../EditProjectRole/EditProjectRole.tsx | 114 -------- .../ProjectRoleForm/ProjectRoleForm.tsx | 148 ---------- .../ProjectRoleDeleteConfirm.tsx | 74 ----- .../ProjectRoleList/ProjectRoleList.tsx | 270 ------------------ .../ProjectRoles/ProjectRoles.styles.ts | 10 - .../ProjectRoles/ProjectRoles.tsx | 22 -- .../projectRoles/hooks/useProjectRoleForm.ts | 187 ------------ .../PermissionAccordion.tsx | 63 ++-- .../admin/roles/RoleForm/RoleForm.tsx | 87 +++--- .../admin/roles/RoleForm/useRoleForm.ts | 7 +- .../admin/roles/RoleModal/RoleModal.tsx | 34 +-- frontend/src/component/admin/roles/Roles.tsx | 75 ++++- .../RolePermissionsCell.tsx | 3 +- .../RolesActionsCell/RolesActionsCell.tsx | 5 +- .../roles/RolesTable/RolesCell/RolesCell.tsx | 3 +- .../admin/roles/RolesTable/RolesTable.tsx | 29 +- .../RoleDescription/RoleDescription.tsx | 50 ++-- frontend/src/component/menu/routes.ts | 9 +- .../hooks/api/getters/useRoles/useRoles.ts | 30 +- frontend/src/interfaces/permissions.ts | 18 ++ frontend/src/interfaces/role.ts | 5 + frontend/src/utils/permissions.ts | 112 +++++++- src/lib/util/constants.ts | 5 + 26 files changed, 387 insertions(+), 1104 deletions(-) delete mode 100644 frontend/src/component/admin/projectRoles/CreateProjectRole/CreateProjectRole.tsx delete mode 100644 frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx delete mode 100644 frontend/src/component/admin/projectRoles/ProjectRoleForm/ProjectRoleForm.tsx delete mode 100644 frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.tsx delete mode 100644 frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx delete mode 100644 frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoles.styles.ts delete mode 100644 frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoles.tsx delete mode 100644 frontend/src/component/admin/projectRoles/hooks/useProjectRoleForm.ts rename frontend/src/component/admin/{projectRoles/ProjectRoleForm => roles/RoleForm}/PermissionAccordion/PermissionAccordion.tsx (71%) diff --git a/frontend/src/component/admin/Admin.tsx b/frontend/src/component/admin/Admin.tsx index 26448a35f0..ffffd82d96 100644 --- a/frontend/src/component/admin/Admin.tsx +++ b/frontend/src/component/admin/Admin.tsx @@ -13,10 +13,7 @@ import { InstanceAdmin } from './instance-admin/InstanceAdmin'; import { MaintenanceAdmin } from './maintenance'; import AdminMenu from './menu/AdminMenu'; import { Network } from './network/Network'; -import CreateProjectRole from './projectRoles/CreateProjectRole/CreateProjectRole'; -import EditProjectRole from './projectRoles/EditProjectRole/EditProjectRole'; import { Roles } from './roles/Roles'; -import ProjectRoles from './projectRoles/ProjectRoles/ProjectRoles'; import { ServiceAccounts } from './serviceAccounts/ServiceAccounts'; import CreateUser from './users/CreateUser/CreateUser'; import EditUser from './users/EditUser/EditUser'; @@ -28,11 +25,6 @@ export const Admin = () => ( } /> - } /> - } - /> } /> } /> } /> @@ -46,8 +38,7 @@ export const Admin = () => ( element={} /> } /> - } /> - } /> + } /> } /> } /> } /> diff --git a/frontend/src/component/admin/menu/AdminMenu.tsx b/frontend/src/component/admin/menu/AdminMenu.tsx index 5c3e5097e3..fe820fdab2 100644 --- a/frontend/src/component/admin/menu/AdminMenu.tsx +++ b/frontend/src/component/admin/menu/AdminMenu.tsx @@ -55,7 +55,7 @@ function AdminMenu() { } /> )} - {flags.customRootRoles && ( + {isEnterprise() && ( )} - {flags.RE && ( - - Project roles - - } - /> - )} { - const { setToastData, setToastApiError } = useToast(); - const { uiConfig } = useUiConfig(); - const navigate = useNavigate(); - const { - roleName, - roleDesc, - permissions, - checkedPermissions, - errors, - setRoleName, - setRoleDesc, - handlePermissionChange, - onToggleAllProjectPermissions: checkAllProjectPermissions, - onToggleAllEnvironmentPermissions: checkAllEnvironmentPermissions, - getProjectRolePayload, - validatePermissions, - validateName, - validateNameUniqueness, - clearErrors, - getRoleKey, - } = useProjectRoleForm(); - - const { addRole, loading } = useRolesApi(); - - const onSubmit = async (e: Event) => { - e.preventDefault(); - clearErrors(); - const validName = validateName(); - const validPermissions = validatePermissions(); - - if (validName && validPermissions) { - const payload = getProjectRolePayload(); - try { - await addRole(payload); - navigate('/admin/project-roles'); - setToastData({ - title: 'Project role created', - text: 'Now you can start assigning your project roles to project members.', - confetti: true, - type: 'success', - }); - } catch (error: unknown) { - setToastApiError(formatUnknownError(error)); - } - } - }; - - const formatApiCode = () => { - return `curl --location --request POST '${ - uiConfig.unleashUrl - }/api/admin/roles' \\ ---header 'Authorization: INSERT_API_KEY' \\ ---header 'Content-Type: application/json' \\ ---data-raw '${JSON.stringify(getProjectRolePayload(), undefined, 2)}'`; - }; - - const onCancel = () => { - navigate(GO_BACK); - }; - - return ( - - - - - - ); -}; - -export default CreateProjectRole; diff --git a/frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx b/frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx deleted file mode 100644 index 13aa6169b9..0000000000 --- a/frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import FormTemplate from 'component/common/FormTemplate/FormTemplate'; -import { UpdateButton } from 'component/common/UpdateButton/UpdateButton'; -import { ADMIN } from 'component/providers/AccessProvider/permissions'; -import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi'; -import { useRole } from 'hooks/api/getters/useRole/useRole'; -import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; -import useToast from 'hooks/useToast'; -import { useNavigate } from 'react-router-dom'; -import useProjectRoleForm from '../hooks/useProjectRoleForm'; -import ProjectRoleForm from '../ProjectRoleForm/ProjectRoleForm'; -import { formatUnknownError } from 'utils/formatUnknownError'; -import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; -import { GO_BACK } from 'constants/navigate'; - -const EditProjectRole = () => { - const { uiConfig } = useUiConfig(); - const { setToastData, setToastApiError } = useToast(); - const roleId = useRequiredPathParam('id'); - const { role, refetch } = useRole(roleId); - - const navigate = useNavigate(); - const { - roleName, - roleDesc, - permissions, - checkedPermissions, - errors, - setRoleName, - setRoleDesc, - handlePermissionChange, - onToggleAllProjectPermissions, - onToggleAllEnvironmentPermissions, - getProjectRolePayload, - validatePermissions, - validateName, - clearErrors, - getRoleKey, - } = useProjectRoleForm(role?.name, role?.description, role?.permissions); - - const formatApiCode = () => { - return `curl --location --request PUT '${ - uiConfig.unleashUrl - }/api/admin/roles/${role?.id}' \\ ---header 'Authorization: INSERT_API_KEY' \\ ---header 'Content-Type: application/json' \\ ---data-raw '${JSON.stringify(getProjectRolePayload(), undefined, 2)}'`; - }; - - const { updateRole, loading } = useRolesApi(); - - const onSubmit = async (e: Event) => { - e.preventDefault(); - const payload = getProjectRolePayload(); - - const validName = validateName(); - const validPermissions = validatePermissions(); - - if (validName && validPermissions) { - try { - await updateRole(+roleId, payload); - refetch(); - navigate('/admin/project-roles'); - setToastData({ - type: 'success', - title: 'Project role updated', - text: 'Your role changes will automatically be applied to the users with this role.', - confetti: true, - }); - } catch (error: unknown) { - setToastApiError(formatUnknownError(error)); - } - } - }; - - const onCancel = () => { - navigate(GO_BACK); - }; - - return ( - - - - - - ); -}; - -export default EditProjectRole; diff --git a/frontend/src/component/admin/projectRoles/ProjectRoleForm/ProjectRoleForm.tsx b/frontend/src/component/admin/projectRoles/ProjectRoleForm/ProjectRoleForm.tsx deleted file mode 100644 index 412575ab62..0000000000 --- a/frontend/src/component/admin/projectRoles/ProjectRoleForm/ProjectRoleForm.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import React, { Dispatch, FC, ReactNode, SetStateAction } from 'react'; -import { - Topic as TopicIcon, - CloudCircle as CloudCircleIcon, -} from '@mui/icons-material'; -import { Box, Button, TextField, Typography } from '@mui/material'; -import Input from 'component/common/Input/Input'; -import { PermissionAccordion } from './PermissionAccordion/PermissionAccordion'; -import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import { - IPermission, - IProjectEnvironmentPermissions, - IPermissions, -} from 'interfaces/permissions'; -import { ICheckedPermission } from '../hooks/useProjectRoleForm'; - -interface IProjectRoleForm { - roleName: string; - roleDesc: string; - checkedPermissions: ICheckedPermission; - errors: { [key: string]: string }; - children: ReactNode; - permissions: - | IPermissions - | { - project: IPermission[]; - environments: IProjectEnvironmentPermissions[]; - }; - setRoleName: Dispatch>; - setRoleDesc: Dispatch>; - handlePermissionChange: (permission: IPermission) => void; - checkAllProjectPermissions: () => void; - checkAllEnvironmentPermissions: (envName: string) => void; - onSubmit: (e: any) => void; - onCancel: () => void; - clearErrors: () => void; - validateNameUniqueness?: () => void; - getRoleKey: (permission: { id: number; environment?: string }) => string; -} - -const ProjectRoleForm: FC = ({ - children, - roleName, - roleDesc, - checkedPermissions, - errors, - permissions, - onSubmit, - onCancel, - setRoleName, - setRoleDesc, - handlePermissionChange, - checkAllProjectPermissions, - checkAllEnvironmentPermissions, - validateNameUniqueness, - clearErrors, - getRoleKey, -}: IProjectRoleForm) => { - const { project, environments } = permissions; - - return ( -
- - What is your role name? - setRoleName(e.target.value)} - error={Boolean(errors.name)} - errorText={errors.name} - onFocus={() => clearErrors()} - onBlur={validateNameUniqueness} - autoFocus - sx={{ width: '100%', marginBottom: '1rem' }} - /> - - What is this role for? - setRoleDesc(e.target.value)} - sx={{ width: '100%', marginBottom: '1rem' }} - /> - -
- - You must select at least one permission for a role. - - } - /> -
- } - permissions={project} - checkedPermissions={checkedPermissions} - onPermissionChange={(permission: IPermission) => - handlePermissionChange(permission) - } - onCheckAll={checkAllProjectPermissions} - getRoleKey={getRoleKey} - context="project" - /> -
- {environments.map(environment => ( - - } - permissions={environment.permissions} - key={environment.name} - checkedPermissions={checkedPermissions} - onPermissionChange={(permission: IPermission) => - handlePermissionChange(permission) - } - onCheckAll={() => - checkAllEnvironmentPermissions(environment.name) - } - getRoleKey={getRoleKey} - context="environment" - /> - ))} -
- - {children} - - - - ); -}; - -export default ProjectRoleForm; diff --git a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.tsx b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.tsx deleted file mode 100644 index cc1c5eba52..0000000000 --- a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { Alert, styled } from '@mui/material'; -import React from 'react'; -import { IProjectRole } from 'interfaces/role'; -import { Dialogue } from 'component/common/Dialogue/Dialogue'; -import Input from 'component/common/Input/Input'; - -interface IProjectRoleDeleteConfirmProps { - role: IProjectRole; - open: boolean; - setDialogue: React.Dispatch>; - handleDeleteRole: (id: number) => Promise; - confirmName: string; - setConfirmName: React.Dispatch>; -} - -const DeleteParagraph = styled('p')(({ theme }) => ({ - marginTop: theme.spacing(3), -})); - -const RoleDeleteInput = styled(Input)(({ theme }) => ({ - marginTop: theme.spacing(2), -})); - -const ProjectRoleDeleteConfirm = ({ - role, - open, - setDialogue, - handleDeleteRole, - confirmName, - setConfirmName, -}: IProjectRoleDeleteConfirmProps) => { - const handleChange = (e: React.ChangeEvent) => - setConfirmName(e.currentTarget.value); - - const handleCancel = () => { - setDialogue(false); - setConfirmName(''); - }; - const formId = 'delete-project-role-confirmation-form'; - return ( - handleDeleteRole(role.id)} - disabledPrimaryButton={role?.name !== confirmName} - onClose={handleCancel} - formId={formId} - > - - Danger. Deleting this role will result in removing all - permissions that are active in this environment across all - feature toggles. - - - - In order to delete this role, please enter the name of the role - in the textfield below: {role?.name} - - -
- - -
- ); -}; - -export default ProjectRoleDeleteConfirm; diff --git a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx deleted file mode 100644 index 972c765301..0000000000 --- a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import { useMemo, useState } from 'react'; -import { - Table, - SortableTableHeader, - TableBody, - TableCell, - TableRow, - TablePlaceholder, -} from 'component/common/Table'; -import { useTable, useGlobalFilter, useSortBy } from 'react-table'; -import { ADMIN } from 'component/providers/AccessProvider/permissions'; -import { useRoles } from 'hooks/api/getters/useRoles/useRoles'; -import { IProjectRole } from 'interfaces/role'; -import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi'; -import useToast from 'hooks/useToast'; -import ProjectRoleDeleteConfirm from '../ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm'; -import { formatUnknownError } from 'utils/formatUnknownError'; -import { Box, Button, useMediaQuery } from '@mui/material'; -import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; -import { Delete, Edit, SupervisedUserCircle } from '@mui/icons-material'; -import { useNavigate } from 'react-router-dom'; -import { PageContent } from 'component/common/PageContent/PageContent'; -import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; -import { PageHeader } from 'component/common/PageHeader/PageHeader'; -import { sortTypes } from 'utils/sortTypes'; -import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; -import theme from 'themes/theme'; -import { IconCell } from 'component/common/Table/cells/IconCell/IconCell'; -import { Search } from 'component/common/Search/Search'; -import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; - -const BUILTIN_ROLE_TYPE = 'project'; - -const ProjectRoleList = () => { - const navigate = useNavigate(); - const { projectRoles: data, refetch, loading } = useRoles(); - - const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); - - const { removeRole } = useRolesApi(); - const [currentRole, setCurrentRole] = useState(null); - const [delDialog, setDelDialog] = useState(false); - const [confirmName, setConfirmName] = useState(''); - const { setToastData, setToastApiError } = useToast(); - - const deleteProjectRole = async () => { - if (!currentRole?.id) return; - try { - await removeRole(currentRole?.id); - refetch(); - setToastData({ - type: 'success', - title: 'Successfully deleted role', - text: 'Your role is now deleted', - }); - } catch (error: unknown) { - setToastApiError(formatUnknownError(error)); - } - setDelDialog(false); - setConfirmName(''); - }; - - const columns = useMemo( - () => [ - { - id: 'Icon', - Cell: () => ( - } - /> - ), - disableGlobalFilter: true, - }, - { - Header: 'Project role', - accessor: 'name', - }, - { - Header: 'Description', - accessor: 'description', - width: '90%', - }, - { - Header: 'Actions', - id: 'Actions', - align: 'center', - Cell: ({ - row: { - original: { id, type, name, description }, - }, - }: any) => ( - - { - navigate(`/admin/project-roles/${id}/edit`); - }} - permission={ADMIN} - tooltipProps={{ - title: - type === BUILTIN_ROLE_TYPE - ? 'You cannot edit role' - : 'Edit role', - }} - > - - - { - setCurrentRole({ - id, - name, - description, - } as IProjectRole); - setDelDialog(true); - }} - permission={ADMIN} - tooltipProps={{ - title: - type === BUILTIN_ROLE_TYPE - ? 'You cannot remove role' - : 'Remove role', - }} - > - - - - ), - width: 100, - disableGlobalFilter: true, - disableSortBy: true, - }, - ], - [navigate] - ); - - const initialState = useMemo( - () => ({ - sortBy: [{ id: 'name', desc: false }], - }), - [] - ); - - const { - getTableProps, - getTableBodyProps, - headerGroups, - rows, - prepareRow, - state: { globalFilter }, - setGlobalFilter, - setHiddenColumns, - } = useTable( - { - columns: columns as any[], // TODO: fix after `react-table` v8 update - data, - initialState, - sortTypes, - autoResetGlobalFilter: false, - autoResetHiddenColumns: false, - autoResetSortBy: false, - disableSortRemove: true, - defaultColumn: { - Cell: HighlightCell, - }, - }, - useGlobalFilter, - useSortBy - ); - - useConditionallyHiddenColumns( - [ - { - condition: isExtraSmallScreen, - columns: ['Icon'], - }, - ], - setHiddenColumns, - columns - ); - - let count = - data.length < rows.length - ? `(${data.length} of ${rows.length})` - : `(${rows.length})`; - return ( - - - - - - } - /> - } - > - - - - - {rows.map(row => { - prepareRow(row); - return ( - - {row.cells.map(cell => ( - - {cell.render('Cell')} - - ))} - - ); - })} - -
-
- 0} - show={ - - No project roles found matching “ - {globalFilter} - ” - - } - elseShow={ - - No project roles available. Get started by - adding one. - - } - /> - } - /> - - -
- ); -}; - -export default ProjectRoleList; diff --git a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoles.styles.ts b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoles.styles.ts deleted file mode 100644 index fb25df6238..0000000000 --- a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoles.styles.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { makeStyles } from 'tss-react/mui'; - -export const useStyles = makeStyles()(theme => ({ - rolesListBody: { - padding: theme.spacing(4), - paddingBottom: '4rem', - minHeight: '50vh', - position: 'relative', - }, -})); diff --git a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoles.tsx b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoles.tsx deleted file mode 100644 index 8fdc168476..0000000000 --- a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoles.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useContext } from 'react'; -import AccessContext from 'contexts/AccessContext'; -import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import { ADMIN } from 'component/providers/AccessProvider/permissions'; -import ProjectRoleList from './ProjectRoleList/ProjectRoleList'; -import { AdminAlert } from 'component/common/AdminAlert/AdminAlert'; - -const ProjectRoles = () => { - const { hasAccess } = useContext(AccessContext); - - return ( -
- } - elseShow={} - /> -
- ); -}; - -export default ProjectRoles; diff --git a/frontend/src/component/admin/projectRoles/hooks/useProjectRoleForm.ts b/frontend/src/component/admin/projectRoles/hooks/useProjectRoleForm.ts deleted file mode 100644 index 205e3daf79..0000000000 --- a/frontend/src/component/admin/projectRoles/hooks/useProjectRoleForm.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { useEffect, useState } from 'react'; -import { IPermission } from 'interfaces/permissions'; -import cloneDeep from 'lodash.clonedeep'; -import usePermissions from 'hooks/api/getters/usePermissions/usePermissions'; -import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi'; -import { formatUnknownError } from 'utils/formatUnknownError'; - -export interface ICheckedPermission { - [key: string]: IPermission; -} - -const getRoleKey = (permission: { - id: number; - environment?: string; -}): string => { - return permission.environment - ? `${permission.id}-${permission.environment}` - : `${permission.id}`; -}; - -const useProjectRoleForm = ( - initialRoleName = '', - initialRoleDesc = '', - initialCheckedPermissions: IPermission[] = [] -) => { - const { permissions } = usePermissions({ - revalidateIfStale: false, - revalidateOnReconnect: false, - revalidateOnFocus: false, - }); - - const [roleName, setRoleName] = useState(initialRoleName); - const [roleDesc, setRoleDesc] = useState(initialRoleDesc); - const [checkedPermissions, setCheckedPermissions] = - useState({}); - - useEffect(() => { - if (initialCheckedPermissions.length > 0) { - setCheckedPermissions( - initialCheckedPermissions?.reduce( - ( - acc: { [key: string]: IPermission }, - curr: IPermission - ) => { - acc[getRoleKey(curr)] = curr; - return acc; - }, - {} - ) - ); - } - }, [initialCheckedPermissions?.length]); - - const [errors, setErrors] = useState({}); - - const { validateRole } = useRolesApi(); - - useEffect(() => { - setRoleName(initialRoleName); - }, [initialRoleName]); - - useEffect(() => { - setRoleDesc(initialRoleDesc); - }, [initialRoleDesc]); - - const handlePermissionChange = (permission: IPermission) => { - let checkedPermissionsCopy = cloneDeep(checkedPermissions); - - if (checkedPermissionsCopy[getRoleKey(permission)]) { - delete checkedPermissionsCopy[getRoleKey(permission)]; - } else { - checkedPermissionsCopy[getRoleKey(permission)] = { ...permission }; - } - - setCheckedPermissions(checkedPermissionsCopy); - }; - - const onToggleAllProjectPermissions = () => { - const { project } = permissions; - let checkedPermissionsCopy = cloneDeep(checkedPermissions); - - const allChecked = project.every( - (permission: IPermission) => - checkedPermissionsCopy[getRoleKey(permission)] - ); - - if (allChecked) { - project.forEach((permission: IPermission) => { - delete checkedPermissionsCopy[getRoleKey(permission)]; - }); - } else { - project.forEach((permission: IPermission) => { - checkedPermissionsCopy[getRoleKey(permission)] = { - ...permission, - }; - }); - } - - setCheckedPermissions(checkedPermissionsCopy); - }; - - const onToggleAllEnvironmentPermissions = (envName: string) => { - const { environments } = permissions; - const checkedPermissionsCopy = cloneDeep(checkedPermissions); - const env = environments.find(env => env.name === envName); - if (!env) return; - - const allChecked = env.permissions.every( - (permission: IPermission) => - checkedPermissionsCopy[getRoleKey(permission)] - ); - - if (allChecked) { - env.permissions.forEach((permission: IPermission) => { - delete checkedPermissionsCopy[getRoleKey(permission)]; - }); - } else { - env.permissions.forEach((permission: IPermission) => { - checkedPermissionsCopy[getRoleKey(permission)] = { - ...permission, - }; - }); - } - - setCheckedPermissions(checkedPermissionsCopy); - }; - - const getProjectRolePayload = () => ({ - name: roleName, - description: roleDesc, - permissions: Object.values(checkedPermissions), - }); - - const validateNameUniqueness = async () => { - const payload = getProjectRolePayload(); - - try { - await validateRole(payload); - } catch (error: unknown) { - setErrors(prev => ({ ...prev, name: formatUnknownError(error) })); - } - }; - - const validateName = () => { - if (roleName.length === 0) { - setErrors(prev => ({ ...prev, name: 'Name can not be empty.' })); - return false; - } - return true; - }; - - const validatePermissions = () => { - if (Object.keys(checkedPermissions).length === 0) { - setErrors(prev => ({ - ...prev, - permissions: 'You must include at least one permission.', - })); - return false; - } - return true; - }; - - const clearErrors = () => { - setErrors({}); - }; - - return { - roleName, - roleDesc, - errors, - checkedPermissions, - permissions, - setRoleName, - setRoleDesc, - handlePermissionChange, - onToggleAllProjectPermissions, - onToggleAllEnvironmentPermissions, - getProjectRolePayload, - validatePermissions, - validateName, - clearErrors, - validateNameUniqueness, - getRoleKey, - }; -}; - -export default useProjectRoleForm; diff --git a/frontend/src/component/admin/projectRoles/ProjectRoleForm/PermissionAccordion/PermissionAccordion.tsx b/frontend/src/component/admin/roles/RoleForm/PermissionAccordion/PermissionAccordion.tsx similarity index 71% rename from frontend/src/component/admin/projectRoles/ProjectRoleForm/PermissionAccordion/PermissionAccordion.tsx rename to frontend/src/component/admin/roles/RoleForm/PermissionAccordion/PermissionAccordion.tsx index a6268fddc5..59d73e7f24 100644 --- a/frontend/src/component/admin/projectRoles/ProjectRoleForm/PermissionAccordion/PermissionAccordion.tsx +++ b/frontend/src/component/admin/roles/RoleForm/PermissionAccordion/PermissionAccordion.tsx @@ -13,20 +13,19 @@ import { Typography, } from '@mui/material'; import { ExpandMore } from '@mui/icons-material'; -import { IPermission } from 'interfaces/permissions'; +import { ICheckedPermissions, IPermission } from 'interfaces/permissions'; import StringTruncator from 'component/common/StringTruncator/StringTruncator'; -import { ICheckedPermission } from 'component/admin/projectRoles/hooks/useProjectRoleForm'; +import { getRoleKey } from 'utils/permissions'; interface IEnvironmentPermissionAccordionProps { permissions: IPermission[]; - checkedPermissions: ICheckedPermission; + checkedPermissions: ICheckedPermissions; title: string; Icon: ReactNode; isInitiallyExpanded?: boolean; context: string; onPermissionChange: (permission: IPermission) => void; onCheckAll: () => void; - getRoleKey?: (permission: { id: number; environment?: string }) => string; } const AccordionHeader = styled(Box)(({ theme }) => ({ @@ -52,7 +51,6 @@ export const PermissionAccordion: VFC = ({ context, onPermissionChange, onCheckAll, - getRoleKey = permission => permission.id.toString(), }) => { const [expanded, setExpanded] = useState(isInitiallyExpanded); const permissionMap = useMemo( @@ -141,35 +139,32 @@ export const PermissionAccordion: VFC = ({ all {context} permissions - {permissions?.map((permission: IPermission) => { - return ( - - onPermissionChange(permission) - } - color="primary" - /> - } - label={permission.displayName} - /> - ); - })} + {permissions?.map((permission: IPermission) => ( + + onPermissionChange(permission) + } + color="primary" + /> + } + label={permission.displayName} + /> + ))} diff --git a/frontend/src/component/admin/roles/RoleForm/RoleForm.tsx b/frontend/src/component/admin/roles/RoleForm/RoleForm.tsx index f764a49c50..fe458a0e30 100644 --- a/frontend/src/component/admin/roles/RoleForm/RoleForm.tsx +++ b/frontend/src/component/admin/roles/RoleForm/RoleForm.tsx @@ -1,11 +1,28 @@ import { styled } from '@mui/material'; import Input from 'component/common/Input/Input'; -import { PermissionAccordion } from 'component/admin/projectRoles/ProjectRoleForm/PermissionAccordion/PermissionAccordion'; -import { Person as UserIcon } from '@mui/icons-material'; +import { PermissionAccordion } from './PermissionAccordion/PermissionAccordion'; +import { + Person as UserIcon, + Topic as TopicIcon, + CloudCircle as CloudCircleIcon, +} from '@mui/icons-material'; import { ICheckedPermissions, IPermission } from 'interfaces/permissions'; import { IRoleFormErrors } from './useRoleForm'; -import { ROOT_PERMISSION_CATEGORIES } from '@server/types/permissions'; -import { toggleAllPermissions, togglePermission } from 'utils/permissions'; +import { + flattenProjectPermissions, + getCategorizedProjectPermissions, + getCategorizedRootPermissions, + toggleAllPermissions, + togglePermission, +} from 'utils/permissions'; +import usePermissions from 'hooks/api/getters/usePermissions/usePermissions'; +import { PredefinedRoleType } from 'interfaces/role'; +import { + ENVIRONMENT_PERMISSION_TYPE, + PROJECT_PERMISSION_TYPE, + PROJECT_ROLE_TYPES, + ROOT_ROLE_TYPE, +} from '@server/util/constants'; const StyledInputDescription = styled('p')(({ theme }) => ({ display: 'flex', @@ -22,6 +39,7 @@ const StyledInput = styled(Input)(({ theme }) => ({ })); interface IRoleFormProps { + type?: PredefinedRoleType; name: string; onSetName: (name: string) => void; description: string; @@ -30,34 +48,35 @@ interface IRoleFormProps { setCheckedPermissions: React.Dispatch< React.SetStateAction >; - permissions: IPermission[]; errors: IRoleFormErrors; } export const RoleForm = ({ + type = ROOT_ROLE_TYPE, name, onSetName, description, setDescription, checkedPermissions, setCheckedPermissions, - permissions, errors, }: IRoleFormProps) => { - const categorizedPermissions = permissions.map(permission => { - const category = ROOT_PERMISSION_CATEGORIES.find(category => - category.permissions.includes(permission.name) - ); - - return { - category: category ? category.label : 'Other', - permission, - }; + const { permissions } = usePermissions({ + revalidateIfStale: false, + revalidateOnReconnect: false, + revalidateOnFocus: false, }); - const categories = new Set( - categorizedPermissions.map(({ category }) => category).sort() - ); + const isProjectRole = PROJECT_ROLE_TYPES.includes(type); + + const categories = isProjectRole + ? getCategorizedProjectPermissions( + flattenProjectPermissions( + permissions.project, + permissions.environments + ) + ) + : getCategorizedRootPermissions(permissions.root); const onPermissionChange = (permission: IPermission) => { const newCheckedPermissions = togglePermission( @@ -67,14 +86,10 @@ export const RoleForm = ({ setCheckedPermissions(newCheckedPermissions); }; - const onCheckAll = (category: string) => { - const categoryPermissions = categorizedPermissions - .filter(({ category: pCategory }) => pCategory === category) - .map(({ permission }) => permission); - + const onCheckAll = (permissions: IPermission[]) => { const newCheckedPermissions = toggleAllPermissions( checkedPermissions, - categoryPermissions + permissions ); setCheckedPermissions(newCheckedPermissions); @@ -108,22 +123,26 @@ export const RoleForm = ({ What is your role allowed to do? - {[...categories].map(category => ( + {categories.map(({ label, type, permissions }) => ( } - permissions={categorizedPermissions - .filter( - ({ category: pCategory }) => pCategory === category + key={label} + title={`${label} permissions`} + context={label.toLowerCase()} + Icon={ + type === PROJECT_PERMISSION_TYPE ? ( + + ) : type === ENVIRONMENT_PERMISSION_TYPE ? ( + + ) : ( + ) - .map(({ permission }) => permission)} + } + permissions={permissions} checkedPermissions={checkedPermissions} onPermissionChange={(permission: IPermission) => onPermissionChange(permission) } - onCheckAll={() => onCheckAll(category)} + onCheckAll={() => onCheckAll(permissions)} /> ))} diff --git a/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts b/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts index 709e6541f2..4619bbcd9b 100644 --- a/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts +++ b/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react'; import { IPermission, ICheckedPermissions } from 'interfaces/permissions'; -import IRole from 'interfaces/role'; +import IRole, { PredefinedRoleType } from 'interfaces/role'; import { useRoles } from 'hooks/api/getters/useRoles/useRoles'; import { permissionsToCheckedPermissions } from 'utils/permissions'; +import { ROOT_ROLE_TYPE } from '@server/util/constants'; enum ErrorField { NAME = 'name', @@ -39,10 +40,10 @@ export const useRoleForm = ( setCheckedPermissions(newCheckedPermissions); }, [initialPermissions.length]); - const getRolePayload = (type: 'root-custom' | 'custom' = 'custom') => ({ + const getRolePayload = (type: PredefinedRoleType = ROOT_ROLE_TYPE) => ({ name, description, - type, + type: type === ROOT_ROLE_TYPE ? 'root-custom' : 'custom', permissions: Object.values(checkedPermissions), }); diff --git a/frontend/src/component/admin/roles/RoleModal/RoleModal.tsx b/frontend/src/component/admin/roles/RoleModal/RoleModal.tsx index af50d584c7..23af41740d 100644 --- a/frontend/src/component/admin/roles/RoleModal/RoleModal.tsx +++ b/frontend/src/component/admin/roles/RoleModal/RoleModal.tsx @@ -10,7 +10,8 @@ import { formatUnknownError } from 'utils/formatUnknownError'; import { FormEvent } from 'react'; import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi'; import { useRole } from 'hooks/api/getters/useRole/useRole'; -import usePermissions from 'hooks/api/getters/usePermissions/usePermissions'; +import { PredefinedRoleType } from 'interfaces/role'; +import { ROOT_ROLE_TYPE } from '@server/util/constants'; const StyledForm = styled('form')(() => ({ display: 'flex', @@ -30,12 +31,18 @@ const StyledCancelButton = styled(Button)(({ theme }) => ({ })); interface IRoleModalProps { + type?: PredefinedRoleType; roleId?: number; open: boolean; setOpen: React.Dispatch>; } -export const RoleModal = ({ roleId, open, setOpen }: IRoleModalProps) => { +export const RoleModal = ({ + type = ROOT_ROLE_TYPE, + roleId, + open, + setOpen, +}: IRoleModalProps) => { const { role, refetch: refetchRole } = useRole(roleId?.toString()); const { @@ -56,18 +63,9 @@ export const RoleModal = ({ roleId, open, setOpen }: IRoleModalProps) => { } = useRoleForm(role?.name, role?.description, role?.permissions); const { refetch: refetchRoles } = useRoles(); const { addRole, updateRole, loading } = useRolesApi(); - const { permissions } = usePermissions({ - revalidateIfStale: false, - revalidateOnReconnect: false, - revalidateOnFocus: false, - }); const { setToastData, setToastApiError } = useToast(); const { uiConfig } = useUiConfig(); - const rootPermissions = permissions.root.filter( - ({ name }) => name !== 'ADMIN' - ); - const editing = role !== undefined; const isValid = isNameUnique(name) && @@ -75,7 +73,7 @@ export const RoleModal = ({ roleId, open, setOpen }: IRoleModalProps) => { isNotEmpty(description) && hasPermissions(checkedPermissions); - const payload = getRolePayload('root-custom'); + const payload = getRolePayload(type); const formatApiCode = () => { return `curl --location --request ${editing ? 'PUT' : 'POST'} '${ @@ -121,32 +119,34 @@ export const RoleModal = ({ roleId, open, setOpen }: IRoleModalProps) => { } }; + const titleCasedType = type[0].toUpperCase() + type.slice(1); + return ( { setOpen(false); }} - label={editing ? 'Edit role' : 'New role'} + label={editing ? `Edit ${type} role` : `New ${type} role`} > diff --git a/frontend/src/component/admin/roles/Roles.tsx b/frontend/src/component/admin/roles/Roles.tsx index 2fea3bfd38..561888b907 100644 --- a/frontend/src/component/admin/roles/Roles.tsx +++ b/frontend/src/component/admin/roles/Roles.tsx @@ -4,15 +4,88 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit import { ADMIN } from 'component/providers/AccessProvider/permissions'; import { RolesTable } from './RolesTable/RolesTable'; import { AdminAlert } from 'component/common/AdminAlert/AdminAlert'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { Tab, Tabs, styled } from '@mui/material'; +import { Route, Routes, useLocation } from 'react-router-dom'; +import { CenteredNavLink } from '../menu/CenteredNavLink'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { PROJECT_ROLE_TYPE } from '@server/util/constants'; + +const StyledPageContent = styled(PageContent)(({ theme }) => ({ + '.page-header': { + padding: 0, + }, +})); + +const tabs = [ + { + label: 'Root', + path: '/admin/roles', + }, + { + label: 'Project', + path: '/admin/roles/project-roles', + }, +]; export const Roles = () => { + const { uiConfig } = useUiConfig(); const { hasAccess } = useContext(AccessContext); + const { pathname } = useLocation(); + + if (!uiConfig.flags.customRootRoles) { + return ( +
+ } + elseShow={} + /> +
+ ); + } return (
} + show={ + + {tabs.map(({ label, path }) => ( + + {label} + + } + /> + ))} + + } + > + + + } + /> + } /> + + + } elseShow={} />
diff --git a/frontend/src/component/admin/roles/RolesTable/RolePermissionsCell/RolePermissionsCell.tsx b/frontend/src/component/admin/roles/RolesTable/RolePermissionsCell/RolePermissionsCell.tsx index 0b60064017..0560e85911 100644 --- a/frontend/src/component/admin/roles/RolesTable/RolePermissionsCell/RolePermissionsCell.tsx +++ b/frontend/src/component/admin/roles/RolesTable/RolePermissionsCell/RolePermissionsCell.tsx @@ -4,6 +4,7 @@ import { TooltipLink } from 'component/common/TooltipLink/TooltipLink'; import IRole from 'interfaces/role'; import { useRole } from 'hooks/api/getters/useRole/useRole'; import { RoleDescription } from 'component/common/RoleDescription/RoleDescription'; +import { PREDEFINED_ROLE_TYPES } from '@server/util/constants'; interface IRolePermissionsCellProps { row: { original: IRole }; @@ -15,7 +16,7 @@ export const RolePermissionsCell: VFC = ({ const { original: rowRole } = row; const { role } = useRole(rowRole.id.toString()); - if (!role || role.type === 'root') return null; + if (!role || PREDEFINED_ROLE_TYPES.includes(role.type)) return null; return ( diff --git a/frontend/src/component/admin/roles/RolesTable/RolesActionsCell/RolesActionsCell.tsx b/frontend/src/component/admin/roles/RolesTable/RolesActionsCell/RolesActionsCell.tsx index 1353c20f43..e8ee369a3d 100644 --- a/frontend/src/component/admin/roles/RolesTable/RolesActionsCell/RolesActionsCell.tsx +++ b/frontend/src/component/admin/roles/RolesTable/RolesActionsCell/RolesActionsCell.tsx @@ -1,5 +1,6 @@ import { Delete, Edit } from '@mui/icons-material'; import { Box, styled } from '@mui/material'; +import { PREDEFINED_ROLE_TYPES } from '@server/util/constants'; import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; import { ADMIN } from 'component/providers/AccessProvider/permissions'; import IRole from 'interfaces/role'; @@ -10,8 +11,6 @@ const StyledBox = styled(Box)(() => ({ justifyContent: 'center', })); -const DEFAULT_ROOT_ROLE = 'root'; - interface IRolesActionsCellProps { role: IRole; onEdit: (event: React.SyntheticEvent) => void; @@ -23,7 +22,7 @@ export const RolesActionsCell: VFC = ({ onEdit, onDelete, }) => { - const defaultRole = role.type === DEFAULT_ROOT_ROLE; + const defaultRole = PREDEFINED_ROLE_TYPES.includes(role.type); return ( diff --git a/frontend/src/component/admin/roles/RolesTable/RolesCell/RolesCell.tsx b/frontend/src/component/admin/roles/RolesTable/RolesCell/RolesCell.tsx index fda9123324..8131e3b257 100644 --- a/frontend/src/component/admin/roles/RolesTable/RolesCell/RolesCell.tsx +++ b/frontend/src/component/admin/roles/RolesTable/RolesCell/RolesCell.tsx @@ -3,6 +3,7 @@ import { Badge } from 'component/common/Badge/Badge'; import { styled } from '@mui/material'; import IRole from 'interfaces/role'; import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; +import { PREDEFINED_ROLE_TYPES } from '@server/util/constants'; const StyledBadge = styled(Badge)(({ theme }) => ({ marginLeft: theme.spacing(1), @@ -18,7 +19,7 @@ export const RolesCell = ({ role }: IRolesCellProps) => ( subtitle={role.description} afterTitle={ Predefined} /> } diff --git a/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx b/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx index 0a787249e8..7659eec744 100644 --- a/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx +++ b/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react'; import { TablePlaceholder, VirtualizedTable } from 'component/common/Table'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import IRole from 'interfaces/role'; +import IRole, { PredefinedRoleType } from 'interfaces/role'; import useToast from 'hooks/useToast'; import { formatUnknownError } from 'utils/formatUnknownError'; import { PageContent } from 'component/common/PageContent/PageContent'; @@ -24,11 +24,16 @@ import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi'; import { useRoles } from 'hooks/api/getters/useRoles/useRoles'; import { RoleModal } from '../RoleModal/RoleModal'; import { RolePermissionsCell } from './RolePermissionsCell/RolePermissionsCell'; +import { ROOT_ROLE_TYPE } from '@server/util/constants'; -export const RolesTable = () => { +interface IRolesTableProps { + type?: PredefinedRoleType; +} + +export const RolesTable = ({ type = ROOT_ROLE_TYPE }: IRolesTableProps) => { const { setToastData, setToastApiError } = useToast(); - const { roles, refetch, loading } = useRoles(); + const { roles, projectRoles, refetch, loading } = useRoles(); const { removeRole } = useRolesApi(); const [searchValue, setSearchValue] = useState(''); @@ -114,7 +119,11 @@ export const RolesTable = () => { hiddenColumns: ['description'], }); - const { data, getSearchText } = useSearch(columns, searchValue, roles); + const { data, getSearchText } = useSearch( + columns, + searchValue, + type === ROOT_ROLE_TYPE ? roles : projectRoles + ); const { headerGroups, rows, prepareRow, setHiddenColumns } = useTable( { @@ -145,12 +154,14 @@ export const RolesTable = () => { columns ); + const titledCaseType = type[0].toUpperCase() + type.slice(1); + return ( { setModalOpen(true); }} > - New role + New {type} role } @@ -204,20 +215,22 @@ export const RolesTable = () => { condition={searchValue?.length > 0} show={ - No roles found matching “ + No {type} roles found matching “ {searchValue} ” } elseShow={ - No roles available. Get started by adding one. + No {type} roles available. Get started by adding + one. } /> } /> prop !== 'tooltip', @@ -49,22 +56,13 @@ export const RoleDescription = ({ if (!role) return null; - const { name, description, permissions } = role; + const { name, description, permissions, type } = role; - const categorizedPermissions = [...new Set(permissions)].map(permission => { - const category = ROOT_PERMISSION_CATEGORIES.find(category => - category.permissions.includes(permission.name) - ); + const isProjectRole = PROJECT_ROLE_TYPES.includes(type); - return { - category: category ? category.label : 'Other', - permission, - }; - }); - - const categories = new Set( - categorizedPermissions.map(({ category }) => category).sort() - ); + const categories = isProjectRole + ? getCategorizedProjectPermissions(permissions) + : getCategorizedRootPermissions(permissions); return ( @@ -75,22 +73,18 @@ export const RoleDescription = ({ {description} 0 && role.type !== 'root' - } + condition={!PREDEFINED_ROLE_TYPES.includes(role.type)} show={() => - [...categories].map(category => ( - + categories.map(({ label, permissions }) => ( + - {category} + {label} - {categorizedPermissions - .filter(({ category: c }) => c === category) - .map(({ permission }) => ( -

- {permission.displayName} -

- ))} + {permissions.map(permission => ( +

+ {permission.displayName} +

+ ))}
)) } diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts index 5e9a341d66..ae165d44e8 100644 --- a/frontend/src/component/menu/routes.ts +++ b/frontend/src/component/menu/routes.ts @@ -464,15 +464,8 @@ export const adminMenuRoutes: INavigationMenuItem[] = [ flag: UG, }, { - path: '/admin/roles', + path: '/admin/roles/*', title: 'Roles', - flag: 'customRootRoles', - menu: { adminSettings: true, mode: ['enterprise'] }, - }, - { - path: '/admin/project-roles', - title: 'Project roles', - flag: RE, menu: { adminSettings: true, mode: ['enterprise'] }, }, { diff --git a/frontend/src/hooks/api/getters/useRoles/useRoles.ts b/frontend/src/hooks/api/getters/useRoles/useRoles.ts index 5f11a04a45..70723795fc 100644 --- a/frontend/src/hooks/api/getters/useRoles/useRoles.ts +++ b/frontend/src/hooks/api/getters/useRoles/useRoles.ts @@ -4,10 +4,12 @@ import { formatApiPath } from 'utils/formatPath'; import handleErrorResponses from '../httpErrorResponseHandler'; import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR'; import useUiConfig from '../useUiConfig/useUiConfig'; - -const ROOT_ROLE = 'root'; -const ROOT_ROLES = [ROOT_ROLE, 'root-custom']; -const PROJECT_ROLES = ['project', 'custom']; +import { + PROJECT_ROLE_TYPES, + ROOT_ROLE_TYPE, + ROOT_ROLE_TYPES, + PREDEFINED_ROLE_TYPES, +} from '@server/util/constants'; export const useRoles = () => { const { isEnterprise, uiConfig } = useUiConfig(); @@ -34,7 +36,7 @@ export const useRoles = () => { if (!isEnterprise()) { return { roles: ossData?.rootRoles - .filter(({ type }: IRole) => type === ROOT_ROLE) + .filter(({ type }: IRole) => type === ROOT_ROLE_TYPE) .sort(sortRoles) as IRole[], projectRoles: [], loading: !ossError && !ossData, @@ -46,12 +48,14 @@ export const useRoles = () => { roles: (data?.roles .filter(({ type }: IRole) => uiConfig.flags.customRootRoles - ? ROOT_ROLES.includes(type) - : type === ROOT_ROLE + ? ROOT_ROLE_TYPES.includes(type) + : type === ROOT_ROLE_TYPE ) .sort(sortRoles) ?? []) as IRole[], projectRoles: (data?.roles - .filter(({ type }: IRole) => PROJECT_ROLES.includes(type)) + .filter(({ type }: IRole) => + PROJECT_ROLE_TYPES.includes(type) + ) .sort(sortRoles) ?? []) as IProjectRole[], loading: !error && !data, refetch: () => mutate(), @@ -68,9 +72,15 @@ const fetcher = (path: string) => { }; export const sortRoles = (a: IRole, b: IRole) => { - if (a.type === 'root' && b.type !== 'root') { + if ( + PREDEFINED_ROLE_TYPES.includes(a.type) && + !PREDEFINED_ROLE_TYPES.includes(b.type) + ) { return -1; - } else if (a.type !== 'root' && b.type === 'root') { + } else if ( + !PREDEFINED_ROLE_TYPES.includes(a.type) && + PREDEFINED_ROLE_TYPES.includes(b.type) + ) { return 1; } else { return a.name.localeCompare(b.name); diff --git a/frontend/src/interfaces/permissions.ts b/frontend/src/interfaces/permissions.ts index baf43dbf84..cd7a4fd058 100644 --- a/frontend/src/interfaces/permissions.ts +++ b/frontend/src/interfaces/permissions.ts @@ -1,7 +1,19 @@ +import { + ROOT_PERMISSION_TYPE, + PROJECT_PERMISSION_TYPE, + ENVIRONMENT_PERMISSION_TYPE, +} from '@server/util/constants'; + +export type PermissionType = + | typeof ROOT_PERMISSION_TYPE + | typeof PROJECT_PERMISSION_TYPE + | typeof ENVIRONMENT_PERMISSION_TYPE; + export interface IPermission { id: number; name: string; displayName: string; + type: PermissionType; environment?: string; } @@ -19,3 +31,9 @@ export interface IProjectEnvironmentPermissions { export interface ICheckedPermissions { [key: string]: IPermission; } + +export interface IPermissionCategory { + label: string; + type: PermissionType; + permissions: IPermission[]; +} diff --git a/frontend/src/interfaces/role.ts b/frontend/src/interfaces/role.ts index 0373e3c0fd..e2916413b3 100644 --- a/frontend/src/interfaces/role.ts +++ b/frontend/src/interfaces/role.ts @@ -1,5 +1,10 @@ +import { ROOT_ROLE_TYPE, PROJECT_ROLE_TYPE } from '@server/util/constants'; import { IPermission } from './permissions'; +export type PredefinedRoleType = + | typeof ROOT_ROLE_TYPE + | typeof PROJECT_ROLE_TYPE; + interface IRole { id: number; name: string; diff --git a/frontend/src/utils/permissions.ts b/frontend/src/utils/permissions.ts index a112c2b06a..8cea8ba5f4 100644 --- a/frontend/src/utils/permissions.ts +++ b/frontend/src/utils/permissions.ts @@ -1,7 +1,17 @@ -import { IPermission, ICheckedPermissions } from 'interfaces/permissions'; +import { ROOT_PERMISSION_CATEGORIES } from '@server/types/permissions'; +import { + ENVIRONMENT_PERMISSION_TYPE, + PROJECT_PERMISSION_TYPE, +} from '@server/util/constants'; +import { + IPermission, + ICheckedPermissions, + IPermissionCategory, + IProjectEnvironmentPermissions, +} from 'interfaces/permissions'; import cloneDeep from 'lodash.clonedeep'; -const getRoleKey = (permission: IPermission): string => { +export const getRoleKey = (permission: IPermission): string => { return permission.environment ? `${permission.id}-${permission.environment}` : `${permission.id}`; @@ -61,3 +71,101 @@ export const toggleAllPermissions = ( return checkedPermissionsCopy; }; + +export const getCategorizedRootPermissions = (permissions: IPermission[]) => { + const rootPermissions = permissions.filter(({ name }) => name !== 'ADMIN'); + + return rootPermissions + .reduce((categories: IPermissionCategory[], permission) => { + const categoryLabel = + ROOT_PERMISSION_CATEGORIES.find(category => + category.permissions.includes(permission.name) + )?.label || 'Other'; + + const category = categories.find( + ({ label }) => label === categoryLabel + ); + + if (category) { + category.permissions.push(permission); + } else { + categories.push({ + label: categoryLabel, + type: 'root', + permissions: [permission], + }); + } + + return categories; + }, []) + .sort(sortCategories); +}; + +export const getCategorizedProjectPermissions = ( + permissions: IPermission[] +) => { + const projectPermissions = permissions.filter( + ({ type }) => type === PROJECT_PERMISSION_TYPE + ); + const environmentPermissions = permissions.filter( + ({ type }) => type === ENVIRONMENT_PERMISSION_TYPE + ); + + const categories = []; + + if (projectPermissions.length) { + categories.push({ + label: 'Project', + type: 'project', + permissions: projectPermissions, + }); + } + + categories.push( + ...environmentPermissions.reduce( + (categories: IPermissionCategory[], permission) => { + const categoryLabel = permission.environment; + + const category = categories.find( + ({ label }) => label === categoryLabel + ); + + if (category) { + category.permissions.push(permission); + } else { + categories.push({ + label: categoryLabel!, + type: 'environment', + permissions: [permission], + }); + } + + return categories; + }, + [] + ) + ); + + return categories; +}; + +export const flattenProjectPermissions = ( + projectPermissions: IPermission[], + environmentPermissions: IProjectEnvironmentPermissions[] +) => [ + ...projectPermissions, + ...environmentPermissions.flatMap(({ permissions }) => permissions), +]; + +const sortCategories = ( + { label: aLabel }: IPermissionCategory, + { label: bLabel }: IPermissionCategory +) => { + if (aLabel === 'Other' && bLabel !== 'Other') { + return 1; + } else if (aLabel !== 'Other' && bLabel === 'Other') { + return -1; + } else { + return aLabel.localeCompare(bLabel); + } +}; diff --git a/src/lib/util/constants.ts b/src/lib/util/constants.ts index d317034a79..bb00b4416b 100644 --- a/src/lib/util/constants.ts +++ b/src/lib/util/constants.ts @@ -7,8 +7,13 @@ export const ROOT_PERMISSION_TYPE = 'root'; export const ENVIRONMENT_PERMISSION_TYPE = 'environment'; export const PROJECT_PERMISSION_TYPE = 'project'; +export const ROOT_ROLE_TYPE = 'root'; +export const PROJECT_ROLE_TYPE = 'project'; export const CUSTOM_ROOT_ROLE_TYPE = 'root-custom'; export const CUSTOM_PROJECT_ROLE_TYPE = 'custom'; +export const PREDEFINED_ROLE_TYPES = [ROOT_ROLE_TYPE, PROJECT_ROLE_TYPE]; +export const ROOT_ROLE_TYPES = [ROOT_ROLE_TYPE, CUSTOM_ROOT_ROLE_TYPE]; +export const PROJECT_ROLE_TYPES = [PROJECT_ROLE_TYPE, CUSTOM_PROJECT_ROLE_TYPE]; /* CONTEXT FIELD OPERATORS */