From e15c438c61ca8359e527ba6465dd538f126f7bde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Mon, 5 Jun 2023 14:47:01 +0100 Subject: [PATCH 01/11] feat: custom root roles --- .../PermissionAccordion.tsx | 4 +- .../ProjectRoleForm/ProjectRoleForm.tsx | 6 +- .../projectRoles/hooks/useProjectRoleForm.ts | 6 +- .../ServiceAccountModal.tsx | 67 +++++++++++++++++++ .../CreateEnvironment/CreateEnvironment.tsx | 4 +- .../EditEnvironment/EditEnvironment.tsx | 4 +- .../EnvironmentActionCell.tsx | 4 +- .../useProjectRolesApi/useProjectRolesApi.ts | 2 +- .../useServiceAccountsApi.ts | 2 + .../usePermissions.ts} | 23 ++++--- frontend/src/interfaces/permissions.ts | 21 ++++++ frontend/src/interfaces/project.ts | 17 ----- src/lib/db/access-store.ts | 3 +- src/lib/services/access-service.ts | 5 ++ src/lib/types/model.ts | 1 + 15 files changed, 126 insertions(+), 43 deletions(-) rename frontend/src/hooks/api/getters/{useProjectRolePermissions/useProjectRolePermissions.ts => usePermissions/usePermissions.ts} (71%) create mode 100644 frontend/src/interfaces/permissions.ts diff --git a/frontend/src/component/admin/projectRoles/ProjectRoleForm/PermissionAccordion/PermissionAccordion.tsx b/frontend/src/component/admin/projectRoles/ProjectRoleForm/PermissionAccordion/PermissionAccordion.tsx index 120a49690ea..9c354812736 100644 --- a/frontend/src/component/admin/projectRoles/ProjectRoleForm/PermissionAccordion/PermissionAccordion.tsx +++ b/frontend/src/component/admin/projectRoles/ProjectRoleForm/PermissionAccordion/PermissionAccordion.tsx @@ -13,7 +13,7 @@ import { Typography, } from '@mui/material'; import { ExpandMore } from '@mui/icons-material'; -import { IPermission } from 'interfaces/project'; +import { IPermission } from 'interfaces/permissions'; import StringTruncator from 'component/common/StringTruncator/StringTruncator'; import { ICheckedPermission } from 'component/admin/projectRoles/hooks/useProjectRoleForm'; @@ -23,7 +23,7 @@ interface IEnvironmentPermissionAccordionProps { title: string; Icon: ReactNode; isInitiallyExpanded?: boolean; - context: 'project' | 'environment'; + context: 'root' | 'project' | 'environment'; onPermissionChange: (permission: IPermission) => void; onCheckAll: () => void; getRoleKey: (permission: { id: number; environment?: string }) => string; diff --git a/frontend/src/component/admin/projectRoles/ProjectRoleForm/ProjectRoleForm.tsx b/frontend/src/component/admin/projectRoles/ProjectRoleForm/ProjectRoleForm.tsx index bc3f8d05bc2..412575ab626 100644 --- a/frontend/src/component/admin/projectRoles/ProjectRoleForm/ProjectRoleForm.tsx +++ b/frontend/src/component/admin/projectRoles/ProjectRoleForm/ProjectRoleForm.tsx @@ -10,8 +10,8 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit import { IPermission, IProjectEnvironmentPermissions, - IProjectRolePermissions, -} from 'interfaces/project'; + IPermissions, +} from 'interfaces/permissions'; import { ICheckedPermission } from '../hooks/useProjectRoleForm'; interface IProjectRoleForm { @@ -21,7 +21,7 @@ interface IProjectRoleForm { errors: { [key: string]: string }; children: ReactNode; permissions: - | IProjectRolePermissions + | IPermissions | { project: IPermission[]; environments: IProjectEnvironmentPermissions[]; diff --git a/frontend/src/component/admin/projectRoles/hooks/useProjectRoleForm.ts b/frontend/src/component/admin/projectRoles/hooks/useProjectRoleForm.ts index 7e7008afaf3..27058c54480 100644 --- a/frontend/src/component/admin/projectRoles/hooks/useProjectRoleForm.ts +++ b/frontend/src/component/admin/projectRoles/hooks/useProjectRoleForm.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; -import { IPermission } from 'interfaces/project'; +import { IPermission } from 'interfaces/permissions'; import cloneDeep from 'lodash.clonedeep'; -import useProjectRolePermissions from 'hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions'; +import usePermissions from 'hooks/api/getters/usePermissions/usePermissions'; import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi'; import { formatUnknownError } from 'utils/formatUnknownError'; @@ -23,7 +23,7 @@ const useProjectRoleForm = ( initialRoleDesc = '', initialCheckedPermissions: IPermission[] = [] ) => { - const { permissions } = useProjectRolePermissions({ + const { permissions } = usePermissions({ revalidateIfStale: false, revalidateOnReconnect: false, revalidateOnFocus: false, diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx index b76125401db..82eb34b7369 100644 --- a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx +++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx @@ -33,6 +33,11 @@ import { useServiceAccountTokensApi } from 'hooks/api/actions/useServiceAccountT import { INewPersonalAPIToken } from 'interfaces/personalAPIToken'; import { ServiceAccountTokens } from './ServiceAccountTokens/ServiceAccountTokens'; import { IServiceAccount } from 'interfaces/service-account'; +import { PermissionAccordion } from 'component/admin/projectRoles/ProjectRoleForm/PermissionAccordion/PermissionAccordion'; +import { Person as UserIcon } from '@mui/icons-material'; +import usePermissions from 'hooks/api/getters/usePermissions/usePermissions'; +import { ICheckedPermissions, IPermission } from 'interfaces/permissions'; +import cloneDeep from 'lodash.clonedeep'; const StyledForm = styled('form')(() => ({ display: 'flex', @@ -128,6 +133,7 @@ export const ServiceAccountModal = ({ const { addServiceAccount, updateServiceAccount, loading } = useServiceAccountsApi(); const { createServiceAccountToken } = useServiceAccountTokensApi(); + const { permissions } = usePermissions(); const { setToastData, setToastApiError } = useToast(); const { uiConfig } = useUiConfig(); @@ -154,6 +160,12 @@ export const ServiceAccountModal = ({ calculateExpirationDate(DEFAULT_EXPIRATION) ); const [patErrors, setPatErrors] = useState({}); + const [checkedPermissions, setCheckedPermissions] = + useState({}); + + const granularPermissions = permissions.root.filter( + ({ name }) => name !== 'ADMIN' + ); const editing = serviceAccount !== undefined; @@ -174,6 +186,7 @@ export const ServiceAccountModal = ({ name, username, rootRole, + permissions: Object.values(checkedPermissions), }); const handleSubmit = async (e: FormEvent) => { @@ -258,6 +271,40 @@ export const ServiceAccountModal = ({ setUsername(username); }; + const handlePermissionChange = (permission: IPermission) => { + let checkedPermissionsCopy = cloneDeep(checkedPermissions); + + if (checkedPermissionsCopy[permission.id]) { + delete checkedPermissionsCopy[permission.id]; + } else { + checkedPermissionsCopy[permission.id] = { ...permission }; + } + + setCheckedPermissions(checkedPermissionsCopy); + }; + + const onToggleAllPermissions = () => { + let checkedPermissionsCopy = cloneDeep(checkedPermissions); + + const allChecked = granularPermissions.every( + (permission: IPermission) => checkedPermissionsCopy[permission.id] + ); + + if (allChecked) { + granularPermissions.forEach((permission: IPermission) => { + delete checkedPermissionsCopy[permission.id]; + }); + } else { + granularPermissions.forEach((permission: IPermission) => { + checkedPermissionsCopy[permission.id] = { + ...permission, + }; + }); + } + + setCheckedPermissions(checkedPermissionsCopy); + }; + return ( + + Would you like to specify extra granular + permissions? + + } + permissions={granularPermissions} + checkedPermissions={checkedPermissions} + onPermissionChange={(permission: IPermission) => + handlePermissionChange(permission) + } + onCheckAll={onToggleAllPermissions} + getRoleKey={(permission: { + id: number; + environment?: string; + }) => permission.id.toString()} + context="root" + /> { const { environments } = useEnvironments(); const canCreateMoreEnvs = environments.length < ENV_LIMIT; const { createEnvironment, loading } = useEnvironmentApi(); - const { refetch } = useProjectRolePermissions(); + const { refetch } = usePermissions(); const { name, setName, diff --git a/frontend/src/component/environments/EditEnvironment/EditEnvironment.tsx b/frontend/src/component/environments/EditEnvironment/EditEnvironment.tsx index e38872ca2ce..d9bcb6c7b68 100644 --- a/frontend/src/component/environments/EditEnvironment/EditEnvironment.tsx +++ b/frontend/src/component/environments/EditEnvironment/EditEnvironment.tsx @@ -2,7 +2,7 @@ import FormTemplate from 'component/common/FormTemplate/FormTemplate'; import { UpdateButton } from 'component/common/UpdateButton/UpdateButton'; import useEnvironmentApi from 'hooks/api/actions/useEnvironmentApi/useEnvironmentApi'; import useEnvironment from 'hooks/api/getters/useEnvironment/useEnvironment'; -import useProjectRolePermissions from 'hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions'; +import usePermissions from 'hooks/api/getters/usePermissions/usePermissions'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useToast from 'hooks/useToast'; import { useNavigate } from 'react-router-dom'; @@ -23,7 +23,7 @@ const EditEnvironment = () => { const navigate = useNavigate(); const { name, type, setName, setType, errors, clearErrors } = useEnvironmentForm(environment.name, environment.type); - const { refetch } = useProjectRolePermissions(); + const { refetch } = usePermissions(); const editPayload = () => { return { diff --git a/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentActionCell.tsx b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentActionCell.tsx index 3a55d20f233..ec190151be0 100644 --- a/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentActionCell.tsx +++ b/frontend/src/component/environments/EnvironmentTable/EnvironmentActionCell/EnvironmentActionCell.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import { IEnvironment } from 'interfaces/environments'; import { formatUnknownError } from 'utils/formatUnknownError'; import useEnvironmentApi from 'hooks/api/actions/useEnvironmentApi/useEnvironmentApi'; -import useProjectRolePermissions from 'hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions'; +import usePermissions from 'hooks/api/getters/usePermissions/usePermissions'; import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments'; import useToast from 'hooks/useToast'; import { EnvironmentActionCellPopover } from './EnvironmentActionCellPopover/EnvironmentActionCellPopover'; @@ -25,7 +25,7 @@ export const EnvironmentActionCell = ({ const navigate = useNavigate(); const { setToastApiError, setToastData } = useToast(); const { environments, refetchEnvironments } = useEnvironments(); - const { refetch: refetchPermissions } = useProjectRolePermissions(); + const { refetch: refetchPermissions } = usePermissions(); const { deleteEnvironment, toggleEnvironmentOn, toggleEnvironmentOff } = useEnvironmentApi(); diff --git a/frontend/src/hooks/api/actions/useProjectRolesApi/useProjectRolesApi.ts b/frontend/src/hooks/api/actions/useProjectRolesApi/useProjectRolesApi.ts index 8fad233e393..c3c003d747c 100644 --- a/frontend/src/hooks/api/actions/useProjectRolesApi/useProjectRolesApi.ts +++ b/frontend/src/hooks/api/actions/useProjectRolesApi/useProjectRolesApi.ts @@ -1,4 +1,4 @@ -import { IPermission } from 'interfaces/project'; +import { IPermission } from 'interfaces/permissions'; import useAPI from '../useApi/useApi'; interface ICreateRolePayload { diff --git a/frontend/src/hooks/api/actions/useServiceAccountsApi/useServiceAccountsApi.ts b/frontend/src/hooks/api/actions/useServiceAccountsApi/useServiceAccountsApi.ts index 693e607e7cd..3006a1aa106 100644 --- a/frontend/src/hooks/api/actions/useServiceAccountsApi/useServiceAccountsApi.ts +++ b/frontend/src/hooks/api/actions/useServiceAccountsApi/useServiceAccountsApi.ts @@ -1,9 +1,11 @@ +import { IPermission } from 'interfaces/permissions'; import useAPI from '../useApi/useApi'; export interface IServiceAccountPayload { name: string; username: string; rootRole: number; + permissions: IPermission[]; } export const useServiceAccountsApi = () => { diff --git a/frontend/src/hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions.ts b/frontend/src/hooks/api/getters/usePermissions/usePermissions.ts similarity index 71% rename from frontend/src/hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions.ts rename to frontend/src/hooks/api/getters/usePermissions/usePermissions.ts index 3b4eff9f577..c876ac7a541 100644 --- a/frontend/src/hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions.ts +++ b/frontend/src/hooks/api/getters/usePermissions/usePermissions.ts @@ -4,15 +4,16 @@ import { formatApiPath } from 'utils/formatPath'; import { IProjectEnvironmentPermissions, - IProjectRolePermissions, + IPermissions, IPermission, -} from 'interfaces/project'; +} from 'interfaces/permissions'; import handleErrorResponses from '../httpErrorResponseHandler'; -interface IUseProjectRolePermissions { +interface IUsePermissions { permissions: - | IProjectRolePermissions + | IPermissions | { + root: IPermission[]; project: IPermission[]; environments: IProjectEnvironmentPermissions[]; }; @@ -21,9 +22,7 @@ interface IUseProjectRolePermissions { error: any; } -const useProjectRolePermissions = ( - options: SWRConfiguration = {} -): IUseProjectRolePermissions => { +const usePermissions = (options: SWRConfiguration = {}): IUsePermissions => { const fetcher = () => { const path = formatApiPath(`api/admin/permissions`); return fetch(path, { @@ -35,7 +34,7 @@ const useProjectRolePermissions = ( const KEY = `api/admin/permissions`; - const { data, error } = useSWR<{ permissions: IProjectRolePermissions }>( + const { data, error } = useSWR<{ permissions: IPermissions }>( KEY, fetcher, options @@ -51,11 +50,15 @@ const useProjectRolePermissions = ( }, [data, error]); return { - permissions: data?.permissions || { project: [], environments: [] }, + permissions: data?.permissions || { + root: [], + project: [], + environments: [], + }, error, loading, refetch, }; }; -export default useProjectRolePermissions; +export default usePermissions; diff --git a/frontend/src/interfaces/permissions.ts b/frontend/src/interfaces/permissions.ts new file mode 100644 index 00000000000..baf43dbf841 --- /dev/null +++ b/frontend/src/interfaces/permissions.ts @@ -0,0 +1,21 @@ +export interface IPermission { + id: number; + name: string; + displayName: string; + environment?: string; +} + +export interface IPermissions { + root: IPermission[]; + project: IPermission[]; + environments: IProjectEnvironmentPermissions[]; +} + +export interface IProjectEnvironmentPermissions { + name: string; + permissions: IPermission[]; +} + +export interface ICheckedPermissions { + [key: string]: IPermission; +} diff --git a/frontend/src/interfaces/project.ts b/frontend/src/interfaces/project.ts index bfbf44fd8f5..2b5aa92ece0 100644 --- a/frontend/src/interfaces/project.ts +++ b/frontend/src/interfaces/project.ts @@ -34,20 +34,3 @@ export interface IProjectHealthReport extends IProject { activeCount: number; updatedAt: string; } - -export interface IPermission { - id: number; - name: string; - displayName: string; - environment?: string; -} - -export interface IProjectRolePermissions { - project: IPermission[]; - environments: IProjectEnvironmentPermissions[]; -} - -export interface IProjectEnvironmentPermissions { - name: string; - permissions: IPermission[]; -} diff --git a/src/lib/db/access-store.ts b/src/lib/db/access-store.ts index cf7c3c5f66b..4755ceb71f7 100644 --- a/src/lib/db/access-store.ts +++ b/src/lib/db/access-store.ts @@ -101,6 +101,7 @@ export class AccessStore implements IAccessStore { .select(['id', 'permission', 'type', 'display_name']) .where('type', 'project') .orWhere('type', 'environment') + .orWhere('type', 'root') .from(`${T.PERMISSIONS} as p`); return rows.map(this.mapPermission); } @@ -172,7 +173,7 @@ export class AccessStore implements IAccessStore { } mapUserPermission(row: IPermissionRow): IUserPermission { - let project: string = undefined; + let project: string | undefined = undefined; // Since the editor should have access to the default project, // we map the project to the project and environment specific // permissions that are connected to the editor role. diff --git a/src/lib/services/access-service.ts b/src/lib/services/access-service.ts index 91e491ffd47..1e2d7171107 100644 --- a/src/lib/services/access-service.ts +++ b/src/lib/services/access-service.ts @@ -158,6 +158,10 @@ export class AccessService { const bindablePermissions = await this.store.getAvailablePermissions(); const environments = await this.environmentStore.getAll(); + const rootPermissions = bindablePermissions.filter( + ({ type }) => type === 'root', + ); + const projectPermissions = bindablePermissions.filter((x) => { return x.type === 'project'; }); @@ -176,6 +180,7 @@ export class AccessService { }); return { + root: rootPermissions, project: projectPermissions, environments: allEnvironmentPermissions, }; diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index a7a3f781383..337fc80acda 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -272,6 +272,7 @@ export interface IRoleData { } export interface IAvailablePermissions { + root: IPermission[]; project: IPermission[]; environments: IEnvironmentPermission[]; } From f5f13d43523ad6e0acf2e2f35aab772cb270f9f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Tue, 6 Jun 2023 15:15:53 +0100 Subject: [PATCH 02/11] feat: basic roles skeleton --- frontend/src/component/admin/Admin.tsx | 4 +- .../src/component/admin/menu/AdminMenu.tsx | 12 +- frontend/src/component/admin/roles/Roles.tsx | 20 ++ .../RolesActionsCell/RolesActionsCell.tsx | 45 ++++ .../admin/roles/RolesTable/RolesTable.tsx | 229 ++++++++++++++++++ .../ServiceAccountModal.tsx | 81 +++---- frontend/src/component/menu/routes.ts | 6 + .../useServiceAccountsApi.ts | 2 - frontend/src/interfaces/uiConfig.ts | 1 + src/lib/types/experimental.ts | 7 +- 10 files changed, 361 insertions(+), 46 deletions(-) create mode 100644 frontend/src/component/admin/roles/Roles.tsx create mode 100644 frontend/src/component/admin/roles/RolesTable/RolesActionsCell/RolesActionsCell.tsx create mode 100644 frontend/src/component/admin/roles/RolesTable/RolesTable.tsx diff --git a/frontend/src/component/admin/Admin.tsx b/frontend/src/component/admin/Admin.tsx index 9ad0780bde3..6f8e6de0788 100644 --- a/frontend/src/component/admin/Admin.tsx +++ b/frontend/src/component/admin/Admin.tsx @@ -15,6 +15,7 @@ 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'; @@ -42,7 +43,8 @@ export const Admin = () => ( element={} /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/component/admin/menu/AdminMenu.tsx b/frontend/src/component/admin/menu/AdminMenu.tsx index 54015a225fd..5c3e5097e37 100644 --- a/frontend/src/component/admin/menu/AdminMenu.tsx +++ b/frontend/src/component/admin/menu/AdminMenu.tsx @@ -55,11 +55,21 @@ function AdminMenu() { } /> )} - {flags.RE && ( + {flags.customRootRoles && ( + Roles + + } + /> + )} + {flags.RE && ( + Project roles } diff --git a/frontend/src/component/admin/roles/Roles.tsx b/frontend/src/component/admin/roles/Roles.tsx new file mode 100644 index 00000000000..2fea3bfd386 --- /dev/null +++ b/frontend/src/component/admin/roles/Roles.tsx @@ -0,0 +1,20 @@ +import { useContext } from 'react'; +import AccessContext from 'contexts/AccessContext'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { RolesTable } from './RolesTable/RolesTable'; +import { AdminAlert } from 'component/common/AdminAlert/AdminAlert'; + +export const Roles = () => { + const { hasAccess } = useContext(AccessContext); + + return ( +
+ } + elseShow={} + /> +
+ ); +}; diff --git a/frontend/src/component/admin/roles/RolesTable/RolesActionsCell/RolesActionsCell.tsx b/frontend/src/component/admin/roles/RolesTable/RolesActionsCell/RolesActionsCell.tsx new file mode 100644 index 00000000000..43dfdedde69 --- /dev/null +++ b/frontend/src/component/admin/roles/RolesTable/RolesActionsCell/RolesActionsCell.tsx @@ -0,0 +1,45 @@ +import { Delete, Edit } from '@mui/icons-material'; +import { Box, styled } from '@mui/material'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { VFC } from 'react'; + +const StyledBox = styled(Box)(() => ({ + display: 'flex', + justifyContent: 'center', +})); + +interface IRolesActionsCellProps { + onEdit: (event: React.SyntheticEvent) => void; + onDelete: (event: React.SyntheticEvent) => void; +} + +export const RolesActionsCell: VFC = ({ + onEdit, + onDelete, +}) => { + return ( + + + + + + + + + ); +}; diff --git a/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx b/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx new file mode 100644 index 00000000000..980e9d75312 --- /dev/null +++ b/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx @@ -0,0 +1,229 @@ +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 useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { Button, useMediaQuery } from '@mui/material'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { useFlexLayout, useSortBy, useTable } from 'react-table'; +import { sortTypes } from 'utils/sortTypes'; +import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; +import theme from 'themes/theme'; +import { Search } from 'component/common/Search/Search'; +import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; +import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; +import { useSearch } from 'hooks/useSearch'; +import { INewPersonalAPIToken } from 'interfaces/personalAPIToken'; +import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; +import { IServiceAccount } from 'interfaces/service-account'; +import { IconCell } from 'component/common/Table/cells/IconCell/IconCell'; +import { SupervisedUserCircle } from '@mui/icons-material'; +import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts'; +import { RolesActionsCell } from './RolesActionsCell/RolesActionsCell'; + +export const RolesTable = () => { + const { setToastData, setToastApiError } = useToast(); + + const { roles, refetch, loading } = useServiceAccounts(); // create useRoles() hook + // const { removeRole } = useRolesApi(); + + const [searchValue, setSearchValue] = useState(''); + const [modalOpen, setModalOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [selectedRole, setSelectedRole] = useState(); + + const onDeleteConfirm = async (role: IRole) => { + try { + // await removeRole(role.id); + setToastData({ + title: `${role.name} has been deleted`, + type: 'success', + }); + refetch(); + setDeleteOpen(false); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + // const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + + const columns = useMemo( + () => [ + { + id: 'Icon', + Cell: () => ( + } + /> + ), + disableGlobalFilter: true, + maxWidth: 50, + }, + { + Header: 'Role', + accessor: 'name', + Cell: HighlightCell, + searchable: true, + }, + { + Header: 'Description', + accessor: 'description', + Cell: HighlightCell, + minWidth: 200, + searchable: true, + }, + { + Header: 'Actions', + id: 'Actions', + align: 'center', + Cell: ({ row: { original: role } }: any) => ( + { + setSelectedRole(role); + setModalOpen(true); + }} + onDelete={() => { + setSelectedRole(role); + setDeleteOpen(true); + }} + /> + ), + width: 150, + disableSortBy: true, + }, + ], + [roles] + ); + + const [initialState] = useState({ + sortBy: [{ id: 'name' }], + }); + + const { data, getSearchText } = useSearch(columns, searchValue, roles); + + const { headerGroups, rows, prepareRow, setHiddenColumns } = useTable( + { + columns: columns as any, + data, + initialState, + sortTypes, + autoResetHiddenColumns: false, + autoResetSortBy: false, + disableSortRemove: true, + disableMultiSort: true, + defaultColumn: { + Cell: TextCell, + }, + }, + useSortBy, + useFlexLayout + ); + + // useConditionallyHiddenColumns( + // [ + // { + // condition: isExtraSmallScreen, + // columns: ['role', 'seenAt'], + // }, + // { + // condition: isSmallScreen, + // columns: ['imageUrl', 'tokens', 'createdAt'], + // }, + // ], + // setHiddenColumns, + // columns + // ); + + return ( + + + + + + } + /> + + + } + > + + } + /> + + } + > + + + + 0} + show={ + + No roles found matching “ + {searchValue} + ” + + } + elseShow={ + + No roles available. Get started by adding one. + + } + /> + } + /> + {/* + */} + + ); +}; diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx index 82eb34b7369..b36d78d5ff0 100644 --- a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx +++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx @@ -33,11 +33,11 @@ import { useServiceAccountTokensApi } from 'hooks/api/actions/useServiceAccountT import { INewPersonalAPIToken } from 'interfaces/personalAPIToken'; import { ServiceAccountTokens } from './ServiceAccountTokens/ServiceAccountTokens'; import { IServiceAccount } from 'interfaces/service-account'; -import { PermissionAccordion } from 'component/admin/projectRoles/ProjectRoleForm/PermissionAccordion/PermissionAccordion'; -import { Person as UserIcon } from '@mui/icons-material'; -import usePermissions from 'hooks/api/getters/usePermissions/usePermissions'; -import { ICheckedPermissions, IPermission } from 'interfaces/permissions'; -import cloneDeep from 'lodash.clonedeep'; +// import { PermissionAccordion } from 'component/admin/projectRoles/ProjectRoleForm/PermissionAccordion/PermissionAccordion'; +// import { Person as UserIcon } from '@mui/icons-material'; +// import usePermissions from 'hooks/api/getters/usePermissions/usePermissions'; +// import { ICheckedPermissions, IPermission } from 'interfaces/permissions'; +// import cloneDeep from 'lodash.clonedeep'; const StyledForm = styled('form')(() => ({ display: 'flex', @@ -133,7 +133,7 @@ export const ServiceAccountModal = ({ const { addServiceAccount, updateServiceAccount, loading } = useServiceAccountsApi(); const { createServiceAccountToken } = useServiceAccountTokensApi(); - const { permissions } = usePermissions(); + // const { permissions } = usePermissions(); const { setToastData, setToastApiError } = useToast(); const { uiConfig } = useUiConfig(); @@ -160,12 +160,12 @@ export const ServiceAccountModal = ({ calculateExpirationDate(DEFAULT_EXPIRATION) ); const [patErrors, setPatErrors] = useState({}); - const [checkedPermissions, setCheckedPermissions] = - useState({}); + // const [checkedPermissions, setCheckedPermissions] = + // useState({}); - const granularPermissions = permissions.root.filter( - ({ name }) => name !== 'ADMIN' - ); + // const granularPermissions = permissions.root.filter( + // ({ name }) => name !== 'ADMIN' + // ); const editing = serviceAccount !== undefined; @@ -186,7 +186,6 @@ export const ServiceAccountModal = ({ name, username, rootRole, - permissions: Object.values(checkedPermissions), }); const handleSubmit = async (e: FormEvent) => { @@ -271,39 +270,39 @@ export const ServiceAccountModal = ({ setUsername(username); }; - const handlePermissionChange = (permission: IPermission) => { - let checkedPermissionsCopy = cloneDeep(checkedPermissions); + // const handlePermissionChange = (permission: IPermission) => { + // let checkedPermissionsCopy = cloneDeep(checkedPermissions); - if (checkedPermissionsCopy[permission.id]) { - delete checkedPermissionsCopy[permission.id]; - } else { - checkedPermissionsCopy[permission.id] = { ...permission }; - } + // if (checkedPermissionsCopy[permission.id]) { + // delete checkedPermissionsCopy[permission.id]; + // } else { + // checkedPermissionsCopy[permission.id] = { ...permission }; + // } - setCheckedPermissions(checkedPermissionsCopy); - }; + // setCheckedPermissions(checkedPermissionsCopy); + // }; - const onToggleAllPermissions = () => { - let checkedPermissionsCopy = cloneDeep(checkedPermissions); + // const onToggleAllPermissions = () => { + // let checkedPermissionsCopy = cloneDeep(checkedPermissions); - const allChecked = granularPermissions.every( - (permission: IPermission) => checkedPermissionsCopy[permission.id] - ); + // const allChecked = granularPermissions.every( + // (permission: IPermission) => checkedPermissionsCopy[permission.id] + // ); - if (allChecked) { - granularPermissions.forEach((permission: IPermission) => { - delete checkedPermissionsCopy[permission.id]; - }); - } else { - granularPermissions.forEach((permission: IPermission) => { - checkedPermissionsCopy[permission.id] = { - ...permission, - }; - }); - } + // if (allChecked) { + // granularPermissions.forEach((permission: IPermission) => { + // delete checkedPermissionsCopy[permission.id]; + // }); + // } else { + // granularPermissions.forEach((permission: IPermission) => { + // checkedPermissionsCopy[permission.id] = { + // ...permission, + // }; + // }); + // } - setCheckedPermissions(checkedPermissionsCopy); - }; + // setCheckedPermissions(checkedPermissionsCopy); + // }; return ( - + {/* Would you like to specify extra granular permissions? @@ -404,7 +403,7 @@ export const ServiceAccountModal = ({ environment?: string; }) => permission.id.toString()} context="root" - /> + /> */} { diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index f86e804b5b0..c7cb6ab6b1b 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -54,6 +54,7 @@ export interface IFlags { segmentContextFieldUsage?: boolean; disableNotifications?: boolean; advancedPlayground?: boolean; + customRootRoles?: boolean; } export interface IVersionInfo { diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index e6995dafec6..f26d7953413 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -25,7 +25,8 @@ export type IFlagKey = | 'experimentalExtendedTelemetry' | 'segmentContextFieldUsage' | 'disableNotifications' - | 'advancedPlayground'; + | 'advancedPlayground' + | 'customRootRoles'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -118,6 +119,10 @@ const flags: IFlags = { process.env.ADVANCED_PLAYGROUND, false, ), + customRootRoles: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_CUSTOM_ROOT_ROLES, + false, + ), }; export const defaultExperimentalOptions: IExperimentalOptions = { From d18628b70ef9a12fdc9e34e115cbf1409f27a1db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Wed, 7 Jun 2023 16:17:17 +0100 Subject: [PATCH 03/11] wip: custom root roles --- .../admin/roles/RoleForm/RoleForm.tsx | 0 .../admin/roles/RoleForm/useRoleForm.ts | 172 ++++++++++ .../admin/roles/RoleModal/RoleModal.tsx | 317 ++++++++++++++++++ .../RoleDeleteDialog/RoleDeleteDialog.tsx | 107 ++++++ .../RoleDeleteDialogServiceAccounts.tsx | 114 +++++++ .../RoleDeleteDialogUsers.tsx | 94 ++++++ .../RolesActionsCell/RolesActionsCell.tsx | 17 +- .../roles/RolesTable/RolesCell/RolesCell.tsx | 26 ++ .../admin/roles/RolesTable/RolesTable.tsx | 50 +-- .../ServiceAccountTokensCell.tsx | 7 +- .../cells/HighlightCell/HighlightCell.tsx | 3 + .../hooks/api/getters/useUsers/useUsers.ts | 12 +- 12 files changed, 890 insertions(+), 29 deletions(-) create mode 100644 frontend/src/component/admin/roles/RoleForm/RoleForm.tsx create mode 100644 frontend/src/component/admin/roles/RoleForm/useRoleForm.ts create mode 100644 frontend/src/component/admin/roles/RoleModal/RoleModal.tsx create mode 100644 frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialog.tsx create mode 100644 frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogServiceAccounts/RoleDeleteDialogServiceAccounts.tsx create mode 100644 frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogUsers/RoleDeleteDialogUsers.tsx create mode 100644 frontend/src/component/admin/roles/RolesTable/RolesCell/RolesCell.tsx diff --git a/frontend/src/component/admin/roles/RoleForm/RoleForm.tsx b/frontend/src/component/admin/roles/RoleForm/RoleForm.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts b/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts new file mode 100644 index 00000000000..0b2ecbff1dd --- /dev/null +++ b/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts @@ -0,0 +1,172 @@ +import { useEffect, useState } from 'react'; +import { IPermission, ICheckedPermissions } from 'interfaces/permissions'; +import cloneDeep from 'lodash.clonedeep'; +import usePermissions from 'hooks/api/getters/usePermissions/usePermissions'; +import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi'; +import { formatUnknownError } from 'utils/formatUnknownError'; + +export const useRoleForm = ( + 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[curr.id] = curr; + return acc; + }, + {} + ) + ); + } + }, [initialCheckedPermissions?.length]); + + const [errors, setErrors] = useState({}); + + const { validateRole } = useProjectRolesApi(); + + 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, + }; +}; diff --git a/frontend/src/component/admin/roles/RoleModal/RoleModal.tsx b/frontend/src/component/admin/roles/RoleModal/RoleModal.tsx new file mode 100644 index 00000000000..d156889997f --- /dev/null +++ b/frontend/src/component/admin/roles/RoleModal/RoleModal.tsx @@ -0,0 +1,317 @@ +import { Button, FormControlLabel, Radio, styled } from '@mui/material'; +import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; +import Input from 'component/common/Input/Input'; +import IRole from 'interfaces/role'; +import { useRoleForm } from '../RoleForm/useRoleForm'; + +const StyledForm = styled('form')(() => ({ + display: 'flex', + flexDirection: 'column', + height: '100%', +})); + +const StyledInputDescription = styled('p')(({ theme }) => ({ + display: 'flex', + color: theme.palette.text.primary, + marginBottom: theme.spacing(1), + '&:not(:first-of-type)': { + marginTop: theme.spacing(4), + }, +})); + +const StyledInputSecondaryDescription = styled('p')(({ theme }) => ({ + color: theme.palette.text.secondary, + marginBottom: theme.spacing(1), +})); + +const StyledInput = styled(Input)(({ theme }) => ({ + width: '100%', + maxWidth: theme.spacing(50), +})); + +const StyledRoleBox = styled(FormControlLabel)(({ theme }) => ({ + margin: theme.spacing(0.5, 0), + border: `1px solid ${theme.palette.divider}`, + padding: theme.spacing(2), +})); + +const StyledRoleRadio = styled(Radio)(({ theme }) => ({ + marginRight: theme.spacing(2), +})); + +const StyledSecondaryContainer = styled('div')(({ theme }) => ({ + padding: theme.spacing(3), + backgroundColor: theme.palette.background.elevation2, + borderRadius: theme.shape.borderRadiusMedium, + marginTop: theme.spacing(4), + marginBottom: theme.spacing(2), +})); + +const StyledInlineContainer = styled('div')(({ theme }) => ({ + padding: theme.spacing(0, 4), + '& > p:not(:first-of-type)': { + marginTop: theme.spacing(2), + }, +})); + +const StyledButtonContainer = styled('div')(({ theme }) => ({ + marginTop: 'auto', + display: 'flex', + justifyContent: 'flex-end', + paddingTop: theme.spacing(4), +})); + +const StyledCancelButton = styled(Button)(({ theme }) => ({ + marginLeft: theme.spacing(3), +})); + +enum TokenGeneration { + LATER = 'later', + NOW = 'now', +} + +enum ErrorField { + USERNAME = 'username', +} + +interface IServiceAccountModalErrors { + [ErrorField.USERNAME]?: string; +} + +interface IRoleModalProps { + role?: IRole; + open: boolean; + setOpen: React.Dispatch>; +} + +export const RoleModal = ({ role, open, setOpen }: IRoleModalProps) => { + // const { name, description, permissions, getRolePayload } = useRoleForm(); + + // const handlePermissionChange = (permission: IPermission) => { + // let checkedPermissionsCopy = cloneDeep(checkedPermissions); + + // if (checkedPermissionsCopy[permission.id]) { + // delete checkedPermissionsCopy[permission.id]; + // } else { + // checkedPermissionsCopy[permission.id] = { ...permission }; + // } + + // setCheckedPermissions(checkedPermissionsCopy); + // }; + + // const onToggleAllPermissions = () => { + // let checkedPermissionsCopy = cloneDeep(checkedPermissions); + + // const allChecked = granularPermissions.every( + // (permission: IPermission) => checkedPermissionsCopy[permission.id] + // ); + + // if (allChecked) { + // granularPermissions.forEach((permission: IPermission) => { + // delete checkedPermissionsCopy[permission.id]; + // }); + // } else { + // granularPermissions.forEach((permission: IPermission) => { + // checkedPermissionsCopy[permission.id] = { + // ...permission, + // }; + // }); + // } + + // setCheckedPermissions(checkedPermissionsCopy); + // }; + + return ( + { + setOpen(false); + }} + label="TODO" + // label={editing ? 'Edit service account' : 'New service account'} + > +
TODO: Use RoleForm
+ {/* + +
+ + What is your new service account name? + + setName(e.target.value)} + onBlur={suggestUsername} + autoComplete="off" + required + /> + + What is your new service account username? + + onSetUsername(e.target.value)} + autoComplete="off" + required + disabled={editing} + /> + + What is your service account allowed to do? + + + setRootRole(+e.target.value)} + data-loading + > + {roles + .sort((a, b) => (a.name < b.name ? -1 : 1)) + .map(role => ( + + {role.name} + + {role.description} + +
+ } + control={ + + } + value={role.id} + /> + ))} + + + + + Token + + + In order to connect your newly created + service account, you will also need a + token.{' '} + + Read more about API tokens + + . + + + + setTokenGeneration( + e.target + .value as TokenGeneration + ) + } + name="token-generation" + > + } + label="I want to generate a token later" + /> + } + label="Generate a token now" + /> + + + + + A new personal access token (PAT) + will be generated for the service + account, so you can get started + right away. + + + } + /> + + + } + elseShow={ + <> + + Service account tokens + + + + } + /> + + + + + { + setOpen(false); + }} + > + Cancel + + +
+
*/} +
+ ); +}; diff --git a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialog.tsx b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialog.tsx new file mode 100644 index 00000000000..ed5c49d6843 --- /dev/null +++ b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialog.tsx @@ -0,0 +1,107 @@ +import { Alert, styled } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts'; +import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; +import IRole from 'interfaces/role'; +import { RoleDeleteDialogUsers } from './RoleDeleteDialogUsers/RoleDeleteDialogUsers'; +import { RoleDeleteDialogServiceAccounts } from './RoleDeleteDialogServiceAccounts/RoleDeleteDialogServiceAccounts'; + +const StyledTableContainer = styled('div')(({ theme }) => ({ + marginTop: theme.spacing(1.5), +})); + +const StyledLabel = styled('p')(({ theme }) => ({ + marginTop: theme.spacing(3), +})); + +interface IRoleDeleteDialogProps { + role?: IRole; + open: boolean; + setOpen: React.Dispatch>; + onConfirm: (role: IRole) => void; +} + +export const RoleDeleteDialog = ({ + role, + open, + setOpen, + onConfirm, +}: IRoleDeleteDialogProps) => { + const { users } = useUsers(); + const { serviceAccounts } = useServiceAccounts(); + + const roleUsers = users.filter(({ rootRole }) => rootRole === role?.id); + const roleServiceAccounts = serviceAccounts.filter( + ({ rootRole }) => rootRole === role?.id + ); + + const deleteMessage = ( + <> + You are about to delete role: {role?.name} + + ); + + return ( + onConfirm(role!)} + onClose={() => { + setOpen(false); + }} + > + + + If you delete this role, all current accounts + associated with it will be automatically assigned + the preconfigured Viewer role. + + {deleteMessage} + + + Users ({roleUsers.length}): + + + + + + } + /> + + + Service accounts ( + {roleServiceAccounts.length}): + + + + + + } + /> + + } + elseShow={

{deleteMessage}

} + /> +
+ ); +}; diff --git a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogServiceAccounts/RoleDeleteDialogServiceAccounts.tsx b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogServiceAccounts/RoleDeleteDialogServiceAccounts.tsx new file mode 100644 index 00000000000..9906bd6fabe --- /dev/null +++ b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogServiceAccounts/RoleDeleteDialogServiceAccounts.tsx @@ -0,0 +1,114 @@ +import { VirtualizedTable } from 'component/common/Table'; +import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; +import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; +import { useMemo, useState } from 'react'; +import { useTable, useSortBy, useFlexLayout, Column } from 'react-table'; +import { sortTypes } from 'utils/sortTypes'; +import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; +import { IServiceAccount } from 'interfaces/service-account'; +import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts'; +import { ServiceAccountTokensCell } from 'component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokensCell/ServiceAccountTokensCell'; + +export type PageQueryType = Partial< + Record<'sort' | 'order' | 'search', string> +>; + +interface IRoleDeleteDialogServiceAccountsProps { + serviceAccounts: IServiceAccount[]; +} + +export const RoleDeleteDialogServiceAccounts = ({ + serviceAccounts, +}: IRoleDeleteDialogServiceAccountsProps) => { + const { roles } = useServiceAccounts(); + + const [initialState] = useState(() => ({ + sortBy: [{ id: 'seenAt' }], + })); + + const columns = useMemo( + () => + [ + { + id: 'name', + Header: 'Name', + accessor: (row: any) => row.name || '', + minWidth: 200, + Cell: ({ row: { original: serviceAccount } }: any) => ( + + ), + searchable: true, + }, + { + id: 'tokens', + Header: 'Tokens', + accessor: (row: IServiceAccount) => + row.tokens + ?.map(({ description }) => description) + .join('\n') || '', + Cell: ({ + row: { original: serviceAccount }, + value, + }: { + row: { original: IServiceAccount }; + value: string; + }) => ( + + ), + searchable: true, + maxWidth: 100, + }, + { + Header: 'Created', + accessor: 'createdAt', + Cell: DateCell, + sortType: 'date', + width: 120, + maxWidth: 120, + }, + { + id: 'seenAt', + Header: 'Last seen', + accessor: (row: IServiceAccount) => + row.tokens.sort((a, b) => { + const aSeenAt = new Date(a.seenAt || 0); + const bSeenAt = new Date(b.seenAt || 0); + return bSeenAt?.getTime() - aSeenAt?.getTime(); + })[0]?.seenAt, + Cell: TimeAgoCell, + sortType: 'date', + maxWidth: 150, + }, + ] as Column[], + [roles] + ); + + const { headerGroups, rows, prepareRow } = useTable( + { + columns, + data: serviceAccounts, + initialState, + sortTypes, + autoResetHiddenColumns: false, + autoResetSortBy: false, + disableSortRemove: true, + disableMultiSort: true, + }, + useSortBy, + useFlexLayout + ); + + return ( + + ); +}; diff --git a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogUsers/RoleDeleteDialogUsers.tsx b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogUsers/RoleDeleteDialogUsers.tsx new file mode 100644 index 00000000000..a23eb636dde --- /dev/null +++ b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogUsers/RoleDeleteDialogUsers.tsx @@ -0,0 +1,94 @@ +import { VirtualizedTable } from 'component/common/Table'; +import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; +import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; +import { useMemo, useState } from 'react'; +import { useTable, useSortBy, useFlexLayout, Column } from 'react-table'; +import { sortTypes } from 'utils/sortTypes'; +import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; +import { IUser } from 'interfaces/user'; +import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; +import { ServiceAccountTokensCell } from 'component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokensCell/ServiceAccountTokensCell'; + +export type PageQueryType = Partial< + Record<'sort' | 'order' | 'search', string> +>; + +interface IRoleDeleteDialogUsersProps { + users: IUser[]; +} + +export const RoleDeleteDialogUsers = ({ + users, +}: IRoleDeleteDialogUsersProps) => { + const { roles } = useUsers(); + + const [initialState] = useState(() => ({ + sortBy: [{ id: 'last-login' }], + })); + + const columns = useMemo( + () => + [ + { + id: 'name', + Header: 'Name', + accessor: (row: any) => row.name || '', + minWidth: 200, + Cell: ({ row: { original: user } }: any) => ( + + ), + searchable: true, + }, + { + Header: 'Created', + accessor: 'createdAt', + Cell: DateCell, + sortType: 'date', + width: 120, + maxWidth: 120, + }, + { + id: 'last-login', + Header: 'Last login', + accessor: (row: any) => row.seenAt || '', + Cell: ({ row: { original: user } }: any) => ( + `Last login: ${date}`} + /> + ), + disableGlobalFilter: true, + sortType: 'date', + maxWidth: 150, + }, + ] as Column[], + [roles] + ); + + const { headerGroups, rows, prepareRow } = useTable( + { + columns, + data: users, + initialState, + sortTypes, + autoResetHiddenColumns: false, + autoResetSortBy: false, + disableSortRemove: true, + disableMultiSort: true, + }, + useSortBy, + useFlexLayout + ); + + return ( + + ); +}; diff --git a/frontend/src/component/admin/roles/RolesTable/RolesActionsCell/RolesActionsCell.tsx b/frontend/src/component/admin/roles/RolesTable/RolesActionsCell/RolesActionsCell.tsx index 43dfdedde69..1353c20f436 100644 --- a/frontend/src/component/admin/roles/RolesTable/RolesActionsCell/RolesActionsCell.tsx +++ b/frontend/src/component/admin/roles/RolesTable/RolesActionsCell/RolesActionsCell.tsx @@ -2,6 +2,7 @@ import { Delete, Edit } from '@mui/icons-material'; import { Box, styled } from '@mui/material'; import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import IRole from 'interfaces/role'; import { VFC } from 'react'; const StyledBox = styled(Box)(() => ({ @@ -9,23 +10,32 @@ const StyledBox = styled(Box)(() => ({ justifyContent: 'center', })); +const DEFAULT_ROOT_ROLE = 'root'; + interface IRolesActionsCellProps { + role: IRole; onEdit: (event: React.SyntheticEvent) => void; onDelete: (event: React.SyntheticEvent) => void; } export const RolesActionsCell: VFC = ({ + role, onEdit, onDelete, }) => { + const defaultRole = role.type === DEFAULT_ROOT_ROLE; + return ( @@ -34,8 +44,11 @@ export const RolesActionsCell: VFC = ({ data-loading onClick={onDelete} permission={ADMIN} + disabled={defaultRole} tooltipProps={{ - title: 'Remove role', + title: defaultRole + ? 'You cannot remove a predefined role' + : 'Remove role', }} > diff --git a/frontend/src/component/admin/roles/RolesTable/RolesCell/RolesCell.tsx b/frontend/src/component/admin/roles/RolesTable/RolesCell/RolesCell.tsx new file mode 100644 index 00000000000..fda9123324a --- /dev/null +++ b/frontend/src/component/admin/roles/RolesTable/RolesCell/RolesCell.tsx @@ -0,0 +1,26 @@ +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +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'; + +const StyledBadge = styled(Badge)(({ theme }) => ({ + marginLeft: theme.spacing(1), +})); + +interface IRolesCellProps { + role: IRole; +} + +export const RolesCell = ({ role }: IRolesCellProps) => ( + Predefined} + /> + } + /> +); diff --git a/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx b/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx index 980e9d75312..97827cae420 100644 --- a/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx +++ b/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx @@ -25,6 +25,8 @@ import { IconCell } from 'component/common/Table/cells/IconCell/IconCell'; import { SupervisedUserCircle } from '@mui/icons-material'; import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts'; import { RolesActionsCell } from './RolesActionsCell/RolesActionsCell'; +import { RolesCell } from './RolesCell/RolesCell'; +import { RoleDeleteDialog } from './RoleDeleteDialog/RoleDeleteDialog'; export const RolesTable = () => { const { setToastData, setToastApiError } = useToast(); @@ -69,15 +71,11 @@ export const RolesTable = () => { { Header: 'Role', accessor: 'name', - Cell: HighlightCell, - searchable: true, - }, - { - Header: 'Description', - accessor: 'description', - Cell: HighlightCell, - minWidth: 200, + Cell: ({ row: { original: role } }: any) => ( + + ), searchable: true, + minWidth: 100, }, { Header: 'Actions', @@ -85,6 +83,7 @@ export const RolesTable = () => { align: 'center', Cell: ({ row: { original: role } }: any) => ( { setSelectedRole(role); setModalOpen(true); @@ -98,12 +97,19 @@ export const RolesTable = () => { width: 150, disableSortBy: true, }, + // Always hidden -- for search + { + accessor: 'description', + Header: 'Description', + searchable: true, + }, ], [roles] ); const [initialState] = useState({ sortBy: [{ id: 'name' }], + hiddenColumns: ['description'], }); const { data, getSearchText } = useSearch(columns, searchValue, roles); @@ -126,20 +132,16 @@ export const RolesTable = () => { useFlexLayout ); - // useConditionallyHiddenColumns( - // [ - // { - // condition: isExtraSmallScreen, - // columns: ['role', 'seenAt'], - // }, - // { - // condition: isSmallScreen, - // columns: ['imageUrl', 'tokens', 'createdAt'], - // }, - // ], - // setHiddenColumns, - // columns - // ); + useConditionallyHiddenColumns( + [ + { + condition: isSmallScreen, + columns: ['Icon'], + }, + ], + setHiddenColumns, + columns + ); return ( { role={selectedRole} open={modalOpen} setOpen={setModalOpen} - /> + /> */} */} + /> ); }; diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokensCell/ServiceAccountTokensCell.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokensCell/ServiceAccountTokensCell.tsx index 40023a59562..e86111a90d4 100644 --- a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokensCell/ServiceAccountTokensCell.tsx +++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokensCell/ServiceAccountTokensCell.tsx @@ -14,7 +14,7 @@ const StyledItem = styled(Typography)(({ theme }) => ({ interface IServiceAccountTokensCellProps { serviceAccount: IServiceAccount; value: string; - onCreateToken: () => void; + onCreateToken?: () => void; } export const ServiceAccountTokensCell: VFC = ({ @@ -24,7 +24,10 @@ export const ServiceAccountTokensCell: VFC = ({ }) => { const { searchQuery } = useSearchHighlightContext(); - if (!serviceAccount.tokens || serviceAccount.tokens.length === 0) + if ( + onCreateToken && + (!serviceAccount.tokens || serviceAccount.tokens.length === 0) + ) return ; return ( diff --git a/frontend/src/component/common/Table/cells/HighlightCell/HighlightCell.tsx b/frontend/src/component/common/Table/cells/HighlightCell/HighlightCell.tsx index d7147ca4e6f..08cfcd3fdf4 100644 --- a/frontend/src/component/common/Table/cells/HighlightCell/HighlightCell.tsx +++ b/frontend/src/component/common/Table/cells/HighlightCell/HighlightCell.tsx @@ -7,6 +7,7 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit interface IHighlightCellProps { value: string; subtitle?: string; + afterTitle?: React.ReactNode; } const StyledContainer = styled(Box)(({ theme }) => ({ @@ -40,6 +41,7 @@ const StyledSubtitle = styled('span')(({ theme }) => ({ export const HighlightCell: VFC = ({ value, subtitle, + afterTitle, }) => { const { searchQuery } = useSearchHighlightContext(); @@ -53,6 +55,7 @@ export const HighlightCell: VFC = ({ data-loading > {value} + {afterTitle} { +interface IUseUsersOutput { + users: IUser[]; + roles: IRole[]; + loading: boolean; + refetch: () => void; + error?: Error; +} + +export const useUsers = (): IUseUsersOutput => { const { data, error, mutate } = useSWR( formatApiPath(`api/admin/user-admin`), fetcher From ff19f62b294b868db076e165d615f791b79d9673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Mon, 12 Jun 2023 21:03:41 +0100 Subject: [PATCH 04/11] refactor to useRoles hook, add rolemodal and roleform --- .../CreateProjectRole/CreateProjectRole.tsx | 8 +- .../EditProjectRole/EditProjectRole.tsx | 14 +- .../ProjectRoleList/ProjectRoleList.tsx | 16 +- .../projectRoles/hooks/useProjectRoleForm.ts | 4 +- .../admin/roles/RoleForm/RoleForm.tsx | 91 +++++ .../admin/roles/RoleForm/useRoleForm.ts | 235 +++++------- .../admin/roles/RoleModal/RoleModal.tsx | 354 +++++------------- .../admin/roles/RolesTable/RolesTable.tsx | 21 +- .../ServiceAccountModal.tsx | 66 ---- .../useProjectRolesApi/useProjectRolesApi.ts | 88 ----- .../api/actions/useRolesApi/useRolesApi.ts | 77 ++++ .../useProjectRoles/useProjectRoles.ts | 35 -- .../hooks/api/getters/useRoles/useRoles.ts | 37 ++ frontend/src/interfaces/role.ts | 3 + 14 files changed, 435 insertions(+), 614 deletions(-) delete mode 100644 frontend/src/hooks/api/actions/useProjectRolesApi/useProjectRolesApi.ts create mode 100644 frontend/src/hooks/api/actions/useRolesApi/useRolesApi.ts delete mode 100644 frontend/src/hooks/api/getters/useProjectRoles/useProjectRoles.ts create mode 100644 frontend/src/hooks/api/getters/useRoles/useRoles.ts diff --git a/frontend/src/component/admin/projectRoles/CreateProjectRole/CreateProjectRole.tsx b/frontend/src/component/admin/projectRoles/CreateProjectRole/CreateProjectRole.tsx index a0230edec34..5d19bfda384 100644 --- a/frontend/src/component/admin/projectRoles/CreateProjectRole/CreateProjectRole.tsx +++ b/frontend/src/component/admin/projectRoles/CreateProjectRole/CreateProjectRole.tsx @@ -1,5 +1,5 @@ import FormTemplate from 'component/common/FormTemplate/FormTemplate'; -import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi'; +import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi'; import { useNavigate } from 'react-router-dom'; import ProjectRoleForm from '../ProjectRoleForm/ProjectRoleForm'; import useProjectRoleForm from '../hooks/useProjectRoleForm'; @@ -33,7 +33,7 @@ const CreateProjectRole = () => { getRoleKey, } = useProjectRoleForm(); - const { createRole, loading } = useProjectRolesApi(); + const { addRole, loading } = useRolesApi(); const onSubmit = async (e: Event) => { e.preventDefault(); @@ -44,8 +44,8 @@ const CreateProjectRole = () => { if (validName && validPermissions) { const payload = getProjectRolePayload(); try { - await createRole(payload); - navigate('/admin/roles'); + await addRole(payload); + navigate('/admin/project-roles'); setToastData({ title: 'Project role created', text: 'Now you can start assigning your project roles to project members.', diff --git a/frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx b/frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx index ded2c0c11ca..17416c12e57 100644 --- a/frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx +++ b/frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx @@ -1,7 +1,7 @@ import FormTemplate from 'component/common/FormTemplate/FormTemplate'; import { UpdateButton } from 'component/common/UpdateButton/UpdateButton'; import { ADMIN } from 'component/providers/AccessProvider/permissions'; -import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi'; +import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi'; import useProjectRole from 'hooks/api/getters/useProjectRole/useProjectRole'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useToast from 'hooks/useToast'; @@ -15,8 +15,8 @@ import { GO_BACK } from 'constants/navigate'; const EditProjectRole = () => { const { uiConfig } = useUiConfig(); const { setToastData, setToastApiError } = useToast(); - const projectId = useRequiredPathParam('id'); - const { role } = useProjectRole(projectId); + const roleId = useRequiredPathParam('id'); + const { role } = useProjectRole(roleId); const navigate = useNavigate(); const { @@ -46,8 +46,8 @@ const EditProjectRole = () => { --data-raw '${JSON.stringify(getProjectRolePayload(), undefined, 2)}'`; }; - const { refetch } = useProjectRole(projectId); - const { editRole, loading } = useProjectRolesApi(); + const { refetch } = useProjectRole(roleId); + const { updateRole, loading } = useRolesApi(); const onSubmit = async (e: Event) => { e.preventDefault(); @@ -58,9 +58,9 @@ const EditProjectRole = () => { if (validName && validPermissions) { try { - await editRole(projectId, payload); + await updateRole(+roleId, payload); refetch(); - navigate('/admin/roles'); + navigate('/admin/project-roles'); setToastData({ type: 'success', title: 'Project role updated', diff --git a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx index 5c4d15c38b6..c254163e31a 100644 --- a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx +++ b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx @@ -9,9 +9,9 @@ import { } from 'component/common/Table'; import { useTable, useGlobalFilter, useSortBy } from 'react-table'; import { ADMIN } from 'component/providers/AccessProvider/permissions'; -import useProjectRoles from 'hooks/api/getters/useProjectRoles/useProjectRoles'; -import IRole, { IProjectRole } from 'interfaces/role'; -import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi'; +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'; @@ -30,19 +30,15 @@ import { IconCell } from 'component/common/Table/cells/IconCell/IconCell'; import { Search } from 'component/common/Search/Search'; import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; -const ROOTROLE = 'root'; const BUILTIN_ROLE_TYPE = 'project'; const ProjectRoleList = () => { const navigate = useNavigate(); - const { roles, refetch, loading } = useProjectRoles(); + const { projectRoles: data, refetch, loading } = useRoles(); const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); - const paginationFilter = (role: IRole) => role?.type !== ROOTROLE; - const data = roles.filter(paginationFilter); - - const { deleteRole } = useProjectRolesApi(); + const { removeRole } = useRolesApi(); const [currentRole, setCurrentRole] = useState(null); const [delDialog, setDelDialog] = useState(false); const [confirmName, setConfirmName] = useState(''); @@ -51,7 +47,7 @@ const ProjectRoleList = () => { const deleteProjectRole = async () => { if (!currentRole?.id) return; try { - await deleteRole(currentRole?.id); + await removeRole(currentRole?.id); refetch(); setToastData({ type: 'success', diff --git a/frontend/src/component/admin/projectRoles/hooks/useProjectRoleForm.ts b/frontend/src/component/admin/projectRoles/hooks/useProjectRoleForm.ts index 27058c54480..205e3daf792 100644 --- a/frontend/src/component/admin/projectRoles/hooks/useProjectRoleForm.ts +++ b/frontend/src/component/admin/projectRoles/hooks/useProjectRoleForm.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { IPermission } from 'interfaces/permissions'; import cloneDeep from 'lodash.clonedeep'; import usePermissions from 'hooks/api/getters/usePermissions/usePermissions'; -import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi'; +import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi'; import { formatUnknownError } from 'utils/formatUnknownError'; export interface ICheckedPermission { @@ -53,7 +53,7 @@ const useProjectRoleForm = ( const [errors, setErrors] = useState({}); - const { validateRole } = useProjectRolesApi(); + const { validateRole } = useRolesApi(); useEffect(() => { setRoleName(initialRoleName); diff --git a/frontend/src/component/admin/roles/RoleForm/RoleForm.tsx b/frontend/src/component/admin/roles/RoleForm/RoleForm.tsx index e69de29bb2d..f6a909540a4 100644 --- a/frontend/src/component/admin/roles/RoleForm/RoleForm.tsx +++ b/frontend/src/component/admin/roles/RoleForm/RoleForm.tsx @@ -0,0 +1,91 @@ +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 { ICheckedPermissions, IPermission } from 'interfaces/permissions'; +import { IRoleFormErrors } from './useRoleForm'; + +const StyledInputDescription = styled('p')(({ theme }) => ({ + display: 'flex', + color: theme.palette.text.primary, + marginBottom: theme.spacing(1), + '&:not(:first-of-type)': { + marginTop: theme.spacing(4), + }, +})); + +const StyledInput = styled(Input)(({ theme }) => ({ + width: '100%', + maxWidth: theme.spacing(50), +})); + +interface IRoleFormProps { + name: string; + onSetName: (name: string) => void; + description: string; + setDescription: React.Dispatch>; + checkedPermissions: ICheckedPermissions; + handlePermissionChange: (permission: IPermission) => void; + onToggleAllPermissions: () => void; + permissions: IPermission[]; + errors: IRoleFormErrors; +} + +export const RoleForm = ({ + name, + onSetName, + description, + setDescription, + checkedPermissions, + handlePermissionChange, + onToggleAllPermissions, + permissions, + errors, +}: IRoleFormProps) => { + return ( +
+ + What is your new role name? + + onSetName(e.target.value)} + autoComplete="off" + required + /> + + What is your new role description? + + setDescription(e.target.value)} + autoComplete="off" + required + /> + + What is your role allowed to do? + + } + permissions={permissions} + checkedPermissions={checkedPermissions} + onPermissionChange={(permission: IPermission) => + handlePermissionChange(permission) + } + onCheckAll={onToggleAllPermissions} + getRoleKey={(permission: { + id: number; + environment?: string; + }) => permission.id.toString()} + context="root" + /> +
+ ); +}; diff --git a/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts b/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts index 0b2ecbff1dd..3ec643d5412 100644 --- a/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts +++ b/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts @@ -2,29 +2,42 @@ import { useEffect, useState } from 'react'; import { IPermission, ICheckedPermissions } from 'interfaces/permissions'; import cloneDeep from 'lodash.clonedeep'; import usePermissions from 'hooks/api/getters/usePermissions/usePermissions'; -import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi'; -import { formatUnknownError } from 'utils/formatUnknownError'; +import IRole from 'interfaces/role'; +import { useRoles } from 'hooks/api/getters/useRoles/useRoles'; + +enum ErrorField { + NAME = 'name', +} + +export interface IRoleFormErrors { + [ErrorField.NAME]?: string; +} export const useRoleForm = ( - initialRoleName = '', - initialRoleDesc = '', - initialCheckedPermissions: IPermission[] = [] + initialName = '', + initialDescription = '', + initialPermissions: IPermission[] = [] ) => { + const { roles } = useRoles(); const { permissions } = usePermissions({ revalidateIfStale: false, revalidateOnReconnect: false, revalidateOnFocus: false, }); - const [roleName, setRoleName] = useState(initialRoleName); - const [roleDesc, setRoleDesc] = useState(initialRoleDesc); + const rootPermissions = permissions.root.filter( + ({ name }) => name !== 'ADMIN' + ); + + const [name, setName] = useState(initialName); + const [description, setDescription] = useState(initialDescription); const [checkedPermissions, setCheckedPermissions] = useState({}); useEffect(() => { - if (initialCheckedPermissions.length > 0) { + if (initialPermissions.length > 0) { setCheckedPermissions( - initialCheckedPermissions?.reduce( + initialPermissions.reduce( ( acc: { [key: string]: IPermission }, curr: IPermission @@ -36,137 +49,95 @@ export const useRoleForm = ( ) ); } - }, [initialCheckedPermissions?.length]); + }, [initialPermissions.length]); - const [errors, setErrors] = useState({}); - - const { validateRole } = useProjectRolesApi(); + const [errors, setErrors] = useState({}); useEffect(() => { - setRoleName(initialRoleName); - }, [initialRoleName]); + setName(initialName); + }, [initialName]); 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({}); - // }; + setDescription(initialDescription); + }, [initialDescription]); + + const handlePermissionChange = (permission: IPermission) => { + let checkedPermissionsCopy = cloneDeep(checkedPermissions); + + if (checkedPermissionsCopy[permission.id]) { + delete checkedPermissionsCopy[permission.id]; + } else { + checkedPermissionsCopy[permission.id] = { ...permission }; + } + + setCheckedPermissions(checkedPermissionsCopy); + }; + + const onToggleAllPermissions = () => { + let checkedPermissionsCopy = cloneDeep(checkedPermissions); + + const allChecked = rootPermissions.every( + (permission: IPermission) => checkedPermissionsCopy[permission.id] + ); + + if (allChecked) { + rootPermissions.forEach((permission: IPermission) => { + delete checkedPermissionsCopy[permission.id]; + }); + } else { + rootPermissions.forEach((permission: IPermission) => { + checkedPermissionsCopy[permission.id] = { + ...permission, + }; + }); + } + + setCheckedPermissions(checkedPermissionsCopy); + }; + + const getRolePayload = () => ({ + name, + description, + permissions: Object.values(checkedPermissions), + }); + + const isNameUnique = (name: string) => { + return !roles.some( + (existingRole: IRole) => + existingRole.name.toLowerCase() === name.toLowerCase() + ); + }; + + const isNotEmpty = (value: string) => value.length; + + const hasPermissions = (permissions: ICheckedPermissions) => + Object.keys(checkedPermissions).length > 0; + + const clearError = (field: ErrorField) => { + setErrors(errors => ({ ...errors, [field]: undefined })); + }; + + const setError = (field: ErrorField, error: string) => { + setErrors(errors => ({ ...errors, [field]: error })); + }; return { - roleName, - roleDesc, + name, + description, errors, checkedPermissions, - permissions, - setRoleName, - setRoleDesc, - // handlePermissionChange, - // onToggleAllProjectPermissions, - // onToggleAllEnvironmentPermissions, - // getProjectRolePayload, - // validatePermissions, - // validateName, - // clearErrors, - // validateNameUniqueness, - // getRoleKey, + rootPermissions, + setName, + setDescription, + setCheckedPermissions, + handlePermissionChange, + onToggleAllPermissions, + getRolePayload, + clearError, + setError, + isNameUnique, + isNotEmpty, + hasPermissions, + ErrorField, }; }; diff --git a/frontend/src/component/admin/roles/RoleModal/RoleModal.tsx b/frontend/src/component/admin/roles/RoleModal/RoleModal.tsx index d156889997f..2b32629c451 100644 --- a/frontend/src/component/admin/roles/RoleModal/RoleModal.tsx +++ b/frontend/src/component/admin/roles/RoleModal/RoleModal.tsx @@ -1,8 +1,15 @@ -import { Button, FormControlLabel, Radio, styled } from '@mui/material'; +import { Button, styled } from '@mui/material'; import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; -import Input from 'component/common/Input/Input'; import IRole from 'interfaces/role'; import { useRoleForm } from '../RoleForm/useRoleForm'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import { RoleForm } from '../RoleForm/RoleForm'; +import { useRoles } from 'hooks/api/getters/useRoles/useRoles'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { FormEvent } from 'react'; +import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi'; const StyledForm = styled('form')(() => ({ display: 'flex', @@ -10,50 +17,6 @@ const StyledForm = styled('form')(() => ({ height: '100%', })); -const StyledInputDescription = styled('p')(({ theme }) => ({ - display: 'flex', - color: theme.palette.text.primary, - marginBottom: theme.spacing(1), - '&:not(:first-of-type)': { - marginTop: theme.spacing(4), - }, -})); - -const StyledInputSecondaryDescription = styled('p')(({ theme }) => ({ - color: theme.palette.text.secondary, - marginBottom: theme.spacing(1), -})); - -const StyledInput = styled(Input)(({ theme }) => ({ - width: '100%', - maxWidth: theme.spacing(50), -})); - -const StyledRoleBox = styled(FormControlLabel)(({ theme }) => ({ - margin: theme.spacing(0.5, 0), - border: `1px solid ${theme.palette.divider}`, - padding: theme.spacing(2), -})); - -const StyledRoleRadio = styled(Radio)(({ theme }) => ({ - marginRight: theme.spacing(2), -})); - -const StyledSecondaryContainer = styled('div')(({ theme }) => ({ - padding: theme.spacing(3), - backgroundColor: theme.palette.background.elevation2, - borderRadius: theme.shape.borderRadiusMedium, - marginTop: theme.spacing(4), - marginBottom: theme.spacing(2), -})); - -const StyledInlineContainer = styled('div')(({ theme }) => ({ - padding: theme.spacing(0, 4), - '& > p:not(:first-of-type)': { - marginTop: theme.spacing(2), - }, -})); - const StyledButtonContainer = styled('div')(({ theme }) => ({ marginTop: 'auto', display: 'flex', @@ -65,19 +28,6 @@ const StyledCancelButton = styled(Button)(({ theme }) => ({ marginLeft: theme.spacing(3), })); -enum TokenGeneration { - LATER = 'later', - NOW = 'now', -} - -enum ErrorField { - USERNAME = 'username', -} - -interface IServiceAccountModalErrors { - [ErrorField.USERNAME]?: string; -} - interface IRoleModalProps { role?: IRole; open: boolean; @@ -85,41 +35,74 @@ interface IRoleModalProps { } export const RoleModal = ({ role, open, setOpen }: IRoleModalProps) => { - // const { name, description, permissions, getRolePayload } = useRoleForm(); - - // const handlePermissionChange = (permission: IPermission) => { - // let checkedPermissionsCopy = cloneDeep(checkedPermissions); - - // if (checkedPermissionsCopy[permission.id]) { - // delete checkedPermissionsCopy[permission.id]; - // } else { - // checkedPermissionsCopy[permission.id] = { ...permission }; - // } - - // setCheckedPermissions(checkedPermissionsCopy); - // }; - - // const onToggleAllPermissions = () => { - // let checkedPermissionsCopy = cloneDeep(checkedPermissions); - - // const allChecked = granularPermissions.every( - // (permission: IPermission) => checkedPermissionsCopy[permission.id] - // ); - - // if (allChecked) { - // granularPermissions.forEach((permission: IPermission) => { - // delete checkedPermissionsCopy[permission.id]; - // }); - // } else { - // granularPermissions.forEach((permission: IPermission) => { - // checkedPermissionsCopy[permission.id] = { - // ...permission, - // }; - // }); - // } - - // setCheckedPermissions(checkedPermissionsCopy); - // }; + const { + name, + setName, + description, + setDescription, + checkedPermissions, + handlePermissionChange, + onToggleAllPermissions, + getRolePayload, + isNameUnique, + isNotEmpty, + hasPermissions, + rootPermissions, + errors, + setError, + clearError, + ErrorField, + } = useRoleForm(role?.name, role?.description, role?.permissions); + const { refetch } = useRoles(); + const { addRole, updateRole, loading } = useRolesApi(); + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + + const editing = role !== undefined; + const isValid = + isNameUnique(name) && + isNotEmpty(name) && + isNotEmpty(description) && + hasPermissions(checkedPermissions); + + const formatApiCode = () => { + return `curl --location --request ${editing ? 'PUT' : 'POST'} '${ + uiConfig.unleashUrl + }/api/admin/roles${editing ? `/${role.id}` : ''}' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '${JSON.stringify(getRolePayload(), undefined, 2)}'`; + }; + + const onSetName = (name: string) => { + clearError(ErrorField.NAME); + if (!isNameUnique(name)) { + setError(ErrorField.NAME, 'A role with that name already exists.'); + } + setName(name); + }; + + const onSubmit = async (e: FormEvent) => { + e.preventDefault(); + + if (!isValid) return; + + try { + if (editing) { + await updateRole(role.id, getRolePayload()); + } else { + await addRole(getRolePayload()); + } + setToastData({ + title: `Role ${editing ? 'updated' : 'added'} successfully`, + type: 'success', + }); + refetch(); + setOpen(false); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; return ( { onClose={() => { setOpen(false); }} - label="TODO" - // label={editing ? 'Edit service account' : 'New service account'} + label={editing ? 'Edit role' : 'New role'} > -
TODO: Use RoleForm
- {/* - -
- - What is your new service account name? - - setName(e.target.value)} - onBlur={suggestUsername} - autoComplete="off" - required - /> - - What is your new service account username? - - onSetUsername(e.target.value)} - autoComplete="off" - required - disabled={editing} - /> - - What is your service account allowed to do? - - - setRootRole(+e.target.value)} - data-loading - > - {roles - .sort((a, b) => (a.name < b.name ? -1 : 1)) - .map(role => ( - - {role.name} - - {role.description} - -
- } - control={ - - } - value={role.id} - /> - ))} - - - - - Token - - - In order to connect your newly created - service account, you will also need a - token.{' '} - - Read more about API tokens - - . - - - - setTokenGeneration( - e.target - .value as TokenGeneration - ) - } - name="token-generation" - > - } - label="I want to generate a token later" - /> - } - label="Generate a token now" - /> - - - - - A new personal access token (PAT) - will be generated for the service - account, so you can get started - right away. - - - } - /> - - - } - elseShow={ - <> - - Service account tokens - - - - } - /> - - + + { @@ -311,7 +151,7 @@ export const RoleModal = ({ role, open, setOpen }: IRoleModalProps) => { -
*/} +
); }; diff --git a/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx b/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx index 97827cae420..2bf103b565d 100644 --- a/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx +++ b/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx @@ -10,29 +10,25 @@ import { Button, useMediaQuery } from '@mui/material'; import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; import { useFlexLayout, useSortBy, useTable } from 'react-table'; import { sortTypes } from 'utils/sortTypes'; -import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; -import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; import theme from 'themes/theme'; import { Search } from 'component/common/Search/Search'; -import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; import { useSearch } from 'hooks/useSearch'; -import { INewPersonalAPIToken } from 'interfaces/personalAPIToken'; -import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; -import { IServiceAccount } from 'interfaces/service-account'; import { IconCell } from 'component/common/Table/cells/IconCell/IconCell'; import { SupervisedUserCircle } from '@mui/icons-material'; -import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts'; import { RolesActionsCell } from './RolesActionsCell/RolesActionsCell'; import { RolesCell } from './RolesCell/RolesCell'; import { RoleDeleteDialog } from './RoleDeleteDialog/RoleDeleteDialog'; +import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi'; +import { useRoles } from 'hooks/api/getters/useRoles/useRoles'; +import { RoleModal } from '../RoleModal/RoleModal'; export const RolesTable = () => { const { setToastData, setToastApiError } = useToast(); - const { roles, refetch, loading } = useServiceAccounts(); // create useRoles() hook - // const { removeRole } = useRolesApi(); + const { roles, refetch, loading } = useRoles(); + const { removeRole } = useRolesApi(); const [searchValue, setSearchValue] = useState(''); const [modalOpen, setModalOpen] = useState(false); @@ -41,7 +37,7 @@ export const RolesTable = () => { const onDeleteConfirm = async (role: IRole) => { try { - // await removeRole(role.id); + await removeRole(role.id); setToastData({ title: `${role.name} has been deleted`, type: 'success', @@ -53,7 +49,6 @@ export const RolesTable = () => { } }; - // const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); const columns = useMemo( @@ -215,11 +210,11 @@ export const RolesTable = () => { /> } /> - {/* */} + /> ({ display: 'flex', @@ -133,7 +128,6 @@ export const ServiceAccountModal = ({ const { addServiceAccount, updateServiceAccount, loading } = useServiceAccountsApi(); const { createServiceAccountToken } = useServiceAccountTokensApi(); - // const { permissions } = usePermissions(); const { setToastData, setToastApiError } = useToast(); const { uiConfig } = useUiConfig(); @@ -160,12 +154,6 @@ export const ServiceAccountModal = ({ calculateExpirationDate(DEFAULT_EXPIRATION) ); const [patErrors, setPatErrors] = useState({}); - // const [checkedPermissions, setCheckedPermissions] = - // useState({}); - - // const granularPermissions = permissions.root.filter( - // ({ name }) => name !== 'ADMIN' - // ); const editing = serviceAccount !== undefined; @@ -270,40 +258,6 @@ export const ServiceAccountModal = ({ setUsername(username); }; - // const handlePermissionChange = (permission: IPermission) => { - // let checkedPermissionsCopy = cloneDeep(checkedPermissions); - - // if (checkedPermissionsCopy[permission.id]) { - // delete checkedPermissionsCopy[permission.id]; - // } else { - // checkedPermissionsCopy[permission.id] = { ...permission }; - // } - - // setCheckedPermissions(checkedPermissionsCopy); - // }; - - // const onToggleAllPermissions = () => { - // let checkedPermissionsCopy = cloneDeep(checkedPermissions); - - // const allChecked = granularPermissions.every( - // (permission: IPermission) => checkedPermissionsCopy[permission.id] - // ); - - // if (allChecked) { - // granularPermissions.forEach((permission: IPermission) => { - // delete checkedPermissionsCopy[permission.id]; - // }); - // } else { - // granularPermissions.forEach((permission: IPermission) => { - // checkedPermissionsCopy[permission.id] = { - // ...permission, - // }; - // }); - // } - - // setCheckedPermissions(checkedPermissionsCopy); - // }; - return ( - {/* - Would you like to specify extra granular - permissions? - - } - permissions={granularPermissions} - checkedPermissions={checkedPermissions} - onPermissionChange={(permission: IPermission) => - handlePermissionChange(permission) - } - onCheckAll={onToggleAllPermissions} - getRoleKey={(permission: { - id: number; - environment?: string; - }) => permission.id.toString()} - context="root" - /> */} { - const { makeRequest, createRequest, errors, loading } = useAPI({ - propagateErrors: true, - }); - - const createRole = async (payload: ICreateRolePayload) => { - const path = `api/admin/roles`; - const req = createRequest(path, { - method: 'POST', - body: JSON.stringify(payload), - }); - - try { - const res = await makeRequest(req.caller, req.id); - - return res; - } catch (e) { - throw e; - } - }; - - const editRole = async (id: string, payload: ICreateRolePayload) => { - const path = `api/admin/roles/${id}`; - const req = createRequest(path, { - method: 'PUT', - body: JSON.stringify(payload), - }); - - try { - const res = await makeRequest(req.caller, req.id); - - return res; - } catch (e) { - throw e; - } - }; - - const validateRole = async (payload: ICreateRolePayload) => { - const path = `api/admin/roles/validate`; - const req = createRequest(path, { - method: 'POST', - body: JSON.stringify(payload), - }); - - try { - const res = await makeRequest(req.caller, req.id); - - return res; - } catch (e) { - throw e; - } - }; - - const deleteRole = async (id: number) => { - const path = `api/admin/roles/${id}`; - const req = createRequest(path, { - method: 'DELETE', - }); - - try { - const res = await makeRequest(req.caller, req.id); - - return res; - } catch (e) { - throw e; - } - }; - - return { - createRole, - deleteRole, - editRole, - validateRole, - errors, - loading, - }; -}; - -export default useProjectRolesApi; diff --git a/frontend/src/hooks/api/actions/useRolesApi/useRolesApi.ts b/frontend/src/hooks/api/actions/useRolesApi/useRolesApi.ts new file mode 100644 index 00000000000..f71447f51cb --- /dev/null +++ b/frontend/src/hooks/api/actions/useRolesApi/useRolesApi.ts @@ -0,0 +1,77 @@ +import { IPermission } from 'interfaces/permissions'; +import useAPI from '../useApi/useApi'; + +interface IRolePayload { + name: string; + description: string; + permissions: IPermission[]; +} + +export const useRolesApi = () => { + const { loading, makeRequest, createRequest, errors } = useAPI({ + propagateErrors: true, + }); + + const addRole = async (role: IRolePayload) => { + const requestId = 'addRole'; + const req = createRequest( + 'api/admin/roles', + { + method: 'POST', + body: JSON.stringify(role), + }, + requestId + ); + + const response = await makeRequest(req.caller, req.id); + return await response.json(); + }; + + const updateRole = async (roleId: number, role: IRolePayload) => { + const requestId = 'updateRole'; + const req = createRequest( + `api/admin/roles/${roleId}`, + { + method: 'PUT', + body: JSON.stringify(role), + }, + requestId + ); + + await makeRequest(req.caller, req.id); + }; + + const removeRole = async (roleId: number) => { + const requestId = 'removeRole'; + const req = createRequest( + `api/admin/roles/${roleId}`, + { method: 'DELETE' }, + requestId + ); + + await makeRequest(req.caller, req.id); + }; + + const validateRole = async (payload: IRolePayload) => { + const requestId = 'validateRole'; + const req = createRequest( + 'api/admin/roles/validate', + { + method: 'POST', + body: JSON.stringify(payload), + }, + requestId + ); + + await makeRequest(req.caller, req.id); + }; + + return { + addRole, + updateRole, + removeRole, + validateRole, + errors, + loading, + }; +}; diff --git a/frontend/src/hooks/api/getters/useProjectRoles/useProjectRoles.ts b/frontend/src/hooks/api/getters/useProjectRoles/useProjectRoles.ts deleted file mode 100644 index 42cb2580714..00000000000 --- a/frontend/src/hooks/api/getters/useProjectRoles/useProjectRoles.ts +++ /dev/null @@ -1,35 +0,0 @@ -import useSWR, { mutate, SWRConfiguration } from 'swr'; -import { useState, useEffect } from 'react'; -import { formatApiPath } from 'utils/formatPath'; -import handleErrorResponses from '../httpErrorResponseHandler'; - -const useProjectRoles = (options: SWRConfiguration = {}) => { - const fetcher = () => { - const path = formatApiPath(`api/admin/roles`); - return fetch(path, { - method: 'GET', - }) - .then(handleErrorResponses('project roles')) - .then(res => res.json()); - }; - - const { data, error } = useSWR(`api/admin/roles`, fetcher, options); - const [loading, setLoading] = useState(!error && !data); - - const refetch = () => { - mutate(`api/admin/roles`); - }; - - useEffect(() => { - setLoading(!error && !data); - }, [data, error]); - - return { - roles: data?.roles || [], - error, - loading, - refetch, - }; -}; - -export default useProjectRoles; diff --git a/frontend/src/hooks/api/getters/useRoles/useRoles.ts b/frontend/src/hooks/api/getters/useRoles/useRoles.ts new file mode 100644 index 00000000000..eb2850159b3 --- /dev/null +++ b/frontend/src/hooks/api/getters/useRoles/useRoles.ts @@ -0,0 +1,37 @@ +import IRole, { IProjectRole } from 'interfaces/role'; +import { useMemo } from 'react'; +import { formatApiPath } from 'utils/formatPath'; +import handleErrorResponses from '../httpErrorResponseHandler'; +import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR'; +import useUiConfig from '../useUiConfig/useUiConfig'; + +export const useRoles = () => { + const { isEnterprise } = useUiConfig(); + + const { data, error, mutate } = useConditionalSWR( + isEnterprise(), + { roles: [], projectRoles: [] }, + formatApiPath(`api/admin/roles`), + fetcher + ); + + return useMemo( + () => ({ + roles: (data?.roles.filter(({ type }: IRole) => type === 'root') ?? + []) as IRole[], + projectRoles: (data?.roles.filter( + ({ type }: IRole) => type !== 'root' + ) ?? []) as IProjectRole[], + loading: !error && !data, + refetch: () => mutate(), + error, + }), + [data, error, mutate] + ); +}; + +const fetcher = (path: string) => { + return fetch(path) + .then(handleErrorResponses('Roles')) + .then(res => res.json()); +}; diff --git a/frontend/src/interfaces/role.ts b/frontend/src/interfaces/role.ts index 479ad71c86c..fba3a51d556 100644 --- a/frontend/src/interfaces/role.ts +++ b/frontend/src/interfaces/role.ts @@ -1,9 +1,12 @@ +import { IPermission } from './permissions'; + interface IRole { id: number; name: string; project: string | null; description: string; type: string; + permissions: IPermission[]; } export interface IProjectRole { From 28965037b5b6bb8d5d177d606ff150d2851aa111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Tue, 13 Jun 2023 14:53:01 +0100 Subject: [PATCH 05/11] role select, categorize permissions, ... --- frontend/src/component/admin/Admin.tsx | 7 +- .../PermissionAccordion.tsx | 6 +- .../ProjectRoleList/ProjectRoleList.tsx | 4 +- .../admin/roles/RoleForm/RoleForm.tsx | 82 +++++++++++++---- .../admin/roles/RoleForm/useRoleForm.ts | 4 +- .../admin/roles/RoleModal/RoleModal.tsx | 4 +- .../ServiceAccountModal.tsx | 62 ++++--------- .../common/RoleSelect/RoleSelect.tsx | 63 ++++++++++++++ .../providers/AccessProvider/permissions.ts | 1 + .../hooks/api/getters/useRoles/useRoles.ts | 12 ++- src/lib/db/role-store.ts | 4 +- .../features/access/createAccessService.ts | 8 +- .../createFeatureToggleService.ts | 4 +- src/lib/services/access-service.test.ts | 13 ++- src/lib/services/access-service.ts | 87 ++++++++++++++++--- src/lib/types/permissions.ts | 48 ++++++++++ src/lib/util/constants.ts | 3 +- .../e2e/services/access-service.e2e.test.ts | 2 +- src/test/fixtures/access-service-mock.ts | 2 +- 19 files changed, 307 insertions(+), 109 deletions(-) create mode 100644 frontend/src/component/common/RoleSelect/RoleSelect.tsx diff --git a/frontend/src/component/admin/Admin.tsx b/frontend/src/component/admin/Admin.tsx index 6f8e6de0788..26448a35f0d 100644 --- a/frontend/src/component/admin/Admin.tsx +++ b/frontend/src/component/admin/Admin.tsx @@ -28,8 +28,11 @@ export const Admin = () => ( } /> - } /> - } /> + } /> + } + /> } /> } /> } /> diff --git a/frontend/src/component/admin/projectRoles/ProjectRoleForm/PermissionAccordion/PermissionAccordion.tsx b/frontend/src/component/admin/projectRoles/ProjectRoleForm/PermissionAccordion/PermissionAccordion.tsx index 9c354812736..4258103b94d 100644 --- a/frontend/src/component/admin/projectRoles/ProjectRoleForm/PermissionAccordion/PermissionAccordion.tsx +++ b/frontend/src/component/admin/projectRoles/ProjectRoleForm/PermissionAccordion/PermissionAccordion.tsx @@ -23,10 +23,10 @@ interface IEnvironmentPermissionAccordionProps { title: string; Icon: ReactNode; isInitiallyExpanded?: boolean; - context: 'root' | 'project' | 'environment'; + context: string; onPermissionChange: (permission: IPermission) => void; onCheckAll: () => void; - getRoleKey: (permission: { id: number; environment?: string }) => string; + getRoleKey?: (permission: { id: number; environment?: string }) => string; } const AccordionHeader = styled(Box)(({ theme }) => ({ @@ -52,7 +52,7 @@ export const PermissionAccordion: VFC = ({ context, onPermissionChange, onCheckAll, - getRoleKey, + getRoleKey = permission => permission.id.toString(), }) => { const [expanded, setExpanded] = useState(isInitiallyExpanded); const permissionMap = useMemo( diff --git a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx index c254163e31a..972c765301f 100644 --- a/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx +++ b/frontend/src/component/admin/projectRoles/ProjectRoles/ProjectRoleList/ProjectRoleList.tsx @@ -95,7 +95,7 @@ const ProjectRoleList = () => { data-loading disabled={type === BUILTIN_ROLE_TYPE} onClick={() => { - navigate(`/admin/roles/${id}/edit`); + navigate(`/admin/project-roles/${id}/edit`); }} permission={ADMIN} tooltipProps={{ @@ -204,7 +204,7 @@ const ProjectRoleList = () => { variant="contained" color="primary" onClick={() => - navigate('/admin/create-project-role') + navigate('/admin/project-roles/new') } > New project role diff --git a/frontend/src/component/admin/roles/RoleForm/RoleForm.tsx b/frontend/src/component/admin/roles/RoleForm/RoleForm.tsx index f6a909540a4..0e6e0ede786 100644 --- a/frontend/src/component/admin/roles/RoleForm/RoleForm.tsx +++ b/frontend/src/component/admin/roles/RoleForm/RoleForm.tsx @@ -4,6 +4,8 @@ import { PermissionAccordion } from 'component/admin/projectRoles/ProjectRoleFor import { Person as UserIcon } from '@mui/icons-material'; import { ICheckedPermissions, IPermission } from 'interfaces/permissions'; import { IRoleFormErrors } from './useRoleForm'; +import { ROOT_PERMISSION_CATEGORIES } from '@server/types/permissions'; +import cloneDeep from 'lodash.clonedeep'; const StyledInputDescription = styled('p')(({ theme }) => ({ display: 'flex', @@ -25,8 +27,10 @@ interface IRoleFormProps { description: string; setDescription: React.Dispatch>; checkedPermissions: ICheckedPermissions; + setCheckedPermissions: React.Dispatch< + React.SetStateAction + >; handlePermissionChange: (permission: IPermission) => void; - onToggleAllPermissions: () => void; permissions: IPermission[]; errors: IRoleFormErrors; } @@ -37,11 +41,52 @@ export const RoleForm = ({ description, setDescription, checkedPermissions, + setCheckedPermissions, handlePermissionChange, - onToggleAllPermissions, 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 categories = new Set( + categorizedPermissions.map(({ category }) => category).sort() + ); + + const onToggleAllPermissions = (category: string) => { + let checkedPermissionsCopy = cloneDeep(checkedPermissions); + + const categoryPermissions = categorizedPermissions + .filter(({ category: pCategory }) => pCategory === category) + .map(({ permission }) => permission); + + const allChecked = categoryPermissions.every( + (permission: IPermission) => checkedPermissionsCopy[permission.id] + ); + + if (allChecked) { + categoryPermissions.forEach((permission: IPermission) => { + delete checkedPermissionsCopy[permission.id]; + }); + } else { + categoryPermissions.forEach((permission: IPermission) => { + checkedPermissionsCopy[permission.id] = { + ...permission, + }; + }); + } + + setCheckedPermissions(checkedPermissionsCopy); + }; + return (
@@ -70,22 +115,23 @@ export const RoleForm = ({ What is your role allowed to do? - } - permissions={permissions} - checkedPermissions={checkedPermissions} - onPermissionChange={(permission: IPermission) => - handlePermissionChange(permission) - } - onCheckAll={onToggleAllPermissions} - getRoleKey={(permission: { - id: number; - environment?: string; - }) => permission.id.toString()} - context="root" - /> + {[...categories].map(category => ( + } + permissions={categorizedPermissions + .filter( + ({ category: pCategory }) => pCategory === category + ) + .map(({ permission }) => permission)} + checkedPermissions={checkedPermissions} + onPermissionChange={(permission: IPermission) => + handlePermissionChange(permission) + } + onCheckAll={() => onToggleAllPermissions(category)} + /> + ))}
); }; diff --git a/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts b/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts index 3ec643d5412..2eb6d1eabef 100644 --- a/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts +++ b/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts @@ -98,12 +98,14 @@ export const useRoleForm = ( const getRolePayload = () => ({ name, description, + type: 'root-custom', permissions: Object.values(checkedPermissions), }); const isNameUnique = (name: string) => { return !roles.some( (existingRole: IRole) => + existingRole.name !== initialName && existingRole.name.toLowerCase() === name.toLowerCase() ); }; @@ -111,7 +113,7 @@ export const useRoleForm = ( const isNotEmpty = (value: string) => value.length; const hasPermissions = (permissions: ICheckedPermissions) => - Object.keys(checkedPermissions).length > 0; + Object.keys(permissions).length > 0; const clearError = (field: ErrorField) => { setErrors(errors => ({ ...errors, [field]: undefined })); diff --git a/frontend/src/component/admin/roles/RoleModal/RoleModal.tsx b/frontend/src/component/admin/roles/RoleModal/RoleModal.tsx index 2b32629c451..8ad97d5307c 100644 --- a/frontend/src/component/admin/roles/RoleModal/RoleModal.tsx +++ b/frontend/src/component/admin/roles/RoleModal/RoleModal.tsx @@ -41,8 +41,8 @@ export const RoleModal = ({ role, open, setOpen }: IRoleModalProps) => { description, setDescription, checkedPermissions, + setCheckedPermissions, handlePermissionChange, - onToggleAllPermissions, getRolePayload, isNameUnique, isNotEmpty, @@ -128,8 +128,8 @@ export const RoleModal = ({ role, open, setOpen }: IRoleModalProps) => { description={description} setDescription={setDescription} checkedPermissions={checkedPermissions} + setCheckedPermissions={setCheckedPermissions} handlePermissionChange={handlePermissionChange} - onToggleAllPermissions={onToggleAllPermissions} permissions={rootPermissions} errors={errors} /> diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx index b76125401db..d3ad1034841 100644 --- a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx +++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx @@ -6,7 +6,6 @@ import { Radio, RadioGroup, styled, - Typography, } from '@mui/material'; import FormTemplate from 'component/common/FormTemplate/FormTemplate'; import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; @@ -33,6 +32,8 @@ import { useServiceAccountTokensApi } from 'hooks/api/actions/useServiceAccountT import { INewPersonalAPIToken } from 'interfaces/personalAPIToken'; import { ServiceAccountTokens } from './ServiceAccountTokens/ServiceAccountTokens'; import { IServiceAccount } from 'interfaces/service-account'; +import { RoleSelect } from 'component/common/RoleSelect/RoleSelect'; +import IRole from 'interfaces/role'; const StyledForm = styled('form')(() => ({ display: 'flex', @@ -59,14 +60,9 @@ const StyledInput = styled(Input)(({ theme }) => ({ maxWidth: theme.spacing(50), })); -const StyledRoleBox = styled(FormControlLabel)(({ theme }) => ({ - margin: theme.spacing(0.5, 0), - border: `1px solid ${theme.palette.divider}`, - padding: theme.spacing(2), -})); - -const StyledRoleRadio = styled(Radio)(({ theme }) => ({ - marginRight: theme.spacing(2), +const StyledRoleSelect = styled(RoleSelect)(({ theme }) => ({ + width: '100%', + maxWidth: theme.spacing(50), })); const StyledSecondaryContainer = styled('div')(({ theme }) => ({ @@ -133,7 +129,7 @@ export const ServiceAccountModal = ({ const [name, setName] = useState(''); const [username, setUsername] = useState(''); - const [rootRole, setRootRole] = useState(1); + const [rootRole, setRootRole] = useState(null); const [tokenGeneration, setTokenGeneration] = useState( TokenGeneration.LATER ); @@ -160,7 +156,9 @@ export const ServiceAccountModal = ({ useEffect(() => { setName(serviceAccount?.name || ''); setUsername(serviceAccount?.username || ''); - setRootRole(serviceAccount?.rootRole || 1); + setRootRole( + roles.find(({ id }) => id === serviceAccount?.rootRole) || null + ); setTokenGeneration(TokenGeneration.LATER); setErrors({}); @@ -173,7 +171,7 @@ export const ServiceAccountModal = ({ const getServiceAccountPayload = (): IServiceAccountPayload => ({ name, username, - rootRole, + rootRole: rootRole?.id || 0, }); const handleSubmit = async (e: FormEvent) => { @@ -226,6 +224,7 @@ export const ServiceAccountModal = ({ (serviceAccount: IServiceAccount) => serviceAccount.username === value ); + const isRoleValid = rootRole !== null; const isPATValid = tokenGeneration === TokenGeneration.LATER || (isNotEmpty(patDescription) && patExpiresAt > new Date()); @@ -233,6 +232,7 @@ export const ServiceAccountModal = ({ isNotEmpty(name) && isNotEmpty(username) && (editing || isUnique(username)) && + isRoleValid && isPATValid; const suggestUsername = () => { @@ -305,39 +305,11 @@ export const ServiceAccountModal = ({ What is your service account allowed to do? - - setRootRole(+e.target.value)} - data-loading - > - {roles - .sort((a, b) => (a.name < b.name ? -1 : 1)) - .map(role => ( - - {role.name} - - {role.description} - - - } - control={ - - } - value={role.id} - /> - ))} - - + ({ + display: 'flex', + flexDirection: 'column', + '& > span:last-of-type': { + fontSize: theme.fontSizes.smallerBody, + color: theme.palette.text.secondary, + }, +})); + +export interface IRoleSelectProps + extends Partial> { + value: IRole | null; + setValue: (role: IRole | null) => void; + required?: boolean; +} + +export const RoleSelect = ({ + value, + setValue, + required, + ...rest +}: IRoleSelectProps) => { + const { roles } = useRoles(); + + const renderRoleOption = ( + props: React.HTMLAttributes, + option: IRole + ) => ( +
  • + + {option.name} + {option.description} + +
  • + ); + + return ( + <> + setValue(role || null)} + options={roles} + renderOption={renderRoleOption} + getOptionLabel={option => option.name} + renderInput={params => ( + + )} + {...rest} + /> + + ); +}; diff --git a/frontend/src/component/providers/AccessProvider/permissions.ts b/frontend/src/component/providers/AccessProvider/permissions.ts index b69651bb404..a9e98c216e3 100644 --- a/frontend/src/component/providers/AccessProvider/permissions.ts +++ b/frontend/src/component/providers/AccessProvider/permissions.ts @@ -18,6 +18,7 @@ export const CREATE_ADDON = 'CREATE_ADDON'; export const UPDATE_ADDON = 'UPDATE_ADDON'; export const DELETE_ADDON = 'DELETE_ADDON'; export const CREATE_API_TOKEN = 'CREATE_API_TOKEN'; +export const UPDATE_API_TOKEN = 'UPDATE_API_TOKEN'; export const DELETE_API_TOKEN = 'DELETE_API_TOKEN'; export const READ_API_TOKEN = 'READ_API_TOKEN'; export const DELETE_ENVIRONMENT = 'DELETE_ENVIRONMENT'; diff --git a/frontend/src/hooks/api/getters/useRoles/useRoles.ts b/frontend/src/hooks/api/getters/useRoles/useRoles.ts index eb2850159b3..c3e7d570887 100644 --- a/frontend/src/hooks/api/getters/useRoles/useRoles.ts +++ b/frontend/src/hooks/api/getters/useRoles/useRoles.ts @@ -5,6 +5,9 @@ import handleErrorResponses from '../httpErrorResponseHandler'; import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR'; import useUiConfig from '../useUiConfig/useUiConfig'; +const ROOT_ROLES = ['root', 'root-custom']; +const PROJECT_ROLES = ['project', 'custom']; + export const useRoles = () => { const { isEnterprise } = useUiConfig(); @@ -17,10 +20,11 @@ export const useRoles = () => { return useMemo( () => ({ - roles: (data?.roles.filter(({ type }: IRole) => type === 'root') ?? - []) as IRole[], - projectRoles: (data?.roles.filter( - ({ type }: IRole) => type !== 'root' + roles: (data?.roles.filter(({ type }: IRole) => + ROOT_ROLES.includes(type) + ) ?? []) as IRole[], + projectRoles: (data?.roles.filter(({ type }: IRole) => + PROJECT_ROLES.includes(type) ) ?? []) as IProjectRole[], loading: !error && !data, refetch: () => mutate(), diff --git a/src/lib/db/role-store.ts b/src/lib/db/role-store.ts index f93dce63229..468830861ce 100644 --- a/src/lib/db/role-store.ts +++ b/src/lib/db/role-store.ts @@ -160,7 +160,7 @@ export default class RoleStore implements IRoleStore { return this.db .select(['id', 'name', 'type', 'description']) .from(T.ROLES) - .where('type', 'root'); + .whereIn('type', ['root', 'root-custom']); } async removeRolesForProject(projectId: string): Promise { @@ -177,7 +177,7 @@ export default class RoleStore implements IRoleStore { .distinctOn('user_id') .from(`${T.ROLES} AS r`) .leftJoin(`${T.ROLE_USER} AS ru`, 'r.id', 'ru.role_id') - .where('r.type', '=', 'root'); + .whereIn('r.type', ['root', 'root-custom']); return rows.map((row) => ({ roleId: Number(row.id), diff --git a/src/lib/features/access/createAccessService.ts b/src/lib/features/access/createAccessService.ts index 1887a575121..cd7b2dee3b9 100644 --- a/src/lib/features/access/createAccessService.ts +++ b/src/lib/features/access/createAccessService.ts @@ -17,7 +17,7 @@ export const createAccessService = ( db: Db, config: IUnleashConfig, ): AccessService => { - const { eventBus, getLogger } = config; + const { eventBus, getLogger, flagResolver } = config; const eventStore = new EventStore(db, getLogger); const groupStore = new GroupStore(db); const accountStore = new AccountStore(db, getLogger); @@ -31,7 +31,7 @@ export const createAccessService = ( return new AccessService( { accessStore, accountStore, roleStore, environmentStore }, - { getLogger }, + { getLogger, flagResolver }, groupService, ); }; @@ -39,7 +39,7 @@ export const createAccessService = ( export const createFakeAccessService = ( config: IUnleashConfig, ): AccessService => { - const { getLogger } = config; + const { getLogger, flagResolver } = config; const eventStore = new FakeEventStore(); const groupStore = new FakeGroupStore(); const accountStore = new FakeAccountStore(); @@ -53,7 +53,7 @@ export const createFakeAccessService = ( return new AccessService( { accessStore, accountStore, roleStore, environmentStore }, - { getLogger }, + { getLogger, flagResolver }, groupService, ); }; diff --git a/src/lib/features/feature-toggle/createFeatureToggleService.ts b/src/lib/features/feature-toggle/createFeatureToggleService.ts index 77cad99bd72..67829f12fdf 100644 --- a/src/lib/features/feature-toggle/createFeatureToggleService.ts +++ b/src/lib/features/feature-toggle/createFeatureToggleService.ts @@ -91,7 +91,7 @@ export const createFeatureToggleService = ( ); const accessService = new AccessService( { accessStore, accountStore, roleStore, environmentStore }, - { getLogger }, + { getLogger, flagResolver }, groupService, ); const segmentService = new SegmentService( @@ -145,7 +145,7 @@ export const createFakeFeatureToggleService = ( ); const accessService = new AccessService( { accessStore, accountStore, roleStore, environmentStore }, - { getLogger }, + { getLogger, flagResolver }, groupService, ); const segmentService = new SegmentService( diff --git a/src/lib/services/access-service.test.ts b/src/lib/services/access-service.test.ts index d9053291165..05a54964328 100644 --- a/src/lib/services/access-service.test.ts +++ b/src/lib/services/access-service.test.ts @@ -3,10 +3,15 @@ import getLogger from '../../test/fixtures/no-logger'; import createStores from '../../test/fixtures/store'; import { AccessService, IRoleValidation } from './access-service'; import { GroupService } from './group-service'; +import { createTestConfig } from 'test/config/test-config'; function getSetup(withNameInUse: boolean) { const stores = createStores(); + const config = createTestConfig({ + getLogger, + }); + stores.roleStore = { ...stores.roleStore, async nameInUse(): Promise { @@ -14,13 +19,7 @@ function getSetup(withNameInUse: boolean) { }, }; return { - accessService: new AccessService( - stores, - { - getLogger, - }, - {} as GroupService, - ), + accessService: new AccessService(stores, config, {} as GroupService), stores, }; } diff --git a/src/lib/services/access-service.ts b/src/lib/services/access-service.ts index 1e2d7171107..c69f6607954 100644 --- a/src/lib/services/access-service.ts +++ b/src/lib/services/access-service.ts @@ -25,12 +25,18 @@ import NameExistsError from '../error/name-exists-error'; import { IEnvironmentStore } from 'lib/types/stores/environment-store'; import RoleInUseError from '../error/role-in-use-error'; import { roleSchema } from '../schema/role-schema'; -import { ALL_ENVS, ALL_PROJECTS, CUSTOM_ROLE_TYPE } from '../util/constants'; +import { + ALL_ENVS, + ALL_PROJECTS, + CUSTOM_ROOT_ROLE_TYPE, + CUSTOM_PROJECT_ROLE_TYPE, +} from '../util/constants'; import { DEFAULT_PROJECT } from '../types/project'; import InvalidOperationError from '../error/invalid-operation-error'; import BadDataError from '../error/bad-data-error'; import { IGroupModelWithProjectRole } from '../types/group'; import { GroupService } from './group-service'; +import { IFlagResolver, IUnleashConfig } from 'lib/types'; const { ADMIN } = permissions; @@ -45,6 +51,7 @@ const PROJECT_ADMIN = [ interface IRoleCreation { name: string; description: string; + type?: 'root-custom' | 'custom'; permissions?: IPermission[]; } @@ -58,6 +65,7 @@ interface IRoleUpdate { id: number; name: string; description: string; + type?: 'root-custom' | 'custom'; permissions?: IPermission[]; } @@ -76,6 +84,8 @@ export class AccessService { private logger: Logger; + private flagResolver: IFlagResolver; + constructor( { accessStore, @@ -86,7 +96,10 @@ export class AccessService { IUnleashStores, 'accessStore' | 'accountStore' | 'roleStore' | 'environmentStore' >, - { getLogger }: { getLogger: Function }, + { + getLogger, + flagResolver, + }: Pick, groupService: GroupService, ) { this.store = accessStore; @@ -95,6 +108,7 @@ export class AccessService { this.groupService = groupService; this.environmentStore = environmentStore; this.logger = getLogger('/services/access-service.ts'); + this.flagResolver = flagResolver; } /** @@ -233,7 +247,7 @@ export class AccessService { await this.store.removeRolesOfTypeForUser( userId, RoleType.ROOT, - ); + ); // TODO await this.store.addUserToRole( userId, @@ -472,38 +486,80 @@ export class AccessService { } async createRole(role: IRoleCreation): Promise { + const roleType = + role.type === CUSTOM_ROOT_ROLE_TYPE + ? CUSTOM_ROOT_ROLE_TYPE + : CUSTOM_PROJECT_ROLE_TYPE; + + if ( + roleType === CUSTOM_ROOT_ROLE_TYPE && + !this.flagResolver.isEnabled('customRootRoles') + ) { + throw new InvalidOperationError( + 'Custom root roles are not enabled.', + ); + } + const baseRole = { ...(await this.validateRole(role)), - roleType: CUSTOM_ROLE_TYPE, + roleType, }; const rolePermissions = role.permissions; const newRole = await this.roleStore.create(baseRole); if (rolePermissions) { - await this.store.addEnvironmentPermissionsToRole( - newRole.id, - rolePermissions, - ); + if (roleType === CUSTOM_ROOT_ROLE_TYPE) { + await this.store.addPermissionsToRole( + newRole.id, + rolePermissions.map(({ name }) => name), + ); + } else { + await this.store.addEnvironmentPermissionsToRole( + newRole.id, + rolePermissions, + ); + } } return newRole; } async updateRole(role: IRoleUpdate): Promise { + const roleType = + role.type === CUSTOM_ROOT_ROLE_TYPE + ? CUSTOM_ROOT_ROLE_TYPE + : CUSTOM_PROJECT_ROLE_TYPE; + + if ( + roleType === CUSTOM_ROOT_ROLE_TYPE && + !this.flagResolver.isEnabled('customRootRoles') + ) { + throw new InvalidOperationError( + 'Custom root roles are not enabled.', + ); + } + await this.validateRole(role, role.id); const baseRole = { id: role.id, name: role.name, description: role.description, - roleType: CUSTOM_ROLE_TYPE, + roleType, }; const rolePermissions = role.permissions; const newRole = await this.roleStore.update(baseRole); if (rolePermissions) { await this.store.wipePermissionsFromRole(newRole.id); - await this.store.addEnvironmentPermissionsToRole( - newRole.id, - rolePermissions, - ); + if (roleType === CUSTOM_ROOT_ROLE_TYPE) { + await this.store.addPermissionsToRole( + newRole.id, + rolePermissions.map(({ name }) => name), + ); + } else { + await this.store.addEnvironmentPermissionsToRole( + newRole.id, + rolePermissions, + ); + } } return newRole; } @@ -537,7 +593,10 @@ export class AccessService { async validateRoleIsNotBuiltIn(roleId: number): Promise { const role = await this.store.get(roleId); - if (role.type !== CUSTOM_ROLE_TYPE) { + if ( + role.type !== CUSTOM_PROJECT_ROLE_TYPE && + role.type !== CUSTOM_ROOT_ROLE_TYPE + ) { throw new InvalidOperationError( 'You cannot change built in roles.', ); diff --git a/src/lib/types/permissions.ts b/src/lib/types/permissions.ts index 8d6bcaa4cd4..412648dd1fe 100644 --- a/src/lib/types/permissions.ts +++ b/src/lib/types/permissions.ts @@ -46,3 +46,51 @@ export const SKIP_CHANGE_REQUEST = 'SKIP_CHANGE_REQUEST'; export const READ_PROJECT_API_TOKEN = 'READ_PROJECT_API_TOKEN'; export const CREATE_PROJECT_API_TOKEN = 'CREATE_PROJECT_API_TOKEN'; export const DELETE_PROJECT_API_TOKEN = 'DELETE_PROJECT_API_TOKEN'; + +export const ROOT_PERMISSION_CATEGORIES = [ + { + label: 'Addon', + permissions: [CREATE_ADDON, UPDATE_ADDON, DELETE_ADDON], + }, + { + label: 'API token', + permissions: [ + READ_API_TOKEN, + CREATE_API_TOKEN, + UPDATE_API_TOKEN, + DELETE_API_TOKEN, + ], + }, + { + label: 'Application', + permissions: [UPDATE_APPLICATION], + }, + { + label: 'Context field', + permissions: [ + CREATE_CONTEXT_FIELD, + UPDATE_CONTEXT_FIELD, + DELETE_CONTEXT_FIELD, + ], + }, + { + label: 'Project', + permissions: [CREATE_PROJECT], + }, + { + label: 'Role', + permissions: [READ_ROLE, UPDATE_ROLE], + }, + { + label: 'Segment', + permissions: [CREATE_SEGMENT, UPDATE_SEGMENT, DELETE_SEGMENT], + }, + { + label: 'Strategy', + permissions: [CREATE_STRATEGY, UPDATE_STRATEGY, DELETE_STRATEGY], + }, + { + label: 'Tag type', + permissions: [UPDATE_TAG_TYPE, DELETE_TAG_TYPE], + }, +]; diff --git a/src/lib/util/constants.ts b/src/lib/util/constants.ts index ebfd409cef2..d317034a79c 100644 --- a/src/lib/util/constants.ts +++ b/src/lib/util/constants.ts @@ -7,7 +7,8 @@ export const ROOT_PERMISSION_TYPE = 'root'; export const ENVIRONMENT_PERMISSION_TYPE = 'environment'; export const PROJECT_PERMISSION_TYPE = 'project'; -export const CUSTOM_ROLE_TYPE = 'custom'; +export const CUSTOM_ROOT_ROLE_TYPE = 'root-custom'; +export const CUSTOM_PROJECT_ROLE_TYPE = 'custom'; /* CONTEXT FIELD OPERATORS */ diff --git a/src/test/e2e/services/access-service.e2e.test.ts b/src/test/e2e/services/access-service.e2e.test.ts index fbf8c848993..316b86ab666 100644 --- a/src/test/e2e/services/access-service.e2e.test.ts +++ b/src/test/e2e/services/access-service.e2e.test.ts @@ -219,7 +219,7 @@ beforeAll(async () => { experimental: { environments: { enabled: true } }, }); groupService = new GroupService(stores, { getLogger }); - accessService = new AccessService(stores, { getLogger }, groupService); + accessService = new AccessService(stores, config, groupService); const roles = await accessService.getRootRoles(); editorRole = roles.find((r) => r.name === RoleName.EDITOR); adminRole = roles.find((r) => r.name === RoleName.ADMIN); diff --git a/src/test/fixtures/access-service-mock.ts b/src/test/fixtures/access-service-mock.ts index 0583410fc8e..2aaa9e6c126 100644 --- a/src/test/fixtures/access-service-mock.ts +++ b/src/test/fixtures/access-service-mock.ts @@ -20,7 +20,7 @@ class AccessServiceMock extends AccessService { roleStore: undefined, environmentStore: undefined, }, - { getLogger: noLoggerProvider }, + { getLogger: noLoggerProvider, flagResolver: undefined }, undefined, ); } From f3e6c18e3a576f0664f5b34055dae7c55fc36e10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Tue, 13 Jun 2023 18:44:36 +0100 Subject: [PATCH 06/11] adapt new role logic in all the places --- .../admin/groups/GroupForm/GroupForm.tsx | 28 ++---- .../groups/GroupsList/GroupCard/GroupCard.tsx | 16 +-- .../admin/groups/GroupsList/GroupsList.tsx | 8 -- .../EditProjectRole/EditProjectRole.tsx | 9 +- .../admin/roles/RoleForm/RoleForm.tsx | 1 + .../admin/roles/RoleModal/RoleModal.tsx | 15 ++- .../RoleDeleteDialog/RoleDeleteDialog.tsx | 27 ++++- .../RoleDeleteDialogGroups.tsx | 86 ++++++++++++++++ .../RoleDeleteDialogServiceAccounts.tsx | 7 +- .../RoleDeleteDialogUsers.tsx | 8 +- .../RolePermissionsCell.tsx | 31 ++++++ .../admin/roles/RolesTable/RolesTable.tsx | 9 +- .../ServiceAccountsTable.tsx | 4 + .../admin/users/UserForm/UserForm.tsx | 76 ++------------ .../admin/users/UsersList/UsersList.tsx | 4 + .../admin/users/hooks/useAddUserForm.ts | 14 ++- .../component/common/RoleBadge/RoleBadge.tsx | 23 +++++ .../RoleDescription/RoleDescription.tsx | 98 +++++++++++++++++++ .../common/RoleSelect/RoleSelect.tsx | 10 +- .../common/Table/cells/RoleCell/RoleCell.tsx | 17 ++++ .../ProjectRoleDescription.tsx | 17 ++-- .../user/Profile/ProfileTab/ProfileTab.tsx | 28 +++--- .../useProjectRole.ts => useRole/useRole.ts} | 29 ++++-- frontend/src/interfaces/role.ts | 2 +- 24 files changed, 394 insertions(+), 173 deletions(-) create mode 100644 frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogGroups/RoleDeleteDialogGroups.tsx create mode 100644 frontend/src/component/admin/roles/RolesTable/RolePermissionsCell/RolePermissionsCell.tsx create mode 100644 frontend/src/component/common/RoleBadge/RoleBadge.tsx create mode 100644 frontend/src/component/common/RoleDescription/RoleDescription.tsx create mode 100644 frontend/src/component/common/Table/cells/RoleCell/RoleCell.tsx rename frontend/src/hooks/api/getters/{useProjectRole/useProjectRole.ts => useRole/useRole.ts} (55%) diff --git a/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx b/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx index 80830401a35..389fce95968 100644 --- a/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx +++ b/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx @@ -1,5 +1,5 @@ import React, { FC } from 'react'; -import { Autocomplete, Box, Button, styled, TextField } from '@mui/material'; +import { Box, Button, styled } from '@mui/material'; import { UG_DESC_ID, UG_NAME_ID } from 'utils/testIds'; import Input from 'component/common/Input/Input'; import { IGroupUser } from 'interfaces/group'; @@ -10,9 +10,10 @@ import { ItemList } from 'component/common/ItemList/ItemList'; import useAuthSettings from 'hooks/api/getters/useAuthSettings/useAuthSettings'; import { Link } from 'react-router-dom'; import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; -import { IProjectRole } from 'interfaces/role'; +import IRole, { IProjectRole } from 'interfaces/role'; import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { RoleSelect } from 'component/common/RoleSelect/RoleSelect'; const StyledForm = styled('form')(() => ({ display: 'flex', @@ -128,10 +129,8 @@ export const GroupForm: FC = ({ const groupRootRolesEnabled = Boolean(uiConfig.flags.groupRootRoles); - const roleIdToRole = (rootRoleId: number | null): IProjectRole | null => { - return ( - roles.find((role: IProjectRole) => role.id === rootRoleId) || null - ); + const roleIdToRole = (rootRoleId: number | null): IRole | null => { + return roles.find((role: IRole) => role.id === rootRoleId) || null; }; const renderRoleOption = ( @@ -214,23 +213,12 @@ export const GroupForm: FC = ({
    - - setRootRole(newValue?.id || null) + setValue={role => + setRootRole(role?.id || null) } - options={roles.filter( - (role: IProjectRole) => - role.name !== 'Viewer' - )} - renderOption={renderRoleOption} - getOptionLabel={option => option.name} - renderInput={params => ( - - )} /> diff --git a/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCard.tsx b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCard.tsx index 25ec159fa61..bc74ce3d201 100644 --- a/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCard.tsx +++ b/frontend/src/component/admin/groups/GroupsList/GroupCard/GroupCard.tsx @@ -6,7 +6,7 @@ import { GroupCardAvatars } from './GroupCardAvatars/GroupCardAvatars'; import { Badge } from 'component/common/Badge/Badge'; import { GroupCardActions } from './GroupCardActions/GroupCardActions'; import TopicOutlinedIcon from '@mui/icons-material/TopicOutlined'; -import { IProjectRole } from 'interfaces/role'; +import { RoleBadge } from 'component/common/RoleBadge/RoleBadge'; const StyledLink = styled(Link)(({ theme }) => ({ textDecoration: 'none', @@ -86,14 +86,12 @@ const InfoBadgeDescription = styled('span')(({ theme }) => ({ interface IGroupCardProps { group: IGroup; - rootRoles: IProjectRole[]; onEditUsers: (group: IGroup) => void; onRemoveGroup: (group: IGroup) => void; } export const GroupCard = ({ group, - rootRoles, onEditUsers, onRemoveGroup, }: IGroupCardProps) => { @@ -117,17 +115,7 @@ export const GroupCard = ({ show={

    Root role:

    - } - > - { - rootRoles.find( - (role: IProjectRole) => - role.id === group.rootRole - )?.name - } - +
    } /> diff --git a/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx b/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx index 02e475d03f7..11b1ff1594d 100644 --- a/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx +++ b/frontend/src/component/admin/groups/GroupsList/GroupsList.tsx @@ -18,8 +18,6 @@ import { Add } from '@mui/icons-material'; import { NAVIGATE_TO_CREATE_GROUP } from 'utils/testIds'; import { EditGroupUsers } from '../Group/EditGroupUsers/EditGroupUsers'; import { RemoveGroup } from '../RemoveGroup/RemoveGroup'; -import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; -import { IProjectRole } from 'interfaces/role'; type PageQueryType = Partial>; @@ -51,7 +49,6 @@ export const GroupsList: VFC = () => { const [searchValue, setSearchValue] = useState( searchParams.get('search') || '' ); - const { roles } = useUsers(); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); @@ -85,10 +82,6 @@ export const GroupsList: VFC = () => { setRemoveOpen(true); }; - const getBindableRootRoles = () => { - return roles.filter((role: IProjectRole) => role.type === 'root'); - }; - return ( { diff --git a/frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx b/frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx index 17416c12e57..13aa6169b97 100644 --- a/frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx +++ b/frontend/src/component/admin/projectRoles/EditProjectRole/EditProjectRole.tsx @@ -2,7 +2,7 @@ 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 useProjectRole from 'hooks/api/getters/useProjectRole/useProjectRole'; +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'; @@ -16,7 +16,7 @@ const EditProjectRole = () => { const { uiConfig } = useUiConfig(); const { setToastData, setToastApiError } = useToast(); const roleId = useRequiredPathParam('id'); - const { role } = useProjectRole(roleId); + const { role, refetch } = useRole(roleId); const navigate = useNavigate(); const { @@ -35,18 +35,17 @@ const EditProjectRole = () => { validateName, clearErrors, getRoleKey, - } = useProjectRoleForm(role.name, role.description, role?.permissions); + } = useProjectRoleForm(role?.name, role?.description, role?.permissions); const formatApiCode = () => { return `curl --location --request PUT '${ uiConfig.unleashUrl - }/api/admin/roles/${role.id}' \\ + }/api/admin/roles/${role?.id}' \\ --header 'Authorization: INSERT_API_KEY' \\ --header 'Content-Type: application/json' \\ --data-raw '${JSON.stringify(getProjectRolePayload(), undefined, 2)}'`; }; - const { refetch } = useProjectRole(roleId); const { updateRole, loading } = useRolesApi(); const onSubmit = async (e: Event) => { diff --git a/frontend/src/component/admin/roles/RoleForm/RoleForm.tsx b/frontend/src/component/admin/roles/RoleForm/RoleForm.tsx index 0e6e0ede786..47b757ba4b8 100644 --- a/frontend/src/component/admin/roles/RoleForm/RoleForm.tsx +++ b/frontend/src/component/admin/roles/RoleForm/RoleForm.tsx @@ -117,6 +117,7 @@ export const RoleForm = ({ {[...categories].map(category => ( } diff --git a/frontend/src/component/admin/roles/RoleModal/RoleModal.tsx b/frontend/src/component/admin/roles/RoleModal/RoleModal.tsx index 8ad97d5307c..5b8fa7bd553 100644 --- a/frontend/src/component/admin/roles/RoleModal/RoleModal.tsx +++ b/frontend/src/component/admin/roles/RoleModal/RoleModal.tsx @@ -1,6 +1,5 @@ import { Button, styled } from '@mui/material'; import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; -import IRole from 'interfaces/role'; import { useRoleForm } from '../RoleForm/useRoleForm'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import FormTemplate from 'component/common/FormTemplate/FormTemplate'; @@ -10,6 +9,7 @@ import useToast from 'hooks/useToast'; 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'; const StyledForm = styled('form')(() => ({ display: 'flex', @@ -29,12 +29,14 @@ const StyledCancelButton = styled(Button)(({ theme }) => ({ })); interface IRoleModalProps { - role?: IRole; + roleId?: number; open: boolean; setOpen: React.Dispatch>; } -export const RoleModal = ({ role, open, setOpen }: IRoleModalProps) => { +export const RoleModal = ({ roleId, open, setOpen }: IRoleModalProps) => { + const { role, refetch: refetchRole } = useRole(roleId?.toString()); + const { name, setName, @@ -53,7 +55,7 @@ export const RoleModal = ({ role, open, setOpen }: IRoleModalProps) => { clearError, ErrorField, } = useRoleForm(role?.name, role?.description, role?.permissions); - const { refetch } = useRoles(); + const { refetch: refetchRoles } = useRoles(); const { addRole, updateRole, loading } = useRolesApi(); const { setToastData, setToastApiError } = useToast(); const { uiConfig } = useUiConfig(); @@ -82,6 +84,11 @@ export const RoleModal = ({ role, open, setOpen }: IRoleModalProps) => { setName(name); }; + const refetch = () => { + refetchRoles(); + refetchRole(); + }; + const onSubmit = async (e: FormEvent) => { e.preventDefault(); diff --git a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialog.tsx b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialog.tsx index ed5c49d6843..f3c8aef3c8f 100644 --- a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialog.tsx +++ b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialog.tsx @@ -6,6 +6,8 @@ import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; import IRole from 'interfaces/role'; import { RoleDeleteDialogUsers } from './RoleDeleteDialogUsers/RoleDeleteDialogUsers'; import { RoleDeleteDialogServiceAccounts } from './RoleDeleteDialogServiceAccounts/RoleDeleteDialogServiceAccounts'; +import { useGroups } from 'hooks/api/getters/useGroups/useGroups'; +import { RoleDeleteDialogGroups } from './RoleDeleteDialogGroups/RoleDeleteDialogGroups'; const StyledTableContainer = styled('div')(({ theme }) => ({ marginTop: theme.spacing(1.5), @@ -30,11 +32,13 @@ export const RoleDeleteDialog = ({ }: IRoleDeleteDialogProps) => { const { users } = useUsers(); const { serviceAccounts } = useServiceAccounts(); + const { groups } = useGroups(); const roleUsers = users.filter(({ rootRole }) => rootRole === role?.id); const roleServiceAccounts = serviceAccounts.filter( ({ rootRole }) => rootRole === role?.id ); + const roleGroups = groups?.filter(({ rootRole }) => rootRole === role?.id); const deleteMessage = ( <> @@ -55,14 +59,16 @@ export const RoleDeleteDialog = ({ > - If you delete this role, all current accounts + If you delete this role, all current entities associated with it will be automatically assigned - the preconfigured Viewer role. + the predefined Viewer role. {deleteMessage} } /> + + + Groups ({roleGroups?.length}): + + + + + + } + /> } elseShow={

    {deleteMessage}

    } diff --git a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogGroups/RoleDeleteDialogGroups.tsx b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogGroups/RoleDeleteDialogGroups.tsx new file mode 100644 index 00000000000..52e00bf4a83 --- /dev/null +++ b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogGroups/RoleDeleteDialogGroups.tsx @@ -0,0 +1,86 @@ +import { VirtualizedTable } from 'component/common/Table'; +import { DateCell } from 'component/common/Table/cells/DateCell/DateCell'; +import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; +import { useMemo, useState } from 'react'; +import { useTable, useSortBy, useFlexLayout, Column } from 'react-table'; +import { sortTypes } from 'utils/sortTypes'; +import { IGroup } from 'interfaces/group'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; + +export type PageQueryType = Partial< + Record<'sort' | 'order' | 'search', string> +>; + +interface IRoleDeleteDialogGroupsProps { + groups: IGroup[]; +} + +export const RoleDeleteDialogGroups = ({ + groups, +}: IRoleDeleteDialogGroupsProps) => { + const [initialState] = useState(() => ({ + sortBy: [{ id: 'createdAt' }], + })); + + console.log(groups); + + const columns = useMemo( + () => + [ + { + id: 'name', + Header: 'Name', + accessor: (row: any) => row.name || '', + minWidth: 200, + Cell: ({ row: { original: group } }: any) => ( + + ), + }, + { + Header: 'Created', + accessor: 'createdAt', + Cell: DateCell, + sortType: 'date', + width: 120, + maxWidth: 120, + }, + { + id: 'users', + Header: 'Users', + accessor: (row: IGroup) => + row.users.length === 1 + ? '1 user' + : `${row.users.length} users`, + Cell: TextCell, + maxWidth: 150, + }, + ] as Column[], + [] + ); + + const { headerGroups, rows, prepareRow } = useTable( + { + columns, + data: groups, + initialState, + sortTypes, + autoResetHiddenColumns: false, + autoResetSortBy: false, + disableSortRemove: true, + disableMultiSort: true, + }, + useSortBy, + useFlexLayout + ); + + return ( + + ); +}; diff --git a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogServiceAccounts/RoleDeleteDialogServiceAccounts.tsx b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogServiceAccounts/RoleDeleteDialogServiceAccounts.tsx index 9906bd6fabe..a50b116643b 100644 --- a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogServiceAccounts/RoleDeleteDialogServiceAccounts.tsx +++ b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogServiceAccounts/RoleDeleteDialogServiceAccounts.tsx @@ -6,7 +6,6 @@ import { useTable, useSortBy, useFlexLayout, Column } from 'react-table'; import { sortTypes } from 'utils/sortTypes'; import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; import { IServiceAccount } from 'interfaces/service-account'; -import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts'; import { ServiceAccountTokensCell } from 'component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokensCell/ServiceAccountTokensCell'; export type PageQueryType = Partial< @@ -20,8 +19,6 @@ interface IRoleDeleteDialogServiceAccountsProps { export const RoleDeleteDialogServiceAccounts = ({ serviceAccounts, }: IRoleDeleteDialogServiceAccountsProps) => { - const { roles } = useServiceAccounts(); - const [initialState] = useState(() => ({ sortBy: [{ id: 'seenAt' }], })); @@ -40,7 +37,6 @@ export const RoleDeleteDialogServiceAccounts = ({ subtitle={serviceAccount.username} /> ), - searchable: true, }, { id: 'tokens', @@ -61,7 +57,6 @@ export const RoleDeleteDialogServiceAccounts = ({ value={value} /> ), - searchable: true, maxWidth: 100, }, { @@ -86,7 +81,7 @@ export const RoleDeleteDialogServiceAccounts = ({ maxWidth: 150, }, ] as Column[], - [roles] + [] ); const { headerGroups, rows, prepareRow } = useTable( diff --git a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogUsers/RoleDeleteDialogUsers.tsx b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogUsers/RoleDeleteDialogUsers.tsx index a23eb636dde..0ca7f991b1d 100644 --- a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogUsers/RoleDeleteDialogUsers.tsx +++ b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogUsers/RoleDeleteDialogUsers.tsx @@ -6,8 +6,6 @@ import { useTable, useSortBy, useFlexLayout, Column } from 'react-table'; import { sortTypes } from 'utils/sortTypes'; import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; import { IUser } from 'interfaces/user'; -import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; -import { ServiceAccountTokensCell } from 'component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokensCell/ServiceAccountTokensCell'; export type PageQueryType = Partial< Record<'sort' | 'order' | 'search', string> @@ -20,8 +18,6 @@ interface IRoleDeleteDialogUsersProps { export const RoleDeleteDialogUsers = ({ users, }: IRoleDeleteDialogUsersProps) => { - const { roles } = useUsers(); - const [initialState] = useState(() => ({ sortBy: [{ id: 'last-login' }], })); @@ -40,7 +36,6 @@ export const RoleDeleteDialogUsers = ({ subtitle={user.email || user.username} /> ), - searchable: true, }, { Header: 'Created', @@ -61,12 +56,11 @@ export const RoleDeleteDialogUsers = ({ title={date => `Last login: ${date}`} /> ), - disableGlobalFilter: true, sortType: 'date', maxWidth: 150, }, ] as Column[], - [roles] + [] ); const { headerGroups, rows, prepareRow } = useTable( diff --git a/frontend/src/component/admin/roles/RolesTable/RolePermissionsCell/RolePermissionsCell.tsx b/frontend/src/component/admin/roles/RolesTable/RolePermissionsCell/RolePermissionsCell.tsx new file mode 100644 index 00000000000..0b600640170 --- /dev/null +++ b/frontend/src/component/admin/roles/RolesTable/RolePermissionsCell/RolePermissionsCell.tsx @@ -0,0 +1,31 @@ +import { VFC } from 'react'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +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'; + +interface IRolePermissionsCellProps { + row: { original: IRole }; +} + +export const RolePermissionsCell: VFC = ({ + row, +}) => { + const { original: rowRole } = row; + const { role } = useRole(rowRole.id.toString()); + + if (!role || role.type === 'root') return null; + + return ( + + } + > + {role.permissions?.length === 1 + ? '1 permission' + : `${role.permissions?.length} permissions`} + + + ); +}; diff --git a/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx b/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx index 2bf103b565d..0f26103dc27 100644 --- a/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx +++ b/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx @@ -23,6 +23,7 @@ import { RoleDeleteDialog } from './RoleDeleteDialog/RoleDeleteDialog'; 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'; export const RolesTable = () => { const { setToastData, setToastApiError } = useToast(); @@ -72,6 +73,12 @@ export const RolesTable = () => { searchable: true, minWidth: 100, }, + { + id: 'permissions', + Header: 'Permissions', + Cell: RolePermissionsCell, + maxWidth: 140, + }, { Header: 'Actions', id: 'Actions', @@ -211,7 +218,7 @@ export const RolesTable = () => { } /> diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx index a2c0761e2d5..8013a338aab 100644 --- a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx +++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx @@ -28,6 +28,7 @@ import { ServiceAccountTokenDialog } from './ServiceAccountTokenDialog/ServiceAc import { ServiceAccountTokensCell } from './ServiceAccountTokensCell/ServiceAccountTokensCell'; import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; import { IServiceAccount } from 'interfaces/service-account'; +import { RoleCell } from 'component/common/Table/cells/RoleCell/RoleCell'; export const ServiceAccountsTable = () => { const { setToastData, setToastApiError } = useToast(); @@ -92,6 +93,9 @@ export const ServiceAccountsTable = () => { accessor: (row: any) => roles.find((role: IRole) => role.id === row.rootRole) ?.name || '', + Cell: ({ row: { original: serviceAccount }, value }: any) => ( + + ), maxWidth: 120, }, { diff --git a/frontend/src/component/admin/users/UserForm/UserForm.tsx b/frontend/src/component/admin/users/UserForm/UserForm.tsx index 706fbee6671..6125ea605df 100644 --- a/frontend/src/component/admin/users/UserForm/UserForm.tsx +++ b/frontend/src/component/admin/users/UserForm/UserForm.tsx @@ -1,19 +1,11 @@ import Input from 'component/common/Input/Input'; -import { - FormControlLabel, - Button, - RadioGroup, - FormControl, - Typography, - Radio, - Switch, - styled, -} from '@mui/material'; +import { Button, FormControl, Typography, Switch, styled } from '@mui/material'; import React from 'react'; -import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { EDIT } from 'constants/misc'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { RoleSelect } from 'component/common/RoleSelect/RoleSelect'; +import IRole from 'interfaces/role'; const StyledForm = styled('form')(() => ({ display: 'flex', @@ -38,16 +30,6 @@ const StyledRoleSubtitle = styled(Typography)(({ theme }) => ({ margin: theme.spacing(1, 0), })); -const StyledRoleBox = styled(FormControlLabel)(({ theme }) => ({ - margin: theme.spacing(0.5, 0), - border: `1px solid ${theme.palette.divider}`, - padding: theme.spacing(2), -})); - -const StyledRoleRadio = styled(Radio)(({ theme }) => ({ - marginRight: theme.spacing(2), -})); - const StyledFlexRow = styled('div')(() => ({ display: 'flex', alignItems: 'center', @@ -66,12 +48,12 @@ const StyledCancelButton = styled(Button)(({ theme }) => ({ interface IUserForm { email: string; name: string; - rootRole: number; + rootRole: IRole | null; sendEmail: boolean; setEmail: React.Dispatch>; setName: React.Dispatch>; setSendEmail: React.Dispatch>; - setRootRole: React.Dispatch>; + setRootRole: React.Dispatch>; handleSubmit: (e: any) => void; handleCancel: () => void; errors: { [key: string]: string }; @@ -95,19 +77,8 @@ const UserForm: React.FC = ({ clearErrors, mode, }) => { - const { roles } = useUsers(); const { uiConfig } = useUiConfig(); - // @ts-expect-error - const sortRoles = (a, b) => { - if (b.name[0] < a.name[0]) { - return 1; - } else if (a.name[0] < b.name[0]) { - return -1; - } - return 0; - }; - return ( @@ -132,39 +103,10 @@ const UserForm: React.FC = ({ errorText={errors.email} onFocus={() => clearErrors()} /> - - - What is your team member allowed to do? - - setRootRole(+e.target.value)} - data-loading - > - {/* @ts-expect-error */} - {roles.sort(sortRoles).map(role => ( - - {role.name} - - {role.description} - - - } - control={ - - } - value={role.id} - /> - ))} - - + + What is your team member allowed to do? + + { const navigate = useNavigate(); @@ -126,6 +127,9 @@ const UsersList = () => { accessor: (row: any) => roles.find((role: IRole) => role.id === row.rootRole) ?.name || '', + Cell: ({ row: { original: user }, value }: any) => ( + + ), disableGlobalFilter: true, maxWidth: 120, }, diff --git a/frontend/src/component/admin/users/hooks/useAddUserForm.ts b/frontend/src/component/admin/users/hooks/useAddUserForm.ts index 607f1e30e17..83db52a2c77 100644 --- a/frontend/src/component/admin/users/hooks/useAddUserForm.ts +++ b/frontend/src/component/admin/users/hooks/useAddUserForm.ts @@ -1,17 +1,22 @@ import { useEffect, useState } from 'react'; import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import IRole from 'interfaces/role'; +import { useRoles } from 'hooks/api/getters/useRoles/useRoles'; const useCreateUserForm = ( initialName = '', initialEmail = '', - initialRootRole = 1 + initialRootRole = null ) => { const { uiConfig } = useUiConfig(); + const { roles } = useRoles(); const [name, setName] = useState(initialName); const [email, setEmail] = useState(initialEmail); const [sendEmail, setSendEmail] = useState(false); - const [rootRole, setRootRole] = useState(initialRootRole); + const [rootRole, setRootRole] = useState( + roles.find(({ id }) => id === initialRootRole) || null + ); const [errors, setErrors] = useState({}); const { users } = useUsers(); @@ -29,7 +34,7 @@ const useCreateUserForm = ( }, [uiConfig?.emailEnabled]); useEffect(() => { - setRootRole(initialRootRole); + setRootRole(roles.find(({ id }) => id === initialRootRole) || null); }, [initialRootRole]); const getAddUserPayload = () => { @@ -37,7 +42,7 @@ const useCreateUserForm = ( name: name, email: email, sendEmail: sendEmail, - rootRole: rootRole, + rootRole: rootRole?.id || 0, }; }; @@ -54,7 +59,6 @@ const useCreateUserForm = ( }; const validateEmail = () => { - // @ts-expect-error if (users.some(user => user['email'] === email)) { setErrors(prev => ({ ...prev, email: 'Email already exists' })); return false; diff --git a/frontend/src/component/common/RoleBadge/RoleBadge.tsx b/frontend/src/component/common/RoleBadge/RoleBadge.tsx new file mode 100644 index 00000000000..681103b19c7 --- /dev/null +++ b/frontend/src/component/common/RoleBadge/RoleBadge.tsx @@ -0,0 +1,23 @@ +import { Badge } from 'component/common/Badge/Badge'; +import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip'; +import { useRole } from 'hooks/api/getters/useRole/useRole'; +import { Person as UserIcon } from '@mui/icons-material'; +import { RoleDescription } from 'component/common/RoleDescription/RoleDescription'; + +interface IRoleBadgeProps { + roleId: number; +} + +export const RoleBadge = ({ roleId }: IRoleBadgeProps) => { + const { role } = useRole(roleId.toString()); + + if (!role) return null; + + return ( + }> + }> + {role.name} + + + ); +}; diff --git a/frontend/src/component/common/RoleDescription/RoleDescription.tsx b/frontend/src/component/common/RoleDescription/RoleDescription.tsx new file mode 100644 index 00000000000..085dab68027 --- /dev/null +++ b/frontend/src/component/common/RoleDescription/RoleDescription.tsx @@ -0,0 +1,98 @@ +import { SxProps, Theme, styled } from '@mui/material'; +import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender'; +import { ROOT_PERMISSION_CATEGORIES } from '@server/types/permissions'; +import { useRole } from 'hooks/api/getters/useRole/useRole'; + +const StyledDescription = styled('div', { + shouldForwardProp: prop => prop !== 'tooltip', +})<{ tooltip?: boolean }>(({ theme, tooltip }) => ({ + width: '100%', + maxWidth: theme.spacing(50), + padding: tooltip ? theme.spacing(1) : theme.spacing(3), + backgroundColor: tooltip + ? theme.palette.background.paper + : theme.palette.neutral.light, + color: theme.palette.text.secondary, + fontSize: theme.fontSizes.smallBody, + borderRadius: theme.shape.borderRadiusMedium, +})); + +const StyledDescriptionBlock = styled('div')(({ theme }) => ({ + marginTop: theme.spacing(2), +})); + +const StyledDescriptionHeader = styled('p')(({ theme }) => ({ + color: theme.palette.text.primary, + fontSize: theme.fontSizes.smallBody, + fontWeight: theme.fontWeight.bold, + marginBottom: theme.spacing(1), +})); + +const StyledDescriptionSubHeader = styled('p')(({ theme }) => ({ + fontSize: theme.fontSizes.smallBody, + marginTop: theme.spacing(1), +})); + +interface IRoleDescriptionProps { + roleId: number; + tooltip?: boolean; + className?: string; + sx?: SxProps; +} + +export const RoleDescription = ({ + roleId, + tooltip, + ...rest +}: IRoleDescriptionProps) => { + const { role } = useRole(roleId.toString()); + + if (!role) return null; + + const { name, description, permissions } = role; + + const categorizedPermissions = [...new Set(permissions)].map(permission => { + const category = ROOT_PERMISSION_CATEGORIES.find(category => + category.permissions.includes(permission.name) + ); + + return { + category: category ? category.label : 'Other', + permission, + }; + }); + + const categories = new Set( + categorizedPermissions.map(({ category }) => category).sort() + ); + + return ( + + + {name} + + + {description} + + + [...categories].map(category => ( + + + {category} + + {categorizedPermissions + .filter(({ category: c }) => c === category) + .map(({ permission }) => ( +

    + {permission.displayName} +

    + ))} +
    + )) + } + /> +
    + ); +}; diff --git a/frontend/src/component/common/RoleSelect/RoleSelect.tsx b/frontend/src/component/common/RoleSelect/RoleSelect.tsx index 3e175539d84..0f3df308b8b 100644 --- a/frontend/src/component/common/RoleSelect/RoleSelect.tsx +++ b/frontend/src/component/common/RoleSelect/RoleSelect.tsx @@ -6,6 +6,8 @@ import { } from '@mui/material'; import { useRoles } from 'hooks/api/getters/useRoles/useRoles'; import IRole from 'interfaces/role'; +import { RoleDescription } from '../RoleDescription/RoleDescription'; +import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender'; const StyledRoleOption = styled('div')(({ theme }) => ({ display: 'flex', @@ -16,7 +18,7 @@ const StyledRoleOption = styled('div')(({ theme }) => ({ }, })); -export interface IRoleSelectProps +interface IRoleSelectProps extends Partial> { value: IRole | null; setValue: (role: IRole | null) => void; @@ -58,6 +60,12 @@ export const RoleSelect = ({ )} {...rest} /> + ( + + )} + /> ); }; diff --git a/frontend/src/component/common/Table/cells/RoleCell/RoleCell.tsx b/frontend/src/component/common/Table/cells/RoleCell/RoleCell.tsx new file mode 100644 index 00000000000..3bc1f3440ff --- /dev/null +++ b/frontend/src/component/common/Table/cells/RoleCell/RoleCell.tsx @@ -0,0 +1,17 @@ +import { VFC } from 'react'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +import { TooltipLink } from 'component/common/TooltipLink/TooltipLink'; +import { RoleDescription } from 'component/common/RoleDescription/RoleDescription'; + +interface IRoleCellProps { + roleId: number; + value: string; +} + +export const RoleCell: VFC = ({ roleId, value }) => ( + + }> + {value} + + +); diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectRoleDescription/ProjectRoleDescription.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectRoleDescription/ProjectRoleDescription.tsx index 19b62b40a64..a33f3742983 100644 --- a/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectRoleDescription/ProjectRoleDescription.tsx +++ b/frontend/src/component/project/ProjectAccess/ProjectAccessAssign/ProjectRoleDescription/ProjectRoleDescription.tsx @@ -1,6 +1,6 @@ import { styled, SxProps, Theme } from '@mui/material'; import { ForwardedRef, forwardRef, useMemo, VFC } from 'react'; -import useProjectRole from 'hooks/api/getters/useProjectRole/useProjectRole'; +import { useRole } from 'hooks/api/getters/useRole/useRole'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import useProjectAccess from 'hooks/api/getters/useProjectAccess/useProjectAccess'; import { ProjectRoleDescriptionProjectPermissions } from './ProjectRoleDescriptionProjectPermissions/ProjectRoleDescriptionProjectPermissions'; @@ -64,13 +64,13 @@ export const ProjectRoleDescription: VFC = }: IProjectRoleDescriptionProps, ref: ForwardedRef ) => { - const { role } = useProjectRole(roleId.toString()); + const { role } = useRole(roleId.toString()); const { access } = useProjectAccess(projectId); const accessRole = access?.roles.find(role => role.id === roleId); const environments = useMemo(() => { const environments = new Set(); - role.permissions + role?.permissions ?.filter((permission: any) => permission.environment) .forEach((permission: any) => { environments.add(permission.environment); @@ -79,7 +79,7 @@ export const ProjectRoleDescription: VFC = }, [role]); const projectPermissions = useMemo(() => { - return role.permissions?.filter( + return role?.permissions?.filter( (permission: any) => !permission.environment ); }, [role]); @@ -92,7 +92,9 @@ export const ProjectRoleDescription: VFC = ref={ref} > 0} + condition={Boolean( + role?.permissions && role?.permissions?.length > 0 + )} show={ <> = @@ -132,7 +134,8 @@ export const ProjectRoleDescription: VFC = environment } permissions={ - role.permissions + role?.permissions || + [] } /> diff --git a/frontend/src/component/user/Profile/ProfileTab/ProfileTab.tsx b/frontend/src/component/user/Profile/ProfileTab/ProfileTab.tsx index 27ba8709a7a..c2fab492260 100644 --- a/frontend/src/component/user/Profile/ProfileTab/ProfileTab.tsx +++ b/frontend/src/component/user/Profile/ProfileTab/ProfileTab.tsx @@ -14,11 +14,11 @@ import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; import { useProfile } from 'hooks/api/getters/useProfile/useProfile'; import { useLocationSettings } from 'hooks/useLocationSettings'; import { IUser } from 'interfaces/user'; -import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import TopicOutlinedIcon from '@mui/icons-material/TopicOutlined'; import { useNavigate } from 'react-router-dom'; import { PageContent } from 'component/common/PageContent/PageContent'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { RoleBadge } from 'component/common/RoleBadge/RoleBadge'; const StyledHeader = styled('div')(({ theme }) => ({ display: 'flex', @@ -134,21 +134,17 @@ export const ProfileTab = ({ user }: IProfileTabProps) => { Access - Your root role - - } - iconRight - > - {profile?.rootRole.name} - - + ( + <> + + Your root role + + + + )} + /> Projects diff --git a/frontend/src/hooks/api/getters/useProjectRole/useProjectRole.ts b/frontend/src/hooks/api/getters/useRole/useRole.ts similarity index 55% rename from frontend/src/hooks/api/getters/useProjectRole/useProjectRole.ts rename to frontend/src/hooks/api/getters/useRole/useRole.ts index 5763ae6ebf2..c47739e546a 100644 --- a/frontend/src/hooks/api/getters/useProjectRole/useProjectRole.ts +++ b/frontend/src/hooks/api/getters/useRole/useRole.ts @@ -2,20 +2,35 @@ import { mutate, SWRConfiguration } from 'swr'; import { useState, useEffect } from 'react'; import { formatApiPath } from 'utils/formatPath'; import handleErrorResponses from '../httpErrorResponseHandler'; -import { useEnterpriseSWR } from '../useEnterpriseSWR/useEnterpriseSWR'; +import IRole from 'interfaces/role'; +import useUiConfig from '../useUiConfig/useUiConfig'; +import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR'; + +export interface IUseRoleOutput { + role?: IRole; + refetch: () => void; + loading: boolean; + error?: Error; +} + +export const useRole = ( + id?: string, + options: SWRConfiguration = {} +): IUseRoleOutput => { + const { isEnterprise } = useUiConfig(); -const useProjectRole = (id: string, options: SWRConfiguration = {}) => { const fetcher = () => { const path = formatApiPath(`api/admin/roles/${id}`); return fetch(path, { method: 'GET', }) - .then(handleErrorResponses('project role')) + .then(handleErrorResponses('role')) .then(res => res.json()); }; - const { data, error } = useEnterpriseSWR( - {}, + const { data, error } = useConditionalSWR( + Boolean(id) && isEnterprise(), + undefined, `api/admin/roles/${id}`, fetcher, options @@ -31,11 +46,9 @@ const useProjectRole = (id: string, options: SWRConfiguration = {}) => { }, [data, error]); return { - role: data ? data : {}, + role: data as IRole, error, loading, refetch, }; }; - -export default useProjectRole; diff --git a/frontend/src/interfaces/role.ts b/frontend/src/interfaces/role.ts index fba3a51d556..284a3c6a7a2 100644 --- a/frontend/src/interfaces/role.ts +++ b/frontend/src/interfaces/role.ts @@ -6,7 +6,7 @@ interface IRole { project: string | null; description: string; type: string; - permissions: IPermission[]; + permissions?: IPermission[]; } export interface IProjectRole { From bcdacb8706e71c311991397a60d6ce3f10c5c629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Tue, 13 Jun 2023 20:24:22 +0100 Subject: [PATCH 07/11] test: update snap, fix test --- src/lib/__snapshots__/create-config.test.ts.snap | 2 ++ src/lib/services/access-service.test.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index c9489da2f86..3e77646d447 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -71,6 +71,7 @@ exports[`should create default config 1`] = ` "anonymiseEventLog": false, "caseInsensitiveInOperators": false, "cleanClientApi": false, + "customRootRoles": false, "demo": false, "disableBulkToggle": false, "disableNotifications": false, @@ -105,6 +106,7 @@ exports[`should create default config 1`] = ` "anonymiseEventLog": false, "caseInsensitiveInOperators": false, "cleanClientApi": false, + "customRootRoles": false, "demo": false, "disableBulkToggle": false, "disableNotifications": false, diff --git a/src/lib/services/access-service.test.ts b/src/lib/services/access-service.test.ts index 05a54964328..a9e3385d2cd 100644 --- a/src/lib/services/access-service.test.ts +++ b/src/lib/services/access-service.test.ts @@ -3,7 +3,7 @@ import getLogger from '../../test/fixtures/no-logger'; import createStores from '../../test/fixtures/store'; import { AccessService, IRoleValidation } from './access-service'; import { GroupService } from './group-service'; -import { createTestConfig } from 'test/config/test-config'; +import { createTestConfig } from '../../test/config/test-config'; function getSetup(withNameInUse: boolean) { const stores = createStores(); From 25fb07a210eb2a8f6b9603af8ce0ef6313184220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Wed, 14 Jun 2023 10:18:59 +0100 Subject: [PATCH 08/11] fix: graceful fallback to OSS, clean up --- .../RoleDeleteDialogGroups.tsx | 2 - .../admin/roles/RolesTable/RolesTable.tsx | 2 +- .../ServiceAccountTokensCell.tsx | 9 ++- .../common/HtmlTooltip/HtmlTooltip.tsx | 7 +- .../component/common/RoleBadge/RoleBadge.tsx | 6 +- .../RoleDescription/RoleDescription.tsx | 4 +- .../common/Table/cells/RoleCell/RoleCell.tsx | 25 +++++-- .../src/hooks/api/getters/useRole/useRole.ts | 65 ++++++++++-------- .../hooks/api/getters/useRoles/useRoles.ts | 66 ++++++++++++++----- src/lib/routes/admin-api/user-admin.ts | 1 - 10 files changed, 125 insertions(+), 62 deletions(-) diff --git a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogGroups/RoleDeleteDialogGroups.tsx b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogGroups/RoleDeleteDialogGroups.tsx index 52e00bf4a83..68050045a35 100644 --- a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogGroups/RoleDeleteDialogGroups.tsx +++ b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogGroups/RoleDeleteDialogGroups.tsx @@ -22,8 +22,6 @@ export const RoleDeleteDialogGroups = ({ sortBy: [{ id: 'createdAt' }], })); - console.log(groups); - const columns = useMemo( () => [ diff --git a/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx b/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx index 0f26103dc27..0a787249e8c 100644 --- a/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx +++ b/frontend/src/component/admin/roles/RolesTable/RolesTable.tsx @@ -106,7 +106,7 @@ export const RolesTable = () => { searchable: true, }, ], - [roles] + [] ); const [initialState] = useState({ diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokensCell/ServiceAccountTokensCell.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokensCell/ServiceAccountTokensCell.tsx index e86111a90d4..f1cc930d71c 100644 --- a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokensCell/ServiceAccountTokensCell.tsx +++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokensCell/ServiceAccountTokensCell.tsx @@ -24,11 +24,10 @@ export const ServiceAccountTokensCell: VFC = ({ }) => { const { searchQuery } = useSearchHighlightContext(); - if ( - onCreateToken && - (!serviceAccount.tokens || serviceAccount.tokens.length === 0) - ) - return ; + if (!serviceAccount.tokens || serviceAccount.tokens.length === 0) { + if (!onCreateToken) return 0 tokens; + else return ; + } return ( diff --git a/frontend/src/component/common/HtmlTooltip/HtmlTooltip.tsx b/frontend/src/component/common/HtmlTooltip/HtmlTooltip.tsx index e826ff1acd3..a9a458f8026 100644 --- a/frontend/src/component/common/HtmlTooltip/HtmlTooltip.tsx +++ b/frontend/src/component/common/HtmlTooltip/HtmlTooltip.tsx @@ -65,6 +65,7 @@ export interface IHtmlTooltipProps extends TooltipProps { fontSize?: string; } -export const HtmlTooltip = (props: IHtmlTooltipProps) => ( - {props.children} -); +export const HtmlTooltip = (props: IHtmlTooltipProps) => { + if (!Boolean(props.title)) return props.children; + return {props.children}; +}; diff --git a/frontend/src/component/common/RoleBadge/RoleBadge.tsx b/frontend/src/component/common/RoleBadge/RoleBadge.tsx index 681103b19c7..726cd1f7b29 100644 --- a/frontend/src/component/common/RoleBadge/RoleBadge.tsx +++ b/frontend/src/component/common/RoleBadge/RoleBadge.tsx @@ -15,7 +15,11 @@ export const RoleBadge = ({ roleId }: IRoleBadgeProps) => { return ( }> - }> + } + sx={{ cursor: 'pointer' }} + > {role.name} diff --git a/frontend/src/component/common/RoleDescription/RoleDescription.tsx b/frontend/src/component/common/RoleDescription/RoleDescription.tsx index 085dab68027..80e8432e547 100644 --- a/frontend/src/component/common/RoleDescription/RoleDescription.tsx +++ b/frontend/src/component/common/RoleDescription/RoleDescription.tsx @@ -75,7 +75,9 @@ export const RoleDescription = ({ {description} 0 && role.type !== 'root' + } show={() => [...categories].map(category => ( diff --git a/frontend/src/component/common/Table/cells/RoleCell/RoleCell.tsx b/frontend/src/component/common/Table/cells/RoleCell/RoleCell.tsx index 3bc1f3440ff..f8b32610a81 100644 --- a/frontend/src/component/common/Table/cells/RoleCell/RoleCell.tsx +++ b/frontend/src/component/common/Table/cells/RoleCell/RoleCell.tsx @@ -2,16 +2,27 @@ import { VFC } from 'react'; import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; import { TooltipLink } from 'component/common/TooltipLink/TooltipLink'; import { RoleDescription } from 'component/common/RoleDescription/RoleDescription'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; interface IRoleCellProps { roleId: number; value: string; } -export const RoleCell: VFC = ({ roleId, value }) => ( - - }> - {value} - - -); +export const RoleCell: VFC = ({ roleId, value }) => { + const { isEnterprise, uiConfig } = useUiConfig(); + + if (isEnterprise() && uiConfig.flags.customRootRoles) { + return ( + + } + > + {value} + + + ); + } + + return {value}; +}; diff --git a/frontend/src/hooks/api/getters/useRole/useRole.ts b/frontend/src/hooks/api/getters/useRole/useRole.ts index c47739e546a..837b32fb3d1 100644 --- a/frontend/src/hooks/api/getters/useRole/useRole.ts +++ b/frontend/src/hooks/api/getters/useRole/useRole.ts @@ -1,5 +1,5 @@ -import { mutate, SWRConfiguration } from 'swr'; -import { useState, useEffect } from 'react'; +import { SWRConfiguration } from 'swr'; +import { useMemo } from 'react'; import { formatApiPath } from 'utils/formatPath'; import handleErrorResponses from '../httpErrorResponseHandler'; import IRole from 'interfaces/role'; @@ -19,36 +19,49 @@ export const useRole = ( ): IUseRoleOutput => { const { isEnterprise } = useUiConfig(); - const fetcher = () => { - const path = formatApiPath(`api/admin/roles/${id}`); - return fetch(path, { - method: 'GET', - }) - .then(handleErrorResponses('role')) - .then(res => res.json()); - }; - - const { data, error } = useConditionalSWR( + const { data, error, mutate } = useConditionalSWR( Boolean(id) && isEnterprise(), undefined, - `api/admin/roles/${id}`, + formatApiPath(`api/admin/roles/${id}`), fetcher, options ); - const [loading, setLoading] = useState(!error && !data); - const refetch = () => { - mutate(`api/admin/roles/${id}`); - }; + const { + data: ossData, + error: ossError, + mutate: ossMutate, + } = useConditionalSWR( + Boolean(id) && !isEnterprise(), + { rootRoles: [] }, + formatApiPath(`api/admin/user-admin`), + fetcher, + options + ); - useEffect(() => { - setLoading(!error && !data); - }, [data, error]); + return useMemo(() => { + if (!isEnterprise()) { + return { + role: ((ossData?.rootRoles ?? []) as IRole[]).find( + ({ id: rId }) => rId === +id! + ), + loading: !ossError && !ossData, + refetch: () => ossMutate(), + error: ossError, + }; + } else { + return { + role: data as IRole, + loading: !error && !data, + refetch: () => mutate(), + error, + }; + } + }, [data, error, mutate, ossData, ossError, ossMutate]); +}; - return { - role: data as IRole, - error, - loading, - refetch, - }; +const fetcher = (path: string) => { + return fetch(path) + .then(handleErrorResponses('Role')) + .then(res => res.json()); }; diff --git a/frontend/src/hooks/api/getters/useRoles/useRoles.ts b/frontend/src/hooks/api/getters/useRoles/useRoles.ts index c3e7d570887..f18920daf3f 100644 --- a/frontend/src/hooks/api/getters/useRoles/useRoles.ts +++ b/frontend/src/hooks/api/getters/useRoles/useRoles.ts @@ -5,11 +5,12 @@ import handleErrorResponses from '../httpErrorResponseHandler'; import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR'; import useUiConfig from '../useUiConfig/useUiConfig'; -const ROOT_ROLES = ['root', 'root-custom']; +const ROOT_ROLE = 'root'; +const ROOT_ROLES = [ROOT_ROLE, 'root-custom']; const PROJECT_ROLES = ['project', 'custom']; export const useRoles = () => { - const { isEnterprise } = useUiConfig(); + const { isEnterprise, uiConfig } = useUiConfig(); const { data, error, mutate } = useConditionalSWR( isEnterprise(), @@ -18,20 +19,45 @@ export const useRoles = () => { fetcher ); - return useMemo( - () => ({ - roles: (data?.roles.filter(({ type }: IRole) => - ROOT_ROLES.includes(type) - ) ?? []) as IRole[], - projectRoles: (data?.roles.filter(({ type }: IRole) => - PROJECT_ROLES.includes(type) - ) ?? []) as IProjectRole[], - loading: !error && !data, - refetch: () => mutate(), - error, - }), - [data, error, mutate] + const { + data: ossData, + error: ossError, + mutate: ossMutate, + } = useConditionalSWR( + !isEnterprise(), + { rootRoles: [] }, + formatApiPath(`api/admin/user-admin`), + fetcher ); + + return useMemo(() => { + if (!isEnterprise()) { + return { + roles: ossData?.rootRoles + .filter(({ type }: IRole) => type === ROOT_ROLE) + .sort(sortRoles) as IRole[], + loading: !ossError && !ossData, + refetch: () => ossMutate(), + error: ossError, + }; + } else { + return { + roles: (data?.roles + .filter(({ type }: IRole) => + uiConfig.flags.customRootRoles + ? ROOT_ROLES.includes(type) + : type === ROOT_ROLE + ) + .sort(sortRoles) ?? []) as IRole[], + projectRoles: (data?.roles + .filter(({ type }: IRole) => PROJECT_ROLES.includes(type)) + .sort(sortRoles) ?? []) as IProjectRole[], + loading: !error && !data, + refetch: () => mutate(), + error, + }; + } + }, [data, error, mutate, ossData, ossError, ossMutate]); }; const fetcher = (path: string) => { @@ -39,3 +65,13 @@ const fetcher = (path: string) => { .then(handleErrorResponses('Roles')) .then(res => res.json()); }; + +export const sortRoles = (a: IRole, b: IRole) => { + if (a.type === 'root' && b.type !== 'root') { + return -1; + } else if (a.type !== 'root' && b.type === 'root') { + return 1; + } else { + return a.name.localeCompare(b.name); + } +}; diff --git a/src/lib/routes/admin-api/user-admin.ts b/src/lib/routes/admin-api/user-admin.ts index 2a8dec5c337..4ef45894b9c 100644 --- a/src/lib/routes/admin-api/user-admin.ts +++ b/src/lib/routes/admin-api/user-admin.ts @@ -523,7 +523,6 @@ export default class UserAdminController extends Controller { req: Request, res: Response, ): Promise { - console.log('user-admin controller'); const adminCount = await this.accountService.getAdminCount(); this.openApiService.respondWithValidation( From 5e7ff9e4bf8c171d6d78d57c59cf54b34335f53b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Wed, 14 Jun 2023 10:27:39 +0100 Subject: [PATCH 09/11] fix: default value on hook --- frontend/src/hooks/api/getters/useRoles/useRoles.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/hooks/api/getters/useRoles/useRoles.ts b/frontend/src/hooks/api/getters/useRoles/useRoles.ts index f18920daf3f..5f11a04a453 100644 --- a/frontend/src/hooks/api/getters/useRoles/useRoles.ts +++ b/frontend/src/hooks/api/getters/useRoles/useRoles.ts @@ -36,6 +36,7 @@ export const useRoles = () => { roles: ossData?.rootRoles .filter(({ type }: IRole) => type === ROOT_ROLE) .sort(sortRoles) as IRole[], + projectRoles: [], loading: !ossError && !ossData, refetch: () => ossMutate(), error: ossError, From 4fb9eb691ff6cfbd1e449eed7eb0ed68867247f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Wed, 14 Jun 2023 14:23:18 +0100 Subject: [PATCH 10/11] fix: address some PR comments --- .../admin/groups/GroupForm/GroupForm.tsx | 23 +--------------- .../admin/roles/RoleForm/useRoleForm.ts | 23 +++++++--------- .../RoleDeleteDialog/RoleDeleteDialog.tsx | 27 +++++++++---------- src/lib/db/access-store.ts | 4 +-- src/lib/services/access-service.ts | 6 ++--- src/lib/types/model.ts | 1 + src/lib/types/stores/access-store.ts | 5 +++- src/test/fixtures/fake-access-store.ts | 5 +++- 8 files changed, 37 insertions(+), 57 deletions(-) diff --git a/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx b/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx index 389fce95968..9d51f17073b 100644 --- a/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx +++ b/frontend/src/component/admin/groups/GroupForm/GroupForm.tsx @@ -10,7 +10,7 @@ import { ItemList } from 'component/common/ItemList/ItemList'; import useAuthSettings from 'hooks/api/getters/useAuthSettings/useAuthSettings'; import { Link } from 'react-router-dom'; import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; -import IRole, { IProjectRole } from 'interfaces/role'; +import IRole from 'interfaces/role'; import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { RoleSelect } from 'component/common/RoleSelect/RoleSelect'; @@ -75,15 +75,6 @@ const StyledAutocompleteWrapper = styled('div')(({ theme }) => ({ }, })); -const StyledRoleOption = styled('div')(({ theme }) => ({ - display: 'flex', - flexDirection: 'column', - '& > span:last-of-type': { - fontSize: theme.fontSizes.smallerBody, - color: theme.palette.text.secondary, - }, -})); - interface IGroupForm { name: string; description: string; @@ -133,18 +124,6 @@ export const GroupForm: FC = ({ return roles.find((role: IRole) => role.id === rootRoleId) || null; }; - const renderRoleOption = ( - props: React.HTMLAttributes, - option: IProjectRole - ) => ( -
  • - - {option.name} - {option.description} - -
  • - ); - return (
    diff --git a/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts b/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts index 2eb6d1eabef..b42e67eae5c 100644 --- a/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts +++ b/frontend/src/component/admin/roles/RoleForm/useRoleForm.ts @@ -35,20 +35,15 @@ export const useRoleForm = ( useState({}); useEffect(() => { - if (initialPermissions.length > 0) { - setCheckedPermissions( - initialPermissions.reduce( - ( - acc: { [key: string]: IPermission }, - curr: IPermission - ) => { - acc[curr.id] = curr; - return acc; - }, - {} - ) - ); - } + setCheckedPermissions( + initialPermissions.reduce( + (acc: { [key: string]: IPermission }, curr: IPermission) => { + acc[curr.id] = curr; + return acc; + }, + {} + ) + ); }, [initialPermissions.length]); const [errors, setErrors] = useState({}); diff --git a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialog.tsx b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialog.tsx index f3c8aef3c8f..cdbda43d97e 100644 --- a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialog.tsx +++ b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialog.tsx @@ -40,10 +40,8 @@ export const RoleDeleteDialog = ({ ); const roleGroups = groups?.filter(({ rootRole }) => rootRole === role?.id); - const deleteMessage = ( - <> - You are about to delete role: {role?.name} - + const entitiesWithRole = Boolean( + roleUsers.length || roleServiceAccounts.length || roleGroups?.length ); return ( @@ -52,25 +50,21 @@ export const RoleDeleteDialog = ({ open={open} primaryButtonText="Delete role" secondaryButtonText="Cancel" + disabledPrimaryButton={entitiesWithRole} onClick={() => onConfirm(role!)} onClose={() => { setOpen(false); }} > - If you delete this role, all current entities - associated with it will be automatically assigned - the predefined Viewer role. + You are not allowed to delete a role that is + currently in use. Please change the role of the + following entities first: - {deleteMessage} } - elseShow={

    {deleteMessage}

    } + elseShow={ +

    + You are about to delete role:{' '} + {role?.name} +

    + } /> ); diff --git a/src/lib/db/access-store.ts b/src/lib/db/access-store.ts index 4755ceb71f7..205f0b8764c 100644 --- a/src/lib/db/access-store.ts +++ b/src/lib/db/access-store.ts @@ -426,11 +426,11 @@ export class AccessStore implements IAccessStore { async removeRolesOfTypeForUser( userId: number, - roleType: string, + roleTypes: string[], ): Promise { const rolesToRemove = this.db(T.ROLES) .select('id') - .where({ type: roleType }); + .whereIn('type', roleTypes); return this.db(T.ROLE_USER) .where({ user_id: userId }) diff --git a/src/lib/services/access-service.ts b/src/lib/services/access-service.ts index c69f6607954..1fa234dee8c 100644 --- a/src/lib/services/access-service.ts +++ b/src/lib/services/access-service.ts @@ -244,10 +244,10 @@ export class AccessService { const newRootRole = await this.resolveRootRole(role); if (newRootRole) { try { - await this.store.removeRolesOfTypeForUser( - userId, + await this.store.removeRolesOfTypeForUser(userId, [ RoleType.ROOT, - ); // TODO + RoleType.ROOT_CUSTOM, + ]); await this.store.addUserToRole( userId, diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 337fc80acda..be8d8d59f43 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -306,6 +306,7 @@ export enum RoleName { export enum RoleType { ROOT = 'root', + ROOT_CUSTOM = 'root-custom', PROJECT = 'project', } diff --git a/src/lib/types/stores/access-store.ts b/src/lib/types/stores/access-store.ts index 157198191c2..14dd24affbd 100644 --- a/src/lib/types/stores/access-store.ts +++ b/src/lib/types/stores/access-store.ts @@ -120,7 +120,10 @@ export interface IAccessStore extends Store { projectId: string, ): Promise; - removeRolesOfTypeForUser(userId: number, roleType: string): Promise; + removeRolesOfTypeForUser( + userId: number, + roleTypes: string[], + ): Promise; addPermissionsToRole( role_id: number, diff --git a/src/test/fixtures/fake-access-store.ts b/src/test/fixtures/fake-access-store.ts index dba3ba1b505..c4220f00b41 100644 --- a/src/test/fixtures/fake-access-store.ts +++ b/src/test/fixtures/fake-access-store.ts @@ -181,7 +181,10 @@ class AccessStoreMock implements IAccessStore { return Promise.resolve([]); } - removeRolesOfTypeForUser(userId: number, roleType: string): Promise { + removeRolesOfTypeForUser( + userId: number, + roleTypes: string[], + ): Promise { return Promise.resolve(undefined); } From 756681abada94a94c244996fd044b762ce25135c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Wed, 14 Jun 2023 14:28:58 +0100 Subject: [PATCH 11/11] Update src/lib/services/access-service.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gastón Fournier --- src/lib/services/access-service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/services/access-service.ts b/src/lib/services/access-service.ts index 1fa234dee8c..cb01e844349 100644 --- a/src/lib/services/access-service.ts +++ b/src/lib/services/access-service.ts @@ -486,6 +486,7 @@ export class AccessService { } async createRole(role: IRoleCreation): Promise { + // CUSTOM_PROJECT_ROLE_TYPE is assumed by default for backward compatibility const roleType = role.type === CUSTOM_ROOT_ROLE_TYPE ? CUSTOM_ROOT_ROLE_TYPE