From 849a6cbecc06daabaf94629c0ac64224fc49ee7a Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Wed, 24 Jun 2026 19:30:34 +1000 Subject: [PATCH 1/3] refactor: centralize role/route constants and derive role maps from metadata --- src/authz-module/audit-user/index.tsx | 8 +- .../components/PermissionTable.test.tsx | 6 +- .../components/RenderAdminRole.test.tsx | 12 +-- .../components/RenderAdminRole.tsx | 4 +- .../components/TableCells.test.tsx | 2 +- src/authz-module/components/TableCells.tsx | 9 +- src/authz-module/components/constants.ts | 100 ++++++------------ src/authz-module/constants.ts | 51 +++------ src/authz-module/hooks/useQuerySettings.ts | 5 +- .../AssignRoleWizard.tsx | 5 +- .../AssignRoleWizardPage.tsx | 4 +- .../components/DefineApplicationScopeStep.tsx | 8 +- .../SelectUsersAndRoleStep.test.tsx | 7 +- .../components/SelectUsersAndRoleStep.tsx | 7 +- .../role-assignation-wizard/constants.ts | 2 + .../hooks/useScopeListData.ts | 4 +- .../hooks/useScopePermissions.ts | 3 +- .../role-assignation-wizard/utils.ts | 12 +-- src/authz-module/roles-permissions/index.ts | 6 ++ src/authz-module/utils.tsx | 16 ++- src/constants.ts | 2 +- 21 files changed, 110 insertions(+), 163 deletions(-) diff --git a/src/authz-module/audit-user/index.tsx b/src/authz-module/audit-user/index.tsx index 0619402a..7ceeacee 100644 --- a/src/authz-module/audit-user/index.tsx +++ b/src/authz-module/audit-user/index.tsx @@ -9,7 +9,7 @@ import { } from '@openedx/paragon'; import TableFooter from '@src/authz-module/components/TableFooter/TableFooter'; import { - AUTHZ_HOME_PATH, TABLE_DEFAULT_PAGE_SIZE, + ROUTES, TABLE_DEFAULT_PAGE_SIZE, } from '@src/authz-module/constants'; import AuthZLayout from '@src/authz-module/components/AuthZLayout'; import { useNavigate, useParams } from 'react-router-dom'; @@ -81,7 +81,7 @@ const AuditUserPage = () => { if (!user && !isLoadingUser) { // @ts-ignore if (!isErrorUser || errorUser?.customAttributes?.httpErrorStatus === 404) { - navigate(AUTHZ_HOME_PATH); + navigate(ROUTES.HOME_PATH); } } }, [user, isLoadingUser, navigate, isErrorUser, errorUser]); @@ -96,7 +96,7 @@ const AuditUserPage = () => { const navLinks = useMemo(() => [ { label: formatMessage(baseMessages['authz.management.home.nav.link']), - to: AUTHZ_HOME_PATH, + to: ROUTES.HOME_PATH, }, ], [formatMessage]); @@ -202,7 +202,7 @@ const AuditUserPage = () => { }); handleCloseConfirmDeletionModal(); if (remainingRolesCount === 0) { - navigate(AUTHZ_HOME_PATH); + navigate(ROUTES.HOME_PATH); } }, onError: (error, retryVariables) => { diff --git a/src/authz-module/components/PermissionTable.test.tsx b/src/authz-module/components/PermissionTable.test.tsx index 679d6a27..0b4d8d8e 100644 --- a/src/authz-module/components/PermissionTable.test.tsx +++ b/src/authz-module/components/PermissionTable.test.tsx @@ -10,7 +10,7 @@ const mockRoles: Role[] = [ userCount: 0, permissions: [], role: '', - contextType: '', + contextType: 'course', scope: '', }, { @@ -19,7 +19,7 @@ const mockRoles: Role[] = [ userCount: 0, permissions: [], role: '', - contextType: '', + contextType: 'course', scope: '', }, { @@ -28,7 +28,7 @@ const mockRoles: Role[] = [ userCount: 0, permissions: [], role: '', - contextType: '', + contextType: 'course', scope: '', }, ]; diff --git a/src/authz-module/components/RenderAdminRole.test.tsx b/src/authz-module/components/RenderAdminRole.test.tsx index d903a922..172f6c0a 100644 --- a/src/authz-module/components/RenderAdminRole.test.tsx +++ b/src/authz-module/components/RenderAdminRole.test.tsx @@ -27,13 +27,13 @@ describe('RenderAdminRole', () => { expect(container.querySelector('.mb-0')).toBeInTheDocument(); }); - it('displays admin message for roles containing admin', () => { - renderWrapper(); + it('displays admin message for superuser role', () => { + renderWrapper(); expect(screen.getByText(/super admins have full access/i)).toBeInTheDocument(); }); - it('displays staff message for superuser role', () => { - renderWrapper(); + it('displays staff message for non-django admin roles', () => { + renderWrapper(); expect(screen.getByText(/global staff have access/i)).toBeInTheDocument(); }); @@ -57,9 +57,9 @@ describe('RenderAdminRole', () => { expect(screen.getByText(/global staff have access/i)).toBeInTheDocument(); }); - it('displays admin message for mixed case admin role', () => { + it('displays staff message for mixed case admin role', () => { renderWrapper(); - expect(screen.getByText(/super admins have full access/i)).toBeInTheDocument(); + expect(screen.getByText(/global staff have access/i)).toBeInTheDocument(); }); it('displays staff message for regular role without admin', () => { diff --git a/src/authz-module/components/RenderAdminRole.tsx b/src/authz-module/components/RenderAdminRole.tsx index 7d3eeb59..568a0120 100644 --- a/src/authz-module/components/RenderAdminRole.tsx +++ b/src/authz-module/components/RenderAdminRole.tsx @@ -1,4 +1,5 @@ import { useIntl } from '@edx/frontend-platform/i18n'; +import { SUPERUSER_ROLE } from '@src/authz-module/constants'; import messages from '@src/authz-module/audit-user/messages'; interface RenderAdminRoleProps { @@ -7,8 +8,7 @@ interface RenderAdminRoleProps { const RenderAdminRole = ({ role }: RenderAdminRoleProps) => { const intl = useIntl(); - // Determine which message to show based on role - const messageKey = role?.toLowerCase().includes('admin') + const messageKey = role === SUPERUSER_ROLE ? 'authz.user.table.permissions.role.admin' : 'authz.user.table.permissions.role.staff'; diff --git a/src/authz-module/components/TableCells.test.tsx b/src/authz-module/components/TableCells.test.tsx index af121e36..904e1fd3 100644 --- a/src/authz-module/components/TableCells.test.tsx +++ b/src/authz-module/components/TableCells.test.tsx @@ -260,7 +260,7 @@ describe('TableCells Components', () => { const viewButton = screen.getByRole('button', { name: /view/i }); await user.click(viewButton); - expect(mockNavigate).toHaveBeenCalledWith('/authz/user/user+with@special.chars'); + expect(mockNavigate).toHaveBeenCalledWith(`/authz/user/${encodeURIComponent('user+with@special.chars')}`); }); }); diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx index 1daf4ad6..8a0954ea 100644 --- a/src/authz-module/components/TableCells.tsx +++ b/src/authz-module/components/TableCells.tsx @@ -9,7 +9,8 @@ import { UserRoleWithPermissions, RoleToDelete } from '@src/types'; import { useNavigate } from 'react-router-dom'; import { useContext, useMemo } from 'react'; import { - ADMIN_ROLES, DJANGO_MANAGED_ROLES, MAP_ROLE_KEY_TO_LABEL, + ADMIN_ROLES, buildUserPath, DJANGO_MANAGED_ROLES, getScopeContextType, + MAP_ROLE_KEY_TO_LABEL, SUPERUSER_ROLE, } from '@src/authz-module/constants'; import { Icon, IconButton, OverlayTrigger, Tooltip, DataTableContext, @@ -62,7 +63,7 @@ const NameCell = ({ row }: CellProps) => { const ViewActionCell = ({ row }: CellProps) => { const { formatMessage } = useIntl(); const navigate = useNavigate(); - const viewPath = `/authz/user/${row.original.username}`; + const viewPath = buildUserPath(row.original.username ?? ''); return ( { iconSrc: RESOURCE_ICONS.GLOBAL, }; } - const scopeIcon = row.original.role?.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE; + const scopeIcon = getScopeContextType(row.original.scope) === 'library' ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE; return { scopeText: row.original.scope, iconSrc: scopeIcon, @@ -125,7 +126,7 @@ const PermissionsCell = ({ row }: CellProps) => { { isDjangoRole ? formatMessage( messages['authz.user.table.permissions.access.label'], - { accessType: role === 'django.superuser' ? 'total' : 'partial' }, + { accessType: role === SUPERUSER_ROLE ? 'total' : 'partial' }, ) : formatMessage(messages['authz.user.table.permissions.available.count'], { count })} diff --git a/src/authz-module/components/constants.ts b/src/authz-module/components/constants.ts index 21f43bf0..27e9eef1 100644 --- a/src/authz-module/components/constants.ts +++ b/src/authz-module/components/constants.ts @@ -1,74 +1,44 @@ import type { IntlShape } from '@edx/frontend-platform/i18n'; import { Language, LibraryBooks, School } from '@openedx/paragon/icons'; +import { allRolesMetadata } from '@src/authz-module/roles-permissions'; +import { GLOBAL_STAFF_ROLE, MAP_ROLE_KEY_TO_LABEL, SUPERUSER_ROLE } from '@src/authz-module/constants'; import messages from './messages'; -export const getRolesFiltersOptions = (intl: IntlShape) => [ - { - groupName: intl.formatMessage(messages['authz.team.members.table.group.global']), - groupIcon: Language, - displayName: 'Super Admin', - value: 'super_admin', - }, - { - groupName: intl.formatMessage(messages['authz.team.members.table.group.global']), - groupIcon: Language, - displayName: 'Global Staff', - value: 'global_staff', - }, - - { - groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']), - groupIcon: School, - displayName: 'Course Admin', - value: 'course_admin', - }, - { - groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']), - groupIcon: School, - displayName: 'Course Staff', - value: 'course_staff', - }, - { - groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']), - groupIcon: School, - displayName: 'Course Editor', - value: 'course_editor', - }, - { - groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']), - groupIcon: School, - displayName: 'Course Auditor', - value: 'course_auditor', - }, - - { - groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']), - groupIcon: LibraryBooks, - displayName: 'Library Admin', - value: 'library_admin', - }, - { - groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']), - groupIcon: LibraryBooks, - displayName: 'Library Author', - value: 'library_author', - }, - { - groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']), - groupIcon: LibraryBooks, - displayName: 'Library Contributor', - value: 'library_contributor', - }, - { - groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']), - groupIcon: LibraryBooks, - displayName: 'Library User', - value: 'library_user', - }, -]; - export const RESOURCE_ICONS = { COURSE: School, LIBRARY: LibraryBooks, GLOBAL: Language, }; + +// The API expects the underscore format when roles are sent as filter values, +// while role data received from the API uses the dotted format (e.g. django.superuser). +const GLOBAL_ROLE_FILTER_OPTIONS = [ + { value: 'super_admin', displayName: MAP_ROLE_KEY_TO_LABEL[SUPERUSER_ROLE] }, + { value: 'global_staff', displayName: MAP_ROLE_KEY_TO_LABEL[GLOBAL_STAFF_ROLE] }, +]; + +export const getRolesFiltersOptions = (intl: IntlShape) => { + const globalGroup = { + groupName: intl.formatMessage(messages['authz.team.members.table.group.global']), + groupIcon: RESOURCE_ICONS.GLOBAL, + }; + const contextGroups = { + course: { + groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']), + groupIcon: RESOURCE_ICONS.COURSE, + }, + library: { + groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']), + groupIcon: RESOURCE_ICONS.LIBRARY, + }, + }; + + return [ + ...GLOBAL_ROLE_FILTER_OPTIONS.map((role) => ({ ...globalGroup, ...role })), + ...allRolesMetadata.map((meta) => ({ + ...contextGroups[meta.contextType], + displayName: meta.name, + value: meta.role, + })), + ]; +}; diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index a3dc353a..aa386682 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -1,4 +1,7 @@ -const ORG_AGGREGATE_SCOPE_BUILDERS = { +import type { ContextType } from '@src/types'; +import { allRolesMetadata } from './roles-permissions'; + +const ORG_AGGREGATE_SCOPE_BUILDERS: Record string> = { course: (orgSlug: string) => `course-v1:${orgSlug}+*`, library: (orgSlug: string) => `lib:${orgSlug}:*`, }; @@ -9,14 +12,10 @@ export const getOrgAggregateScopeKey = (contextType: string, orgSlug: string): s return builder(orgSlug); }; +export const getScopeContextType = (scope: string): ContextType => (scope.startsWith('lib') ? 'library' : 'course'); + export const DEFAULT_TOAST_DELAY = 5000; export const RETRY_TOAST_DELAY = 120_000; // 2 minutes -export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({ - username: 'skeleton', - name: '', - email: '', - roles: [], -})); export const ROUTES = { HOME_PATH: '/authz', @@ -24,6 +23,8 @@ export const ROUTES = { ASSIGN_ROLE_WIZARD_PATH: '/assign-role', }; +export const buildUserPath = (username: string) => `${ROUTES.HOME_PATH}${ROUTES.AUDIT_USER_PATH.replace(':username', encodeURIComponent(username))}`; + export const buildWizardPath = (options?: { users?: string; from?: string }) => { const base = `${ROUTES.HOME_PATH}${ROUTES.ASSIGN_ROLE_WIZARD_PATH}`; if (!options) { return base; } @@ -34,42 +35,20 @@ export const buildWizardPath = (options?: { users?: string; from?: string }) => return query ? `${base}?${query}` : base; }; -export enum RoleOperationErrorStatus { - USER_NOT_FOUND = 'user_not_found', - USER_ALREADY_HAS_ROLE = 'user_already_has_role', - USER_DOES_NOT_HAVE_ROLE = 'user_does_not_have_role', - ROLE_ASSIGNMENT_ERROR = 'role_assignment_error', - ROLE_REMOVAL_ERROR = 'role_removal_error', -} - export const MAX_TABLE_FILTERS_APPLIED = 10; -export const AUTHZ_HOME_PATH = '/authz'; +// Role data received from the API uses the dotted format for Django-managed roles. +export const SUPERUSER_ROLE = 'django.superuser'; +export const GLOBAL_STAFF_ROLE = 'django.globalstaff'; +export const DJANGO_MANAGED_ROLES = [SUPERUSER_ROLE, GLOBAL_STAFF_ROLE]; export const MAP_ROLE_KEY_TO_LABEL: Record = { - library_admin: 'Library Admin', - library_author: 'Library Author', - library_contributor: 'Library Contributor', - library_user: 'Library User', - course_admin: 'Course Admin', - course_staff: 'Course Staff', - course_editor: 'Course Editor', - course_auditor: 'Course Auditor', - 'django.superuser': 'Super Admin', - 'django.globalstaff': 'Global Staff', + ...Object.fromEntries(allRolesMetadata.map((meta) => [meta.role, meta.name])), + [SUPERUSER_ROLE]: 'Super Admin', + [GLOBAL_STAFF_ROLE]: 'Global Staff', }; -export const DJANGO_MANAGED_ROLES = ['django.superuser', 'django.globalstaff']; - export const TABLE_DEFAULT_PAGE_SIZE = 10; export const DEFAULT_FILTER_PAGE_SIZE = 5; export const ADMIN_ROLES = ['course_admin', 'library_admin']; - -// Resource Type Definitions -export const RESOURCE_TYPES = { - LIBRARY: 'library', - COURSE: 'course', -} as const; - -export type ResourceType = typeof RESOURCE_TYPES[keyof typeof RESOURCE_TYPES]; diff --git a/src/authz-module/hooks/useQuerySettings.ts b/src/authz-module/hooks/useQuerySettings.ts index bc40a61a..9ec7f94c 100644 --- a/src/authz-module/hooks/useQuerySettings.ts +++ b/src/authz-module/hooks/useQuerySettings.ts @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react'; import { QuerySettings } from '@src/authz-module/data/api'; +import { TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants'; interface DataTableFilters { pageSize: number; @@ -32,7 +33,7 @@ export const useQuerySettings = ( scopes: null, organizations: null, search: null, - pageSize: 10, + pageSize: TABLE_DEFAULT_PAGE_SIZE, pageIndex: 0, order: null, sortBy: null, @@ -49,7 +50,7 @@ export const useQuerySettings = ( const scopesFilter = tableFilters.filters?.find((filter) => filter.id === 'scope')?.value?.join(',') ?? ''; // Extract pagination - const { pageSize = 10, pageIndex = 0 } = tableFilters; + const { pageSize = TABLE_DEFAULT_PAGE_SIZE, pageIndex = 0 } = tableFilters; // Extract and convert sorting let sortByOption = ''; diff --git a/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx b/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx index d15adab4..59a1264f 100644 --- a/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx +++ b/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx @@ -10,14 +10,11 @@ import { RoleMetadata } from '@src/types'; import { useToastManager } from '@src/components/ToastManager/ToastManagerContext'; import SelectUsersAndRoleStep from './components/SelectUsersAndRoleStep'; import DefineApplicationScopeStep from './components/DefineApplicationScopeStep'; -import { libraryRolesMetadata } from '../roles-permissions/library/constants'; -import { courseRolesMetadata } from '../roles-permissions/course/constants'; +import { allRolesMetadata } from '../roles-permissions'; import { useValidateUsers, useAssignTeamMembersRole } from '../data/hooks'; import messages from './messages'; import { formatRoleAssignmentError } from './utils'; -const allRolesMetadata = [...courseRolesMetadata, ...libraryRolesMetadata]; - const STEPS = { SELECT_USERS_AND_ROLE: 'select-users-and-role', DEFINE_APPLICATION_SCOPE: 'define-application-scope', diff --git a/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx index df3ec26d..7951e937 100644 --- a/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx +++ b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx @@ -2,7 +2,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import { useIntl } from '@edx/frontend-platform/i18n'; import AssignRoleWizard from './AssignRoleWizard'; import AuthZLayout from '../components/AuthZLayout'; -import { ROUTES } from '../constants'; +import { buildUserPath, ROUTES } from '../constants'; import messages from './messages'; const AssignRoleWizardPage = () => { @@ -15,7 +15,7 @@ const AssignRoleWizardPage = () => { const presetUser = initialUsers.trim(); const destination = (presetUser && !presetUser.includes(',')) - ? `${ROUTES.HOME_PATH}/user/${presetUser}` + ? buildUserPath(presetUser) : returnTo; return ( diff --git a/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.tsx b/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.tsx index 171b162c..846b46d4 100644 --- a/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.tsx +++ b/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.tsx @@ -1,16 +1,14 @@ import { useState, useEffect, useMemo } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Alert } from '@openedx/paragon'; -import { courseRolesMetadata } from '@src/authz-module/roles-permissions/course/constants'; -import { libraryRolesMetadata } from '@src/authz-module/roles-permissions/library/constants'; +import { allRolesMetadata } from '@src/authz-module/roles-permissions'; +import type { ContextType } from '@src/types'; import useScopeListData from '../hooks/useScopeListData'; import ScopeFilterBar from './ScopeFilterBar'; import ScopeList from './ScopeList'; import messages from '../messages'; -const allRolesMetadata = [...courseRolesMetadata, ...libraryRolesMetadata]; - -function getContextType(role: string | null): string | undefined { +function getContextType(role: string | null): ContextType | undefined { if (!role) { return undefined; } return allRolesMetadata.find((r) => r.role === role)?.contextType; } diff --git a/src/authz-module/role-assignation-wizard/components/SelectUsersAndRoleStep.test.tsx b/src/authz-module/role-assignation-wizard/components/SelectUsersAndRoleStep.test.tsx index a5424a94..be90bdea 100644 --- a/src/authz-module/role-assignation-wizard/components/SelectUsersAndRoleStep.test.tsx +++ b/src/authz-module/role-assignation-wizard/components/SelectUsersAndRoleStep.test.tsx @@ -1,27 +1,28 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWrapper } from '@src/setupTest'; +import type { RoleMetadata } from '@src/types'; import SelectUsersAndRoleStep from './SelectUsersAndRoleStep'; jest.mock('@edx/frontend-platform', () => ({ getConfig: () => ({ LMS_BASE_URL: 'http://localhost:8000' }), })); -const libraryRole = { +const libraryRole: RoleMetadata = { role: 'library_admin', name: 'Library Admin', description: 'Can manage the library.', contextType: 'library', }; -const courseRole = { +const courseRole: RoleMetadata = { role: 'course_admin', name: 'Course Admin', description: 'Can manage the course team.', contextType: 'course', }; -const disabledRole = { +const disabledRole: RoleMetadata = { role: 'course_editor', name: 'Course Editor', description: 'Can edit course content.', diff --git a/src/authz-module/role-assignation-wizard/components/SelectUsersAndRoleStep.tsx b/src/authz-module/role-assignation-wizard/components/SelectUsersAndRoleStep.tsx index 9757c3cb..b07e0c3d 100644 --- a/src/authz-module/role-assignation-wizard/components/SelectUsersAndRoleStep.tsx +++ b/src/authz-module/role-assignation-wizard/components/SelectUsersAndRoleStep.tsx @@ -5,7 +5,7 @@ import { Tooltip, } from '@openedx/paragon'; import { getConfig } from '@edx/frontend-platform'; -import { RoleMetadata } from '@src/types'; +import { ContextType, RoleMetadata } from '@src/types'; import HighlightedUsersInput from './HighlightedUsersInput'; import messages from '../messages'; @@ -19,8 +19,7 @@ interface SelectUsersAndRoleStepProps { inputRef?: React.RefObject; } -const CONTEXT_ORDER = ['library', 'course']; -const ADMIN_URL = `${getConfig().LMS_BASE_URL}/admin`; +const CONTEXT_ORDER: ContextType[] = ['library', 'course']; const SelectUsersAndRoleStep = ({ users, @@ -123,7 +122,7 @@ const SelectUsersAndRoleStep = ({

{intl.formatMessage(messages['wizard.step1.docs.heading'])}

{intl.formatMessage(messages['wizard.step1.docs.body'])}{' '} - + {intl.formatMessage(messages['wizard.step1.docs.link'])}

diff --git a/src/authz-module/role-assignation-wizard/constants.ts b/src/authz-module/role-assignation-wizard/constants.ts index 8fc39484..61d53f93 100644 --- a/src/authz-module/role-assignation-wizard/constants.ts +++ b/src/authz-module/role-assignation-wizard/constants.ts @@ -1,3 +1,5 @@ +// Error codes returned by the role-assignment API. Each code has a matching +// `wizard.save.error.` message in ./messages.ts. export const ROLE_ASSIGNMENT_ERRORS = { USER_ALREADY_HAS_ROLE: 'user_already_has_role', USER_NOT_FOUND: 'user_not_found', diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts index 205a6976..33b3c2c2 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts @@ -1,13 +1,13 @@ import { useMemo } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Scope } from '@src/types'; +import { ContextType, Scope } from '@src/types'; import { useOrgs, useScopes } from '@src/authz-module/data/hooks'; import { getOrgAggregateScopeKey } from '@src/authz-module/constants'; import messages from '../messages'; import useScopePermissions from './useScopePermissions'; interface UseScopeListDataParams { - contextType: string | undefined; + contextType: ContextType | undefined; search: string; orgs: string[]; } diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts index a92122ec..9a4d7eb2 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts @@ -2,9 +2,10 @@ import { useMemo } from 'react'; import { useValidateUserPermissions } from '@src/data/hooks'; import { getOrgAggregateScopeKey } from '@src/authz-module/constants'; import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from '@src/authz-module/roles-permissions'; +import type { ContextType } from '@src/types'; interface UseScopePermissionsParams { - contextType: string | undefined; + contextType: ContextType | undefined; orderedOrgs: string[]; } diff --git a/src/authz-module/role-assignation-wizard/utils.ts b/src/authz-module/role-assignation-wizard/utils.ts index 46a1573c..2f0254a8 100644 --- a/src/authz-module/role-assignation-wizard/utils.ts +++ b/src/authz-module/role-assignation-wizard/utils.ts @@ -5,19 +5,15 @@ import messages from './messages'; type RoleAssignmentError = PutAssignTeamMembersRoleResponse['errors'][number]; +const KNOWN_ERRORS: string[] = Object.values(ROLE_ASSIGNMENT_ERRORS); + export const formatRoleAssignmentError = ( intl: IntlShape, e: RoleAssignmentError, ): string => { const params = { userIdentifier: e.userIdentifier, scope: e.scope }; - if (e.error === ROLE_ASSIGNMENT_ERRORS.USER_ALREADY_HAS_ROLE) { - return intl.formatMessage(messages['wizard.save.error.user_already_has_role'], params); - } - if (e.error === ROLE_ASSIGNMENT_ERRORS.USER_NOT_FOUND) { - return intl.formatMessage(messages['wizard.save.error.user_not_found'], params); - } - if (e.error === ROLE_ASSIGNMENT_ERRORS.ROLE_ASSIGNMENT_ERROR) { - return intl.formatMessage(messages['wizard.save.error.role_assignment_error'], params); + if (KNOWN_ERRORS.includes(e.error)) { + return intl.formatMessage(messages[`wizard.save.error.${e.error}`], params); } return intl.formatMessage(messages['wizard.save.error.default'], { ...params, error: e.error }); }; diff --git a/src/authz-module/roles-permissions/index.ts b/src/authz-module/roles-permissions/index.ts index 690fa56a..e29da434 100644 --- a/src/authz-module/roles-permissions/index.ts +++ b/src/authz-module/roles-permissions/index.ts @@ -1,3 +1,7 @@ +import type { RoleMetadata } from '@src/types'; +import { libraryRolesMetadata } from './library/constants'; +import { courseRolesMetadata } from './course/constants'; + export { CONTENT_LIBRARY_PERMISSIONS, libraryResourceTypes, @@ -13,3 +17,5 @@ export { rolesObject, courseRolesMetadata, } from './course/constants'; + +export const allRolesMetadata: RoleMetadata[] = [...courseRolesMetadata, ...libraryRolesMetadata]; diff --git a/src/authz-module/utils.tsx b/src/authz-module/utils.tsx index fd2a422e..7ae0e429 100644 --- a/src/authz-module/utils.tsx +++ b/src/authz-module/utils.tsx @@ -1,6 +1,7 @@ import { Icon } from '@openedx/paragon'; import { FilterList } from '@openedx/paragon/icons'; import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from './roles-permissions'; +import { getScopeContextType } from './constants'; /** * Returns a header value for a DataTable column that shows a filter icon @@ -31,16 +32,11 @@ export const getCellHeader = (columnId: string, columnTitle: string, filtersAppl return columnTitle; }; -export const getScopeManageAction = (scope: string) => { - if (scope.startsWith('lib')) { - return CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM; - } - if (scope.startsWith('course')) { - return CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM; - } - // Default fallback or throw error for unknown scopes - return CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM; -}; +export const getScopeManageAction = (scope: string) => ( + getScopeContextType(scope) === 'library' + ? CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM + : CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM +); export const getScopeManageActionPermission = (scope: string) => { const action = getScopeManageAction(scope); diff --git a/src/constants.ts b/src/constants.ts index fc01102d..bfebd972 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -15,6 +15,6 @@ export const STATUS_404 = 404; export const ERROR_STATUS: ErrorStatusCode = { [CustomErrors.NO_ACCESS]: [403, 401], - [CustomErrors.NOT_FOUND]: [400, 404], + [CustomErrors.NOT_FOUND]: [STATUS_400, STATUS_404], [CustomErrors.SERVER_ERROR]: [500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511], }; From 68244d7599fe668390de79be0531cbe0b5e45e28 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Wed, 24 Jun 2026 20:58:31 +1000 Subject: [PATCH 2/3] fix: add type for contextType and fix RolePermissions test typing --- .../roles-permissions/RolesPermissions.test.tsx | 2 ++ src/authz-module/roles-permissions/library/utils.test.ts | 9 +++++---- src/types.ts | 4 +++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/authz-module/roles-permissions/RolesPermissions.test.tsx b/src/authz-module/roles-permissions/RolesPermissions.test.tsx index a29fa0ae..66d0a4ce 100644 --- a/src/authz-module/roles-permissions/RolesPermissions.test.tsx +++ b/src/authz-module/roles-permissions/RolesPermissions.test.tsx @@ -25,6 +25,7 @@ jest.mock('./course/constants', () => ({ ], coursePermissions: [], courseResourceTypes: [], + courseRolesMetadata: [], })); jest.mock('./library/constants', () => ({ @@ -35,6 +36,7 @@ jest.mock('./library/constants', () => ({ ], libraryPermissions: [], libraryResourceTypes: [], + libraryRolesMetadata: [], })); jest.mock('@openedx/paragon', () => ({ diff --git a/src/authz-module/roles-permissions/library/utils.test.ts b/src/authz-module/roles-permissions/library/utils.test.ts index 0d4f0ff5..f5c9b2bb 100644 --- a/src/authz-module/roles-permissions/library/utils.test.ts +++ b/src/authz-module/roles-permissions/library/utils.test.ts @@ -1,4 +1,5 @@ import { createIntl } from '@edx/frontend-platform/i18n'; +import type { Role } from '@src/types'; import { buildPermissionMatrixByResource } from './utils'; const intl = createIntl({ locale: 'en', messages: {} }); @@ -14,15 +15,15 @@ const permissions = [ const resources = [ { key: 'library', label: 'Library', description: '' }, ]; -const roles = [ +const roles: Role[] = [ { - name: 'admin', permissions: ['create_library', 'edit_library'], userCount: 2, role: 'admin', description: '', contextType: '', scope: '', + name: 'admin', permissions: ['create_library', 'edit_library'], userCount: 2, role: 'admin', description: '', contextType: 'library', scope: '', }, { - name: 'editor', permissions: ['edit_library'], userCount: 2, role: 'editor', description: '', contextType: '', scope: '', + name: 'editor', permissions: ['edit_library'], userCount: 2, role: 'editor', description: '', contextType: 'library', scope: '', }, { - name: 'guest', permissions: [], userCount: 2, role: 'guest', description: '', contextType: '', scope: '', + name: 'guest', permissions: [], userCount: 2, role: 'guest', description: '', contextType: 'library', scope: '', }, ]; diff --git a/src/types.ts b/src/types.ts index e473331b..aff28d59 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,11 +27,13 @@ export interface LibraryMetadata { allowPublicRead: boolean; } +export type ContextType = 'course' | 'library'; + export interface RoleMetadata { role: string; name: string; description: string; - contextType: string; + contextType: ContextType; disabled?: boolean; } // TODO: remove unnecessary fields when libraries gets removed From 44c4a766fe3aad5ebdccc5180fff1e9aa9e7177c Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Thu, 25 Jun 2026 10:42:34 +1000 Subject: [PATCH 3/3] fix: align global staff role with backend key --- src/authz-module/components/RenderAdminRole.test.tsx | 2 +- src/authz-module/components/TableCells.test.tsx | 6 +++--- src/authz-module/constants.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/authz-module/components/RenderAdminRole.test.tsx b/src/authz-module/components/RenderAdminRole.test.tsx index 172f6c0a..601ef8ca 100644 --- a/src/authz-module/components/RenderAdminRole.test.tsx +++ b/src/authz-module/components/RenderAdminRole.test.tsx @@ -6,7 +6,7 @@ import RenderAdminRole from './RenderAdminRole'; describe('RenderAdminRole', () => { const adminRole = 'course_admin'; const superuserRole = 'django.superuser'; - const staffRole = 'django.globalstaff'; + const staffRole = 'django.staff'; const instructorRole = 'instructor'; const emptyRole = ''; const mixedCaseAdminRole = 'Library_Admin'; diff --git a/src/authz-module/components/TableCells.test.tsx b/src/authz-module/components/TableCells.test.tsx index 904e1fd3..0ef46916 100644 --- a/src/authz-module/components/TableCells.test.tsx +++ b/src/authz-module/components/TableCells.test.tsx @@ -353,7 +353,7 @@ describe('TableCells Components', () => { row: { id: '0', original: { - role: 'django.globalstaff', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + role: 'django.staff', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, }, }, column: { id: 'org' }, @@ -409,7 +409,7 @@ describe('TableCells Components', () => { row: { id: '0', original: { - role: 'django.globalstaff', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, + role: 'django.staff', org: 'Test Org', scope: 'Test Scope', permissionCount: 1, }, }, column: { id: 'scope' }, @@ -465,7 +465,7 @@ describe('TableCells Components', () => { row: { id: '0', original: { - role: 'django.globalstaff', + role: 'django.staff', permissionCount: 5, org: 'Test Org', scope: 'Test Scope', diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index aa386682..351e78cd 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -39,7 +39,7 @@ export const MAX_TABLE_FILTERS_APPLIED = 10; // Role data received from the API uses the dotted format for Django-managed roles. export const SUPERUSER_ROLE = 'django.superuser'; -export const GLOBAL_STAFF_ROLE = 'django.globalstaff'; +export const GLOBAL_STAFF_ROLE = 'django.staff'; export const DJANGO_MANAGED_ROLES = [SUPERUSER_ROLE, GLOBAL_STAFF_ROLE]; export const MAP_ROLE_KEY_TO_LABEL: Record = {