1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-31 00:16:47 +01:00

feat: add usage info to project role deletion dialog (#4464)

## About the changes
<!-- Describe the changes introduced. What are they and why are they
being introduced? Feel free to also add screenshots or steps to view the
changes if they're visual. -->

Adds projects user and group -usage information to the dialog shown when
user wants to delete a project role

<img width="670" alt="Skjermbilde 2023-08-10 kl 08 28 40"
src="https://github.com/Unleash/unleash/assets/707867/a1df961b-2d0f-419d-b9bf-fedef896a84e">

---------

Co-authored-by: Nuno Góis <github@nunogois.com>
This commit is contained in:
David Leek 2023-08-17 09:43:43 +02:00 committed by GitHub
parent da7829daca
commit 76d3cc59cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 453 additions and 107 deletions

View File

@ -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 (
<RoleDeleteDialogProjectRole
role={role}
open={open}
setOpen={setOpen}
onConfirm={onConfirm}
/>
);
}
return (
<Dialogue
title="Delete role?"
<RoleDeleteDialogRootRole
role={role}
open={open}
primaryButtonText="Delete role"
secondaryButtonText="Cancel"
disabledPrimaryButton={entitiesWithRole}
onClick={() => onConfirm(role!)}
onClose={() => {
setOpen(false);
}}
>
<ConditionallyRender
condition={entitiesWithRole}
show={
<>
<Alert severity="error">
You are not allowed to delete a role that is
currently in use. Please change the role of the
following entities first:
</Alert>
<ConditionallyRender
condition={Boolean(roleUsers.length)}
show={
<>
<StyledLabel>
Users ({roleUsers.length}):
</StyledLabel>
<StyledTableContainer>
<RoleDeleteDialogUsers
users={roleUsers}
/>
</StyledTableContainer>
</>
}
/>
<ConditionallyRender
condition={Boolean(roleServiceAccounts.length)}
show={
<>
<StyledLabel>
Service accounts (
{roleServiceAccounts.length}):
</StyledLabel>
<StyledTableContainer>
<RoleDeleteDialogServiceAccounts
serviceAccounts={
roleServiceAccounts
}
/>
</StyledTableContainer>
</>
}
/>
<ConditionallyRender
condition={Boolean(roleGroups?.length)}
show={
<>
<StyledLabel>
Groups ({roleGroups?.length}):
</StyledLabel>
<StyledTableContainer>
<RoleDeleteDialogGroups
groups={roleGroups!}
/>
</StyledTableContainer>
</>
}
/>
</>
}
elseShow={
<p>
You are about to delete role:{' '}
<strong>{role?.name}</strong>
</p>
}
/>
</Dialogue>
setOpen={setOpen}
onConfirm={onConfirm}
/>
);
};

View File

@ -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<React.SetStateAction<boolean>>;
onConfirm: (role: IRole) => void;
}
export const RoleDeleteDialogProjectRole = ({
role,
open,
setOpen,
onConfirm,
}: IRoleDeleteDialogProps) => {
const { projects } = useProjectRoleAccessUsage(role?.id);
const entitiesWithRole = Boolean(projects?.length);
return (
<Dialogue
title="Delete project role?"
open={open}
primaryButtonText="Delete role"
secondaryButtonText="Cancel"
disabledPrimaryButton={entitiesWithRole}
onClick={() => onConfirm(role!)}
onClose={() => {
setOpen(false);
}}
maxWidth="md"
>
<ConditionallyRender
condition={entitiesWithRole}
show={
<>
<Alert severity="error">
You are not allowed to delete a role that is
currently in use. Please change the role of the
following entities first:
</Alert>
<ConditionallyRender
condition={Boolean(projects?.length)}
show={
<>
<StyledLabel>
Role assigned in {projects?.length}{' '}
projects:
</StyledLabel>
<StyledTableContainer>
<RoleDeleteDialogProjectRoleTable
projects={projects}
/>
</StyledTableContainer>
</>
}
/>
</>
}
elseShow={
<p>
You are about to delete role:{' '}
<strong>{role?.name}</strong>
</p>
}
/>
</Dialogue>
);
};

View File

@ -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) => (
<LinkCell
title={item.project}
to={`/projects/${item.project}`}
/>
),
},
{
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<IProjectRoleUsageCount>[],
[]
);
const { headerGroups, rows, prepareRow } = useTable(
{
columns,
data: projects,
initialState,
sortTypes,
autoResetHiddenColumns: false,
autoResetSortBy: false,
disableSortRemove: true,
disableMultiSort: true,
},
useSortBy,
useFlexLayout
);
return (
<VirtualizedTable
rows={rows}
headerGroups={headerGroups}
prepareRow={prepareRow}
/>
);
};

View File

@ -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<React.SetStateAction<boolean>>;
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 (
<Dialogue
title="Delete root role?"
open={open}
primaryButtonText="Delete role"
secondaryButtonText="Cancel"
disabledPrimaryButton={entitiesWithRole}
onClick={() => onConfirm(role!)}
onClose={() => {
setOpen(false);
}}
>
<ConditionallyRender
condition={entitiesWithRole}
show={
<>
<Alert severity="error">
You are not allowed to delete a role that is
currently in use. Please change the role of the
following entities first:
</Alert>
<ConditionallyRender
condition={Boolean(roleUsers.length)}
show={
<>
<StyledLabel>
Users ({roleUsers.length}):
</StyledLabel>
<StyledTableContainer>
<RoleDeleteDialogUsers
users={roleUsers}
/>
</StyledTableContainer>
</>
}
/>
<ConditionallyRender
condition={Boolean(roleServiceAccounts.length)}
show={
<>
<StyledLabel>
Service accounts (
{roleServiceAccounts.length}):
</StyledLabel>
<StyledTableContainer>
<RoleDeleteDialogServiceAccounts
serviceAccounts={
roleServiceAccounts
}
/>
</StyledTableContainer>
</>
}
/>
<ConditionallyRender
condition={Boolean(roleGroups?.length)}
show={
<>
<StyledLabel>
Groups ({roleGroups?.length}):
</StyledLabel>
<StyledTableContainer>
<RoleDeleteDialogGroups
groups={roleGroups!}
/>
</StyledTableContainer>
</>
}
/>
</>
}
elseShow={
<p>
You are about to delete role:{' '}
<strong>{role?.name}</strong>
</p>
}
/>
</Dialogue>
);
};

View File

@ -0,0 +1,2 @@
export const CUSTOM_ROOT_ROLE_TYPE = 'root-custom';
export const CUSTOM_PROJECT_ROLE_TYPE = 'custom';

View File

@ -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());
};

View File

@ -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;
}

View File

@ -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<IProjectRoleUsage[]> {
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,

View File

@ -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<IProjectRoleUsage[]> {
return this.store.getProjectUserAndGroupCountsForRole(roleId);
}
async createDefaultProjectRoles(
owner: IUser,
projectId: string,

View File

@ -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<IProjectRoleUsage[]> {
return this.accessService.getProjectRoleUsage(roleId);
}
async statusJob(): Promise<void> {
const projects = await this.store.getAll();

View File

@ -1 +1,9 @@
export const DEFAULT_PROJECT = 'default';
export interface IProjectRoleUsage {
project: string;
role: number;
userCount: number;
groupCount: number;
serviceAccountCount: number;
}

View File

@ -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<IRole, number> {
getGroupIdsForRole(roleId: number, projectId?: string): Promise<number[]>;
getProjectUserAndGroupCountsForRole(
roleId: number,
): Promise<IProjectRoleUsage[]>;
wipePermissionsFromRole(role_id: number): Promise<void>;
addEnvironmentPermissionsToRole(

View File

@ -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<IProjectRoleUsage[]> {
throw new Error('Method not implemented.');
}
addAccessToProject(
users: IAccessInfo[],
groups: IAccessInfo[],