diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e71d6b3a..d80490ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,8 @@ jobs: run: npm ci - name: Lint run: npm run lint + - name: Types + run: npm run types - name: Test run: npm run test - name: Build diff --git a/src/authz-module/audit-user/index.test.tsx b/src/authz-module/audit-user/index.test.tsx index 1a458ec7..f535ece4 100644 --- a/src/authz-module/audit-user/index.test.tsx +++ b/src/authz-module/audit-user/index.test.tsx @@ -4,7 +4,7 @@ import { import { AppContext } from '@edx/frontend-platform/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { mockHttpClient, mockAppContext } from '@src/setupTest'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ToastManagerProvider } from '@src/components/ToastManager/ToastManagerContext'; @@ -76,24 +76,6 @@ const renderWithRouter = (route = '/audit/johndoe') => { }, }); - const mockAppContext = { - authenticatedUser: { - username: 'testuser', - email: 'testuser@example.com', - userId: 1, - }, - config: { - 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, - }, - }; - return render( @@ -116,7 +98,7 @@ describe('AuditUserPage', () => { beforeEach(() => { jest.clearAllMocks(); // Set up default mock behavior for useRevokeUserRoles - mockRevokeUserRoles.mockImplementation((variables, { onSuccess }) => { + mockRevokeUserRoles.mockImplementation((_variables, { onSuccess }) => { // Simulate successful deletion by default onSuccess({ errors: [], completed: ['role1'] }); }); @@ -154,7 +136,7 @@ describe('AuditUserPage', () => { }); it('navigates to home if user is not found', async () => { - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest .fn() .mockResolvedValueOnce({ data: null }) @@ -169,7 +151,7 @@ describe('AuditUserPage', () => { }); it('allows user to interact with Assign Role button', async () => { - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest .fn() .mockResolvedValueOnce({ data: mockUser }) @@ -215,7 +197,7 @@ describe('AuditUserPage', () => { }); it('renders correct table headers', async () => { - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest .fn() .mockResolvedValueOnce({ data: mockUser }) @@ -294,7 +276,7 @@ describe('AuditUserPage', () => { }); it('renders the breadcrumb navigation with home link', async () => { - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest .fn() .mockResolvedValueOnce({ data: mockUser }) @@ -310,7 +292,7 @@ describe('AuditUserPage', () => { }); it('opens and closes the ConfirmDeletionModal when delete is clicked and cancel is pressed', async () => { - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest .fn() .mockResolvedValueOnce({ data: mockUser }) @@ -342,7 +324,7 @@ describe('AuditUserPage', () => { }); it('calls onSave when confirming deletion in ConfirmDeletionModal', async () => { - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest .fn() .mockResolvedValueOnce({ data: mockUser }) @@ -389,7 +371,7 @@ describe('AuditUserPage', () => { }); }); - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest .fn() .mockResolvedValueOnce({ data: mockUser }) @@ -424,12 +406,12 @@ describe('AuditUserPage', () => { it('shows error toast with retry when role revocation fails', async () => { // Override mock for this specific test case - mockRevokeUserRoles.mockImplementation((variables, { onError }) => { + mockRevokeUserRoles.mockImplementation((_variables, { onError }) => { // Call onError immediately to simulate failure onError(new Error('Network error')); }); - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest .fn() .mockResolvedValueOnce({ data: mockUser }) @@ -464,7 +446,7 @@ describe('AuditUserPage', () => { }); it('shows the extra warning when rolesCount is 1', async () => { - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest .fn() .mockResolvedValueOnce({ data: mockUser }) diff --git a/src/authz-module/audit-user/index.tsx b/src/authz-module/audit-user/index.tsx index 04f14d7c..0619402a 100644 --- a/src/authz-module/audit-user/index.tsx +++ b/src/authz-module/audit-user/index.tsx @@ -4,7 +4,6 @@ import { } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { AppContext } from '@edx/frontend-platform/react'; -import type { AppContextType } from '@edx/frontend-platform/react'; import { Container, DataTable, } from '@openedx/paragon'; @@ -23,7 +22,7 @@ import { } from '@src/authz-module/components/TableCells'; import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings'; import { useRevokeUserRoles, useUserAssignedRoles } from '@src/authz-module/data/hooks'; -import { RoleToDelete } from 'types'; +import { RoleToDelete } from '@src/types'; import { useToastManager } from '@src/components/ToastManager/ToastManagerContext'; import UserPermissions from '@src/authz-module/components/UserPermissions'; import OrgFilter from '@src/authz-module/components/TableControlBar/OrgFilter'; @@ -37,7 +36,7 @@ const AuditUserPage = () => { const { formatMessage } = useIntl(); const [columnsWithFiltersApplied, setColumnsWithFiltersApplied] = useState([]); const { username } = useParams(); - const { authenticatedUser } = useContext(AppContext as React.Context); + const { authenticatedUser } = useContext(AppContext); const navigate = useNavigate(); const { isLoading: isLoadingUser, data: user, isError: isErrorUser, error: errorUser, @@ -112,10 +111,10 @@ const AuditUserPage = () => { Header: formatMessage(messages['authz.user.table.action.column.header']), Cell: createActionsCell({ onClickDeleteButton: handleShowConfirmDeletionModal, - isUserAuthenticatedPage: username === authenticatedUser.username, + isUserAuthenticatedPage: username === authenticatedUser?.username, }), }, - ], [authenticatedUser.username, formatMessage, handleShowConfirmDeletionModal, username]); + ], [authenticatedUser?.username, formatMessage, handleShowConfirmDeletionModal, username]); const columns = useMemo(() => [ { diff --git a/src/authz-module/authz-home/index.test.tsx b/src/authz-module/authz-home/index.test.tsx index 8f0690ec..8ddb952b 100644 --- a/src/authz-module/authz-home/index.test.tsx +++ b/src/authz-module/authz-home/index.test.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { screen } from '@testing-library/react'; import { useAllRoleAssignments, useOrgs, useScopes } from '@src/authz-module/data/hooks'; import { renderWithAllProviders } from '@src/setupTest'; diff --git a/src/authz-module/components/AddRoleButton.tsx b/src/authz-module/components/AddRoleButton.tsx index 6433741b..fb952aa7 100644 --- a/src/authz-module/components/AddRoleButton.tsx +++ b/src/authz-module/components/AddRoleButton.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Button } from '@openedx/paragon'; import { Plus } from '@openedx/paragon/icons'; diff --git a/src/authz-module/components/AuthZTitle.test.tsx b/src/authz-module/components/AuthZTitle.test.tsx index b67656fe..8218366f 100644 --- a/src/authz-module/components/AuthZTitle.test.tsx +++ b/src/authz-module/components/AuthZTitle.test.tsx @@ -1,7 +1,7 @@ import { ReactNode } from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import AuthZTitle, { AuthZTitleProps } from './AuthZTitle'; +import AuthZTitle from './AuthZTitle'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -9,7 +9,7 @@ jest.mock('react-router-dom', () => ({ })); describe('AuthZTitle', () => { - const defaultProps: AuthZTitleProps = { + const defaultProps = { activeLabel: 'Current Page', pageTitle: 'Page Title', pageSubtitle: 'Page Subtitle', diff --git a/src/authz-module/components/PermissionTable.test.tsx b/src/authz-module/components/PermissionTable.test.tsx index 5946e90b..679d6a27 100644 --- a/src/authz-module/components/PermissionTable.test.tsx +++ b/src/authz-module/components/PermissionTable.test.tsx @@ -11,6 +11,7 @@ const mockRoles: Role[] = [ permissions: [], role: '', contextType: '', + scope: '', }, { name: 'Editor', @@ -19,6 +20,7 @@ const mockRoles: Role[] = [ permissions: [], role: '', contextType: '', + scope: '', }, { name: 'Viewer', @@ -27,6 +29,7 @@ const mockRoles: Role[] = [ permissions: [], role: '', contextType: '', + scope: '', }, ]; diff --git a/src/authz-module/components/RenderPermissionColumn.tsx b/src/authz-module/components/RenderPermissionColumn.tsx index 30fe9ade..3dfd269c 100644 --- a/src/authz-module/components/RenderPermissionColumn.tsx +++ b/src/authz-module/components/RenderPermissionColumn.tsx @@ -1,51 +1,43 @@ import { Icon } from '@openedx/paragon'; -import { RolePermission } from 'types'; +import { PermissionItem } from '@src/types'; import ResourceTooltip from './ResourceTooltip'; -interface ExtendedRolePermission extends RolePermission { - icon: React.ComponentType>; -} - -interface PermissionItem { - key: string; - icon: React.ComponentType>; - label: string; - description: string; - perms: ExtendedRolePermission[]; -} - interface RenderPermissionColumnProps { items: PermissionItem[]; } -const RenderPermissionColumn = ({ items }: RenderPermissionColumnProps) => items.map(({ - key, icon, label, description, perms, -}) => ( -
-
- -
{label}
- -
-
    - {perms.map((perm, index) => ( -
  • - - - {perm.label} - -
  • - ))} -
-
-)); +const RenderPermissionColumn = ({ items }: RenderPermissionColumnProps) => ( + <> + {items.map(({ + key, icon, label, description, perms, + }) => ( +
+
+ +
{label}
+ +
+
    + {perms.map((perm, index) => ( +
  • + + + {perm.label} + +
  • + ))} +
+
+ ))} + +); export default RenderPermissionColumn; diff --git a/src/authz-module/components/RenderPermissionInLine.tsx b/src/authz-module/components/RenderPermissionInLine.tsx index 946a6839..b4bfb84e 100644 --- a/src/authz-module/components/RenderPermissionInLine.tsx +++ b/src/authz-module/components/RenderPermissionInLine.tsx @@ -1,19 +1,7 @@ import { Icon } from '@openedx/paragon'; -import { RolePermission } from 'types'; +import { PermissionItem } from '@src/types'; import ResourceTooltip from './ResourceTooltip'; -interface ExtendedRolePermission extends RolePermission { - icon: React.ComponentType>; -} - -interface PermissionItem { - key: string; - icon: React.ComponentType>; - label: string; - description: string; - perms: ExtendedRolePermission[]; -} - interface RenderPermissionInLineProps { items: PermissionItem[]; } diff --git a/src/authz-module/components/ResourceTooltip.tsx b/src/authz-module/components/ResourceTooltip.tsx index 4faa2c22..e726b6ed 100644 --- a/src/authz-module/components/ResourceTooltip.tsx +++ b/src/authz-module/components/ResourceTooltip.tsx @@ -1,9 +1,14 @@ import { Icon, OverlayTrigger, Popover } from '@openedx/paragon'; import { Info } from '@openedx/paragon/icons'; -import { PermissionsResourceGrouped, RoleResourceGroup } from '@src/types'; +import { PermissionMetadata } from '@src/types'; type ResourceTooltipProps = { - resourceGroup: PermissionsResourceGrouped | RoleResourceGroup; + resourceGroup: { + key: string; + label: string; + description: string; + permissions: PermissionMetadata[]; + }; }; const ResourceTooltip = ({ resourceGroup }:ResourceTooltipProps) => ( @@ -17,7 +22,7 @@ const ResourceTooltip = ({ resourceGroup }:ResourceTooltipProps) => (

{resourceGroup.description}

    {resourceGroup.permissions.map(permission => ( -
  • {permission.label.trim()}: {permission.description}
  • +
  • {permission.label?.trim() ?? ''}: {permission.description}
  • ))}
diff --git a/src/authz-module/components/RoleCard/PermissionsRow.tsx b/src/authz-module/components/RoleCard/PermissionsRow.tsx deleted file mode 100644 index 84ec4991..00000000 --- a/src/authz-module/components/RoleCard/PermissionsRow.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { ComponentType } from 'react'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { - Chip, Col, Row, -} from '@openedx/paragon'; -import { RoleResourceGroup } from '@src/types'; -import { actionsDictionary, ActionKey } from './constants'; -import ResourceTooltip from '../ResourceTooltip'; -import messages from './messages'; - -type PermissionRowProps = { - resource: RoleResourceGroup; -}; - -const PermissionRow = ({ resource }: PermissionRowProps) => { - const { formatMessage } = useIntl(); - return ( - - - {resource.label} - - - -
- {resource.permissions.map((action, index) => ( - <> - - {action.label} - - {(index === resource.permissions.length - 1) ? null - : (
)} - - ))} -
- -
- ); -}; - -export default PermissionRow; diff --git a/src/authz-module/components/RoleCard/index.test.tsx b/src/authz-module/components/RoleCard/index.test.tsx deleted file mode 100644 index 5508b9cd..00000000 --- a/src/authz-module/components/RoleCard/index.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { screen } from '@testing-library/react'; -import { renderWrapper } from '@src/setupTest'; -import userEvent from '@testing-library/user-event'; -import RoleCard from '.'; - -jest.mock('@openedx/paragon/icons', () => ({ - Delete: () => , - Person: () => , -})); - -jest.mock('./constants', () => ({ - actionsDictionary: { - view: () => , - manage: () => , - }, -})); - -describe('RoleCard', () => { - const defaultProps = { - title: 'Admin', - objectName: 'Test Library', - description: 'Can manage everything', - handleDelete: jest.fn(), - userCounter: 2, - permissionsByResource: [ - { - key: 'library', - label: 'Library Resource', - permissions: [ - { - key: 'view', label: 'View', actionKey: 'view', disabled: false, - }, - { - key: 'manage', label: 'Manage', actionKey: 'manage', disabled: true, - }, - ], - }, - ], - }; - - it('renders all role card sections correctly', async () => { - const user = userEvent.setup(); - renderWrapper(); - - // Title - expect(screen.getByText('Admin')).toBeInTheDocument(); - - // User counter with icon - expect(screen.getByText('2')).toBeInTheDocument(); - expect(screen.getByRole('img', { name: 'person icon' })).toBeInTheDocument(); - - // Subtitle (object name) - expect(screen.getByText('Test Library')).toBeInTheDocument(); - - // Description - expect(screen.getByText('Can manage everything')).toBeInTheDocument(); - - // Delete button - expect(screen.getByRole('button', { name: /Delete role action/i })).toBeInTheDocument(); - - // Collapsible title - expect(screen.getByText('Permissions')).toBeInTheDocument(); - - await user.click(screen.getByText('Permissions')); - - // Resource label - expect(screen.getByText('Library Resource')).toBeInTheDocument(); - - // Action chips - expect(screen.getByText('View')).toBeInTheDocument(); - expect(screen.getByText('Manage')).toBeInTheDocument(); - - // Action icons - expect(screen.getByRole('img', { name: 'view action icon' })).toBeInTheDocument(); - expect(screen.getByRole('img', { name: 'manage action icon' })).toBeInTheDocument(); - }); - - it('does not show delete button when handleDelete is not passed', () => { - renderWrapper(); - expect(screen.queryByRole('button', { name: /delete role action/i })).not.toBeInTheDocument(); - }); - - it('handles no userCounter gracefully', () => { - renderWrapper(); - expect(screen.queryByRole('img', { name: 'person icon' })).not.toBeInTheDocument(); - expect(screen.queryByText('2')).not.toBeInTheDocument(); - }); - - it('handles empty permissions gracefully', () => { - renderWrapper(); - expect(screen.queryByText('Library Resource')).not.toBeInTheDocument(); - }); -}); diff --git a/src/authz-module/components/RoleCard/index.tsx b/src/authz-module/components/RoleCard/index.tsx deleted file mode 100644 index 59b3ac20..00000000 --- a/src/authz-module/components/RoleCard/index.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useIntl } from '@edx/frontend-platform/i18n'; -import { - Card, Collapsible, Container, Icon, IconButton, -} from '@openedx/paragon'; -import { Delete, Person } from '@openedx/paragon/icons'; -import PermissionRow from './PermissionsRow'; -import messages from './messages'; - -interface CardTitleProps { - title: string; - userCounter?: number | null; -} - -interface RoleCardProps extends CardTitleProps { - objectName?: string | null; - description: string; - handleDelete?: () => void; - permissionsByResource: any[]; -} - -const CardTitle = ({ title, userCounter = null }: CardTitleProps) => { - const { formatMessage } = useIntl(); - - return ( -
- {title} - {userCounter !== null && ( - - - {userCounter} - - )} -
- ); -}; - -const RoleCard = ({ - title, objectName, description, handleDelete, permissionsByResource, userCounter, -}: RoleCardProps) => { - const intl = useIntl(); - - return ( - - } - subtitle={(objectName && {objectName}) || ''} - actions={ - handleDelete && ( - - ) - } - /> - - {description} - - - - {permissionsByResource.map((resourceGroup) => ( - - ))} - - - - ); -}; - -export default RoleCard; diff --git a/src/authz-module/components/TableCells.test.tsx b/src/authz-module/components/TableCells.test.tsx index 2b3ae2de..af121e36 100644 --- a/src/authz-module/components/TableCells.test.tsx +++ b/src/authz-module/components/TableCells.test.tsx @@ -49,6 +49,7 @@ describe('TableCells Components', () => { }; const mockCellProps = { row: { + id: '0', original: mockUserRole, }, }; @@ -70,6 +71,7 @@ describe('TableCells Components', () => { it('displays username when full name is not available', () => { const propsWithoutFullName = { row: { + id: '0', original: { ...mockUserRole, fullName: undefined, @@ -84,6 +86,7 @@ describe('TableCells Components', () => { it('displays username when full name is empty string', () => { const propsWithEmptyFullName = { row: { + id: '0', original: { ...mockUserRole, fullName: '', @@ -98,6 +101,7 @@ describe('TableCells Components', () => { it('shows current user indicator when username matches authenticated user', () => { const currentUserProps = { row: { + id: '0', original: { ...mockUserRole, username: 'testuser', @@ -120,6 +124,7 @@ describe('TableCells Components', () => { it('shows current user indicator with username fallback when no full name is provided', () => { const currentUserPropsNoFullName = { row: { + id: '0', original: { ...mockUserRole, username: 'testuser', @@ -161,6 +166,7 @@ describe('TableCells Components', () => { const mockCellProps = { row: { + id: '0', original: mockUserRole, }, }; @@ -201,6 +207,7 @@ describe('TableCells Components', () => { const user = userEvent.setup(); const differentUserProps = { row: { + id: '0', original: { ...mockUserRole, username: 'janedoe', @@ -220,6 +227,7 @@ describe('TableCells Components', () => { const user = userEvent.setup(); const emptyUsernameProps = { row: { + id: '0', original: { ...mockUserRole, username: '', @@ -239,6 +247,7 @@ describe('TableCells Components', () => { const user = userEvent.setup(); const specialUsernameProps = { row: { + id: '0', original: { ...mockUserRole, username: 'user+with@special.chars', @@ -265,6 +274,7 @@ describe('TableCells Components', () => { value: 'library_admin', cell: mockCell, row: { + id: '0', original: { role: 'library_admin', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, }, @@ -283,6 +293,7 @@ describe('TableCells Components', () => { value: 'unknown_role', cell: mockCell, row: { + id: '0', original: { role: 'unknown_role', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, }, @@ -302,6 +313,7 @@ describe('TableCells Components', () => { value: 'course_staff', cell: mockCell, row: { + id: '0', original: { role: 'course_staff', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, }, @@ -321,6 +333,7 @@ describe('TableCells Components', () => { const props = { value: 'Test Org', row: { + id: '0', original: { role: 'django.superuser', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, }, @@ -338,6 +351,7 @@ describe('TableCells Components', () => { const props = { value: 'Test Org', row: { + id: '0', original: { role: 'django.globalstaff', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, }, @@ -355,6 +369,7 @@ describe('TableCells Components', () => { const props = { value: 'Test Organization', row: { + id: '0', original: { role: 'library_admin', org: 'Test Organization', scope: 'Test Scope', permissionCount: 1, }, @@ -374,6 +389,7 @@ describe('TableCells Components', () => { const props = { value: 'library', row: { + id: '0', original: { role: 'django.superuser', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, }, @@ -391,6 +407,7 @@ describe('TableCells Components', () => { const props = { value: 'course', row: { + id: '0', original: { role: 'django.globalstaff', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, }, @@ -408,6 +425,7 @@ describe('TableCells Components', () => { const props = { value: 'Course Scope', row: { + id: '0', original: { role: 'course_admin', org: 'Test Org', scope: 'Course Scope', permissionCount: 1, }, @@ -426,6 +444,7 @@ describe('TableCells Components', () => { it('displays "Total Access" for Django superuser role', () => { const props = { row: { + id: '0', original: { role: 'django.superuser', org: 'Test Org', @@ -444,6 +463,7 @@ describe('TableCells Components', () => { it('displays "Partial Access" for Django global staff role', () => { const props = { row: { + id: '0', original: { role: 'django.globalstaff', permissionCount: 5, @@ -462,6 +482,7 @@ describe('TableCells Components', () => { it('displays permission count for non-Django roles', () => { const props = { row: { + id: '0', original: { role: 'library_admin', permissionCount: 3, @@ -602,8 +623,8 @@ describe('TableCells Components', () => { const mockCellProps = { row: { - original: mockUserRole, id: 'test-row-1', + original: mockUserRole, isExpanded: false, toggleRowExpanded: jest.fn(), values: mockUserRole, diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx index 246326ac..1daf4ad6 100644 --- a/src/authz-module/components/TableCells.tsx +++ b/src/authz-module/components/TableCells.tsx @@ -5,9 +5,7 @@ import { Delete, ExpandMore, Info, } from '@openedx/paragon/icons'; -import { - TableCellValue, AppContextType, UserRoleWithPermissions, RoleToDelete, -} from '@src/types'; +import { UserRoleWithPermissions, RoleToDelete } from '@src/types'; import { useNavigate } from 'react-router-dom'; import { useContext, useMemo } from 'react'; import { @@ -15,6 +13,7 @@ import { } from '@src/authz-module/constants'; import { Icon, IconButton, OverlayTrigger, Tooltip, DataTableContext, + type DataTableCellProps, } from '@openedx/paragon'; import { RESOURCE_ICONS } from './constants'; import messages from './messages'; @@ -27,7 +26,7 @@ interface DataTableInstance { toggleRowExpanded?: (rowId: string, expanded: boolean) => void; } -type CellProps = TableCellValue; +type CellProps = DataTableCellProps; type CellPropsWithValue = CellProps & { value: string; }; @@ -46,7 +45,7 @@ type ActionsCellProps = CellProps & ActionsCellExtraProps; const NameCell = ({ row }: CellProps) => { const intl = useIntl(); - const { authenticatedUser } = useContext(AppContext) as AppContextType; + const { authenticatedUser } = useContext(AppContext); const username = authenticatedUser?.username; if (row.original.username === username) { @@ -57,7 +56,7 @@ const NameCell = ({ row }: CellProps) => { ); } - return row.original.fullName || row.original.username; + return {row.original.fullName || row.original.username || ''}; }; const ViewActionCell = ({ row }: CellProps) => { @@ -147,7 +146,7 @@ const ViewAllPermissionsCell = ({ row }: CellProps) => { }); } // Toggle the current row - row.toggleRowExpanded(); + row.toggleRowExpanded?.(); }; return ( diff --git a/src/authz-module/components/TableControlBar/RolesFilter.test.tsx b/src/authz-module/components/TableControlBar/RolesFilter.test.tsx index 1ba0724f..a00f2f56 100644 --- a/src/authz-module/components/TableControlBar/RolesFilter.test.tsx +++ b/src/authz-module/components/TableControlBar/RolesFilter.test.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { screen } from '@testing-library/react'; import { renderWrapper } from '@src/setupTest'; import RolesFilter from './RolesFilter'; diff --git a/src/authz-module/components/TableControlBar/ScopesFilter.tsx b/src/authz-module/components/TableControlBar/ScopesFilter.tsx index cb8bb206..a93854a6 100644 --- a/src/authz-module/components/TableControlBar/ScopesFilter.tsx +++ b/src/authz-module/components/TableControlBar/ScopesFilter.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { LocationOn } from '@openedx/paragon/icons'; import { useScopes } from '@src/authz-module/data/hooks'; diff --git a/src/authz-module/components/TableFooter/TableFooter.test.tsx b/src/authz-module/components/TableFooter/TableFooter.test.tsx index b03f4efe..77e370fe 100644 --- a/src/authz-module/components/TableFooter/TableFooter.test.tsx +++ b/src/authz-module/components/TableFooter/TableFooter.test.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { DataTableContext } from '@openedx/paragon'; diff --git a/src/authz-module/components/TableFooter/TableFooter.tsx b/src/authz-module/components/TableFooter/TableFooter.tsx index 76169aa1..3728b1ff 100644 --- a/src/authz-module/components/TableFooter/TableFooter.tsx +++ b/src/authz-module/components/TableFooter/TableFooter.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react'; +import { useContext } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { DataTableContext, Pagination, TableFooter } from '@openedx/paragon'; import messages from '../messages'; diff --git a/src/authz-module/components/UserPermissions.test.tsx b/src/authz-module/components/UserPermissions.test.tsx index 33d4dac5..fac0fa65 100644 --- a/src/authz-module/components/UserPermissions.test.tsx +++ b/src/authz-module/components/UserPermissions.test.tsx @@ -1,6 +1,6 @@ import { initializeMockApp } from '@edx/frontend-platform/testing'; import { renderWrapper } from '@src/setupTest'; -import * as coursesConstants from '@src/authz-module/constants'; +import * as coursesConstants from '@src/authz-module/roles-permissions'; import UserPermissions from './UserPermissions'; jest.mock('./RenderPermissionInLine', () => ( @@ -88,7 +88,8 @@ describe('UserPermissions', () => { ]; const originalRolesObject = coursesConstants.rolesObject; - (coursesConstants as any).rolesObject = [...originalRolesObject, ...mockRoleObject]; + const rolesObjectSpy = jest.spyOn(coursesConstants, 'rolesObject', 'get') + .mockReturnValue([...originalRolesObject, ...mockRoleObject] as typeof originalRolesObject); const props = { row: { @@ -100,7 +101,7 @@ describe('UserPermissions', () => { const { getByTestId } = renderWrapper(); expect(getByTestId('render-permission-inline')).toBeInTheDocument(); - (coursesConstants as any).rolesObject = originalRolesObject; + rolesObjectSpy.mockRestore(); }); it('returns null when role is not found in rolesObject (line 52 coverage)', () => { diff --git a/src/authz-module/components/UserPermissions.tsx b/src/authz-module/components/UserPermissions.tsx index 30dbc6ad..468b5a5e 100644 --- a/src/authz-module/components/UserPermissions.tsx +++ b/src/authz-module/components/UserPermissions.tsx @@ -1,14 +1,13 @@ +import { DJANGO_MANAGED_ROLES } from '@src/authz-module/constants'; import { courseResourceTypes, coursePermissions, rolesObject, - DJANGO_MANAGED_ROLES, -} from '@src/authz-module/constants'; -import { libraryResourceTypes, libraryPermissions, rolesLibraryObject, -} from '@src/authz-module/libraries/constants'; +} from '@src/authz-module/roles-permissions'; +import { PermissionItem } from '@src/types'; import RenderPermissionColumn from './RenderPermissionColumn'; import RenderPermissionInLine from './RenderPermissionInLine'; import RenderAdminRole from './RenderAdminRole'; @@ -60,7 +59,7 @@ const UserPermissions = ({ row }: UserPermissionsProps) => { ); return perms.length ? { ...resource, perms } : null; }) - .filter(Boolean); + .filter((r): r is PermissionItem => r !== null); const isSingleRow = resources.length <= 3; const mid = Math.ceil(resources.length / 2); diff --git a/src/authz-module/components/constants.ts b/src/authz-module/components/constants.ts index 58d931f3..21f43bf0 100644 --- a/src/authz-module/components/constants.ts +++ b/src/authz-module/components/constants.ts @@ -1,4 +1,4 @@ -import { IntlShape } from '@edx/frontend-platform/i18n'; +import type { IntlShape } from '@edx/frontend-platform/i18n'; import { Language, LibraryBooks, School } from '@openedx/paragon/icons'; import messages from './messages'; diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index 1d9cdd49..a3dc353a 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -1,84 +1,3 @@ -import { PermissionMetadata, ResourceMetadata } from 'types'; -import { - School, LibraryBooks, Article, Group, LocalOffer, - BookOpen, - Sync, - Folder, - Calendar, - Download, - DrawShapes, - CheckCircle, - RemoveRedEye, - Plus, - EditOutline, - DownloadDone, - Settings, - Checklist, - Delete, - Upload, -} 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', - - 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_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', -}; - const ORG_AGGREGATE_SCOPE_BUILDERS = { course: (orgSlug: string) => `course-v1:${orgSlug}+*`, library: (orgSlug: string) => `lib:${orgSlug}:*`, @@ -90,426 +9,6 @@ export const getOrgAggregateScopeKey = (contextType: string, orgSlug: string): s return builder(orgSlug); }; -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 courseResourceTypes: ResourceMetadata[] = [ - { - key: 'course_tags_taxonomies', label: 'Tags', description: 'Permissions for managing tags used to organize course content.', icon: LocalOffer, - }, - { - key: 'course_updates_handouts', label: 'Course updates & handouts', description: 'Permissions for viewing and managing course updates and handouts that are visible to learners.', icon: Sync, - }, - { - key: 'course_advanced_certificates', label: 'Advanced & certificates', description: 'Permissions for managing advanced course settings and course certificates.', icon: CheckCircle, - }, - { - key: 'course_access_content', label: 'Course Access & content', description: 'Permissions related to accessing the course and managing core course content, including creating, editing, and publishing materials.', icon: BookOpen, - }, - { - key: 'course_files', label: 'Files', description: 'Permissions for viewing and managing course pages and additional learning resources.', icon: Folder, - }, - { - key: 'course_schedule_details', label: 'Schedule & details', description: 'Permissions for viewing and editing the course schedule and course information.', icon: Calendar, - }, - { - key: 'course_library_updates', label: 'Library updates', description: 'Permissions for reviewing and managing updates made to content libraries connected to the course.', icon: LibraryBooks, - }, - { - key: 'course_grading', label: 'Grading', description: 'Permissions related to viewing and managing grading configuration and grading policies.', icon: School, - }, - { - key: 'course_pages_resources', label: 'Pages & resources', description: 'Permissions for viewing and managing course pages and additional learning resources.', icon: Article, - }, - - { - key: 'course_other', label: 'Other', description: 'Additional permissions not included in other categories, such as viewing checklists and platform-level course roles.', icon: DrawShapes, - }, - { - key: 'course_import_export', label: 'Import / export', description: 'Permissions for importing and exporting course content and related data.', icon: Download, - }, - { - key: 'course_team_group', label: 'Course team & groups', description: 'Permissions for viewing and managing the course team, learner groups, and group configurations.', icon: Group, - }, -]; - -export const coursePermissions: PermissionMetadata[] = [ - { - key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE, - resource: 'course_access_content', - description: 'View course: See the course in the Studio home and access the course outline in read-only mode. Includes the "View Live" option to preview the course as a learner in the LMS.', - label: 'View course', - icon: RemoveRedEye, - }, - { - key: CONTENT_COURSE_PERMISSIONS.CREATE_COURSE, - resource: 'course_access_content', - description: 'Create a new course in Studio.', - label: 'Create course', - icon: Plus, - }, - { - key: CONTENT_COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT, - resource: 'course_access_content', - description: 'Publish course content.', - label: 'Publish content', - icon: DownloadDone, - - }, - { - key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_CONTENT, - resource: 'course_access_content', - description: 'Edit the course outline, units, and components.', - label: 'Edit content', - icon: EditOutline, - - }, - { - key: CONTENT_COURSE_PERMISSIONS.REVIEW_COURSE_LIBRARY_UPDATES, - resource: 'course_library_updates', - description: 'Accept or reject pending updates from content libraries linked to this course.', - label: 'Review library updates', - icon: Checklist, - }, - - { - key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES, - resource: 'course_updates_handouts', - description: 'See course announcements and handouts visible to learners.', - label: 'View updates', - icon: RemoveRedEye, - - }, - { - key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES, - resource: 'course_updates_handouts', - description: 'Create, edit, and delete course announcements and handouts.', - label: 'Manage course updates', - icon: Settings, - }, - - { - key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES, - resource: 'course_pages_resources', - description: 'See the Pages & Resources section in Studio.', - label: 'View pages and resources', - icon: RemoveRedEye, - }, - { - key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_PAGES_RESOURCES, - resource: 'course_pages_resources', - description: 'Enable or disable course features such as Discussions, the Wiki, Notes, Calculator, and Live. Create and edit Textbooks and Custom pages, and manage their configurations.', - label: 'Manage pages & resources', - icon: EditOutline, - }, - - { - key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, - resource: 'course_files', - description: 'See the list of files and assets uploaded to the course.', - label: 'View files', - icon: RemoveRedEye, - }, - { - key: CONTENT_COURSE_PERMISSIONS.CREATE_COURSE_FILES, - resource: 'course_files', - description: 'Upload new files and assets to the course.', - label: 'Create files', - icon: Plus, - }, - { - key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_FILES, - resource: 'course_files', - description: 'Permanently remove files and assets from the course.', - label: 'Edit files', - icon: EditOutline, - }, - { - key: CONTENT_COURSE_PERMISSIONS.DELETE_COURSE_FILES, - resource: 'course_files', - description: 'Delete files.', - label: 'Delete files', - icon: Delete, - - }, - - { - key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_SCHEDULE, - resource: 'course_schedule_details', - description: 'See the course start and end dates, enrollment dates, and pacing settings.', - label: 'View schedule', - icon: RemoveRedEye, - }, - { - key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_SCHEDULE, - resource: 'course_schedule_details', - description: 'Update course start and end dates, enrollment dates, and pacing settings.', - label: 'Edit schedule', - icon: EditOutline, - }, - { - key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_DETAILS, - resource: 'course_schedule_details', - description: 'See course information including the course summary, pacing, and prerequisites.', - label: 'View details', - icon: RemoveRedEye, - }, - { - key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_DETAILS, - resource: 'course_schedule_details', - description: 'Update course information including the course summary, pacing, and prerequisites.', - label: 'Edit details', - icon: EditOutline, - }, - - { - key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS, - resource: 'course_grading', - description: 'See the grading configuration for the course, including assignment types and grading scale.', - label: 'View grading', - icon: RemoveRedEye, - }, - { - key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_GRADING_SETTINGS, - resource: 'course_grading', - description: 'Update the grading configuration for the course, including assignment types and grading scale.', - label: 'Edit grading settings', - icon: EditOutline, - }, - - { - key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, - resource: 'course_team_group', - description: 'See the list of users with a role assigned to this course.', - label: 'View course team', - icon: RemoveRedEye, - }, - { - key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_GROUP_CONFIGURATION, - resource: 'course_team_group', - description: 'Create and manage content groups used to target course content to specific learners.', - label: 'Manage group config', - icon: Settings, - }, - { - key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, - resource: 'course_team_group', - description: 'Add, change, or remove role assignments for this course from the Roles and Permissions console.', - label: 'Manage course team', - icon: Settings, - }, - { - key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAGS, - resource: 'course_tags_taxonomies', - description: 'Create, edit, and delete tags on this course.', - label: 'Manage tags', - icon: Settings, - }, - - { - key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_ADVANCED_SETTINGS, - resource: 'course_advanced_certificates', - description: 'Access and edit the Advanced Settings page in Studio. This covers a wide range of technical course configurations, including proctoring, timed exams, LTI tools, enrollment limits, and custom display options.', - label: 'Manage advanced settings', - icon: Settings, - }, - { - key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_CERTIFICATES, - resource: 'course_advanced_certificates', - description: 'Access and edit the Advanced Settings page in Studio. This covers a wide range of technical course configurations, including proctoring, timed exams, LTI tools, enrollment limits, and custom display options.', - label: 'Manage certificates', - icon: Settings, - }, - - { - key: CONTENT_COURSE_PERMISSIONS.IMPORT_COURSE, - resource: 'course_import_export', - description: 'Import course content from a file. This is a high-privilege action that can overwrite most course content and settings.', - label: 'Import course', - icon: Download, - }, - { - key: CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE, - resource: 'course_import_export', - description: 'Download the course content as a file for backup or reuse in another platform.', - label: 'Export course', - icon: Upload, - }, - { - key: CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE_TAGS, - resource: 'course_import_export', - description: 'Download the tag data associated with this course.', - label: 'Export tags', - icon: Upload, - }, - - { - key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS, - resource: 'course_other', - description: 'See the course launch checklist in Studio.', - label: 'View checklists', - icon: RemoveRedEye, - }, - { - key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS, - resource: 'course_other', - description: 'See the list of users with platform-wide roles such as Global Staff and Super Admin.', - label: 'View global staff & super admins', - icon: RemoveRedEye, - }, - -]; - -export const rolesObject = [ - { - role: 'course_admin', - permissions: [ - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_SCHEDULE, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_DETAILS, - CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_CONTENT, - CONTENT_COURSE_PERMISSIONS.REVIEW_COURSE_LIBRARY_UPDATES, - CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES, - CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_PAGES_RESOURCES, - CONTENT_COURSE_PERMISSIONS.CREATE_COURSE_FILES, - CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_FILES, - CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_GRADING_SETTINGS, - CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_GROUP_CONFIGURATION, - CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_DETAILS, - CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAGS, - CONTENT_COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT, - CONTENT_COURSE_PERMISSIONS.DELETE_COURSE_FILES, - CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_SCHEDULE, - CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_ADVANCED_SETTINGS, - CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_CERTIFICATES, - CONTENT_COURSE_PERMISSIONS.IMPORT_COURSE, - CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE, - CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE_TAGS, - CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, - ], - userCount: 1, - name: 'Course Admin', - description: 'course level administration, including access and role management for the course team, plus all Staff capabilities.', - }, - - { - role: 'course_staff', - permissions: [ - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_SCHEDULE, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_DETAILS, - CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_CONTENT, - CONTENT_COURSE_PERMISSIONS.REVIEW_COURSE_LIBRARY_UPDATES, - CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES, - CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_PAGES_RESOURCES, - CONTENT_COURSE_PERMISSIONS.CREATE_COURSE_FILES, - CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_FILES, - CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_GRADING_SETTINGS, - CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_GROUP_CONFIGURATION, - CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_DETAILS, - CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAGS, - CONTENT_COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT, - CONTENT_COURSE_PERMISSIONS.DELETE_COURSE_FILES, - CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_SCHEDULE, - CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_ADVANCED_SETTINGS, - CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_CERTIFICATES, - CONTENT_COURSE_PERMISSIONS.IMPORT_COURSE, - CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE, - CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE_TAGS, - ], - userCount: 1, - name: 'Course Staff', - description: 'operating the course lifecycle in Studio, publishing content, handling scheduling, and managing high impact configuration for the course.', - }, - { - role: 'course_editor', - permissions: [ - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_SCHEDULE, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_DETAILS, - CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_CONTENT, - CONTENT_COURSE_PERMISSIONS.REVIEW_COURSE_LIBRARY_UPDATES, - CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES, - CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_PAGES_RESOURCES, - CONTENT_COURSE_PERMISSIONS.CREATE_COURSE_FILES, - CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_FILES, - CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_GRADING_SETTINGS, - CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_GROUP_CONFIGURATION, - CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_DETAILS, - CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAGS, - ], - userCount: 1, - name: 'Course Editor', - description: 'building and maintaining course content and supporting assets, without operational controls or high impact actions that can affect a live course.', - disabled: true, - }, - { - role: 'course_auditor', - permissions: [ - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_SCHEDULE, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_DETAILS, - ], - userCount: 1, - name: 'Course Auditor', - description: ' QA, compliance review, content review, and general oversight, no changes in Studio.', - disabled: true, - }, - -]; - export const DEFAULT_TOAST_DELAY = 5000; export const RETRY_TOAST_DELAY = 120_000; // 2 minutes export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({ diff --git a/src/authz-module/data/api.test.tsx b/src/authz-module/data/api.test.tsx index 3da148c3..35d7ed99 100644 --- a/src/authz-module/data/api.test.tsx +++ b/src/authz-module/data/api.test.tsx @@ -1,4 +1,5 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { mockHttpClient } from '@src/setupTest'; import { getTeamMembers, getUserAssignedRoles, @@ -41,7 +42,7 @@ describe('API functions', () => { }, }; - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValue(mockResponse), }); @@ -55,7 +56,7 @@ describe('API functions', () => { it('should handle all query parameters', async () => { const mockResponse = { data: { results: [], count: 0 } }; const mockGet = jest.fn().mockResolvedValue(mockResponse); - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: mockGet, }); @@ -92,7 +93,7 @@ describe('API functions', () => { }, }; - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValue(mockResponse), }); @@ -106,7 +107,7 @@ describe('API functions', () => { it('should handle all query parameters including organizations', async () => { const mockResponse = { data: { results: [], count: 0 } }; const mockGet = jest.fn().mockResolvedValue(mockResponse); - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: mockGet, }); @@ -142,14 +143,14 @@ describe('API functions', () => { }, }; - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ put: jest.fn().mockResolvedValue(mockResponse), }); const requestData = { users: ['user1'], role: 'admin', - scope: 'lib:123', + scopes: ['lib:123'], }; const result = await assignTeamMembersRole(requestData); @@ -169,7 +170,7 @@ describe('API functions', () => { }, }; - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ delete: jest.fn().mockResolvedValue(mockResponse), }); @@ -197,7 +198,7 @@ describe('API functions', () => { }, }; - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValue(mockResponse), }); @@ -221,7 +222,7 @@ describe('API functions', () => { }, }; - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValue(mockResponse), }); @@ -245,7 +246,7 @@ describe('API functions', () => { }, }; - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValue(mockResponse), }); @@ -259,7 +260,7 @@ describe('API functions', () => { it('should handle search, page, and pageSize parameters', async () => { const mockResponse = { data: { results: [], count: 0 } }; const mockGet = jest.fn().mockResolvedValue(mockResponse); - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: mockGet, }); @@ -286,7 +287,7 @@ describe('API functions', () => { }, }; - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValue(mockResponse), }); @@ -303,7 +304,7 @@ describe('API functions', () => { }, }; const mockGet = jest.fn().mockResolvedValue(mockResponse); - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: mockGet, }); diff --git a/src/authz-module/data/hooks.test.tsx b/src/authz-module/data/hooks.test.tsx index 7d0361d9..c2db5a8e 100644 --- a/src/authz-module/data/hooks.test.tsx +++ b/src/authz-module/data/hooks.test.tsx @@ -2,6 +2,8 @@ import { ReactNode } from 'react'; import { act, renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { mockHttpClient } from '@src/setupTest'; +import type { QuerySettings } from './api'; import { useLibrary, usePermissionsByRole, @@ -85,43 +87,7 @@ const mockOrgs = { ], }; -const mockScopes = { - count: 2, - next: null, - previous: null, - results: [ - { - externalKey: 'course-v1:OpenedX+DemoX+DemoCourse', - displayName: 'Open edX Demo Course', - org: { - id: 1, - created: '2026-04-02T19:30:36.779095Z', - modified: '2026-04-02T19:30:36.779095Z', - name: 'OpenedX', - shortName: 'OpenedX', - description: '', - logo: null, - active: true, - }, - }, - { - externalKey: 'lib:WGU:CSPROB', - displayName: 'Computer Science Problems', - org: { - id: 2, - created: '2026-04-02T19:31:21.196446Z', - modified: '2026-04-02T19:31:21.196446Z', - name: 'WGU', - shortName: 'WGU', - description: '', - logo: null, - active: true, - }, - }, - ], -}; - -const mockQuerySettings = { +const mockQuerySettings: QuerySettings = { roles: null, scopes: null, organizations: null, @@ -204,7 +170,7 @@ describe('useTeamMembers', () => { }); it('returns data when API call succeeds', async () => { - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValue({ data: mockMembers }), }); @@ -219,7 +185,7 @@ describe('useTeamMembers', () => { }); it('appends roles and search params when provided', async () => { - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValue({ data: { count: 0, results: [] } }), }); @@ -237,7 +203,7 @@ describe('useTeamMembers', () => { }); it('appends sort params when sortBy and order are provided', async () => { - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValue({ data: { count: 0, results: [] } }), }); @@ -255,7 +221,7 @@ describe('useTeamMembers', () => { }); it('handles error when API call fails', async () => { - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockRejectedValue(new Error('API failure')), }); @@ -277,7 +243,7 @@ describe('useLibrary', () => { }); it('returns metadata on success', async () => { - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValueOnce({ data: mockLibrary }), }); @@ -299,7 +265,7 @@ describe('useLibrary', () => { slug: 'test-library', allow_public_read: true, }; - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValueOnce({ data: rawLibrary }), }); @@ -317,7 +283,7 @@ describe('useLibrary', () => { }); it('throws on error', () => { - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockRejectedValue(new Error('Not found')), }); @@ -341,7 +307,7 @@ describe('usePermissionsByRole', () => { { role: 'user', permissions: ['perm2'], userCount: 2 }, ]; - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValue({ data: { results: mockRoles } }), }); @@ -353,7 +319,7 @@ describe('usePermissionsByRole', () => { }); it('returns error if getRoles fails', async () => { - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockRejectedValue(new Error('Not found')), }); const wrapper = createWrapper(); @@ -381,7 +347,7 @@ describe('useAssignTeamMembersRole', () => { errors: [], }; - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ put: jest.fn().mockResolvedValue({ data: mockResponse }), }); @@ -400,7 +366,7 @@ describe('useAssignTeamMembersRole', () => { }); it('handles error when adding team members fails', async () => { - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ put: jest.fn().mockRejectedValue(new Error('Failed to add members')), }); @@ -430,7 +396,7 @@ describe('useValidateUsers', () => { invalidUsers: ['unknown_user'], }; - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ post: jest.fn().mockResolvedValue({ data: mockResponse }), }); @@ -449,7 +415,7 @@ describe('useValidateUsers', () => { }); it('handles error when validation fails', async () => { - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ post: jest.fn().mockRejectedValue(new Error('Validation failed')), }); @@ -484,7 +450,7 @@ describe('useScopes', () => { }); it('returns pages data on success', async () => { - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValue({ data: makeScopesResponse() }), }); @@ -497,7 +463,7 @@ describe('useScopes', () => { }); it('hasNextPage is false when next is null', async () => { - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValue({ data: makeScopesResponse(null) }), }); @@ -508,7 +474,7 @@ describe('useScopes', () => { }); it('hasNextPage is true when next URL has page param', async () => { - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValue({ data: makeScopesResponse('http://localhost:8000/api/authz/v1/scopes/?page=2'), }), @@ -521,7 +487,7 @@ describe('useScopes', () => { }); it('hasNextPage is false when next URL has no page param', async () => { - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValue({ data: makeScopesResponse('http://localhost:8000/api/authz/v1/scopes/'), }), @@ -534,7 +500,7 @@ describe('useScopes', () => { }); it('hasNextPage is false when next is an invalid URL', async () => { - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValue({ data: makeScopesResponse('not-a-valid-url'), }), @@ -547,7 +513,7 @@ describe('useScopes', () => { }); it('handles error when API call fails', async () => { - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockRejectedValue(new Error('Network error')), }); @@ -556,6 +522,17 @@ describe('useScopes', () => { await waitFor(() => expect(result.current.isError).toBe(true)); expect(result.current.error).toBeDefined(); }); + + it('handles search parameter', async () => { + mockHttpClient().mockReturnValue({ + get: jest.fn().mockResolvedValue({ data: makeScopesResponse() }), + }); + + const { result } = renderHook(() => useScopes({ search: 'library' }), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.pages[0].results).toHaveLength(1); + }); }); describe('useOrgs', () => { @@ -563,23 +540,19 @@ describe('useOrgs', () => { jest.clearAllMocks(); }); - const mockOrgsResult = [{ - id: 1, name: 'Org One', shortName: 'org1', description: '', logo: null, active: true, - }]; - it('returns organizations on success', async () => { - getAuthenticatedHttpClient.mockReturnValue({ - get: jest.fn().mockResolvedValue({ data: { results: mockOrgsResult } }), + mockHttpClient().mockReturnValue({ + get: jest.fn().mockResolvedValue({ data: mockOrgs }), }); const { result } = renderHook(() => useOrgs(), { wrapper: createWrapper() }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data?.results).toEqual(mockOrgsResult); + expect(result.current.data?.results).toEqual(mockOrgs.results); }); it('handles error when API fails', async () => { - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockRejectedValue(new Error('Failed')), }); @@ -605,7 +578,7 @@ describe('useRevokeUserRoles', () => { errors: [], }; - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ delete: jest.fn().mockResolvedValue({ data: mockResponse }), }); @@ -630,7 +603,7 @@ describe('useRevokeUserRoles', () => { }); it('handles error when revoking roles fails', async () => { - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ delete: jest.fn().mockRejectedValue(new Error('Failed to revoke roles')), }); @@ -659,7 +632,7 @@ describe('useRevokeUserRoles', () => { data: { completed: [], errors: [] }, }); - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ delete: mockDelete, }); @@ -699,7 +672,7 @@ describe('useRevokeUserRoles', () => { errors: [], }; - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ delete: jest.fn().mockResolvedValue({ data: mockResponse }), }); @@ -780,23 +753,14 @@ describe('useRevokeUserRoles', () => { describe('useAllRoleAssignments', () => { beforeEach(() => { - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn(() => Promise.resolve({ data: mockAssignments })), }); }); it('fetches and returns role assignments', async () => { const { result } = renderHook( - () => useAllRoleAssignments({ - roles: null, - scopes: null, - organizations: null, - search: null, - order: null, - sortBy: null, - pageSize: 10, - pageIndex: 0, - }), + () => useAllRoleAssignments(mockQuerySettings), { wrapper: createWrapper() }, ); await waitFor(() => { @@ -807,7 +771,7 @@ describe('useAllRoleAssignments', () => { }); it('handles empty results', async () => { - (getAuthenticatedHttpClient as jest.Mock).mockReturnValueOnce({ + mockHttpClient().mockReturnValueOnce({ get: jest.fn(() => Promise.resolve({ data: { results: [], count: 0, next: null, previous: null, @@ -815,16 +779,7 @@ describe('useAllRoleAssignments', () => { })), }); const { result } = renderHook( - () => useAllRoleAssignments({ - roles: null, - scopes: null, - organizations: null, - search: null, - order: null, - sortBy: null, - pageSize: 10, - pageIndex: 0, - }), + () => useAllRoleAssignments(mockQuerySettings), { wrapper: createWrapper() }, ); await waitFor(() => { @@ -834,77 +789,13 @@ describe('useAllRoleAssignments', () => { }); }); -describe('useOrgs', () => { - beforeEach(() => { - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ - get: jest.fn(() => Promise.resolve({ data: mockOrgs })), - }); - }); - - it('fetches and returns organizations', async () => { - const { result } = renderHook(() => useOrgs(), { wrapper: createWrapper() }); - await waitFor(() => { - expect(result.current.data?.results).toHaveLength(2); - expect(result.current.data?.results[0].name).toBe('Organization 1'); - expect(result.current.data?.count).toBe(2); - }); - }); - - it('handles empty results', async () => { - (getAuthenticatedHttpClient as jest.Mock).mockReturnValueOnce({ - get: jest.fn(() => Promise.resolve({ - data: { - count: 0, next: null, previous: null, results: [], - }, - })), - }); - const { result } = renderHook(() => useOrgs(), { wrapper: createWrapper() }); - await waitFor(() => { - expect(result.current.data?.results).toEqual([]); - expect(result.current.data?.count).toBe(0); - }); - }); -}); - -describe('useScopes', () => { - beforeEach(() => { - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ - get: jest.fn(() => Promise.resolve({ data: mockScopes })), - }); - }); - - it('fetches and returns scopes', async () => { - const { result } = renderHook(() => useScopes(), { wrapper: createWrapper() }); - await waitFor(() => { - expect(result.current.data?.pages[0].results).toHaveLength(2); - expect(result.current.data?.pages[0].results[0].displayName).toBe('Open edX Demo Course'); - expect(result.current.data?.pages[0].count).toBe(2); - }); - }); - - it('handles empty results', async () => { - (getAuthenticatedHttpClient as jest.Mock).mockReturnValueOnce({ - get: jest.fn(() => Promise.resolve({ - data: { - count: 0, next: null, previous: null, results: [], - }, - })), - }); - const { result } = renderHook(() => useScopes(), { wrapper: createWrapper() }); - await waitFor(() => { - expect(result.current.data?.pages[0].results).toEqual([]); - expect(result.current.data?.pages[0].count).toBe(0); - }); - }); -}); - describe('useUserAssignedRoles', () => { beforeEach(() => { jest.clearAllMocks(); }); it('returns user role assignments when API call succeeds', async () => { - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValue({ data: mockUserAssignments }), }); @@ -921,7 +812,7 @@ describe('useUserAssignedRoles', () => { }); it('returns empty results when user has no role assignments', async () => { - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValue({ data: mockEmptyUserAssignments }), }); @@ -944,7 +835,7 @@ describe('useUserAssignedRoles', () => { pageIndex: 1, }; - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValue({ data: mockFilteredUserAssignments }), }); @@ -959,7 +850,7 @@ describe('useUserAssignedRoles', () => { }); it('handles API error when fetching user assignments fails', async () => { - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockRejectedValue(new Error('User not found')), }); @@ -975,7 +866,7 @@ describe('useUserAssignedRoles', () => { it('does not refetch on window focus', async () => { const mockGet = jest.fn().mockResolvedValue({ data: mockUserAssignments }); - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ get: mockGet, }); @@ -997,7 +888,7 @@ describe('useUserAssignedRoles', () => { .mockResolvedValueOnce({ data: mockUserAssignments }) .mockResolvedValueOnce({ data: mockFilteredUserAssignments }); - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ get: mockGet, }); @@ -1024,74 +915,3 @@ describe('useUserAssignedRoles', () => { expect(mockGet).toHaveBeenCalledTimes(2); }); }); - -describe('useScopes', () => { - const mockScopesData = { - count: 2, - next: null, - previous: null, - results: [ - { - displayName: 'Test Library 1', - scope: 'lib:test-library-1', - }, - { - displayName: 'Test Course 1', - scope: 'course:test-course-1', - }, - ], - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('returns scopes when API call succeeds', async () => { - getAuthenticatedHttpClient.mockReturnValue({ - get: jest.fn().mockResolvedValue({ data: mockScopesData }), - }); - - const { result } = renderHook(() => useScopes(), { - wrapper: createWrapper(), - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(getAuthenticatedHttpClient).toHaveBeenCalled(); - expect(result.current.data?.pages[0]).toEqual(mockScopesData); - expect(result.current.data?.pages[0].results).toHaveLength(2); - }); - - it('handles search parameter', async () => { - getAuthenticatedHttpClient.mockReturnValue({ - get: jest.fn().mockResolvedValue({ - data: { - count: 1, next: null, previous: null, results: [mockScopesData.results[0]], - }, - }), - }); - - const { result } = renderHook(() => useScopes({ search: 'library' }), { - wrapper: createWrapper(), - }); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data?.pages[0].results).toHaveLength(1); - }); - - it('handles error when API call fails', async () => { - getAuthenticatedHttpClient.mockReturnValue({ - get: jest.fn().mockRejectedValue(new Error('API failure')), - }); - - const { result } = renderHook(() => useScopes(), { - wrapper: createWrapper(), - }); - - await waitFor(() => expect(result.current.isError).toBe(true)); - - expect(getAuthenticatedHttpClient).toHaveBeenCalled(); - expect(result.current.error).toBeDefined(); - expect(result.current.data).toBeUndefined(); - }); -}); diff --git a/src/authz-module/libraries/constants.ts b/src/authz-module/libraries/constants.ts deleted file mode 100644 index 921385a8..00000000 --- a/src/authz-module/libraries/constants.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { PermissionMetadata, ResourceMetadata, RoleMetadata } from 'types'; -import { - Group, CollectionsBookmark, Notes, AutoAwesomeMosaic, - RemoveRedEye, - Settings, - DownloadDone, - Plus, - EditOutline, - Delete, - SpinnerIcon, - FileDownload, -} 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 viewing, managing, and publishing the library structure and metadata.', icon: CollectionsBookmark, - }, - { - key: 'library_content', label: 'Content', description: 'Permissions for creating, editing, deleting, and publishing content within the library.', icon: Notes, - }, - { - key: 'library_team', label: 'Team', description: 'Permissions for viewing and managing users who have access to the library.', icon: Group, - }, - { - key: 'library_collection', label: 'Collection', description: 'Permissions for creating and managing content collections within the library.', icon: AutoAwesomeMosaic, - }, -]; - -export const libraryPermissions: PermissionMetadata[] = [ - { - key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, resource: 'library', label: 'View', description: 'View: See the library in Studio and access its content in read-only mode.', icon: RemoveRedEye, - }, - { - key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, resource: 'library', label: 'Manage tag', description: 'Create, edit, and delete tags on this library.', icon: Settings, - }, - { - key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY, resource: 'library', label: 'Publish', description: 'Publish the library to make it available for use in courses.', icon: DownloadDone, - }, - { - key: CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_CONTENT, resource: 'library_content', label: 'Create', description: 'Create new content items in the library.', icon: Plus, - }, - { - key: CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT, resource: 'library_content', label: 'Edit', description: 'Edit existing content items in the library.', icon: EditOutline, - }, - { - key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_CONTENT, resource: 'library_content', label: 'Delete', description: 'Permanently remove content items from the library.', icon: Delete, - }, - { - key: CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT, resource: 'library_content', label: 'Publish', description: 'Publish individual content items to make them available for reuse in courses.', icon: DownloadDone, - }, - { - key: CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT, resource: 'library_content', label: 'Reuse', description: 'Add published content from this library to a course.', icon: SpinnerIcon, - }, - { - key: CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT, resource: 'library_content', label: 'Import Content from Course', description: ' Import content from an existing course into this library.', icon: FileDownload, - }, - { - key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, resource: 'library_team', label: 'View', description: 'See the list of users with a role assigned to this library.', icon: RemoveRedEye, - }, - { - key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, resource: 'library_team', label: 'Manage', description: 'Add, change, or remove role assignments for this library from the Roles and Permissions console.', icon: Settings, - }, - { - key: CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION, resource: 'library_collection', label: 'View', description: 'Create new collections to organize content within the library.', icon: RemoveRedEye, - }, - { - key: CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION, resource: 'library_collection', label: 'Publish', description: 'Update the name and contents of existing collections.', icon: EditOutline, - }, - { - key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION, resource: 'library_collection', label: 'Edit', description: 'Permanently remove collections from the library.', icon: EditOutline, - }, -]; - -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.', - }, -]; diff --git a/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx b/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx index 391c4fe9..d15adab4 100644 --- a/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx +++ b/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx @@ -6,7 +6,7 @@ import { Stepper, Button, StatefulButton, Icon, } from '@openedx/paragon'; import { SpinnerSimple } from '@openedx/paragon/icons'; -import { RoleMetadata } from 'types'; +import { RoleMetadata } from '@src/types'; import { useToastManager } from '@src/components/ToastManager/ToastManagerContext'; import SelectUsersAndRoleStep from './components/SelectUsersAndRoleStep'; import DefineApplicationScopeStep from './components/DefineApplicationScopeStep'; diff --git a/src/authz-module/role-assignation-wizard/components/OrgSection.tsx b/src/authz-module/role-assignation-wizard/components/OrgSection.tsx index cdf24d93..4be8e14d 100644 --- a/src/authz-module/role-assignation-wizard/components/OrgSection.tsx +++ b/src/authz-module/role-assignation-wizard/components/OrgSection.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Icon } from '@openedx/paragon'; import { ExpandLess, ExpandMore } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Scope } from 'types'; +import { Scope } from '@src/types'; import messages from '../messages'; import ScopeCheckboxItem from './ScopeCheckboxItem'; diff --git a/src/authz-module/role-assignation-wizard/components/ScopeCheckboxItem.tsx b/src/authz-module/role-assignation-wizard/components/ScopeCheckboxItem.tsx index e53e6b5c..75d14544 100644 --- a/src/authz-module/role-assignation-wizard/components/ScopeCheckboxItem.tsx +++ b/src/authz-module/role-assignation-wizard/components/ScopeCheckboxItem.tsx @@ -1,5 +1,5 @@ import { Form } from '@openedx/paragon'; -import { Scope } from 'types'; +import { Scope } from '@src/types'; interface ScopeCheckboxItemProps { scope: Scope; diff --git a/src/authz-module/role-assignation-wizard/components/ScopeList.tsx b/src/authz-module/role-assignation-wizard/components/ScopeList.tsx index 77f25f87..e6744b42 100644 --- a/src/authz-module/role-assignation-wizard/components/ScopeList.tsx +++ b/src/authz-module/role-assignation-wizard/components/ScopeList.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react'; import { Spinner } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Org, Scope } from 'types'; +import { Org, Scope } from '@src/types'; import OrgSection from './OrgSection'; import ScopeCheckboxItem from './ScopeCheckboxItem'; import messages from '../messages'; diff --git a/src/authz-module/role-assignation-wizard/components/SelectUsersAndRoleStep.tsx b/src/authz-module/role-assignation-wizard/components/SelectUsersAndRoleStep.tsx index 78021c4c..9757c3cb 100644 --- a/src/authz-module/role-assignation-wizard/components/SelectUsersAndRoleStep.tsx +++ b/src/authz-module/role-assignation-wizard/components/SelectUsersAndRoleStep.tsx @@ -5,7 +5,7 @@ import { Tooltip, } from '@openedx/paragon'; import { getConfig } from '@edx/frontend-platform'; -import { RoleMetadata } from 'types'; +import { RoleMetadata } from '@src/types'; import HighlightedUsersInput from './HighlightedUsersInput'; import messages from '../messages'; diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.test.ts b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.test.ts index 64917f11..30eb05a6 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.test.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.test.ts @@ -1,6 +1,6 @@ import { renderHook } from '@testing-library/react'; import { useValidateUserPermissions } from '@src/data/hooks'; -import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from '@src/authz-module/constants'; +import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from '@src/authz-module/roles-permissions'; import useScopePermissions from './useScopePermissions'; jest.mock('@src/data/hooks', () => ({ diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts index af72b9ce..a92122ec 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useValidateUserPermissions } from '@src/data/hooks'; -import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS, getOrgAggregateScopeKey } from '@src/authz-module/constants'; +import { getOrgAggregateScopeKey } from '@src/authz-module/constants'; +import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from '@src/authz-module/roles-permissions'; interface UseScopePermissionsParams { contextType: string | undefined; diff --git a/src/authz-module/role-assignation-wizard/utils.ts b/src/authz-module/role-assignation-wizard/utils.ts index f51de65e..46a1573c 100644 --- a/src/authz-module/role-assignation-wizard/utils.ts +++ b/src/authz-module/role-assignation-wizard/utils.ts @@ -1,4 +1,4 @@ -import { IntlShape } from '@edx/frontend-platform/i18n'; +import type { IntlShape } from '@edx/frontend-platform/i18n'; import { PutAssignTeamMembersRoleResponse } from '@src/authz-module/data/api'; import { ROLE_ASSIGNMENT_ERRORS } from './constants'; import messages from './messages'; diff --git a/src/authz-module/roles-permissions/RolesPermissions.tsx b/src/authz-module/roles-permissions/RolesPermissions.tsx index 588bb45c..eff45521 100644 --- a/src/authz-module/roles-permissions/RolesPermissions.tsx +++ b/src/authz-module/roles-permissions/RolesPermissions.tsx @@ -7,14 +7,20 @@ import { Container, Hyperlink, } from '@openedx/paragon'; -import { coursePermissions, courseResourceTypes, rolesObject } from '@src/authz-module/constants'; +import { + coursePermissions, + courseResourceTypes, + rolesObject, + rolesLibraryObject, + libraryPermissions, + libraryResourceTypes, +} from '@src/authz-module/roles-permissions'; import AnchorButton from '../components/AnchorButton'; import PermissionTable from '../components/PermissionTable'; import { buildPermissionMatrixByResource } from './library/utils'; import messages from './library/messages'; -import { rolesLibraryObject, libraryPermissions, libraryResourceTypes } from './library/constants'; const RolesPermissions = () => { const intl = useIntl(); diff --git a/src/authz-module/roles-permissions/course/constants.ts b/src/authz-module/roles-permissions/course/constants.ts index be6400ed..9d955ad3 100644 --- a/src/authz-module/roles-permissions/course/constants.ts +++ b/src/authz-module/roles-permissions/course/constants.ts @@ -1,4 +1,6 @@ -import { PermissionMetadata, ResourceMetadata, RoleMetadata } from 'types'; +import { + PermissionMetadata, ResourceMetadata, Role, RoleMetadata, +} from '@src/types'; import { LibraryBooks, Article, Group, LocalOffer, BookOpen, @@ -291,10 +293,11 @@ export const coursePermissions: PermissionMetadata[] = [ ]; // roles hardcoded, todo: need to add the constants from above in order to merge the different permissions array. -export const rolesObject = [ +export const rolesObject: Role[] = [ { role: 'course_admin', contextType: 'course', + scope: '', permissions: [ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES, @@ -334,6 +337,7 @@ export const rolesObject = [ { role: 'course_staff', contextType: 'course', + scope: '', permissions: [ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES, @@ -371,6 +375,7 @@ export const rolesObject = [ { role: 'course_editor', contextType: 'course', + scope: '', permissions: [ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES, @@ -401,6 +406,7 @@ export const rolesObject = [ { role: 'course_auditor', contextType: 'course', + scope: '', permissions: [ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE, CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES, diff --git a/src/authz-module/roles-permissions/index.ts b/src/authz-module/roles-permissions/index.ts new file mode 100644 index 00000000..690fa56a --- /dev/null +++ b/src/authz-module/roles-permissions/index.ts @@ -0,0 +1,15 @@ +export { + CONTENT_LIBRARY_PERMISSIONS, + libraryResourceTypes, + libraryPermissions, + libraryRolesMetadata, + rolesLibraryObject, +} from './library/constants'; + +export { + CONTENT_COURSE_PERMISSIONS, + courseResourceTypes, + coursePermissions, + rolesObject, + courseRolesMetadata, +} from './course/constants'; diff --git a/src/authz-module/roles-permissions/library/constants.ts b/src/authz-module/roles-permissions/library/constants.ts index 5477b832..5d42f3d1 100644 --- a/src/authz-module/roles-permissions/library/constants.ts +++ b/src/authz-module/roles-permissions/library/constants.ts @@ -1,4 +1,6 @@ -import { PermissionMetadata, ResourceMetadata, RoleMetadata } from 'types'; +import { + PermissionMetadata, ResourceMetadata, Role, RoleMetadata, +} from '@src/types'; import { Group, CollectionsBookmark, Notes, AutoAwesomeMosaic, } from '@openedx/paragon/icons'; @@ -79,10 +81,11 @@ export const libraryPermissions: PermissionMetadata[] = [ { key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Delete entire collections from the library.' }, ]; -export const rolesLibraryObject = [ +export const rolesLibraryObject: Role[] = [ { role: 'library_admin', contextType: 'library', + scope: '', permissions: [ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, @@ -106,6 +109,7 @@ export const rolesLibraryObject = [ { role: 'library_author', contextType: 'library', + scope: '', permissions: [ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, @@ -127,6 +131,7 @@ export const rolesLibraryObject = [ { role: 'library_contributor', contextType: 'library', + scope: '', permissions: [ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, @@ -148,6 +153,7 @@ export const rolesLibraryObject = [ { role: 'library_user', contextType: 'library', + scope: '', permissions: [ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT, diff --git a/src/authz-module/roles-permissions/library/utils.test.ts b/src/authz-module/roles-permissions/library/utils.test.ts index 71804dd6..0d4f0ff5 100644 --- a/src/authz-module/roles-permissions/library/utils.test.ts +++ b/src/authz-module/roles-permissions/library/utils.test.ts @@ -1,6 +1,7 @@ -import { buildPermissionMatrixByResource, buildPermissionMatrixByRole } from './utils'; +import { createIntl } from '@edx/frontend-platform/i18n'; +import { buildPermissionMatrixByResource } from './utils'; -const intl = { formatMessage: jest.fn((msg: any) => msg) }; +const intl = createIntl({ locale: 'en', messages: {} }); const permissions = [ { @@ -15,57 +16,17 @@ const resources = [ ]; const roles = [ { - name: 'admin', permissions: ['create_library', 'edit_library'], userCount: 2, role: 'admin', description: '', contextType: '', + name: 'admin', permissions: ['create_library', 'edit_library'], userCount: 2, role: 'admin', description: '', contextType: '', scope: '', }, { - name: 'editor', permissions: ['edit_library'], userCount: 2, role: 'editor', description: '', contextType: '', + name: 'editor', permissions: ['edit_library'], userCount: 2, role: 'editor', description: '', contextType: '', scope: '', }, { - name: 'guest', permissions: [], userCount: 2, role: 'guest', description: '', contextType: '', + name: 'guest', permissions: [], userCount: 2, role: 'guest', description: '', contextType: '', scope: '', }, ]; describe('buildPermissionsMatrix', () => { - it('returns permissions a matrix of given roles', () => { - const matrix = buildPermissionMatrixByRole({ - roles, permissions, resources, intl, - }); - expect(matrix.length).toBe(3); - expect(matrix[1]).toEqual({ - name: 'editor', - userCount: 2, - role: 'editor', - description: '', - contextType: '', - permissions: ['edit_library'], - resources: [ - { - key: 'library', - label: 'Library', - description: '', - permissions: [ - { - actionKey: 'create', - description: '', - disabled: true, - key: 'create_library', - label: 'Create Library', - resource: 'library', - }, - { - key: 'edit_library', - resource: 'library', - label: 'Edit Library', - description: '', - actionKey: 'edit', - disabled: false, - }, - ], - }, - ], - }); - }); - it('should build permission matrix grouped by resources with role access mapped', () => { const matrix = buildPermissionMatrixByResource({ roles, permissions, resources, intl, diff --git a/src/authz-module/roles-permissions/library/utils.ts b/src/authz-module/roles-permissions/library/utils.ts index 4cedb3f3..affcaec2 100644 --- a/src/authz-module/roles-permissions/library/utils.ts +++ b/src/authz-module/roles-permissions/library/utils.ts @@ -1,8 +1,8 @@ -import { IntlShape } from '@edx/frontend-platform/i18n'; +import type { IntlShape } from '@edx/frontend-platform/i18n'; import { actionKeys } from '@src/authz-module/components/RoleCard/constants'; import { EnrichedPermission, PermissionMetadata, PermissionsResourceGrouped, - PermissionsRoleGrouped, ResourceMetadata, Role, RoleResourceGroup, + ResourceMetadata, Role, } from '@src/types'; import actionMessages from '@src/authz-module/components/RoleCard/messages'; @@ -96,60 +96,4 @@ const buildPermissionMatrixByResource = ({ }); }; -/** - * Builds a permission matrix for grouped by roles. - * - * Builds a permission matrix grouped by resource, mapping each action to its display label - * and enabled/disabled state based on the role's allowed permissions. - * - * @param roles - Array of roles metadata. - * @param permissions - Permissions metadata. - * @param resources - Resources metadata. - * @param intl - the i18n function to enable label translations. - * @returns An array of permission groupings by role and resource with action-level details. - */ -const buildPermissionMatrixByRole = ({ - roles, permissions, resources, intl, -}: BuildPermissionsMatrixProps): PermissionsRoleGrouped[] => { - const enrichedPermissions = permissions.reduce((acc, perm) => { - acc[perm.key] = getPermissionMetadata(perm, intl); - return acc; - }, {} as Record); - - return roles.map(role => { - const allowed = new Set(role.permissions); - const permissionsGroupedByResource: Record = {}; - - permissions.forEach(permission => { - const enriched = enrichedPermissions[permission.key]; - const { resource } = permission; - - if (!enriched.actionKey) { return; } - - if (!permissionsGroupedByResource[resource]) { - const resourceInfo = resources.find(r => r.key === resource); - if (!resourceInfo) { return; } - - permissionsGroupedByResource[resource] = { - key: resourceInfo.key, - label: resourceInfo.label, - description: resourceInfo.description, - permissions: [], - }; - } - - permissionsGroupedByResource[resource].permissions.push({ - ...enriched, - description: permission.description, - disabled: !allowed.has(permission.key), - }); - }); - - return { - ...role, - resources: Object.values(permissionsGroupedByResource), - }; - }); -}; - -export { buildPermissionMatrixByResource, buildPermissionMatrixByRole }; +export { buildPermissionMatrixByResource }; diff --git a/src/authz-module/team-members/TeamMembersTable.test.tsx b/src/authz-module/team-members/TeamMembersTable.test.tsx index 917a0fb5..ba3aecfa 100644 --- a/src/authz-module/team-members/TeamMembersTable.test.tsx +++ b/src/authz-module/team-members/TeamMembersTable.test.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithAllProviders } from '@src/setupTest'; diff --git a/src/authz-module/utils.test.tsx b/src/authz-module/utils.test.tsx index e11a4dff..dd4d3af4 100644 --- a/src/authz-module/utils.test.tsx +++ b/src/authz-module/utils.test.tsx @@ -1,9 +1,8 @@ -import React from 'react'; import { screen } from '@testing-library/react'; import { initializeMockApp } from '@edx/frontend-platform/testing'; import { renderWrapper } from '@src/setupTest'; import { getCellHeader, getScopeManageAction, getScopeManageActionPermission } from './utils'; -import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from './constants'; +import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from './roles-permissions'; const renderCellHeader = (columnId: string, columnTitle: string, filtersApplied: string[]) => { const result = getCellHeader(columnId, columnTitle, filtersApplied); diff --git a/src/authz-module/utils.tsx b/src/authz-module/utils.tsx index 47c38e1e..fd2a422e 100644 --- a/src/authz-module/utils.tsx +++ b/src/authz-module/utils.tsx @@ -1,6 +1,6 @@ import { Icon } from '@openedx/paragon'; import { FilterList } from '@openedx/paragon/icons'; -import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from './constants'; +import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from './roles-permissions'; /** * Returns a header value for a DataTable column that shows a filter icon diff --git a/src/components/ToastManager/ToastManagerContext.tsx b/src/components/ToastManager/ToastManagerContext.tsx index dcf454d2..8a40dd9b 100644 --- a/src/components/ToastManager/ToastManagerContext.tsx +++ b/src/components/ToastManager/ToastManagerContext.tsx @@ -22,19 +22,19 @@ export const ERROR_TOAST_MAP: Record void; delay?: number; } -const Bold = (chunk: string) => {chunk}; +const Bold = (chunks: React.ReactNode[]) => {chunks}; const Br = () =>
; type ToastManagerContextType = { showToast: (toast: Omit) => void; showErrorToast: (error, retryFn?: () => void) => void; - Bold: (chunk: string) => JSX.Element; + Bold: (chunks: React.ReactNode[]) => JSX.Element; Br: () => JSX.Element; }; @@ -111,7 +111,7 @@ export const ToastManagerProvider = ({ children }: ToastManagerProviderProps) => label: intl.formatMessage(messages['authz.team.toast.retry.label']), } : undefined} > - {toast.message} + {toast.message as string} ))} diff --git a/src/data/hooks.test.tsx b/src/data/hooks.test.tsx index e006989c..464c770f 100644 --- a/src/data/hooks.test.tsx +++ b/src/data/hooks.test.tsx @@ -2,6 +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 { mockHttpClient } from '@src/setupTest'; import { useValidateUserPermissions, useUserAccount, useValidateUserPermissionsNonSuspense } from './hooks'; jest.mock('@edx/frontend-platform/auth', () => ({ @@ -88,7 +89,7 @@ describe('useValidateUserPermissions', () => { }); it('returns allowed true when permissions are valid', async () => { - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ post: jest.fn().mockResolvedValueOnce({ data: mockValidPermissions }), }); @@ -96,14 +97,13 @@ describe('useValidateUserPermissions', () => { wrapper: createWrapper(), }); - await waitFor(() => expect(result.current).toBeDefined()); + await waitFor(() => expect(result.current.data?.[0].allowed).toBe(true)); expect(getAuthenticatedHttpClient).toHaveBeenCalled(); - expect(result.current.data[0].allowed).toBe(true); }); it('returns allowed false when permissions are invalid', async () => { - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ post: jest.fn().mockResolvedValue({ data: mockInvalidPermissions }), }); @@ -111,16 +111,15 @@ describe('useValidateUserPermissions', () => { wrapper: createWrapper(), }); - await waitFor(() => expect(result.current).toBeDefined()); + await waitFor(() => expect(result.current.data?.[0].allowed).toBe(false)); expect(getAuthenticatedHttpClient).toHaveBeenCalled(); - expect(result.current.data[0].allowed).toBe(false); }); it('handles error when the API call fails', async () => { const mockError = new Error('API Error'); - getAuthenticatedHttpClient.mockReturnValue({ + mockHttpClient().mockReturnValue({ post: jest.fn().mockRejectedValue(new Error('API Error')), }); @@ -142,7 +141,7 @@ describe('useValidateUserPermissionsNonSuspense', () => { }); it('returns allowed true when permissions are valid', async () => { - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ post: jest.fn().mockResolvedValueOnce({ data: mockValidPermissions }), }); @@ -150,14 +149,13 @@ describe('useValidateUserPermissionsNonSuspense', () => { wrapper: createWrapper(), }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await waitFor(() => expect(result.current.data?.[0].allowed).toBe(true)); expect(getAuthenticatedHttpClient).toHaveBeenCalled(); - expect(result.current.data[0].allowed).toBe(true); }); it('returns allowed false when permissions are invalid', async () => { - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ post: jest.fn().mockResolvedValue({ data: mockInvalidPermissions }), }); @@ -165,15 +163,14 @@ describe('useValidateUserPermissionsNonSuspense', () => { wrapper: createWrapper(), }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await waitFor(() => expect(result.current.data?.[0].allowed).toBe(false)); expect(getAuthenticatedHttpClient).toHaveBeenCalled(); - expect(result.current.data[0].allowed).toBe(false); }); it('handles error when the API call fails', async () => { const mockError = new Error('API Error'); - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ post: jest.fn().mockRejectedValueOnce(mockError), }); @@ -188,7 +185,7 @@ describe('useValidateUserPermissionsNonSuspense', () => { }); it('starts with loading state', () => { - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ post: jest.fn().mockImplementation(() => new Promise(() => {})), }); @@ -210,7 +207,7 @@ describe('useValidateUserPermissionsNonSuspense', () => { it('does not retry on failure', async () => { const mockPost = jest.fn().mockRejectedValue(new Error('Network Error')); - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ post: mockPost, }); @@ -242,7 +239,7 @@ describe('useValidateUserPermissionsNonSuspense', () => { { action: 'act:write', object: 'course:test-course', allowed: false }, ]; - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ post: jest.fn().mockResolvedValueOnce({ data: multipleValidPermissions }), }); @@ -250,11 +247,11 @@ describe('useValidateUserPermissionsNonSuspense', () => { wrapper: createWrapper(), }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(result.current.data).toHaveLength(2); - expect(result.current.data[0].allowed).toBe(true); - expect(result.current.data[1].allowed).toBe(false); + await waitFor(() => { + expect(result.current.data).toHaveLength(2); + expect(result.current.data?.[0].allowed).toBe(true); + expect(result.current.data?.[1].allowed).toBe(false); + }); }); }); @@ -264,7 +261,7 @@ describe('useUserAccount', () => { }); it('fetches user account data successfully', async () => { - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValueOnce({ data: mockUserAccountData }), }); @@ -280,7 +277,7 @@ describe('useUserAccount', () => { }); it('handles user account data with minimal information', async () => { - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockResolvedValueOnce({ data: mockEmptyUserData }), }); @@ -297,7 +294,7 @@ describe('useUserAccount', () => { it('handles API error gracefully', async () => { const mockError = new Error('User not found'); - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ get: jest.fn().mockRejectedValueOnce(mockError), }); @@ -313,7 +310,7 @@ describe('useUserAccount', () => { it('does not refetch on window focus', async () => { const mockGet = jest.fn().mockResolvedValueOnce({ data: mockUserAccountData }); - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ get: mockGet, }); @@ -335,7 +332,7 @@ describe('useUserAccount', () => { .mockResolvedValueOnce({ data: mockUserAccountData }) .mockResolvedValueOnce({ data: mockEmptyUserData }); - (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + mockHttpClient().mockReturnValue({ get: mockGet, }); diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 00000000..9fb2f578 --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,21 @@ +// Module augmentations for external libraries. +// Application domain types belong in src/types.ts, not here. + +export {}; + +declare module '@openedx/paragon' { + export interface DataTableRow { + original: T; + id: string; + isExpanded?: boolean; + toggleRowExpanded?: () => void; + } + + export interface DataTableCellProps { + row: DataTableRow; + value?: unknown; + cell?: { + getCellProps: (props?: Record) => Record; + }; + } +} diff --git a/src/index.tsx b/src/index.tsx index 7e5ee477..8a35c69e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -30,7 +30,8 @@ subscribe(APP_READY, () => { ); }); -subscribe(APP_INIT_ERROR, (error) => { +subscribe(APP_INIT_ERROR, (_message, data) => { + const error = data as Error; const root = createRoot(document.getElementById('root') as HTMLElement); root.render( @@ -47,7 +48,7 @@ initialize({ config: () => { mergeConfig({ COURSE_AUTHORING_MICROFRONTEND_URL: process.env.COURSE_AUTHORING_MICROFRONTEND_URL || null, - }, 'AdminConsoleAppConfig'); + }); }, }, }); diff --git a/src/setupTest.tsx b/src/setupTest.tsx index 15983d8a..d459ad16 100644 --- a/src/setupTest.tsx +++ b/src/setupTest.tsx @@ -4,17 +4,26 @@ import { ReactNode } from 'react'; import { render } from '@testing-library/react'; import { BrowserRouter } from 'react-router-dom'; import { AppContext } from '@edx/frontend-platform/react'; +import type { ConfigDocument } from '@edx/frontend-platform/config'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -const mockAppContext = { +// Lazy so each test file's own `jest.mock('@edx/frontend-platform/auth', ...)` +// (which is hoisted above this setup file's imports) is in effect by the time +// callers do `mockHttpClient().mockReturnValue(...)`. +export const mockHttpClient = (): jest.Mock => jest.requireMock('@edx/frontend-platform/auth').getAuthenticatedHttpClient; + +export const mockAppContext = { authenticatedUser: { + userId: 1, username: 'testuser', email: 'testuser@example.com', + roles: [], + administrator: false, }, config: { ...process.env, - }, + } as ConfigDocument, }; interface WrapperProps { diff --git a/src/types.ts b/src/types.ts index 93a85913..e473331b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -56,6 +56,10 @@ export type PermissionMetadata = { icon?: React.ComponentType>; }; +export type PermissionItem = ResourceMetadata & { + perms: PermissionMetadata[]; +}; + export type Org = { id: string; name: string; @@ -83,35 +87,6 @@ export type PermissionsResourceGrouped = ResourceMetadata & { permissions: PermissionWithRoles[]; }; -export type RolePermission = EnrichedPermission & { - disabled: boolean; -}; - -export type RoleResourceGroup = { - key: string; - label: string; - description: string; - permissions: RolePermission[]; -}; - -export type PermissionsRoleGrouped = Role & { - resources: RoleResourceGroup[]; -}; - -// Paragon table type -export interface TableCellValue { - row: { - original: T; - }; -} - -export type AppContextType = { - authenticatedUser: { - username: string; - email: string; - }; -}; - export interface UserRole { isSuperadmin?: boolean; role: string; diff --git a/tsconfig.json b/tsconfig.json index a45a627a..0d327c42 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,12 +3,12 @@ "compilerOptions": { "outDir": "dist", "rootDir": ".", - "baseUrl": "./src", - "paths": { - "*": ["*"], - "@src/*": ["*"] - } - }, - "include": ["*.js", ".eslintrc.js", "src/**/*", "plugins/**/*"], - "exclude": ["dist", "node_modules"] + // Ignore deprecation warnings for TypeScript 6.0, which will be used in the next major release of TypeScript. + "ignoreDeprecations": "6.0", + "paths": { + "@src/*": ["./src/*"] + } + }, + "include": ["*.js", ".eslintrc.js", "src/**/*", "plugins/**/*"], + "exclude": ["dist", "node_modules"] }