Skip to content

Commit e04f840

Browse files
committed
feat: implement Assign Role Wizard with multi-step functionality
- Added AssignRoleWizard component for assigning roles to users. - Created AssignRoleWizardPage to handle routing and initial user input. - Implemented SelectUsersAndRoleStep for user and role selection with validation. - Developed DefineApplicationScopeStep as a placeholder for defining application scopes. - Introduced HighlightedUsersInput for enhanced user input with invalid user highlighting. - Added tests for all new components to ensure functionality and correctness. - Updated messages for internationalization support. - Enhanced types for RoleMetadata to include contextType and disabled properties.
1 parent 3125965 commit e04f840

37 files changed

Lines changed: 1621 additions & 118 deletions

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

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import React from 'react';
22
import { screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { useNavigate } from 'react-router-dom';
35
import { renderWrapper } from '@src/setupTest';
46
import AuthzHome from './index';
57

6-
jest.mock('../components/AuthZLayout', () => function MockAuthZLayout({ children }: { children: React.ReactNode }) {
7-
return <div data-testid="authz-layout">{children}</div>;
8+
jest.mock('../components/AuthZLayout', () => function MockAuthZLayout(
9+
{ children, actions }: { children: React.ReactNode; actions?: React.ReactNode[] },
10+
) {
11+
return <div data-testid="authz-layout">{actions}{children}</div>;
812
});
913

1014
jest.mock('../roles-permissions/RolesPermissions', () => function MockRolesPermissions() {
@@ -14,8 +18,20 @@ jest.mock('../roles-permissions/RolesPermissions', () => function MockRolesPermi
1418
jest.mock('@openedx/paragon', () => ({
1519
Tab: ({ children, title } : { children: React.ReactNode, title: string }) => <div data-testid="tab" role="tabpanel">{title}: {children}</div>,
1620
Tabs: ({ children }: { children: React.ReactNode }) => <div data-testid="tabs">{children}</div>,
21+
Button: ({ children, onClick }: React.PropsWithChildren<{ onClick?: () => void }>) => (
22+
<button type="button" onClick={onClick}>{children}</button>
23+
),
24+
Icon: () => null,
1725
}));
1826

27+
jest.mock('react-router-dom', () => ({
28+
...jest.requireActual('react-router-dom'),
29+
useNavigate: jest.fn(),
30+
}));
31+
32+
const mockNavigate = jest.fn();
33+
(useNavigate as jest.Mock).mockReturnValue(mockNavigate);
34+
1935
describe('AuthzHome', () => {
2036
it('renders without crashing', () => {
2137
renderWrapper(<AuthzHome />);
@@ -37,4 +53,11 @@ describe('AuthzHome', () => {
3753
renderWrapper(<AuthzHome />);
3854
expect(screen.getByTestId('roles-permissions')).toBeInTheDocument();
3955
});
56+
57+
it('navigates to the wizard when the Assign Role button is clicked', async () => {
58+
const user = userEvent.setup();
59+
renderWrapper(<AuthzHome />);
60+
await user.click(screen.getByRole('button', { name: /Assign Role/i }));
61+
expect(mockNavigate).toHaveBeenCalled();
62+
});
4063
});

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

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { useIntl } from '@edx/frontend-platform/i18n';
2-
import { Tab, Tabs } from '@openedx/paragon';
3-
import { useLocation } from 'react-router-dom';
2+
import { Button, Tab, Tabs } from '@openedx/paragon';
3+
import { Plus } from '@openedx/paragon/icons';
4+
import { useLocation, useNavigate } from 'react-router-dom';
45
import RolesPermissions from '../roles-permissions/RolesPermissions';
56
import AuthZLayout from '../components/AuthZLayout';
67

8+
import { buildWizardPath } from '../constants';
79
import messages from './messages';
810

911
const AuthzHome = () => {
1012
const { hash } = useLocation();
13+
const navigate = useNavigate();
1114
const intl = useIntl();
1215

1316
const rootBreadcrumb = intl.formatMessage(messages['authz.breadcrumb.root']) || '';
@@ -21,13 +24,16 @@ const AuthzHome = () => {
2124
activeLabel={pageTitle}
2225
pageTitle={pageTitle}
2326
pageSubtitle=""
24-
actions={
25-
[]
26-
// this needs to be enable again once is refactored to be used outside of library context
27-
// [
28-
// <AddNewTeamMemberTrigger libraryId="" key="add-new-member" />,
29-
// ]
30-
}
27+
actions={[
28+
<Button
29+
iconBefore={Plus}
30+
variant="primary"
31+
onClick={() => navigate(buildWizardPath())}
32+
key="assign-role"
33+
>
34+
{intl.formatMessage(messages['authz.manage.assign.role.button'])}
35+
</Button>,
36+
]}
3137
>
3238
<Tabs
3339
variant="tabs"

src/authz-module/authz-home/messages.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ const messages = defineMessages({
2121
defaultMessage: 'Roles and Permissions',
2222
description: 'Libraries AuthZ title for the roles tab',
2323
},
24+
'authz.manage.assign.role.button': {
25+
id: 'authz.manage.assign.role.button',
26+
defaultMessage: 'Assign Role',
27+
description: 'Button to navigate to the assign role wizard',
28+
},
2429
});
2530

2631
export default messages;

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: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
export const ROUTES = {
2+
HOME_PATH: '/authz',
23
LIBRARIES_TEAM_PATH: '/libraries/:libraryId',
34
LIBRARIES_USER_PATH: '/libraries/:libraryId/:username',
5+
ASSIGN_ROLE_WIZARD_PATH: '/assign-role',
6+
};
7+
8+
export const buildWizardPath = (options?: { users?: string; from?: string }) => {
9+
const base = `${ROUTES.HOME_PATH}${ROUTES.ASSIGN_ROLE_WIZARD_PATH}`;
10+
if (!options) { return base; }
11+
const params = new URLSearchParams();
12+
if (options.users) { params.set('users', options.users); }
13+
if (options.from) { params.set('from', options.from); }
14+
const query = params.toString();
15+
return query ? `${base}?${query}` : base;
416
};
517

618
export enum RoleOperationErrorStatus {
@@ -10,3 +22,57 @@ export enum RoleOperationErrorStatus {
1022
ROLE_ASSIGNMENT_ERROR = 'role_assignment_error',
1123
ROLE_REMOVAL_ERROR = 'role_removal_error',
1224
}
25+
26+
// Course Permission Keys
27+
export const COURSE_PERMISSIONS = {
28+
// View permissions (Course Auditor)
29+
VIEW_COURSE: 'courses.view_course',
30+
VIEW_COURSE_UPDATES: 'courses.view_course_updates',
31+
VIEW_PAGES_AND_RESOURCES: 'courses.view_pages_and_resources',
32+
VIEW_FILES: 'courses.view_files',
33+
VIEW_GRADING_SETTINGS: 'courses.view_grading_settings',
34+
VIEW_CHECKLISTS: 'courses.view_checklists',
35+
VIEW_COURSE_TEAM: 'courses.view_course_team',
36+
VIEW_SCHEDULE_AND_DETAILS: 'courses.view_schedule_and_details',
37+
38+
// Edit permissions (Course Editor)
39+
EDIT_COURSE_CONTENT: 'courses.edit_course_content',
40+
MANAGE_LIBRARY_UPDATES: 'courses.manage_library_updates',
41+
MANAGE_COURSE_UPDATES: 'courses.manage_course_updates',
42+
MANAGE_PAGES_AND_RESOURCES: 'courses.manage_pages_and_resources',
43+
CREATE_FILES: 'courses.create_files',
44+
EDIT_FILES: 'courses.edit_files',
45+
EDIT_GRADING_SETTINGS: 'courses.edit_grading_settings',
46+
MANAGE_GROUP_CONFIGURATIONS: 'courses.manage_group_configurations',
47+
EDIT_DETAILS: 'courses.edit_details',
48+
MANAGE_TAGS: 'courses.manage_tags',
49+
50+
// Publish & lifecycle permissions (Course Staff)
51+
PUBLISH_COURSE_CONTENT: 'courses.publish_course_content',
52+
DELETE_FILES: 'courses.delete_files',
53+
EDIT_SCHEDULE: 'courses.edit_schedule',
54+
MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings',
55+
MANAGE_CERTIFICATES: 'courses.manage_certificates',
56+
IMPORT_COURSE: 'courses.import_course',
57+
EXPORT_COURSE: 'courses.export_course',
58+
EXPORT_TAGS: 'courses.export_tags',
59+
60+
// Team & taxonomy permissions (Course Admin only)
61+
MANAGE_COURSE_TEAM: 'courses.manage_course_team',
62+
MANAGE_TAXONOMIES: 'courses.manage_taxonomies',
63+
64+
// Legacy role permissions
65+
LEGACY_STAFF_ROLE_PERMISSIONS: 'courses.legacy_staff_role_permissions',
66+
LEGACY_INSTRUCTOR_ROLE_PERMISSIONS: 'courses.legacy_instructor_role_permissions',
67+
LEGACY_LIMITED_STAFF_ROLE_PERMISSIONS: 'courses.legacy_limited_staff_role_permissions',
68+
LEGACY_DATA_RESEARCHER_PERMISSIONS: 'courses.legacy_data_researcher_permissions',
69+
LEGACY_BETA_TESTER_PERMISSIONS: 'courses.legacy_beta_tester_permissions',
70+
};
71+
72+
// Resource Type Definitions
73+
export const RESOURCE_TYPES = {
74+
LIBRARY: 'library',
75+
COURSE: 'course',
76+
} as const;
77+
78+
export type ResourceType = typeof RESOURCE_TYPES[keyof typeof RESOURCE_TYPES];

src/authz-module/data/api.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,20 @@ export interface AssignTeamMembersRoleRequest {
5050
scope: string;
5151
}
5252

53+
export type ValidateUsersRequest = {
54+
users: string[];
55+
};
56+
57+
export type ValidateUsersResponse = {
58+
validUsers: string[];
59+
invalidUsers: string[];
60+
summary: {
61+
total: number;
62+
validCount: number;
63+
invalidCount: number;
64+
};
65+
};
66+
5367
export const getTeamMembers = async (object: string, querySettings: QuerySettings): Promise<GetTeamMembersResponse> => {
5468
const url = new URL(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`));
5569

@@ -77,6 +91,16 @@ export const assignTeamMembersRole = async (
7791
return camelCaseObject(res.data);
7892
};
7993

94+
export const validateUsers = async (
95+
data: ValidateUsersRequest,
96+
): Promise<ValidateUsersResponse> => {
97+
const res = await getAuthenticatedHttpClient().post(
98+
getApiUrl('/api/authz/v1/users/validate/'),
99+
data,
100+
);
101+
return camelCaseObject(res.data);
102+
};
103+
80104
// TODO: this should be replaced in the future with Console API
81105
export const getLibrary = async (libraryId: string): Promise<LibraryMetadata> => {
82106
const { data } = await getAuthenticatedHttpClient().get(getStudioApiUrl(`/api/libraries/v2/${libraryId}/`));

0 commit comments

Comments
 (0)