Skip to content
Merged
188 changes: 188 additions & 0 deletions src/authz-module/audit-user/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import AuditUserPage from './index';

jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
configure: jest.fn(),
}));

const mockUser = {
username: 'johndoe',
email: 'john@example.com',
profile_image: { has_image: false },
};
const mockAssignments = {
count: 1,
results: [
{
id: '1',
role: 'library_admin',
org: 'Test Org',
scope: 'lib:test',
permissionCount: 5,
},
],
next: null,
previous: null,
};

const renderWithRouter = (route = '/audit/johndoe') => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});

return render(
<QueryClientProvider client={queryClient}>
<IntlProvider locale="en">
<MemoryRouter initialEntries={[route]}>
<Routes>
<Route path="/audit/:username" element={<AuditUserPage />} />
<Route path="/authz" element={<div>Home Page</div>} />
</Routes>
</MemoryRouter>
</IntlProvider>
</QueryClientProvider>,
);
};

describe('AuditUserPage', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('renders user info and table when data is loaded', async () => {
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
get: jest
.fn()
.mockResolvedValueOnce({ data: mockUser })
.mockResolvedValueOnce({ data: mockAssignments }),
});

renderWithRouter();

await waitFor(() => {
expect(screen.getByRole('heading', { name: 'johndoe' })).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /assign role/i })).toBeInTheDocument();
expect(screen.getByText('Library Admin')).toBeInTheDocument();
expect(screen.getByText('Test Org')).toBeInTheDocument();
expect(screen.getByText('lib:test')).toBeInTheDocument();
expect(screen.getByText('5 permissions available')).toBeInTheDocument();
});
});

it('navigates to home if user is not found', async () => {
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
get: jest
.fn()
.mockResolvedValueOnce({ data: null })
.mockResolvedValueOnce({ data: mockAssignments }),
});

renderWithRouter();

await waitFor(() => {
expect(screen.getByText('Home Page')).toBeInTheDocument();
});
});

it('allows user to interact with Assign Role button', async () => {
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
get: jest
.fn()
.mockResolvedValueOnce({ data: mockUser })
.mockResolvedValueOnce({ data: mockAssignments }),
});

renderWithRouter();

await waitFor(() => {
expect(screen.getByRole('button', { name: /assign role/i })).toBeInTheDocument();
});

const user = userEvent.setup();
const button = screen.getByRole('button', { name: /assign role/i });
await user.click(button);
expect(button).not.toBeInTheDocument();
});

it('renders empty state when user has no assignments', async () => {
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
get: jest
.fn()
.mockResolvedValueOnce({ data: mockUser })
.mockResolvedValueOnce({
data: {
count: 0, results: [], next: null, previous: null,
},
}),
});

renderWithRouter();

await waitFor(() => {
expect(screen.getByRole('heading', { name: 'johndoe' })).toBeInTheDocument();
expect(screen.queryByText('5 permissions available')).not.toBeInTheDocument();
expect(screen.getByRole('table')).toBeInTheDocument();
});
});

it('renders correct table headers', async () => {
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
get: jest
.fn()
.mockResolvedValueOnce({ data: mockUser })
.mockResolvedValueOnce({ data: mockAssignments }),
});

renderWithRouter();

await waitFor(() => {
expect(screen.getByText('Role')).toBeInTheDocument();
expect(screen.getByText('Organization')).toBeInTheDocument();
expect(screen.getByText('Scope')).toBeInTheDocument();
expect(screen.getByText('Permissions')).toBeInTheDocument();
expect(screen.getByText('Actions')).toBeInTheDocument();
});
});

it('renders the pagination controls when assignments are present', async () => {
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
get: jest
.fn()
.mockResolvedValueOnce({ data: mockUser })
.mockResolvedValueOnce({ data: mockAssignments }),
});

renderWithRouter();

await waitFor(() => {
expect(screen.getByText('Showing 1 of 1.')).toBeInTheDocument();
});
});

it('renders the breadcrumb navigation with home link', async () => {
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
get: jest
.fn()
.mockResolvedValueOnce({ data: mockUser })
.mockResolvedValueOnce({ data: mockAssignments }),
});

renderWithRouter();

await waitFor(() => {
expect(screen.getByRole('link', { name: /roles and permissions management/i })).toBeInTheDocument();
expect(screen.getByText(mockUser.username, { selector: 'li[aria-current="page"]' })).toBeInTheDocument();
});
});
});
129 changes: 129 additions & 0 deletions src/authz-module/audit-user/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { useEffect, useMemo } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import debounce from 'lodash.debounce';
import {
Container, DataTable,
} from '@openedx/paragon';
import TableFooter from '@src/authz-module/components/TableFooter/TableFooter';
import { AUTHZ_HOME_PATH, TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants';
import AuthZLayout from '@src/authz-module/components/AuthZLayout';
import { useNavigate, useParams } from 'react-router-dom';
import { useUserAccount } from '@src/data/hooks';
import baseMessages from '@src/authz-module/messages';
import AddRoleButton from '@src/authz-module/components/AddRoleButton';
import {
OrgCell, RoleCell, ScopeCell, PermissionsCell, ViewAllPermissionsCell, ActionsCell,
} from '@src/authz-module/components/TableCells';
import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings';
import { useUserAssignedRoles } from '@src/authz-module/data/hooks';
import messages from './messages';

const AuditUserPage = () => {
const { formatMessage } = useIntl();
const { username } = useParams();
const navigate = useNavigate();
const {
isLoading: isLoadingUser, data: user, isError: isErrorUser, error: errorUser,
} = useUserAccount(username);
const { querySettings, handleTableFetch } = useQuerySettings();
const { isLoading: isLoadingUserAssignments, data: { results: userAssignments, count } = { results: [], count: 0 } } = useUserAssignedRoles(username ?? '', querySettings);

const fetchData = useMemo(() => debounce(handleTableFetch, 500), [handleTableFetch]);

useEffect(() => {
if (!user && !isLoadingUser) {
// @ts-ignore
if (!isErrorUser || errorUser?.customAttributes?.httpErrorStatus === 404) {
navigate(AUTHZ_HOME_PATH);
}
}
}, [user, isLoadingUser, navigate, isErrorUser, errorUser]);

useEffect(() => () => fetchData.cancel(), [fetchData]);

const navLinks = useMemo(() => [
{
label: formatMessage(baseMessages['authz.management.home.nav.link']),
to: AUTHZ_HOME_PATH,
},
], [formatMessage]);
const additionalColumns = useMemo(() => [
{
id: 'view_permissions',
Header: '',
Cell: ViewAllPermissionsCell,
},
{
id: 'action',
Header: formatMessage(messages['authz.user.table.action.column.header']),
Cell: ActionsCell,
},
], [formatMessage]);
const columns = useMemo(() => [
{
Header: formatMessage(messages['authz.user.table.role.column.header']),
accessor: 'role',
Cell: RoleCell,
},
{
Header: formatMessage(messages['authz.user.table.organization.column.header']),
accessor: 'org',
Cell: OrgCell,
},
{
Header: formatMessage(messages['authz.user.table.scope.column.header']),
accessor: 'scope',
Cell: ScopeCell,
disableFilters: true,
},
{
Header: formatMessage(messages['authz.user.table.permissions.column.header']),
Cell: PermissionsCell,
disableFilters: true,
disableSortBy: true,
},
], [formatMessage]);
const pageCount = Math.ceil(count / TABLE_DEFAULT_PAGE_SIZE);

return (
<div className="authz-module">
<AuthZLayout
context={{
id: '',
org: '',
title: '',
}}
navLinks={navLinks}
activeLabel={username || ''}
pageTitle={user?.username || ''}
pageSubtitle={user?.email || ''}
actions={
[
<AddRoleButton presetUsername={user?.username} key="add-role-button" />,
]
}
>
<Container className="bg-light-200 p-5">
<DataTable
isPaginated
manualPagination
data={userAssignments}
fetchData={fetchData}
itemCount={count}
pageCount={pageCount}
initialState={{ pageSize: TABLE_DEFAULT_PAGE_SIZE }}
additionalColumns={additionalColumns}
columns={columns}
isLoading={isLoadingUserAssignments}
>
<DataTable.Table />
<TableFooter />
</DataTable>

</Container>
</AuthZLayout>
</div>
);
};

export default AuditUserPage;
33 changes: 33 additions & 0 deletions src/authz-module/audit-user/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { defineMessages } from '@edx/frontend-platform/i18n';

const messages = defineMessages(
{
'authz.user.table.role.column.header': {
id: 'authz.user.table.role.column.header',
defaultMessage: 'Role',
description: 'Header for the role column in the user table',
},
'authz.user.table.organization.column.header': {
id: 'authz.user.table.organization.column.header',
defaultMessage: 'Organization',
description: 'Header for the organization column in the user table',
},
'authz.user.table.scope.column.header': {
id: 'authz.user.table.scope.column.header',
defaultMessage: 'Scope',
description: 'Header for the scope column in the user table',
},
'authz.user.table.permissions.column.header': {
id: 'authz.user.table.permissions.column.header',
defaultMessage: 'Permissions',
description: 'Header for the permissions column in the user table',
},
'authz.user.table.action.column.header': {
id: 'authz.user.table.action.column.header',
defaultMessage: 'Actions',
description: 'Header for the actions column in the user table',
},
},
);

export default messages;
2 changes: 1 addition & 1 deletion src/authz-module/components/AuthZTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const AuthZTitle = ({
/>
<Row className="mt-4">
<Col xs={12} md={7} className="mb-4">
<div className="d-flex align-items-center">
<div className="d-flex align-items-center flex-column-sm">
<h2 className="text-primary mb-0">{pageTitle}</h2>
{typeof pageSubtitle === 'string'
? <> { pageSubtitle !== '' && <hr className="mx-lg-3" /> }<h3 className="mb-0 py-2 font-weight-light text-gray-700">{pageSubtitle}</h3></>
Expand Down
Loading