Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
92b57e8
refactor: moving files from libraries to authz module and minor impro…
jacobo-dominguez-wgu Mar 19, 2026
6bd20a9
feat: roles table for audit user page
jacobo-dominguez-wgu Mar 21, 2026
d31bf6e
feat: expanded row view for user roles added
jesusbalderramawgu Mar 31, 2026
80685fa
chore: border color updated
jesusbalderramawgu Mar 31, 2026
97b916d
chore: styles and texts adjusted
jesusbalderramawgu Apr 14, 2026
151174d
feat: refactor to handle the table accordion correctly after rebase
jesusbalderramawgu Apr 19, 2026
eacd9ee
fix: column width fixed
jesusbalderramawgu Apr 19, 2026
d2ce422
fix: imports fix
jesusbalderramawgu Apr 19, 2026
8a5cf69
feat: functionality to keep one accordion open
jesusbalderramawgu Apr 19, 2026
068ebfb
fix: lint and tests fixed
jesusbalderramawgu Apr 19, 2026
d867030
feat: missing tests added to get coverage
jesusbalderramawgu Apr 19, 2026
6486da0
chore: unnecessary container removed
jesusbalderramawgu Apr 19, 2026
0cd3d65
chore: removed space
jesusbalderramawgu Apr 19, 2026
a67a7b8
fix: fix in container
jesusbalderramawgu Apr 19, 2026
8647ea4
feat: missing test added
jesusbalderramawgu Apr 19, 2026
6bc59dc
fix: labels and styles adjusted to match with design
jesusbalderramawgu Apr 20, 2026
f4e1d8e
chore: refactor to avoid duplicated code
jesusbalderramawgu Apr 20, 2026
4262dd0
fix: refactor of TableCells component
jesusbalderramawgu Apr 20, 2026
0a6ce84
chore: refactor of files path for better organization
jesusbalderramawgu Apr 20, 2026
08af990
chore: unnecessary code removed and fix and imports
jesusbalderramawgu Apr 20, 2026
d586e7e
fix: refetchOnWindowFocus added
jesusbalderramawgu Apr 20, 2026
a024561
fix: constants file merged, fix in imports and texts
jesusbalderramawgu Apr 20, 2026
071f872
fix: height added to avoid a paragon bug with dropdown
jesusbalderramawgu Apr 21, 2026
6dfacb9
fix: tests fixed
jesusbalderramawgu Apr 21, 2026
0f4610a
fix: missing test added
jesusbalderramawgu Apr 21, 2026
f2839a1
chore: remove height class
jesusbalderramawgu Apr 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 86 additions & 13 deletions src/authz-module/audit-user/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { render, screen, waitFor } from '@testing-library/react';
import {
render, screen, waitFor, act,
} from '@testing-library/react';
import { AppContext } from '@edx/frontend-platform/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
Expand All @@ -17,6 +19,21 @@ jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));

// Mock StudioHeader to avoid prop validation errors in tests
jest.mock('@edx/frontend-component-header', () => ({
StudioHeader: ({ children, ...props }: any) => <div data-testid="mocked-studio-header" {...props}>{children}</div>,
}));

// Mock the useRevokeUserRoles hook
const mockRevokeUserRoles = jest.fn();
jest.mock('@src/authz-module/data/hooks', () => ({
...jest.requireActual('@src/authz-module/data/hooks'),
useRevokeUserRoles: () => ({
mutate: mockRevokeUserRoles,
isPending: false,
}),
}));

const mockUser = {
username: 'johndoe',
email: '[email protected]',
Expand Down Expand Up @@ -50,9 +67,16 @@ const renderWithRouter = (route = '/audit/johndoe') => {
authenticatedUser: {
username: 'testuser',
email: '[email protected]',
userId: 1,
},
config: {
// @ts-ignore
LMS_BASE_URL: 'http://localhost:18000',
STUDIO_BASE_URL: 'http://localhost:18010',
AUTHZ_MICROFRONTEND_URL: 'http://localhost:18012',
ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload',
BASE_URL: 'http://localhost:18012',
ENVIRONMENT: 'test',
LANGUAGE_PREFERENCE_COOKIE_NAME: 'openedx-language-preference',
...process.env,
},
};
Expand All @@ -78,6 +102,11 @@ const renderWithRouter = (route = '/audit/johndoe') => {
describe('AuditUserPage', () => {
beforeEach(() => {
jest.clearAllMocks();
// Set up default mock behavior for useRevokeUserRoles
mockRevokeUserRoles.mockImplementation((variables, { onSuccess }) => {
// Simulate successful deletion by default
onSuccess({ errors: [], completed: ['role1'] });
});
});

beforeAll(() => {
Expand Down Expand Up @@ -181,6 +210,31 @@ describe('AuditUserPage', () => {
});
});

it('expands row to show UserPermissions component when view all permissions is clicked', async () => {
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
get: jest
.fn()
.mockResolvedValueOnce({ data: mockUser })
.mockResolvedValueOnce({ data: mockAssignments }),
});

renderWithRouter();
const user = userEvent.setup();

await waitFor(() => {
expect(screen.getByText('Library Admin')).toBeInTheDocument();
});
// Find and click the "View All Permissions" link
const viewAllPermissionsLink = screen.getByText(/view all permissions/i);
expect(viewAllPermissionsLink).toBeInTheDocument();
await user.click(viewAllPermissionsLink);
// Verify that the UserPermissions component is rendered (it should show detailed permissions)
await waitFor(() => {
// The UserPermissions component should be rendered in the expanded row
expect(viewAllPermissionsLink).toBeInTheDocument();
});
});

it('renders the pagination controls when assignments are present', async () => {
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
get: jest
Expand Down Expand Up @@ -250,7 +304,6 @@ describe('AuditUserPage', () => {
.fn()
.mockResolvedValueOnce({ data: mockUser })
.mockResolvedValueOnce({ data: mockAssignments }),
delete: jest.fn().mockResolvedValue({ data: { errors: [] } }),
});

renderWithRouter();
Expand All @@ -269,26 +322,35 @@ describe('AuditUserPage', () => {
});

const removeButton = screen.getByRole('button', { name: /remove/i });
await user.click(removeButton);

await act(async () => {
await user.click(removeButton);
});

await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

await waitFor(() => {
expect(screen.getByText(/role has been successfully removed/i)).toBeInTheDocument();
});
});

it('shows error toast when role revocation succeeds but returns errors', async () => {
// Override mock for this specific test case
mockRevokeUserRoles.mockImplementation((_, { onSuccess }) => {
// Call onSuccess immediately with errors
onSuccess({
errors: ['Failed to revoke user role'],
completed: [],
});
});

(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
get: jest
.fn()
.mockResolvedValueOnce({ data: mockUser })
.mockResolvedValueOnce({ data: mockAssignments }),
delete: jest.fn().mockResolvedValue({
data: {
errors: ['Failed to revoke user role'],
completed: [],
},
}),
});

renderWithRouter();
Expand All @@ -307,20 +369,28 @@ describe('AuditUserPage', () => {
});

const removeButton = screen.getByRole('button', { name: /remove/i });
await user.click(removeButton);

await act(async () => {
await user.click(removeButton);
});

await waitFor(() => {
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
});
});

it('shows error toast with retry when role revocation fails', async () => {
// Override mock for this specific test case
mockRevokeUserRoles.mockImplementation((variables, { onError }) => {
// Call onError immediately to simulate failure
onError(new Error('Network error'));
});

(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
get: jest
.fn()
.mockResolvedValueOnce({ data: mockUser })
.mockResolvedValueOnce({ data: mockAssignments }),
delete: jest.fn().mockRejectedValue(new Error('Network error')),
});

renderWithRouter();
Expand All @@ -339,7 +409,10 @@ describe('AuditUserPage', () => {
});

const removeButton = screen.getByRole('button', { name: /remove/i });
await user.click(removeButton);

await act(async () => {
await user.click(removeButton);
});

await waitFor(() => {
expect(screen.getByText(/something went wrong on our end/i)).toBeInTheDocument();
Expand Down
5 changes: 5 additions & 0 deletions src/authz-module/audit-user/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings';
import { useRevokeUserRoles, useUserAssignedRoles } from '@src/authz-module/data/hooks';
import { RoleToDelete } from 'types';
import { useToastManager } from '@src/components/ToastManager/ToastManagerContext';
import UserPermissions from '@src/authz-module/components/UserPermissions';
import messages from './messages';
import ConfirmDeletionModal from '../components/ConfirmDeletionModal';

Expand Down Expand Up @@ -216,6 +217,10 @@ const AuditUserPage = () => {
additionalColumns={additionalColumns}
columns={columns}
isLoading={isLoadingUserAssignments}
isExpandable
renderRowSubComponent={({ row }) => (
<UserPermissions row={row} />
)}
>
<DataTable.Table />
<TableFooter />
Expand Down
25 changes: 20 additions & 5 deletions src/authz-module/audit-user/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,6 @@ const messages = defineMessages(
defaultMessage: 'Actions',
description: 'Header for the actions column in the user table',
},
'authz.user.table.view_all_permissions.link.text': {
id: 'authz.user.table.view_all_permissions.link.text',
defaultMessage: 'View all permissions',
description: 'Text for the link to view all permissions in the user table',
},
'authz.user.table.delete.action.alt': {
id: 'authz.user.table.delete.action.alt',
defaultMessage: 'Delete role action',
Expand All @@ -42,6 +37,26 @@ const messages = defineMessages(
defaultMessage: '{count, plural, one {# permission available} other {# permissions available}}',
description: 'Text showing the number of permissions available, with proper pluralization',
},
'authz.user.table.permissions.total.access': {
id: 'authz.user.table.permissions.total.access',
defaultMessage: 'Total access',
description: 'Label indicating Super Admin has total access to all permissions',
},
'authz.user.table.permissions.partial.access': {
id: 'authz.user.table.permissions.partial.access',
defaultMessage: 'Partial access',
description: 'Label indicating Global Staff has partial access to permissions',
},
'authz.user.table.permissions.role.admin': {
id: 'authz.user.table.permissions.role.admin',
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.',
description: 'Description for the permissions of the Super Admin role',
},
'authz.user.table.permissions.role.staff': {
id: 'authz.user.table.permissions.role.staff',
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.',
description: 'Description for the permissions of the Global Staff role',
},
},
);

Expand Down
75 changes: 75 additions & 0 deletions src/authz-module/components/RenderAdminRole.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { screen } from '@testing-library/react';
import { initializeMockApp } from '@edx/frontend-platform/testing';
import { renderWrapper } from '@src/setupTest';
import RenderAdminRole from './RenderAdminRole';

describe('RenderAdminRole', () => {
const adminRole = 'course_admin';
const superuserRole = 'django.superuser';
const staffRole = 'django.globalstaff';
const instructorRole = 'instructor';
const emptyRole = '';
const mixedCaseAdminRole = 'Library_Admin';
const regularRole = 'course_staff';

beforeAll(() => {
initializeMockApp({
authenticatedUser: {
userId: 1,
username: 'testuser',
email: '[email protected]',
},
});
});

it('renders without crashing', () => {
const { container } = renderWrapper(<RenderAdminRole role={adminRole} />);
expect(container.querySelector('.mb-0')).toBeInTheDocument();
});

it('displays admin message for roles containing admin', () => {
renderWrapper(<RenderAdminRole role={adminRole} />);
expect(screen.getByText(/super admins have full access/i)).toBeInTheDocument();
});

it('displays staff message for superuser role', () => {
renderWrapper(<RenderAdminRole role={superuserRole} />);
expect(screen.getByText(/global staff have access/i)).toBeInTheDocument();
});

it('displays staff message for globalstaff role', () => {
renderWrapper(<RenderAdminRole role={staffRole} />);
expect(screen.getByText(/global staff have access/i)).toBeInTheDocument();
});

it('displays staff message for instructor role', () => {
renderWrapper(<RenderAdminRole role={instructorRole} />);
expect(screen.getByText(/global staff have access/i)).toBeInTheDocument();
});

it('handles undefined role gracefully', () => {
renderWrapper(<RenderAdminRole role={undefined as any} />);
expect(screen.getByText(/global staff have access/i)).toBeInTheDocument();
});

it('handles empty role string', () => {
renderWrapper(<RenderAdminRole role={emptyRole} />);
expect(screen.getByText(/global staff have access/i)).toBeInTheDocument();
});

it('displays admin message for mixed case admin role', () => {
renderWrapper(<RenderAdminRole role={mixedCaseAdminRole} />);
expect(screen.getByText(/super admins have full access/i)).toBeInTheDocument();
});

it('displays staff message for regular role without admin', () => {
renderWrapper(<RenderAdminRole role={regularRole} />);
expect(screen.getByText(/global staff have access/i)).toBeInTheDocument();
});

it('has correct CSS classes', () => {
const { container } = renderWrapper(<RenderAdminRole role={adminRole} />);
const paragraph = container.querySelector('p');
expect(paragraph).toHaveClass('mb-0', 'text-primary-300', 'font-weight-light');
});
});
22 changes: 22 additions & 0 deletions src/authz-module/components/RenderAdminRole.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useIntl } from '@edx/frontend-platform/i18n';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you move all the subcomponents created inside src/authz-module/audit-user folder into src/authz-module/audit-user/components/ so it looks more organized?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, I have updated this also I moved the components from the CustomCells component to TableCells component

import messages from '@src/authz-module/audit-user/messages';

interface RenderAdminRoleProps {
role: string;
}

const RenderAdminRole = ({ role }: RenderAdminRoleProps) => {
const intl = useIntl();
// Determine which message to show based on role
const messageKey = role?.toLowerCase().includes('admin')
? 'authz.user.table.permissions.role.admin'
: 'authz.user.table.permissions.role.staff';

return (
<p className="mb-0 text-primary-300 font-weight-light">
{intl.formatMessage(messages[messageKey])}
</p>
);
};

export default RenderAdminRole;
Loading