Skip to content

Commit 0d4f93e

Browse files
feat(authz): adding expanded permissions view (#125)
* refactor: moving files from libraries to authz module and minor improvements on the header * feat: roles table for audit user page * feat: expanded row view for user roles added * chore: border color updated * chore: styles and texts adjusted * feat: refactor to handle the table accordion correctly after rebase * fix: column width fixed * fix: imports fix * feat: functionality to keep one accordion open * fix: lint and tests fixed * feat: missing tests added to get coverage * chore: unnecessary container removed * chore: removed space * fix: fix in container * feat: missing test added * fix: labels and styles adjusted to match with design * chore: refactor to avoid duplicated code * fix: refactor of TableCells component * chore: refactor of files path for better organization * chore: unnecessary code removed and fix and imports * fix: refetchOnWindowFocus added * fix: constants file merged, fix in imports and texts * fix: height added to avoid a paragon bug with dropdown * fix: tests fixed * fix: missing test added * chore: remove height class --------- Co-authored-by: jacobo-dominguez-wgu <[email protected]>
1 parent f1371b8 commit 0d4f93e

23 files changed

Lines changed: 1442 additions & 74 deletions

src/authz-module/audit-user/index.test.tsx

Lines changed: 86 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { render, screen, waitFor } from '@testing-library/react';
1+
import {
2+
render, screen, waitFor, act,
3+
} from '@testing-library/react';
24
import { AppContext } from '@edx/frontend-platform/react';
35
import userEvent from '@testing-library/user-event';
46
import { MemoryRouter, Route, Routes } from 'react-router-dom';
@@ -17,6 +19,21 @@ jest.mock('@edx/frontend-platform/logging', () => ({
1719
logError: jest.fn(),
1820
}));
1921

22+
// Mock StudioHeader to avoid prop validation errors in tests
23+
jest.mock('@edx/frontend-component-header', () => ({
24+
StudioHeader: ({ children, ...props }: any) => <div data-testid="mocked-studio-header" {...props}>{children}</div>,
25+
}));
26+
27+
// Mock the useRevokeUserRoles hook
28+
const mockRevokeUserRoles = jest.fn();
29+
jest.mock('@src/authz-module/data/hooks', () => ({
30+
...jest.requireActual('@src/authz-module/data/hooks'),
31+
useRevokeUserRoles: () => ({
32+
mutate: mockRevokeUserRoles,
33+
isPending: false,
34+
}),
35+
}));
36+
2037
const mockUser = {
2138
username: 'johndoe',
2239
@@ -50,9 +67,16 @@ const renderWithRouter = (route = '/audit/johndoe') => {
5067
authenticatedUser: {
5168
username: 'testuser',
5269
70+
userId: 1,
5371
},
5472
config: {
55-
// @ts-ignore
73+
LMS_BASE_URL: 'http://localhost:18000',
74+
STUDIO_BASE_URL: 'http://localhost:18010',
75+
AUTHZ_MICROFRONTEND_URL: 'http://localhost:18012',
76+
ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload',
77+
BASE_URL: 'http://localhost:18012',
78+
ENVIRONMENT: 'test',
79+
LANGUAGE_PREFERENCE_COOKIE_NAME: 'openedx-language-preference',
5680
...process.env,
5781
},
5882
};
@@ -78,6 +102,11 @@ const renderWithRouter = (route = '/audit/johndoe') => {
78102
describe('AuditUserPage', () => {
79103
beforeEach(() => {
80104
jest.clearAllMocks();
105+
// Set up default mock behavior for useRevokeUserRoles
106+
mockRevokeUserRoles.mockImplementation((variables, { onSuccess }) => {
107+
// Simulate successful deletion by default
108+
onSuccess({ errors: [], completed: ['role1'] });
109+
});
81110
});
82111

83112
beforeAll(() => {
@@ -181,6 +210,31 @@ describe('AuditUserPage', () => {
181210
});
182211
});
183212

213+
it('expands row to show UserPermissions component when view all permissions is clicked', async () => {
214+
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
215+
get: jest
216+
.fn()
217+
.mockResolvedValueOnce({ data: mockUser })
218+
.mockResolvedValueOnce({ data: mockAssignments }),
219+
});
220+
221+
renderWithRouter();
222+
const user = userEvent.setup();
223+
224+
await waitFor(() => {
225+
expect(screen.getByText('Library Admin')).toBeInTheDocument();
226+
});
227+
// Find and click the "View All Permissions" link
228+
const viewAllPermissionsLink = screen.getByText(/view all permissions/i);
229+
expect(viewAllPermissionsLink).toBeInTheDocument();
230+
await user.click(viewAllPermissionsLink);
231+
// Verify that the UserPermissions component is rendered (it should show detailed permissions)
232+
await waitFor(() => {
233+
// The UserPermissions component should be rendered in the expanded row
234+
expect(viewAllPermissionsLink).toBeInTheDocument();
235+
});
236+
});
237+
184238
it('renders the pagination controls when assignments are present', async () => {
185239
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
186240
get: jest
@@ -250,7 +304,6 @@ describe('AuditUserPage', () => {
250304
.fn()
251305
.mockResolvedValueOnce({ data: mockUser })
252306
.mockResolvedValueOnce({ data: mockAssignments }),
253-
delete: jest.fn().mockResolvedValue({ data: { errors: [] } }),
254307
});
255308

256309
renderWithRouter();
@@ -269,26 +322,35 @@ describe('AuditUserPage', () => {
269322
});
270323

271324
const removeButton = screen.getByRole('button', { name: /remove/i });
272-
await user.click(removeButton);
325+
326+
await act(async () => {
327+
await user.click(removeButton);
328+
});
273329

274330
await waitFor(() => {
275331
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
332+
});
333+
334+
await waitFor(() => {
276335
expect(screen.getByText(/role has been successfully removed/i)).toBeInTheDocument();
277336
});
278337
});
279338

280339
it('shows error toast when role revocation succeeds but returns errors', async () => {
340+
// Override mock for this specific test case
341+
mockRevokeUserRoles.mockImplementation((_, { onSuccess }) => {
342+
// Call onSuccess immediately with errors
343+
onSuccess({
344+
errors: ['Failed to revoke user role'],
345+
completed: [],
346+
});
347+
});
348+
281349
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
282350
get: jest
283351
.fn()
284352
.mockResolvedValueOnce({ data: mockUser })
285353
.mockResolvedValueOnce({ data: mockAssignments }),
286-
delete: jest.fn().mockResolvedValue({
287-
data: {
288-
errors: ['Failed to revoke user role'],
289-
completed: [],
290-
},
291-
}),
292354
});
293355

294356
renderWithRouter();
@@ -307,20 +369,28 @@ describe('AuditUserPage', () => {
307369
});
308370

309371
const removeButton = screen.getByRole('button', { name: /remove/i });
310-
await user.click(removeButton);
372+
373+
await act(async () => {
374+
await user.click(removeButton);
375+
});
311376

312377
await waitFor(() => {
313378
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
314379
});
315380
});
316381

317382
it('shows error toast with retry when role revocation fails', async () => {
383+
// Override mock for this specific test case
384+
mockRevokeUserRoles.mockImplementation((variables, { onError }) => {
385+
// Call onError immediately to simulate failure
386+
onError(new Error('Network error'));
387+
});
388+
318389
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
319390
get: jest
320391
.fn()
321392
.mockResolvedValueOnce({ data: mockUser })
322393
.mockResolvedValueOnce({ data: mockAssignments }),
323-
delete: jest.fn().mockRejectedValue(new Error('Network error')),
324394
});
325395

326396
renderWithRouter();
@@ -339,7 +409,10 @@ describe('AuditUserPage', () => {
339409
});
340410

341411
const removeButton = screen.getByRole('button', { name: /remove/i });
342-
await user.click(removeButton);
412+
413+
await act(async () => {
414+
await user.click(removeButton);
415+
});
343416

344417
await waitFor(() => {
345418
expect(screen.getByText(/something went wrong on our end/i)).toBeInTheDocument();

src/authz-module/audit-user/index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings';
2424
import { useRevokeUserRoles, useUserAssignedRoles } from '@src/authz-module/data/hooks';
2525
import { RoleToDelete } from 'types';
2626
import { useToastManager } from '@src/components/ToastManager/ToastManagerContext';
27+
import UserPermissions from '@src/authz-module/components/UserPermissions';
2728
import messages from './messages';
2829
import ConfirmDeletionModal from '../components/ConfirmDeletionModal';
2930

@@ -216,6 +217,10 @@ const AuditUserPage = () => {
216217
additionalColumns={additionalColumns}
217218
columns={columns}
218219
isLoading={isLoadingUserAssignments}
220+
isExpandable
221+
renderRowSubComponent={({ row }) => (
222+
<UserPermissions row={row} />
223+
)}
219224
>
220225
<DataTable.Table />
221226
<TableFooter />

src/authz-module/audit-user/messages.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,6 @@ const messages = defineMessages(
2727
defaultMessage: 'Actions',
2828
description: 'Header for the actions column in the user table',
2929
},
30-
'authz.user.table.view_all_permissions.link.text': {
31-
id: 'authz.user.table.view_all_permissions.link.text',
32-
defaultMessage: 'View all permissions',
33-
description: 'Text for the link to view all permissions in the user table',
34-
},
3530
'authz.user.table.delete.action.alt': {
3631
id: 'authz.user.table.delete.action.alt',
3732
defaultMessage: 'Delete role action',
@@ -42,6 +37,26 @@ const messages = defineMessages(
4237
defaultMessage: '{count, plural, one {# permission available} other {# permissions available}}',
4338
description: 'Text showing the number of permissions available, with proper pluralization',
4439
},
40+
'authz.user.table.permissions.total.access': {
41+
id: 'authz.user.table.permissions.total.access',
42+
defaultMessage: 'Total access',
43+
description: 'Label indicating Super Admin has total access to all permissions',
44+
},
45+
'authz.user.table.permissions.partial.access': {
46+
id: 'authz.user.table.permissions.partial.access',
47+
defaultMessage: 'Partial access',
48+
description: 'Label indicating Global Staff has partial access to permissions',
49+
},
50+
'authz.user.table.permissions.role.admin': {
51+
id: 'authz.user.table.permissions.role.admin',
52+
defaultMessage: 'Super Admins have full access to all areas of the platform, including content, settings, and user management. This role is managed at the platform level and cannot be changed from here. To modify it, go to Django Admin.',
53+
description: 'Description for the permissions of the Super Admin role',
54+
},
55+
'authz.user.table.permissions.role.staff': {
56+
id: 'authz.user.table.permissions.role.staff',
57+
defaultMessage: 'Global Staff have access to all areas of the platform, similar to Super Admin, but cannot grant or revoke Super Admin or Global Staff roles to other users. This role is managed at the platform level and cannot be changed from here. To modify it, go to Django Admin.',
58+
description: 'Description for the permissions of the Global Staff role',
59+
},
4560
},
4661
);
4762

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { screen } from '@testing-library/react';
2+
import { initializeMockApp } from '@edx/frontend-platform/testing';
3+
import { renderWrapper } from '@src/setupTest';
4+
import RenderAdminRole from './RenderAdminRole';
5+
6+
describe('RenderAdminRole', () => {
7+
const adminRole = 'course_admin';
8+
const superuserRole = 'django.superuser';
9+
const staffRole = 'django.globalstaff';
10+
const instructorRole = 'instructor';
11+
const emptyRole = '';
12+
const mixedCaseAdminRole = 'Library_Admin';
13+
const regularRole = 'course_staff';
14+
15+
beforeAll(() => {
16+
initializeMockApp({
17+
authenticatedUser: {
18+
userId: 1,
19+
username: 'testuser',
20+
21+
},
22+
});
23+
});
24+
25+
it('renders without crashing', () => {
26+
const { container } = renderWrapper(<RenderAdminRole role={adminRole} />);
27+
expect(container.querySelector('.mb-0')).toBeInTheDocument();
28+
});
29+
30+
it('displays admin message for roles containing admin', () => {
31+
renderWrapper(<RenderAdminRole role={adminRole} />);
32+
expect(screen.getByText(/super admins have full access/i)).toBeInTheDocument();
33+
});
34+
35+
it('displays staff message for superuser role', () => {
36+
renderWrapper(<RenderAdminRole role={superuserRole} />);
37+
expect(screen.getByText(/global staff have access/i)).toBeInTheDocument();
38+
});
39+
40+
it('displays staff message for globalstaff role', () => {
41+
renderWrapper(<RenderAdminRole role={staffRole} />);
42+
expect(screen.getByText(/global staff have access/i)).toBeInTheDocument();
43+
});
44+
45+
it('displays staff message for instructor role', () => {
46+
renderWrapper(<RenderAdminRole role={instructorRole} />);
47+
expect(screen.getByText(/global staff have access/i)).toBeInTheDocument();
48+
});
49+
50+
it('handles undefined role gracefully', () => {
51+
renderWrapper(<RenderAdminRole role={undefined as any} />);
52+
expect(screen.getByText(/global staff have access/i)).toBeInTheDocument();
53+
});
54+
55+
it('handles empty role string', () => {
56+
renderWrapper(<RenderAdminRole role={emptyRole} />);
57+
expect(screen.getByText(/global staff have access/i)).toBeInTheDocument();
58+
});
59+
60+
it('displays admin message for mixed case admin role', () => {
61+
renderWrapper(<RenderAdminRole role={mixedCaseAdminRole} />);
62+
expect(screen.getByText(/super admins have full access/i)).toBeInTheDocument();
63+
});
64+
65+
it('displays staff message for regular role without admin', () => {
66+
renderWrapper(<RenderAdminRole role={regularRole} />);
67+
expect(screen.getByText(/global staff have access/i)).toBeInTheDocument();
68+
});
69+
70+
it('has correct CSS classes', () => {
71+
const { container } = renderWrapper(<RenderAdminRole role={adminRole} />);
72+
const paragraph = container.querySelector('p');
73+
expect(paragraph).toHaveClass('mb-0', 'text-primary-300', 'font-weight-light');
74+
});
75+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useIntl } from '@edx/frontend-platform/i18n';
2+
import messages from '@src/authz-module/audit-user/messages';
3+
4+
interface RenderAdminRoleProps {
5+
role: string;
6+
}
7+
8+
const RenderAdminRole = ({ role }: RenderAdminRoleProps) => {
9+
const intl = useIntl();
10+
// Determine which message to show based on role
11+
const messageKey = role?.toLowerCase().includes('admin')
12+
? 'authz.user.table.permissions.role.admin'
13+
: 'authz.user.table.permissions.role.staff';
14+
15+
return (
16+
<p className="mb-0 text-primary-300 font-weight-light">
17+
{intl.formatMessage(messages[messageKey])}
18+
</p>
19+
);
20+
};
21+
22+
export default RenderAdminRole;

0 commit comments

Comments
 (0)