Skip to content

Commit 858153b

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 7143553 commit 858153b

35 files changed

Lines changed: 1536 additions & 106 deletions

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

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
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';
5-
import { renderWithAllProviders } from '@src/setupTest';
4+
import { ToastManagerProvider } from '@src/authz-module/libraries-manager/ToastManagerContext';
5+
import { renderWithAllProviders, renderWrapper } from '@src/setupTest';
66
import userEvent from '@testing-library/user-event';
7+
import { useNavigate } from 'react-router-dom';
78
import AuthzHome from './index';
89
import messages from './messages';
910

@@ -28,6 +29,33 @@ const renderAuthzHome = () => renderWithAllProviders(
2829
</ToastManagerProvider>,
2930
);
3031

32+
jest.mock('../components/AuthZLayout', () => function MockAuthZLayout(
33+
{ children, actions }: { children: React.ReactNode; actions?: React.ReactNode[] },
34+
) {
35+
return <div data-testid="authz-layout">{actions}{children}</div>;
36+
});
37+
38+
jest.mock('../roles-permissions/RolesPermissions', () => function MockRolesPermissions() {
39+
return <div data-testid="roles-permissions">Roles & Permissions Content</div>;
40+
});
41+
42+
jest.mock('@openedx/paragon', () => ({
43+
Tab: ({ children, title } : { children: React.ReactNode, title: string }) => <div data-testid="tab" role="tabpanel">{title}: {children}</div>,
44+
Tabs: ({ children }: { children: React.ReactNode }) => <div data-testid="tabs">{children}</div>,
45+
Button: ({ children, onClick }: React.PropsWithChildren<{ onClick?: () => void }>) => (
46+
<button type="button" onClick={onClick}>{children}</button>
47+
),
48+
Icon: () => null,
49+
}));
50+
51+
jest.mock('react-router-dom', () => ({
52+
...jest.requireActual('react-router-dom'),
53+
useNavigate: jest.fn(),
54+
}));
55+
56+
const mockNavigate = jest.fn();
57+
(useNavigate as jest.Mock).mockReturnValue(mockNavigate);
58+
3159
describe('AuthzHome', () => {
3260
beforeEach(() => {
3361
(useAllRoleAssignments as jest.Mock).mockReturnValue(emptyResponse);
@@ -71,4 +99,11 @@ describe('AuthzHome', () => {
7199
expect(screen.getAllByText('Role').length).toBe(2); // Header and role filter;
72100
expect(screen.getByText('Actions')).toBeInTheDocument();
73101
});
102+
103+
it('navigates to the wizard when the Assign Role button is clicked', async () => {
104+
const user = userEvent.setup();
105+
renderWrapper(<AuthzHome />);
106+
await user.click(screen.getByRole('button', { name: /Assign Role/i }));
107+
expect(mockNavigate).toHaveBeenCalled();
108+
});
74109
});

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: '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/AddRoleButton.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,20 @@ import { Plus } from '@openedx/paragon/icons';
55

66
import baseMessages from '@src/authz-module/messages';
77
import { useNavigate } from 'react-router-dom';
8+
import { buildWizardPath } from '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)