From 44334dbc82e8bad94d983c958086f7e8e37acfb8 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Tue, 21 Jul 2026 19:32:38 +1000 Subject: [PATCH 1/5] feat: add checkbox for platform-wide assigment --- src/authz-module/constants.test.ts | 18 +++++++- src/authz-module/constants.ts | 11 +++++ .../hooks/useScopeListData.test.ts | 28 +++++++++-- .../hooks/useScopeListData.ts | 4 +- .../hooks/useScopePermissions.test.ts | 46 ++++++++++++++++--- .../hooks/useScopePermissions.ts | 34 ++++++++------ 6 files changed, 113 insertions(+), 28 deletions(-) diff --git a/src/authz-module/constants.test.ts b/src/authz-module/constants.test.ts index 8e5eace3..b4ec6444 100644 --- a/src/authz-module/constants.test.ts +++ b/src/authz-module/constants.test.ts @@ -1,4 +1,6 @@ -import { buildWizardPath, getOrgAggregateScopeKey, ROUTES } from './constants'; +import { + buildWizardPath, getOrgAggregateScopeKey, getPlatformAggregateScopeKey, ROUTES, +} from './constants'; const BASE = `${ROUTES.HOME_PATH}${ROUTES.ASSIGN_ROLE_WIZARD_PATH}`; @@ -46,3 +48,17 @@ describe('getOrgAggregateScopeKey', () => { expect(() => getOrgAggregateScopeKey('unknown', 'MIT')).toThrow('Unknown contextType: "unknown"'); }); }); + +describe('getPlatformAggregateScopeKey', () => { + it('returns the platform-wide course wildcard scope for course context', () => { + expect(getPlatformAggregateScopeKey('course')).toBe('course-v1:*'); + }); + + it('returns the platform-wide library wildcard scope for library context', () => { + expect(getPlatformAggregateScopeKey('library')).toBe('lib:*'); + }); + + it('throws for an unknown contextType', () => { + expect(() => getPlatformAggregateScopeKey('unknown')).toThrow('Unknown contextType: "unknown"'); + }); +}); diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index f26940cb..510490a9 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -9,6 +9,17 @@ export const getOrgAggregateScopeKey = (contextType: string, orgSlug: string): s return builder(orgSlug); }; +const PLATFORM_AGGREGATE_SCOPE_KEYS = { + course: 'course-v1:*', + library: 'lib:*', +}; + +export const getPlatformAggregateScopeKey = (contextType: string): string => { + const scope = PLATFORM_AGGREGATE_SCOPE_KEYS[contextType]; + if (!scope) { throw new Error(`Unknown contextType: "${contextType}"`); } + return scope; +}; + 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/role-assignation-wizard/hooks/useScopeListData.test.ts b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.test.ts index ff9fa445..aad09619 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.test.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.test.ts @@ -228,7 +228,7 @@ describe('useScopeListData', () => { }); describe('Platform aggregate scope item', () => { - it('returns null platformAggregateScopeItem for library context (disabled pending backend support)', () => { + it('returns null platformAggregateScopeItem when the user lacks platform permission', () => { mockUseScopes.mockReturnValue(makeScopesHook()); mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); @@ -241,7 +241,8 @@ describe('useScopeListData', () => { expect(result.current.platformAggregateScopeItem).toBeNull(); }); - it('returns null platformAggregateScopeItem for course context (disabled pending backend support)', () => { + it('emits the platform-wide course scope (course-v1:*) when permission is granted', () => { + mockUseScopePermissions.mockReturnValue({ hasPlatformPermission: true, orgHasPermission: {} }); mockUseScopes.mockReturnValue(makeScopesHook()); mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); @@ -251,10 +252,31 @@ describe('useScopeListData', () => { orgs: [], }), { wrapper }); - expect(result.current.platformAggregateScopeItem).toBeNull(); + expect(result.current.platformAggregateScopeItem).toMatchObject({ + externalKey: 'course-v1:*', + org: null, + }); + }); + + it('emits the platform-wide library scope (lib:*) when permission is granted', () => { + mockUseScopePermissions.mockReturnValue({ hasPlatformPermission: true, orgHasPermission: {} }); + mockUseScopes.mockReturnValue(makeScopesHook()); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + const { result } = renderHook(() => useScopeListData({ + contextType: 'library', + search: '', + orgs: [], + }), { wrapper }); + + expect(result.current.platformAggregateScopeItem).toMatchObject({ + externalKey: 'lib:*', + org: null, + }); }); it('returns null platformAggregateScopeItem when contextType is undefined', () => { + mockUseScopePermissions.mockReturnValue({ hasPlatformPermission: true, orgHasPermission: {} }); mockUseScopes.mockReturnValue(makeScopesHook()); mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts index 3c192f84..53d214d6 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts @@ -3,7 +3,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Scope } from '@src/types'; import { useOrgs, useScopes } from '@src/authz-module/data/hooks'; import { useCourseAuthoringFlag } from '@src/authz-module/hooks/useCourseAuthoringFlag'; -import { getOrgAggregateScopeKey } from '@src/authz-module/constants'; +import { getOrgAggregateScopeKey, getPlatformAggregateScopeKey } from '@src/authz-module/constants'; import messages from '../messages'; import useScopePermissions from './useScopePermissions'; @@ -83,7 +83,7 @@ const useScopeListData = ({ contextType, search, orgs }: UseScopeListDataParams) const platformAggregateScopeItem: Scope | null = (contextType && hasPlatformPermission) ? { - externalKey: '*', + externalKey: getPlatformAggregateScopeKey(contextType), displayName: platformAggregateLabel, description: aggregateDescription, org: null, diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.test.ts b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.test.ts index 30eb05a6..27b56f09 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.test.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.test.ts @@ -16,7 +16,24 @@ describe('useScopePermissions', () => { }); describe('hasPlatformPermission', () => { - it('is always false (pending backend support)', () => { + it('is true when the platform-wide entry (index 0) is allowed', () => { + mockUseValidateUserPermissions.mockReturnValue({ + data: [{ allowed: true }, { allowed: false }], + }); + + const { result } = renderHook(() => useScopePermissions({ + contextType: 'course', + orderedOrgs: ['MIT'], + })); + + expect(result.current.hasPlatformPermission).toBe(true); + }); + + it('is false when the platform-wide entry is not allowed', () => { + mockUseValidateUserPermissions.mockReturnValue({ + data: [{ allowed: false }, { allowed: true }], + }); + const { result } = renderHook(() => useScopePermissions({ contextType: 'course', orderedOrgs: ['MIT'], @@ -24,6 +41,15 @@ describe('useScopePermissions', () => { expect(result.current.hasPlatformPermission).toBe(false); }); + + it('is false when contextType is undefined', () => { + const { result } = renderHook(() => useScopePermissions({ + contextType: undefined, + orderedOrgs: ['MIT'], + })); + + expect(result.current.hasPlatformPermission).toBe(false); + }); }); describe('orgHasPermission', () => { @@ -37,8 +63,9 @@ describe('useScopePermissions', () => { }); it('maps allowed responses by org slug index for course context', () => { + // Index 0 is the platform-wide entry; org entries follow. mockUseValidateUserPermissions.mockReturnValue({ - data: [{ allowed: true }, { allowed: false }], + data: [{ allowed: false }, { allowed: true }, { allowed: false }], }); const { result } = renderHook(() => useScopePermissions({ @@ -50,8 +77,9 @@ describe('useScopePermissions', () => { }); it('maps allowed responses by org slug index for library context', () => { + // Index 0 is the platform-wide entry; org entries follow. mockUseValidateUserPermissions.mockReturnValue({ - data: [{ allowed: false }, { allowed: true }], + data: [{ allowed: false }, { allowed: false }, { allowed: true }], }); const { result } = renderHook(() => useScopePermissions({ @@ -86,37 +114,41 @@ describe('useScopePermissions', () => { }); describe('permission request construction', () => { - it('uses MANAGE_COURSE_TEAM action with course-v1 scope for course context', () => { + it('uses MANAGE_COURSE_TEAM action with the platform-wide and per-org course scopes', () => { renderHook(() => useScopePermissions({ contextType: 'course', orderedOrgs: ['MIT', 'HarvardX'], })); expect(mockUseValidateUserPermissions).toHaveBeenCalledWith([ + { action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, scope: 'course-v1:*' }, { action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, scope: 'course-v1:MIT+*' }, { action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, scope: 'course-v1:HarvardX+*' }, ]); }); - it('uses MANAGE_LIBRARY_TEAM action with lib scope for library context', () => { + it('uses MANAGE_LIBRARY_TEAM action with the platform-wide and per-org lib scopes', () => { renderHook(() => useScopePermissions({ contextType: 'library', orderedOrgs: ['MIT', 'HarvardX'], })); expect(mockUseValidateUserPermissions).toHaveBeenCalledWith([ + { action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, scope: 'lib:*' }, { action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, scope: 'lib:MIT:*' }, { action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, scope: 'lib:HarvardX:*' }, ]); }); - it('passes empty array to useValidateUserPermissions when orderedOrgs is empty', () => { + it('validates only the platform-wide scope when orderedOrgs is empty', () => { renderHook(() => useScopePermissions({ contextType: 'course', orderedOrgs: [], })); - expect(mockUseValidateUserPermissions).toHaveBeenCalledWith([]); + expect(mockUseValidateUserPermissions).toHaveBeenCalledWith([ + { action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, scope: 'course-v1:*' }, + ]); }); it('passes empty array when contextType is undefined', () => { diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts index a92122ec..6c90e3fd 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { useValidateUserPermissions } from '@src/data/hooks'; -import { getOrgAggregateScopeKey } from '@src/authz-module/constants'; +import { getOrgAggregateScopeKey, getPlatformAggregateScopeKey } from '@src/authz-module/constants'; import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from '@src/authz-module/roles-permissions'; interface UseScopePermissionsParams { @@ -17,32 +17,36 @@ const useScopePermissions = ({ contextType, orderedOrgs, }: UseScopePermissionsParams): UseScopePermissionsResult => { - // TODO: compute hasPlatformPermission once the backend supports validating platform-wide permissions. - const hasPlatformPermission = false; - - // Validate per-organization permissions for org-level aggregate options + // Validate the platform-wide aggregate (course-v1:* / lib:*) together with each + // org-level aggregate in a single request. The platform-wide scope is always at + // index 0; the per-org scopes follow in `orderedOrgs` order. // Note: Using glob patterns (*:org:*) - const orgPermissionRequests = useMemo(() => { - if (!orderedOrgs.length || !contextType) { return []; } + const permissionRequests = useMemo(() => { + if (!contextType) { return []; } const action = contextType === 'course' ? CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM : CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM; - return orderedOrgs.map((org) => ({ - action, - scope: getOrgAggregateScopeKey(contextType, org), - })); + return [ + { action, scope: getPlatformAggregateScopeKey(contextType) }, + ...orderedOrgs.map((org) => ({ + action, + scope: getOrgAggregateScopeKey(contextType, org), + })), + ]; }, [orderedOrgs, contextType]); - const { data: orgPerms } = useValidateUserPermissions(orgPermissionRequests); + const { data: perms } = useValidateUserPermissions(permissionRequests); + + const hasPlatformPermission = !!contextType && (perms?.[0]?.allowed ?? false); - // Build a map of `org: has_permission` + // Build a map of `org: has_permission`. Offset by 1 to skip the platform-wide entry. const orgHasPermission = useMemo(() => { const map: Record = {}; orderedOrgs.forEach((org, idx) => { - map[org] = orgPerms?.[idx]?.allowed ?? false; + map[org] = perms?.[idx + 1]?.allowed ?? false; }); return map; - }, [orderedOrgs, orgPerms]); + }, [orderedOrgs, perms]); return { hasPlatformPermission, orgHasPermission }; }; From 8e5eb48b610bc232b93a28c6e09ef398817ee1e9 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Fri, 24 Jul 2026 18:59:04 +1000 Subject: [PATCH 2/5] style: enforce type for contextType param --- src/authz-module/constants.test.ts | 5 ++-- src/authz-module/constants.ts | 28 +++++++++---------- .../hooks/useScopeListData.ts | 5 ++-- .../hooks/useScopePermissions.ts | 5 ++-- 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/authz-module/constants.test.ts b/src/authz-module/constants.test.ts index b4ec6444..60b4f9d0 100644 --- a/src/authz-module/constants.test.ts +++ b/src/authz-module/constants.test.ts @@ -1,6 +1,7 @@ import { buildWizardPath, getOrgAggregateScopeKey, getPlatformAggregateScopeKey, ROUTES, } from './constants'; +import type { ContextType } from './constants'; const BASE = `${ROUTES.HOME_PATH}${ROUTES.ASSIGN_ROLE_WIZARD_PATH}`; @@ -45,7 +46,7 @@ describe('getOrgAggregateScopeKey', () => { }); it('throws for an unknown contextType', () => { - expect(() => getOrgAggregateScopeKey('unknown', 'MIT')).toThrow('Unknown contextType: "unknown"'); + expect(() => getOrgAggregateScopeKey('unknown' as ContextType, 'MIT')).toThrow('Unknown contextType: "unknown"'); }); }); @@ -59,6 +60,6 @@ describe('getPlatformAggregateScopeKey', () => { }); it('throws for an unknown contextType', () => { - expect(() => getPlatformAggregateScopeKey('unknown')).toThrow('Unknown contextType: "unknown"'); + expect(() => getPlatformAggregateScopeKey('unknown' as ContextType)).toThrow('Unknown contextType: "unknown"'); }); }); diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index 510490a9..c344f9aa 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -1,20 +1,28 @@ +// Resource Type Definitions +export const CONTEXT_TYPES = { + LIBRARY: 'library', + COURSE: 'course', +} as const; + +export type ContextType = typeof CONTEXT_TYPES[keyof typeof CONTEXT_TYPES]; + const ORG_AGGREGATE_SCOPE_BUILDERS = { - course: (orgSlug: string) => `course-v1:${orgSlug}+*`, - library: (orgSlug: string) => `lib:${orgSlug}:*`, + [CONTEXT_TYPES.COURSE]: (orgSlug: string) => `course-v1:${orgSlug}+*`, + [CONTEXT_TYPES.LIBRARY]: (orgSlug: string) => `lib:${orgSlug}:*`, }; -export const getOrgAggregateScopeKey = (contextType: string, orgSlug: string): string => { +export const getOrgAggregateScopeKey = (contextType: ContextType, orgSlug: string): string => { const builder = ORG_AGGREGATE_SCOPE_BUILDERS[contextType]; if (!builder) { throw new Error(`Unknown contextType: "${contextType}"`); } return builder(orgSlug); }; const PLATFORM_AGGREGATE_SCOPE_KEYS = { - course: 'course-v1:*', - library: 'lib:*', + [CONTEXT_TYPES.COURSE]: 'course-v1:*', + [CONTEXT_TYPES.LIBRARY]: 'lib:*', }; -export const getPlatformAggregateScopeKey = (contextType: string): string => { +export const getPlatformAggregateScopeKey = (contextType: ContextType): string => { const scope = PLATFORM_AGGREGATE_SCOPE_KEYS[contextType]; if (!scope) { throw new Error(`Unknown contextType: "${contextType}"`); } return scope; @@ -76,11 +84,3 @@ 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 CONTEXT_TYPES = { - LIBRARY: 'library', - COURSE: 'course', -} as const; - -export type ResourceType = typeof CONTEXT_TYPES[keyof typeof CONTEXT_TYPES]; diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts index 53d214d6..3740ed0d 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts @@ -4,6 +4,7 @@ import { 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'; @@ -83,7 +84,7 @@ const useScopeListData = ({ contextType, search, orgs }: UseScopeListDataParams) const platformAggregateScopeItem: Scope | null = (contextType && hasPlatformPermission) ? { - externalKey: getPlatformAggregateScopeKey(contextType), + externalKey: getPlatformAggregateScopeKey(contextType as ContextType), displayName: platformAggregateLabel, description: aggregateDescription, org: null, @@ -109,7 +110,7 @@ const useScopeListData = ({ contextType, search, orgs }: UseScopeListDataParams) .map((orgSlug) => [ orgSlug, { - externalKey: getOrgAggregateScopeKey(contextType, orgSlug), + externalKey: getOrgAggregateScopeKey(contextType as ContextType, orgSlug), displayName: orgAggregateLabel, description: aggregateDescription, org: { id: '0', name: orgSlug, shortName: orgSlug }, diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts index 6c90e3fd..f63980ed 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useValidateUserPermissions } from '@src/data/hooks'; import { 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'; interface UseScopePermissionsParams { @@ -27,10 +28,10 @@ const useScopePermissions = ({ ? CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM : CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM; return [ - { action, scope: getPlatformAggregateScopeKey(contextType) }, + { action, scope: getPlatformAggregateScopeKey(contextType as ContextType) }, ...orderedOrgs.map((org) => ({ action, - scope: getOrgAggregateScopeKey(contextType, org), + scope: getOrgAggregateScopeKey(contextType as ContextType, org), })), ]; }, [orderedOrgs, contextType]); From 5bd97a98725da3c941168747e1b69d4584aa384b Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Mon, 27 Jul 2026 17:11:57 +1000 Subject: [PATCH 3/5] refactor: use scope matching for useScopePermissions --- .../hooks/useScopePermissions.test.ts | 34 +++++++---- .../hooks/useScopePermissions.ts | 60 ++++++++++++------- 2 files changed, 60 insertions(+), 34 deletions(-) diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.test.ts b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.test.ts index 27b56f09..6d848a2d 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.test.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.test.ts @@ -16,9 +16,12 @@ describe('useScopePermissions', () => { }); describe('hasPlatformPermission', () => { - it('is true when the platform-wide entry (index 0) is allowed', () => { + it('is true when the platform-wide scope is allowed', () => { mockUseValidateUserPermissions.mockReturnValue({ - data: [{ allowed: true }, { allowed: false }], + data: [ + { scope: 'course-v1:*', allowed: true }, + { scope: 'course-v1:MIT+*', allowed: false }, + ], }); const { result } = renderHook(() => useScopePermissions({ @@ -29,9 +32,12 @@ describe('useScopePermissions', () => { expect(result.current.hasPlatformPermission).toBe(true); }); - it('is false when the platform-wide entry is not allowed', () => { + it('is false when the platform-wide scope is not allowed', () => { mockUseValidateUserPermissions.mockReturnValue({ - data: [{ allowed: false }, { allowed: true }], + data: [ + { scope: 'course-v1:*', allowed: false }, + { scope: 'course-v1:MIT+*', allowed: true }, + ], }); const { result } = renderHook(() => useScopePermissions({ @@ -62,10 +68,13 @@ describe('useScopePermissions', () => { expect(result.current.orgHasPermission).toEqual({}); }); - it('maps allowed responses by org slug index for course context', () => { - // Index 0 is the platform-wide entry; org entries follow. + it('maps allowed responses by org slug for course context', () => { mockUseValidateUserPermissions.mockReturnValue({ - data: [{ allowed: false }, { allowed: true }, { allowed: false }], + data: [ + { scope: 'course-v1:*', allowed: false }, + { scope: 'course-v1:MIT+*', allowed: true }, + { scope: 'course-v1:HarvardX+*', allowed: false }, + ], }); const { result } = renderHook(() => useScopePermissions({ @@ -76,10 +85,13 @@ describe('useScopePermissions', () => { expect(result.current.orgHasPermission).toEqual({ MIT: true, HarvardX: false }); }); - it('maps allowed responses by org slug index for library context', () => { - // Index 0 is the platform-wide entry; org entries follow. + it('maps allowed responses by org slug for library context', () => { mockUseValidateUserPermissions.mockReturnValue({ - data: [{ allowed: false }, { allowed: false }, { allowed: true }], + data: [ + { scope: 'lib:*', allowed: false }, + { scope: 'lib:MIT:*', allowed: false }, + { scope: 'lib:HarvardX:*', allowed: true }, + ], }); const { result } = renderHook(() => useScopePermissions({ @@ -101,7 +113,7 @@ describe('useScopePermissions', () => { expect(result.current.orgHasPermission).toEqual({ MIT: false }); }); - it('defaults to false when orgPerms data is undefined', () => { + it('defaults to false when the response data is undefined', () => { mockUseValidateUserPermissions.mockReturnValue({ data: undefined }); const { result } = renderHook(() => useScopePermissions({ diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts index f63980ed..751da9d7 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts @@ -4,6 +4,9 @@ import { getOrgAggregateScopeKey, getPlatformAggregateScopeKey } from '@src/auth import type { ContextType } from '@src/authz-module/constants'; import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from '@src/authz-module/roles-permissions'; +// Stands in for the org slug on the platform-wide entry, which belongs to no org. +const PLATFORM_ORG_KEY = '*'; + interface UseScopePermissionsParams { contextType: string | undefined; orderedOrgs: string[]; @@ -18,38 +21,49 @@ const useScopePermissions = ({ contextType, orderedOrgs, }: UseScopePermissionsParams): UseScopePermissionsResult => { - // Validate the platform-wide aggregate (course-v1:* / lib:*) together with each - // org-level aggregate in a single request. The platform-wide scope is always at - // index 0; the per-org scopes follow in `orderedOrgs` order. + // Every scope this hook validates, mapped back to the org it belongs to: the + // platform-wide aggregate (course-v1:* / lib:*) plus one org-level aggregate per + // org. Keyed by scope because that is what the API echoes back on each result. // Note: Using glob patterns (*:org:*) + const orgByScope = useMemo(() => { + if (!contextType) { return new Map(); } + return new Map([ + [getPlatformAggregateScopeKey(contextType as ContextType), PLATFORM_ORG_KEY], + ...orderedOrgs.map((org) => ( + [getOrgAggregateScopeKey(contextType as ContextType, org), org] as [string, string] + )), + ]); + }, [orderedOrgs, contextType]); + + // Validate them all in a single request. Building the payload from the keys keeps + // the org slugs out of it; `action` is the same for every scope. const permissionRequests = useMemo(() => { - if (!contextType) { return []; } const action = contextType === 'course' ? CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM : CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM; - return [ - { action, scope: getPlatformAggregateScopeKey(contextType as ContextType) }, - ...orderedOrgs.map((org) => ({ - action, - scope: getOrgAggregateScopeKey(contextType as ContextType, org), - })), - ]; - }, [orderedOrgs, contextType]); + return Array.from(orgByScope.keys(), (scope) => ({ action, scope })); + }, [orgByScope, contextType]); const { data: perms } = useValidateUserPermissions(permissionRequests); - const hasPlatformPermission = !!contextType && (perms?.[0]?.allowed ?? false); - - // Build a map of `org: has_permission`. Offset by 1 to skip the platform-wide entry. - const orgHasPermission = useMemo(() => { - const map: Record = {}; - orderedOrgs.forEach((org, idx) => { - map[org] = perms?.[idx + 1]?.allowed ?? false; + // Results are matched by the `scope` the API echoes back. Orgs are seeded to `false` first + // so any scope the response omits stays denied. + return useMemo(() => { + const orgHasPermission: Record = {}; + orgByScope.forEach((org) => { + if (org !== PLATFORM_ORG_KEY) { orgHasPermission[org] = false; } }); - return map; - }, [orderedOrgs, perms]); - - return { hasPlatformPermission, orgHasPermission }; + let hasPlatformPermission = false; + perms?.forEach(({ scope, allowed }) => { + const org = scope === undefined ? undefined : orgByScope.get(scope); + if (org === PLATFORM_ORG_KEY) { + hasPlatformPermission = allowed; + } else if (org !== undefined) { + orgHasPermission[org] = allowed; + } + }); + return { hasPlatformPermission, orgHasPermission }; + }, [orgByScope, perms]); }; export default useScopePermissions; From 2d6e14b31ed85c1d08d060bbfbfb64fdd72f1c63 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Tue, 28 Jul 2026 12:40:46 +1000 Subject: [PATCH 4/5] refactor: update platform-wide message --- .../components/DefineApplicationScopeStep.test.tsx | 10 +++++----- src/authz-module/role-assignation-wizard/messages.ts | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.test.tsx b/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.test.tsx index 8684e31e..1d8c7c7c 100644 --- a/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.test.tsx +++ b/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.test.tsx @@ -160,7 +160,7 @@ describe('DefineApplicationScopeStep', () => { it('does not render platform aggregate (disabled pending backend support)', () => { (useScopes as jest.Mock).mockReturnValue(makeScopesHook()); renderComponent({ selectedRole: 'library_admin' }); - expect(screen.queryByText('All libraries in Platform')).not.toBeInTheDocument(); + expect(screen.queryByText('All libraries on the platform')).not.toBeInTheDocument(); }); it('renders scopes grouped by org in OrgSection', () => { @@ -318,17 +318,17 @@ describe('DefineApplicationScopeStep', () => { it('does not show platform aggregate (disabled pending backend support)', () => { (useScopes as jest.Mock).mockReturnValue(makeScopesHook()); renderComponent({ selectedRole: 'library_admin' }); - expect(screen.queryByText('All libraries in Platform')).not.toBeInTheDocument(); + expect(screen.queryByText('All libraries on the platform')).not.toBeInTheDocument(); }); it('does not show platform aggregate when selectedRole is null', () => { renderComponent({ selectedRole: null }); - expect(screen.queryByText('All libraries in Platform')).not.toBeInTheDocument(); + expect(screen.queryByText('All libraries on the platform')).not.toBeInTheDocument(); }); - it('does not show "All courses in Platform" (disabled pending backend support)', () => { + it('does not show "All courses on the platform" (disabled pending backend support)', () => { renderComponent({ selectedRole: 'course_admin' }); - expect(screen.queryByText('All courses in Platform')).not.toBeInTheDocument(); + expect(screen.queryByText('All courses on the platform')).not.toBeInTheDocument(); }); }); diff --git a/src/authz-module/role-assignation-wizard/messages.ts b/src/authz-module/role-assignation-wizard/messages.ts index 4af243ff..47694635 100644 --- a/src/authz-module/role-assignation-wizard/messages.ts +++ b/src/authz-module/role-assignation-wizard/messages.ts @@ -205,12 +205,12 @@ const messages = defineMessages({ }, 'wizard.step2.scope.aggregate.platform.label.course': { id: 'wizard.step2.scope.aggregate.platform.label.course', - defaultMessage: 'All courses in Platform', + defaultMessage: 'All courses on the platform', description: 'Display name for the platform-wide aggregate scope item when context type is course', }, 'wizard.step2.scope.aggregate.platform.label.library': { id: 'wizard.step2.scope.aggregate.platform.label.library', - defaultMessage: 'All libraries in Platform', + defaultMessage: 'All libraries on the platform', description: 'Display name for the platform-wide aggregate scope item when context type is library', }, From 64de24949a65690e0bdbe45e74933b283bf68546 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Tue, 28 Jul 2026 15:14:10 +1000 Subject: [PATCH 5/5] refactor: organize useScopePermissions hook --- .../hooks/useScopePermissions.ts | 79 ++++++++++--------- 1 file changed, 42 insertions(+), 37 deletions(-) diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts index 751da9d7..e79487bc 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts @@ -4,9 +4,6 @@ import { getOrgAggregateScopeKey, getPlatformAggregateScopeKey } from '@src/auth import type { ContextType } from '@src/authz-module/constants'; import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from '@src/authz-module/roles-permissions'; -// Stands in for the org slug on the platform-wide entry, which belongs to no org. -const PLATFORM_ORG_KEY = '*'; - interface UseScopePermissionsParams { contextType: string | undefined; orderedOrgs: string[]; @@ -21,49 +18,57 @@ const useScopePermissions = ({ contextType, orderedOrgs, }: UseScopePermissionsParams): UseScopePermissionsResult => { - // Every scope this hook validates, mapped back to the org it belongs to: the - // platform-wide aggregate (course-v1:* / lib:*) plus one org-level aggregate per - // org. Keyed by scope because that is what the API echoes back on each result. - // Note: Using glob patterns (*:org:*) - const orgByScope = useMemo(() => { - if (!contextType) { return new Map(); } - return new Map([ - [getPlatformAggregateScopeKey(contextType as ContextType), PLATFORM_ORG_KEY], - ...orderedOrgs.map((org) => ( - [getOrgAggregateScopeKey(contextType as ContextType, org), org] as [string, string] - )), - ]); - }, [orderedOrgs, contextType]); + // Validate the platform-wide aggregate (course-v1:* / lib:*) and one org-level + // aggregate (course-v1:Org+* / lib:Org:*) per org in a single request; `action` + // is the same for every scope. + const typedContext = contextType as ContextType; - // Validate them all in a single request. Building the payload from the keys keeps - // the org slugs out of it; `action` is the same for every scope. + // 1. Build the API request payload const permissionRequests = useMemo(() => { - const action = contextType === 'course' + if (!typedContext) { return []; } + + const action = typedContext === 'course' ? CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM : CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM; - return Array.from(orgByScope.keys(), (scope) => ({ action, scope })); - }, [orgByScope, contextType]); + + const platformRequest = { action, scope: getPlatformAggregateScopeKey(typedContext) }; + const orgRequests = orderedOrgs.map((org) => ({ + action, + scope: getOrgAggregateScopeKey(typedContext, org), + })); + + return [platformRequest, ...orgRequests]; + }, [orderedOrgs, typedContext]); const { data: perms } = useValidateUserPermissions(permissionRequests); - // Results are matched by the `scope` the API echoes back. Orgs are seeded to `false` first - // so any scope the response omits stays denied. - return useMemo(() => { - const orgHasPermission: Record = {}; - orgByScope.forEach((org) => { - if (org !== PLATFORM_ORG_KEY) { orgHasPermission[org] = false; } - }); - let hasPlatformPermission = false; + // 2. Create a lightweight index for fast lookups + // Indexed by the `scope` the API echoes back on each result. + const allowedByScope = useMemo(() => { + const byScope: Record = Object.create(null); perms?.forEach(({ scope, allowed }) => { - const org = scope === undefined ? undefined : orgByScope.get(scope); - if (org === PLATFORM_ORG_KEY) { - hasPlatformPermission = allowed; - } else if (org !== undefined) { - orgHasPermission[org] = allowed; - } + if (scope !== undefined) { byScope[scope] = allowed; } }); - return { hasPlatformPermission, orgHasPermission }; - }, [orgByScope, perms]); + return byScope; + }, [perms]); + + // 3. Extract platform-wide permission + const hasPlatformPermission = !!typedContext + && (allowedByScope[getPlatformAggregateScopeKey(typedContext)] ?? false); + + // 4. Map permissions back to the requested Orgs + const orgHasPermission = useMemo(() => { + const result: Record = {}; + if (typedContext) { + orderedOrgs.forEach((org) => { + const scope = getOrgAggregateScopeKey(typedContext, org); + result[org] = allowedByScope[scope] ?? false; + }); + } + return result; + }, [orderedOrgs, typedContext, allowedByScope]); + + return { hasPlatformPermission, orgHasPermission }; }; export default useScopePermissions;