diff --git a/src/authz-module/audit-user/index.test.tsx b/src/authz-module/audit-user/index.test.tsx new file mode 100644 index 00000000..63fe18f6 --- /dev/null +++ b/src/authz-module/audit-user/index.test.tsx @@ -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( + + + + + } /> + Home Page} /> + + + + , + ); +}; + +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(); + }); + }); +}); diff --git a/src/authz-module/audit-user/index.tsx b/src/authz-module/audit-user/index.tsx new file mode 100644 index 00000000..042d5b3b --- /dev/null +++ b/src/authz-module/audit-user/index.tsx @@ -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 ( +
+ , + ] + } + > + + + + + + + + +
+ ); +}; + +export default AuditUserPage; diff --git a/src/authz-module/audit-user/messages.ts b/src/authz-module/audit-user/messages.ts new file mode 100644 index 00000000..dbf9d7b3 --- /dev/null +++ b/src/authz-module/audit-user/messages.ts @@ -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; diff --git a/src/authz-module/components/AuthZTitle.tsx b/src/authz-module/components/AuthZTitle.tsx index c4aaa734..f21adc6f 100644 --- a/src/authz-module/components/AuthZTitle.tsx +++ b/src/authz-module/components/AuthZTitle.tsx @@ -50,7 +50,7 @@ const AuthZTitle = ({ /> -
+

{pageTitle}

{typeof pageSubtitle === 'string' ? <> { pageSubtitle !== '' &&
}

{pageSubtitle}

diff --git a/src/authz-module/components/TableCells.test.tsx b/src/authz-module/components/TableCells.test.tsx index 00e7ad3d..062b8593 100644 --- a/src/authz-module/components/TableCells.test.tsx +++ b/src/authz-module/components/TableCells.test.tsx @@ -8,8 +8,15 @@ import { RoleCell, OrgCell, ScopeCell, + PermissionsCell, + ActionsCell, + ViewAllPermissionsCell, } from './TableCells'; +// TODO: remove console.log mocks and implement actual logic for these cells, then update tests accordingly +// Mock console.log for TODO functions +jest.spyOn(console, 'log').mockImplementation(() => {}); + const mockNavigate = jest.fn(); jest.mock('react-router-dom', () => ({ @@ -417,4 +424,149 @@ describe('TableCells Components', () => { expect(screen.queryByText('Global')).not.toBeInTheDocument(); }); }); + + describe('PermissionsCell', () => { + it('displays "Total Access" for Django superuser role', () => { + const props = { + row: { + original: { + role: 'django.superuser', + org: 'Test Org', + scope: 'Test Scope', + permissionCount: 10, + }, + }, + column: { id: 'permissions' }, + }; + + renderWrapper(); + + expect(screen.getByText('Total Access')).toBeInTheDocument(); + }); + + it('displays "Partial Access" for Django global staff role', () => { + const props = { + row: { + original: { + role: 'django.globalstaff', + permissionCount: 5, + org: 'Test Org', + scope: 'Test Scope', + }, + }, + column: { id: 'permissions' }, + }; + + renderWrapper(); + + expect(screen.getByText('Partial Access')).toBeInTheDocument(); + }); + + it('displays permission count for non-Django roles', () => { + const props = { + row: { + original: { + role: 'library_admin', + permissionCount: 3, + org: 'Test Org', + scope: 'Test Scope', + }, + }, + column: { id: 'permissions' }, + }; + + renderWrapper(); + + expect(screen.getByText('3 permissions available')).toBeInTheDocument(); + }); + }); + + describe('ActionsCell', () => { + const mockRow = { + original: { + role: 'library_admin', id: '123', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }; + + it('renders a delete button', () => { + const props = { + row: mockRow, + column: { id: 'actions' }, + }; + + renderWrapper(); + + const deleteButton = screen.getByRole('button', { name: /delete role action/i }); + expect(deleteButton).toBeInTheDocument(); + }); + + it('calls handleDelete when delete button is clicked', async () => { + const user = userEvent.setup(); + const props = { + row: mockRow, + column: { id: 'actions' }, + }; + + renderWrapper(); + + const deleteButton = screen.getByRole('button', { name: /delete role action/i }); + await user.click(deleteButton); + // TODO: replace console.log with actual delete logic and update this test accordingly + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith('Delete clicked for row:', mockRow); + }); + + it('handles keyboard interaction for delete button', async () => { + const user = userEvent.setup(); + const props = { + row: mockRow, + column: { id: 'actions' }, + }; + + renderWrapper(); + + const deleteButton = screen.getByRole('button', { name: /delete role action/i }); + deleteButton.focus(); + await user.keyboard('{Enter}'); + // TODO: replace console.log with actual delete logic and update this test accordingly + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith('Delete clicked for row:', mockRow); + }); + }); + + describe('ViewAllPermissionsCell', () => { + const mockRow = { + original: { + role: 'library_admin', id: '123', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + }, + }; + + it('renders a view more link', () => { + const props = { + row: mockRow, + column: { id: 'viewMore' }, + }; + + renderWrapper(); + + expect(screen.getByText('View all permissions')).toBeInTheDocument(); + }); + + it('calls onClick handler when view more link is clicked', async () => { + const user = userEvent.setup(); + const props = { + row: mockRow, + column: { id: 'viewMore' }, + }; + + renderWrapper(); + + const viewMoreButton = screen.getByText('View all permissions'); + await user.click(viewMoreButton); + + // TODO: replace console.log with actual view more logic and update this test accordingly + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith('View more clicked for row:', mockRow); + }); + }); }); diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx index a1a36947..d15cb78f 100644 --- a/src/authz-module/components/TableCells.tsx +++ b/src/authz-module/components/TableCells.tsx @@ -3,13 +3,15 @@ import { Icon, IconButton } from '@openedx/paragon'; import { AppContext } from '@edx/frontend-platform/react'; import { RemoveRedEye, + Delete, ExpandMore, } from '@openedx/paragon/icons'; import { TableCellValue, AppContextType, UserRole } from '@src/types'; import { useNavigate } from 'react-router-dom'; import { useContext, useMemo } from 'react'; import { DJANGO_MANAGED_ROLES, MAP_ROLE_KEY_TO_LABEL } from '@src/authz-module/constants'; -import messages from './messages'; import { RESOURCE_ICONS } from './constants'; +import messages from './messages'; +import ViewMoreLink from './ViewMoreLink'; type CellProps = TableCellValue; type CellPropsWithValue = CellProps & { @@ -91,6 +93,55 @@ const RoleCell = ({ value, cell }: ExtendedCellProps) => ( ); +const PermissionsCell = ({ row }: CellProps) => { + const { formatMessage } = useIntl(); + const { role, permissionCount: count } = row.original; + const isDjangoRole = DJANGO_MANAGED_ROLES.includes(role); + return ( + + { isDjangoRole + ? formatMessage( + messages['authz.user.table.permissions.access.label'], + { accessType: role === 'django.superuser' ? 'total' : 'partial' }, + ) + : formatMessage(messages['authz.user.table.permissions.available.count'], { count })} + + ); +}; + +const ActionsCell = ({ row }: CellProps) => { + const { formatMessage } = useIntl(); + const handleDelete = () => { + // TODO: Implement delete functionality + // eslint-disable-next-line no-console + console.log('Delete clicked for row:', row); + }; + + return ( + + ); +}; + +const ViewAllPermissionsCell = ({ row }: CellProps) => { + const { formatMessage } = useIntl(); + return ( + console.log('View more clicked for row:', row)} + iconSrc={ExpandMore} + /> + ); +}; + export { - NameCell, ViewActionCell, ScopeCell, RoleCell, OrgCell, + NameCell, + ViewActionCell, + RoleCell, + OrgCell, + ScopeCell, + PermissionsCell, + ActionsCell, + ViewAllPermissionsCell, }; diff --git a/src/authz-module/components/ViewMoreLink.test.tsx b/src/authz-module/components/ViewMoreLink.test.tsx new file mode 100644 index 00000000..e687515c --- /dev/null +++ b/src/authz-module/components/ViewMoreLink.test.tsx @@ -0,0 +1,77 @@ +import { screen } from '@testing-library/react'; +import { renderWrapper } from '@src/setupTest'; +import userEvent from '@testing-library/user-event'; +import { ExpandMore } from '@openedx/paragon/icons'; +import ViewMoreLink from './ViewMoreLink'; + +describe('ViewMoreLink', () => { + const mockOnClick = jest.fn(); + const defaultProps = { + label: 'View more details', + onClick: mockOnClick, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders the link with the provided label', () => { + renderWrapper(); + + expect(screen.getByText('View more details')).toBeInTheDocument(); + }); + + it('renders without an icon when iconSrc is not provided', () => { + renderWrapper(); + + const link = screen.getByText('View more details'); + expect(link).toBeInTheDocument(); + // The icon should not be present when iconSrc is not provided + expect(link.querySelector('svg')).not.toBeInTheDocument(); + }); + + it('renders with an icon when iconSrc is provided', () => { + renderWrapper(); + + const link = screen.getByText('View more details'); + expect(link).toBeInTheDocument(); + // The icon should be present when iconSrc is provided + expect(link.querySelector('svg')).toBeInTheDocument(); + }); + }); + + describe('user interactions', () => { + it('calls onClick handler when user clicks the link', async () => { + const user = userEvent.setup(); + renderWrapper(); + + const link = screen.getByText('View more details'); + await user.click(link); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + it('calls onClick handler when user clicks the link with an icon', async () => { + const user = userEvent.setup(); + renderWrapper(); + + const link = screen.getByText('View more details'); + await user.click(link); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + it('handles multiple clicks correctly', async () => { + const user = userEvent.setup(); + renderWrapper(); + + const link = screen.getByText('View more details'); + await user.click(link); + await user.click(link); + await user.click(link); + + expect(mockOnClick).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/src/authz-module/components/ViewMoreLink.tsx b/src/authz-module/components/ViewMoreLink.tsx new file mode 100644 index 00000000..61820cbb --- /dev/null +++ b/src/authz-module/components/ViewMoreLink.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Hyperlink, Icon } from '@openedx/paragon'; + +interface ViewMoreLinkProps { + label: string; + onClick: () => void; + iconSrc?: React.ComponentType | undefined; +} + +const ViewMoreLink = ({ label, onClick, iconSrc }: ViewMoreLinkProps) => ( + { + e.preventDefault(); + onClick(); + }} + > + {label} + {iconSrc && ( + + )} + +); + +export default ViewMoreLink; diff --git a/src/authz-module/components/messages.ts b/src/authz-module/components/messages.ts index 541008f1..002d795f 100644 --- a/src/authz-module/components/messages.ts +++ b/src/authz-module/components/messages.ts @@ -97,6 +97,41 @@ const messages = defineMessages({ defaultMessage: 'Search to show more', description: 'Message displayed when there are more results available than currently shown', }, + 'authz.table.footer.items.showing.text': { + id: 'authz.table.footer.items.showing.text', + defaultMessage: 'Showing {pageSize} of {itemCount}.', + description: 'Text in the table footer indicating how many items are being shown out of the total count.', + }, + 'authz.user.table.org.all.organizations.label': { + id: 'authz.user.table.org.all.organizations.label', + defaultMessage: 'All Organizations', + description: 'Label for the "All Organizations" message on the user assignments table when a user has a django managed role assigned.', + }, + 'authz.user.table.scope.global.label': { + id: 'authz.user.table.scope.global.label', + defaultMessage: 'Global', + description: 'Label for the "Global" scope in the user assignments table when a user has a django managed role assigned.', + }, + 'authz.user.table.permissions.access.label': { + id: 'authz.user.table.permissions.access.label', + defaultMessage: '{accessType, select, total {Total Access} partial {Partial Access} other {No Access}}', + description: 'Label for the permissions access level in the user assignments table, can be Total or Partial.', + }, + 'authz.user.table.permissions.available.count': { + id: 'authz.user.table.permissions.available.count', + defaultMessage: '{count, plural, one {# permission available} other {# permissions available}}', + description: 'Text showing the number of permissions available, with proper pluralization', + }, + 'authz.user.table.delete.action.alt': { + id: 'authz.user.table.delete.action.alt', + defaultMessage: 'Delete role action', + description: 'Alt description for delete button', + }, + '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', + }, }); export default messages; diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index 6cd023df..997b1366 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -1,7 +1,113 @@ +import { PermissionMetadata, ResourceMetadata, RoleMetadata } from 'types'; + +export const CONTENT_LIBRARY_PERMISSIONS = { + DELETE_LIBRARY: 'content_libraries.delete_library', + MANAGE_LIBRARY_TAGS: 'content_libraries.manage_library_tags', + VIEW_LIBRARY: 'content_libraries.view_library', + + EDIT_LIBRARY_CONTENT: 'content_libraries.edit_library_content', + PUBLISH_LIBRARY_CONTENT: 'content_libraries.publish_library_content', + REUSE_LIBRARY_CONTENT: 'content_libraries.reuse_library_content', + + CREATE_LIBRARY_COLLECTION: 'content_libraries.create_library_collection', + EDIT_LIBRARY_COLLECTION: 'content_libraries.edit_library_collection', + DELETE_LIBRARY_COLLECTION: 'content_libraries.delete_library_collection', + + MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team', + VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team', +}; + +export const CONTENT_COURSE_PERMISSIONS = { + VIEW_COURSE: 'courses.view_course', + CREATE_COURSE: 'courses.create_course', + EDIT_COURSE_CONTENT: 'courses.edit_course_content', + PUBLISH_COURSE_CONTENT: 'courses.publish_course_content', + + REVIEW_COURSE_LIBRARY_UPDATES: 'courses.manage_library_updates', + + VIEW_COURSE_UPDATES: 'courses.view_course_updates', + MANAGE_COURSE_UPDATES: 'courses.manage_course_updates', + + VIEW_COURSE_PAGES_RESOURCES: 'courses.view_pages_and_resources', + MANAGE_COURSE_PAGES_RESOURCES: 'courses.manage_pages_and_resources', + + VIEW_COURSE_FILES: 'courses.view_files', + CREATE_COURSE_FILES: 'courses.create_files', + EDIT_COURSE_FILES: 'courses.edit_files', + DELETE_COURSE_FILES: 'courses.delete_files', + + VIEW_COURSE_SCHEDULE: 'courses.view_schedule', + EDIT_COURSE_SCHEDULE: 'courses.edit_schedule', + VIEW_COURSE_DETAILS: 'courses.view_details', + EDIT_COURSE_DETAILS: 'courses.edit_details', + + VIEW_COURSE_GRADING_SETTINGS: 'courses.view_grading_settings', + EDIT_COURSE_GRADING_SETTINGS: 'courses.edit_grading_settings', + + VIEW_COURSE_TEAM: 'courses.view_course_team', + MANAGE_COURSE_TEAM: 'courses.manage_course_team', + MANAGE_COURSE_GROUP_CONFIGURATION: 'courses.manage_group_configurations', + + MANAGE_COURSE_TAGS: 'courses.manage_tags', + MANAGE_COURSE_TAXONOMIES: 'courses.manage_taxonomies', + + MANAGE_COURSE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings', + MANAGE_COURSE_CERTIFICATES: 'courses.manage_certificates', + + IMPORT_COURSE: 'courses.import_course', + EXPORT_COURSE: 'courses.export_course', + EXPORT_COURSE_TAGS: 'courses.export_tags', + + VIEW_COURSE_CHECKLISTS: 'courses.view_checklists', + VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS: 'courses.view_global_staff_and_superadmins', +}; + +// Note: this information will eventually come from the backend API +// but for the MVP we decided to manage it in the frontend +export const libraryRolesMetadata: RoleMetadata[] = [ + { role: 'library_admin', name: 'Library Admin', description: 'The Library Admin has full control over the library, including managing users, modifying content, and handling publishing workflows. They ensure content is properly maintained and accessible as needed.' }, + { role: 'library_author', name: 'Library Author', description: 'The Library Author is responsible for creating, editing, and publishing content within a library. They can manage tags and collections but cannot delete libraries or manage users.' }, + { role: 'library_contributor', name: 'Library Contributor', description: 'The Library Contributor can create and edit content within a library but cannot publish it. They support the authoring process while leaving final publishing to Authors or Admins.' }, + { role: 'library_user', name: 'Library User', description: 'The Library User can view and reuse content but cannot edit or delete any resource.' }, +]; + +export const libraryResourceTypes: ResourceMetadata[] = [ + { key: 'library', label: 'Library', description: 'Permissions related to the library as a whole.' }, + { key: 'library_content', label: 'Content', description: 'Permissions to create, edit, delete, and publish individual content items within the library.' }, + { key: 'library_collection', label: 'Collection', description: 'Permissions to create, edit, and delete content collections within the library.' }, + { key: 'library_team', label: 'Team', description: 'Permissions to manage user access and roles within the library.' }, +]; + +export const libraryPermissions: PermissionMetadata[] = [ + { key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY, resource: 'library', description: 'Allows the user to delete the library and all its contents.' }, + { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, resource: 'library', description: 'Add or remove tags from content.' }, + { key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, resource: 'library', description: 'View content, search, filter, and sort within the library.' }, + + { key: CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT, resource: 'library_content', description: 'Edit content in draft mode' }, + { key: CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT, resource: 'library_content', description: 'Publish content, making it available for reuse' }, + { key: CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT, resource: 'library_content', description: 'Reuse published content within a course.' }, + + { key: CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Create new collections within a library.' }, + { key: CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Add or remove content from existing collections.' }, + { key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Delete entire collections from the library.' }, + + { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, resource: 'library_team', description: 'View the list of users who have access to the library.' }, + { key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, resource: 'library_team', description: 'Add, remove, and assign roles to users within the library.' }, +]; + +export const DEFAULT_TOAST_DELAY = 5000; +export const RETRY_TOAST_DELAY = 120_000; // 2 minutes +export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({ + username: 'skeleton', + name: '', + email: '', + roles: [], +})); + export const ROUTES = { LIBRARIES_TEAM_PATH: '/libraries/:libraryId', LIBRARIES_USER_PATH: '/libraries/:libraryId/:username', - + AUDIT_USER_PATH: '/user/:username', }; export enum RoleOperationErrorStatus { @@ -14,6 +120,8 @@ export enum RoleOperationErrorStatus { export const MAX_TABLE_FILTERS_APPLIED = 10; +export const AUTHZ_HOME_PATH = '/authz'; + export const MAP_ROLE_KEY_TO_LABEL: Record = { library_admin: 'Library Admin', library_author: 'Library Author', diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index d5eb8437..b9db8bbb 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -22,6 +22,13 @@ export interface GetTeamMembersResponse { count: number; } +export interface GetUserAssignmentsResponse { + results: UserRole[]; + count: number; + next: string | null; + previous: string | null; +} + export type RevokeUserRolesRequest = { users: string; role: string; @@ -191,3 +198,24 @@ export const getScopes = async (search?: string, page?: number, pageSize?: numbe const { data } = await getAuthenticatedHttpClient().get(url); return camelCaseObject(data); }; + +export const getUserAssignedRoles = async (username: string, querySettings: QuerySettings) +: Promise => { + const url = new URL(getApiUrl(`/api/authz/v1/users/${username}/assignments/`)); + + if (querySettings.roles) { + url.searchParams.set('roles', querySettings.roles); + } + if (querySettings.search) { + url.searchParams.set('search', querySettings.search); + } + if (querySettings.sortBy && querySettings.order) { + url.searchParams.set('sort_by', querySettings.sortBy); + url.searchParams.set('order', querySettings.order); + } + url.searchParams.set('page_size', querySettings.pageSize.toString()); + url.searchParams.set('page', (querySettings.pageIndex + 1).toString()); + + const { data } = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(data); +}; diff --git a/src/authz-module/data/hooks.test.tsx b/src/authz-module/data/hooks.test.tsx index 7fa8f9ea..0736be91 100644 --- a/src/authz-module/data/hooks.test.tsx +++ b/src/authz-module/data/hooks.test.tsx @@ -3,10 +3,15 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { - useLibrary, usePermissionsByRole, useTeamMembers, useAssignTeamMembersRole, useRevokeUserRoles, + useLibrary, + usePermissionsByRole, + useTeamMembers, + useAssignTeamMembersRole, + useRevokeUserRoles, useAllRoleAssignments, useOrgs, useScopes, + useUserAssignedRoles, } from './hooks'; jest.mock('@edx/frontend-platform/auth', () => ({ @@ -111,6 +116,53 @@ const mockQuerySettings = { pageIndex: 0, }; +const mockUserAssignments = { + count: 3, + results: [ + { + id: '1', + role: 'library_admin', + scope: 'lib:test-library-1', + permissionCount: 15, + }, + { + id: '2', + role: 'course_staff', + scope: 'course:test-course-1', + permissionCount: 8, + }, + { + id: '3', + role: 'django.superuser', + scope: 'global', + permissionCount: 50, + }, + ], + next: 'http://api.example.com/userAssignments?page=2', + previous: null, +}; + +const mockEmptyUserAssignments = { + count: 0, + results: [], + next: null, + previous: null, +}; + +const mockFilteredUserAssignments = { + count: 1, + results: [ + { + id: '1', + role: 'library_admin', + scope: 'lib:test-library-1', + permissionCount: 15, + }, + ], + next: null, + previous: null, +}; + const createWrapper = () => { const queryClient = new QueryClient({ defaultOptions: { @@ -527,3 +579,131 @@ describe('useScopes', () => { }); }); }); + +describe('useUserAssignedRoles', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns user role assignments when API call succeeds', async () => { + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: jest.fn().mockResolvedValue({ data: mockUserAssignments }), + }); + + const { result } = renderHook(() => useUserAssignedRoles('john.doe', mockQuerySettings), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(getAuthenticatedHttpClient).toHaveBeenCalled(); + expect(result.current.data).toEqual(mockUserAssignments); + expect(result.current.data?.results).toHaveLength(3); + expect(result.current.data?.count).toBe(3); + }); + + it('returns empty results when user has no role assignments', async () => { + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: jest.fn().mockResolvedValue({ data: mockEmptyUserAssignments }), + }); + + const { result } = renderHook(() => useUserAssignedRoles('newuser', mockQuerySettings), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.results).toHaveLength(0); + expect(result.current.data?.count).toBe(0); + expect(result.current.data?.next).toBeNull(); + }); + + it('applies query settings for filtering and pagination', async () => { + const filteredQuerySettings = { + ...mockQuerySettings, + roles: 'library_admin', + search: 'library', + pageSize: 5, + pageIndex: 1, + }; + + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: jest.fn().mockResolvedValue({ data: mockFilteredUserAssignments }), + }); + + const { result } = renderHook(() => useUserAssignedRoles('john.doe', filteredQuerySettings), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.results).toHaveLength(1); + expect(result.current.data?.results[0].role).toBe('library_admin'); + }); + + it('handles API error when fetching user assignments fails', async () => { + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: jest.fn().mockRejectedValue(new Error('User not found')), + }); + + const { result } = renderHook(() => useUserAssignedRoles('nonexistent.user', mockQuerySettings), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error?.message).toBe('User not found'); + expect(result.current.data).toBeUndefined(); + }); + + it('does not refetch on window focus', async () => { + const mockGet = jest.fn().mockResolvedValue({ data: mockUserAssignments }); + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: mockGet, + }); + + const { result } = renderHook(() => useUserAssignedRoles('john.doe', mockQuerySettings), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + act(() => { + window.dispatchEvent(new Event('focus')); + }); + + expect(mockGet).toHaveBeenCalledTimes(1); + }); + + it('updates when query settings change', async () => { + const mockGet = jest.fn() + .mockResolvedValueOnce({ data: mockUserAssignments }) + .mockResolvedValueOnce({ data: mockFilteredUserAssignments }); + + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: mockGet, + }); + + const { result, rerender } = renderHook( + ({ querySettings }) => useUserAssignedRoles('john.doe', querySettings), + { + wrapper: createWrapper(), + initialProps: { querySettings: mockQuerySettings }, + }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.count).toBe(3); + + rerender({ + querySettings: { + ...mockQuerySettings, + roles: 'library_admin', + pageSize: 1, + }, + }); + + await waitFor(() => expect(result.current.data?.count).toBe(1)); + expect(mockGet).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts index 8b0cfa1d..cfbc71ba 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -8,7 +8,7 @@ import { GetAllRoleAssignmentsResponse, getLibrary, getOrgs, GetOrgsResponse, getPermissionsByRole, getScopes, GetScopesResponse, getTeamMembers, GetTeamMembersResponse, PermissionsByRole, QuerySettings, revokeUserRoles, - RevokeUserRolesRequest, + RevokeUserRolesRequest, getUserAssignedRoles, GetUserAssignmentsResponse, } from './api'; const authzQueryKeys = { @@ -21,6 +21,7 @@ const authzQueryKeys = { allRoleAssignments: (querySettings?: QuerySettings) => [...authzQueryKeys.all, 'allRoleAssignments', querySettings] as const, orgs: (search?: string, page?: number, pageSize?: number) => [...authzQueryKeys.all, 'organizations', search, page, pageSize] as const, scopes: (search?: string, page?: number, pageSize?: number) => [...authzQueryKeys.all, 'scopes', search, page, pageSize] as const, + userRoles: (username: string, querySettings?: QuerySettings) => [...authzQueryKeys.all, 'userRoles', username, querySettings] as const, }; /** @@ -167,3 +168,23 @@ export const useScopes = (search?: string, page?: number, pageSize?: number) => }); return result; }; + +/* + * React Query hook to fetch all the roles assigned to a specific user. + * It retrieves the full list of roles with the corresponding permissions. + * @param username - The username of the user + * @param querySettings - Optional query parameters for filtering, sorting, and pagination + * + * @example + * ```tsx + * const { data: userRoles } = useUserAssignedRoles('jdoe', querySettings); + * ``` +*/ +export const useUserAssignedRoles = ( + username: string, + querySettings: QuerySettings, +) => useQuery({ + queryKey: authzQueryKeys.userRoles(username, querySettings), + queryFn: () => getUserAssignedRoles(username, querySettings), + staleTime: 1000 * 60 * 30, // refetch after 30 minutes +}); diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss index 93543517..074f1f74 100644 --- a/src/authz-module/index.scss +++ b/src/authz-module/index.scss @@ -21,6 +21,11 @@ width: 0; } + .pgn__data-table tr:has(td[data-role="Super Admin"]), + .pgn__data-table tr:has(td[data-role="Global Staff"]) { + background-color: var(--pgn-color-primary-200); + } + .tab-content { background-color: var(--pgn-color-light-200); } @@ -57,6 +62,17 @@ line-height: 24px; } } + + @media(--pgn-size-breakpoint-max-width-sm){ + .flex-column-sm { + flex-direction: column; + } + hr { + border-top: var(--pgn-size-border-width) solid var(--pgn-color-border); + border-right: none; + width: 100%; + } + } } .toast-container { diff --git a/src/authz-module/index.tsx b/src/authz-module/index.tsx index a5b6cbcc..3a6de7f2 100644 --- a/src/authz-module/index.tsx +++ b/src/authz-module/index.tsx @@ -8,6 +8,7 @@ import { CustomErrors } from '@src/constants'; import { ToastManagerProvider } from './libraries-manager/ToastManagerContext'; import { LibrariesUserManager, LibrariesLayout, LibrariesTeamManager } from './libraries-manager'; import AuthzHome from './authz-home'; +import AuditUserPage from './audit-user'; import { ROUTES } from './constants'; import './index.scss'; @@ -29,6 +30,10 @@ const AuthZModule = () => ( } /> } /> + } + /> } /> diff --git a/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx index 0e64ca76..959c044e 100644 --- a/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx @@ -2,11 +2,11 @@ import { screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWrapper } from '@src/setupTest'; import { initializeMockApp } from '@edx/frontend-platform/testing'; -import { useLibrary, useUpdateLibrary } from '@src/authz-module/data/hooks'; +import { useLibrary } from '@src/authz-module/data/hooks'; import { useLibraryAuthZ } from './context'; import LibrariesTeamManager from './LibrariesTeamManager'; import { ToastManagerProvider } from './ToastManagerContext'; -import { CONTENT_LIBRARY_PERMISSIONS } from './constants'; +import { CONTENT_LIBRARY_PERMISSIONS } from '../constants'; jest.mock('./context', () => { const actual = jest.requireActual('./context'); @@ -21,7 +21,6 @@ const mockedUseLibraryAuthZ = useLibraryAuthZ as jest.Mock; jest.mock('@src/authz-module/data/hooks', () => ({ useLibrary: jest.fn(), - useUpdateLibrary: jest.fn(), })); jest.mock('./components/TeamTable', () => ({ @@ -57,7 +56,6 @@ describe('LibrariesTeamManager', () => { org: 'Test Org', allowPublicRead: false, }; - const mutate = jest.fn(); const libraryAuthZContext = { libraryId: libraryData.id, libraryName: libraryData.title, @@ -91,10 +89,6 @@ describe('LibrariesTeamManager', () => { (useLibrary as jest.Mock).mockReturnValue({ data: libraryData, }); - (useUpdateLibrary as jest.Mock).mockReturnValue({ - mutate, - isPending: false, - }); }); it('renders tabs and layout content correctly', () => { diff --git a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx index fa4b2c60..f8a7ed46 100644 --- a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx @@ -40,7 +40,7 @@ const LibrariesTeamManager = () => { }, [roles, permissions, resources, intl]); return ( -
+
{ }; return ( -
+
onClick={open} disabled={isPending} > - {intl.formatMessage(messages['authz.manage.assign.role.title'])} + {intl.formatMessage(baseMessages['authz.management.assign.role.title'])} {isOpen && ( diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/messages.ts b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/messages.ts index fb6c516f..05e28d5e 100644 --- a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/messages.ts +++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/messages.ts @@ -1,11 +1,6 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ - 'authz.manage.assign.role.title': { - id: 'authz.manage.assign.role.title', - defaultMessage: 'Assign Role', - description: 'Text for the assign role button', - }, 'libraries.authz.manage.add.member.title': { id: 'libraries.authz.manage.add.member.title', defaultMessage: 'Add New Team Member', diff --git a/src/authz-module/libraries-manager/components/TeamTable/components/Cells.test.tsx b/src/authz-module/libraries-manager/components/TeamTable/components/Cells.test.tsx index fe7c9404..ce627b32 100644 --- a/src/authz-module/libraries-manager/components/TeamTable/components/Cells.test.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable/components/Cells.test.tsx @@ -23,7 +23,7 @@ jest.mock('@src/authz-module/data/hooks', () => ({ useTeamMembers: jest.fn(), })); -jest.mock('../hooks/useQuerySettings', () => ({ +jest.mock('@src/authz-module/hooks/useQuerySettings', () => ({ useQuerySettings: jest.fn(() => ({ querySettings: { page: 1, limit: 10 }, })), diff --git a/src/authz-module/libraries-manager/components/TeamTable/components/Cells.tsx b/src/authz-module/libraries-manager/components/TeamTable/components/Cells.tsx index 9f160994..9f0faa43 100644 --- a/src/authz-module/libraries-manager/components/TeamTable/components/Cells.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable/components/Cells.tsx @@ -5,8 +5,8 @@ import { TableCellValue, TeamMember } from '@src/types'; import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context'; import { useNavigate } from 'react-router-dom'; import { useTeamMembers } from '@src/authz-module/data/hooks'; -import { SKELETON_ROWS } from '@src/authz-module/libraries-manager/constants'; -import { useQuerySettings } from '../hooks/useQuerySettings'; +import { SKELETON_ROWS } from '@src/authz-module/constants'; +import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings'; import messages from '../messages'; type CellProps = TableCellValue; diff --git a/src/authz-module/libraries-manager/components/TeamTable/hooks/useQuerySettings.test.ts b/src/authz-module/libraries-manager/components/TeamTable/hooks/useQuerySettings.test.ts deleted file mode 100644 index 6a9dc765..00000000 --- a/src/authz-module/libraries-manager/components/TeamTable/hooks/useQuerySettings.test.ts +++ /dev/null @@ -1,447 +0,0 @@ -import { renderHook, act } from '@testing-library/react'; -import { QuerySettings } from '@src/authz-module/data/api'; -import { useQuerySettings } from './useQuerySettings'; - -describe('useQuerySettings', () => { - const defaultQuerySettings: QuerySettings = { - roles: null, - search: null, - pageSize: 10, - pageIndex: 0, - sortBy: null, - order: null, - }; - - it('should initialize with default query settings when no initial settings provided', () => { - const { result } = renderHook(() => useQuerySettings()); - - expect(result.current.querySettings).toEqual(defaultQuerySettings); - expect(typeof result.current.handleTableFetch).toBe('function'); - }); - - it('should initialize with custom initial query settings', () => { - const customInitialSettings: QuerySettings = { - roles: 'admin,editor', - search: 'test-user', - pageSize: 20, - pageIndex: 2, - sortBy: 'username', - order: 'asc', - }; - - const { result } = renderHook(() => useQuerySettings(customInitialSettings)); - - expect(result.current.querySettings).toEqual(customInitialSettings); - }); - - it('should update query settings when handleTableFetch is called with new filters', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 15, - pageIndex: 1, - sortBy: [{ id: 'username', desc: false }], - filters: [ - { id: 'roles', value: ['admin', 'editor'] }, - { id: 'username', value: 'john' }, - ], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings).toEqual({ - roles: 'admin,editor', - search: 'john', - pageSize: 15, - pageIndex: 1, - sortBy: 'username', - order: 'asc', - }); - }); - - it('should handle descending sort order by adding minus prefix', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 10, - pageIndex: 0, - sortBy: [{ id: 'email', desc: true }], - filters: [], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings.order).toBe('desc'); - }); - - it('should convert camelCase sort field to snake_case', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 10, - pageIndex: 0, - sortBy: [{ id: 'firstName', desc: false }], - filters: [], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings.sortBy).toBe('first_name'); - }); - - it('should convert camelCase sort field to snake_case with descending order', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 10, - pageIndex: 0, - sortBy: [{ id: 'lastName', desc: true }], - filters: [], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings.order).toBe('desc'); - }); - - it('should handle empty filters by setting values to null', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 10, - pageIndex: 0, - sortBy: [], - filters: [], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings).toEqual({ - roles: null, - search: null, - pageSize: 10, - pageIndex: 0, - order: null, - sortBy: null, - }); - }); - - it('should handle empty roles filter array by setting roles to null', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 10, - pageIndex: 0, - sortBy: [], - filters: [ - { id: 'roles', value: [] }, - { id: 'username', value: '' }, - ], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings).toEqual({ - roles: null, - search: null, - pageSize: 10, - pageIndex: 0, - order: null, - sortBy: null, - }); - }); - - it('should handle missing filters by setting default values', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 10, - pageIndex: 0, - sortBy: [], - filters: [ - { id: 'roles', value: undefined }, - { id: 'username', value: undefined }, - ], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings).toEqual({ - roles: null, - search: null, - pageSize: 10, - pageIndex: 0, - order: null, - sortBy: null, - }); - }); - - it('should use default pagination values when not provided', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - sortBy: [], - filters: [], - } as any; // Missing pageSize and pageIndex - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings.pageSize).toBe(10); - expect(result.current.querySettings.pageIndex).toBe(0); - }); - - it('should not update state if settings have not changed', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 10, - pageIndex: 0, - sortBy: [], - filters: [], - }; - - const initialSettings = result.current.querySettings; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - // Should be the same object reference since no changes occurred - expect(result.current.querySettings).toBe(initialSettings); - }); - - it('should update state when settings have changed', () => { - const { result } = renderHook(() => useQuerySettings()); - - const initialSettings = result.current.querySettings; - - const tableFilters = { - pageSize: 20, // Different from default - pageIndex: 0, - sortBy: [], - filters: [], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - // Should be a different object reference since pageSize changed - expect(result.current.querySettings).not.toBe(initialSettings); - expect(result.current.querySettings.pageSize).toBe(20); - }); - - it('should handle complex filter combinations', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 25, - pageIndex: 3, - sortBy: [{ id: 'userRole', desc: true }], - filters: [ - { id: 'roles', value: ['admin', 'editor', 'viewer'] }, - { id: 'username', value: 'test@example.com' }, - { id: 'otherFilter', value: 'ignored' }, // Should be ignored - ], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings).toEqual({ - roles: 'admin,editor,viewer', - search: 'test@example.com', - pageSize: 25, - pageIndex: 3, - order: 'desc', - sortBy: 'user_role', - }); - }); - - it('should handle multiple camelCase words in sort field', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 10, - pageIndex: 0, - sortBy: [{ id: 'userFirstLastName', desc: false }], - filters: [], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings.sortBy).toBe('user_first_last_name'); - }); - - it('should preserve handleTableFetch function reference across renders', () => { - const { result, rerender } = renderHook(() => useQuerySettings()); - - const initialHandleTableFetch = result.current.handleTableFetch; - - rerender(); - - expect(result.current.handleTableFetch).toBe(initialHandleTableFetch); - }); - - it('should handle whitespace-only search values as provided', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 10, - pageIndex: 0, - sortBy: [], - filters: [ - { id: 'username', value: ' ' }, // Whitespace only - ], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings.search).toBe(' '); - }); - - it('should detect changes in roles filter', () => { - const { result } = renderHook(() => useQuerySettings()); - - // First set some roles - act(() => { - result.current.handleTableFetch({ - pageSize: 10, - pageIndex: 0, - sortBy: [], - filters: [{ id: 'roles', value: ['admin'] }], - }); - }); - - const settingsAfterFirstUpdate = result.current.querySettings; - - // Then change roles - act(() => { - result.current.handleTableFetch({ - pageSize: 10, - pageIndex: 0, - sortBy: [], - filters: [{ id: 'roles', value: ['editor'] }], - }); - }); - - expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate); - expect(result.current.querySettings.roles).toBe('editor'); - }); - - it('should detect changes in search filter', () => { - const { result } = renderHook(() => useQuerySettings()); - - // First set a search term - act(() => { - result.current.handleTableFetch({ - pageSize: 10, - pageIndex: 0, - sortBy: [], - filters: [{ id: 'username', value: 'john' }], - }); - }); - - const settingsAfterFirstUpdate = result.current.querySettings; - - // Then change search term - act(() => { - result.current.handleTableFetch({ - pageSize: 10, - pageIndex: 0, - sortBy: [], - filters: [{ id: 'username', value: 'jane' }], - }); - }); - - expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate); - expect(result.current.querySettings.search).toBe('jane'); - }); - - it('should detect changes in ordering', () => { - const { result } = renderHook(() => useQuerySettings()); - - // First set ordering - act(() => { - result.current.handleTableFetch({ - pageSize: 10, - pageIndex: 0, - sortBy: [{ id: 'username', desc: false }], - filters: [], - }); - }); - - const settingsAfterFirstUpdate = result.current.querySettings; - - // Then change ordering - act(() => { - result.current.handleTableFetch({ - pageSize: 10, - pageIndex: 0, - sortBy: [{ id: 'email', desc: true }], - filters: [], - }); - }); - - expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate); - expect(result.current.querySettings.sortBy).toBe('email'); - expect(result.current.querySettings.order).toBe('desc'); - }); - - it('should detect changes in pageSize', () => { - const { result } = renderHook(() => useQuerySettings()); - - const initialSettings = result.current.querySettings; - - act(() => { - result.current.handleTableFetch({ - pageSize: 50, - pageIndex: 0, - sortBy: [], - filters: [], - }); - }); - - expect(result.current.querySettings).not.toBe(initialSettings); - expect(result.current.querySettings.pageSize).toBe(50); - }); - - it('should detect changes in pageIndex', () => { - const { result } = renderHook(() => useQuerySettings()); - - const initialSettings = result.current.querySettings; - - act(() => { - result.current.handleTableFetch({ - pageSize: 10, - pageIndex: 5, - sortBy: [], - filters: [], - }); - }); - - expect(result.current.querySettings).not.toBe(initialSettings); - expect(result.current.querySettings.pageIndex).toBe(5); - }); -}); diff --git a/src/authz-module/libraries-manager/components/TeamTable/hooks/useQuerySettings.ts b/src/authz-module/libraries-manager/components/TeamTable/hooks/useQuerySettings.ts deleted file mode 100644 index 3c7d879f..00000000 --- a/src/authz-module/libraries-manager/components/TeamTable/hooks/useQuerySettings.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { useCallback, useState } from 'react'; -import { QuerySettings } from '@src/authz-module/data/api'; - -interface DataTableFilters { - pageSize: number; - pageIndex: number; - sortBy: Array<{ id: string; desc: boolean }>; - filters: Array<{ id: string; value: any }>; -} - -interface UseQuerySettingsReturn { - querySettings: QuerySettings; - handleTableFetch: (tableFilters: DataTableFilters) => void; -} - -enum SortOrderKeys { - ASC = 'asc', - DESC = 'desc', -} - -/** - * Custom hook to manage query settings for table data fetching - * Converts DataTable filter/sort/pagination settings to API query parameters - * and manages URL synchronization - * - * @param initialQuerySettings - Initial query settings - * @returns Object containing querySettings and handleTableFetch function - */ -export const useQuerySettings = ( - initialQuerySettings: QuerySettings = { - roles: null, - search: null, - pageSize: 10, - pageIndex: 0, - order: null, - sortBy: null, - }, -): UseQuerySettingsReturn => { - const [querySettings, setQuerySettings] = useState(initialQuerySettings); - - const handleTableFetch = useCallback((tableFilters: DataTableFilters) => { - setQuerySettings((prevSettings) => { - // Extract filters - const rolesFilter = tableFilters.filters.find((filter) => filter.id === 'roles')?.value?.join(',') ?? ''; - const searchFilter = tableFilters.filters.find((filter) => filter.id === 'username')?.value ?? ''; - - // Extract pagination - const { pageSize = 10, pageIndex = 0 } = tableFilters; - - // Extract and convert sorting - let sortByOption = ''; - let sortByOrder = ''; - if (tableFilters.sortBy.length) { - sortByOption = tableFilters.sortBy[0].id.replace(/([A-Z])/g, '_$1').toLowerCase(); - sortByOrder = tableFilters.sortBy[0].desc ? SortOrderKeys.DESC : SortOrderKeys.ASC; - } - - const newQuerySettings: QuerySettings = { - roles: rolesFilter || null, - search: searchFilter || null, - sortBy: sortByOption || null, - order: sortByOrder || null, - pageSize, - pageIndex, - }; - - const hasChanged = ( - prevSettings.roles !== newQuerySettings.roles - || prevSettings.search !== newQuerySettings.search - || prevSettings.pageSize !== newQuerySettings.pageSize - || prevSettings.pageIndex !== newQuerySettings.pageIndex - || prevSettings.sortBy !== newQuerySettings.sortBy - || prevSettings.order !== newQuerySettings.order - ); - - if (!hasChanged) { - return prevSettings; // No change, prevent unnecessary update - } - - return newQuerySettings; - }); - }, []); - - return { - querySettings, - handleTableFetch, - }; -}; diff --git a/src/authz-module/libraries-manager/components/TeamTable/index.test.tsx b/src/authz-module/libraries-manager/components/TeamTable/index.test.tsx index 719edb87..f1229274 100644 --- a/src/authz-module/libraries-manager/components/TeamTable/index.test.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable/index.test.tsx @@ -4,7 +4,7 @@ import { renderWrapper } from '@src/setupTest'; import { useTeamMembers } from '@src/authz-module/data/hooks'; import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context'; import { ToastManagerProvider } from '@src/authz-module/libraries-manager/ToastManagerContext'; -import { CONTENT_LIBRARY_PERMISSIONS } from '@src/authz-module/libraries-manager/constants'; +import { CONTENT_LIBRARY_PERMISSIONS } from '@src/authz-module/constants'; import TeamTable from './index'; const mockNavigate = jest.fn(); diff --git a/src/authz-module/libraries-manager/components/TeamTable/index.tsx b/src/authz-module/libraries-manager/components/TeamTable/index.tsx index 5b1bc893..77efa3ad 100644 --- a/src/authz-module/libraries-manager/components/TeamTable/index.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable/index.tsx @@ -11,9 +11,8 @@ import { import { useTeamMembers } from '@src/authz-module/data/hooks'; import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context'; import { useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext'; -import { SKELETON_ROWS } from '@src/authz-module/libraries-manager/constants'; -import { TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants'; -import { useQuerySettings } from './hooks/useQuerySettings'; +import { SKELETON_ROWS, TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants'; +import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings'; import TableControlBar from './components/TableControlBar'; import messages from './messages'; import { diff --git a/src/authz-module/libraries-manager/constants.ts b/src/authz-module/libraries-manager/constants.ts deleted file mode 100644 index 787edfb9..00000000 --- a/src/authz-module/libraries-manager/constants.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { PermissionMetadata, ResourceMetadata, RoleMetadata } from 'types'; -import { - Group, CollectionsBookmark, Notes, AutoAwesomeMosaic, -} from '@openedx/paragon/icons'; - -export const CONTENT_LIBRARY_PERMISSIONS = { - DELETE_LIBRARY: 'content_libraries.delete_library', - MANAGE_LIBRARY_TAGS: 'content_libraries.manage_library_tags', - VIEW_LIBRARY: 'content_libraries.view_library', - - CREATE_LIBRARY_CONTENT: 'content_libraries.create_library_content', - EDIT_LIBRARY_CONTENT: 'content_libraries.edit_library_content', - DELETE_LIBRARY_CONTENT: 'content_libraries.delete_library_content', - PUBLISH_LIBRARY_CONTENT: 'content_libraries.publish_library_content', - REUSE_LIBRARY_CONTENT: 'content_libraries.reuse_library_content', - IMPORT_LIBRARY_CONTENT: 'content_libraries.import_library_content', - - MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team', - VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team', - - CREATE_LIBRARY_COLLECTION: 'content_libraries.create_library_collection', - EDIT_LIBRARY_COLLECTION: 'content_libraries.edit_library_collection', - DELETE_LIBRARY_COLLECTION: 'content_libraries.delete_library_collection', -}; - -// Note: this information will eventually come from the backend API -// but for the MVP we decided to manage it in the frontend -export const libraryRolesMetadata: RoleMetadata[] = [ - { role: 'library_admin', name: 'Library Admin', description: 'The Library Admin has full control over the library, including managing users, modifying content, and handling publishing workflows. They ensure content is properly maintained and accessible as needed.' }, - { role: 'library_author', name: 'Library Author', description: 'The Library Author is responsible for creating, editing, and publishing content within a library. They can manage tags and collections but cannot delete libraries or manage users.' }, - { role: 'library_contributor', name: 'Library Contributor', description: 'The Library Contributor can create and edit content within a library but cannot publish it. They support the authoring process while leaving final publishing to Authors or Admins.' }, - { role: 'library_user', name: 'Library User', description: 'The Library User can view and reuse content but cannot edit or delete any resource.' }, -]; - -export const libraryResourceTypes: ResourceMetadata[] = [ - { - key: 'library', label: 'Library', description: 'Permissions related to the library as a whole.', icon: CollectionsBookmark, - }, - { - key: 'library_content', label: 'Content', description: 'Permissions to create, edit, delete, and publish individual content items within the library.', icon: Notes, - }, - { - key: 'library_team', label: 'Team', description: 'Permissions to manage user access and roles within the library.', icon: Group, - }, - { - key: 'library_collection', label: 'Collection', description: 'Permissions to create, edit, and delete content collections within the library.', icon: AutoAwesomeMosaic, - }, -]; - -export const libraryPermissions: PermissionMetadata[] = [ - { key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, resource: 'library', description: 'View content, search, filter, and sort within the library.' }, - { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, resource: 'library', description: 'Add or remove tags from content.' }, - { key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY, resource: 'library', description: 'Allows the user to delete the library and all its contents.' }, - - { key: CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_CONTENT, resource: 'library_content', description: 'Create content within the library.' }, - { key: CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT, resource: 'library_content', description: 'Edit content in draft mode' }, - { key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_CONTENT, resource: 'library_content', description: 'Delete content within the library.' }, - { key: CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT, resource: 'library_content', description: 'Publish content, making it available for reuse' }, - { key: CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT, resource: 'library_content', description: 'Reuse published content within a course.' }, - { key: CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT, resource: 'library_content', description: 'Import content from courses.' }, - - { key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, resource: 'library_team', description: 'View the list of users who have access to the library.' }, - { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, resource: 'library_team', description: 'Add, remove, and assign roles to users within the library.' }, - - { key: CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Create new collections within a library.' }, - { key: CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Add or remove content from existing collections.' }, - { key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Delete entire collections from the library.' }, -]; - -export const rolesLibraryObject = [ - { - role: 'library_admin', - permissions: [ - CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, - CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, - CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY, - CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT, - CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT, - CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, - CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, - CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION, - CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION, - CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION, - CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_CONTENT, - CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_CONTENT, - CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT, - CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT, - ], - userCount: 1, - name: 'Library Admin', - description: 'The Library Admin has full control over the library, including managing users, modifying content, and handling publishing workflows. They ensure content is properly maintained and accessible as needed.', - }, - { - role: 'library_author', - permissions: [ - CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, - CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, - CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT, - CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT, - CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT, - CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, - CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION, - CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION, - CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION, - CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_CONTENT, - CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_CONTENT, - CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT, - ], - userCount: 1, - name: 'Library Author', - description: 'The Library Author is responsible for creating, editing, and publishing content within a library. They can manage tags and collections but cannot delete libraries or manage users.', - }, - { - role: 'library_contributor', - permissions: [ - CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, - CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, - CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT, - CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT, - CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, - CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION, - CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION, - CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION, - CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_CONTENT, - CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_CONTENT, - CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT, - - ], - userCount: 1, - name: 'Library Contributor', - description: 'The Library Contributor can create and edit content within a library but cannot publish it. They support the authoring process while leaving final publishing to Authors or Admins.', - }, - { - role: 'library_user', - permissions: [ - CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, - CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT, - CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, - ], - userCount: 1, - name: 'Library User', - description: 'The Library User can view and reuse content but cannot edit or delete anything.', - }, -]; - -export const DEFAULT_TOAST_DELAY = 5000; -export const RETRY_TOAST_DELAY = 120_000; // 2 minutes -export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({ - username: 'skeleton', - name: '', - email: '', - roles: [], -})); diff --git a/src/authz-module/libraries-manager/context.test.tsx b/src/authz-module/libraries-manager/context.test.tsx index 8025e6cf..d004b906 100644 --- a/src/authz-module/libraries-manager/context.test.tsx +++ b/src/authz-module/libraries-manager/context.test.tsx @@ -5,7 +5,7 @@ import { useValidateUserPermissions } from '@src/data/hooks'; import { renderWrapper } from '@src/setupTest'; import { usePermissionsByRole } from '@src/authz-module/data/hooks'; import { CustomErrors } from '@src/constants'; -import { CONTENT_LIBRARY_PERMISSIONS } from './constants'; +import { CONTENT_LIBRARY_PERMISSIONS } from '../constants'; import { LibraryAuthZProvider, useLibraryAuthZ } from './context'; jest.mock('react-router-dom', () => ({ diff --git a/src/authz-module/libraries-manager/context.tsx b/src/authz-module/libraries-manager/context.tsx index 507e50b1..a6ad0035 100644 --- a/src/authz-module/libraries-manager/context.tsx +++ b/src/authz-module/libraries-manager/context.tsx @@ -9,7 +9,7 @@ import { PermissionMetadata, ResourceMetadata, Role } from 'types'; import { CustomErrors } from '@src/constants'; import { CONTENT_LIBRARY_PERMISSIONS, libraryPermissions, libraryResourceTypes, libraryRolesMetadata, -} from './constants'; +} from '../constants'; const LIBRARY_TEAM_PERMISSIONS = [ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, diff --git a/src/authz-module/messages.ts b/src/authz-module/messages.ts index 88229f8c..48d0a3c2 100644 --- a/src/authz-module/messages.ts +++ b/src/authz-module/messages.ts @@ -2,6 +2,16 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages( { + 'authz.management.home.nav.link': { + id: 'authz.management.home.nav.link', + defaultMessage: 'Roles and Permissions Management', + description: 'Text for the roles and permissions management home page title navigation link', + }, + 'authz.management.specific.user.nav.link': { + id: 'authz.management.specific.user.nav.link', + defaultMessage: 'Specific User', + description: 'Text for the specific user page navigation link', + }, 'authz.management.assign.role.title': { id: 'authz.management.assign.role.title', defaultMessage: 'Assign Role', diff --git a/src/data/api.ts b/src/data/api.ts index b3d39531..447ad72d 100644 --- a/src/data/api.ts +++ b/src/data/api.ts @@ -1,6 +1,8 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { PermissionValidationRequest, PermissionValidationResponse } from '@src/types'; +import { camelCaseObject } from '@edx/frontend-platform'; import { getApiUrl } from './utils'; +import { UserAccount } from './types'; export const validateUserPermissions = async ( validations: PermissionValidationRequest[], @@ -8,3 +10,9 @@ export const validateUserPermissions = async ( const { data } = await getAuthenticatedHttpClient().post(getApiUrl('/api/authz/v1/permissions/validate/me'), validations); return data; }; + +export const getUserAccount = async (username?: string): Promise => { + const url = new URL(getApiUrl(`/api/user/v1/accounts/${username}`)); + const { data } = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(data); +}; diff --git a/src/data/hooks.test.tsx b/src/data/hooks.test.tsx index 7dc20ec9..a2e284cc 100644 --- a/src/data/hooks.test.tsx +++ b/src/data/hooks.test.tsx @@ -2,7 +2,7 @@ import { act, ReactNode } from 'react'; import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { useValidateUserPermissions } from './hooks'; +import { useValidateUserPermissions, useUserAccount } from './hooks'; jest.mock('@edx/frontend-platform/auth', () => ({ getAuthenticatedHttpClient: jest.fn(), @@ -42,6 +42,46 @@ const mockInvalidPermissions = [ { action: 'act:read', object: 'lib:test-lib', allowed: false }, ]; +const mockUserAccountData = { + username: 'john.doe', + bio: 'Software Developer', + accountPrivacy: 'public', + country: 'US', + dateJoined: '2023-01-15T10:30:00Z', + levelOfEducation: 'bachelor', + timeZone: 'America/New_York', + profileImage: { + hasImage: true, + imageUrlFull: 'https://example.com/profile_full.jpg', + imageUrlLarge: 'https://example.com/profile_large.jpg', + imageUrlMedium: 'https://example.com/profile_medium.jpg', + imageUrlSmall: 'https://example.com/profile_small.jpg', + }, + courseCertificates: null, + languageProficiencies: [], + socialLinks: [], +}; + +const mockEmptyUserData = { + username: 'jane.smith', + bio: null, + accountPrivacy: 'private', + country: null, + dateJoined: '2023-06-20T14:15:00Z', + levelOfEducation: null, + timeZone: null, + profileImage: { + hasImage: false, + imageUrlFull: '', + imageUrlLarge: '', + imageUrlMedium: '', + imageUrlSmall: '', + }, + courseCertificates: null, + languageProficiencies: [], + socialLinks: [], +}; + describe('useValidateUserPermissions', () => { beforeEach(() => { jest.clearAllMocks(); @@ -95,3 +135,102 @@ describe('useValidateUserPermissions', () => { } }); }); + +describe('useUserAccount', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fetches user account data successfully', async () => { + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: jest.fn().mockResolvedValueOnce({ data: mockUserAccountData }), + }); + + const { result } = renderHook(() => useUserAccount('john.doe'), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(getAuthenticatedHttpClient).toHaveBeenCalled(); + expect(result.current.data).toEqual(mockUserAccountData); + expect(result.current.data?.username).toBe('john.doe'); + }); + + it('handles user account data with minimal information', async () => { + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: jest.fn().mockResolvedValueOnce({ data: mockEmptyUserData }), + }); + + const { result } = renderHook(() => useUserAccount('jane.smith'), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.username).toBe('jane.smith'); + expect(result.current.data?.bio).toBeNull(); + expect(result.current.data?.country).toBeNull(); + }); + + it('handles API error gracefully', async () => { + const mockError = new Error('User not found'); + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: jest.fn().mockRejectedValueOnce(mockError), + }); + + const { result } = renderHook(() => useUserAccount('nonexistent.user'), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error).toEqual(mockError); + expect(result.current.data).toBeUndefined(); + }); + + it('does not refetch on window focus', async () => { + const mockGet = jest.fn().mockResolvedValueOnce({ data: mockUserAccountData }); + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: mockGet, + }); + + const { result } = renderHook(() => useUserAccount('john.doe'), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + act(() => { + window.dispatchEvent(new Event('focus')); + }); + + expect(mockGet).toHaveBeenCalledTimes(1); + }); + + it('updates data when username changes', async () => { + const mockGet = jest.fn() + .mockResolvedValueOnce({ data: mockUserAccountData }) + .mockResolvedValueOnce({ data: mockEmptyUserData }); + + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: mockGet, + }); + + const { result, rerender } = renderHook( + ({ username }) => useUserAccount(username), + { + wrapper: createWrapper(), + initialProps: { username: 'john.doe' }, + }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.username).toBe('john.doe'); + + rerender({ username: 'jane.smith' }); + + await waitFor(() => expect(result.current.data?.username).toBe('jane.smith')); + expect(mockGet).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/data/hooks.ts b/src/data/hooks.ts index 80d4154e..b6587c48 100644 --- a/src/data/hooks.ts +++ b/src/data/hooks.ts @@ -1,11 +1,12 @@ -import { useSuspenseQuery } from '@tanstack/react-query'; +import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; import { PermissionValidationRequest, PermissionValidationResponse } from '@src/types'; import { appId } from '@src/constants'; -import { validateUserPermissions } from './api'; +import { getUserAccount, validateUserPermissions } from './api'; const adminConsoleQueryKeys = { all: [appId] as const, permissions: (permissions: PermissionValidationRequest[]) => [...adminConsoleQueryKeys.all, 'validatePermissions', permissions] as const, + userAccount: (username?: string) => [...adminConsoleQueryKeys.all, 'userAccount', username] as const, }; /** @@ -32,3 +33,11 @@ export const useValidateUserPermissions = ( queryFn: () => validateUserPermissions(permissions), retry: false, }); + +export const useUserAccount = (username?: string) => useQuery({ + queryKey: adminConsoleQueryKeys.userAccount(username), + queryFn: async () => getUserAccount(username), + retry: false, + enabled: !!username, + refetchOnWindowFocus: false, +}); diff --git a/src/data/types.ts b/src/data/types.ts new file mode 100644 index 00000000..57429147 --- /dev/null +++ b/src/data/types.ts @@ -0,0 +1,39 @@ +export type ProfileImage = { + hasImage: boolean; + imageUrlFull: string; + imageUrlLarge: string; + imageUrlMedium: string; + imageUrlSmall: string; +}; + +export type UserAccount = { + accountPrivacy: string; + profileImage: ProfileImage; + username: string; + bio: string | null; + courseCertificates: unknown | null; // Type unclear from data + country: string | null; + dateJoined: string; // ISO date string + languageProficiencies: unknown[]; // Array type unclear from empty data + levelOfEducation: string | null; + socialLinks: unknown[]; // Array type unclear from empty data + timeZone: string | null; + name: string; + email: string; + id: number; + verifiedName: string | null; + extendedProfile: unknown[]; // Array type unclear from empty data + gender: string | null; + state: string | null; + goals: string; + isActive: boolean; + lastLogin: string; // ISO date string + mailingAddress: string; + requiresParentalConsent: boolean; + secondaryEmail: string | null; + secondaryEmailEnabled: boolean | null; + yearOfBirth: number | null; + phoneNumber: string | null; + activationKey: string; + pendingNameChange: string | null; +}; diff --git a/src/index.tsx b/src/index.tsx index cbd7cf93..7e5ee477 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,7 +6,7 @@ import { AppProvider, ErrorPage } from '@edx/frontend-platform/react'; import { APP_INIT_ERROR, APP_READY, subscribe, initialize, mergeConfig, } from '@edx/frontend-platform'; -import AuthZModule from 'authz-module'; +import AuthZModule from '@src/authz-module'; import messages from './i18n';