diff --git a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialog.tsx b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialog.tsx index e405f5b3711a..d549da07df30 100644 --- a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialog.tsx +++ b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialog.tsx @@ -1,21 +1,7 @@ -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'; -import { useGroups } from 'hooks/api/getters/useGroups/useGroups'; -import { RoleDeleteDialogGroups } from './RoleDeleteDialogGroups/RoleDeleteDialogGroups'; - -const StyledTableContainer = styled('div')(({ theme }) => ({ - marginTop: theme.spacing(1.5), -})); - -const StyledLabel = styled('p')(({ theme }) => ({ - marginTop: theme.spacing(3), -})); +import { RoleDeleteDialogRootRole } from './RoleDeleteDialogRootRole/RoleDeleteDialogRootRole'; +import { RoleDeleteDialogProjectRole } from './RoleDeleteDialogProjectRole/RoleDeleteDialogProjectRole'; +import { CUSTOM_PROJECT_ROLE_TYPE } from 'constants/roles'; interface IRoleDeleteDialogProps { role?: IRole; @@ -30,98 +16,23 @@ export const RoleDeleteDialog = ({ setOpen, onConfirm, }: 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 entitiesWithRole = Boolean( - roleUsers.length || roleServiceAccounts.length || roleGroups?.length - ); + if (role?.type === CUSTOM_PROJECT_ROLE_TYPE) { + return ( + + ); + } return ( - onConfirm(role!)} - onClose={() => { - setOpen(false); - }} - > - - - You are not allowed to delete a role that is - currently in use. Please change the role of the - following entities first: - - - - Users ({roleUsers.length}): - - - - - > - } - /> - - - Service accounts ( - {roleServiceAccounts.length}): - - - - - > - } - /> - - - Groups ({roleGroups?.length}): - - - - - > - } - /> - > - } - elseShow={ - - You are about to delete role:{' '} - {role?.name} - - } - /> - + setOpen={setOpen} + onConfirm={onConfirm} + /> ); }; diff --git a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogProjectRole/RoleDeleteDialogProjectRole.tsx b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogProjectRole/RoleDeleteDialogProjectRole.tsx new file mode 100644 index 000000000000..46ce929ac184 --- /dev/null +++ b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogProjectRole/RoleDeleteDialogProjectRole.tsx @@ -0,0 +1,82 @@ +import { Alert, styled } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import { IRole } from 'interfaces/role'; +import { useProjectRoleAccessUsage } from 'hooks/api/getters/useProjectRoleAccessUsage/useProjectRoleAccessUsage'; +import { RoleDeleteDialogProjectRoleTable } from './RoleDeleteDialogProjectRoleTable'; + +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 RoleDeleteDialogProjectRole = ({ + role, + open, + setOpen, + onConfirm, +}: IRoleDeleteDialogProps) => { + const { projects } = useProjectRoleAccessUsage(role?.id); + + const entitiesWithRole = Boolean(projects?.length); + + return ( + onConfirm(role!)} + onClose={() => { + setOpen(false); + }} + maxWidth="md" + > + + + You are not allowed to delete a role that is + currently in use. Please change the role of the + following entities first: + + + + Role assigned in {projects?.length}{' '} + projects: + + + + + > + } + /> + > + } + elseShow={ + + You are about to delete role:{' '} + {role?.name} + + } + /> + + ); +}; diff --git a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogProjectRole/RoleDeleteDialogProjectRoleTable.tsx b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogProjectRole/RoleDeleteDialogProjectRoleTable.tsx new file mode 100644 index 000000000000..935ce9e88553 --- /dev/null +++ b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogProjectRole/RoleDeleteDialogProjectRoleTable.tsx @@ -0,0 +1,92 @@ +import { VirtualizedTable } from 'component/common/Table'; +import { useMemo, useState } from 'react'; +import { useTable, useSortBy, useFlexLayout, Column } from 'react-table'; +import { sortTypes } from 'utils/sortTypes'; +import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; +import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell'; +import { IProjectRoleUsageCount } from 'interfaces/project'; +import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell'; + +interface IRoleDeleteDialogProjectRoleTableProps { + projects: IProjectRoleUsageCount[]; +} + +export const RoleDeleteDialogProjectRoleTable = ({ + projects, +}: IRoleDeleteDialogProjectRoleTableProps) => { + const [initialState] = useState(() => ({ + sortBy: [{ id: 'name' }], + })); + + const columns = useMemo( + () => + [ + { + id: 'name', + Header: 'Project name', + accessor: (row: any) => row.project || '', + minWidth: 200, + Cell: ({ row: { original: item } }: any) => ( + + ), + }, + { + id: 'users', + Header: 'Assigned users', + accessor: (row: any) => + row.userCount === 1 + ? '1 user' + : `${row.userCount} users`, + Cell: TextCell, + maxWidth: 150, + }, + { + id: 'serviceAccounts', + Header: 'Service accounts', + accessor: (row: any) => + row.serviceAccountCount === 1 + ? '1 account' + : `${row.serviceAccountCount} accounts`, + Cell: TextCell, + maxWidth: 150, + }, + { + id: 'groups', + Header: 'Assigned groups', + accessor: (row: any) => + row.groupCount === 1 + ? '1 group' + : `${row.groupCount} groups`, + Cell: TextCell, + maxWidth: 150, + }, + ] as Column[], + [] + ); + + const { headerGroups, rows, prepareRow } = useTable( + { + columns, + data: projects, + initialState, + sortTypes, + autoResetHiddenColumns: false, + autoResetSortBy: false, + disableSortRemove: true, + disableMultiSort: true, + }, + useSortBy, + useFlexLayout + ); + + return ( + + ); +}; diff --git a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogGroups/RoleDeleteDialogGroups.tsx b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogRootRole/RoleDeleteDialogGroups.tsx similarity index 100% rename from frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogGroups/RoleDeleteDialogGroups.tsx rename to frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogRootRole/RoleDeleteDialogGroups.tsx diff --git a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogRootRole/RoleDeleteDialogRootRole.tsx b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogRootRole/RoleDeleteDialogRootRole.tsx new file mode 100644 index 000000000000..a43f9aa91658 --- /dev/null +++ b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogRootRole/RoleDeleteDialogRootRole.tsx @@ -0,0 +1,127 @@ +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'; +import { RoleDeleteDialogServiceAccounts } from './RoleDeleteDialogServiceAccounts'; +import { useGroups } from 'hooks/api/getters/useGroups/useGroups'; +import { RoleDeleteDialogGroups } from './RoleDeleteDialogGroups'; + +const StyledTableContainer = styled('div')(({ theme }) => ({ + marginTop: theme.spacing(1.5), +})); + +const StyledLabel = styled('p')(({ theme }) => ({ + marginTop: theme.spacing(3), +})); + +interface IRoleDeleteDialogRootRoleProps { + role?: IRole; + open: boolean; + setOpen: React.Dispatch>; + onConfirm: (role: IRole) => void; +} + +export const RoleDeleteDialogRootRole = ({ + role, + open, + setOpen, + onConfirm, +}: IRoleDeleteDialogRootRoleProps) => { + 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 entitiesWithRole = Boolean( + roleUsers.length || roleServiceAccounts.length || roleGroups?.length + ); + + return ( + onConfirm(role!)} + onClose={() => { + setOpen(false); + }} + > + + + You are not allowed to delete a role that is + currently in use. Please change the role of the + following entities first: + + + + Users ({roleUsers.length}): + + + + + > + } + /> + + + Service accounts ( + {roleServiceAccounts.length}): + + + + + > + } + /> + + + Groups ({roleGroups?.length}): + + + + + > + } + /> + > + } + elseShow={ + + You are about to delete role:{' '} + {role?.name} + + } + /> + + ); +}; diff --git a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogServiceAccounts/RoleDeleteDialogServiceAccounts.tsx b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogRootRole/RoleDeleteDialogServiceAccounts.tsx similarity index 100% rename from frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogServiceAccounts/RoleDeleteDialogServiceAccounts.tsx rename to frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogRootRole/RoleDeleteDialogServiceAccounts.tsx diff --git a/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogUsers/RoleDeleteDialogUsers.tsx b/frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogRootRole/RoleDeleteDialogUsers.tsx similarity index 100% rename from frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogUsers/RoleDeleteDialogUsers.tsx rename to frontend/src/component/admin/roles/RolesTable/RoleDeleteDialog/RoleDeleteDialogRootRole/RoleDeleteDialogUsers.tsx diff --git a/frontend/src/constants/roles.ts b/frontend/src/constants/roles.ts new file mode 100644 index 000000000000..1846da27a60b --- /dev/null +++ b/frontend/src/constants/roles.ts @@ -0,0 +1,2 @@ +export const CUSTOM_ROOT_ROLE_TYPE = 'root-custom'; +export const CUSTOM_PROJECT_ROLE_TYPE = 'custom'; diff --git a/frontend/src/hooks/api/getters/useProjectRoleAccessUsage/useProjectRoleAccessUsage.ts b/frontend/src/hooks/api/getters/useProjectRoleAccessUsage/useProjectRoleAccessUsage.ts new file mode 100644 index 000000000000..9f7f11538b1b --- /dev/null +++ b/frontend/src/hooks/api/getters/useProjectRoleAccessUsage/useProjectRoleAccessUsage.ts @@ -0,0 +1,33 @@ +import { formatApiPath } from 'utils/formatPath'; +import { useMemo } from 'react'; +import handleErrorResponses from '../httpErrorResponseHandler'; +import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR'; +import useUiConfig from '../useUiConfig/useUiConfig'; +import { IProjectRoleUsageCount } from 'interfaces/project'; + +export const useProjectRoleAccessUsage = (roleId?: number) => { + const { isEnterprise } = useUiConfig(); + + const { data, error, mutate } = useConditionalSWR( + isEnterprise() && roleId, + { projects: [] }, + formatApiPath(`api/admin/projects/roles/${roleId}/access`), + fetcher + ); + + return useMemo( + () => ({ + projects: (data?.projects ?? []) as IProjectRoleUsageCount[], + loading: !error && !data, + refetch: () => mutate(), + error, + }), + [data, error, mutate] + ); +}; + +const fetcher = (path: string) => { + return fetch(path) + .then(handleErrorResponses('Project role usage')) + .then(res => res.json()); +}; diff --git a/frontend/src/interfaces/project.ts b/frontend/src/interfaces/project.ts index c3d3512fa5c4..a3dd0fd27f26 100644 --- a/frontend/src/interfaces/project.ts +++ b/frontend/src/interfaces/project.ts @@ -35,3 +35,11 @@ export interface IProjectHealthReport extends IProject { activeCount: number; updatedAt: string; } + +export interface IProjectRoleUsageCount { + project: string; + role: number; + userCount: number; + groupCount: number; + serviceAccountCount: number; +} diff --git a/src/lib/db/access-store.ts b/src/lib/db/access-store.ts index de92160d66bd..64314e7fb529 100644 --- a/src/lib/db/access-store.ts +++ b/src/lib/db/access-store.ts @@ -5,6 +5,7 @@ import { Logger } from '../logger'; import { IAccessInfo, IAccessStore, + IProjectRoleUsage, IRole, IRoleWithProject, IUserPermission, @@ -304,6 +305,59 @@ export class AccessStore implements IAccessStore { return rows.map((r) => r.group_id); } + async getProjectUserAndGroupCountsForRole( + roleId: number, + ): Promise { + const query = await this.db.raw( + ` + SELECT + uq.project, + sum(uq.user_count) AS user_count, + sum(uq.svc_account_count) AS svc_account_count, + sum(uq.group_count) AS group_count + FROM ( + SELECT + project, + 0 AS user_count, + 0 AS svc_account_count, + count(project) AS group_count + FROM group_role + WHERE role_id = ? + GROUP BY project + + UNION SELECT + project, + count(us.id) AS user_count, + count(svc.id) AS svc_account_count, + 0 AS group_count + FROM role_user AS usr_r + LEFT OUTER JOIN public.users AS us ON us.id = usr_r.user_id AND us.is_service = 'false' + LEFT OUTER JOIN public.users AS svc ON svc.id = usr_r.user_id AND svc.is_service = 'true' + WHERE usr_r.role_id = ? + GROUP BY usr_r.project + ) AS uq + GROUP BY uq.project + `, + [roleId, roleId], + ); + + /* + const rows2 = await this.db(T.ROLE_USER) + .select('project', this.db.raw('count(project) as user_count')) + .where('role_id', roleId) + .groupBy('project'); + */ + return query.rows.map((r) => { + return { + project: r.project, + role: roleId, + userCount: Number(r.user_count), + groupCount: Number(r.group_count), + serviceAccountCount: Number(r.svc_account_count), + }; + }); + } + async addUserToRole( userId: number, roleId: number, diff --git a/src/lib/services/access-service.ts b/src/lib/services/access-service.ts index 0d1200f9902b..90a71512ffa4 100644 --- a/src/lib/services/access-service.ts +++ b/src/lib/services/access-service.ts @@ -3,6 +3,7 @@ import User, { IProjectUser, IUser } from '../types/user'; import { IAccessInfo, IAccessStore, + IProjectRoleUsage, IRole, IRoleWithPermissions, IRoleWithProject, @@ -453,6 +454,10 @@ export class AccessService { return [roles, users.flat(), groups]; } + async getProjectRoleUsage(roleId: number): Promise { + return this.store.getProjectUserAndGroupCountsForRole(roleId); + } + async createDefaultProjectRoles( owner: IUser, projectId: string, diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index 564fe88ff4c8..26c216118d26 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -35,6 +35,7 @@ import { RoleName, IFlagResolver, ProjectAccessAddedEvent, + IProjectRoleUsage, } from '../types'; import { IProjectQuery, IProjectStore } from '../types/stores/project-store'; import { @@ -697,6 +698,10 @@ export default class ProjectService { return this.store.getProjectsByUser(userId); } + async getProjectRoleUsage(roleId: number): Promise { + return this.accessService.getProjectRoleUsage(roleId); + } + async statusJob(): Promise { const projects = await this.store.getAll(); diff --git a/src/lib/types/project.ts b/src/lib/types/project.ts index 0b4d3ce913b4..6c7bc5b8676d 100644 --- a/src/lib/types/project.ts +++ b/src/lib/types/project.ts @@ -1 +1,9 @@ export const DEFAULT_PROJECT = 'default'; + +export interface IProjectRoleUsage { + project: string; + role: number; + userCount: number; + groupCount: number; + serviceAccountCount: number; +} diff --git a/src/lib/types/stores/access-store.ts b/src/lib/types/stores/access-store.ts index 57cd21939741..44c93962d78a 100644 --- a/src/lib/types/stores/access-store.ts +++ b/src/lib/types/stores/access-store.ts @@ -14,6 +14,14 @@ export interface IRole { type: string; } +export interface IProjectRoleUsage { + project: string; + role: number; + userCount: number; + groupCount: number; + serviceAccountCount: number; +} + export interface IRoleWithProject extends IRole { project: string; } @@ -70,6 +78,10 @@ export interface IAccessStore extends Store { getGroupIdsForRole(roleId: number, projectId?: string): Promise; + getProjectUserAndGroupCountsForRole( + roleId: number, + ): Promise; + wipePermissionsFromRole(role_id: number): Promise; addEnvironmentPermissionsToRole( diff --git a/src/test/fixtures/fake-access-store.ts b/src/test/fixtures/fake-access-store.ts index b01eac2d859e..cce7c3a333ed 100644 --- a/src/test/fixtures/fake-access-store.ts +++ b/src/test/fixtures/fake-access-store.ts @@ -2,6 +2,7 @@ import { IAccessInfo, IAccessStore, + IProjectRoleUsage, IRole, IRoleWithProject, IUserPermission, @@ -20,6 +21,12 @@ class AccessStoreMock implements IAccessStore { this.fakeRolesStore = roleStore ?? new FakeRoleStore(); } + getProjectUserAndGroupCountsForRole( + roleId: number, + ): Promise { + throw new Error('Method not implemented.'); + } + addAccessToProject( users: IAccessInfo[], groups: IAccessInfo[],
- You are about to delete role:{' '} - {role?.name} -
+ You are about to delete role:{' '} + {role?.name} +