Skip to content

Commit 2b3b480

Browse files
authored
feat(authz): add scope selection step to the Role Assignment Wizard (#111)
* feat(authz): add role assignation wizard scope selection step Add scope selection step (step 2) to the role assignation wizard, including ScopeList, ScopeFilterBar, useScopeListData, and useScopePermissions. Migrate useScopes to infinite query, fix useOrgs data extraction, add missing userRoles cache invalidation on role assignment, and align all tests with the updated hook shapes. * fix: solve lint issues * feat(authz): enhance role assignment wizard with internationalization support * feat(authz): implement error handling for role assignment with localized messages * feat(tests): add intlWrapper for testing with internationalization support * fix: solve lint issues * feat(authz): enhance role assignment wizard with error handling and user feedback * fix: address feedback * fix(authz): boost CSS specificity to prevent Paragon overrides on highlighted input * fix: address feedback * fix: address feedback * feat(authz): enhance role assignment wizard with improved scope handling and UI updates * feat(authz): update role assignment wizard navigation for single and multiple preset users * feat(authz): update role assignment wizard to support multiple organizations and enhance error handling * feat(authz): update tests for role assignment wizard to reflect new scope handling and organization filtering * fix: increase test coverage * feat(authz): integrate useScopePermissions for enhanced permission handling in role assignment wizard * feat(authz): implement getOrgAggregateScopeKey for dynamic scope generation in role assignment
1 parent 7609058 commit 2b3b480

32 files changed

Lines changed: 2462 additions & 174 deletions

src/authz-module/authz-home/index.test.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ const emptyResponse = {
2222
refetch: jest.fn(),
2323
};
2424

25+
const emptyScopesResponse = {
26+
data: { pages: [] },
27+
error: null,
28+
isLoading: false,
29+
hasNextPage: false,
30+
fetchNextPage: jest.fn(),
31+
isFetchingNextPage: false,
32+
};
33+
2534
const renderAuthzHome = () => renderWithAllProviders(
2635
<ToastManagerProvider>
2736
<AuthzHome />
@@ -32,7 +41,7 @@ describe('AuthzHome', () => {
3241
beforeEach(() => {
3342
(useAllRoleAssignments as jest.Mock).mockReturnValue(emptyResponse);
3443
(useOrgs as jest.Mock).mockReturnValue(emptyResponse);
35-
(useScopes as jest.Mock).mockReturnValue(emptyResponse);
44+
(useScopes as jest.Mock).mockReturnValue(emptyScopesResponse);
3645
});
3746

3847
it('renders without crashing', () => {

src/authz-module/components/TableControlBar/ScopesFilter.test.tsx

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,20 @@ import ScopesFilter from './ScopesFilter';
66
jest.mock('@src/authz-module/data/hooks', () => ({
77
useScopes: () => ({
88
data: {
9-
results: [
9+
pages: [
1010
{
11-
externalKey: 'course:123',
12-
name: 'Test Course',
13-
organization: { name: 'Test Org' },
14-
},
15-
{
16-
externalKey: 'library:456',
17-
name: 'Test Library',
18-
organization: { name: 'Another Org' },
11+
results: [
12+
{
13+
externalKey: 'course:123',
14+
name: 'Test Course',
15+
organization: { name: 'Test Org' },
16+
},
17+
{
18+
externalKey: 'library:456',
19+
name: 'Test Library',
20+
organization: { name: 'Another Org' },
21+
},
22+
],
1923
},
2024
],
2125
},

src/authz-module/components/TableControlBar/ScopesFilter.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ const ScopesFilter = ({
1515
}: ScopesFilterProps) => {
1616
const { formatMessage } = useIntl();
1717
const [searchValue, setSearchValue] = useState<string | undefined>(undefined);
18-
const { data: scopesData = { results: [] } } = useScopes(searchValue, 1, DEFAULT_FILTER_PAGE_SIZE);
18+
const { data: scopesData } = useScopes({ search: searchValue, pageSize: DEFAULT_FILTER_PAGE_SIZE });
1919

20-
const filterChoices = useMemo(() => scopesData.results.map((scope) => {
20+
const filterChoices = useMemo(() => (scopesData?.pages?.flatMap((p) => p.results) ?? []).map((scope) => {
2121
const scopeIcon = scope.externalKey?.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE;
2222
let groupName = formatMessage(messages['authz.team.members.table.group.courses']);
2323
if (scope.externalKey?.startsWith('lib')) {
@@ -30,7 +30,7 @@ const ScopesFilter = ({
3030
groupName,
3131
groupIcon: scopeIcon,
3232
};
33-
}), [scopesData, formatMessage]);
33+
}), [scopesData?.pages, formatMessage]);
3434

3535
const handleSearchChange = (value: string) => {
3636
setSearchValue(value);

src/authz-module/constants.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { buildWizardPath, ROUTES } from './constants';
1+
import { buildWizardPath, getOrgAggregateScopeKey, ROUTES } from './constants';
22

33
const BASE = `${ROUTES.HOME_PATH}${ROUTES.ASSIGN_ROLE_WIZARD_PATH}`;
44

@@ -32,3 +32,17 @@ describe('buildWizardPath', () => {
3232
expect(buildWizardPath({ users: '', from: '' })).toBe(BASE);
3333
});
3434
});
35+
36+
describe('getOrgAggregateScopeKey', () => {
37+
it('returns course wildcard scope for course context', () => {
38+
expect(getOrgAggregateScopeKey('course', 'MIT')).toBe('course-v1:MIT+*');
39+
});
40+
41+
it('returns library wildcard scope for library context', () => {
42+
expect(getOrgAggregateScopeKey('library', 'MIT')).toBe('lib:MIT:*');
43+
});
44+
45+
it('throws for an unknown contextType', () => {
46+
expect(() => getOrgAggregateScopeKey('unknown', 'MIT')).toThrow('Unknown contextType: "unknown"');
47+
});
48+
});

src/authz-module/constants.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,17 @@ export const CONTENT_COURSE_PERMISSIONS = {
8080
VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS: 'courses.view_global_staff_and_superadmins',
8181
};
8282

83+
const ORG_AGGREGATE_SCOPE_BUILDERS = {
84+
course: (orgSlug: string) => `course-v1:${orgSlug}+*`,
85+
library: (orgSlug: string) => `lib:${orgSlug}:*`,
86+
};
87+
88+
export const getOrgAggregateScopeKey = (contextType: string, orgSlug: string): string => {
89+
const builder = ORG_AGGREGATE_SCOPE_BUILDERS[contextType];
90+
if (!builder) { throw new Error(`Unknown contextType: "${contextType}"`); }
91+
return builder(orgSlug);
92+
};
93+
8394
export const libraryResourceTypes: ResourceMetadata[] = [
8495
{ key: 'library', label: 'Library', description: 'Permissions related to the library as a whole.' },
8596
{ key: 'library_content', label: 'Content', description: 'Permissions to create, edit, delete, and publish individual content items within the library.' },

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -277,30 +277,37 @@ describe('API functions', () => {
277277
it('should fetch scopes successfully', async () => {
278278
const mockResponse = {
279279
data: {
280-
scopes: [
280+
results: [
281281
{ displayName: 'Library 1', scope: 'lib:test1' },
282282
],
283+
count: 1,
284+
next: null,
285+
previous: null,
283286
},
284287
};
285288

286289
getAuthenticatedHttpClient.mockReturnValue({
287290
get: jest.fn().mockResolvedValue(mockResponse),
288291
});
289292

290-
const result = await getScopes();
293+
const result = await getScopes({});
291294

292-
expect(result.scopes).toHaveLength(1);
295+
expect(result.results).toHaveLength(1);
293296
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
294297
});
295298

296299
it('should handle search, page, and pageSize parameters', async () => {
297-
const mockResponse = { data: { scopes: [] } };
300+
const mockResponse = {
301+
data: {
302+
results: [], count: 0, next: null, previous: null,
303+
},
304+
};
298305
const mockGet = jest.fn().mockResolvedValue(mockResponse);
299306
getAuthenticatedHttpClient.mockReturnValue({
300307
get: mockGet,
301308
});
302309

303-
await getScopes('library', 3, 50);
310+
await getScopes({ search: 'library', page: 3, pageSize: 50 });
304311

305312
expect(mockGet).toHaveBeenCalled();
306313
const calledUrl = mockGet.mock.calls[0][0];

src/authz-module/data/api.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,13 @@ export type PermissionsByRole = {
5454
};
5555
export interface PutAssignTeamMembersRoleResponse {
5656
completed: { userIdentifier: string; status: string }[];
57-
errors: { userIdentifier: string; error: string }[];
57+
errors: { userIdentifier: string; scope: string; error: string }[];
5858
}
5959

6060
export interface AssignTeamMembersRoleRequest {
6161
users: string[];
6262
role: string;
63-
scope: string;
63+
scopes: string[];
6464
}
6565

6666
export interface GetAllRoleAssignmentsResponse {
@@ -97,6 +97,15 @@ export type ValidateUsersResponse = {
9797
};
9898
};
9999

100+
export interface GetScopesParams {
101+
scopeType?: string;
102+
search?: string;
103+
orgs?: string[];
104+
page?: number;
105+
pageSize?: number;
106+
managementPermissionOnly?: boolean;
107+
}
108+
100109
export const getTeamMembers = async (object: string, querySettings: QuerySettings): Promise<GetTeamMembersResponse> => {
101110
const url = new URL(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`));
102111

@@ -208,17 +217,14 @@ export const getOrgs = async (search?: string, page?: number, pageSize?: number)
208217
return camelCaseObject(data);
209218
};
210219

211-
export const getScopes = async (search?: string, page?: number, pageSize?: number): Promise<GetScopesResponse> => {
220+
export const getScopes = async (params: GetScopesParams): Promise<GetScopesResponse> => {
212221
const url = new URL(getApiUrl('/api/authz/v1/scopes/'));
213-
if (search !== undefined) {
214-
url.searchParams.set('search', search);
215-
}
216-
if (page !== undefined) {
217-
url.searchParams.set('page', page.toString());
218-
}
219-
if (pageSize !== undefined) {
220-
url.searchParams.set('page_size', pageSize.toString());
221-
}
222+
if (params.search) { url.searchParams.set('search', params.search); }
223+
if (params.scopeType) { url.searchParams.set('scope_type', params.scopeType); }
224+
if (params.orgs?.length) { url.searchParams.set('orgs', params.orgs.join(',')); }
225+
if (params.managementPermissionOnly) { url.searchParams.set('management_permission_only', 'true'); }
226+
url.searchParams.set('page', (params.page ?? 1).toString());
227+
url.searchParams.set('page_size', (params.pageSize ?? 10).toString());
222228
const { data } = await getAuthenticatedHttpClient().get(url);
223229
return camelCaseObject(data);
224230
};

0 commit comments

Comments
 (0)