diff --git a/src/authz-module/audit-user/index.tsx b/src/authz-module/audit-user/index.tsx index 9125030b..55ff2c12 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'; @@ -94,7 +94,7 @@ const AuditUserPage = () => { useEffect(() => { if (!user && !isLoadingUser) { if (!isErrorUser || getHttpErrorStatus(errorUser) === 404) { - navigate(AUTHZ_HOME_PATH); + navigate(ROUTES.HOME_PATH); } } }, [user, isLoadingUser, navigate, isErrorUser, errorUser]); @@ -109,7 +109,7 @@ const AuditUserPage = () => { const navLinks = useMemo(() => [ { label: formatMessage(baseMessages['authz.management.home.nav.link']), - to: AUTHZ_HOME_PATH, + to: ROUTES.HOME_PATH, }, ], [formatMessage]); @@ -216,7 +216,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..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'; @@ -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 397e39a8..a4fa14a1 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')}`); }); it('disables the view action and shows a tooltip when course authoring is disabled for the course', async () => { @@ -364,7 +364,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' }, @@ -420,7 +420,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' }, @@ -476,7 +476,7 @@ describe('TableCells Components', () => { row: { id: '0', original: { - role: 'django.globalstaff', + role: 'django.staff', permissionCount: 5, org: 'Test Org', scope: 'Test Scope', @@ -516,7 +516,7 @@ describe('TableCells Components', () => { original: { role: 'library_admin', org: 'Test Org', - scope: 'Test Scope', + scope: 'lib:Org:TestScope', permissionCount: 1, canManageScope: true, }, @@ -538,7 +538,7 @@ describe('TableCells Components', () => { expect(deleteButton).toBeInTheDocument(); await user.click(deleteButton); - expect(mockOnClickDeleteButton).toHaveBeenCalledWith({ name: 'Library Admin', role: 'library_admin', scope: 'Test Scope' }); + expect(mockOnClickDeleteButton).toHaveBeenCalledWith({ name: 'Library Admin', role: 'library_admin', scope: 'lib:Org:TestScope' }); }); it('renders a disabled delete icon for admin roles when isUserAuthenticatedPage is true', () => { @@ -586,7 +586,7 @@ describe('TableCells Components', () => { original: { role: 'django.superuser', org: 'Test Org', - scope: 'Test Scope', + scope: 'lib:Org:TestScope', permissionCount: 1, }, }; @@ -610,7 +610,7 @@ describe('TableCells Components', () => { }); const customRow = { original: { - ...baseRow, + ...baseRow.original, canManageScope: false, }, }; diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx index 0b7febba..2d0d184d 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, type ComponentProps } 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, @@ -97,8 +98,8 @@ const NameCell = ({ row }: CellProps) => { const ViewActionCell = ({ row, isCourseEnabled }: CellProps & Partial) => { const { formatMessage } = useIntl(); const navigate = useNavigate(); - const viewPath = `/authz/user/${row.original.username}`; - const isCourseScope = !row.original.role?.startsWith('lib') && !DJANGO_MANAGED_ROLES.includes(row.original.role); + const viewPath = buildUserPath(row.original.username ?? ''); + const isCourseScope = getScopeContextType(row.original.scope) === 'course' && !DJANGO_MANAGED_ROLES.includes(row.original.role); const isDisabled = isCourseEnabled !== undefined && isCourseScope && !isCourseEnabled(row.original.scope); if (isDisabled) { @@ -144,7 +145,7 @@ const ScopeCell = ({ row }: CellProps) => { 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, @@ -174,10 +175,10 @@ const PermissionsCell = ({ row }: CellProps) => { const isDjangoRole = DJANGO_MANAGED_ROLES.includes(role); return ( - { isDjangoRole + {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 })} @@ -239,7 +240,7 @@ const ActionsCell = ({ {formatMessage(messages['authz.user.table.delete.action.djangorole.tooltip'])} - )} + )} > {formatMessage(messages['authz.user.table.delete.action.adminrole.tooltip'])} - )} + )} > (scopesData?.pages?.flatMap((p) => p.results) ?? []) // Libraries are always available; courses only when the authoring flag is enabled for them. - .filter((scope) => scope.externalKey?.startsWith('lib') || isCourseEnabled(scope.externalKey)) + .filter((scope) => getScopeContextType(scope.externalKey ?? '') === 'library' || isCourseEnabled(scope.externalKey)) .map((scope) => { - const scopeIcon = scope.externalKey?.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE; - let groupName = formatMessage(messages['authz.team.members.table.group.courses']); - if (scope.externalKey?.startsWith('lib')) { - groupName = formatMessage(messages['authz.team.members.table.group.libraries']); - } + const isLibraryScope = getScopeContextType(scope.externalKey ?? '') === 'library'; + const scopeIcon = isLibraryScope ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE; + const groupName = isLibraryScope + ? formatMessage(messages['authz.team.members.table.group.libraries']) + : formatMessage(messages['authz.team.members.table.group.courses']); return { displayName: scope.displayName, value: scope.externalKey, diff --git a/src/authz-module/components/UserPermissions.tsx b/src/authz-module/components/UserPermissions.tsx index efa56be4..c8b756b0 100644 --- a/src/authz-module/components/UserPermissions.tsx +++ b/src/authz-module/components/UserPermissions.tsx @@ -1,6 +1,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { DJANGO_MANAGED_ROLES } from '@src/authz-module/constants'; import { + LIBRARY_ROLE_KEYS, courseResourceTypes, coursePermissions, courseRolesWithPermissions, @@ -36,7 +37,7 @@ const UserPermissions = ({ row }: UserPermissionsProps) => { // 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 isLibraryRole = LIBRARY_ROLE_KEYS.includes(roleKey); const config = isLibraryRole ? { resourceTypes: libraryResourceTypes, diff --git a/src/authz-module/components/constants.ts b/src/authz-module/components/constants.ts index d2afdcae..9c9c55cd 100644 --- a/src/authz-module/components/constants.ts +++ b/src/authz-module/components/constants.ts @@ -1,84 +1,45 @@ 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', - contextType: 'global', - }, - { - groupName: intl.formatMessage(messages['authz.team.members.table.group.global']), - groupIcon: Language, - displayName: 'Global Staff', - value: 'global_staff', - contextType: 'global', - }, - - { - groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']), - groupIcon: School, - displayName: 'Course Admin', - value: 'course_admin', - contextType: 'course', - }, - { - groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']), - groupIcon: School, - displayName: 'Course Staff', - value: 'course_staff', - contextType: 'course', - }, - { - groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']), - groupIcon: School, - displayName: 'Course Editor', - value: 'course_editor', - contextType: 'course', - }, - { - groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']), - groupIcon: School, - displayName: 'Course Auditor', - value: 'course_auditor', - contextType: 'course', - }, - - { - groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']), - groupIcon: LibraryBooks, - displayName: 'Library Admin', - value: 'library_admin', - contextType: 'library', - }, - { - groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']), - groupIcon: LibraryBooks, - displayName: 'Library Author', - value: 'library_author', - contextType: 'library', - }, - { - groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']), - groupIcon: LibraryBooks, - displayName: 'Library Contributor', - value: 'library_contributor', - contextType: 'library', - }, - { - groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']), - groupIcon: LibraryBooks, - displayName: 'Library User', - value: 'library_user', - contextType: 'library', - }, -]; - 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], contextType: 'global' }, + { value: 'global_staff', displayName: MAP_ROLE_KEY_TO_LABEL[GLOBAL_STAFF_ROLE], contextType: 'global' }, +]; + +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, + contextType: meta.contextType, + })), + ]; +}; diff --git a/src/authz-module/constants.test.ts b/src/authz-module/constants.test.ts index 60b4f9d0..a0ca98cd 100644 --- a/src/authz-module/constants.test.ts +++ b/src/authz-module/constants.test.ts @@ -1,7 +1,7 @@ +import type { ContextType } from '@src/types'; import { - buildWizardPath, getOrgAggregateScopeKey, getPlatformAggregateScopeKey, ROUTES, + buildUserPath, buildWizardPath, getOrgAggregateScopeKey, getPlatformAggregateScopeKey, getScopeContextType, ROUTES, } from './constants'; -import type { ContextType } from './constants'; const BASE = `${ROUTES.HOME_PATH}${ROUTES.ASSIGN_ROLE_WIZARD_PATH}`; @@ -36,6 +36,30 @@ describe('buildWizardPath', () => { }); }); +describe('buildUserPath', () => { + it('builds the audit-user path for a username', () => { + expect(buildUserPath('alice')).toBe(`${ROUTES.HOME_PATH}/user/alice`); + }); + + it('encodes special characters in the username', () => { + expect(buildUserPath('a b@c')).toBe(`${ROUTES.HOME_PATH}/user/a%20b%40c`); + }); +}); + +describe('getScopeContextType', () => { + it('returns library for a library scope', () => { + expect(getScopeContextType('lib:MIT:intro')).toBe('library'); + }); + + it('returns course for a course scope', () => { + expect(getScopeContextType('course-v1:MIT+6.00x+2024')).toBe('course'); + }); + + it('defaults to course for an unrecognized scope', () => { + expect(getScopeContextType('MIT')).toBe('course'); + }); +}); + describe('getOrgAggregateScopeKey', () => { it('returns course wildcard scope for course context', () => { expect(getOrgAggregateScopeKey('course', 'MIT')).toBe('course-v1:MIT+*'); diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index c344f9aa..d855ecf8 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -1,11 +1,12 @@ +import type { ContextType } from '@src/types'; +import { allRolesMetadata } from './roles-permissions'; + // Resource Type Definitions -export const CONTEXT_TYPES = { +export const CONTEXT_TYPES: Record = { LIBRARY: 'library', COURSE: 'course', } as const; -export type ContextType = typeof CONTEXT_TYPES[keyof typeof CONTEXT_TYPES]; - const ORG_AGGREGATE_SCOPE_BUILDERS = { [CONTEXT_TYPES.COURSE]: (orgSlug: string) => `course-v1:${orgSlug}+*`, [CONTEXT_TYPES.LIBRARY]: (orgSlug: string) => `lib:${orgSlug}:*`, @@ -27,15 +28,10 @@ export const getPlatformAggregateScopeKey = (contextType: ContextType): string = if (!scope) { throw new Error(`Unknown contextType: "${contextType}"`); } return scope; }; +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', @@ -43,6 +39,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; } @@ -53,33 +51,19 @@ 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.staff'; +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; 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/AssignRoleWizardPage.tsx b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx index a146542d..5ac0f07f 100644 --- a/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx +++ b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx @@ -3,7 +3,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { useValidateUserPermissionsNonSuspense } from '@src/data/hooks'; import AssignRoleWizard from './AssignRoleWizard'; import AuthZLayout from '../components/AuthZLayout'; -import { ROUTES } from '../constants'; +import { buildUserPath, ROUTES } from '../constants'; import messages from './messages'; import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS, courseRolesMetadata, libraryRolesMetadata, @@ -21,7 +21,7 @@ const AssignRoleWizardPage = () => { const presetUser = initialUsers.trim(); const destination = (presetUser && !presetUser.includes(',')) - ? `${ROUTES.HOME_PATH}/user/${presetUser}` + ? buildUserPath(presetUser) : returnTo; const { data: managePermissions } = useValidateUserPermissionsNonSuspense(MANAGE_TEAM_PERMISSIONS); 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 3740ed0d..2ffd4677 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts @@ -1,15 +1,14 @@ 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 { useCourseAuthoringFlag } from '@src/authz-module/hooks/useCourseAuthoringFlag'; import { getOrgAggregateScopeKey, getPlatformAggregateScopeKey } from '@src/authz-module/constants'; -import type { ContextType } 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 e79487bc..d1421ff4 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts @@ -1,11 +1,11 @@ import { useMemo } from 'react'; import { useValidateUserPermissions } from '@src/data/hooks'; import { getOrgAggregateScopeKey, getPlatformAggregateScopeKey } from '@src/authz-module/constants'; -import type { ContextType } 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 2d46d3dd..5073799a 100644 --- a/src/authz-module/roles-permissions/index.ts +++ b/src/authz-module/roles-permissions/index.ts @@ -1,4 +1,5 @@ -import { CONTENT_COURSE_PERMISSIONS } from './course/constants'; +import type { RoleMetadata } from '@src/types'; +import { CONTENT_COURSE_PERMISSIONS, courseRolesMetadata as _courseRolesMetadata } from './course/constants'; import { CONTENT_LIBRARY_PERMISSIONS, libraryRolesMetadata as _libraryRolesMetadata } from './library/constants'; export { @@ -28,4 +29,6 @@ export const VIEW_TEAM_PERMISSIONS: { action: string }[] = [ { action: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM }, { action: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM }, ]; +export const allRolesMetadata: RoleMetadata[] = [..._courseRolesMetadata, ..._libraryRolesMetadata]; + export { buildPermissionMatrixByResource, getPermissionMetadata } from './utils'; 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], }; 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