diff --git a/src/authz-module/authz-home/index.test.tsx b/src/authz-module/authz-home/index.test.tsx index 4f186aa7..8f0690ec 100644 --- a/src/authz-module/authz-home/index.test.tsx +++ b/src/authz-module/authz-home/index.test.tsx @@ -22,6 +22,15 @@ const emptyResponse = { refetch: jest.fn(), }; +const emptyScopesResponse = { + data: { pages: [] }, + error: null, + isLoading: false, + hasNextPage: false, + fetchNextPage: jest.fn(), + isFetchingNextPage: false, +}; + const renderAuthzHome = () => renderWithAllProviders( @@ -32,7 +41,7 @@ describe('AuthzHome', () => { beforeEach(() => { (useAllRoleAssignments as jest.Mock).mockReturnValue(emptyResponse); (useOrgs as jest.Mock).mockReturnValue(emptyResponse); - (useScopes as jest.Mock).mockReturnValue(emptyResponse); + (useScopes as jest.Mock).mockReturnValue(emptyScopesResponse); }); it('renders without crashing', () => { diff --git a/src/authz-module/components/TableControlBar/ScopesFilter.test.tsx b/src/authz-module/components/TableControlBar/ScopesFilter.test.tsx index 18654ad4..e2d0cbf0 100644 --- a/src/authz-module/components/TableControlBar/ScopesFilter.test.tsx +++ b/src/authz-module/components/TableControlBar/ScopesFilter.test.tsx @@ -6,16 +6,20 @@ import ScopesFilter from './ScopesFilter'; jest.mock('@src/authz-module/data/hooks', () => ({ useScopes: () => ({ data: { - results: [ + pages: [ { - externalKey: 'course:123', - name: 'Test Course', - organization: { name: 'Test Org' }, - }, - { - externalKey: 'library:456', - name: 'Test Library', - organization: { name: 'Another Org' }, + results: [ + { + externalKey: 'course:123', + name: 'Test Course', + organization: { name: 'Test Org' }, + }, + { + externalKey: 'library:456', + name: 'Test Library', + organization: { name: 'Another Org' }, + }, + ], }, ], }, diff --git a/src/authz-module/components/TableControlBar/ScopesFilter.tsx b/src/authz-module/components/TableControlBar/ScopesFilter.tsx index 9661438d..cb8bb206 100644 --- a/src/authz-module/components/TableControlBar/ScopesFilter.tsx +++ b/src/authz-module/components/TableControlBar/ScopesFilter.tsx @@ -15,9 +15,9 @@ const ScopesFilter = ({ }: ScopesFilterProps) => { const { formatMessage } = useIntl(); const [searchValue, setSearchValue] = useState(undefined); - const { data: scopesData = { results: [] } } = useScopes(searchValue, 1, DEFAULT_FILTER_PAGE_SIZE); + const { data: scopesData } = useScopes({ search: searchValue, pageSize: DEFAULT_FILTER_PAGE_SIZE }); - const filterChoices = useMemo(() => scopesData.results.map((scope) => { + const filterChoices = useMemo(() => (scopesData?.pages?.flatMap((p) => p.results) ?? []).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')) { @@ -30,7 +30,7 @@ const ScopesFilter = ({ groupName, groupIcon: scopeIcon, }; - }), [scopesData, formatMessage]); + }), [scopesData?.pages, formatMessage]); const handleSearchChange = (value: string) => { setSearchValue(value); diff --git a/src/authz-module/constants.test.ts b/src/authz-module/constants.test.ts index 4ba99ad1..8e5eace3 100644 --- a/src/authz-module/constants.test.ts +++ b/src/authz-module/constants.test.ts @@ -1,4 +1,4 @@ -import { buildWizardPath, ROUTES } from './constants'; +import { buildWizardPath, getOrgAggregateScopeKey, ROUTES } from './constants'; const BASE = `${ROUTES.HOME_PATH}${ROUTES.ASSIGN_ROLE_WIZARD_PATH}`; @@ -32,3 +32,17 @@ describe('buildWizardPath', () => { expect(buildWizardPath({ users: '', from: '' })).toBe(BASE); }); }); + +describe('getOrgAggregateScopeKey', () => { + it('returns course wildcard scope for course context', () => { + expect(getOrgAggregateScopeKey('course', 'MIT')).toBe('course-v1:MIT+*'); + }); + + it('returns library wildcard scope for library context', () => { + expect(getOrgAggregateScopeKey('library', 'MIT')).toBe('lib:MIT:*'); + }); + + it('throws for an unknown contextType', () => { + expect(() => getOrgAggregateScopeKey('unknown', 'MIT')).toThrow('Unknown contextType: "unknown"'); + }); +}); diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index 3f18cc1c..a37f69d3 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -80,6 +80,17 @@ export const CONTENT_COURSE_PERMISSIONS = { VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS: 'courses.view_global_staff_and_superadmins', }; +const ORG_AGGREGATE_SCOPE_BUILDERS = { + course: (orgSlug: string) => `course-v1:${orgSlug}+*`, + library: (orgSlug: string) => `lib:${orgSlug}:*`, +}; + +export const getOrgAggregateScopeKey = (contextType: string, orgSlug: string): string => { + const builder = ORG_AGGREGATE_SCOPE_BUILDERS[contextType]; + if (!builder) { throw new Error(`Unknown contextType: "${contextType}"`); } + return builder(orgSlug); +}; + export const libraryResourceTypes: ResourceMetadata[] = [ { key: 'library', label: 'Library', description: 'Permissions related to the library as a whole.' }, { key: 'library_content', label: 'Content', description: 'Permissions to create, edit, delete, and publish individual content items within the library.' }, diff --git a/src/authz-module/data/api.test.tsx b/src/authz-module/data/api.test.tsx index 4d0fda2d..3da148c3 100644 --- a/src/authz-module/data/api.test.tsx +++ b/src/authz-module/data/api.test.tsx @@ -277,9 +277,12 @@ describe('API functions', () => { it('should fetch scopes successfully', async () => { const mockResponse = { data: { - scopes: [ + results: [ { displayName: 'Library 1', scope: 'lib:test1' }, ], + count: 1, + next: null, + previous: null, }, }; @@ -287,20 +290,24 @@ describe('API functions', () => { get: jest.fn().mockResolvedValue(mockResponse), }); - const result = await getScopes(); + const result = await getScopes({}); - expect(result.scopes).toHaveLength(1); + expect(result.results).toHaveLength(1); expect(getAuthenticatedHttpClient).toHaveBeenCalled(); }); it('should handle search, page, and pageSize parameters', async () => { - const mockResponse = { data: { scopes: [] } }; + const mockResponse = { + data: { + results: [], count: 0, next: null, previous: null, + }, + }; const mockGet = jest.fn().mockResolvedValue(mockResponse); getAuthenticatedHttpClient.mockReturnValue({ get: mockGet, }); - await getScopes('library', 3, 50); + await getScopes({ search: 'library', page: 3, pageSize: 50 }); expect(mockGet).toHaveBeenCalled(); const calledUrl = mockGet.mock.calls[0][0]; diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index 4af3a9be..078687f6 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -54,13 +54,13 @@ export type PermissionsByRole = { }; export interface PutAssignTeamMembersRoleResponse { completed: { userIdentifier: string; status: string }[]; - errors: { userIdentifier: string; error: string }[]; + errors: { userIdentifier: string; scope: string; error: string }[]; } export interface AssignTeamMembersRoleRequest { users: string[]; role: string; - scope: string; + scopes: string[]; } export interface GetAllRoleAssignmentsResponse { @@ -97,6 +97,15 @@ export type ValidateUsersResponse = { }; }; +export interface GetScopesParams { + scopeType?: string; + search?: string; + orgs?: string[]; + page?: number; + pageSize?: number; + managementPermissionOnly?: boolean; +} + export const getTeamMembers = async (object: string, querySettings: QuerySettings): Promise => { const url = new URL(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`)); @@ -208,17 +217,14 @@ export const getOrgs = async (search?: string, page?: number, pageSize?: number) return camelCaseObject(data); }; -export const getScopes = async (search?: string, page?: number, pageSize?: number): Promise => { +export const getScopes = async (params: GetScopesParams): Promise => { const url = new URL(getApiUrl('/api/authz/v1/scopes/')); - if (search !== undefined) { - url.searchParams.set('search', search); - } - if (page !== undefined) { - url.searchParams.set('page', page.toString()); - } - if (pageSize !== undefined) { - url.searchParams.set('page_size', pageSize.toString()); - } + if (params.search) { url.searchParams.set('search', params.search); } + if (params.scopeType) { url.searchParams.set('scope_type', params.scopeType); } + if (params.orgs?.length) { url.searchParams.set('orgs', params.orgs.join(',')); } + if (params.managementPermissionOnly) { url.searchParams.set('management_permission_only', 'true'); } + url.searchParams.set('page', (params.page ?? 1).toString()); + url.searchParams.set('page_size', (params.pageSize ?? 10).toString()); const { data } = await getAuthenticatedHttpClient().get(url); return camelCaseObject(data); }; diff --git a/src/authz-module/data/hooks.test.tsx b/src/authz-module/data/hooks.test.tsx index 1fb5ccf4..ba7691dc 100644 --- a/src/authz-module/data/hooks.test.tsx +++ b/src/authz-module/data/hooks.test.tsx @@ -390,7 +390,7 @@ describe('useAssignTeamMembersRole', () => { }); await act(async () => { - result.current.mutate({ data: { scope: 'lib:123', users: ['jdoe'], role: 'author' } }); + result.current.mutate({ data: { scopes: ['lib:123'], users: ['jdoe'], role: 'author' } }); }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); @@ -409,7 +409,7 @@ describe('useAssignTeamMembersRole', () => { }); await act(async () => { - result.current.mutate({ data: { scope: 'lib:123', users: ['jdoe'], role: 'author' } }); + result.current.mutate({ data: { scopes: ['lib:123'], users: ['jdoe'], role: 'author' } }); }); await waitFor(() => expect(result.current.isError).toBe(true)); @@ -467,6 +467,128 @@ describe('useValidateUsers', () => { }); }); +describe('useScopes', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const makeScopesResponse = (next: string | null = null) => ({ + results: [{ + externalKey: 'lib:testorg:testlib', + displayName: 'Test Library', + org: { id: 1, name: 'Test Org', slug: 'testorg' }, + }], + count: 1, + next, + previous: null, + }); + + it('returns pages data on success', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValue({ data: makeScopesResponse() }), + }); + + const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.pages).toHaveLength(1); + expect(result.current.data?.pages[0].results).toHaveLength(1); + }); + + it('hasNextPage is false when next is null', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValue({ data: makeScopesResponse(null) }), + }); + + const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.hasNextPage).toBe(false); + }); + + it('hasNextPage is true when next URL has page param', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValue({ + data: makeScopesResponse('http://localhost:8000/api/authz/v1/scopes/?page=2'), + }), + }); + + const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.hasNextPage).toBe(true); + }); + + it('hasNextPage is false when next URL has no page param', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValue({ + data: makeScopesResponse('http://localhost:8000/api/authz/v1/scopes/'), + }), + }); + + const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.hasNextPage).toBe(false); + }); + + it('hasNextPage is false when next is an invalid URL', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValue({ + data: makeScopesResponse('not-a-valid-url'), + }), + }); + + const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.hasNextPage).toBe(false); + }); + + it('handles error when API call fails', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockRejectedValue(new Error('Network error')), + }); + + const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toBeDefined(); + }); +}); + +describe('useOrgs', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const mockOrgsResult = [{ + id: 1, name: 'Org One', shortName: 'org1', description: '', logo: null, active: true, + }]; + + it('returns organizations on success', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValue({ data: { results: mockOrgsResult } }), + }); + + const { result } = renderHook(() => useOrgs(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.results).toEqual(mockOrgsResult); + }); + + it('handles error when API fails', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockRejectedValue(new Error('Failed')), + }); + + const { result } = renderHook(() => useOrgs(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); +}); + describe('useRevokeUserRoles', () => { beforeEach(() => { jest.clearAllMocks(); @@ -665,9 +787,9 @@ describe('useScopes', () => { it('fetches and returns scopes', async () => { const { result } = renderHook(() => useScopes(), { wrapper: createWrapper() }); await waitFor(() => { - expect(result.current.data?.results).toHaveLength(2); - expect(result.current.data?.results[0].displayName).toBe('Open edX Demo Course'); - expect(result.current.data?.count).toBe(2); + expect(result.current.data?.pages[0].results).toHaveLength(2); + expect(result.current.data?.pages[0].results[0].displayName).toBe('Open edX Demo Course'); + expect(result.current.data?.pages[0].count).toBe(2); }); }); @@ -681,8 +803,8 @@ describe('useScopes', () => { }); const { result } = renderHook(() => useScopes(), { wrapper: createWrapper() }); await waitFor(() => { - expect(result.current.data?.results).toEqual([]); - expect(result.current.data?.count).toBe(0); + expect(result.current.data?.pages[0].results).toEqual([]); + expect(result.current.data?.pages[0].count).toBe(0); }); }); }); @@ -816,6 +938,9 @@ describe('useUserAssignedRoles', () => { describe('useScopes', () => { const mockScopesData = { + count: 2, + next: null, + previous: null, results: [ { displayName: 'Test Library 1', @@ -844,21 +969,25 @@ describe('useScopes', () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(getAuthenticatedHttpClient).toHaveBeenCalled(); - expect(result.current.data).toEqual(mockScopesData); - expect(result.current.data?.results).toHaveLength(2); + expect(result.current.data?.pages[0]).toEqual(mockScopesData); + expect(result.current.data?.pages[0].results).toHaveLength(2); }); it('handles search parameter', async () => { getAuthenticatedHttpClient.mockReturnValue({ - get: jest.fn().mockResolvedValue({ data: { results: [mockScopesData.results[0]] } }), + get: jest.fn().mockResolvedValue({ + data: { + count: 1, next: null, previous: null, results: [mockScopesData.results[0]], + }, + }), }); - const { result } = renderHook(() => useScopes('library'), { + const { result } = renderHook(() => useScopes({ search: 'library' }), { wrapper: createWrapper(), }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(result.current.data?.results).toHaveLength(1); + expect(result.current.data?.pages[0].results).toHaveLength(1); }); it('handles error when API call fails', async () => { diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts index 7242fffd..a8a6e76d 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -1,5 +1,5 @@ import { - useMutation, useQuery, useQueryClient, useSuspenseQuery, + useInfiniteQuery, useMutation, useQuery, useQueryClient, useSuspenseQuery, } from '@tanstack/react-query'; import { appId } from '@src/constants'; import { LibraryMetadata } from '@src/types'; @@ -10,7 +10,7 @@ import { getPermissionsByRole, getScopes, GetScopesResponse, getTeamMembers, GetTeamMembersResponse, PermissionsByRole, QuerySettings, revokeUserRoles, RevokeUserRolesRequest, getUserAssignedRoles, GetUserAssignmentsResponse, - validateUsers, ValidateUsersRequest, + validateUsers, ValidateUsersRequest, GetScopesParams, } from './api'; const authzQueryKeys = { @@ -91,10 +91,16 @@ export const useAssignTeamMembersRole = () => { mutationFn: async ({ data }: { data: AssignTeamMembersRoleRequest }) => assignTeamMembersRole(data), - onSettled: (_data, error, { data: { scope } }) => { + onSettled: (_data, error, { data: { scopes } }) => { if (!error) { - queryClient.invalidateQueries({ queryKey: authzQueryKeys.teamMembersAll(scope) }); - queryClient.invalidateQueries({ queryKey: authzQueryKeys.permissionsByRole(scope) }); + scopes.forEach((scope) => { + queryClient.invalidateQueries({ queryKey: authzQueryKeys.teamMembersAll(scope) }); + queryClient.invalidateQueries({ queryKey: authzQueryKeys.permissionsByRole(scope) }); + }); + queryClient.invalidateQueries({ queryKey: [...authzQueryKeys.all, 'userRoles'] }); + queryClient.invalidateQueries({ + predicate: (query) => query.queryKey.includes('allRoleAssignments'), + }); } }, }); @@ -163,32 +169,62 @@ export const useAllRoleAssignments = (querySettings: QuerySettings) => { }; /** - * React query hook to fetch the list of organizations for the organization filter component. - * @param search - The search term to filter organizations. - * @returns The list of organizations matching the search term. + * React Query hook to fetch a paginated, searchable list of organizations. + * Results are cached for 30 minutes — suitable for both filter dropdowns and full listings. + * + * @param search - Optional text filter applied to organization names. + * @param page - 1-based page number; defaults to the first page when omitted. + * @param pageSize - Items per page; defaults to the API default when omitted. + * @returns A `QueryResult` with `results`, `count`, `next`, and `previous`. + * + * @example + * ```tsx + * const { data } = useOrgs('edX'); + * const orgs = data?.results ?? []; + * ``` */ -export const useOrgs = (search?: string, page?: number, pageSize?: number) => { - const result = useQuery({ - queryKey: authzQueryKeys.orgs(search, page, pageSize), - queryFn: () => getOrgs(search, page, pageSize), - refetchOnWindowFocus: false, - }); - return result; -}; +export const useOrgs = (search?: string, page?: number, pageSize?: number) => useQuery({ + queryKey: authzQueryKeys.orgs(search, page, pageSize), + queryFn: () => getOrgs(search, page, pageSize), + staleTime: 1000 * 60 * 30, + refetchOnWindowFocus: false, +}); -/* - * React query hook to fetch the list of scopes for the scope filter component. - * @param search - The search term to filter scopes. - * @returns The list of scopes matching the search term. - */ -export const useScopes = (search?: string, page?: number, pageSize?: number) => { - const result = useQuery({ - queryKey: authzQueryKeys.scopes(search, page, pageSize), - queryFn: () => getScopes(search, page, pageSize), - refetchOnWindowFocus: false, - }); - return result; -}; +/** + * React Query hook to fetch a paginated, filterable list of scopes (courses or libraries). + * Uses infinite query to support infinite scroll — call `fetchNextPage` to load more results. + * + * @param params - Filter parameters (all optional): + * - `search` — text filter applied to scope names + * - `scopeType` — filter by scope type (e.g. course, library) + * - `org` — filter by organization + * - `pageSize` — number of items per page + * - `managementPermissionOnly` — when true, returns only scopes the requester can manage + * @returns An `InfiniteQueryResult` whose `data.pages` contains the accumulated `GetScopesResponse` pages. + * + * @example + * ```tsx + * const { data, fetchNextPage, hasNextPage } = useScopes({ search: 'intro', orgs: ['edX'] }); + * const scopes = data?.pages.flatMap((p) => p.results) ?? []; + * ``` + */ +export const useScopes = (params: Omit = {}) => useInfiniteQuery({ + queryKey: [...authzQueryKeys.all, 'scopes', params], + queryFn: ({ pageParam }) => getScopes({ ...params, page: pageParam as number }), + getNextPageParam: (lastPage) => { + if (!lastPage.next) { return undefined; } + try { + const nextUrl = new URL(lastPage.next); + const page = nextUrl.searchParams.get('page'); + return page ? parseInt(page, 10) : undefined; + } catch { + return undefined; + } + }, + initialPageParam: 1, + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false, +}); /* * React Query hook to fetch all the roles assigned to a specific user. diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss index ae2e7093..3e8abd01 100644 --- a/src/authz-module/index.scss +++ b/src/authz-module/index.scss @@ -89,6 +89,13 @@ word-break: break-word; overflow-wrap: break-word; width: 100%; + + &.form-control { + font-family: inherit; + font-size: 1rem; + line-height: 1.5; + padding: 0.375rem 0.75rem; + } } &__overlay { @@ -107,15 +114,22 @@ z-index: 1; min-height: 100px; resize: vertical; - caret-color: #212529; - &--highlighted { + &--highlighted.form-control { color: transparent; background: transparent; } } } +.scope-list { + max-height: 500px; +} + +.scope-search-input { + width: 18.75rem; // 300px +} + .toast-container { // Ensure toast appears above modal z-index: 1000; diff --git a/src/authz-module/libraries-manager/LibrariesUserManager.test.tsx b/src/authz-module/libraries-manager/LibrariesUserManager.test.tsx index 48cb2a05..b465a6ee 100644 --- a/src/authz-module/libraries-manager/LibrariesUserManager.test.tsx +++ b/src/authz-module/libraries-manager/LibrariesUserManager.test.tsx @@ -182,6 +182,80 @@ describe('LibrariesUserManager', () => { expect(navLinkLibraryTeamManagement).toHaveAttribute('href', '/authz/libraries/lib:123'); }); + describe('Navigation guards', () => { + it('redirects to team path when canManageTeam is false', () => { + (useLibraryAuthZ as jest.Mock).mockReturnValue({ + ...defaultMockData, + canManageTeam: false, + }); + + renderComponent(); + + expect(mockNavigate).toHaveBeenCalledWith('/authz/libraries/lib:123'); + }); + + it('redirects to team path when user is not found after loading completes', async () => { + (useTeamMembers as jest.Mock).mockReturnValue({ + data: { results: [] }, + isLoading: false, + isFetching: false, + }); + + renderComponent(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/authz/libraries/lib:123'); + }); + }); + + it('does not redirect while member data is still fetching', () => { + (useTeamMembers as jest.Mock).mockReturnValue({ + data: undefined, + isLoading: true, + isFetching: true, + }); + + renderComponent(); + + // navigate should only be called for canManageTeam=true case (not at all here) + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); + + describe('Loading state', () => { + it('renders skeleton while loading team member data', () => { + (useTeamMembers as jest.Mock).mockReturnValue({ + data: undefined, + isLoading: true, + isFetching: true, + }); + + renderComponent(); + + // Just verify the component renders without crashing in loading state + expect(screen.queryByText('Admin')).not.toBeInTheDocument(); + }); + }); + + describe('Assign role button', () => { + it('navigates to assign-role wizard when Assign Role button is clicked', async () => { + const user = userEvent.setup(); + renderComponent(); + + // There are two "Assign Role" elements: the mocked AssignNewRoleTrigger and the real Button + // The real Button navigates; use getAllByText and click the one that is a - @@ -192,9 +239,9 @@ const AssignRoleWizard = ({ onClose, initialUsers = '', roles = allRolesMetadata pending: intl.formatMessage(messages['wizard.button.save.pending']), }} icons={{ pending: }} - state="default" + state={assignRoleMutation.isPending ? 'pending' : 'default'} onClick={handleSave} - disabled={!canSave} + disabled={!canSave || assignRoleMutation.isPending} /> diff --git a/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.test.tsx b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.test.tsx index 6f8c87b2..a544972b 100644 --- a/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.test.tsx +++ b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.test.tsx @@ -14,6 +14,13 @@ jest.mock('react-router-dom', () => ({ jest.mock('../data/hooks', () => ({ useValidateUsers: jest.fn(), + useAssignTeamMembersRole: jest.fn(() => ({ + mutate: jest.fn(), + mutateAsync: jest.fn(), + isPending: false, + isError: false, + isSuccess: false, + })), })); jest.mock('@edx/frontend-component-header', () => ({ @@ -84,4 +91,20 @@ describe('AssignRoleWizardPage', () => { await user.click(screen.getByRole('button', { name: /Cancel/i })); expect(navigate).toHaveBeenCalledWith('/authz'); }); + + it('navigates to the user-specific view when a single preset user is set', async () => { + const { navigate } = setupMocks({ users: 'alice' }); + const user = userEvent.setup(); + renderPage(); + await user.click(screen.getByRole('button', { name: /Cancel/i })); + expect(navigate).toHaveBeenCalledWith('/authz/user/alice'); + }); + + it('navigates to returnTo when multiple preset users are set', async () => { + const { navigate } = setupMocks({ users: 'alice,bob', from: '/authz/team' }); + const user = userEvent.setup(); + renderPage(); + await user.click(screen.getByRole('button', { name: /Cancel/i })); + expect(navigate).toHaveBeenCalledWith('/authz/team'); + }); }); diff --git a/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx index feb25942..df3ec26d 100644 --- a/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx +++ b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx @@ -13,6 +13,11 @@ const AssignRoleWizardPage = () => { const raw = searchParams.get('from') ?? ''; const returnTo = (raw.startsWith('/') && !raw.startsWith('//')) ? raw : ROUTES.HOME_PATH; + const presetUser = initialUsers.trim(); + const destination = (presetUser && !presetUser.includes(',')) + ? `${ROUTES.HOME_PATH}/user/${presetUser}` + : returnTo; + return ( { {/* TODO: pass a filtered `roles` prop once the permission-lookup API is available, so the wizard only shows role groups the current user can assign. */} navigate(returnTo)} + onClose={() => navigate(destination)} initialUsers={initialUsers} /> 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 54f59e29..c08bc34f 100644 --- a/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.test.tsx +++ b/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.test.tsx @@ -1,26 +1,461 @@ -import { render, screen } from '@testing-library/react'; +import { screen, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper } from '@src/setupTest'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import DefineApplicationScopeStep from './DefineApplicationScopeStep'; +import { useScopes, useOrgs } from '../../data/hooks'; +import useScopePermissions from '../hooks/useScopePermissions'; + +jest.mock('../../data/hooks', () => ({ + useScopes: jest.fn(), + useOrgs: jest.fn(), +})); + +jest.mock('../hooks/useScopePermissions'); + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedUser: jest.fn(), +})); + +// IntersectionObserver is used for infinite scroll +const mockObserve = jest.fn(); +const mockDisconnect = jest.fn(); +beforeAll(() => { + window.IntersectionObserver = jest.fn().mockImplementation(() => ({ + observe: mockObserve, + disconnect: mockDisconnect, + unobserve: jest.fn(), + })); +}); + +const makeScopesHook = (overrides = {}) => ({ + data: { + pages: [{ + results: [], count: 0, next: null, previous: null, + }], + }, + fetchNextPage: jest.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + isError: false, + ...overrides, +}); + +const defaultOrgs = [ + { + id: 1, name: 'Organization One', shortName: 'org1', description: '', logo: null, active: true, + }, + { + id: 2, name: 'Organization Two', shortName: 'org2', description: '', logo: null, active: true, + }, +]; + +const defaultProps = { + selectedRole: 'library_admin', + selectedScopes: new Set(), + onScopeToggle: jest.fn(), +}; + +const renderComponent = (props = {}) => renderWrapper( + , +); describe('DefineApplicationScopeStep', () => { - const defaultProps = { - selectedRole: 'library_admin', - selectedScopes: new Set(['lib:123']), - onScopeToggle: jest.fn(), - }; - - it('renders the Step 2 placeholder heading', () => { - render(); - expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Step 2'); - }); - - it('renders with null selectedRole', () => { - render( - , - ); - expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); + beforeEach(() => { + jest.clearAllMocks(); + mockObserve.mockClear(); + mockDisconnect.mockClear(); + (useScopes as jest.Mock).mockReturnValue(makeScopesHook()); + (useOrgs as jest.Mock).mockReturnValue({ data: { results: defaultOrgs } }); + (getAuthenticatedUser as jest.Mock).mockReturnValue({ administrator: true }); + (useScopePermissions as jest.Mock).mockReturnValue({ + hasPlatformPermission: false, + orgHasPermission: { org1: true, org2: true }, + }); + }); + + describe('Title and layout', () => { + it('renders the step title "Where It Applies"', () => { + renderComponent(); + expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Where It Applies'); + }); + + it('renders the search input', () => { + renderComponent(); + expect(screen.getByPlaceholderText('Search')).toBeInTheDocument(); + }); + + it('renders count display', () => { + renderComponent(); + expect(screen.getByText(/Showing 0 of 0/)).toBeInTheDocument(); + }); + }); + + describe('Context type filter badge', () => { + it('shows "Libraries" badge for a library role', () => { + renderComponent({ selectedRole: 'library_admin' }); + expect(screen.getByText('Filter applied:')).toBeInTheDocument(); + expect(screen.getByText('Libraries')).toBeInTheDocument(); + }); + + it('shows "Courses" badge for a course role', () => { + renderComponent({ selectedRole: 'course_admin' }); + expect(screen.getByText('Courses')).toBeInTheDocument(); + }); + + it('does not show filter badge when selectedRole is null', () => { + renderComponent({ selectedRole: null }); + expect(screen.queryByText('Filter applied:')).not.toBeInTheDocument(); + }); + }); + + describe('Loading state', () => { + it('shows loading spinner when isLoading is true', () => { + (useScopes as jest.Mock).mockReturnValue(makeScopesHook({ isLoading: true })); + renderComponent(); + expect(screen.getByText('Loading scopes...')).toBeInTheDocument(); + }); + + it('shows loading-more spinner when isFetchingNextPage is true', () => { + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ isFetchingNextPage: true }), + ); + renderComponent(); + expect(screen.getByText('Loading more...')).toBeInTheDocument(); + }); + }); + + describe('Empty state', () => { + it('shows "No scopes found." when there are no results', () => { + renderComponent(); + expect(screen.getByText('No scopes found.')).toBeInTheDocument(); + }); + }); + + describe('Scope list rendering', () => { + const makeScope = (externalKey: string, displayName: string, orgSlug: string) => ({ + externalKey, + displayName, + org: orgSlug ? { id: 1, name: orgSlug, shortName: orgSlug } : null, + }); + + 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(); + }); + + it('renders scopes grouped by org in OrgSection', () => { + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ + data: { + pages: [{ + results: [makeScope('lib:org1/lib1', 'Library One', 'org1')], + count: 1, + next: null, + previous: null, + }], + }, + }), + ); + renderComponent(); + expect(screen.getByText('Org: Organization One')).toBeInTheDocument(); + expect(screen.getByLabelText('Library One')).toBeInTheDocument(); + }); + + it('checkbox is checked when scope is in selectedScopes', () => { + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ + data: { + pages: [{ + results: [makeScope('lib:org1/lib1', 'Library One', 'org1')], + count: 1, + next: null, + previous: null, + }], + }, + }), + ); + renderComponent({ selectedScopes: new Set(['lib:org1/lib1']) }); + expect(screen.getByLabelText('Library One')).toBeChecked(); + }); + + it('checkbox is unchecked when scope is not in selectedScopes', () => { + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ + data: { + pages: [{ + results: [makeScope('lib:org1/lib1', 'Library One', 'org1')], + count: 1, + next: null, + previous: null, + }], + }, + }), + ); + renderComponent({ selectedScopes: new Set() }); + expect(screen.getByLabelText('Library One')).not.toBeChecked(); + }); + + it('calls onScopeToggle with scope id when checkbox is clicked', async () => { + const onScopeToggle = jest.fn(); + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ + data: { + pages: [{ + results: [makeScope('lib:org1/lib1', 'Library One', 'org1')], + count: 1, + next: null, + previous: null, + }], + }, + }), + ); + renderComponent({ onScopeToggle }); + const checkbox = screen.getByLabelText('Library One'); + await userEvent.click(checkbox); + expect(onScopeToggle).toHaveBeenCalledWith('lib:org1/lib1'); + }); + + it('shows scope description when present', () => { + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ + data: { + pages: [{ + results: [{ ...makeScope('lib:org1/lib1', 'Library One', 'org1'), description: 'A test description' }], + count: 1, + next: null, + previous: null, + }], + }, + }), + ); + renderComponent(); + expect(screen.getByText('A test description')).toBeInTheDocument(); + }); + + it('shows correct count "Showing X of Y"', () => { + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ + data: { + pages: [{ + results: [makeScope('lib:org1/lib1', 'Library One', 'org1')], + count: 5, + next: null, + previous: null, + }], + }, + }), + ); + renderComponent(); + expect(screen.getByText('Showing 1 of 5.')).toBeInTheDocument(); + }); + }); + + describe('Aggregate scope items', () => { + const makeScope = (externalKey: string, displayName: string, orgSlug: string) => ({ + externalKey, displayName, org: orgSlug ? { id: 1, name: orgSlug, shortName: orgSlug } : null, + }); + + it('shows org aggregate option when org is in managedOrgs and contextType is set', () => { + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ + data: { + pages: [{ + results: [makeScope('lib:org1/lib1', 'Library One', 'org1')], + count: 1, + next: null, + previous: null, + }], + }, + }), + ); + // org1 is in managedOrgs (Set(['org1', 'org2'])) + renderComponent({ selectedRole: 'library_admin' }); + expect(screen.getByText('All libraries in this organization')).toBeInTheDocument(); + }); + + // Org aggregate is always shown when contextType is set - backend filters orgs by permissions + it('shows org aggregate even when some orgs are not in managedOrgs', () => { + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ + data: { + pages: [{ + results: [makeScope('lib:org3/lib1', 'Library One', 'org3')], + count: 1, + next: null, + previous: null, + }], + }, + }), + ); + (useScopePermissions as jest.Mock).mockReturnValue({ + hasPlatformPermission: false, + orgHasPermission: { org3: true }, + }); + renderComponent({ selectedRole: 'library_admin' }); + expect(screen.getByText('All libraries in this organization')).toBeInTheDocument(); + }); + + 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(); + }); + + it('does not show platform aggregate when selectedRole is null', () => { + renderComponent({ selectedRole: null }); + expect(screen.queryByText('All libraries in Platform')).not.toBeInTheDocument(); + }); + + it('does not show "All courses in Platform" (disabled pending backend support)', () => { + renderComponent({ selectedRole: 'course_admin' }); + expect(screen.queryByText('All courses in Platform')).not.toBeInTheDocument(); + }); + }); + + describe('Organization dropdown', () => { + it('renders organization dropdown button', () => { + renderComponent(); + expect(screen.getByText('Organization')).toBeInTheDocument(); + }); + + it('shows all organizations in dropdown', async () => { + renderComponent(); + const toggle = screen.getByText('Organization'); + await userEvent.click(toggle); + await waitFor(() => { + expect(screen.getByText('Organization One')).toBeInTheDocument(); + expect(screen.getByText('Organization Two')).toBeInTheDocument(); + }); + }); + + it('updates org filter when organization is selected', async () => { + renderComponent(); + const toggle = screen.getByText('Organization'); + await userEvent.click(toggle); + await waitFor(() => screen.getByText('Organization One')); + await userEvent.click(screen.getByText('Organization One')); + expect(useScopes).toHaveBeenLastCalledWith(expect.objectContaining({ orgs: ['org1'] })); + }); + + it('clears org filter when selected organization is deselected', async () => { + renderComponent(); + const toggle = screen.getByText('Organization'); + await userEvent.click(toggle); + await waitFor(() => screen.getByText('Organization One')); + await userEvent.click(screen.getByText('Organization One')); + await userEvent.click(screen.getByText('Organization One')); + expect(useScopes).toHaveBeenLastCalledWith(expect.objectContaining({ orgs: undefined })); + }); + }); + + describe('Search input', () => { + it('updates search state when typing', () => { + renderComponent(); + const searchInput = screen.getByPlaceholderText('Search'); + fireEvent.change(searchInput, { target: { value: 'mylib' } }); + expect(searchInput).toHaveValue('mylib'); + }); + }); + + describe('OrgSection collapse/expand', () => { + const makeScope = (externalKey: string, displayName: string, orgSlug: string) => ({ + externalKey, displayName, org: orgSlug ? { id: 1, name: orgSlug, shortName: orgSlug } : null, + }); + + it('starts expanded and collapses when header clicked', async () => { + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ + data: { + pages: [{ + results: [makeScope('lib:org1/lib1', 'Library One', 'org1')], + count: 1, + next: null, + previous: null, + }], + }, + }), + ); + renderComponent(); + + // Initially visible + expect(screen.getByLabelText('Library One')).toBeInTheDocument(); + + // Click the org header button to collapse + const orgHeader = screen.getByText('Org: Organization One').closest('button')!; + await userEvent.click(orgHeader); + + // Now the scope should be hidden + expect(screen.queryByLabelText('Library One')).not.toBeInTheDocument(); + }); + + it('expands again after second click', async () => { + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ + data: { + pages: [{ + results: [makeScope('lib:org1/lib1', 'Library One', 'org1')], + count: 1, + next: null, + previous: null, + }], + }, + }), + ); + renderComponent(); + + const orgHeader = screen.getByText('Org: Organization One').closest('button')!; + await userEvent.click(orgHeader); + await userEvent.click(orgHeader); + + expect(screen.getByLabelText('Library One')).toBeInTheDocument(); + }); + }); + + describe('IntersectionObserver for infinite scroll', () => { + it('sets up IntersectionObserver on the load-more div', () => { + renderComponent(); + expect(window.IntersectionObserver).toHaveBeenCalled(); + expect(mockObserve).toHaveBeenCalled(); + }); + + it('calls fetchNextPage when intersection is triggered with hasNextPage=true', () => { + const fetchNextPage = jest.fn(); + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ hasNextPage: true, fetchNextPage }), + ); + + let intersectionCallback: (entries: IntersectionObserverEntry[]) => void; + (window.IntersectionObserver as jest.Mock).mockImplementation((cb) => { + intersectionCallback = cb; + return { observe: mockObserve, disconnect: mockDisconnect, unobserve: jest.fn() }; + }); + + renderComponent(); + + // Simulate intersection + intersectionCallback!([{ isIntersecting: true } as IntersectionObserverEntry]); + expect(fetchNextPage).toHaveBeenCalled(); + }); + + it('does not call fetchNextPage when hasNextPage is false', () => { + const fetchNextPage = jest.fn(); + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ hasNextPage: false, fetchNextPage }), + ); + + let intersectionCallback: (entries: IntersectionObserverEntry[]) => void; + (window.IntersectionObserver as jest.Mock).mockImplementation((cb) => { + intersectionCallback = cb; + return { observe: mockObserve, disconnect: mockDisconnect, unobserve: jest.fn() }; + }); + + renderComponent(); + + intersectionCallback!([{ isIntersecting: true } as IntersectionObserverEntry]); + expect(fetchNextPage).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.tsx b/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.tsx index 1f6f2375..171b162c 100644 --- a/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.tsx +++ b/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.tsx @@ -1,14 +1,106 @@ +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 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 { + if (!role) { return undefined; } + return allRolesMetadata.find((r) => r.role === role)?.contextType; +} + interface DefineApplicationScopeStepProps { selectedRole: string | null; selectedScopes: Set; onScopeToggle: (scopeId: string) => void; + assignmentErrors?: string[]; } -// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars -const DefineApplicationScopeStep = (_props: DefineApplicationScopeStepProps) => -// Placeholder for Step 2 - "Define Application Scope" -// TODO: implement scope selection UI using selectedRole, selectedScopes, onScopeToggle +const DefineApplicationScopeStep = ({ + selectedRole, + selectedScopes, + onScopeToggle, + assignmentErrors = [], +}: DefineApplicationScopeStepProps) => { + const intl = useIntl(); + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [selectedOrgs, setSelectedOrgs] = useState([]); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(search), 300); + return () => clearTimeout(timer); + }, [search]); + + const contextType = useMemo( + () => getContextType(selectedRole), + [selectedRole], + ); + const contextLabelMessages = { + course: messages['wizard.step2.contextLabel.course'], + library: messages['wizard.step2.contextLabel.library'], + }; + const contextLabel = contextType && contextLabelMessages[contextType] + ? intl.formatMessage(contextLabelMessages[contextType]) + : intl.formatMessage(messages['wizard.step2.contextLabel.default']); + + const { + organizations, + orderedOrgs, + scopesByOrg, + allScopes, + totalCount, + queryState, + platformAggregateScopeItem, + orgAggregateScopeItems, + } = useScopeListData({ contextType, search: debouncedSearch, orgs: selectedOrgs }); + + return ( +
+

{intl.formatMessage(messages['wizard.step.defineScope.title'])}

+ + + +
+ + {assignmentErrors.length > 0 && ( + +

+ {intl.formatMessage(messages['wizard.step2.errors.alert.title'])} +

+
    + {assignmentErrors.map((msg) =>
  • {msg}
  • )} +
+
+ )} + + +
+ ); +}; - // eslint-disable-next-line implicit-arrow-linebreak -

Step 2

; export default DefineApplicationScopeStep; diff --git a/src/authz-module/role-assignation-wizard/components/OrgSection.tsx b/src/authz-module/role-assignation-wizard/components/OrgSection.tsx new file mode 100644 index 00000000..cdf24d93 --- /dev/null +++ b/src/authz-module/role-assignation-wizard/components/OrgSection.tsx @@ -0,0 +1,57 @@ +import { useState } from 'react'; +import { Icon } from '@openedx/paragon'; +import { ExpandLess, ExpandMore } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Scope } from 'types'; +import messages from '../messages'; +import ScopeCheckboxItem from './ScopeCheckboxItem'; + +export interface OrgSectionProps { + orgName: string; + scopes: Scope[]; + selectedScopes: Set; + onScopeToggle: (scopeId: string) => void; + aggregateScopeItem?: Scope; +} + +const OrgSection = ({ + orgName, scopes, selectedScopes, onScopeToggle, aggregateScopeItem, +}: OrgSectionProps) => { + const intl = useIntl(); + const [collapsed, setCollapsed] = useState(false); + + return ( +
+ + + {!collapsed && ( +
+ {aggregateScopeItem && ( + + )} + {scopes.map((scope) => ( + + ))} +
+ )} +
+ ); +}; + +export default OrgSection; diff --git a/src/authz-module/role-assignation-wizard/components/ScopeCheckboxItem.tsx b/src/authz-module/role-assignation-wizard/components/ScopeCheckboxItem.tsx new file mode 100644 index 00000000..e53e6b5c --- /dev/null +++ b/src/authz-module/role-assignation-wizard/components/ScopeCheckboxItem.tsx @@ -0,0 +1,25 @@ +import { Form } from '@openedx/paragon'; +import { Scope } from 'types'; + +interface ScopeCheckboxItemProps { + scope: Scope; + checked: boolean; + onToggle: (scopeId: string) => void; +} + +const ScopeCheckboxItem = ({ scope, checked, onToggle }: ScopeCheckboxItemProps) => ( +
+ onToggle(scope.externalKey)} + data-testid={`toggle-scope-${scope.externalKey}`} + > + {scope.displayName} + + {scope.description && ( + {scope.description} + )} +
+); + +export default ScopeCheckboxItem; diff --git a/src/authz-module/role-assignation-wizard/components/ScopeFilterBar.tsx b/src/authz-module/role-assignation-wizard/components/ScopeFilterBar.tsx new file mode 100644 index 00000000..a6b547c1 --- /dev/null +++ b/src/authz-module/role-assignation-wizard/components/ScopeFilterBar.tsx @@ -0,0 +1,73 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Form, Icon, Badge, Stack, +} from '@openedx/paragon'; +import { Search } from '@openedx/paragon/icons'; +import OrgFilter from '@src/authz-module/components/TableControlBar/OrgFilter'; +import { ChangeEvent } from 'react'; +import messages from '../messages'; + +interface ScopeFilterBarProps { + search: string; + onSearchChange: (value: string) => void; + selectedOrgs: string[]; + onOrgsChange: (value: string[]) => void; + contextType: string | undefined; + contextLabel: string; + allScopesCount: number; + totalCount: number; +} + +const ScopeFilterBar = ({ + search, + onSearchChange, + selectedOrgs, + onOrgsChange, + contextType, + contextLabel, + allScopesCount, + totalCount, +}: ScopeFilterBarProps) => { + const intl = useIntl(); + + return ( + <> +
+
+
+ + ) => onSearchChange(e.target.value)} + placeholder={intl.formatMessage(messages['wizard.step2.search.placeholder'])} + trailingElement={} + /> + +
+ + +
+ + + {intl.formatMessage(messages['wizard.step2.count'], { shown: allScopesCount, total: totalCount })} + +
+ + {contextType && ( + + {intl.formatMessage(messages['wizard.step2.filter.applied'])} + + {contextLabel} + + + )} + + ); +}; + +export default ScopeFilterBar; diff --git a/src/authz-module/role-assignation-wizard/components/ScopeList.tsx b/src/authz-module/role-assignation-wizard/components/ScopeList.tsx new file mode 100644 index 00000000..77f25f87 --- /dev/null +++ b/src/authz-module/role-assignation-wizard/components/ScopeList.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef } from 'react'; +import { Spinner } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Org, Scope } from 'types'; +import OrgSection from './OrgSection'; +import ScopeCheckboxItem from './ScopeCheckboxItem'; +import messages from '../messages'; + +interface ScopeListQueryState { + isLoading: boolean; + isFetchingNextPage: boolean; + hasNextPage: boolean | undefined; + isError: boolean; + fetchNextPage: () => void; +} + +interface ScopeListProps { + orderedOrgs: string[]; + scopesByOrg: Record; + organizations: Org[] | undefined; + selectedScopes: Set; + onScopeToggle: (scopeId: string) => void; + queryState: ScopeListQueryState; + platformAggregateScopeItem: Scope | null; + orgAggregateScopeItems: Record; +} + +const ScopeList = ({ + orderedOrgs, + scopesByOrg, + organizations, + selectedScopes, + onScopeToggle, + queryState, + platformAggregateScopeItem, + orgAggregateScopeItems, +}: ScopeListProps) => { + const intl = useIntl(); + const { + isLoading, isFetchingNextPage, hasNextPage, isError, fetchNextPage, + } = queryState; + const loadMoreRef = useRef(null); + + useEffect(() => { + const el = loadMoreRef.current; + if (!el) { return undefined; } + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage && !isError) { + fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, isError, fetchNextPage]); + + return ( +
+ {isLoading ? ( +
+ +
+ ) : ( + <> + {platformAggregateScopeItem && ( + + )} + + {orderedOrgs.map((org) => ( + o.shortName === org)?.name || org} + scopes={scopesByOrg[org]} + selectedScopes={selectedScopes} + onScopeToggle={onScopeToggle} + aggregateScopeItem={orgAggregateScopeItems[org]} + /> + ))} + + {orderedOrgs.length === 0 && ( +

{intl.formatMessage(messages['wizard.step2.scopeList.empty'])}

+ )} + +
+ + {isFetchingNextPage && ( +
+ +
+ )} + + )} +
+ ); +}; + +export default ScopeList; diff --git a/src/authz-module/role-assignation-wizard/constants.ts b/src/authz-module/role-assignation-wizard/constants.ts new file mode 100644 index 00000000..8fc39484 --- /dev/null +++ b/src/authz-module/role-assignation-wizard/constants.ts @@ -0,0 +1,5 @@ +export const ROLE_ASSIGNMENT_ERRORS = { + USER_ALREADY_HAS_ROLE: 'user_already_has_role', + USER_NOT_FOUND: 'user_not_found', + ROLE_ASSIGNMENT_ERROR: 'role_assignment_error', +} as const; diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.test.ts b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.test.ts new file mode 100644 index 00000000..0a9d6483 --- /dev/null +++ b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.test.ts @@ -0,0 +1,503 @@ +import { renderHook } from '@testing-library/react'; +import { intlWrapper as wrapper } from '@src/setupTest'; +import useScopeListData from './useScopeListData'; +import { useScopes, useOrgs } from '../../data/hooks'; +import useScopePermissions from './useScopePermissions'; + +jest.mock('../../data/hooks', () => ({ + useScopes: jest.fn(), + useOrgs: jest.fn(), +})); + +jest.mock('./useScopePermissions'); + +const mockUseScopes = useScopes as jest.Mock; +const mockUseOrganizations = useOrgs as jest.Mock; +const mockUseScopePermissions = useScopePermissions as jest.Mock; + +const makeScopesHook = (overrides = {}) => ({ + data: { + pages: [{ + results: [], count: 0, next: null, previous: null, + }], + }, + fetchNextPage: jest.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + isError: false, + ...overrides, +}); + +const defaultOrgs = [ + { + id: 1, name: 'Organization One', shortName: 'org1', description: '', logo: null, active: true, + }, + { + id: 2, name: 'Organization Two', shortName: 'org2', description: '', logo: null, active: true, + }, +]; + +describe('useScopeListData', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseScopePermissions.mockReturnValue({ + hasPlatformPermission: false, + orgHasPermission: { org1: true, org2: true }, + }); + }); + + describe('Return value structure', () => { + it('returns expected shape when contextType is library', () => { + mockUseScopes.mockReturnValue(makeScopesHook()); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + const { result } = renderHook(() => useScopeListData({ + contextType: 'library', + search: '', + orgs: [], + }), { wrapper }); + + expect(result.current).toMatchObject({ + organizations: defaultOrgs, + orderedOrgs: expect.any(Array), + scopesByOrg: expect.any(Object), + allScopes: expect.any(Array), + totalCount: expect.any(Number), + queryState: expect.objectContaining({ + isLoading: expect.any(Boolean), + isFetchingNextPage: expect.any(Boolean), + hasNextPage: expect.any(Boolean), + isError: expect.any(Boolean), + fetchNextPage: expect.any(Function), + }), + platformAggregateScopeItem: null, + }); + }); + + it('returns expected shape when contextType is course', () => { + mockUseScopes.mockReturnValue(makeScopesHook()); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + const { result } = renderHook(() => useScopeListData({ + contextType: 'course', + search: '', + orgs: [], + }), { wrapper }); + + expect(result.current.platformAggregateScopeItem).toBeNull(); + }); + + it('returns null platformAggregateScopeItem when contextType is undefined', () => { + mockUseScopes.mockReturnValue(makeScopesHook()); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + const { result } = renderHook(() => useScopeListData({ + contextType: undefined, + search: '', + orgs: [], + }), { wrapper }); + + expect(result.current.platformAggregateScopeItem).toBeNull(); + }); + }); + + describe('Scope grouping by organization', () => { + it('groups scopes by org shortName', () => { + const scopesWithOrgs = { + data: { + pages: [{ + results: [ + { externalKey: 'lib:org1/lib1', displayName: 'Library 1', org: { id: 1, name: 'Org 1', shortName: 'org1' } }, + { externalKey: 'lib:org2/lib2', displayName: 'Library 2', org: { id: 2, name: 'Org 2', shortName: 'org2' } }, + { externalKey: 'lib:org1/lib3', displayName: 'Library 3', org: { id: 1, name: 'Org 1', shortName: 'org1' } }, + ], + count: 3, + next: null, + previous: null, + }], + }, + }; + mockUseScopes.mockReturnValue(makeScopesHook(scopesWithOrgs)); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + const { result } = renderHook(() => useScopeListData({ + contextType: 'library', + search: '', + orgs: [], + }), { wrapper }); + + expect(Object.keys(result.current.scopesByOrg)).toEqual(['org1', 'org2']); + expect(result.current.scopesByOrg.org1).toHaveLength(2); + expect(result.current.scopesByOrg.org2).toHaveLength(1); + }); + + it('excludes scopes without org from grouping', () => { + const scopesWithAndWithoutOrg = { + data: { + pages: [{ + results: [ + { externalKey: 'lib:platform', displayName: 'Platform Lib', org: null }, + { externalKey: 'lib:org1/lib1', displayName: 'Library 1', org: { id: 1, name: 'Org 1', shortName: 'org1' } }, + ], + count: 2, + next: null, + previous: null, + }], + }, + }; + mockUseScopes.mockReturnValue(makeScopesHook(scopesWithAndWithoutOrg)); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + const { result } = renderHook(() => useScopeListData({ + contextType: 'library', + search: '', + orgs: [], + }), { wrapper }); + + expect(Object.keys(result.current.scopesByOrg)).toEqual(['org1']); + }); + + it('sorts org scopes with "All" aggregate first', () => { + const scopesWithAggregate = { + data: { + pages: [{ + results: [ + { externalKey: 'lib:org1/lib1', displayName: 'Library 1', org: { id: 1, name: 'Org 1', shortName: 'org1' } }, + { externalKey: 'lib:org1/*', displayName: 'All libraries in org1', org: { id: 1, name: 'Org 1', shortName: 'org1' } }, + ], + count: 2, + next: null, + previous: null, + }], + }, + }; + mockUseScopes.mockReturnValue(makeScopesHook(scopesWithAggregate)); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + const { result } = renderHook(() => useScopeListData({ + contextType: 'library', + search: '', + orgs: [], + }), { wrapper }); + + const org1Scopes = result.current.scopesByOrg.org1; + // Note: API returns scopes in whatever order - no sorting applied by this hook + expect(org1Scopes[0].displayName).toBe('Library 1'); + expect(org1Scopes[1].displayName).toBe('All libraries in org1'); + }); + + it('orders organizations alphabetically', () => { + const multipleOrgs = { + data: { + pages: [{ + results: [ + { externalKey: 'lib:org2/lib1', displayName: 'Lib 1', org: { id: 2, name: 'Org 2', shortName: 'org2' } }, + { externalKey: 'lib:org1/lib1', displayName: 'Lib 1', org: { id: 1, name: 'Org 1', shortName: 'org1' } }, + { externalKey: 'lib:org3/lib1', displayName: 'Lib 1', org: { id: 3, name: 'Org 3', shortName: 'org3' } }, + ], + count: 3, + next: null, + previous: null, + }], + }, + }; + mockUseScopes.mockReturnValue(makeScopesHook(multipleOrgs)); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + const { result } = renderHook(() => useScopeListData({ + contextType: 'library', + search: '', + orgs: [], + }), { wrapper }); + + expect(result.current.orderedOrgs).toEqual(['org1', 'org2', 'org3']); + }); + }); + + describe('Platform aggregate scope item', () => { + it('returns null platformAggregateScopeItem for library context (disabled pending backend support)', () => { + mockUseScopes.mockReturnValue(makeScopesHook()); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + const { result } = renderHook(() => useScopeListData({ + contextType: 'library', + search: '', + orgs: [], + }), { wrapper }); + + expect(result.current.platformAggregateScopeItem).toBeNull(); + }); + + it('returns null platformAggregateScopeItem for course context (disabled pending backend support)', () => { + mockUseScopes.mockReturnValue(makeScopesHook()); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + const { result } = renderHook(() => useScopeListData({ + contextType: 'course', + search: '', + orgs: [], + }), { wrapper }); + + expect(result.current.platformAggregateScopeItem).toBeNull(); + }); + + it('returns null platformAggregateScopeItem when contextType is undefined', () => { + mockUseScopes.mockReturnValue(makeScopesHook()); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + const { result } = renderHook(() => useScopeListData({ + contextType: undefined, + search: '', + orgs: [], + }), { wrapper }); + + expect(result.current.platformAggregateScopeItem).toBeNull(); + }); + }); + + describe('Error and loading states', () => { + it('passes through loading state from useScopes', () => { + mockUseScopes.mockReturnValue(makeScopesHook({ isLoading: true })); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + const { result } = renderHook(() => useScopeListData({ + contextType: 'library', + search: '', + orgs: [], + }), { wrapper }); + + expect(result.current.queryState.isLoading).toBe(true); + }); + + it('passes through error state from useScopes', () => { + mockUseScopes.mockReturnValue(makeScopesHook({ isError: true })); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + const { result } = renderHook(() => useScopeListData({ + contextType: 'library', + search: '', + orgs: [], + }), { wrapper }); + + expect(result.current.queryState.isError).toBe(true); + }); + + it('passes through fetchNextPage from useScopes', () => { + const fetchNextPageFn = jest.fn(); + mockUseScopes.mockReturnValue(makeScopesHook({ fetchNextPage: fetchNextPageFn })); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + const { result } = renderHook(() => useScopeListData({ + contextType: 'library', + search: '', + orgs: [], + }), { wrapper }); + + result.current.queryState.fetchNextPage(); + expect(fetchNextPageFn).toHaveBeenCalled(); + }); + }); + + describe('Query parameters', () => { + it('passes contextType to useScopes', () => { + mockUseScopes.mockReturnValue(makeScopesHook()); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + renderHook(() => useScopeListData({ + contextType: 'library', + search: '', + orgs: [], + }), { wrapper }); + + expect(mockUseScopes).toHaveBeenCalledWith(expect.objectContaining({ + scopeType: 'library', + })); + }); + + it('passes search to useScopes when provided', () => { + mockUseScopes.mockReturnValue(makeScopesHook()); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + renderHook(() => useScopeListData({ + contextType: 'library', + search: 'mylib', + orgs: [], + }), { wrapper }); + + expect(mockUseScopes).toHaveBeenCalledWith(expect.objectContaining({ + search: 'mylib', + })); + }); + + it('passes org to useScopes when provided', () => { + mockUseScopes.mockReturnValue(makeScopesHook()); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + renderHook(() => useScopeListData({ + contextType: 'library', + search: '', + orgs: ['org1'], + }), { wrapper }); + + expect(mockUseScopes).toHaveBeenCalledWith(expect.objectContaining({ + orgs: ['org1'], + })); + }); + + it('passes undefined for empty search', () => { + mockUseScopes.mockReturnValue(makeScopesHook()); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + renderHook(() => useScopeListData({ + contextType: 'library', + search: '', + orgs: [], + }), { wrapper }); + + expect(mockUseScopes).toHaveBeenCalledWith(expect.objectContaining({ + search: undefined, + })); + }); + + it('passes undefined for empty orgs', () => { + mockUseScopes.mockReturnValue(makeScopesHook()); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + renderHook(() => useScopeListData({ + contextType: 'library', + search: '', + orgs: [], + }), { wrapper }); + + expect(mockUseScopes).toHaveBeenCalledWith(expect.objectContaining({ + orgs: undefined, + })); + }); + }); + + describe('Total count', () => { + it('calculates totalCount from first page', () => { + const scopesWithCount = { + data: { + pages: [{ + results: [], + count: 42, + next: 'http://api/pages/2', + previous: null, + }], + }, + }; + mockUseScopes.mockReturnValue(makeScopesHook(scopesWithCount)); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + const { result } = renderHook(() => useScopeListData({ + contextType: 'library', + search: '', + orgs: [], + }), { wrapper }); + + expect(result.current.totalCount).toBe(42); + }); + + it('returns 0 when no data', () => { + mockUseScopes.mockReturnValue(makeScopesHook()); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + const { result } = renderHook(() => useScopeListData({ + contextType: 'library', + search: '', + orgs: [], + }), { wrapper }); + + expect(result.current.totalCount).toBe(0); + }); + }); + + describe('useScopeListData — error and loading states', () => { + it('handles loading state', () => { + mockUseScopes.mockReturnValue(makeScopesHook({ + isLoading: true, + data: undefined, + })); + mockUseOrganizations.mockReturnValue({ data: undefined }); + + const { result } = renderHook(() => useScopeListData({ + contextType: 'course', + search: '', + orgs: [], + }), { wrapper }); + + expect(result.current.queryState.isLoading).toBe(true); + }); + + it('handles error state', () => { + mockUseScopes.mockReturnValue(makeScopesHook({ + isError: true, + error: new Error('Failed to fetch'), + })); + mockUseOrganizations.mockReturnValue({ data: undefined }); + + const { result } = renderHook(() => useScopeListData({ + contextType: 'course', + search: '', + orgs: [], + }), { wrapper }); + + expect(result.current.queryState.isError).toBe(true); + }); + + it('handles fetch next page', () => { + const mockFetchNextPage = jest.fn(); + mockUseScopes.mockReturnValue(makeScopesHook({ + fetchNextPage: mockFetchNextPage, + hasNextPage: true, + })); + mockUseOrganizations.mockReturnValue({ data: { results: defaultOrgs } }); + + const { result } = renderHook(() => useScopeListData({ + contextType: 'course', + search: '', + orgs: [], + }), { wrapper }); + + result.current.queryState.fetchNextPage(); + expect(mockFetchNextPage).toHaveBeenCalled(); + }); + + it('returns empty when organizations data is undefined', () => { + mockUseScopes.mockReturnValue(makeScopesHook()); + mockUseOrganizations.mockReturnValue({ data: undefined }); + + const { result } = renderHook(() => useScopeListData({ + contextType: 'course', + search: '', + orgs: [], + }), { wrapper }); + + expect(result.current.organizations).toBeUndefined(); + }); + }); + + describe('useScopeListData — edge cases', () => { + it('returns empty orderedOrgs when no scopes', () => { + mockUseScopes.mockReturnValue(makeScopesHook({ + data: { + pages: [{ + results: [], count: 0, next: null, previous: null, + }], + }, + })); + mockUseOrganizations.mockReturnValue({ data: { results: [] } }); + + const { result } = renderHook(() => useScopeListData({ + contextType: 'course', + search: '', + orgs: [], + }), { wrapper }); + + expect(result.current.orderedOrgs).toEqual([]); + expect(result.current.scopesByOrg).toEqual({}); + }); + }); +}); diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts new file mode 100644 index 00000000..205a6976 --- /dev/null +++ b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts @@ -0,0 +1,119 @@ +import { useMemo } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { 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; + search: string; + orgs: string[]; +} + +const groupByOrg = (acc: Record, scope: Scope): Record => { + const orgSlug = scope.org!.shortName; + if (!acc[orgSlug]) { acc[orgSlug] = []; } + acc[orgSlug].push(scope); + return acc; +}; + +const useScopeListData = ({ contextType, search, orgs }: UseScopeListDataParams) => { + const intl = useIntl(); + const { + data: scopesData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = useScopes({ + scopeType: contextType, + search: search || undefined, + orgs: orgs.length ? orgs : undefined, + }); + + const { data: orgsData } = useOrgs(); + const organizations = orgsData?.results; + + const allScopes = useMemo( + () => scopesData?.pages.flatMap((page) => page.results) ?? [], + [scopesData], + ); + + const totalCount = scopesData?.pages[0]?.count ?? 0; + + const scopesByOrg = useMemo( + () => allScopes + .filter((s: Scope) => !!s.org) + .reduce>(groupByOrg, {}), + [allScopes], + ); + + const orderedOrgs = useMemo(() => Object.keys(scopesByOrg).sort(), [scopesByOrg]); + + const { hasPlatformPermission, orgHasPermission } = useScopePermissions({ contextType, orderedOrgs }); + + const aggregateDescription = contextType === 'course' + ? intl.formatMessage(messages['wizard.step2.scope.aggregate.description.course']) + : intl.formatMessage(messages['wizard.step2.scope.aggregate.description.library']); + + const platformAggregateLabel = contextType === 'course' + ? intl.formatMessage(messages['wizard.step2.scope.aggregate.platform.label.course']) + : intl.formatMessage(messages['wizard.step2.scope.aggregate.platform.label.library']); + + const orgAggregateLabel = contextType === 'course' + ? intl.formatMessage(messages['wizard.step2.scopeList.aggregate.label.course']) + : intl.formatMessage(messages['wizard.step2.scopeList.aggregate.label.library']); + + const platformAggregateScopeItem: Scope | null = (contextType && hasPlatformPermission) + ? { + externalKey: '*', + displayName: platformAggregateLabel, + description: aggregateDescription, + org: null, + } + : null; + + /** + * Builds a map of org-level aggregate scopes keyed by org slug. + * + * Each entry represents a wildcard scope that grants access to all courses or + * libraries within an org (e.g. `course-v1:OrgSlug+*` or `lib:OrgSlug:*`). + * Only orgs where the current user already holds permission are included. + * + * Returns an empty object when `contextType` is not yet defined. + */ + const orgAggregateScopeItems = useMemo>(() => { + if (!contextType) { return {}; } + return Object.fromEntries( + orderedOrgs + .filter((orgSlug) => orgHasPermission[orgSlug]) + .map((orgSlug) => [ + orgSlug, + { + externalKey: getOrgAggregateScopeKey(contextType, orgSlug), + displayName: orgAggregateLabel, + description: aggregateDescription, + org: { id: '0', name: orgSlug, shortName: orgSlug }, + } satisfies Scope, + ]), + ); + }, [orderedOrgs, contextType, orgHasPermission, orgAggregateLabel, aggregateDescription]); + + return { + organizations, + orderedOrgs, + scopesByOrg, + allScopes, + totalCount, + queryState: { + isLoading, isFetchingNextPage, hasNextPage, isError, fetchNextPage, + }, + platformAggregateScopeItem, + orgAggregateScopeItems, + }; +}; + +export default useScopeListData; diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.test.ts b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.test.ts new file mode 100644 index 00000000..64917f11 --- /dev/null +++ b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.test.ts @@ -0,0 +1,131 @@ +import { renderHook } from '@testing-library/react'; +import { useValidateUserPermissions } from '@src/data/hooks'; +import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from '@src/authz-module/constants'; +import useScopePermissions from './useScopePermissions'; + +jest.mock('@src/data/hooks', () => ({ + useValidateUserPermissions: jest.fn(), +})); + +const mockUseValidateUserPermissions = useValidateUserPermissions as jest.Mock; + +describe('useScopePermissions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseValidateUserPermissions.mockReturnValue({ data: [] }); + }); + + describe('hasPlatformPermission', () => { + it('is always false (pending backend support)', () => { + const { result } = renderHook(() => useScopePermissions({ + contextType: 'course', + orderedOrgs: ['MIT'], + })); + + expect(result.current.hasPlatformPermission).toBe(false); + }); + }); + + describe('orgHasPermission', () => { + it('returns empty map when orderedOrgs is empty', () => { + const { result } = renderHook(() => useScopePermissions({ + contextType: 'course', + orderedOrgs: [], + })); + + expect(result.current.orgHasPermission).toEqual({}); + }); + + it('maps allowed responses by org slug index for course context', () => { + mockUseValidateUserPermissions.mockReturnValue({ + data: [{ allowed: true }, { allowed: false }], + }); + + const { result } = renderHook(() => useScopePermissions({ + contextType: 'course', + orderedOrgs: ['MIT', 'HarvardX'], + })); + + expect(result.current.orgHasPermission).toEqual({ MIT: true, HarvardX: false }); + }); + + it('maps allowed responses by org slug index for library context', () => { + mockUseValidateUserPermissions.mockReturnValue({ + data: [{ allowed: false }, { allowed: true }], + }); + + const { result } = renderHook(() => useScopePermissions({ + contextType: 'library', + orderedOrgs: ['MIT', 'HarvardX'], + })); + + expect(result.current.orgHasPermission).toEqual({ MIT: false, HarvardX: true }); + }); + + it('defaults to false when the response entry is missing', () => { + mockUseValidateUserPermissions.mockReturnValue({ data: [] }); + + const { result } = renderHook(() => useScopePermissions({ + contextType: 'course', + orderedOrgs: ['MIT'], + })); + + expect(result.current.orgHasPermission).toEqual({ MIT: false }); + }); + + it('defaults to false when orgPerms data is undefined', () => { + mockUseValidateUserPermissions.mockReturnValue({ data: undefined }); + + const { result } = renderHook(() => useScopePermissions({ + contextType: 'course', + orderedOrgs: ['MIT', 'HarvardX'], + })); + + expect(result.current.orgHasPermission).toEqual({ MIT: false, HarvardX: false }); + }); + }); + + describe('permission request construction', () => { + it('uses MANAGE_COURSE_TEAM action with course-v1 scope for course context', () => { + renderHook(() => useScopePermissions({ + contextType: 'course', + orderedOrgs: ['MIT', 'HarvardX'], + })); + + expect(mockUseValidateUserPermissions).toHaveBeenCalledWith([ + { 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', () => { + renderHook(() => useScopePermissions({ + contextType: 'library', + orderedOrgs: ['MIT', 'HarvardX'], + })); + + expect(mockUseValidateUserPermissions).toHaveBeenCalledWith([ + { 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', () => { + renderHook(() => useScopePermissions({ + contextType: 'course', + orderedOrgs: [], + })); + + expect(mockUseValidateUserPermissions).toHaveBeenCalledWith([]); + }); + + it('passes empty array when contextType is undefined', () => { + renderHook(() => useScopePermissions({ + contextType: undefined, + orderedOrgs: ['MIT'], + })); + + expect(mockUseValidateUserPermissions).toHaveBeenCalledWith([]); + }); + }); +}); diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts new file mode 100644 index 00000000..af72b9ce --- /dev/null +++ b/src/authz-module/role-assignation-wizard/hooks/useScopePermissions.ts @@ -0,0 +1,49 @@ +import { useMemo } from 'react'; +import { useValidateUserPermissions } from '@src/data/hooks'; +import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS, getOrgAggregateScopeKey } from '@src/authz-module/constants'; + +interface UseScopePermissionsParams { + contextType: string | undefined; + orderedOrgs: string[]; +} + +interface UseScopePermissionsResult { + hasPlatformPermission: boolean; + orgHasPermission: Record; +} + +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 + // Note: Using glob patterns (*:org:*) + const orgPermissionRequests = useMemo(() => { + if (!orderedOrgs.length || !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), + })); + }, [orderedOrgs, contextType]); + + const { data: orgPerms } = useValidateUserPermissions(orgPermissionRequests); + + // Build a map of `org: has_permission` + const orgHasPermission = useMemo(() => { + const map: Record = {}; + orderedOrgs.forEach((org, idx) => { + map[org] = orgPerms?.[idx]?.allowed ?? false; + }); + return map; + }, [orderedOrgs, orgPerms]); + + return { hasPlatformPermission, orgHasPermission }; +}; + +export default useScopePermissions; diff --git a/src/authz-module/role-assignation-wizard/messages.ts b/src/authz-module/role-assignation-wizard/messages.ts index e32b2a40..f90cb2cf 100644 --- a/src/authz-module/role-assignation-wizard/messages.ts +++ b/src/authz-module/role-assignation-wizard/messages.ts @@ -26,7 +26,7 @@ const messages = defineMessages({ }, 'wizard.step.defineScope.title': { id: 'wizard.step.defineScope.title', - defaultMessage: 'Where it applies', + defaultMessage: 'Where It Applies', description: 'Step 2 title in the assign role wizard', }, @@ -68,6 +68,68 @@ const messages = defineMessages({ defaultMessage: 'Role assigned successfully.', description: 'Toast message shown when a role is successfully assigned', }, + 'wizard.save.errors.summary': { + id: 'wizard.save.errors.summary', + defaultMessage: 'Some assignments could not be completed. See details above.', + description: 'Short toast message shown when one or more role assignments fail', + }, + 'wizard.step2.errors.alert.title': { + id: 'wizard.step2.errors.alert.title', + defaultMessage: 'The following errors occurred:', + description: 'Heading of the inline error alert shown in step 2 when role assignments partially fail', + }, + 'wizard.save.error.user_already_has_role': { + id: 'wizard.save.error.user_already_has_role', + defaultMessage: '{userIdentifier} already has this role in {scope}', + description: 'Error shown in the toast when the user already holds the role in the selected scope', + }, + 'wizard.save.error.user_not_found': { + id: 'wizard.save.error.user_not_found', + defaultMessage: 'User "{userIdentifier}" was not found', + description: 'Error shown in the toast when the username or email does not match any account', + }, + 'wizard.save.error.role_assignment_error': { + id: 'wizard.save.error.role_assignment_error', + defaultMessage: 'Could not assign role to {userIdentifier} in {scope}', + description: 'Error shown in the toast when an unexpected error occurs during role assignment', + }, + 'wizard.save.error.default': { + id: 'wizard.save.error.default', + defaultMessage: '{userIdentifier} ({scope}): {error}', + description: 'Fallback error line shown in the toast for unknown role-assignment error codes', + }, + 'wizard.save.error.forbidden': { + id: 'wizard.save.error.forbidden', + defaultMessage: 'You do not have permission to perform this assignment.', + description: 'Inline error shown in step 2 when the backend returns 403 on role assignment', + }, + + // DefineApplicationScopeStep — filter bar + 'wizard.step2.search.placeholder': { + id: 'wizard.step2.search.placeholder', + defaultMessage: 'Search', + description: 'Placeholder text for the scope search input in step 2', + }, + 'wizard.step2.filter.org.label': { + id: 'wizard.step2.filter.org.label', + defaultMessage: 'Organization', + description: 'Default label for the organization filter dropdown in step 2', + }, + 'wizard.step2.filter.org.all': { + id: 'wizard.step2.filter.org.all', + defaultMessage: 'All Organizations', + description: 'Option to clear the organization filter in step 2', + }, + 'wizard.step2.filter.applied': { + id: 'wizard.step2.filter.applied', + defaultMessage: 'Filter applied:', + description: 'Label prefix shown before the active context-type filter badge in step 2', + }, + 'wizard.step2.count': { + id: 'wizard.step2.count', + defaultMessage: 'Showing {shown} of {total}.', + description: 'Count of visible scopes vs total in step 2', + }, // SelectUsersAndRoleStep — users section 'wizard.step1.users.heading': { @@ -118,6 +180,81 @@ const messages = defineMessages({ description: 'Tooltip shown on disabled role options', }, + // DefineApplicationScopeStep — context labels + 'wizard.step2.contextLabel.course': { + id: 'wizard.step2.contextLabel.course', + defaultMessage: 'Courses', + description: 'Context label shown in the scope filter bar when the selected role applies to courses', + }, + 'wizard.step2.contextLabel.library': { + id: 'wizard.step2.contextLabel.library', + defaultMessage: 'Libraries', + description: 'Context label shown in the scope filter bar when the selected role applies to libraries', + }, + 'wizard.step2.contextLabel.default': { + id: 'wizard.step2.contextLabel.default', + defaultMessage: 'Items', + description: 'Fallback context label shown in the scope filter bar when no specific context type is matched', + }, + + // useScopeListData — platform/org aggregate scope items + 'wizard.step2.scope.aggregate.description.course': { + id: 'wizard.step2.scope.aggregate.description.course', + defaultMessage: 'Includes current and future courses', + description: 'Description for the platform-wide aggregate scope item when context type is course', + }, + 'wizard.step2.scope.aggregate.description.library': { + id: 'wizard.step2.scope.aggregate.description.library', + defaultMessage: 'Includes current and future libraries', + description: 'Description for the platform-wide aggregate scope item when context type is library', + }, + 'wizard.step2.scope.aggregate.platform.label.course': { + id: 'wizard.step2.scope.aggregate.platform.label.course', + defaultMessage: 'All courses in 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', + description: 'Display name for the platform-wide aggregate scope item when context type is library', + }, + + // ScopeList — org section header + 'wizard.step2.scopeList.orgLabel': { + id: 'wizard.step2.scopeList.orgLabel', + defaultMessage: 'Org: {orgName}', + description: 'Label for the collapsible org section header in the scope list', + }, + + // ScopeList — org-level aggregate scope items + 'wizard.step2.scopeList.aggregate.label.course': { + id: 'wizard.step2.scopeList.aggregate.label.course', + defaultMessage: 'All courses in this organization', + description: 'Display name for the org-wide aggregate scope item when context type is course', + }, + 'wizard.step2.scopeList.aggregate.label.library': { + id: 'wizard.step2.scopeList.aggregate.label.library', + defaultMessage: 'All libraries in this organization', + description: 'Display name for the org-wide aggregate scope item when context type is library', + }, + + // ScopeList — loading / empty states + 'wizard.step2.scopeList.loading': { + id: 'wizard.step2.scopeList.loading', + defaultMessage: 'Loading scopes...', + description: 'Screen reader text for the loading spinner while scopes are being fetched', + }, + 'wizard.step2.scopeList.loadingMore': { + id: 'wizard.step2.scopeList.loadingMore', + defaultMessage: 'Loading more...', + description: 'Screen reader text for the spinner shown while fetching the next page of scopes', + }, + 'wizard.step2.scopeList.empty': { + id: 'wizard.step2.scopeList.empty', + defaultMessage: 'No scopes found.', + description: 'Message shown when no scopes match the current filters', + }, + // SelectUsersAndRoleStep — documentation link 'wizard.step1.docs.heading': { id: 'wizard.step1.docs.heading', diff --git a/src/authz-module/role-assignation-wizard/utils.test.ts b/src/authz-module/role-assignation-wizard/utils.test.ts new file mode 100644 index 00000000..12d46262 --- /dev/null +++ b/src/authz-module/role-assignation-wizard/utils.test.ts @@ -0,0 +1,28 @@ +import { createIntl } from '@edx/frontend-platform/i18n'; +import { formatRoleAssignmentError } from './utils'; +import { ROLE_ASSIGNMENT_ERRORS } from './constants'; + +const intl = createIntl({ locale: 'en', messages: {} }); +const base = { userIdentifier: 'alice', scope: 'lib:org1/lib1' }; + +describe('formatRoleAssignmentError', () => { + it('formats user_already_has_role', () => { + const result = formatRoleAssignmentError(intl, { ...base, error: ROLE_ASSIGNMENT_ERRORS.USER_ALREADY_HAS_ROLE }); + expect(result).toBe('alice already has this role in lib:org1/lib1'); + }); + + it('formats user_not_found', () => { + const result = formatRoleAssignmentError(intl, { ...base, error: ROLE_ASSIGNMENT_ERRORS.USER_NOT_FOUND }); + expect(result).toBe('User "alice" was not found'); + }); + + it('formats role_assignment_error', () => { + const result = formatRoleAssignmentError(intl, { ...base, error: ROLE_ASSIGNMENT_ERRORS.ROLE_ASSIGNMENT_ERROR }); + expect(result).toBe('Could not assign role to alice in lib:org1/lib1'); + }); + + it('formats unknown error with default fallback', () => { + const result = formatRoleAssignmentError(intl, { ...base, error: 'some_unknown_error' }); + expect(result).toBe('alice (lib:org1/lib1): some_unknown_error'); + }); +}); diff --git a/src/authz-module/role-assignation-wizard/utils.ts b/src/authz-module/role-assignation-wizard/utils.ts new file mode 100644 index 00000000..f51de65e --- /dev/null +++ b/src/authz-module/role-assignation-wizard/utils.ts @@ -0,0 +1,23 @@ +import { IntlShape } from '@edx/frontend-platform/i18n'; +import { PutAssignTeamMembersRoleResponse } from '@src/authz-module/data/api'; +import { ROLE_ASSIGNMENT_ERRORS } from './constants'; +import messages from './messages'; + +type RoleAssignmentError = PutAssignTeamMembersRoleResponse['errors'][number]; + +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); + } + return intl.formatMessage(messages['wizard.save.error.default'], { ...params, error: e.error }); +}; diff --git a/src/authz-module/team-members/TeamMembersTable.test.tsx b/src/authz-module/team-members/TeamMembersTable.test.tsx index 4252a6db..917a0fb5 100644 --- a/src/authz-module/team-members/TeamMembersTable.test.tsx +++ b/src/authz-module/team-members/TeamMembersTable.test.tsx @@ -56,37 +56,41 @@ const mockedOrgs = { const mockedScopes = { data: { - count: 2, - next: null, - previous: null, - results: [ + pages: [ { - externalKey: 'course-v1:OpenedX+DemoX+DemoCourse', - displayName: 'Open edX Demo Course', - org: { - id: 1, - created: '2026-04-02T19:30:36.779095Z', - modified: '2026-04-02T19:30:36.779095Z', - name: 'OpenedX', - shortName: 'OpenedX', - description: '', - logo: null, - active: true, - }, - }, - { - externalKey: 'lib:WGU:CSPROB', - displayName: 'Computer Science Problems', - org: { - id: 2, - created: '2026-04-02T19:31:21.196446Z', - modified: '2026-04-02T19:31:21.196446Z', - name: 'WGU', - shortName: 'WGU', - description: '', - logo: null, - active: true, - }, + count: 2, + next: null, + previous: null, + results: [ + { + externalKey: 'course-v1:OpenedX+DemoX+DemoCourse', + displayName: 'Open edX Demo Course', + org: { + id: 1, + created: '2026-04-02T19:30:36.779095Z', + modified: '2026-04-02T19:30:36.779095Z', + name: 'OpenedX', + shortName: 'OpenedX', + description: '', + logo: null, + active: true, + }, + }, + { + externalKey: 'lib:WGU:CSPROB', + displayName: 'Computer Science Problems', + org: { + id: 2, + created: '2026-04-02T19:31:21.196446Z', + modified: '2026-04-02T19:31:21.196446Z', + name: 'WGU', + shortName: 'WGU', + description: '', + logo: null, + active: true, + }, + }, + ], }, ], }, diff --git a/src/setupTest.tsx b/src/setupTest.tsx index bb5273ae..15983d8a 100644 --- a/src/setupTest.tsx +++ b/src/setupTest.tsx @@ -44,6 +44,10 @@ export const renderWithAllProviders = (ui, options = {}) => { return render(ui, { wrapper: Wrapper, ...options }); }; +export const intlWrapper = ({ children }: WrapperProps) => ( + {children} +); + export const renderWrapper = (ui, options = {}) => { const Wrapper = ({ children }: WrapperProps) => ( diff --git a/src/types.ts b/src/types.ts index 63093d8d..decf55ea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -63,10 +63,10 @@ export type Org = { }; export type Scope = { - key: string; - name: string; - description: string; - organization: Org; + externalKey: string; + displayName: string; + description?: string; + org: Org | null; }; // Permissions Matrix