From 92b57e8b4811d276cd0668507ba2997531ea113e Mon Sep 17 00:00:00 2001 From: jacobo-dominguez-wgu Date: Thu, 19 Mar 2026 15:15:35 -0600 Subject: [PATCH 01/26] refactor: moving files from libraries to authz module and minor improvements on the header --- .../hooks/useQuerySettings.test.ts | 464 ++++++++++++++++++ src/authz-module/hooks/useQuerySettings.ts | 96 ++++ 2 files changed, 560 insertions(+) create mode 100644 src/authz-module/hooks/useQuerySettings.test.ts create mode 100644 src/authz-module/hooks/useQuerySettings.ts diff --git a/src/authz-module/hooks/useQuerySettings.test.ts b/src/authz-module/hooks/useQuerySettings.test.ts new file mode 100644 index 00000000..6a342fa6 --- /dev/null +++ b/src/authz-module/hooks/useQuerySettings.test.ts @@ -0,0 +1,464 @@ +import { renderHook, act } from '@testing-library/react'; +import { QuerySettings } from '@src/authz-module/data/api'; +import { useQuerySettings } from './useQuerySettings'; + +describe('useQuerySettings', () => { + const defaultQuerySettings: QuerySettings = { + roles: null, + search: null, + pageSize: 10, + pageIndex: 0, + sortBy: null, + order: null, + scopes: null, + organizations: null, + }; + + it('should initialize with default query settings when no initial settings provided', () => { + const { result } = renderHook(() => useQuerySettings()); + + expect(result.current.querySettings).toEqual(defaultQuerySettings); + expect(typeof result.current.handleTableFetch).toBe('function'); + }); + + it('should initialize with custom initial query settings', () => { + const customInitialSettings: QuerySettings = { + roles: 'admin,editor', + search: 'test-user', + pageSize: 20, + pageIndex: 2, + sortBy: 'username', + order: 'asc', + scopes: null, + organizations: null, + }; + + const { result } = renderHook(() => useQuerySettings(customInitialSettings)); + + expect(result.current.querySettings).toEqual(customInitialSettings); + }); + + it('should update query settings when handleTableFetch is called with new filters', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 15, + pageIndex: 1, + sortBy: [{ id: 'username', desc: false }], + filters: [ + { id: 'role', value: ['admin', 'editor'] }, + { id: 'name', value: 'john' }, + ], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings).toEqual({ + roles: 'admin,editor', + search: 'john', + pageSize: 15, + pageIndex: 1, + sortBy: 'username', + order: 'asc', + scopes: null, + organizations: null, + }); + }); + + it('should handle descending sort order by adding minus prefix', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 10, + pageIndex: 0, + sortBy: [{ id: 'email', desc: true }], + filters: [], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings.order).toBe('desc'); + }); + + it('should convert camelCase sort field to snake_case', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 10, + pageIndex: 0, + sortBy: [{ id: 'firstName', desc: false }], + filters: [], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings.sortBy).toBe('first_name'); + }); + + it('should convert camelCase sort field to snake_case with descending order', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 10, + pageIndex: 0, + sortBy: [{ id: 'lastName', desc: true }], + filters: [], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings.order).toBe('desc'); + }); + + it('should handle empty filters by setting values to null', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 10, + pageIndex: 0, + sortBy: [], + filters: [], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings).toEqual({ + roles: null, + search: null, + pageSize: 10, + pageIndex: 0, + order: null, + sortBy: null, + organizations: null, + scopes: null, + }); + }); + + it('should handle empty roles filter array by setting roles to null', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 10, + pageIndex: 0, + sortBy: [], + filters: [ + { id: 'roles', value: [] }, + { id: 'username', value: '' }, + ], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings).toEqual({ + roles: null, + search: null, + pageSize: 10, + pageIndex: 0, + order: null, + sortBy: null, + organizations: null, + scopes: null, + }); + }); + + it('should handle missing filters by setting default values', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 10, + pageIndex: 0, + sortBy: [], + filters: [ + { id: 'roles', value: undefined }, + { id: 'username', value: undefined }, + ], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings).toEqual({ + roles: null, + search: null, + pageSize: 10, + pageIndex: 0, + order: null, + sortBy: null, + organizations: null, + scopes: null, + }); + }); + + it('should use default pagination values when not provided', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + sortBy: [], + filters: [], + } as any; // Missing pageSize and pageIndex + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings.pageSize).toBe(10); + expect(result.current.querySettings.pageIndex).toBe(0); + }); + + it('should not update state if settings have not changed', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 10, + pageIndex: 0, + sortBy: [], + filters: [], + }; + + const initialSettings = result.current.querySettings; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + // Should be the same object reference since no changes occurred + expect(result.current.querySettings).toBe(initialSettings); + }); + + it('should update state when settings have changed', () => { + const { result } = renderHook(() => useQuerySettings()); + + const initialSettings = result.current.querySettings; + + const tableFilters = { + pageSize: 20, // Different from default + pageIndex: 0, + sortBy: [], + filters: [], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + // Should be a different object reference since pageSize changed + expect(result.current.querySettings).not.toBe(initialSettings); + expect(result.current.querySettings.pageSize).toBe(20); + }); + + it('should handle complex filter combinations', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 25, + pageIndex: 3, + sortBy: [{ id: 'userRole', desc: true }], + filters: [ + { id: 'role', value: ['admin', 'editor', 'viewer'] }, + { id: 'name', value: 'test@example.com' }, + { id: 'otherFilter', value: 'ignored' }, // Should be ignored + { id: 'org', value: ['org1', 'org2'] }, + { id: 'scope', value: ['scope1', 'scope2'] }, + ], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings).toEqual({ + roles: 'admin,editor,viewer', + search: 'test@example.com', + pageSize: 25, + pageIndex: 3, + order: 'desc', + sortBy: 'user_role', + organizations: 'org1,org2', + scopes: 'scope1,scope2', + + }); + }); + + it('should handle multiple camelCase words in sort field', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 10, + pageIndex: 0, + sortBy: [{ id: 'userFirstLastName', desc: false }], + filters: [], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings.sortBy).toBe('user_first_last_name'); + }); + + it('should preserve handleTableFetch function reference across renders', () => { + const { result, rerender } = renderHook(() => useQuerySettings()); + + const initialHandleTableFetch = result.current.handleTableFetch; + + rerender(); + + expect(result.current.handleTableFetch).toBe(initialHandleTableFetch); + }); + + it('should handle whitespace-only search values as provided', () => { + const { result } = renderHook(() => useQuerySettings()); + + const tableFilters = { + pageSize: 10, + pageIndex: 0, + sortBy: [], + filters: [ + { id: 'name', value: ' ' }, // Whitespace only + ], + }; + + act(() => { + result.current.handleTableFetch(tableFilters); + }); + + expect(result.current.querySettings.search).toBe(' '); + }); + + it('should detect changes in roles filter', () => { + const { result } = renderHook(() => useQuerySettings()); + + // First set some roles + act(() => { + result.current.handleTableFetch({ + pageSize: 10, + pageIndex: 0, + sortBy: [], + filters: [{ id: 'role', value: ['admin'] }], + }); + }); + + const settingsAfterFirstUpdate = result.current.querySettings; + + // Then change roles + act(() => { + result.current.handleTableFetch({ + pageSize: 10, + pageIndex: 0, + sortBy: [], + filters: [{ id: 'role', value: ['editor'] }], + }); + }); + + expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate); + expect(result.current.querySettings.roles).toBe('editor'); + }); + + it('should detect changes in search filter', () => { + const { result } = renderHook(() => useQuerySettings()); + + // First set a search term + act(() => { + result.current.handleTableFetch({ + pageSize: 10, + pageIndex: 0, + sortBy: [], + filters: [{ id: 'name', value: 'john' }], + }); + }); + + const settingsAfterFirstUpdate = result.current.querySettings; + + // Then change search term + act(() => { + result.current.handleTableFetch({ + pageSize: 10, + pageIndex: 0, + sortBy: [], + filters: [{ id: 'name', value: 'jane' }], + }); + }); + + expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate); + expect(result.current.querySettings.search).toBe('jane'); + }); + + it('should detect changes in ordering', () => { + const { result } = renderHook(() => useQuerySettings()); + + // First set ordering + act(() => { + result.current.handleTableFetch({ + pageSize: 10, + pageIndex: 0, + sortBy: [{ id: 'username', desc: false }], + filters: [], + }); + }); + + const settingsAfterFirstUpdate = result.current.querySettings; + + // Then change ordering + act(() => { + result.current.handleTableFetch({ + pageSize: 10, + pageIndex: 0, + sortBy: [{ id: 'email', desc: true }], + filters: [], + }); + }); + + expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate); + expect(result.current.querySettings.sortBy).toBe('email'); + expect(result.current.querySettings.order).toBe('desc'); + }); + + it('should detect changes in pageSize', () => { + const { result } = renderHook(() => useQuerySettings()); + + const initialSettings = result.current.querySettings; + + act(() => { + result.current.handleTableFetch({ + pageSize: 50, + pageIndex: 0, + sortBy: [], + filters: [], + }); + }); + + expect(result.current.querySettings).not.toBe(initialSettings); + expect(result.current.querySettings.pageSize).toBe(50); + }); + + it('should detect changes in pageIndex', () => { + const { result } = renderHook(() => useQuerySettings()); + + const initialSettings = result.current.querySettings; + + act(() => { + result.current.handleTableFetch({ + pageSize: 10, + pageIndex: 5, + sortBy: [], + filters: [], + }); + }); + + expect(result.current.querySettings).not.toBe(initialSettings); + expect(result.current.querySettings.pageIndex).toBe(5); + }); +}); diff --git a/src/authz-module/hooks/useQuerySettings.ts b/src/authz-module/hooks/useQuerySettings.ts new file mode 100644 index 00000000..33675c82 --- /dev/null +++ b/src/authz-module/hooks/useQuerySettings.ts @@ -0,0 +1,96 @@ +import { useCallback, useState } from 'react'; +import { QuerySettings } from '@src/authz-module/data/api'; + +interface DataTableFilters { + pageSize: number; + pageIndex: number; + sortBy: Array<{ id: string; desc: boolean }>; + filters: Array<{ id: string; value: any }>; +} + +interface UseQuerySettingsReturn { + querySettings: QuerySettings; + handleTableFetch: (tableFilters: DataTableFilters) => void; +} + +enum SortOrderKeys { + ASC = 'asc', + DESC = 'desc', +} + +/** + * Custom hook to manage query settings for table data fetching + * Converts DataTable filter/sort/pagination settings to API query parameters + * and manages URL synchronization + * + * @param initialQuerySettings - Initial query settings + * @returns Object containing querySettings and handleTableFetch function + */ +export const useQuerySettings = ( + initialQuerySettings: QuerySettings = { + roles: null, + scopes: null, + organizations: null, + search: null, + pageSize: 10, + pageIndex: 0, + order: null, + sortBy: null, + }, +): UseQuerySettingsReturn => { + const [querySettings, setQuerySettings] = useState(initialQuerySettings); + + const handleTableFetch = useCallback((tableFilters: DataTableFilters) => { + setQuerySettings((prevSettings) => { + // Extract filters + const rolesFilter = tableFilters.filters?.find((filter) => filter.id === 'role')?.value?.join(',') ?? ''; + const searchFilter = tableFilters.filters?.find((filter) => filter.id === 'name')?.value ?? ''; + const organizationsFilter = tableFilters.filters?.find((filter) => filter.id === 'org')?.value?.join(',') ?? ''; + const scopesFilter = tableFilters.filters?.find((filter) => filter.id === 'scope')?.value?.join(',') ?? ''; + + // Extract pagination + const { pageSize = 10, pageIndex = 0 } = tableFilters; + + // Extract and convert sorting + let sortByOption = ''; + let sortByOrder = ''; + if (tableFilters.sortBy?.length) { + sortByOption = tableFilters.sortBy[0]?.id.replace(/([A-Z])/g, '_$1').toLowerCase(); + sortByOrder = tableFilters.sortBy[0]?.desc ? SortOrderKeys.DESC : SortOrderKeys.ASC; + } + + const newQuerySettings: QuerySettings = { + roles: rolesFilter || null, + scopes: scopesFilter || null, + organizations: organizationsFilter || null, + search: searchFilter || null, + sortBy: sortByOption || null, + order: sortByOrder || null, + pageSize, + pageIndex, + }; + + const hasChanged = ( + prevSettings.roles !== newQuerySettings.roles + || prevSettings.scopes !== newQuerySettings.scopes + || prevSettings.organizations !== newQuerySettings.organizations + || prevSettings.search !== newQuerySettings.search + || prevSettings.pageSize !== newQuerySettings.pageSize + || prevSettings.pageIndex !== newQuerySettings.pageIndex + || prevSettings.sortBy !== newQuerySettings.sortBy + || prevSettings.order !== newQuerySettings.order + ); + + if (!hasChanged) { + return prevSettings; // No change, prevent unnecessary update + } + + return newQuerySettings; + }); + }, []); + + return { + querySettings, + handleTableFetch, + }; +}; From 6bd20a92b7c75460d3925b15d49b03f5e2171a83 Mon Sep 17 00:00:00 2001 From: jacobo-dominguez-wgu Date: Sat, 21 Mar 2026 10:03:36 -0600 Subject: [PATCH 02/26] feat: roles table for audit user page --- src/authz-module/audit-user/CustomCells.tsx | 44 ++++++++++++++++++ src/authz-module/audit-user/utils.ts | 7 +++ .../components/ProtectedRoute.tsx | 46 +++++++++++++++++++ src/data/hooks.ts | 1 - 4 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 src/authz-module/audit-user/CustomCells.tsx create mode 100644 src/authz-module/audit-user/utils.ts create mode 100644 src/authz-module/components/ProtectedRoute.tsx diff --git a/src/authz-module/audit-user/CustomCells.tsx b/src/authz-module/audit-user/CustomCells.tsx new file mode 100644 index 00000000..c8b896c2 --- /dev/null +++ b/src/authz-module/audit-user/CustomCells.tsx @@ -0,0 +1,44 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import ViewMoreLink from '@src/authz-module/components/ViewMoreLink'; +import { Delete, ExpandMore } from '@openedx/paragon/icons'; +import { IconButton } from '@openedx/paragon'; +import { TableCellValue, UserRole } from 'types'; +import messages from './messages'; +import { getPermissionsCountByRole } from './utils'; + +type CellProps = TableCellValue; + +export const ViewAllPermissionsCell = ({ row }: CellProps) => { + const { formatMessage } = useIntl(); + return ( + console.log('View more clicked for row:', row)} + iconSrc={ExpandMore} + /> + ); +}; + +export const ActionsCell = ({ row }: CellProps) => { + const { formatMessage } = useIntl(); + const handleDelete = () => { + // TODO: Implement delete functionality + console.log('Delete clicked for row:', row); + }; + + return ( + + ); +}; + +export const PermissionsCell = ({ row }: CellProps) => { + const { formatMessage } = useIntl(); + // TODO handle permissions length per role + const count = getPermissionsCountByRole(row.original.role); + return ( + + {formatMessage(messages['authz.user.table.permissions.available.count'], { count })} + + ); +}; diff --git a/src/authz-module/audit-user/utils.ts b/src/authz-module/audit-user/utils.ts new file mode 100644 index 00000000..d215d02e --- /dev/null +++ b/src/authz-module/audit-user/utils.ts @@ -0,0 +1,7 @@ +export const getPermissionsCountByRole = (role: string) => { +/* + const roleData = permissionsList.find(item => item.role === role); + return roleData ? roleData.permissions.length : 0; + */ + return Math.floor(Math.random() * 50); +}; diff --git a/src/authz-module/components/ProtectedRoute.tsx b/src/authz-module/components/ProtectedRoute.tsx new file mode 100644 index 00000000..7db6a64d --- /dev/null +++ b/src/authz-module/components/ProtectedRoute.tsx @@ -0,0 +1,46 @@ +// src/components/ProtectedRoute.tsx +import { ReactElement } from 'react'; +import { useValidateUserPermissions } from '@src/data/hooks'; +import LoadingPage from 'components/LoadingPage'; +import { CustomErrors } from 'constants'; +import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from 'authz-module/constants'; + +const REQUIRED_USER_PERMISSIONS = [ + CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, + CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, + CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, + CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, +]; + +type ProtectedRouteProps = { + children: ReactElement; + fallback?: ReactElement; +}; + +export const ProtectedRoute = ({ + children, + fallback, +}: ProtectedRouteProps) => { + // TODO: which scope? + const requiredPermissions = REQUIRED_USER_PERMISSIONS.map(action => ({ action, scope: '*' })); + const { data: permissions, isLoading, isError } = useValidateUserPermissions(requiredPermissions); + + if (isLoading) { + return ; + } + + if (isError && fallback) { + return fallback; + } + if (isError) { + throw new Error(CustomErrors.SERVER_ERROR); + } + + const hasAccess = permissions.some(permission => permission.allowed); + + if (!hasAccess) { + throw new Error(CustomErrors.NO_ACCESS); + } + + return children; +}; diff --git a/src/data/hooks.ts b/src/data/hooks.ts index b6587c48..897fc222 100644 --- a/src/data/hooks.ts +++ b/src/data/hooks.ts @@ -39,5 +39,4 @@ export const useUserAccount = (username?: string) => useQuery({ queryFn: async () => getUserAccount(username), retry: false, enabled: !!username, - refetchOnWindowFocus: false, }); From d31bf6ec4f032af6d1b7f92262b3bf569dc62e3c Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Tue, 31 Mar 2026 08:59:09 -0600 Subject: [PATCH 03/26] feat: expanded row view for user roles added --- src/authz-module/audit-user/CustomCells.tsx | 22 +- .../audit-user/RenderAdminRole.tsx | 24 + .../audit-user/RenderPermissionColumn.tsx | 51 ++ .../audit-user/RenderPermissionInLine.tsx | 56 +++ .../audit-user/UserPermissions.tsx | 90 ++++ src/authz-module/audit-user/index.tsx | 7 + src/authz-module/audit-user/messages.ts | 19 +- src/authz-module/courses/constant.ts | 476 ++++++++++++++++++ src/authz-module/libraries/constants.ts | 186 +++++++ src/index.scss | 5 +- 10 files changed, 928 insertions(+), 8 deletions(-) create mode 100644 src/authz-module/audit-user/RenderAdminRole.tsx create mode 100644 src/authz-module/audit-user/RenderPermissionColumn.tsx create mode 100644 src/authz-module/audit-user/RenderPermissionInLine.tsx create mode 100644 src/authz-module/audit-user/UserPermissions.tsx create mode 100644 src/authz-module/courses/constant.ts create mode 100644 src/authz-module/libraries/constants.ts diff --git a/src/authz-module/audit-user/CustomCells.tsx b/src/authz-module/audit-user/CustomCells.tsx index c8b896c2..9babe1d1 100644 --- a/src/authz-module/audit-user/CustomCells.tsx +++ b/src/authz-module/audit-user/CustomCells.tsx @@ -6,15 +6,26 @@ import { TableCellValue, UserRole } from 'types'; import messages from './messages'; import { getPermissionsCountByRole } from './utils'; -type CellProps = TableCellValue; +interface ExpandableTableRow extends TableCellValue { + row: TableCellValue['row'] & { + isExpanded: boolean; + toggleRowExpanded: () => void; + values: T; + }; +} + +type CellProps = ExpandableTableRow; export const ViewAllPermissionsCell = ({ row }: CellProps) => { const { formatMessage } = useIntl(); return ( console.log('View more clicked for row:', row)} + label={formatMessage( + row.isExpanded + ? messages['authz.user.table.view_all_permissions.link.text.close'] + : messages['authz.user.table.view_all_permissions.link.text.open'], + )} + onClick={() => row.toggleRowExpanded()} iconSrc={ExpandMore} /> ); @@ -35,6 +46,9 @@ export const ActionsCell = ({ row }: CellProps) => { export const PermissionsCell = ({ row }: CellProps) => { const { formatMessage } = useIntl(); // TODO handle permissions length per role + if (row.original.permissions.length === 1) { + return {row.original.permissions[0]}; + } const count = getPermissionsCountByRole(row.original.role); return ( diff --git a/src/authz-module/audit-user/RenderAdminRole.tsx b/src/authz-module/audit-user/RenderAdminRole.tsx new file mode 100644 index 00000000..0d1a249b --- /dev/null +++ b/src/authz-module/audit-user/RenderAdminRole.tsx @@ -0,0 +1,24 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; + +interface RenderAdminRoleProps { + role: string; +} + +const RenderAdminRole = ({ role }: RenderAdminRoleProps) => { + const intl = useIntl(); + // Determine which message to show based on role + const messageKey = role?.toLowerCase().includes('admin') + ? 'authz.user.table.permissions.role.admin' + : 'authz.user.table.permissions.role.staff'; + + return ( +
+

+ {intl.formatMessage(messages[messageKey])} +

+
+ ); +}; + +export default RenderAdminRole; diff --git a/src/authz-module/audit-user/RenderPermissionColumn.tsx b/src/authz-module/audit-user/RenderPermissionColumn.tsx new file mode 100644 index 00000000..b90444fe --- /dev/null +++ b/src/authz-module/audit-user/RenderPermissionColumn.tsx @@ -0,0 +1,51 @@ +import { Icon } from '@openedx/paragon'; +import ResourceTooltip from 'authz-module/components/ResourceTooltip'; +import { RolePermission } from 'types'; + +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} + +
  • + ))} +
+
+)); + +export default RenderPermissionColumn; diff --git a/src/authz-module/audit-user/RenderPermissionInLine.tsx b/src/authz-module/audit-user/RenderPermissionInLine.tsx new file mode 100644 index 00000000..db3f30ae --- /dev/null +++ b/src/authz-module/audit-user/RenderPermissionInLine.tsx @@ -0,0 +1,56 @@ +import { Icon } from '@openedx/paragon'; +import ResourceTooltip from 'authz-module/components/ResourceTooltip'; +import { RolePermission } from 'types'; + +interface ExtendedRolePermission extends RolePermission { + icon: React.ComponentType>; +} + +interface PermissionItem { + key: string; + icon: React.ComponentType>; + label: string; + description: string; + perms: ExtendedRolePermission[]; +} + +interface RenderPermissionInLineProps { + items: PermissionItem[]; +} + +const RenderPermissionInLine = ({ items }: RenderPermissionInLineProps) => ( +
+ {items.map(({ + key, icon, label, description, perms, + }, index) => ( +
+
+ +
{label}
+ +
+
+ {perms.map((perm, i) => ( +
+ + + {perm.label} + +
+ ))} +
+
+ ))} +
+); +export default RenderPermissionInLine; diff --git a/src/authz-module/audit-user/UserPermissions.tsx b/src/authz-module/audit-user/UserPermissions.tsx new file mode 100644 index 00000000..871acb7c --- /dev/null +++ b/src/authz-module/audit-user/UserPermissions.tsx @@ -0,0 +1,90 @@ +import { + courseResourceTypes, + coursePermissions, + rolesObject, +} from '../courses/constant'; +import { + libraryResourceTypes, + libraryPermissions, + rolesLibraryObject, +} from '../libraries/constants'; +import RenderPermissionColumn from './RenderPermissionColumn'; +import RenderPermissionInLine from './RenderPermissionInLine'; +import RenderAdminRole from './RenderAdminRole'; + +interface UserPermissionsProps { + row: { + original: { + role: string; + }; + }; +} + +const UserPermissions = ({ row }: UserPermissionsProps) => { + let roleKey = row?.original?.role; + if (!roleKey) { return null; } + + // validation to show django roles + const normalizedRole = roleKey.trim().toLowerCase(); + if (!normalizedRole.includes('library') && !normalizedRole.includes('course') + && (normalizedRole.includes('admin') || normalizedRole.includes('staff'))) { + return ( +
+ +
+ ); + } + + // Normalize role string to match keys in constants (e.g. "Course Admin" -> "course_admin") + roleKey = roleKey.trim().toLowerCase().replace(/[-\s]+/g, '_'); + const isLibraryRole = roleKey.includes('library'); + const config = isLibraryRole + ? { + resourceTypes: libraryResourceTypes, + permissions: libraryPermissions, + roles: rolesLibraryObject, + } + : { + resourceTypes: courseResourceTypes, + permissions: coursePermissions, + roles: rolesObject, + }; + + const roleObj = config.roles.find(r => r.role === roleKey); + if (!roleObj) { return null; } + + const rolePerms = new Set(roleObj.permissions.map(String)); + // Build resource list with permissions (only once) + const resources = config.resourceTypes + .map(resource => { + const perms = config.permissions.filter( + p => p.resource === resource.key && rolePerms.has(String(p.key)), + ); + return perms.length ? { ...resource, perms } : null; + }) + .filter(Boolean); + + const isSingleRow = resources.length <= 3; + const mid = Math.ceil(resources.length / 2); + const columns = isSingleRow + ? [resources] + : [resources.slice(0, mid), resources.slice(mid)]; + return ( +
+ {isSingleRow + ? + : columns.map((col, index) => ( +
+ +
+ ))} +
+ ); +}; + +export default UserPermissions; diff --git a/src/authz-module/audit-user/index.tsx b/src/authz-module/audit-user/index.tsx index 465eae7d..b8550d0e 100644 --- a/src/authz-module/audit-user/index.tsx +++ b/src/authz-module/audit-user/index.tsx @@ -26,6 +26,7 @@ import { RoleToDelete } from 'types'; import { useToastManager } from '@src/components/ToastManager/ToastManagerContext'; import messages from './messages'; import ConfirmDeletionModal from '../components/ConfirmDeletionModal'; +import UserPermissions from './UserPermissions'; const AuditUserPage = () => { const { formatMessage } = useIntl(); @@ -216,6 +217,12 @@ const AuditUserPage = () => { additionalColumns={additionalColumns} columns={columns} isLoading={isLoadingUserAssignments} + isExpandable + renderRowSubComponent={({ row }) => ( +
+ +
+ )} > diff --git a/src/authz-module/audit-user/messages.ts b/src/authz-module/audit-user/messages.ts index dd3747fe..eb0768ba 100644 --- a/src/authz-module/audit-user/messages.ts +++ b/src/authz-module/audit-user/messages.ts @@ -27,8 +27,13 @@ const messages = defineMessages( defaultMessage: 'Actions', description: 'Header for the actions column in the user table', }, - 'authz.user.table.view_all_permissions.link.text': { - id: 'authz.user.table.view_all_permissions.link.text', + 'authz.user.table.view_all_permissions.link.text.close': { + id: 'authz.user.table.view_all_permissions.link.text.close', + defaultMessage: 'Hide all permissions', + description: 'Text for the link to hide all permissions in the user table', + }, + 'authz.user.table.view_all_permissions.link.text.open': { + id: 'authz.user.table.view_all_permissions.link.text.open', defaultMessage: 'View all permissions', description: 'Text for the link to view all permissions in the user table', }, @@ -42,6 +47,16 @@ const messages = defineMessages( defaultMessage: '{count, plural, one {# permission available} other {# permissions available}}', description: 'Text showing the number of permissions available, with proper pluralization', }, + 'authz.user.table.permissions.role.admin': { + id: 'authz.user.table.permissions.role.admin', + defaultMessage: 'Super Admins have full access to all areas of the platform, including content, settings, and user management. This role is managed at the platform level and cannot be changed from here. To modify it, go to Django Admin.', + description: 'Description for the permissions of the Super Admin role', + }, + 'authz.user.table.permissions.role.staff': { + id: 'authz.user.table.permissions.role.staff ', + defaultMessage: 'Global Staff have access to all areas of the platform, similar to Super Admin, but cannot grant or revoke Super Admin or Global Staff roles to other users. This role is managed at the platform level and cannot be changed from here. To modify it, go to Django Admin.', + description: 'Description for the permissions of the Global Staff role', + }, }, ); diff --git a/src/authz-module/courses/constant.ts b/src/authz-module/courses/constant.ts new file mode 100644 index 00000000..88c572b4 --- /dev/null +++ b/src/authz-module/courses/constant.ts @@ -0,0 +1,476 @@ +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_COURSE_PERMISSIONS = { + VIEW_COURSE: 'courses.view_course', + CREATE_COURSE: 'courses.create_course', + EDIT_COURSE_CONTENT: 'courses.edit_course_content', + PUBLISH_COURSE_CONTENT: 'courses.publish_course_content', + + REVIEW_COURSE_LIBRARY_UPDATES: 'courses.manage_library_updates', + + VIEW_COURSE_UPDATES: 'courses.view_course_updates', + MANAGE_COURSE_UPDATES: 'courses.manage_course_updates', + + VIEW_COURSE_PAGES_RESOURCES: 'courses.view_pages_and_resources', + MANAGE_COURSE_PAGES_RESOURCES: 'courses.manage_pages_and_resources', + + VIEW_COURSE_FILES: 'courses.view_files', + CREATE_COURSE_FILES: 'courses.create_files', + EDIT_COURSE_FILES: 'courses.edit_files', + DELETE_COURSE_FILES: 'courses.delete_files', + + VIEW_COURSE_SCHEDULE: 'courses.view_schedule', + EDIT_COURSE_SCHEDULE: 'courses.edit_schedule', + VIEW_COURSE_DETAILS: 'courses.view_details', + EDIT_COURSE_DETAILS: 'courses.edit_details', + + VIEW_COURSE_GRADING_SETTINGS: 'courses.view_grading_settings', + EDIT_COURSE_GRADING_SETTINGS: 'courses.edit_grading_settings', + + VIEW_COURSE_TEAM: 'courses.view_course_team', + MANAGE_COURSE_TEAM: 'courses.manage_course_team', + MANAGE_COURSE_GROUP_CONFIGURATION: 'courses.manage_group_configurations', + + MANAGE_COURSE_TAGS: 'courses.manage_tags', + MANAGE_COURSE_TAXONOMIES: 'courses.manage_taxonomies', + + MANAGE_COURSE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings', + MANAGE_COURSE_CERTIFICATES: 'courses.manage_certificates', + + IMPORT_COURSE: 'courses.import_course', + EXPORT_COURSE: 'courses.export_course', + EXPORT_COURSE_TAGS: 'courses.export_tags', + + VIEW_COURSE_CHECKLISTS: 'courses.view_checklists', + VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS: 'courses.view_global_staff_and_superadmins', +}; + +export const courseResourceTypes: ResourceMetadata[] = [ + { + key: 'course_tags_taxonomies', label: 'Tags & taxonomies', description: 'Permissions for managing tags and taxonomies 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 course 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 settings', + 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_TAXONOMIES, + resource: 'course_tags_taxonomies', + description: 'Create, edit, and delete taxonomies used to organize course content.', + label: 'Manage taxonomies', + 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, + }, + +]; + +// roles hardcoded, todo: need to add the constants from above in order to merge the different permissions array. +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, + CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAXONOMIES, + ], + 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.', + }, + { + 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.', + }, + +]; + +export const DEFAULT_TOAST_DELAY = 5000; +export const RETRY_TOAST_DELAY = 120_000; // 2 minutes +export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({ + username: 'skeleton', + name: '', + email: '', + roles: [], +})); diff --git a/src/authz-module/libraries/constants.ts b/src/authz-module/libraries/constants.ts new file mode 100644 index 00000000..f15bdeea --- /dev/null +++ b/src/authz-module/libraries/constants.ts @@ -0,0 +1,186 @@ +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.', + }, +]; + +export const DEFAULT_TOAST_DELAY = 5000; +export const RETRY_TOAST_DELAY = 120_000; // 2 minutes +export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({ + username: 'skeleton', + name: '', + email: '', + roles: [], +})); diff --git a/src/index.scss b/src/index.scss index d46a89b5..6a816964 100644 --- a/src/index.scss +++ b/src/index.scss @@ -1,3 +1,4 @@ @use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints; - -@import "~@edx/frontend-component-header/dist/index"; +@use "@openedx/paragon/dist/core.min.css"; +@use "@openedx/paragon/dist/light.min.css"; +@import "~@edx/frontend-component-header/dist/index"; \ No newline at end of file From 80685facf4d0f888af4a55e2904ee35c790e2b37 Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Tue, 31 Mar 2026 09:49:59 -0600 Subject: [PATCH 04/26] chore: border color updated --- src/authz-module/audit-user/UserPermissions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/authz-module/audit-user/UserPermissions.tsx b/src/authz-module/audit-user/UserPermissions.tsx index 871acb7c..e58be918 100644 --- a/src/authz-module/audit-user/UserPermissions.tsx +++ b/src/authz-module/audit-user/UserPermissions.tsx @@ -70,7 +70,7 @@ const UserPermissions = ({ row }: UserPermissionsProps) => { ? [resources] : [resources.slice(0, mid), resources.slice(mid)]; return ( -
+
{isSingleRow ? : columns.map((col, index) => ( From 97b916d14824fb069d07af038c5fbbfcb2ab7dc4 Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Tue, 14 Apr 2026 11:30:27 -0600 Subject: [PATCH 05/26] chore: styles and texts adjusted --- src/authz-module/audit-user/RenderAdminRole.tsx | 2 +- src/authz-module/components/ResourceTooltip.tsx | 2 +- src/authz-module/courses/constant.ts | 4 ++-- src/types.ts | 1 + 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/authz-module/audit-user/RenderAdminRole.tsx b/src/authz-module/audit-user/RenderAdminRole.tsx index 0d1a249b..53bb50a0 100644 --- a/src/authz-module/audit-user/RenderAdminRole.tsx +++ b/src/authz-module/audit-user/RenderAdminRole.tsx @@ -14,7 +14,7 @@ const RenderAdminRole = ({ role }: RenderAdminRoleProps) => { return (
-

+

{intl.formatMessage(messages[messageKey])}

diff --git a/src/authz-module/components/ResourceTooltip.tsx b/src/authz-module/components/ResourceTooltip.tsx index 10fa4d2c..4aceeaac 100644 --- a/src/authz-module/components/ResourceTooltip.tsx +++ b/src/authz-module/components/ResourceTooltip.tsx @@ -24,7 +24,7 @@ const ResourceTooltip = ({ resourceGroup }:ResourceTooltipProps) => ( )} > - + ); diff --git a/src/authz-module/courses/constant.ts b/src/authz-module/courses/constant.ts index 88c572b4..392eea66 100644 --- a/src/authz-module/courses/constant.ts +++ b/src/authz-module/courses/constant.ts @@ -221,7 +221,7 @@ export const coursePermissions: PermissionMetadata[] = [ key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_DETAILS, resource: 'course_schedule_details', description: 'See course information including the course summary, pacing, and prerequisites..', - label: 'View course details', + label: 'View details', icon: RemoveRedEye, }, { @@ -236,7 +236,7 @@ export const coursePermissions: PermissionMetadata[] = [ 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 settings', + label: 'View grading', icon: RemoveRedEye, }, { diff --git a/src/types.ts b/src/types.ts index c1778c0f..9554b8e7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,6 +53,7 @@ export type PermissionMetadata = { resource: string; label?: string; description?: string; + icon?: React.ComponentType>; }; export type Org = { From 151174dc233a2f45ebf70eaf9f36700831a0e36c Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Sun, 19 Apr 2026 13:22:47 -0600 Subject: [PATCH 06/26] feat: refactor to handle the table accordion correctly after rebase --- src/authz-module/audit-user/CustomCells.tsx | 9 ++------- src/authz-module/audit-user/UserPermissions.tsx | 6 ++---- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/authz-module/audit-user/CustomCells.tsx b/src/authz-module/audit-user/CustomCells.tsx index 9babe1d1..20b0a0bc 100644 --- a/src/authz-module/audit-user/CustomCells.tsx +++ b/src/authz-module/audit-user/CustomCells.tsx @@ -4,7 +4,6 @@ import { Delete, ExpandMore } from '@openedx/paragon/icons'; import { IconButton } from '@openedx/paragon'; import { TableCellValue, UserRole } from 'types'; import messages from './messages'; -import { getPermissionsCountByRole } from './utils'; interface ExpandableTableRow extends TableCellValue { row: TableCellValue['row'] & { @@ -45,14 +44,10 @@ export const ActionsCell = ({ row }: CellProps) => { export const PermissionsCell = ({ row }: CellProps) => { const { formatMessage } = useIntl(); - // TODO handle permissions length per role - if (row.original.permissions.length === 1) { - return {row.original.permissions[0]}; - } - const count = getPermissionsCountByRole(row.original.role); + const permissionCount = row.original.permissionCount || 0; return ( - {formatMessage(messages['authz.user.table.permissions.available.count'], { count })} + {formatMessage(messages['authz.user.table.permissions.available.count'], { count: permissionCount })} ); }; diff --git a/src/authz-module/audit-user/UserPermissions.tsx b/src/authz-module/audit-user/UserPermissions.tsx index e58be918..b3cfb1cf 100644 --- a/src/authz-module/audit-user/UserPermissions.tsx +++ b/src/authz-module/audit-user/UserPermissions.tsx @@ -1,3 +1,4 @@ +import { DJANGO_MANAGED_ROLES } from '@src/authz-module/constants'; import { courseResourceTypes, coursePermissions, @@ -24,10 +25,7 @@ const UserPermissions = ({ row }: UserPermissionsProps) => { let roleKey = row?.original?.role; if (!roleKey) { return null; } - // validation to show django roles - const normalizedRole = roleKey.trim().toLowerCase(); - if (!normalizedRole.includes('library') && !normalizedRole.includes('course') - && (normalizedRole.includes('admin') || normalizedRole.includes('staff'))) { + if (DJANGO_MANAGED_ROLES.includes(roleKey)) { return (
From eacd9ee1f97d307d3edfa54f5a4bd4ecf883354d Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Sun, 19 Apr 2026 13:32:24 -0600 Subject: [PATCH 07/26] fix: column width fixed --- src/authz-module/audit-user/UserPermissions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/authz-module/audit-user/UserPermissions.tsx b/src/authz-module/audit-user/UserPermissions.tsx index b3cfb1cf..b7b4d31c 100644 --- a/src/authz-module/audit-user/UserPermissions.tsx +++ b/src/authz-module/audit-user/UserPermissions.tsx @@ -74,7 +74,7 @@ const UserPermissions = ({ row }: UserPermissionsProps) => { : columns.map((col, index) => (
From d2ce422c8bb91fb5c6a17abf65180809862909f9 Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Sun, 19 Apr 2026 13:38:26 -0600 Subject: [PATCH 08/26] fix: imports fix --- src/authz-module/audit-user/RenderPermissionColumn.tsx | 2 +- src/authz-module/audit-user/RenderPermissionInLine.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/authz-module/audit-user/RenderPermissionColumn.tsx b/src/authz-module/audit-user/RenderPermissionColumn.tsx index b90444fe..40429a40 100644 --- a/src/authz-module/audit-user/RenderPermissionColumn.tsx +++ b/src/authz-module/audit-user/RenderPermissionColumn.tsx @@ -1,5 +1,5 @@ import { Icon } from '@openedx/paragon'; -import ResourceTooltip from 'authz-module/components/ResourceTooltip'; +import ResourceTooltip from '../components/ResourceTooltip'; import { RolePermission } from 'types'; interface ExtendedRolePermission extends RolePermission { diff --git a/src/authz-module/audit-user/RenderPermissionInLine.tsx b/src/authz-module/audit-user/RenderPermissionInLine.tsx index db3f30ae..10dc071d 100644 --- a/src/authz-module/audit-user/RenderPermissionInLine.tsx +++ b/src/authz-module/audit-user/RenderPermissionInLine.tsx @@ -1,5 +1,5 @@ import { Icon } from '@openedx/paragon'; -import ResourceTooltip from 'authz-module/components/ResourceTooltip'; +import ResourceTooltip from '../components/ResourceTooltip'; import { RolePermission } from 'types'; interface ExtendedRolePermission extends RolePermission { From 8a5cf69d9659dbcb0ec6016f350f90dfcb41faab Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Sun, 19 Apr 2026 13:53:30 -0600 Subject: [PATCH 09/26] feat: functionality to keep one accordion open --- src/authz-module/audit-user/CustomCells.tsx | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/authz-module/audit-user/CustomCells.tsx b/src/authz-module/audit-user/CustomCells.tsx index 20b0a0bc..7d6c8734 100644 --- a/src/authz-module/audit-user/CustomCells.tsx +++ b/src/authz-module/audit-user/CustomCells.tsx @@ -1,12 +1,14 @@ +import { useContext } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import ViewMoreLink from '@src/authz-module/components/ViewMoreLink'; import { Delete, ExpandMore } from '@openedx/paragon/icons'; -import { IconButton } from '@openedx/paragon'; +import { IconButton, DataTableContext } from '@openedx/paragon'; import { TableCellValue, UserRole } from 'types'; import messages from './messages'; interface ExpandableTableRow extends TableCellValue { row: TableCellValue['row'] & { + id: string; isExpanded: boolean; toggleRowExpanded: () => void; values: T; @@ -17,6 +19,21 @@ type CellProps = ExpandableTableRow; export const ViewAllPermissionsCell = ({ row }: CellProps) => { const { formatMessage } = useIntl(); + const instance = useContext(DataTableContext); + const handleToggleExpanded = () => { + if (!row.isExpanded && instance) { + // Close all other expanded rows first + const expanded = (instance as any)?.state?.expanded || {}; + Object.keys(expanded).forEach(rowId => { + if (rowId !== row.id && expanded[rowId]) { + (instance as any).toggleRowExpanded?.(rowId, false); + } + }); + } + // Toggle the current row + row.toggleRowExpanded(); + }; + return ( { ? messages['authz.user.table.view_all_permissions.link.text.close'] : messages['authz.user.table.view_all_permissions.link.text.open'], )} - onClick={() => row.toggleRowExpanded()} + onClick={handleToggleExpanded} iconSrc={ExpandMore} /> ); From 068ebfb9c0d57b856242978a20e67bf0cd1cbf2f Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Sun, 19 Apr 2026 14:05:33 -0600 Subject: [PATCH 10/26] fix: lint and tests fixed --- src/authz-module/audit-user/CustomCells.tsx | 1 + .../audit-user/RenderPermissionColumn.tsx | 2 +- .../audit-user/RenderPermissionInLine.tsx | 2 +- src/authz-module/audit-user/utils.ts | 7 - .../hooks/useQuerySettings.test.ts | 464 ------------------ .../hooks/useQuerySettings.test.tsx | 2 +- src/authz-module/hooks/useQuerySettings.ts | 96 ---- src/authz-module/hooks/useQuerySettings.tsx | 2 +- 8 files changed, 5 insertions(+), 571 deletions(-) delete mode 100644 src/authz-module/hooks/useQuerySettings.test.ts delete mode 100644 src/authz-module/hooks/useQuerySettings.ts diff --git a/src/authz-module/audit-user/CustomCells.tsx b/src/authz-module/audit-user/CustomCells.tsx index 7d6c8734..29a33844 100644 --- a/src/authz-module/audit-user/CustomCells.tsx +++ b/src/authz-module/audit-user/CustomCells.tsx @@ -51,6 +51,7 @@ export const ActionsCell = ({ row }: CellProps) => { const { formatMessage } = useIntl(); const handleDelete = () => { // TODO: Implement delete functionality + // eslint-disable-next-line no-console console.log('Delete clicked for row:', row); }; diff --git a/src/authz-module/audit-user/RenderPermissionColumn.tsx b/src/authz-module/audit-user/RenderPermissionColumn.tsx index 40429a40..6595c570 100644 --- a/src/authz-module/audit-user/RenderPermissionColumn.tsx +++ b/src/authz-module/audit-user/RenderPermissionColumn.tsx @@ -1,6 +1,6 @@ import { Icon } from '@openedx/paragon'; -import ResourceTooltip from '../components/ResourceTooltip'; import { RolePermission } from 'types'; +import ResourceTooltip from '../components/ResourceTooltip'; interface ExtendedRolePermission extends RolePermission { icon: React.ComponentType>; diff --git a/src/authz-module/audit-user/RenderPermissionInLine.tsx b/src/authz-module/audit-user/RenderPermissionInLine.tsx index 10dc071d..e34e3497 100644 --- a/src/authz-module/audit-user/RenderPermissionInLine.tsx +++ b/src/authz-module/audit-user/RenderPermissionInLine.tsx @@ -1,6 +1,6 @@ import { Icon } from '@openedx/paragon'; -import ResourceTooltip from '../components/ResourceTooltip'; import { RolePermission } from 'types'; +import ResourceTooltip from '../components/ResourceTooltip'; interface ExtendedRolePermission extends RolePermission { icon: React.ComponentType>; diff --git a/src/authz-module/audit-user/utils.ts b/src/authz-module/audit-user/utils.ts index d215d02e..e69de29b 100644 --- a/src/authz-module/audit-user/utils.ts +++ b/src/authz-module/audit-user/utils.ts @@ -1,7 +0,0 @@ -export const getPermissionsCountByRole = (role: string) => { -/* - const roleData = permissionsList.find(item => item.role === role); - return roleData ? roleData.permissions.length : 0; - */ - return Math.floor(Math.random() * 50); -}; diff --git a/src/authz-module/hooks/useQuerySettings.test.ts b/src/authz-module/hooks/useQuerySettings.test.ts deleted file mode 100644 index 6a342fa6..00000000 --- a/src/authz-module/hooks/useQuerySettings.test.ts +++ /dev/null @@ -1,464 +0,0 @@ -import { renderHook, act } from '@testing-library/react'; -import { QuerySettings } from '@src/authz-module/data/api'; -import { useQuerySettings } from './useQuerySettings'; - -describe('useQuerySettings', () => { - const defaultQuerySettings: QuerySettings = { - roles: null, - search: null, - pageSize: 10, - pageIndex: 0, - sortBy: null, - order: null, - scopes: null, - organizations: null, - }; - - it('should initialize with default query settings when no initial settings provided', () => { - const { result } = renderHook(() => useQuerySettings()); - - expect(result.current.querySettings).toEqual(defaultQuerySettings); - expect(typeof result.current.handleTableFetch).toBe('function'); - }); - - it('should initialize with custom initial query settings', () => { - const customInitialSettings: QuerySettings = { - roles: 'admin,editor', - search: 'test-user', - pageSize: 20, - pageIndex: 2, - sortBy: 'username', - order: 'asc', - scopes: null, - organizations: null, - }; - - const { result } = renderHook(() => useQuerySettings(customInitialSettings)); - - expect(result.current.querySettings).toEqual(customInitialSettings); - }); - - it('should update query settings when handleTableFetch is called with new filters', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 15, - pageIndex: 1, - sortBy: [{ id: 'username', desc: false }], - filters: [ - { id: 'role', value: ['admin', 'editor'] }, - { id: 'name', value: 'john' }, - ], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings).toEqual({ - roles: 'admin,editor', - search: 'john', - pageSize: 15, - pageIndex: 1, - sortBy: 'username', - order: 'asc', - scopes: null, - organizations: null, - }); - }); - - it('should handle descending sort order by adding minus prefix', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 10, - pageIndex: 0, - sortBy: [{ id: 'email', desc: true }], - filters: [], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings.order).toBe('desc'); - }); - - it('should convert camelCase sort field to snake_case', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 10, - pageIndex: 0, - sortBy: [{ id: 'firstName', desc: false }], - filters: [], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings.sortBy).toBe('first_name'); - }); - - it('should convert camelCase sort field to snake_case with descending order', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 10, - pageIndex: 0, - sortBy: [{ id: 'lastName', desc: true }], - filters: [], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings.order).toBe('desc'); - }); - - it('should handle empty filters by setting values to null', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 10, - pageIndex: 0, - sortBy: [], - filters: [], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings).toEqual({ - roles: null, - search: null, - pageSize: 10, - pageIndex: 0, - order: null, - sortBy: null, - organizations: null, - scopes: null, - }); - }); - - it('should handle empty roles filter array by setting roles to null', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 10, - pageIndex: 0, - sortBy: [], - filters: [ - { id: 'roles', value: [] }, - { id: 'username', value: '' }, - ], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings).toEqual({ - roles: null, - search: null, - pageSize: 10, - pageIndex: 0, - order: null, - sortBy: null, - organizations: null, - scopes: null, - }); - }); - - it('should handle missing filters by setting default values', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 10, - pageIndex: 0, - sortBy: [], - filters: [ - { id: 'roles', value: undefined }, - { id: 'username', value: undefined }, - ], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings).toEqual({ - roles: null, - search: null, - pageSize: 10, - pageIndex: 0, - order: null, - sortBy: null, - organizations: null, - scopes: null, - }); - }); - - it('should use default pagination values when not provided', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - sortBy: [], - filters: [], - } as any; // Missing pageSize and pageIndex - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings.pageSize).toBe(10); - expect(result.current.querySettings.pageIndex).toBe(0); - }); - - it('should not update state if settings have not changed', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 10, - pageIndex: 0, - sortBy: [], - filters: [], - }; - - const initialSettings = result.current.querySettings; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - // Should be the same object reference since no changes occurred - expect(result.current.querySettings).toBe(initialSettings); - }); - - it('should update state when settings have changed', () => { - const { result } = renderHook(() => useQuerySettings()); - - const initialSettings = result.current.querySettings; - - const tableFilters = { - pageSize: 20, // Different from default - pageIndex: 0, - sortBy: [], - filters: [], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - // Should be a different object reference since pageSize changed - expect(result.current.querySettings).not.toBe(initialSettings); - expect(result.current.querySettings.pageSize).toBe(20); - }); - - it('should handle complex filter combinations', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 25, - pageIndex: 3, - sortBy: [{ id: 'userRole', desc: true }], - filters: [ - { id: 'role', value: ['admin', 'editor', 'viewer'] }, - { id: 'name', value: 'test@example.com' }, - { id: 'otherFilter', value: 'ignored' }, // Should be ignored - { id: 'org', value: ['org1', 'org2'] }, - { id: 'scope', value: ['scope1', 'scope2'] }, - ], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings).toEqual({ - roles: 'admin,editor,viewer', - search: 'test@example.com', - pageSize: 25, - pageIndex: 3, - order: 'desc', - sortBy: 'user_role', - organizations: 'org1,org2', - scopes: 'scope1,scope2', - - }); - }); - - it('should handle multiple camelCase words in sort field', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 10, - pageIndex: 0, - sortBy: [{ id: 'userFirstLastName', desc: false }], - filters: [], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings.sortBy).toBe('user_first_last_name'); - }); - - it('should preserve handleTableFetch function reference across renders', () => { - const { result, rerender } = renderHook(() => useQuerySettings()); - - const initialHandleTableFetch = result.current.handleTableFetch; - - rerender(); - - expect(result.current.handleTableFetch).toBe(initialHandleTableFetch); - }); - - it('should handle whitespace-only search values as provided', () => { - const { result } = renderHook(() => useQuerySettings()); - - const tableFilters = { - pageSize: 10, - pageIndex: 0, - sortBy: [], - filters: [ - { id: 'name', value: ' ' }, // Whitespace only - ], - }; - - act(() => { - result.current.handleTableFetch(tableFilters); - }); - - expect(result.current.querySettings.search).toBe(' '); - }); - - it('should detect changes in roles filter', () => { - const { result } = renderHook(() => useQuerySettings()); - - // First set some roles - act(() => { - result.current.handleTableFetch({ - pageSize: 10, - pageIndex: 0, - sortBy: [], - filters: [{ id: 'role', value: ['admin'] }], - }); - }); - - const settingsAfterFirstUpdate = result.current.querySettings; - - // Then change roles - act(() => { - result.current.handleTableFetch({ - pageSize: 10, - pageIndex: 0, - sortBy: [], - filters: [{ id: 'role', value: ['editor'] }], - }); - }); - - expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate); - expect(result.current.querySettings.roles).toBe('editor'); - }); - - it('should detect changes in search filter', () => { - const { result } = renderHook(() => useQuerySettings()); - - // First set a search term - act(() => { - result.current.handleTableFetch({ - pageSize: 10, - pageIndex: 0, - sortBy: [], - filters: [{ id: 'name', value: 'john' }], - }); - }); - - const settingsAfterFirstUpdate = result.current.querySettings; - - // Then change search term - act(() => { - result.current.handleTableFetch({ - pageSize: 10, - pageIndex: 0, - sortBy: [], - filters: [{ id: 'name', value: 'jane' }], - }); - }); - - expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate); - expect(result.current.querySettings.search).toBe('jane'); - }); - - it('should detect changes in ordering', () => { - const { result } = renderHook(() => useQuerySettings()); - - // First set ordering - act(() => { - result.current.handleTableFetch({ - pageSize: 10, - pageIndex: 0, - sortBy: [{ id: 'username', desc: false }], - filters: [], - }); - }); - - const settingsAfterFirstUpdate = result.current.querySettings; - - // Then change ordering - act(() => { - result.current.handleTableFetch({ - pageSize: 10, - pageIndex: 0, - sortBy: [{ id: 'email', desc: true }], - filters: [], - }); - }); - - expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate); - expect(result.current.querySettings.sortBy).toBe('email'); - expect(result.current.querySettings.order).toBe('desc'); - }); - - it('should detect changes in pageSize', () => { - const { result } = renderHook(() => useQuerySettings()); - - const initialSettings = result.current.querySettings; - - act(() => { - result.current.handleTableFetch({ - pageSize: 50, - pageIndex: 0, - sortBy: [], - filters: [], - }); - }); - - expect(result.current.querySettings).not.toBe(initialSettings); - expect(result.current.querySettings.pageSize).toBe(50); - }); - - it('should detect changes in pageIndex', () => { - const { result } = renderHook(() => useQuerySettings()); - - const initialSettings = result.current.querySettings; - - act(() => { - result.current.handleTableFetch({ - pageSize: 10, - pageIndex: 5, - sortBy: [], - filters: [], - }); - }); - - expect(result.current.querySettings).not.toBe(initialSettings); - expect(result.current.querySettings.pageIndex).toBe(5); - }); -}); diff --git a/src/authz-module/hooks/useQuerySettings.test.tsx b/src/authz-module/hooks/useQuerySettings.test.tsx index 6a342fa6..ee73342e 100644 --- a/src/authz-module/hooks/useQuerySettings.test.tsx +++ b/src/authz-module/hooks/useQuerySettings.test.tsx @@ -1,5 +1,5 @@ import { renderHook, act } from '@testing-library/react'; -import { QuerySettings } from '@src/authz-module/data/api'; +import { QuerySettings } from '../data/api'; import { useQuerySettings } from './useQuerySettings'; describe('useQuerySettings', () => { diff --git a/src/authz-module/hooks/useQuerySettings.ts b/src/authz-module/hooks/useQuerySettings.ts deleted file mode 100644 index 33675c82..00000000 --- a/src/authz-module/hooks/useQuerySettings.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { useCallback, useState } from 'react'; -import { QuerySettings } from '@src/authz-module/data/api'; - -interface DataTableFilters { - pageSize: number; - pageIndex: number; - sortBy: Array<{ id: string; desc: boolean }>; - filters: Array<{ id: string; value: any }>; -} - -interface UseQuerySettingsReturn { - querySettings: QuerySettings; - handleTableFetch: (tableFilters: DataTableFilters) => void; -} - -enum SortOrderKeys { - ASC = 'asc', - DESC = 'desc', -} - -/** - * Custom hook to manage query settings for table data fetching - * Converts DataTable filter/sort/pagination settings to API query parameters - * and manages URL synchronization - * - * @param initialQuerySettings - Initial query settings - * @returns Object containing querySettings and handleTableFetch function - */ -export const useQuerySettings = ( - initialQuerySettings: QuerySettings = { - roles: null, - scopes: null, - organizations: null, - search: null, - pageSize: 10, - pageIndex: 0, - order: null, - sortBy: null, - }, -): UseQuerySettingsReturn => { - const [querySettings, setQuerySettings] = useState(initialQuerySettings); - - const handleTableFetch = useCallback((tableFilters: DataTableFilters) => { - setQuerySettings((prevSettings) => { - // Extract filters - const rolesFilter = tableFilters.filters?.find((filter) => filter.id === 'role')?.value?.join(',') ?? ''; - const searchFilter = tableFilters.filters?.find((filter) => filter.id === 'name')?.value ?? ''; - const organizationsFilter = tableFilters.filters?.find((filter) => filter.id === 'org')?.value?.join(',') ?? ''; - const scopesFilter = tableFilters.filters?.find((filter) => filter.id === 'scope')?.value?.join(',') ?? ''; - - // Extract pagination - const { pageSize = 10, pageIndex = 0 } = tableFilters; - - // Extract and convert sorting - let sortByOption = ''; - let sortByOrder = ''; - if (tableFilters.sortBy?.length) { - sortByOption = tableFilters.sortBy[0]?.id.replace(/([A-Z])/g, '_$1').toLowerCase(); - sortByOrder = tableFilters.sortBy[0]?.desc ? SortOrderKeys.DESC : SortOrderKeys.ASC; - } - - const newQuerySettings: QuerySettings = { - roles: rolesFilter || null, - scopes: scopesFilter || null, - organizations: organizationsFilter || null, - search: searchFilter || null, - sortBy: sortByOption || null, - order: sortByOrder || null, - pageSize, - pageIndex, - }; - - const hasChanged = ( - prevSettings.roles !== newQuerySettings.roles - || prevSettings.scopes !== newQuerySettings.scopes - || prevSettings.organizations !== newQuerySettings.organizations - || prevSettings.search !== newQuerySettings.search - || prevSettings.pageSize !== newQuerySettings.pageSize - || prevSettings.pageIndex !== newQuerySettings.pageIndex - || prevSettings.sortBy !== newQuerySettings.sortBy - || prevSettings.order !== newQuerySettings.order - ); - - if (!hasChanged) { - return prevSettings; // No change, prevent unnecessary update - } - - return newQuerySettings; - }); - }, []); - - return { - querySettings, - handleTableFetch, - }; -}; diff --git a/src/authz-module/hooks/useQuerySettings.tsx b/src/authz-module/hooks/useQuerySettings.tsx index 33675c82..e90849af 100644 --- a/src/authz-module/hooks/useQuerySettings.tsx +++ b/src/authz-module/hooks/useQuerySettings.tsx @@ -1,5 +1,5 @@ import { useCallback, useState } from 'react'; -import { QuerySettings } from '@src/authz-module/data/api'; +import { QuerySettings } from '../data/api'; interface DataTableFilters { pageSize: number; From d867030eeeb80cf6acf65b9239c8b304e609be18 Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Sun, 19 Apr 2026 14:41:51 -0600 Subject: [PATCH 11/26] feat: missing tests added to get coverage --- .../audit-user/CustomCells.test.tsx | 158 ++++++++++++++++++ .../audit-user/RenderAdminRole.test.tsx | 63 +++++++ .../RenderPermissionColumn.test.tsx | 122 ++++++++++++++ .../RenderPermissionInline.test.tsx | 57 +++++++ .../audit-user/UserPermissions.test.tsx | 65 +++++++ .../components/ProtectedRoute.tsx | 46 ----- 6 files changed, 465 insertions(+), 46 deletions(-) create mode 100644 src/authz-module/audit-user/CustomCells.test.tsx create mode 100644 src/authz-module/audit-user/RenderAdminRole.test.tsx create mode 100644 src/authz-module/audit-user/RenderPermissionColumn.test.tsx create mode 100644 src/authz-module/audit-user/RenderPermissionInline.test.tsx create mode 100644 src/authz-module/audit-user/UserPermissions.test.tsx delete mode 100644 src/authz-module/components/ProtectedRoute.tsx diff --git a/src/authz-module/audit-user/CustomCells.test.tsx b/src/authz-module/audit-user/CustomCells.test.tsx new file mode 100644 index 00000000..3867b7a4 --- /dev/null +++ b/src/authz-module/audit-user/CustomCells.test.tsx @@ -0,0 +1,158 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { renderWrapper } from '@src/setupTest'; +import { DataTableContext } from '@openedx/paragon'; +import { ViewAllPermissionsCell, ActionsCell, PermissionsCell } from './CustomCells'; + +describe('CustomCells', () => { + const mockUserRole = { + role: 'course_admin', + org: 'OpenedX', + scope: 'course-v1:OpenedX+DemoX+DemoCourse', + permissionCount: 5, + fullName: 'John Doe', + username: 'johndoe', + email: 'johndoe@example.com', + }; + + const mockCellProps = { + row: { + original: mockUserRole, + id: 'test-row-1', + isExpanded: false, + toggleRowExpanded: jest.fn(), + values: mockUserRole, + }, + }; + + beforeAll(() => { + initializeMockApp({ + authenticatedUser: { + userId: 1, + username: 'testuser', + email: 'test@example.com', + }, + }); + }); + + describe('ViewAllPermissionsCell', () => { + it('renders view more link', () => { + renderWrapper(); + expect(screen.getByText(/view all permissions/i)).toBeInTheDocument(); + }); + + it('displays correct link text', () => { + renderWrapper(); + expect(screen.getByText(/view all permissions/i)).toBeInTheDocument(); + }); + + it('handles toggle expand functionality with accordion behavior', async () => { + const user = userEvent.setup(); + const mockToggleRowExpanded = jest.fn(); + const mockInstance = { + state: { + expanded: { + 'other-row-1': true, + 'other-row-2': true, + }, + }, + toggleRowExpanded: mockToggleRowExpanded, + }; + + const propsWithToggle = { + row: { + ...mockCellProps.row, + toggleRowExpanded: jest.fn(), + }, + }; + + renderWrapper( + + + , + ); + + const toggleButton = screen.getByText(/view all permissions/i); + await user.click(toggleButton); + + // Should close other expanded rows first + expect(mockToggleRowExpanded).toHaveBeenCalledWith('other-row-1', false); + expect(mockToggleRowExpanded).toHaveBeenCalledWith('other-row-2', false); + // Should toggle the current row + expect(propsWithToggle.row.toggleRowExpanded).toHaveBeenCalled(); + }); + + it('toggles row without closing others when row is already expanded', async () => { + const user = userEvent.setup(); + const mockToggleRowExpanded = jest.fn(); + const mockInstance = { + state: { + expanded: { + 'other-row-1': true, + }, + }, + toggleRowExpanded: mockToggleRowExpanded, + }; + + const propsWithExpandedRow = { + row: { + ...mockCellProps.row, + isExpanded: true, + toggleRowExpanded: jest.fn(), + }, + }; + + renderWrapper( + + + , + ); + + const toggleButton = screen.getByText(/hide all permissions/i); + await user.click(toggleButton); + + // Should NOT close other expanded rows when current row is already expanded + expect(mockToggleRowExpanded).not.toHaveBeenCalled(); + // Should still toggle the current row + expect(propsWithExpandedRow.row.toggleRowExpanded).toHaveBeenCalled(); + }); + }); + + describe('ActionsCell', () => { + it('renders delete button', () => { + renderWrapper(); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + }); + + describe('PermissionsCell', () => { + it('renders permission count', () => { + renderWrapper(); + expect(screen.getByText(/5/)).toBeInTheDocument(); + }); + + it('handles zero permission count', () => { + const propsWithZero = { + row: { + ...mockCellProps.row, + original: { ...mockUserRole, permissionCount: 0 }, + }, + }; + renderWrapper(); + expect(screen.getByText(/0/)).toBeInTheDocument(); + }); + + it('handles missing permission count', () => { + const { permissionCount, ...userRoleWithoutCount } = mockUserRole; + const propsWithoutCount = { + row: { + ...mockCellProps.row, + original: userRoleWithoutCount as any, + }, + }; + renderWrapper(); + expect(screen.getByText(/0/)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/authz-module/audit-user/RenderAdminRole.test.tsx b/src/authz-module/audit-user/RenderAdminRole.test.tsx new file mode 100644 index 00000000..fe3f3a49 --- /dev/null +++ b/src/authz-module/audit-user/RenderAdminRole.test.tsx @@ -0,0 +1,63 @@ +import { screen } from '@testing-library/react'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { renderWrapper } from '@src/setupTest'; +import RenderAdminRole from './RenderAdminRole'; + +describe('RenderAdminRole', () => { + const adminRole = 'course_admin'; + const superuserRole = 'django.superuser'; + const staffRole = 'django.globalstaff'; + const instructorRole = 'instructor'; + const emptyRole = ''; + + beforeAll(() => { + initializeMockApp({ + authenticatedUser: { + userId: 1, + username: 'testuser', + email: 'test@example.com', + }, + }); + }); + + it('renders without crashing', () => { + const { container } = renderWrapper(); + expect(container.querySelector('.mb-4')).toBeInTheDocument(); + }); + + it('displays admin message for roles containing admin', () => { + renderWrapper(); + expect(screen.getByText(/super admins have full access/i)).toBeInTheDocument(); + }); + + it('displays staff message for superuser role', () => { + renderWrapper(); + expect(screen.getByText(/global staff have access/i)).toBeInTheDocument(); + }); + + it('displays staff message for roles not containing admin', () => { + renderWrapper(); + expect(screen.getByText(/global staff have access/i)).toBeInTheDocument(); + }); + + it('displays staff message for instructor role', () => { + renderWrapper(); + expect(screen.getByText(/global staff have access/i)).toBeInTheDocument(); + }); + + it('handles undefined role gracefully', () => { + renderWrapper(); + expect(screen.getByText(/global staff have access/i)).toBeInTheDocument(); + }); + + it('handles empty role string', () => { + renderWrapper(); + expect(screen.getByText(/global staff have access/i)).toBeInTheDocument(); + }); + + it('has correct CSS classes', () => { + const { container } = renderWrapper(); + const paragraph = container.querySelector('p'); + expect(paragraph).toHaveClass('mb-0', 'text-primary-300', 'font-weight-light'); + }); +}); diff --git a/src/authz-module/audit-user/RenderPermissionColumn.test.tsx b/src/authz-module/audit-user/RenderPermissionColumn.test.tsx new file mode 100644 index 00000000..f14decfe --- /dev/null +++ b/src/authz-module/audit-user/RenderPermissionColumn.test.tsx @@ -0,0 +1,122 @@ +import { screen } from '@testing-library/react'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { renderWrapper } from '@src/setupTest'; +import { BookOpen, Person } from '@openedx/paragon/icons'; +import RenderPermissionColumn from './RenderPermissionColumn'; + +describe('RenderPermissionColumn', () => { + beforeAll(() => { + initializeMockApp({ + authenticatedUser: { + userId: 1, + username: 'testuser', + email: 'test@example.com', + }, + }); + }); + + const mockItems = [ + { + key: 'course_content', + icon: BookOpen, + label: 'Course Content', + description: 'Manage course content', + perms: [ + { + key: 'view_course', + resource: 'course_content', + label: 'View Course', + description: 'View course content', + actionKey: 'view_course', + icon: BookOpen, + disabled: false, + }, + { + key: 'edit_course', + resource: 'course_content', + label: 'Edit Course', + description: 'Edit course content', + actionKey: 'edit_course', + icon: Person, + disabled: false, + }, + ], + }, + { + key: 'user_management', + icon: Person, + label: 'User Management', + description: 'Manage users', + perms: [ + { + key: 'view_users', + resource: 'user_management', + label: 'View Users', + description: 'View user information', + actionKey: 'view_users', + icon: Person, + disabled: false, + }, + ], + }, + ]; + + it('renders without crashing', () => { + const { container } = renderWrapper(); + expect(container.querySelector('.mb-4')).toBeInTheDocument(); + }); + + it('displays resource labels', () => { + renderWrapper(); + expect(screen.getByText('Course Content')).toBeInTheDocument(); + expect(screen.getByText('User Management')).toBeInTheDocument(); + }); + + it('displays permission labels', () => { + renderWrapper(); + expect(screen.getByText('View Course')).toBeInTheDocument(); + expect(screen.getByText('Edit Course')).toBeInTheDocument(); + expect(screen.getByText('View Users')).toBeInTheDocument(); + }); + + it('renders multiple resource groups', () => { + renderWrapper(); + const resourceGroups = screen.getAllByText(/Course Content|User Management/); + expect(resourceGroups).toHaveLength(2); + }); + + it('renders permissions in horizontal list', () => { + const { container } = renderWrapper(); + const permissionsList = container.querySelector('ul.d-flex'); + expect(permissionsList).toBeInTheDocument(); + }); + + it('applies correct CSS classes to permission items', () => { + const { container } = renderWrapper(); + const permissionItem = container.querySelector('li.d-flex'); + expect(permissionItem).toHaveClass('align-items-center', 'text-primary-400'); + }); + + it('handles empty items array', () => { + const { container } = renderWrapper(); + expect(container.firstChild).toBeNull(); + }); + + it('handles single item with single permission', () => { + const singleItem = [mockItems[1]]; // User Management item with one permission + renderWrapper(); + expect(screen.getByText('User Management')).toBeInTheDocument(); + expect(screen.getByText('View Users')).toBeInTheDocument(); + }); + + it('applies border styling to multiple permissions correctly', () => { + const { container } = renderWrapper(); + const permissionItems = container.querySelectorAll('li'); + // First item should have border-right and pr-2 + expect(permissionItems[0]).toHaveClass('border-right', 'pr-2'); + // Last item should not have border-right + expect(permissionItems[1]).not.toHaveClass('border-right'); + // Second item should have pl-2 + expect(permissionItems[1]).toHaveClass('pl-2'); + }); +}); diff --git a/src/authz-module/audit-user/RenderPermissionInline.test.tsx b/src/authz-module/audit-user/RenderPermissionInline.test.tsx new file mode 100644 index 00000000..1a70799b --- /dev/null +++ b/src/authz-module/audit-user/RenderPermissionInline.test.tsx @@ -0,0 +1,57 @@ +import { screen } from '@testing-library/react'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { renderWrapper } from '@src/setupTest'; +import { BookOpen } from '@openedx/paragon/icons'; +import RenderPermissionInLine from './RenderPermissionInLine'; + +describe('RenderPermissionInLine', () => { + beforeAll(() => { + initializeMockApp({ + authenticatedUser: { + userId: 1, + username: 'testuser', + email: 'test@example.com', + }, + }); + }); + + const mockItems = [ + { + key: 'course_content', + icon: BookOpen, + label: 'Course Content', + description: 'Manage course content', + perms: [ + { + key: 'view_course', + resource: 'course_content', + label: 'View Course', + description: 'View course content', + actionKey: 'view_course', + icon: BookOpen, + disabled: false, + }, + ], + }, + ]; + + it('renders without crashing', () => { + const { container } = renderWrapper(); + expect(container.querySelector('.d-flex')).toBeInTheDocument(); + }); + + it('displays the resource label', () => { + renderWrapper(); + expect(screen.getByText('Course Content')).toBeInTheDocument(); + }); + + it('displays permission labels', () => { + renderWrapper(); + expect(screen.getByText('View Course')).toBeInTheDocument(); + }); + + it('renders empty when no items provided', () => { + const { container } = renderWrapper(); + expect(container.querySelector('.d-flex')).toBeInTheDocument(); + }); +}); diff --git a/src/authz-module/audit-user/UserPermissions.test.tsx b/src/authz-module/audit-user/UserPermissions.test.tsx new file mode 100644 index 00000000..67f5659f --- /dev/null +++ b/src/authz-module/audit-user/UserPermissions.test.tsx @@ -0,0 +1,65 @@ +import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { renderWrapper } from '@src/setupTest'; +import UserPermissions from './UserPermissions'; + +describe('UserPermissions', () => { + beforeAll(() => { + initializeMockApp({ + authenticatedUser: { + userId: 1, + username: 'testuser', + email: 'test@example.com', + }, + }); + }); + + it('renders Django managed roles', () => { + const props = { + row: { + original: { + role: 'django.superuser', + }, + }, + }; + + const { container } = renderWrapper(); + expect(container.querySelector('.d-flex')).toBeInTheDocument(); + }); + + it('renders regular course roles', () => { + const props = { + row: { + original: { + role: 'course_admin', + }, + }, + }; + + const { container } = renderWrapper(); + expect(container.querySelector('.d-flex')).toBeInTheDocument(); + }); + + it('returns null when role is empty', () => { + const props = { + row: { + original: { + role: '', + }, + }, + }; + + const { container } = renderWrapper(); + expect(container.firstChild).toBeNull(); + }); + + it('returns null when row data is invalid', () => { + const props = { + row: { + original: undefined as any, + }, + }; + + const { container } = renderWrapper(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/src/authz-module/components/ProtectedRoute.tsx b/src/authz-module/components/ProtectedRoute.tsx deleted file mode 100644 index 7db6a64d..00000000 --- a/src/authz-module/components/ProtectedRoute.tsx +++ /dev/null @@ -1,46 +0,0 @@ -// src/components/ProtectedRoute.tsx -import { ReactElement } from 'react'; -import { useValidateUserPermissions } from '@src/data/hooks'; -import LoadingPage from 'components/LoadingPage'; -import { CustomErrors } from 'constants'; -import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from 'authz-module/constants'; - -const REQUIRED_USER_PERMISSIONS = [ - CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, - CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, - CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, - CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, -]; - -type ProtectedRouteProps = { - children: ReactElement; - fallback?: ReactElement; -}; - -export const ProtectedRoute = ({ - children, - fallback, -}: ProtectedRouteProps) => { - // TODO: which scope? - const requiredPermissions = REQUIRED_USER_PERMISSIONS.map(action => ({ action, scope: '*' })); - const { data: permissions, isLoading, isError } = useValidateUserPermissions(requiredPermissions); - - if (isLoading) { - return ; - } - - if (isError && fallback) { - return fallback; - } - if (isError) { - throw new Error(CustomErrors.SERVER_ERROR); - } - - const hasAccess = permissions.some(permission => permission.allowed); - - if (!hasAccess) { - throw new Error(CustomErrors.NO_ACCESS); - } - - return children; -}; From 6486da082ce79385e91b05fe8620fe07a65f112c Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Sun, 19 Apr 2026 14:53:37 -0600 Subject: [PATCH 12/26] chore: unnecessary container removed --- src/authz-module/audit-user/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/authz-module/audit-user/index.tsx b/src/authz-module/audit-user/index.tsx index b8550d0e..44ffd009 100644 --- a/src/authz-module/audit-user/index.tsx +++ b/src/authz-module/audit-user/index.tsx @@ -219,9 +219,7 @@ const AuditUserPage = () => { isLoading={isLoadingUserAssignments} isExpandable renderRowSubComponent={({ row }) => ( -
-
)} > From 0cd3d6587b7aa1762fbf1570ba7aaec54c7bdc5e Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Sun, 19 Apr 2026 15:02:47 -0600 Subject: [PATCH 13/26] chore: removed space --- src/authz-module/audit-user/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/authz-module/audit-user/index.tsx b/src/authz-module/audit-user/index.tsx index 44ffd009..f575e30e 100644 --- a/src/authz-module/audit-user/index.tsx +++ b/src/authz-module/audit-user/index.tsx @@ -219,7 +219,7 @@ const AuditUserPage = () => { isLoading={isLoadingUserAssignments} isExpandable renderRowSubComponent={({ row }) => ( - + )} > From a67a7b80dcf3672ae20cc6a6761f941d148dfb0a Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Sun, 19 Apr 2026 15:10:47 -0600 Subject: [PATCH 14/26] fix: fix in container --- .../audit-user/RenderPermissionColumn.tsx | 4 +-- .../audit-user/UserPermissions.tsx | 25 ++++++++++++------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/authz-module/audit-user/RenderPermissionColumn.tsx b/src/authz-module/audit-user/RenderPermissionColumn.tsx index 6595c570..bdeed441 100644 --- a/src/authz-module/audit-user/RenderPermissionColumn.tsx +++ b/src/authz-module/audit-user/RenderPermissionColumn.tsx @@ -21,7 +21,7 @@ interface RenderPermissionColumnProps { const RenderPermissionColumn = ({ items }: RenderPermissionColumnProps) => items.map(({ key, icon, label, description, perms, }) => ( -
+
{label}
@@ -31,7 +31,7 @@ const RenderPermissionColumn = ({ items }: RenderPermissionColumnProps) => items }} />
-
    +
      {perms.map((perm, index) => (
    • {
      {isSingleRow ? - : columns.map((col, index) => ( -
      - + : ( +
      + {columns.map((col, index) => ( +
      + + {index === 0 && ( +
      + )} +
      + ))}
      - ))} + )}
      ); }; From 8647ea4c289f02cef0bd49c41ce2e5092c74f47e Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Sun, 19 Apr 2026 15:44:52 -0600 Subject: [PATCH 15/26] feat: missing test added --- .../audit-user/UserPermissions.test.tsx | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/authz-module/audit-user/UserPermissions.test.tsx b/src/authz-module/audit-user/UserPermissions.test.tsx index 67f5659f..1904c6cd 100644 --- a/src/authz-module/audit-user/UserPermissions.test.tsx +++ b/src/authz-module/audit-user/UserPermissions.test.tsx @@ -1,6 +1,15 @@ import { initializeMockApp } from '@edx/frontend-platform/testing'; import { renderWrapper } from '@src/setupTest'; import UserPermissions from './UserPermissions'; +import * as coursesConstants from '../courses/constant'; + +jest.mock('./RenderPermissionInLine', () => ( + jest.fn(({ items }) => ( +
      + Mocked RenderPermissionInLine +
      + )) +)); describe('UserPermissions', () => { beforeAll(() => { @@ -13,6 +22,10 @@ describe('UserPermissions', () => { }); }); + beforeEach(() => { + jest.clearAllMocks(); + }); + it('renders Django managed roles', () => { const props = { row: { @@ -62,4 +75,44 @@ describe('UserPermissions', () => { const { container } = renderWrapper(); expect(container.firstChild).toBeNull(); }); + + it('renders RenderPermissionInLine for single row layout', () => { + const mockRoleObject = [ + { + role: 'test_viewer', + permissions: [1, 50, 100], + userCount: 1, + name: 'Test Viewer', + description: 'Test role with limited permissions', + }, + ]; + + const originalRolesObject = coursesConstants.rolesObject; + (coursesConstants as any).rolesObject = [...originalRolesObject, ...mockRoleObject]; + + const props = { + row: { + original: { + role: 'test_viewer', + }, + }, + }; + + const { getByTestId } = renderWrapper(); + expect(getByTestId('render-permission-inline')).toBeInTheDocument(); + (coursesConstants as any).rolesObject = originalRolesObject; + }); + + it('returns null when role is not found in rolesObject (line 52 coverage)', () => { + const props = { + row: { + original: { + role: 'unknown_role', + }, + }, + }; + + const { container } = renderWrapper(); + expect(container.firstChild).toBeNull(); + }); }); From 6bc59dc2036808f9a3cd530a1c7cb3a05643d80d Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Mon, 20 Apr 2026 14:38:46 -0600 Subject: [PATCH 16/26] fix: labels and styles adjusted to match with design --- src/authz-module/audit-user/CustomCells.tsx | 15 +++++++++++++++ .../audit-user/RenderAdminRole.test.tsx | 16 ++++++++++++++-- src/authz-module/audit-user/RenderAdminRole.tsx | 8 +++----- src/authz-module/audit-user/messages.ts | 10 ++++++++++ src/authz-module/index.scss | 9 ++------- 5 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/authz-module/audit-user/CustomCells.tsx b/src/authz-module/audit-user/CustomCells.tsx index 29a33844..37f401ab 100644 --- a/src/authz-module/audit-user/CustomCells.tsx +++ b/src/authz-module/audit-user/CustomCells.tsx @@ -63,6 +63,21 @@ export const ActionsCell = ({ row }: CellProps) => { export const PermissionsCell = ({ row }: CellProps) => { const { formatMessage } = useIntl(); const permissionCount = row.original.permissionCount || 0; + const role = row.original.role || ''; + if (role === 'django.superuser') { + return ( + + {formatMessage(messages['authz.user.table.permissions.total.access'])} + + ); + } + if (role === 'django.globalstaff') { + return ( + + {formatMessage(messages['authz.user.table.permissions.partial.access'])} + + ); + } return ( {formatMessage(messages['authz.user.table.permissions.available.count'], { count: permissionCount })} diff --git a/src/authz-module/audit-user/RenderAdminRole.test.tsx b/src/authz-module/audit-user/RenderAdminRole.test.tsx index fe3f3a49..d903a922 100644 --- a/src/authz-module/audit-user/RenderAdminRole.test.tsx +++ b/src/authz-module/audit-user/RenderAdminRole.test.tsx @@ -9,6 +9,8 @@ describe('RenderAdminRole', () => { const staffRole = 'django.globalstaff'; const instructorRole = 'instructor'; const emptyRole = ''; + const mixedCaseAdminRole = 'Library_Admin'; + const regularRole = 'course_staff'; beforeAll(() => { initializeMockApp({ @@ -22,7 +24,7 @@ describe('RenderAdminRole', () => { it('renders without crashing', () => { const { container } = renderWrapper(); - expect(container.querySelector('.mb-4')).toBeInTheDocument(); + expect(container.querySelector('.mb-0')).toBeInTheDocument(); }); it('displays admin message for roles containing admin', () => { @@ -35,7 +37,7 @@ describe('RenderAdminRole', () => { expect(screen.getByText(/global staff have access/i)).toBeInTheDocument(); }); - it('displays staff message for roles not containing admin', () => { + it('displays staff message for globalstaff role', () => { renderWrapper(); expect(screen.getByText(/global staff have access/i)).toBeInTheDocument(); }); @@ -55,6 +57,16 @@ describe('RenderAdminRole', () => { expect(screen.getByText(/global staff have access/i)).toBeInTheDocument(); }); + it('displays admin message for mixed case admin role', () => { + renderWrapper(); + expect(screen.getByText(/super admins have full access/i)).toBeInTheDocument(); + }); + + it('displays staff message for regular role without admin', () => { + renderWrapper(); + expect(screen.getByText(/global staff have access/i)).toBeInTheDocument(); + }); + it('has correct CSS classes', () => { const { container } = renderWrapper(); const paragraph = container.querySelector('p'); diff --git a/src/authz-module/audit-user/RenderAdminRole.tsx b/src/authz-module/audit-user/RenderAdminRole.tsx index 53bb50a0..006fc7b8 100644 --- a/src/authz-module/audit-user/RenderAdminRole.tsx +++ b/src/authz-module/audit-user/RenderAdminRole.tsx @@ -13,11 +13,9 @@ const RenderAdminRole = ({ role }: RenderAdminRoleProps) => { : 'authz.user.table.permissions.role.staff'; return ( -
      -

      - {intl.formatMessage(messages[messageKey])} -

      -
      +

      + {intl.formatMessage(messages[messageKey])} +

      ); }; diff --git a/src/authz-module/audit-user/messages.ts b/src/authz-module/audit-user/messages.ts index eb0768ba..0a35df09 100644 --- a/src/authz-module/audit-user/messages.ts +++ b/src/authz-module/audit-user/messages.ts @@ -47,6 +47,16 @@ const messages = defineMessages( defaultMessage: '{count, plural, one {# permission available} other {# permissions available}}', description: 'Text showing the number of permissions available, with proper pluralization', }, + 'authz.user.table.permissions.total.access': { + id: 'authz.user.table.permissions.total.access', + defaultMessage: 'Total access', + description: 'Label indicating Super Admin has total access to all permissions', + }, + 'authz.user.table.permissions.partial.access': { + id: 'authz.user.table.permissions.partial.access', + defaultMessage: 'Partial access', + description: 'Label indicating Global Staff has partial access to permissions', + }, 'authz.user.table.permissions.role.admin': { id: 'authz.user.table.permissions.role.admin', defaultMessage: 'Super Admins have full access to all areas of the platform, including content, settings, and user management. This role is managed at the platform level and cannot be changed from here. To modify it, go to Django Admin.', diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss index 34c408b4..83de1ed8 100644 --- a/src/authz-module/index.scss +++ b/src/authz-module/index.scss @@ -10,8 +10,8 @@ .filters .dropdown-toggle::after { display: none !important; } - .pgn__data-table tr:has(td[data-role="Super Admin"]), - .pgn__data-table tr:has(td[data-role="Global Staff"]) { + .pgn__data-table tr:has(span[data-role="Super Admin"]), + .pgn__data-table tr:has(span[data-role="Global Staff"]) { background-color: var(--pgn-color-primary-200); } @@ -21,11 +21,6 @@ width: 0; } - .pgn__data-table tr:has(td[data-role="Super Admin"]), - .pgn__data-table tr:has(td[data-role="Global Staff"]) { - background-color: var(--pgn-color-primary-200); - } - .tab-content { background-color: var(--pgn-color-light-200); } From f4e1d8ef43b1921e48acc6e76717dc0e064b6ef7 Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Mon, 20 Apr 2026 14:58:28 -0600 Subject: [PATCH 17/26] chore: refactor to avoid duplicated code --- .../audit-user/CustomCells.test.tsx | 39 +---------------- src/authz-module/audit-user/CustomCells.tsx | 42 +------------------ 2 files changed, 3 insertions(+), 78 deletions(-) diff --git a/src/authz-module/audit-user/CustomCells.test.tsx b/src/authz-module/audit-user/CustomCells.test.tsx index 3867b7a4..ae497a81 100644 --- a/src/authz-module/audit-user/CustomCells.test.tsx +++ b/src/authz-module/audit-user/CustomCells.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import { initializeMockApp } from '@edx/frontend-platform/testing'; import { renderWrapper } from '@src/setupTest'; import { DataTableContext } from '@openedx/paragon'; -import { ViewAllPermissionsCell, ActionsCell, PermissionsCell } from './CustomCells'; +import { ViewAllPermissionsCell } from './CustomCells'; describe('CustomCells', () => { const mockUserRole = { @@ -118,41 +118,4 @@ describe('CustomCells', () => { expect(propsWithExpandedRow.row.toggleRowExpanded).toHaveBeenCalled(); }); }); - - describe('ActionsCell', () => { - it('renders delete button', () => { - renderWrapper(); - expect(screen.getByRole('button')).toBeInTheDocument(); - }); - }); - - describe('PermissionsCell', () => { - it('renders permission count', () => { - renderWrapper(); - expect(screen.getByText(/5/)).toBeInTheDocument(); - }); - - it('handles zero permission count', () => { - const propsWithZero = { - row: { - ...mockCellProps.row, - original: { ...mockUserRole, permissionCount: 0 }, - }, - }; - renderWrapper(); - expect(screen.getByText(/0/)).toBeInTheDocument(); - }); - - it('handles missing permission count', () => { - const { permissionCount, ...userRoleWithoutCount } = mockUserRole; - const propsWithoutCount = { - row: { - ...mockCellProps.row, - original: userRoleWithoutCount as any, - }, - }; - renderWrapper(); - expect(screen.getByText(/0/)).toBeInTheDocument(); - }); - }); }); diff --git a/src/authz-module/audit-user/CustomCells.tsx b/src/authz-module/audit-user/CustomCells.tsx index 37f401ab..b1d82d90 100644 --- a/src/authz-module/audit-user/CustomCells.tsx +++ b/src/authz-module/audit-user/CustomCells.tsx @@ -1,8 +1,8 @@ import { useContext } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import ViewMoreLink from '@src/authz-module/components/ViewMoreLink'; -import { Delete, ExpandMore } from '@openedx/paragon/icons'; -import { IconButton, DataTableContext } from '@openedx/paragon'; +import { ExpandMore } from '@openedx/paragon/icons'; +import { DataTableContext } from '@openedx/paragon'; import { TableCellValue, UserRole } from 'types'; import messages from './messages'; @@ -46,41 +46,3 @@ export const ViewAllPermissionsCell = ({ row }: CellProps) => { /> ); }; - -export const ActionsCell = ({ row }: CellProps) => { - const { formatMessage } = useIntl(); - const handleDelete = () => { - // TODO: Implement delete functionality - // eslint-disable-next-line no-console - console.log('Delete clicked for row:', row); - }; - - return ( - - ); -}; - -export const PermissionsCell = ({ row }: CellProps) => { - const { formatMessage } = useIntl(); - const permissionCount = row.original.permissionCount || 0; - const role = row.original.role || ''; - if (role === 'django.superuser') { - return ( - - {formatMessage(messages['authz.user.table.permissions.total.access'])} - - ); - } - if (role === 'django.globalstaff') { - return ( - - {formatMessage(messages['authz.user.table.permissions.partial.access'])} - - ); - } - return ( - - {formatMessage(messages['authz.user.table.permissions.available.count'], { count: permissionCount })} - - ); -}; From 4262dd0457714ce6c3d6deb918199d76013dec16 Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Mon, 20 Apr 2026 15:08:35 -0600 Subject: [PATCH 18/26] fix: refactor of TableCells component --- .../audit-user/CustomCells.test.tsx | 121 ------------------ src/authz-module/audit-user/CustomCells.tsx | 48 ------- src/authz-module/audit-user/messages.ts | 10 -- .../components/TableCells.test.tsx | 106 ++++++++++++--- src/authz-module/components/TableCells.tsx | 35 ++++- src/authz-module/components/messages.ts | 10 ++ 6 files changed, 128 insertions(+), 202 deletions(-) delete mode 100644 src/authz-module/audit-user/CustomCells.test.tsx delete mode 100644 src/authz-module/audit-user/CustomCells.tsx diff --git a/src/authz-module/audit-user/CustomCells.test.tsx b/src/authz-module/audit-user/CustomCells.test.tsx deleted file mode 100644 index ae497a81..00000000 --- a/src/authz-module/audit-user/CustomCells.test.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { initializeMockApp } from '@edx/frontend-platform/testing'; -import { renderWrapper } from '@src/setupTest'; -import { DataTableContext } from '@openedx/paragon'; -import { ViewAllPermissionsCell } from './CustomCells'; - -describe('CustomCells', () => { - const mockUserRole = { - role: 'course_admin', - org: 'OpenedX', - scope: 'course-v1:OpenedX+DemoX+DemoCourse', - permissionCount: 5, - fullName: 'John Doe', - username: 'johndoe', - email: 'johndoe@example.com', - }; - - const mockCellProps = { - row: { - original: mockUserRole, - id: 'test-row-1', - isExpanded: false, - toggleRowExpanded: jest.fn(), - values: mockUserRole, - }, - }; - - beforeAll(() => { - initializeMockApp({ - authenticatedUser: { - userId: 1, - username: 'testuser', - email: 'test@example.com', - }, - }); - }); - - describe('ViewAllPermissionsCell', () => { - it('renders view more link', () => { - renderWrapper(); - expect(screen.getByText(/view all permissions/i)).toBeInTheDocument(); - }); - - it('displays correct link text', () => { - renderWrapper(); - expect(screen.getByText(/view all permissions/i)).toBeInTheDocument(); - }); - - it('handles toggle expand functionality with accordion behavior', async () => { - const user = userEvent.setup(); - const mockToggleRowExpanded = jest.fn(); - const mockInstance = { - state: { - expanded: { - 'other-row-1': true, - 'other-row-2': true, - }, - }, - toggleRowExpanded: mockToggleRowExpanded, - }; - - const propsWithToggle = { - row: { - ...mockCellProps.row, - toggleRowExpanded: jest.fn(), - }, - }; - - renderWrapper( - - - , - ); - - const toggleButton = screen.getByText(/view all permissions/i); - await user.click(toggleButton); - - // Should close other expanded rows first - expect(mockToggleRowExpanded).toHaveBeenCalledWith('other-row-1', false); - expect(mockToggleRowExpanded).toHaveBeenCalledWith('other-row-2', false); - // Should toggle the current row - expect(propsWithToggle.row.toggleRowExpanded).toHaveBeenCalled(); - }); - - it('toggles row without closing others when row is already expanded', async () => { - const user = userEvent.setup(); - const mockToggleRowExpanded = jest.fn(); - const mockInstance = { - state: { - expanded: { - 'other-row-1': true, - }, - }, - toggleRowExpanded: mockToggleRowExpanded, - }; - - const propsWithExpandedRow = { - row: { - ...mockCellProps.row, - isExpanded: true, - toggleRowExpanded: jest.fn(), - }, - }; - - renderWrapper( - - - , - ); - - const toggleButton = screen.getByText(/hide all permissions/i); - await user.click(toggleButton); - - // Should NOT close other expanded rows when current row is already expanded - expect(mockToggleRowExpanded).not.toHaveBeenCalled(); - // Should still toggle the current row - expect(propsWithExpandedRow.row.toggleRowExpanded).toHaveBeenCalled(); - }); - }); -}); diff --git a/src/authz-module/audit-user/CustomCells.tsx b/src/authz-module/audit-user/CustomCells.tsx deleted file mode 100644 index b1d82d90..00000000 --- a/src/authz-module/audit-user/CustomCells.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useContext } from 'react'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import ViewMoreLink from '@src/authz-module/components/ViewMoreLink'; -import { ExpandMore } from '@openedx/paragon/icons'; -import { DataTableContext } from '@openedx/paragon'; -import { TableCellValue, UserRole } from 'types'; -import messages from './messages'; - -interface ExpandableTableRow extends TableCellValue { - row: TableCellValue['row'] & { - id: string; - isExpanded: boolean; - toggleRowExpanded: () => void; - values: T; - }; -} - -type CellProps = ExpandableTableRow; - -export const ViewAllPermissionsCell = ({ row }: CellProps) => { - const { formatMessage } = useIntl(); - const instance = useContext(DataTableContext); - const handleToggleExpanded = () => { - if (!row.isExpanded && instance) { - // Close all other expanded rows first - const expanded = (instance as any)?.state?.expanded || {}; - Object.keys(expanded).forEach(rowId => { - if (rowId !== row.id && expanded[rowId]) { - (instance as any).toggleRowExpanded?.(rowId, false); - } - }); - } - // Toggle the current row - row.toggleRowExpanded(); - }; - - return ( - - ); -}; diff --git a/src/authz-module/audit-user/messages.ts b/src/authz-module/audit-user/messages.ts index 0a35df09..da213254 100644 --- a/src/authz-module/audit-user/messages.ts +++ b/src/authz-module/audit-user/messages.ts @@ -27,16 +27,6 @@ const messages = defineMessages( defaultMessage: 'Actions', description: 'Header for the actions column in the user table', }, - 'authz.user.table.view_all_permissions.link.text.close': { - id: 'authz.user.table.view_all_permissions.link.text.close', - defaultMessage: 'Hide all permissions', - description: 'Text for the link to hide all permissions in the user table', - }, - 'authz.user.table.view_all_permissions.link.text.open': { - id: 'authz.user.table.view_all_permissions.link.text.open', - defaultMessage: 'View all permissions', - description: 'Text for the link to view all permissions in the user table', - }, 'authz.user.table.delete.action.alt': { id: 'authz.user.table.delete.action.alt', defaultMessage: 'Delete role action', diff --git a/src/authz-module/components/TableCells.test.tsx b/src/authz-module/components/TableCells.test.tsx index f43c3d94..052a0a94 100644 --- a/src/authz-module/components/TableCells.test.tsx +++ b/src/authz-module/components/TableCells.test.tsx @@ -2,6 +2,7 @@ import { screen } from '@testing-library/react'; import { initializeMockApp } from '@edx/frontend-platform/testing'; import { renderWrapper } from '@src/setupTest'; import userEvent from '@testing-library/user-event'; +import { DataTableContext } from '@openedx/paragon'; import { NameCell, ViewActionCell, @@ -554,38 +555,105 @@ describe('TableCells Components', () => { }); describe('ViewAllPermissionsCell', () => { - const mockRow = { - original: { - role: 'library_admin', id: '123', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + const mockUserRole = { + role: 'course_admin', + org: 'OpenedX', + scope: 'course-v1:OpenedX+DemoX+DemoCourse', + permissionCount: 5, + fullName: 'John Doe', + username: 'johndoe', + email: 'johndoe@example.com', + }; + + const mockCellProps = { + row: { + original: mockUserRole, + id: 'test-row-1', + isExpanded: false, + toggleRowExpanded: jest.fn(), + values: mockUserRole, }, }; - it('renders a view more link', () => { - const props = { - row: mockRow, - column: { id: 'viewMore' }, + it('renders view more link', () => { + renderWrapper(); + expect(screen.getByText(/view all permissions/i)).toBeInTheDocument(); + }); + + it('displays correct link text', () => { + renderWrapper(); + expect(screen.getByText(/view all permissions/i)).toBeInTheDocument(); + }); + + it('handles toggle expand functionality with accordion behavior', async () => { + const user = userEvent.setup(); + const mockToggleRowExpanded = jest.fn(); + const mockInstance = { + state: { + expanded: { + 'other-row-1': true, + 'other-row-2': true, + }, + }, + toggleRowExpanded: mockToggleRowExpanded, + }; + + const propsWithToggle = { + row: { + ...mockCellProps.row, + toggleRowExpanded: jest.fn(), + }, }; - renderWrapper(); + renderWrapper( + + + , + ); - expect(screen.getByText('View all permissions')).toBeInTheDocument(); + const toggleButton = screen.getByText(/view all permissions/i); + await user.click(toggleButton); + + // Should close other expanded rows first + expect(mockToggleRowExpanded).toHaveBeenCalledWith('other-row-1', false); + expect(mockToggleRowExpanded).toHaveBeenCalledWith('other-row-2', false); + // Should toggle the current row + expect(propsWithToggle.row.toggleRowExpanded).toHaveBeenCalled(); }); - it('calls onClick handler when view more link is clicked', async () => { + it('toggles row without closing others when row is already expanded', async () => { const user = userEvent.setup(); - const props = { - row: mockRow, - column: { id: 'viewMore' }, + const mockToggleRowExpanded = jest.fn(); + const mockInstance = { + state: { + expanded: { + 'other-row-1': true, + }, + }, + toggleRowExpanded: mockToggleRowExpanded, + }; + + const propsWithExpandedRow = { + row: { + ...mockCellProps.row, + isExpanded: true, + toggleRowExpanded: jest.fn(), + }, }; - renderWrapper(); + renderWrapper( + + + , + ); - const viewMoreButton = screen.getByText('View all permissions'); - await user.click(viewMoreButton); + const toggleButton = screen.getByText(/hide all permissions/i); + await user.click(toggleButton); - // TODO: replace console.log with actual view more logic and update this test accordingly - // eslint-disable-next-line no-console - expect(console.log).toHaveBeenCalledWith('View more clicked for row:', mockRow); + // Should NOT close other expanded rows when current row is already expanded + expect(mockToggleRowExpanded).not.toHaveBeenCalled(); + // Should still toggle the current row + expect(propsWithExpandedRow.row.toggleRowExpanded).toHaveBeenCalled(); }); }); }); diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx index 3652324e..54f329f9 100644 --- a/src/authz-module/components/TableCells.tsx +++ b/src/authz-module/components/TableCells.tsx @@ -18,7 +18,17 @@ import { RESOURCE_ICONS } from './constants'; import messages from './messages'; import ViewMoreLink from './ViewMoreLink'; +interface ExpandableTableRow extends TableCellValue { + row: TableCellValue['row'] & { + id: string; + isExpanded: boolean; + toggleRowExpanded: () => void; + values: T; + }; +} + type CellProps = TableCellValue; +type ExpandableCellProps = ExpandableTableRow; type CellPropsWithValue = CellProps & { value: string; }; @@ -123,12 +133,29 @@ const PermissionsCell = ({ row }: CellProps) => { const ViewAllPermissionsCell = ({ row }: CellProps) => { const { formatMessage } = useIntl(); + const instance = useContext(DataTableContext); + const handleToggleExpanded = () => { + if (!row.isExpanded && instance) { + // Close all other expanded rows first + const expanded = (instance as any)?.state?.expanded || {}; + Object.keys(expanded).forEach(rowId => { + if (rowId !== row.id && expanded[rowId]) { + (instance as any).toggleRowExpanded?.(rowId, false); + } + }); + } + // Toggle the current row + row.toggleRowExpanded(); + }; + return ( console.log('View more clicked for row:', row)} + label={formatMessage( + row.isExpanded + ? messages['authz.user.table.view_all_permissions.link.text.close'] + : messages['authz.user.table.view_all_permissions.link.text.open'], + )} + onClick={handleToggleExpanded} iconSrc={ExpandMore} /> ); diff --git a/src/authz-module/components/messages.ts b/src/authz-module/components/messages.ts index 3cb4a879..aa1a67c5 100644 --- a/src/authz-module/components/messages.ts +++ b/src/authz-module/components/messages.ts @@ -157,6 +157,16 @@ const messages = defineMessages({ defaultMessage: 'You can’t remove this role here. Please go to Django Admin to manage it.', description: 'Tooltip for delete button when hovering over Django roles', }, + 'authz.user.table.view_all_permissions.link.text.close': { + id: 'authz.user.table.view_all_permissions.link.text.close', + defaultMessage: 'Hide all permissions', + description: 'Text for the link to hide all permissions in the user table', + }, + 'authz.user.table.view_all_permissions.link.text.open': { + id: 'authz.user.table.view_all_permissions.link.text.open', + defaultMessage: 'View all permissions', + description: 'Text for the link to view all permissions in the user table', + }, }); export default messages; From 0a6ce84b3dc1c054ed482d90b13bcb3d6aa9c7b0 Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Mon, 20 Apr 2026 15:12:08 -0600 Subject: [PATCH 19/26] chore: refactor of files path for better organization --- .../audit-user/UserPermissions.tsx | 6 +- src/authz-module/audit-user/index.tsx | 2 +- .../RenderAdminRole.test.tsx | 0 .../RenderAdminRole.tsx | 2 +- .../RenderPermissionColumn.test.tsx | 0 .../RenderPermissionColumn.tsx | 2 +- .../RenderPermissionInLine.tsx | 2 +- .../RenderPermissionInline.test.tsx | 0 .../UserPermissions.test.tsx | 0 .../components/UserPermissions.tsx | 95 +++++++++++++++++++ .../hooks/useQuerySettings.test.tsx | 2 +- src/authz-module/hooks/useQuerySettings.tsx | 2 +- 12 files changed, 104 insertions(+), 9 deletions(-) rename src/authz-module/{audit-user => components}/RenderAdminRole.test.tsx (100%) rename src/authz-module/{audit-user => components}/RenderAdminRole.tsx (92%) rename src/authz-module/{audit-user => components}/RenderPermissionColumn.test.tsx (100%) rename src/authz-module/{audit-user => components}/RenderPermissionColumn.tsx (96%) rename src/authz-module/{audit-user => components}/RenderPermissionInLine.tsx (96%) rename src/authz-module/{audit-user => components}/RenderPermissionInline.test.tsx (100%) rename src/authz-module/{audit-user => components}/UserPermissions.test.tsx (100%) create mode 100644 src/authz-module/components/UserPermissions.tsx diff --git a/src/authz-module/audit-user/UserPermissions.tsx b/src/authz-module/audit-user/UserPermissions.tsx index a92f9977..0247a931 100644 --- a/src/authz-module/audit-user/UserPermissions.tsx +++ b/src/authz-module/audit-user/UserPermissions.tsx @@ -9,9 +9,9 @@ import { libraryPermissions, rolesLibraryObject, } from '../libraries/constants'; -import RenderPermissionColumn from './RenderPermissionColumn'; -import RenderPermissionInLine from './RenderPermissionInLine'; -import RenderAdminRole from './RenderAdminRole'; +import RenderPermissionColumn from '../components/RenderPermissionColumn'; +import RenderPermissionInLine from '../components/RenderPermissionInLine'; +import RenderAdminRole from '../components/RenderAdminRole'; interface UserPermissionsProps { row: { diff --git a/src/authz-module/audit-user/index.tsx b/src/authz-module/audit-user/index.tsx index f575e30e..212b0573 100644 --- a/src/authz-module/audit-user/index.tsx +++ b/src/authz-module/audit-user/index.tsx @@ -26,7 +26,7 @@ import { RoleToDelete } from 'types'; import { useToastManager } from '@src/components/ToastManager/ToastManagerContext'; import messages from './messages'; import ConfirmDeletionModal from '../components/ConfirmDeletionModal'; -import UserPermissions from './UserPermissions'; +import UserPermissions from '../components/UserPermissions'; const AuditUserPage = () => { const { formatMessage } = useIntl(); diff --git a/src/authz-module/audit-user/RenderAdminRole.test.tsx b/src/authz-module/components/RenderAdminRole.test.tsx similarity index 100% rename from src/authz-module/audit-user/RenderAdminRole.test.tsx rename to src/authz-module/components/RenderAdminRole.test.tsx diff --git a/src/authz-module/audit-user/RenderAdminRole.tsx b/src/authz-module/components/RenderAdminRole.tsx similarity index 92% rename from src/authz-module/audit-user/RenderAdminRole.tsx rename to src/authz-module/components/RenderAdminRole.tsx index 006fc7b8..8e86663c 100644 --- a/src/authz-module/audit-user/RenderAdminRole.tsx +++ b/src/authz-module/components/RenderAdminRole.tsx @@ -1,5 +1,5 @@ import { useIntl } from '@edx/frontend-platform/i18n'; -import messages from './messages'; +import messages from '../audit-user/messages'; interface RenderAdminRoleProps { role: string; diff --git a/src/authz-module/audit-user/RenderPermissionColumn.test.tsx b/src/authz-module/components/RenderPermissionColumn.test.tsx similarity index 100% rename from src/authz-module/audit-user/RenderPermissionColumn.test.tsx rename to src/authz-module/components/RenderPermissionColumn.test.tsx diff --git a/src/authz-module/audit-user/RenderPermissionColumn.tsx b/src/authz-module/components/RenderPermissionColumn.tsx similarity index 96% rename from src/authz-module/audit-user/RenderPermissionColumn.tsx rename to src/authz-module/components/RenderPermissionColumn.tsx index bdeed441..30fe9ade 100644 --- a/src/authz-module/audit-user/RenderPermissionColumn.tsx +++ b/src/authz-module/components/RenderPermissionColumn.tsx @@ -1,6 +1,6 @@ import { Icon } from '@openedx/paragon'; import { RolePermission } from 'types'; -import ResourceTooltip from '../components/ResourceTooltip'; +import ResourceTooltip from './ResourceTooltip'; interface ExtendedRolePermission extends RolePermission { icon: React.ComponentType>; diff --git a/src/authz-module/audit-user/RenderPermissionInLine.tsx b/src/authz-module/components/RenderPermissionInLine.tsx similarity index 96% rename from src/authz-module/audit-user/RenderPermissionInLine.tsx rename to src/authz-module/components/RenderPermissionInLine.tsx index e34e3497..946a6839 100644 --- a/src/authz-module/audit-user/RenderPermissionInLine.tsx +++ b/src/authz-module/components/RenderPermissionInLine.tsx @@ -1,6 +1,6 @@ import { Icon } from '@openedx/paragon'; import { RolePermission } from 'types'; -import ResourceTooltip from '../components/ResourceTooltip'; +import ResourceTooltip from './ResourceTooltip'; interface ExtendedRolePermission extends RolePermission { icon: React.ComponentType>; diff --git a/src/authz-module/audit-user/RenderPermissionInline.test.tsx b/src/authz-module/components/RenderPermissionInline.test.tsx similarity index 100% rename from src/authz-module/audit-user/RenderPermissionInline.test.tsx rename to src/authz-module/components/RenderPermissionInline.test.tsx diff --git a/src/authz-module/audit-user/UserPermissions.test.tsx b/src/authz-module/components/UserPermissions.test.tsx similarity index 100% rename from src/authz-module/audit-user/UserPermissions.test.tsx rename to src/authz-module/components/UserPermissions.test.tsx diff --git a/src/authz-module/components/UserPermissions.tsx b/src/authz-module/components/UserPermissions.tsx new file mode 100644 index 00000000..a92f9977 --- /dev/null +++ b/src/authz-module/components/UserPermissions.tsx @@ -0,0 +1,95 @@ +import { DJANGO_MANAGED_ROLES } from '@src/authz-module/constants'; +import { + courseResourceTypes, + coursePermissions, + rolesObject, +} from '../courses/constant'; +import { + libraryResourceTypes, + libraryPermissions, + rolesLibraryObject, +} from '../libraries/constants'; +import RenderPermissionColumn from './RenderPermissionColumn'; +import RenderPermissionInLine from './RenderPermissionInLine'; +import RenderAdminRole from './RenderAdminRole'; + +interface UserPermissionsProps { + row: { + original: { + role: string; + }; + }; +} + +const UserPermissions = ({ row }: UserPermissionsProps) => { + let roleKey = row?.original?.role; + if (!roleKey) { return null; } + + if (DJANGO_MANAGED_ROLES.includes(roleKey)) { + return ( +
      + +
      + ); + } + + // Normalize role string to match keys in constants (e.g. "Course Admin" -> "course_admin") + roleKey = roleKey.trim().toLowerCase().replace(/[-\s]+/g, '_'); + const isLibraryRole = roleKey.includes('library'); + const config = isLibraryRole + ? { + resourceTypes: libraryResourceTypes, + permissions: libraryPermissions, + roles: rolesLibraryObject, + } + : { + resourceTypes: courseResourceTypes, + permissions: coursePermissions, + roles: rolesObject, + }; + + const roleObj = config.roles.find(r => r.role === roleKey); + if (!roleObj) { return null; } + + const rolePerms = new Set(roleObj.permissions.map(String)); + // Build resource list with permissions (only once) + const resources = config.resourceTypes + .map(resource => { + const perms = config.permissions.filter( + p => p.resource === resource.key && rolePerms.has(String(p.key)), + ); + return perms.length ? { ...resource, perms } : null; + }) + .filter(Boolean); + + const isSingleRow = resources.length <= 3; + const mid = Math.ceil(resources.length / 2); + const columns = isSingleRow + ? [resources] + : [resources.slice(0, mid), resources.slice(mid)]; + return ( +
      + {isSingleRow + ? + : ( +
      + {columns.map((col, index) => ( +
      + + {index === 0 && ( +
      + )} +
      + ))} +
      + )} +
      + ); +}; + +export default UserPermissions; diff --git a/src/authz-module/hooks/useQuerySettings.test.tsx b/src/authz-module/hooks/useQuerySettings.test.tsx index ee73342e..6a342fa6 100644 --- a/src/authz-module/hooks/useQuerySettings.test.tsx +++ b/src/authz-module/hooks/useQuerySettings.test.tsx @@ -1,5 +1,5 @@ import { renderHook, act } from '@testing-library/react'; -import { QuerySettings } from '../data/api'; +import { QuerySettings } from '@src/authz-module/data/api'; import { useQuerySettings } from './useQuerySettings'; describe('useQuerySettings', () => { diff --git a/src/authz-module/hooks/useQuerySettings.tsx b/src/authz-module/hooks/useQuerySettings.tsx index e90849af..33675c82 100644 --- a/src/authz-module/hooks/useQuerySettings.tsx +++ b/src/authz-module/hooks/useQuerySettings.tsx @@ -1,5 +1,5 @@ import { useCallback, useState } from 'react'; -import { QuerySettings } from '../data/api'; +import { QuerySettings } from '@src/authz-module/data/api'; interface DataTableFilters { pageSize: number; From 08af9908246c355776678360dcf011ca31b81628 Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Mon, 20 Apr 2026 16:03:23 -0600 Subject: [PATCH 20/26] chore: unnecessary code removed and fix and imports --- .../audit-user/UserPermissions.tsx | 95 ------------------- src/authz-module/audit-user/index.tsx | 2 +- src/authz-module/audit-user/messages.ts | 2 +- src/authz-module/audit-user/utils.ts | 0 .../components/RenderAdminRole.tsx | 2 +- src/authz-module/components/TableCells.tsx | 13 ++- .../components/UserPermissions.test.tsx | 2 +- .../components/UserPermissions.tsx | 4 +- src/authz-module/courses/constant.ts | 9 -- src/authz-module/libraries/constants.ts | 9 -- .../roles-permissions/course/constants.ts | 10 -- .../roles-permissions/library/constants.ts | 9 -- 12 files changed, 16 insertions(+), 141 deletions(-) delete mode 100644 src/authz-module/audit-user/UserPermissions.tsx delete mode 100644 src/authz-module/audit-user/utils.ts diff --git a/src/authz-module/audit-user/UserPermissions.tsx b/src/authz-module/audit-user/UserPermissions.tsx deleted file mode 100644 index 0247a931..00000000 --- a/src/authz-module/audit-user/UserPermissions.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { DJANGO_MANAGED_ROLES } from '@src/authz-module/constants'; -import { - courseResourceTypes, - coursePermissions, - rolesObject, -} from '../courses/constant'; -import { - libraryResourceTypes, - libraryPermissions, - rolesLibraryObject, -} from '../libraries/constants'; -import RenderPermissionColumn from '../components/RenderPermissionColumn'; -import RenderPermissionInLine from '../components/RenderPermissionInLine'; -import RenderAdminRole from '../components/RenderAdminRole'; - -interface UserPermissionsProps { - row: { - original: { - role: string; - }; - }; -} - -const UserPermissions = ({ row }: UserPermissionsProps) => { - let roleKey = row?.original?.role; - if (!roleKey) { return null; } - - if (DJANGO_MANAGED_ROLES.includes(roleKey)) { - return ( -
      - -
      - ); - } - - // Normalize role string to match keys in constants (e.g. "Course Admin" -> "course_admin") - roleKey = roleKey.trim().toLowerCase().replace(/[-\s]+/g, '_'); - const isLibraryRole = roleKey.includes('library'); - const config = isLibraryRole - ? { - resourceTypes: libraryResourceTypes, - permissions: libraryPermissions, - roles: rolesLibraryObject, - } - : { - resourceTypes: courseResourceTypes, - permissions: coursePermissions, - roles: rolesObject, - }; - - const roleObj = config.roles.find(r => r.role === roleKey); - if (!roleObj) { return null; } - - const rolePerms = new Set(roleObj.permissions.map(String)); - // Build resource list with permissions (only once) - const resources = config.resourceTypes - .map(resource => { - const perms = config.permissions.filter( - p => p.resource === resource.key && rolePerms.has(String(p.key)), - ); - return perms.length ? { ...resource, perms } : null; - }) - .filter(Boolean); - - const isSingleRow = resources.length <= 3; - const mid = Math.ceil(resources.length / 2); - const columns = isSingleRow - ? [resources] - : [resources.slice(0, mid), resources.slice(mid)]; - return ( -
      - {isSingleRow - ? - : ( -
      - {columns.map((col, index) => ( -
      - - {index === 0 && ( -
      - )} -
      - ))} -
      - )} -
      - ); -}; - -export default UserPermissions; diff --git a/src/authz-module/audit-user/index.tsx b/src/authz-module/audit-user/index.tsx index 212b0573..f0e793f4 100644 --- a/src/authz-module/audit-user/index.tsx +++ b/src/authz-module/audit-user/index.tsx @@ -26,7 +26,7 @@ import { RoleToDelete } from 'types'; import { useToastManager } from '@src/components/ToastManager/ToastManagerContext'; import messages from './messages'; import ConfirmDeletionModal from '../components/ConfirmDeletionModal'; -import UserPermissions from '../components/UserPermissions'; +import UserPermissions from '@src/authz-module/components/UserPermissions'; const AuditUserPage = () => { const { formatMessage } = useIntl(); diff --git a/src/authz-module/audit-user/messages.ts b/src/authz-module/audit-user/messages.ts index da213254..8eee8fee 100644 --- a/src/authz-module/audit-user/messages.ts +++ b/src/authz-module/audit-user/messages.ts @@ -53,7 +53,7 @@ const messages = defineMessages( description: 'Description for the permissions of the Super Admin role', }, 'authz.user.table.permissions.role.staff': { - id: 'authz.user.table.permissions.role.staff ', + id: 'authz.user.table.permissions.role.staff', defaultMessage: 'Global Staff have access to all areas of the platform, similar to Super Admin, but cannot grant or revoke Super Admin or Global Staff roles to other users. This role is managed at the platform level and cannot be changed from here. To modify it, go to Django Admin.', description: 'Description for the permissions of the Global Staff role', }, diff --git a/src/authz-module/audit-user/utils.ts b/src/authz-module/audit-user/utils.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/authz-module/components/RenderAdminRole.tsx b/src/authz-module/components/RenderAdminRole.tsx index 8e86663c..7d3eeb59 100644 --- a/src/authz-module/components/RenderAdminRole.tsx +++ b/src/authz-module/components/RenderAdminRole.tsx @@ -1,5 +1,5 @@ import { useIntl } from '@edx/frontend-platform/i18n'; -import messages from '../audit-user/messages'; +import messages from '@src/authz-module/audit-user/messages'; interface RenderAdminRoleProps { role: string; diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx index 54f329f9..89ee3f99 100644 --- a/src/authz-module/components/TableCells.tsx +++ b/src/authz-module/components/TableCells.tsx @@ -27,6 +27,13 @@ interface ExpandableTableRow extends TableCellValue { }; } +interface DataTableInstance { + state?: { + expanded?: Record; + }; + toggleRowExpanded?: (rowId: string, expanded: boolean) => void; +} + type CellProps = TableCellValue; type ExpandableCellProps = ExpandableTableRow; type CellPropsWithValue = CellProps & { @@ -133,14 +140,14 @@ const PermissionsCell = ({ row }: CellProps) => { const ViewAllPermissionsCell = ({ row }: CellProps) => { const { formatMessage } = useIntl(); - const instance = useContext(DataTableContext); + const instance = useContext(DataTableContext) as DataTableInstance; const handleToggleExpanded = () => { if (!row.isExpanded && instance) { // Close all other expanded rows first - const expanded = (instance as any)?.state?.expanded || {}; + const expanded = instance.state?.expanded || {}; Object.keys(expanded).forEach(rowId => { if (rowId !== row.id && expanded[rowId]) { - (instance as any).toggleRowExpanded?.(rowId, false); + instance.toggleRowExpanded?.(rowId, false); } }); } diff --git a/src/authz-module/components/UserPermissions.test.tsx b/src/authz-module/components/UserPermissions.test.tsx index 1904c6cd..3e22faa0 100644 --- a/src/authz-module/components/UserPermissions.test.tsx +++ b/src/authz-module/components/UserPermissions.test.tsx @@ -1,7 +1,7 @@ import { initializeMockApp } from '@edx/frontend-platform/testing'; import { renderWrapper } from '@src/setupTest'; +import * as coursesConstants from '@src/authz-module/courses/constant'; import UserPermissions from './UserPermissions'; -import * as coursesConstants from '../courses/constant'; jest.mock('./RenderPermissionInLine', () => ( jest.fn(({ items }) => ( diff --git a/src/authz-module/components/UserPermissions.tsx b/src/authz-module/components/UserPermissions.tsx index a92f9977..54fb540d 100644 --- a/src/authz-module/components/UserPermissions.tsx +++ b/src/authz-module/components/UserPermissions.tsx @@ -3,12 +3,12 @@ import { courseResourceTypes, coursePermissions, rolesObject, -} from '../courses/constant'; +} from '@src/authz-module/courses/constant'; import { libraryResourceTypes, libraryPermissions, rolesLibraryObject, -} from '../libraries/constants'; +} from '@src/authz-module/libraries/constants'; import RenderPermissionColumn from './RenderPermissionColumn'; import RenderPermissionInLine from './RenderPermissionInLine'; import RenderAdminRole from './RenderAdminRole'; diff --git a/src/authz-module/courses/constant.ts b/src/authz-module/courses/constant.ts index 392eea66..68bc674c 100644 --- a/src/authz-module/courses/constant.ts +++ b/src/authz-module/courses/constant.ts @@ -465,12 +465,3 @@ export const rolesObject = [ }, ]; - -export const DEFAULT_TOAST_DELAY = 5000; -export const RETRY_TOAST_DELAY = 120_000; // 2 minutes -export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({ - username: 'skeleton', - name: '', - email: '', - roles: [], -})); diff --git a/src/authz-module/libraries/constants.ts b/src/authz-module/libraries/constants.ts index f15bdeea..921385a8 100644 --- a/src/authz-module/libraries/constants.ts +++ b/src/authz-module/libraries/constants.ts @@ -175,12 +175,3 @@ export const rolesLibraryObject = [ description: 'The Library User can view and reuse content but cannot edit or delete anything.', }, ]; - -export const DEFAULT_TOAST_DELAY = 5000; -export const RETRY_TOAST_DELAY = 120_000; // 2 minutes -export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({ - username: 'skeleton', - name: '', - email: '', - roles: [], -})); diff --git a/src/authz-module/roles-permissions/course/constants.ts b/src/authz-module/roles-permissions/course/constants.ts index 8d3622aa..8e5d7535 100644 --- a/src/authz-module/roles-permissions/course/constants.ts +++ b/src/authz-module/roles-permissions/course/constants.ts @@ -441,13 +441,3 @@ export const courseRolesMetadata: RoleMetadata[] = [ role: 'course_auditor', name: 'Course Auditor', description: 'Can view course content and settings, but cannot make changes.', contextType: 'course', disabled: true, }, ]; - -// TODO: check if we need this later -// export const DEFAULT_TOAST_DELAY = 5000; -// export const RETRY_TOAST_DELAY = 120_000; // 2 minutes -// export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({ -// username: 'skeleton', -// name: '', -// email: '', -// roles: [], -// })); diff --git a/src/authz-module/roles-permissions/library/constants.ts b/src/authz-module/roles-permissions/library/constants.ts index 234cf045..5477b832 100644 --- a/src/authz-module/roles-permissions/library/constants.ts +++ b/src/authz-module/roles-permissions/library/constants.ts @@ -158,12 +158,3 @@ export const rolesLibraryObject = [ description: 'The Library User can view and reuse content but cannot edit or delete anything.', }, ]; -// TODO: check if we need this later -// export const DEFAULT_TOAST_DELAY = 5000; -// export const RETRY_TOAST_DELAY = 120_000; // 2 minutes -// export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({ -// username: 'skeleton', -// name: '', -// email: '', -// roles: [], -// })); From d586e7ecf71b0a792e40c34aa1b4d2b4600f75e2 Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Mon, 20 Apr 2026 16:08:00 -0600 Subject: [PATCH 21/26] fix: refetchOnWindowFocus added --- src/data/hooks.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/data/hooks.ts b/src/data/hooks.ts index 897fc222..b6587c48 100644 --- a/src/data/hooks.ts +++ b/src/data/hooks.ts @@ -39,4 +39,5 @@ export const useUserAccount = (username?: string) => useQuery({ queryFn: async () => getUserAccount(username), retry: false, enabled: !!username, + refetchOnWindowFocus: false, }); From a02456173fc174631d4960238b18f4507deaa7ab Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Mon, 20 Apr 2026 16:42:54 -0600 Subject: [PATCH 22/26] fix: constants file merged, fix in imports and texts --- src/authz-module/components/TableCells.tsx | 20 +- .../components/UserPermissions.test.tsx | 2 +- .../components/UserPermissions.tsx | 4 +- src/authz-module/constants.ts | 420 ++++++++++++++++ src/authz-module/courses/constant.ts | 467 ------------------ .../roles-permissions/RolesPermissions.tsx | 2 +- src/index.scss | 2 +- 7 files changed, 436 insertions(+), 481 deletions(-) delete mode 100644 src/authz-module/courses/constant.ts diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx index 89ee3f99..d04f509b 100644 --- a/src/authz-module/components/TableCells.tsx +++ b/src/authz-module/components/TableCells.tsx @@ -156,15 +156,17 @@ const ViewAllPermissionsCell = ({ row }: CellProps) => { }; return ( - +
      + +
      ); }; diff --git a/src/authz-module/components/UserPermissions.test.tsx b/src/authz-module/components/UserPermissions.test.tsx index 3e22faa0..33d4dac5 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/courses/constant'; +import * as coursesConstants from '@src/authz-module/constants'; import UserPermissions from './UserPermissions'; jest.mock('./RenderPermissionInLine', () => ( diff --git a/src/authz-module/components/UserPermissions.tsx b/src/authz-module/components/UserPermissions.tsx index 54fb540d..30dbc6ad 100644 --- a/src/authz-module/components/UserPermissions.tsx +++ b/src/authz-module/components/UserPermissions.tsx @@ -1,9 +1,9 @@ -import { DJANGO_MANAGED_ROLES } from '@src/authz-module/constants'; import { courseResourceTypes, coursePermissions, rolesObject, -} from '@src/authz-module/courses/constant'; + DJANGO_MANAGED_ROLES, +} from '@src/authz-module/constants'; import { libraryResourceTypes, libraryPermissions, diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index 4aeff139..3f18cc1c 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -1,4 +1,22 @@ 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', @@ -86,6 +104,408 @@ export const libraryPermissions: PermissionMetadata[] = [ { 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 & taxonomies', description: 'Permissions for managing tags and taxonomies 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_TAXONOMIES, + resource: 'course_tags_taxonomies', + description: 'Create, edit, and delete taxonomies used to organize course content.', + label: 'Manage taxonomies', + 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, + CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAXONOMIES, + ], + 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.', + }, + { + 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.', + }, + +]; + 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/courses/constant.ts b/src/authz-module/courses/constant.ts deleted file mode 100644 index 68bc674c..00000000 --- a/src/authz-module/courses/constant.ts +++ /dev/null @@ -1,467 +0,0 @@ -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_COURSE_PERMISSIONS = { - VIEW_COURSE: 'courses.view_course', - CREATE_COURSE: 'courses.create_course', - EDIT_COURSE_CONTENT: 'courses.edit_course_content', - PUBLISH_COURSE_CONTENT: 'courses.publish_course_content', - - REVIEW_COURSE_LIBRARY_UPDATES: 'courses.manage_library_updates', - - VIEW_COURSE_UPDATES: 'courses.view_course_updates', - MANAGE_COURSE_UPDATES: 'courses.manage_course_updates', - - VIEW_COURSE_PAGES_RESOURCES: 'courses.view_pages_and_resources', - MANAGE_COURSE_PAGES_RESOURCES: 'courses.manage_pages_and_resources', - - VIEW_COURSE_FILES: 'courses.view_files', - CREATE_COURSE_FILES: 'courses.create_files', - EDIT_COURSE_FILES: 'courses.edit_files', - DELETE_COURSE_FILES: 'courses.delete_files', - - VIEW_COURSE_SCHEDULE: 'courses.view_schedule', - EDIT_COURSE_SCHEDULE: 'courses.edit_schedule', - VIEW_COURSE_DETAILS: 'courses.view_details', - EDIT_COURSE_DETAILS: 'courses.edit_details', - - VIEW_COURSE_GRADING_SETTINGS: 'courses.view_grading_settings', - EDIT_COURSE_GRADING_SETTINGS: 'courses.edit_grading_settings', - - VIEW_COURSE_TEAM: 'courses.view_course_team', - MANAGE_COURSE_TEAM: 'courses.manage_course_team', - MANAGE_COURSE_GROUP_CONFIGURATION: 'courses.manage_group_configurations', - - MANAGE_COURSE_TAGS: 'courses.manage_tags', - MANAGE_COURSE_TAXONOMIES: 'courses.manage_taxonomies', - - MANAGE_COURSE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings', - MANAGE_COURSE_CERTIFICATES: 'courses.manage_certificates', - - IMPORT_COURSE: 'courses.import_course', - EXPORT_COURSE: 'courses.export_course', - EXPORT_COURSE_TAGS: 'courses.export_tags', - - VIEW_COURSE_CHECKLISTS: 'courses.view_checklists', - VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS: 'courses.view_global_staff_and_superadmins', -}; - -export const courseResourceTypes: ResourceMetadata[] = [ - { - key: 'course_tags_taxonomies', label: 'Tags & taxonomies', description: 'Permissions for managing tags and taxonomies 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_TAXONOMIES, - resource: 'course_tags_taxonomies', - description: 'Create, edit, and delete taxonomies used to organize course content.', - label: 'Manage taxonomies', - 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, - }, - -]; - -// roles hardcoded, todo: need to add the constants from above in order to merge the different permissions array. -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, - CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAXONOMIES, - ], - 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.', - }, - { - 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.', - }, - -]; diff --git a/src/authz-module/roles-permissions/RolesPermissions.tsx b/src/authz-module/roles-permissions/RolesPermissions.tsx index 6b7d07cf..588bb45c 100644 --- a/src/authz-module/roles-permissions/RolesPermissions.tsx +++ b/src/authz-module/roles-permissions/RolesPermissions.tsx @@ -7,13 +7,13 @@ import { Container, Hyperlink, } from '@openedx/paragon'; +import { coursePermissions, courseResourceTypes, rolesObject } from '@src/authz-module/constants'; import AnchorButton from '../components/AnchorButton'; import PermissionTable from '../components/PermissionTable'; import { buildPermissionMatrixByResource } from './library/utils'; import messages from './library/messages'; -import { coursePermissions, courseResourceTypes, rolesObject } from './course/constants'; import { rolesLibraryObject, libraryPermissions, libraryResourceTypes } from './library/constants'; const RolesPermissions = () => { diff --git a/src/index.scss b/src/index.scss index 6a816964..6cf82699 100644 --- a/src/index.scss +++ b/src/index.scss @@ -1,4 +1,4 @@ @use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints; @use "@openedx/paragon/dist/core.min.css"; @use "@openedx/paragon/dist/light.min.css"; -@import "~@edx/frontend-component-header/dist/index"; \ No newline at end of file +@import "~@edx/frontend-component-header/dist/index"; From 071f8724fa008a14d1131ec003228b9a8d8d1a49 Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Mon, 20 Apr 2026 21:20:13 -0600 Subject: [PATCH 23/26] fix: height added to avoid a paragon bug with dropdown --- src/authz-module/team-members/TeamMembersTable.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/authz-module/team-members/TeamMembersTable.tsx b/src/authz-module/team-members/TeamMembersTable.tsx index 4749dcd6..11dede71 100644 --- a/src/authz-module/team-members/TeamMembersTable.tsx +++ b/src/authz-module/team-members/TeamMembersTable.tsx @@ -133,6 +133,7 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { }, ] } + className="vh-100" > From 6dfacb961dc306bc3fe9f37646164c681d126239 Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Mon, 20 Apr 2026 21:56:26 -0600 Subject: [PATCH 24/26] fix: tests fixed --- src/authz-module/audit-user/index.test.tsx | 74 ++++++++++++++++++---- src/authz-module/audit-user/index.tsx | 2 +- src/authz-module/components/TableCells.tsx | 12 +--- 3 files changed, 63 insertions(+), 25 deletions(-) diff --git a/src/authz-module/audit-user/index.test.tsx b/src/authz-module/audit-user/index.test.tsx index 5a26a6b8..b5114b26 100644 --- a/src/authz-module/audit-user/index.test.tsx +++ b/src/authz-module/audit-user/index.test.tsx @@ -1,4 +1,6 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { + render, screen, waitFor, act, +} from '@testing-library/react'; import { AppContext } from '@edx/frontend-platform/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; @@ -17,6 +19,21 @@ jest.mock('@edx/frontend-platform/logging', () => ({ logError: jest.fn(), })); +// Mock StudioHeader to avoid prop validation errors in tests +jest.mock('@edx/frontend-component-header', () => ({ + StudioHeader: ({ children, ...props }: any) =>
      {children}
      , +})); + +// Mock the useRevokeUserRoles hook +const mockRevokeUserRoles = jest.fn(); +jest.mock('@src/authz-module/data/hooks', () => ({ + ...jest.requireActual('@src/authz-module/data/hooks'), + useRevokeUserRoles: () => ({ + mutate: mockRevokeUserRoles, + isPending: false, + }), +})); + const mockUser = { username: 'johndoe', email: 'john@example.com', @@ -50,9 +67,16 @@ const renderWithRouter = (route = '/audit/johndoe') => { authenticatedUser: { username: 'testuser', email: 'testuser@example.com', + userId: 1, }, config: { - // @ts-ignore + LMS_BASE_URL: 'http://localhost:18000', + STUDIO_BASE_URL: 'http://localhost:18010', + AUTHZ_MICROFRONTEND_URL: 'http://localhost:18012', + ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload', + BASE_URL: 'http://localhost:18012', + ENVIRONMENT: 'test', + LANGUAGE_PREFERENCE_COOKIE_NAME: 'openedx-language-preference', ...process.env, }, }; @@ -78,6 +102,11 @@ const renderWithRouter = (route = '/audit/johndoe') => { describe('AuditUserPage', () => { beforeEach(() => { jest.clearAllMocks(); + // Set up default mock behavior for useRevokeUserRoles + mockRevokeUserRoles.mockImplementation((variables, { onSuccess }) => { + // Simulate successful deletion by default + onSuccess({ errors: [], completed: ['role1'] }); + }); }); beforeAll(() => { @@ -250,7 +279,6 @@ describe('AuditUserPage', () => { .fn() .mockResolvedValueOnce({ data: mockUser }) .mockResolvedValueOnce({ data: mockAssignments }), - delete: jest.fn().mockResolvedValue({ data: { errors: [] } }), }); renderWithRouter(); @@ -269,26 +297,35 @@ describe('AuditUserPage', () => { }); const removeButton = screen.getByRole('button', { name: /remove/i }); - await user.click(removeButton); + + await act(async () => { + await user.click(removeButton); + }); await waitFor(() => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + await waitFor(() => { expect(screen.getByText(/role has been successfully removed/i)).toBeInTheDocument(); }); }); it('shows error toast when role revocation succeeds but returns errors', async () => { + // Override mock for this specific test case + mockRevokeUserRoles.mockImplementation((_, { onSuccess }) => { + // Call onSuccess immediately with errors + onSuccess({ + errors: ['Failed to revoke user role'], + completed: [], + }); + }); + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ get: jest .fn() .mockResolvedValueOnce({ data: mockUser }) .mockResolvedValueOnce({ data: mockAssignments }), - delete: jest.fn().mockResolvedValue({ - data: { - errors: ['Failed to revoke user role'], - completed: [], - }, - }), }); renderWithRouter(); @@ -307,7 +344,10 @@ describe('AuditUserPage', () => { }); const removeButton = screen.getByRole('button', { name: /remove/i }); - await user.click(removeButton); + + await act(async () => { + await user.click(removeButton); + }); await waitFor(() => { expect(screen.getByText(/something went wrong/i)).toBeInTheDocument(); @@ -315,12 +355,17 @@ describe('AuditUserPage', () => { }); it('shows error toast with retry when role revocation fails', async () => { + // Override mock for this specific test case + mockRevokeUserRoles.mockImplementation((variables, { onError }) => { + // Call onError immediately to simulate failure + onError(new Error('Network error')); + }); + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ get: jest .fn() .mockResolvedValueOnce({ data: mockUser }) .mockResolvedValueOnce({ data: mockAssignments }), - delete: jest.fn().mockRejectedValue(new Error('Network error')), }); renderWithRouter(); @@ -339,7 +384,10 @@ describe('AuditUserPage', () => { }); const removeButton = screen.getByRole('button', { name: /remove/i }); - await user.click(removeButton); + + await act(async () => { + await user.click(removeButton); + }); await waitFor(() => { expect(screen.getByText(/something went wrong on our end/i)).toBeInTheDocument(); diff --git a/src/authz-module/audit-user/index.tsx b/src/authz-module/audit-user/index.tsx index f0e793f4..4f7fc132 100644 --- a/src/authz-module/audit-user/index.tsx +++ b/src/authz-module/audit-user/index.tsx @@ -24,9 +24,9 @@ import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings'; import { useRevokeUserRoles, useUserAssignedRoles } from '@src/authz-module/data/hooks'; import { RoleToDelete } from 'types'; import { useToastManager } from '@src/components/ToastManager/ToastManagerContext'; +import UserPermissions from '@src/authz-module/components/UserPermissions'; import messages from './messages'; import ConfirmDeletionModal from '../components/ConfirmDeletionModal'; -import UserPermissions from '@src/authz-module/components/UserPermissions'; const AuditUserPage = () => { const { formatMessage } = useIntl(); diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx index d04f509b..a0324fb3 100644 --- a/src/authz-module/components/TableCells.tsx +++ b/src/authz-module/components/TableCells.tsx @@ -12,21 +12,12 @@ import { useNavigate } from 'react-router-dom'; import { useContext, useMemo } from 'react'; import { ADMIN_ROLES, DJANGO_MANAGED_ROLES, MAP_ROLE_KEY_TO_LABEL } from '@src/authz-module/constants'; import { - Icon, IconButton, OverlayTrigger, Tooltip, + Icon, IconButton, OverlayTrigger, Tooltip, DataTableContext, } from '@openedx/paragon'; import { RESOURCE_ICONS } from './constants'; import messages from './messages'; import ViewMoreLink from './ViewMoreLink'; -interface ExpandableTableRow extends TableCellValue { - row: TableCellValue['row'] & { - id: string; - isExpanded: boolean; - toggleRowExpanded: () => void; - values: T; - }; -} - interface DataTableInstance { state?: { expanded?: Record; @@ -35,7 +26,6 @@ interface DataTableInstance { } type CellProps = TableCellValue; -type ExpandableCellProps = ExpandableTableRow; type CellPropsWithValue = CellProps & { value: string; }; From 0f4610ab27fcc175c874bcd1d3c02e24f21bc51b Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Mon, 20 Apr 2026 22:03:23 -0600 Subject: [PATCH 25/26] fix: missing test added --- src/authz-module/audit-user/index.test.tsx | 25 ++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/authz-module/audit-user/index.test.tsx b/src/authz-module/audit-user/index.test.tsx index b5114b26..f2088f94 100644 --- a/src/authz-module/audit-user/index.test.tsx +++ b/src/authz-module/audit-user/index.test.tsx @@ -210,6 +210,31 @@ describe('AuditUserPage', () => { }); }); + it('expands row to show UserPermissions component when view all permissions is clicked', async () => { + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: jest + .fn() + .mockResolvedValueOnce({ data: mockUser }) + .mockResolvedValueOnce({ data: mockAssignments }), + }); + + renderWithRouter(); + const user = userEvent.setup(); + + await waitFor(() => { + expect(screen.getByText('Library Admin')).toBeInTheDocument(); + }); + // Find and click the "View All Permissions" link + const viewAllPermissionsLink = screen.getByText(/view all permissions/i); + expect(viewAllPermissionsLink).toBeInTheDocument(); + await user.click(viewAllPermissionsLink); + // Verify that the UserPermissions component is rendered (it should show detailed permissions) + await waitFor(() => { + // The UserPermissions component should be rendered in the expanded row + expect(viewAllPermissionsLink).toBeInTheDocument(); + }); + }); + it('renders the pagination controls when assignments are present', async () => { (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ get: jest From f2839a1bb5dbd7049484e282064e092f6c959b2e Mon Sep 17 00:00:00 2001 From: Jesus Balderrama Date: Tue, 21 Apr 2026 10:44:12 -0600 Subject: [PATCH 26/26] chore: remove height class --- src/authz-module/team-members/TeamMembersTable.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/authz-module/team-members/TeamMembersTable.tsx b/src/authz-module/team-members/TeamMembersTable.tsx index 11dede71..4749dcd6 100644 --- a/src/authz-module/team-members/TeamMembersTable.tsx +++ b/src/authz-module/team-members/TeamMembersTable.tsx @@ -133,7 +133,6 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { }, ] } - className="vh-100" >