Skip to content

Commit f1371b8

Browse files
authored
feat(authz): add role assignment wizard and normalize resource folder naming (openedx#109)
Introduce a multi-step AssignRoleWizard with DefineApplicationScopeStep, SelectUsersAndRoleStep, and HighlightedUsersInput components. Singularize roles-permissions subfolders (courses → course, libraries → library) for naming consistency. Update hooks, API, constants, and related tests.
1 parent 78f78cc commit f1371b8

37 files changed

Lines changed: 1527 additions & 111 deletions

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import React from 'react';
22
import { screen } from '@testing-library/react';
33
import { useAllRoleAssignments, useOrgs, useScopes } from '@src/authz-module/data/hooks';
4-
import { ToastManagerProvider } from '@src/components/ToastManager/ToastManagerContext';
54
import { renderWithAllProviders } from '@src/setupTest';
65
import userEvent from '@testing-library/user-event';
6+
import { ToastManagerProvider } from '@src/components/ToastManager/ToastManagerContext';
77
import AuthzHome from './index';
88
import messages from './messages';
99

src/authz-module/components/AddRoleButton.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ describe('AddRoleButton', () => {
7676
await user.click(button);
7777

7878
expect(mockNavigate).toHaveBeenCalledTimes(1);
79-
expect(mockNavigate).toHaveBeenCalledWith(`/authz/assign-role?username=${presetUsername}`);
79+
expect(mockNavigate).toHaveBeenCalledWith(`/authz/assign-role?users=${presetUsername}`);
8080
});
8181

8282
it('handles special characters in presetUsername correctly', async () => {
@@ -88,7 +88,7 @@ describe('AddRoleButton', () => {
8888
await user.click(button);
8989

9090
expect(mockNavigate).toHaveBeenCalledTimes(1);
91-
expect(mockNavigate).toHaveBeenCalledWith(`/authz/assign-role?username=${presetUsername}`);
91+
expect(mockNavigate).toHaveBeenCalledWith(`/authz/assign-role?${new URLSearchParams({ users: presetUsername }).toString()}`);
9292
});
9393
});
9494

@@ -128,7 +128,7 @@ describe('AddRoleButton', () => {
128128
await user.click(button);
129129

130130
expect(mockNavigate).toHaveBeenCalledTimes(3);
131-
expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role?username=testuser');
131+
expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role?users=testuser');
132132
});
133133
});
134134
});

src/authz-module/components/AddRoleButton.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,23 @@ import React from 'react';
22
import { useIntl } from '@edx/frontend-platform/i18n';
33
import { Button } from '@openedx/paragon';
44
import { Plus } from '@openedx/paragon/icons';
5+
import { useNavigate } from 'react-router-dom';
56

67
import baseMessages from '@src/authz-module/messages';
7-
import { useNavigate } from 'react-router-dom';
8+
import { buildWizardPath } from '@src/authz-module/constants';
89

910
interface AddRoleButtonProps {
1011
presetUsername?: string;
12+
from?: string;
1113
}
1214

13-
const AddRoleButton = ({ presetUsername }: AddRoleButtonProps) => {
15+
const AddRoleButton = ({ presetUsername, from }: AddRoleButtonProps) => {
1416
const intl = useIntl();
1517
const navigate = useNavigate();
1618

1719
const handleClick = () => {
18-
const assignRolePath = `/authz/assign-role${presetUsername ? `?username=${presetUsername}` : ''}`;
19-
navigate(assignRolePath);
20+
const path = buildWizardPath({ from, users: presetUsername });
21+
navigate(path);
2022
};
2123

2224
return (

src/authz-module/components/PermissionTable.test.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,23 @@ const mockRoles: Role[] = [
1010
userCount: 0,
1111
permissions: [],
1212
role: '',
13+
contextType: '',
1314
},
1415
{
1516
name: 'Editor',
1617
description: 'Editor role',
1718
userCount: 0,
1819
permissions: [],
1920
role: '',
21+
contextType: '',
2022
},
2123
{
2224
name: 'Viewer',
2325
description: 'Viewer role',
2426
userCount: 0,
2527
permissions: [],
2628
role: '',
29+
contextType: '',
2730
},
2831
];
2932

src/authz-module/constants.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { buildWizardPath, ROUTES } from './constants';
2+
3+
const BASE = `${ROUTES.HOME_PATH}${ROUTES.ASSIGN_ROLE_WIZARD_PATH}`;
4+
5+
describe('buildWizardPath', () => {
6+
it('returns the base path when called with no arguments', () => {
7+
expect(buildWizardPath()).toBe(BASE);
8+
});
9+
10+
it('returns the base path when called with an empty options object', () => {
11+
expect(buildWizardPath({})).toBe(BASE);
12+
});
13+
14+
it('appends ?users= when only users is provided', () => {
15+
expect(buildWizardPath({ users: 'alice' })).toBe(`${BASE}?users=alice`);
16+
});
17+
18+
it('appends ?from= when only from is provided', () => {
19+
expect(buildWizardPath({ from: '/authz/libraries/lib:123/alice' }))
20+
.toBe(`${BASE}?from=%2Fauthz%2Flibraries%2Flib%3A123%2Falice`);
21+
});
22+
23+
it('appends both users and from when both are provided', () => {
24+
const result = buildWizardPath({ users: 'alice', from: '/authz/libraries/lib:123/alice' });
25+
const parsed = new URL(result, 'http://x');
26+
expect(parsed.pathname).toBe(BASE);
27+
expect(parsed.searchParams.get('users')).toBe('alice');
28+
expect(parsed.searchParams.get('from')).toBe('/authz/libraries/lib:123/alice');
29+
});
30+
31+
it('omits the query string when users and from are both empty strings', () => {
32+
expect(buildWizardPath({ users: '', from: '' })).toBe(BASE);
33+
});
34+
});

src/authz-module/constants.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { PermissionMetadata, ResourceMetadata, RoleMetadata } from 'types';
1+
import { PermissionMetadata, ResourceMetadata } from 'types';
22

33
export const CONTENT_LIBRARY_PERMISSIONS = {
44
DELETE_LIBRARY: 'content_libraries.delete_library',
@@ -62,15 +62,6 @@ export const CONTENT_COURSE_PERMISSIONS = {
6262
VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS: 'courses.view_global_staff_and_superadmins',
6363
};
6464

65-
// Note: this information will eventually come from the backend API
66-
// but for the MVP we decided to manage it in the frontend
67-
export const libraryRolesMetadata: RoleMetadata[] = [
68-
{ role: 'library_admin', name: 'Library Admin', description: 'The Library Admin has full control over the library, including managing users, modifying content, and handling publishing workflows. They ensure content is properly maintained and accessible as needed.' },
69-
{ role: 'library_author', name: 'Library Author', description: 'The Library Author is responsible for creating, editing, and publishing content within a library. They can manage tags and collections but cannot delete libraries or manage users.' },
70-
{ role: 'library_contributor', name: 'Library Contributor', description: 'The Library Contributor can create and edit content within a library but cannot publish it. They support the authoring process while leaving final publishing to Authors or Admins.' },
71-
{ role: 'library_user', name: 'Library User', description: 'The Library User can view and reuse content but cannot edit or delete any resource.' },
72-
];
73-
7465
export const libraryResourceTypes: ResourceMetadata[] = [
7566
{ key: 'library', label: 'Library', description: 'Permissions related to the library as a whole.' },
7667
{ key: 'library_content', label: 'Content', description: 'Permissions to create, edit, delete, and publish individual content items within the library.' },
@@ -105,9 +96,21 @@ export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({
10596
}));
10697

10798
export const ROUTES = {
99+
HOME_PATH: '/authz',
108100
LIBRARIES_TEAM_PATH: '/libraries/:libraryId',
109101
LIBRARIES_USER_PATH: '/libraries/:libraryId/:username',
110102
AUDIT_USER_PATH: '/user/:username',
103+
ASSIGN_ROLE_WIZARD_PATH: '/assign-role',
104+
};
105+
106+
export const buildWizardPath = (options?: { users?: string; from?: string }) => {
107+
const base = `${ROUTES.HOME_PATH}${ROUTES.ASSIGN_ROLE_WIZARD_PATH}`;
108+
if (!options) { return base; }
109+
const params = new URLSearchParams();
110+
if (options.users) { params.set('users', options.users); }
111+
if (options.from) { params.set('from', options.from); }
112+
const query = params.toString();
113+
return query ? `${base}?${query}` : base;
111114
};
112115

113116
export enum RoleOperationErrorStatus {
@@ -141,3 +144,11 @@ export const TABLE_DEFAULT_PAGE_SIZE = 10;
141144

142145
export const DEFAULT_FILTER_PAGE_SIZE = 5;
143146
export const ADMIN_ROLES = ['course_admin', 'library_admin'];
147+
148+
// Resource Type Definitions
149+
export const RESOURCE_TYPES = {
150+
LIBRARY: 'library',
151+
COURSE: 'course',
152+
} as const;
153+
154+
export type ResourceType = typeof RESOURCE_TYPES[keyof typeof RESOURCE_TYPES];

src/authz-module/data/api.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,19 @@ export interface GetScopesResponse {
8383
previous: string | null;
8484
results:Array<Scope>;
8585
}
86+
export type ValidateUsersRequest = {
87+
users: string[];
88+
};
89+
90+
export type ValidateUsersResponse = {
91+
validUsers: string[];
92+
invalidUsers: string[];
93+
summary: {
94+
total: number;
95+
validCount: number;
96+
invalidCount: number;
97+
};
98+
};
8699

87100
export const getTeamMembers = async (object: string, querySettings: QuerySettings): Promise<GetTeamMembersResponse> => {
88101
const url = new URL(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`));
@@ -111,6 +124,16 @@ export const assignTeamMembersRole = async (
111124
return camelCaseObject(res.data);
112125
};
113126

127+
export const validateUsers = async (
128+
data: ValidateUsersRequest,
129+
): Promise<ValidateUsersResponse> => {
130+
const res = await getAuthenticatedHttpClient().post(
131+
getApiUrl('/api/authz/v1/users/validate/'),
132+
data,
133+
);
134+
return camelCaseObject(res.data);
135+
};
136+
114137
// TODO: this should be replaced in the future with Console API
115138
export const getLibrary = async (libraryId: string): Promise<LibraryMetadata> => {
116139
const { data } = await getAuthenticatedHttpClient().get(getStudioApiUrl(`/api/libraries/v2/${libraryId}/`));

0 commit comments

Comments
 (0)