Skip to content

Commit 67f9939

Browse files
authored
feat: Add warning when removing access-permissions from last granted role (RocketChat#37073)
1 parent fd4f9b2 commit 67f9939

10 files changed

Lines changed: 150 additions & 38 deletions

File tree

.changeset/spicy-zebras-deliver.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@rocket.chat/meteor': minor
3+
---
4+
5+
Adds a warning modal in order to prevent the `access-permissions` permission from being removed from the last granted role in the permissions table. This hardening measure ensures that administrators cannot accidentally lock themselves out of the system's permission management screen.

apps/meteor/app/authorization/lib/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@ export const CONSTANTS = {
99
SETTINGS_LEVEL: 'settings',
1010
} as const;
1111

12+
export const confirmationRequiredPermissions = ['access-permissions'];
13+
1214
export { AuthorizationUtils } from './AuthorizationUtils';

apps/meteor/client/views/admin/permissions/PermissionsPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ const PermissionsPage = ({ isEnterprise }: { isEnterprise: boolean }): ReactElem
6262
<Tabs.Item
6363
data-qa='PermissionTable-Permissions'
6464
selected={type === 'permissions'}
65-
onClick={handlePermissionsTab}
65+
onClick={canViewPermission ? handlePermissionsTab : undefined}
6666
disabled={!canViewPermission}
6767
>
6868
{t('Permissions')}

apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionRow.tsx

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,26 @@ type PermissionRowProps = {
3434

3535
const PermissionRow = ({ permission, roleList, onGrant, onRemove }: PermissionRowProps): ReactElement => {
3636
const { t } = useTranslation();
37-
const { _id, roles } = permission;
38-
const changeRole = useChangeRole({ onGrant, onRemove, permissionId: _id });
37+
const { _id: permissionId, roles } = permission;
38+
const changeRole = useChangeRole({ onGrant, onRemove, permissionId });
39+
const permissionName = getName(t, permission);
3940

4041
return (
41-
<GenericTableRow key={_id} role='link' action tabIndex={0}>
42-
<GenericTableCell maxWidth='x300' withTruncatedText title={t(`${_id}_description` as TranslationKey)}>
43-
{getName(t, permission)}
42+
<GenericTableRow key={permissionId} role='link' action tabIndex={0}>
43+
<GenericTableCell maxWidth='x300' withTruncatedText title={t(`${permissionId}_description` as TranslationKey)}>
44+
{permissionName}
4445
</GenericTableCell>
45-
{roleList.map(({ _id, name, description }) => (
46-
<RoleCell key={_id} _id={_id} name={name} description={description} grantedRoles={roles} onChange={changeRole} permissionId={_id} />
46+
{roleList.map(({ _id: roleId, name, description }) => (
47+
<RoleCell
48+
key={roleId}
49+
_id={roleId}
50+
name={name}
51+
description={description}
52+
grantedRoles={roles}
53+
onChange={changeRole}
54+
permissionId={permissionId}
55+
permissionName={permissionName}
56+
/>
4757
))}
4858
</GenericTableRow>
4959
);

apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTable.spec.tsx

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import type { IPermission, IRole } from '@rocket.chat/core-typings';
12
import { mockAppRoot } from '@rocket.chat/mock-providers';
23
import { composeStories } from '@storybook/react';
3-
import { render } from '@testing-library/react';
4+
import { render, screen } from '@testing-library/react';
5+
import userEvent from '@testing-library/user-event';
46
import { axe } from 'jest-axe';
57

8+
import PermissionsTable from './PermissionsTable';
69
import * as stories from './PermissionsTable.stories';
710

811
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
@@ -20,3 +23,57 @@ test.each(testCases)('%s should have no a11y violations', async (_storyname, Sto
2023
const results = await axe(container, { rules: { 'label': { enabled: false }, 'button-name': { enabled: false } } });
2124
expect(results).toHaveNoViolations();
2225
});
26+
27+
const defaultPermissions: IPermission[] = [
28+
{
29+
_id: 'access-permissions',
30+
_updatedAt: new Date('2023-01-01'),
31+
roles: ['admin'],
32+
},
33+
];
34+
35+
const roles: IRole[] = [
36+
{
37+
description: 'Owner of the workspace',
38+
name: 'owner',
39+
protected: true,
40+
scope: 'Users',
41+
_id: 'owner',
42+
},
43+
{
44+
description: 'Administrator',
45+
name: 'admin',
46+
protected: true,
47+
scope: 'Users',
48+
_id: 'admin',
49+
},
50+
];
51+
52+
test('should display modal if the permission is access-permissions and has only one granted role', async () => {
53+
render(
54+
<PermissionsTable permissions={defaultPermissions} total={defaultPermissions.length} setFilter={() => undefined} roleList={roles} />,
55+
{
56+
wrapper: mockAppRoot().build(),
57+
},
58+
);
59+
60+
await userEvent.click(screen.getByRole('checkbox', { name: 'access-permissions - Administrator' }));
61+
expect(screen.getByRole('dialog')).toBeInTheDocument();
62+
});
63+
64+
test('should NOT display modal if the permission is access-permissions and has more than one granted role', async () => {
65+
const morePermissions: IPermission[] = [
66+
{
67+
_id: 'access-permissions',
68+
_updatedAt: new Date('2023-01-01'),
69+
roles: ['admin', 'owner'],
70+
},
71+
];
72+
73+
render(<PermissionsTable permissions={morePermissions} total={morePermissions.length} setFilter={() => undefined} roleList={roles} />, {
74+
wrapper: mockAppRoot().build(),
75+
});
76+
77+
await userEvent.click(screen.getByRole('checkbox', { name: 'access-permissions - Administrator' }));
78+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
79+
});

apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTable.stories.tsx

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -80,41 +80,21 @@ const permissions: IPermission[] = [
8080
_id: '0',
8181
_updatedAt: new Date('2023-01-01'),
8282
roles: ['admin'],
83-
group: 'admin',
84-
level: 'settings',
85-
section: 'General',
86-
settingId: 'general_settings',
87-
sorter: 1,
8883
},
8984
{
9085
_id: '1',
9186
_updatedAt: new Date('2023-01-01'),
9287
roles: ['user'],
93-
group: 'admin',
94-
level: 'settings',
95-
section: 'General',
96-
settingId: 'general_settings',
97-
sorter: 2,
9888
},
9989
{
10090
_id: '2',
10191
_updatedAt: new Date('2023-01-01'),
10292
roles: ['user'],
103-
group: 'admin',
104-
level: 'settings',
105-
section: 'General',
106-
settingId: 'general_settings',
107-
sorter: 3,
10893
},
10994
{
11095
_id: '3',
11196
_updatedAt: new Date('2023-01-01'),
11297
roles: ['user'],
113-
group: 'admin',
114-
level: 'settings',
115-
section: 'General',
116-
settingId: 'general_settings',
117-
sorter: 4,
11898
},
11999
];
120100

apps/meteor/client/views/admin/permissions/PermissionsTable/PermissionsTable.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ type PermissionsTableProps = {
1616
permissions: IPermission[];
1717
setFilter: (filter: string) => void;
1818
total: number;
19-
paginationProps: ReturnType<typeof usePagination>;
19+
paginationProps?: ReturnType<typeof usePagination>;
2020
};
2121

2222
const PermissionsTable = ({ roleList, permissions, setFilter, total, paginationProps }: PermissionsTableProps) => {

apps/meteor/client/views/admin/permissions/PermissionsTable/RoleCell.tsx

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import type { IRole } from '@rocket.chat/core-typings';
22
import { Margins, Box, CheckBox, Throbber } from '@rocket.chat/fuselage';
33
import { useEffectEvent } from '@rocket.chat/fuselage-hooks';
4+
import { GenericModal } from '@rocket.chat/ui-client';
5+
import { useSetModal } from '@rocket.chat/ui-contexts';
46
import type { ReactElement } from 'react';
57
import { useState, memo } from 'react';
8+
import { useTranslation } from 'react-i18next';
69

7-
import { AuthorizationUtils } from '../../../../../app/authorization/lib';
10+
import { AuthorizationUtils, confirmationRequiredPermissions } from '../../../../../app/authorization/lib';
811
import { GenericTableCell } from '../../../../components/GenericTable';
912

1013
type RoleCellProps = {
@@ -13,28 +16,50 @@ type RoleCellProps = {
1316
description: IRole['description'];
1417
onChange: (roleId: IRole['_id'], granted: boolean) => Promise<boolean>;
1518
permissionId: string;
19+
permissionName: string;
1620
grantedRoles: IRole['_id'][];
1721
};
1822

19-
const RoleCell = ({ _id, name, description, onChange, permissionId, grantedRoles = [] }: RoleCellProps): ReactElement => {
23+
const RoleCell = ({ _id, name, description, onChange, permissionId, permissionName, grantedRoles = [] }: RoleCellProps): ReactElement => {
24+
const { t } = useTranslation();
25+
const setModal = useSetModal();
2026
const [granted, setGranted] = useState(() => !!grantedRoles.includes(_id));
2127
const [loading, setLoading] = useState(false);
2228

2329
const isRestrictedForRole = AuthorizationUtils.isPermissionRestrictedForRole(permissionId, _id);
30+
const shouldDisplayConfirmation = confirmationRequiredPermissions.includes(permissionId) && grantedRoles.length === 1 && granted;
2431

25-
const handleChange = useEffectEvent(async () => {
32+
const handleChange = useEffectEvent(() => {
33+
if (shouldDisplayConfirmation) {
34+
const handleSubmit = () => {
35+
handleConfirmChange();
36+
setModal(null);
37+
};
38+
39+
return setModal(
40+
<GenericModal onConfirm={handleSubmit} onCancel={() => setModal(null)} confirmText={t('Remove')} variant='danger'>
41+
{t('Last_role_in_permission_warning')}
42+
</GenericModal>,
43+
);
44+
}
45+
46+
return handleConfirmChange();
47+
});
48+
49+
const handleConfirmChange = useEffectEvent(async () => {
2650
setLoading(true);
2751
const result = await onChange(_id, granted);
2852
setGranted(result);
2953
setLoading(false);
3054
});
3155

3256
const isDisabled = !!loading || !!isRestrictedForRole;
57+
const checkboxLabel = `${permissionName} - ${description || name}`;
3358

3459
return (
3560
<GenericTableCell withTruncatedText>
3661
<Margins inline={2}>
37-
<CheckBox checked={granted} onChange={handleChange} disabled={isDisabled} />
62+
<CheckBox aria-label={checkboxLabel} checked={granted} onChange={handleChange} disabled={isDisabled} />
3863
{!loading && (
3964
<Box display='inline' color='hint' invisible>
4065
{description || name}

0 commit comments

Comments
 (0)