Skip to content

Commit e24d466

Browse files
committed
feat: Implement DefineApplicationScopeStep with filtering and organization scope management
1 parent ecb68bd commit e24d466

16 files changed

Lines changed: 2109 additions & 52 deletions

src/authz-module/data/api.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,63 @@ export const getPermissionsByRole = async (scope: string): Promise<PermissionsBy
120120
return camelCaseObject(data.results);
121121
};
122122

123+
export interface ScopeItem {
124+
externalKey: string;
125+
displayName: string;
126+
org: { id: number; name: string; shortName: string } | null;
127+
description?: string; // synthetic field used only for aggregate items created on the frontend
128+
}
129+
130+
export interface GetScopesResponse {
131+
results: ScopeItem[];
132+
count: number;
133+
next: string | null;
134+
previous: string | null;
135+
}
136+
137+
export interface GetScopesParams {
138+
scopeType?: string;
139+
search?: string;
140+
org?: string;
141+
page?: number;
142+
pageSize?: number;
143+
managementPermissionOnly?: boolean;
144+
}
145+
146+
export interface OrganizationItem {
147+
id: number;
148+
name: string;
149+
shortName: string;
150+
description: string;
151+
logo: string | null;
152+
active: boolean;
153+
}
154+
155+
export interface GetOrganizationsResponse {
156+
results: OrganizationItem[];
157+
count: number;
158+
next: string | null;
159+
previous: string | null;
160+
}
161+
162+
export const getScopes = async (params: GetScopesParams): Promise<GetScopesResponse> => {
163+
const url = new URL(getApiUrl('/api/authz/v1/scopes/'));
164+
if (params.scopeType) { url.searchParams.set('scope_type', params.scopeType); }
165+
if (params.search) { url.searchParams.set('search', params.search); }
166+
if (params.org) { url.searchParams.set('org', params.org); }
167+
if (params.managementPermissionOnly) { url.searchParams.set('management_permission_only', 'true'); }
168+
url.searchParams.set('page', (params.page ?? 1).toString());
169+
url.searchParams.set('page_size', (params.pageSize ?? 10).toString());
170+
const { data } = await getAuthenticatedHttpClient().get(url);
171+
return camelCaseObject(data);
172+
};
173+
174+
export const getOrganizations = async (): Promise<OrganizationItem[]> => {
175+
const url = new URL(getApiUrl('/api/authz/v1/orgs/'));
176+
const { data } = await getAuthenticatedHttpClient().get(url);
177+
return camelCaseObject(data.results ?? data);
178+
};
179+
123180
export const revokeUserRoles = async (
124181
data: RevokeUserRolesRequest,
125182
): Promise<DeleteRevokeUserRolesResponse> => {

src/authz-module/data/hooks.test.tsx

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
44
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
55
import {
66
useLibrary, usePermissionsByRole, useTeamMembers, useAssignTeamMembersRole, useRevokeUserRoles,
7-
useValidateUsers,
7+
useValidateUsers, useScopes, useOrganizations,
88
} from './hooks';
99

1010
jest.mock('@edx/frontend-platform/auth', () => ({
@@ -346,6 +346,128 @@ describe('useValidateUsers', () => {
346346
});
347347
});
348348

349+
describe('useScopes', () => {
350+
beforeEach(() => {
351+
jest.clearAllMocks();
352+
});
353+
354+
const makeScopesResponse = (next: string | null = null) => ({
355+
results: [{
356+
externalKey: 'lib:testorg:testlib',
357+
displayName: 'Test Library',
358+
org: { id: 1, name: 'Test Org', slug: 'testorg' },
359+
}],
360+
count: 1,
361+
next,
362+
previous: null,
363+
});
364+
365+
it('returns pages data on success', async () => {
366+
getAuthenticatedHttpClient.mockReturnValue({
367+
get: jest.fn().mockResolvedValue({ data: makeScopesResponse() }),
368+
});
369+
370+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
371+
372+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
373+
374+
expect(result.current.data?.pages).toHaveLength(1);
375+
expect(result.current.data?.pages[0].results).toHaveLength(1);
376+
});
377+
378+
it('hasNextPage is false when next is null', async () => {
379+
getAuthenticatedHttpClient.mockReturnValue({
380+
get: jest.fn().mockResolvedValue({ data: makeScopesResponse(null) }),
381+
});
382+
383+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
384+
385+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
386+
expect(result.current.hasNextPage).toBe(false);
387+
});
388+
389+
it('hasNextPage is true when next URL has page param', async () => {
390+
getAuthenticatedHttpClient.mockReturnValue({
391+
get: jest.fn().mockResolvedValue({
392+
data: makeScopesResponse('http://localhost:8000/api/authz/v1/scopes/?page=2'),
393+
}),
394+
});
395+
396+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
397+
398+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
399+
expect(result.current.hasNextPage).toBe(true);
400+
});
401+
402+
it('hasNextPage is false when next URL has no page param', async () => {
403+
getAuthenticatedHttpClient.mockReturnValue({
404+
get: jest.fn().mockResolvedValue({
405+
data: makeScopesResponse('http://localhost:8000/api/authz/v1/scopes/'),
406+
}),
407+
});
408+
409+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
410+
411+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
412+
expect(result.current.hasNextPage).toBe(false);
413+
});
414+
415+
it('hasNextPage is false when next is an invalid URL', async () => {
416+
getAuthenticatedHttpClient.mockReturnValue({
417+
get: jest.fn().mockResolvedValue({
418+
data: makeScopesResponse('not-a-valid-url'),
419+
}),
420+
});
421+
422+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
423+
424+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
425+
expect(result.current.hasNextPage).toBe(false);
426+
});
427+
428+
it('handles error when API call fails', async () => {
429+
getAuthenticatedHttpClient.mockReturnValue({
430+
get: jest.fn().mockRejectedValue(new Error('Network error')),
431+
});
432+
433+
const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() });
434+
435+
await waitFor(() => expect(result.current.isError).toBe(true));
436+
expect(result.current.error).toBeDefined();
437+
});
438+
});
439+
440+
describe('useOrganizations', () => {
441+
beforeEach(() => {
442+
jest.clearAllMocks();
443+
});
444+
445+
const mockOrgs = [{
446+
id: 1, name: 'Org One', shortName: 'org1', description: '', logo: null, active: true,
447+
}];
448+
449+
it('returns organizations on success', async () => {
450+
getAuthenticatedHttpClient.mockReturnValue({
451+
get: jest.fn().mockResolvedValue({ data: { results: mockOrgs } }),
452+
});
453+
454+
const { result } = renderHook(() => useOrganizations(), { wrapper: createWrapper() });
455+
456+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
457+
expect(result.current.data).toEqual(mockOrgs);
458+
});
459+
460+
it('handles error when API fails', async () => {
461+
getAuthenticatedHttpClient.mockReturnValue({
462+
get: jest.fn().mockRejectedValue(new Error('Failed')),
463+
});
464+
465+
const { result } = renderHook(() => useOrganizations(), { wrapper: createWrapper() });
466+
467+
await waitFor(() => expect(result.current.isError).toBe(true));
468+
});
469+
});
470+
349471
describe('useRevokeUserRoles', () => {
350472
beforeEach(() => {
351473
jest.clearAllMocks();

src/authz-module/data/hooks.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import {
2-
useMutation, useQuery, useQueryClient, useSuspenseQuery,
2+
useInfiniteQuery, useMutation, useQuery, useQueryClient, useSuspenseQuery,
33
} from '@tanstack/react-query';
44
import { appId } from '@src/constants';
55
import { LibraryMetadata } from '@src/types';
66
import {
7-
assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getPermissionsByRole, getTeamMembers,
8-
GetTeamMembersResponse, PermissionsByRole, QuerySettings, revokeUserRoles, RevokeUserRolesRequest,
9-
validateUsers, ValidateUsersRequest,
7+
assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getOrganizations,
8+
getPermissionsByRole, getScopes, GetScopesParams, GetScopesResponse, getTeamMembers,
9+
GetTeamMembersResponse, OrganizationItem, PermissionsByRole, QuerySettings, revokeUserRoles,
10+
RevokeUserRolesRequest, validateUsers, ValidateUsersRequest,
1011
} from './api';
1112

1213
const authzQueryKeys = {
@@ -106,6 +107,41 @@ export const useValidateUsers = () => useMutation({
106107
}) => validateUsers(data),
107108
});
108109

110+
/**
111+
* React Query hook to fetch a paginated, searchable list of scopes (courses or libraries).
112+
* Uses infinite query to support infinite scroll.
113+
*
114+
* @param params - Filter params: contextType, search, org, pageSize
115+
*/
116+
export const useScopes = (params: Omit<GetScopesParams, 'page'>) => useInfiniteQuery<GetScopesResponse, Error>({
117+
queryKey: [...authzQueryKeys.all, 'scopes', params],
118+
queryFn: ({ pageParam }) => getScopes({ ...params, page: pageParam as number }),
119+
getNextPageParam: (lastPage) => {
120+
if (!lastPage.next) { return undefined; }
121+
try {
122+
const nextUrl = new URL(lastPage.next);
123+
const page = nextUrl.searchParams.get('page');
124+
return page ? parseInt(page, 10) : undefined;
125+
} catch {
126+
return undefined;
127+
}
128+
},
129+
initialPageParam: 1,
130+
staleTime: 1000 * 60 * 5,
131+
});
132+
133+
/**
134+
* React Query hook to fetch the list of organizations for a given context type.
135+
* Used to populate the Organization filter dropdown in the scope selector.
136+
*
137+
* @param contextType - 'course' | 'library'
138+
*/
139+
export const useOrganizations = () => useQuery<OrganizationItem[], Error>({
140+
queryKey: [...authzQueryKeys.all, 'organizations'],
141+
queryFn: () => getOrganizations(),
142+
staleTime: 1000 * 60 * 30,
143+
});
144+
109145
/**
110146
* React Query hook to remove roles for a specific team member within a scope.
111147
*

src/authz-module/libraries-manager/LibrariesUserManager.test.tsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@ jest.mock('@edx/frontend-platform/logging', () => ({
1111
logError: jest.fn(),
1212
}));
1313

14+
const mockNavigate = jest.fn();
15+
1416
jest.mock('react-router-dom', () => ({
1517
...jest.requireActual('react-router-dom'),
1618
useParams: jest.fn(),
19+
useNavigate: () => mockNavigate,
1720
}));
1821

1922
jest.mock('./context', () => ({
@@ -162,6 +165,80 @@ describe('LibrariesUserManager', () => {
162165
expect(navLinkLibraryTeamManagement).toHaveAttribute('href', '/authz/libraries/lib:123');
163166
});
164167

168+
describe('Navigation guards', () => {
169+
it('redirects to team path when canManageTeam is false', () => {
170+
(useLibraryAuthZ as jest.Mock).mockReturnValue({
171+
...defaultMockData,
172+
canManageTeam: false,
173+
});
174+
175+
renderComponent();
176+
177+
expect(mockNavigate).toHaveBeenCalledWith('/authz/libraries/lib:123');
178+
});
179+
180+
it('redirects to team path when user is not found after loading completes', async () => {
181+
(useTeamMembers as jest.Mock).mockReturnValue({
182+
data: { results: [] },
183+
isLoading: false,
184+
isFetching: false,
185+
});
186+
187+
renderComponent();
188+
189+
await waitFor(() => {
190+
expect(mockNavigate).toHaveBeenCalledWith('/authz/libraries/lib:123');
191+
});
192+
});
193+
194+
it('does not redirect while member data is still fetching', () => {
195+
(useTeamMembers as jest.Mock).mockReturnValue({
196+
data: undefined,
197+
isLoading: true,
198+
isFetching: true,
199+
});
200+
201+
renderComponent();
202+
203+
// navigate should only be called for canManageTeam=true case (not at all here)
204+
expect(mockNavigate).not.toHaveBeenCalled();
205+
});
206+
});
207+
208+
describe('Loading state', () => {
209+
it('renders skeleton while loading team member data', () => {
210+
(useTeamMembers as jest.Mock).mockReturnValue({
211+
data: undefined,
212+
isLoading: true,
213+
isFetching: true,
214+
});
215+
216+
renderComponent();
217+
218+
// Just verify the component renders without crashing in loading state
219+
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
220+
});
221+
});
222+
223+
describe('Assign role button', () => {
224+
it('navigates to assign-role wizard when Assign Role button is clicked', async () => {
225+
const user = userEvent.setup();
226+
renderComponent();
227+
228+
// There are two "Assign Role" elements: the mocked AssignNewRoleTrigger and the real Button
229+
// The real Button navigates; use getAllByText and click the one that is a <button>
230+
const assignRoleButtons = screen.getAllByText('Assign Role');
231+
const realButton = assignRoleButtons.find(
232+
(el) => el.tagName === 'BUTTON' || el.closest('button[class*="btn-primary"]') !== null,
233+
);
234+
await user.click(realButton || assignRoleButtons[0]);
235+
236+
expect(mockNavigate).toHaveBeenCalledWith(
237+
'/authz/assign-role?scope=lib:123&users=testuser',
238+
);
239+
});
240+
});
241+
165242
describe('Revoking User Role Flow', () => {
166243
it('opens confirmation modal when delete role button is clicked', async () => {
167244
const user = userEvent.setup();

src/authz-module/libraries-manager/LibrariesUserManager.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { useEffect, useMemo, useState } from 'react';
22
import { useNavigate, useParams } from 'react-router-dom';
33
import { useIntl } from '@edx/frontend-platform/i18n';
4-
import { Container, Skeleton } from '@openedx/paragon';
4+
import { Button, Container, Skeleton } from '@openedx/paragon';
5+
import { Plus } from '@openedx/paragon/icons';
56
import { ROUTES } from '@src/authz-module/constants';
67
import { Role } from 'types';
78
import { useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext';
89
import AuthZLayout from '../components/AuthZLayout';
910
import { useLibraryAuthZ } from './context';
1011
import RoleCard from '../components/RoleCard';
11-
import { AssignNewRoleTrigger } from './components/AssignNewRoleModal';
1212
import ConfirmDeletionModal from './components/ConfirmDeletionModal';
1313
import { useLibrary, useRevokeUserRoles, useTeamMembers } from '../data/hooks';
1414
import { buildPermissionMatrixByRole } from './utils';
@@ -154,11 +154,16 @@ const LibrariesUserManager = () => {
154154
pageTitle={user?.username || ''}
155155
pageSubtitle={user?.email || ''}
156156
actions={user && canManageTeam
157-
? [<AssignNewRoleTrigger
158-
username={user.username}
159-
libraryId={libraryId}
160-
currentUserRoles={userRoles.map(role => role.role)}
161-
/>]
157+
? [
158+
<Button
159+
key="assign-role-wizard-trigger"
160+
iconBefore={Plus}
161+
variant="primary"
162+
onClick={() => navigate(`/authz/assign-role?scope=${libraryId}&users=${encodeURIComponent(user.username)}`)}
163+
>
164+
{intl.formatMessage(messages['library.authz.manage.add.role.button'])}
165+
</Button>,
166+
]
162167
: []}
163168
>
164169
<Container className="bg-light-200 p-5">

0 commit comments

Comments
 (0)