Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions src/authz-module/authz-home/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import React from 'react';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useNavigate } from 'react-router-dom';
import { renderWrapper } from '@src/setupTest';
import AuthzHome from './index';

jest.mock('../components/AuthZLayout', () => function MockAuthZLayout({ children }: { children: React.ReactNode }) {
return <div data-testid="authz-layout">{children}</div>;
jest.mock('../components/AuthZLayout', () => function MockAuthZLayout(
{ children, actions }: { children: React.ReactNode; actions?: React.ReactNode[] },
) {
return <div data-testid="authz-layout">{actions}{children}</div>;
});

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

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
}));

const mockNavigate = jest.fn();
(useNavigate as jest.Mock).mockReturnValue(mockNavigate);

describe('AuthzHome', () => {
it('renders without crashing', () => {
renderWrapper(<AuthzHome />);
Expand All @@ -37,4 +53,11 @@ describe('AuthzHome', () => {
renderWrapper(<AuthzHome />);
expect(screen.getByTestId('roles-permissions')).toBeInTheDocument();
});

it('navigates to the wizard when the Assign Role button is clicked', async () => {
const user = userEvent.setup();
renderWrapper(<AuthzHome />);
await user.click(screen.getByRole('button', { name: /Assign Role/i }));
expect(mockNavigate).toHaveBeenCalled();
});
});
24 changes: 15 additions & 9 deletions src/authz-module/authz-home/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { Tab, Tabs } from '@openedx/paragon';
import { useLocation } from 'react-router-dom';
import { Button, Tab, Tabs } from '@openedx/paragon';
import { Plus } from '@openedx/paragon/icons';
import { useLocation, useNavigate } from 'react-router-dom';
import RolesPermissions from '../roles-permissions/RolesPermissions';
import AuthZLayout from '../components/AuthZLayout';

import { buildWizardPath } from '../constants';
import messages from './messages';

const AuthzHome = () => {
const { hash } = useLocation();
const navigate = useNavigate();
const intl = useIntl();

const rootBreadcrumb = intl.formatMessage(messages['authz.breadcrumb.root']) || '';
Expand All @@ -21,13 +24,16 @@ const AuthzHome = () => {
activeLabel={pageTitle}
pageTitle={pageTitle}
pageSubtitle=""
actions={
[]
// this needs to be enable again once is refactored to be used outside of library context
// [
// <AddNewTeamMemberTrigger libraryId="" key="add-new-member" />,
// ]
}
actions={[
<Button
iconBefore={Plus}
variant="primary"
onClick={() => navigate(buildWizardPath())}
key="assign-role"
>
{intl.formatMessage(messages['authz.manage.assign.role.button'])}
</Button>,
]}
>
<Tabs
variant="tabs"
Expand Down
5 changes: 5 additions & 0 deletions src/authz-module/authz-home/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ const messages = defineMessages({
defaultMessage: 'Roles and Permissions',
description: 'Libraries AuthZ title for the roles tab',
},
'authz.manage.assign.role.button': {
id: 'authz.manage.assign.role.button',
defaultMessage: 'Assign Role',
description: 'Button to navigate to the assign role wizard',
},
});

export default messages;
3 changes: 3 additions & 0 deletions src/authz-module/components/PermissionTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,23 @@ const mockRoles: Role[] = [
userCount: 0,
permissions: [],
role: '',
contextType: '',
},
{
name: 'Editor',
description: 'Editor role',
userCount: 0,
permissions: [],
role: '',
contextType: '',
},
{
name: 'Viewer',
description: 'Viewer role',
userCount: 0,
permissions: [],
role: '',
contextType: '',
},
];

Expand Down
34 changes: 34 additions & 0 deletions src/authz-module/constants.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { buildWizardPath, ROUTES } from './constants';

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

describe('buildWizardPath', () => {
it('returns the base path when called with no arguments', () => {
expect(buildWizardPath()).toBe(BASE);
});

it('returns the base path when called with an empty options object', () => {
expect(buildWizardPath({})).toBe(BASE);
});

it('appends ?users= when only users is provided', () => {
expect(buildWizardPath({ users: 'alice' })).toBe(`${BASE}?users=alice`);
});

it('appends ?from= when only from is provided', () => {
expect(buildWizardPath({ from: '/authz/libraries/lib:123/alice' }))
.toBe(`${BASE}?from=%2Fauthz%2Flibraries%2Flib%3A123%2Falice`);
});

it('appends both users and from when both are provided', () => {
const result = buildWizardPath({ users: 'alice', from: '/authz/libraries/lib:123/alice' });
const parsed = new URL(result, 'http://x');
expect(parsed.pathname).toBe(BASE);
expect(parsed.searchParams.get('users')).toBe('alice');
expect(parsed.searchParams.get('from')).toBe('/authz/libraries/lib:123/alice');
});

it('omits the query string when users and from are both empty strings', () => {
expect(buildWizardPath({ users: '', from: '' })).toBe(BASE);
});
});
66 changes: 66 additions & 0 deletions src/authz-module/constants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
export const ROUTES = {
HOME_PATH: '/authz',
LIBRARIES_TEAM_PATH: '/libraries/:libraryId',
LIBRARIES_USER_PATH: '/libraries/:libraryId/:username',
ASSIGN_ROLE_WIZARD_PATH: '/assign-role',
};

export const buildWizardPath = (options?: { users?: string; from?: string }) => {
const base = `${ROUTES.HOME_PATH}${ROUTES.ASSIGN_ROLE_WIZARD_PATH}`;
if (!options) { return base; }
const params = new URLSearchParams();
if (options.users) { params.set('users', options.users); }
if (options.from) { params.set('from', options.from); }
const query = params.toString();
return query ? `${base}?${query}` : base;
};

export enum RoleOperationErrorStatus {
Expand All @@ -10,3 +22,57 @@ export enum RoleOperationErrorStatus {
ROLE_ASSIGNMENT_ERROR = 'role_assignment_error',
ROLE_REMOVAL_ERROR = 'role_removal_error',
}

// Course Permission Keys
export const COURSE_PERMISSIONS = {
// View permissions (Course Auditor)
VIEW_COURSE: 'courses.view_course',
VIEW_COURSE_UPDATES: 'courses.view_course_updates',
VIEW_PAGES_AND_RESOURCES: 'courses.view_pages_and_resources',
VIEW_FILES: 'courses.view_files',
VIEW_GRADING_SETTINGS: 'courses.view_grading_settings',
VIEW_CHECKLISTS: 'courses.view_checklists',
VIEW_COURSE_TEAM: 'courses.view_course_team',
VIEW_SCHEDULE_AND_DETAILS: 'courses.view_schedule_and_details',

// Edit permissions (Course Editor)
EDIT_COURSE_CONTENT: 'courses.edit_course_content',
MANAGE_LIBRARY_UPDATES: 'courses.manage_library_updates',
MANAGE_COURSE_UPDATES: 'courses.manage_course_updates',
MANAGE_PAGES_AND_RESOURCES: 'courses.manage_pages_and_resources',
CREATE_FILES: 'courses.create_files',
EDIT_FILES: 'courses.edit_files',
EDIT_GRADING_SETTINGS: 'courses.edit_grading_settings',
MANAGE_GROUP_CONFIGURATIONS: 'courses.manage_group_configurations',
EDIT_DETAILS: 'courses.edit_details',
MANAGE_TAGS: 'courses.manage_tags',

// Publish & lifecycle permissions (Course Staff)
PUBLISH_COURSE_CONTENT: 'courses.publish_course_content',
DELETE_FILES: 'courses.delete_files',
EDIT_SCHEDULE: 'courses.edit_schedule',
MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings',
MANAGE_CERTIFICATES: 'courses.manage_certificates',
IMPORT_COURSE: 'courses.import_course',
EXPORT_COURSE: 'courses.export_course',
EXPORT_TAGS: 'courses.export_tags',

// Team & taxonomy permissions (Course Admin only)
MANAGE_COURSE_TEAM: 'courses.manage_course_team',
MANAGE_TAXONOMIES: 'courses.manage_taxonomies',

// Legacy role permissions
LEGACY_STAFF_ROLE_PERMISSIONS: 'courses.legacy_staff_role_permissions',
LEGACY_INSTRUCTOR_ROLE_PERMISSIONS: 'courses.legacy_instructor_role_permissions',
LEGACY_LIMITED_STAFF_ROLE_PERMISSIONS: 'courses.legacy_limited_staff_role_permissions',
LEGACY_DATA_RESEARCHER_PERMISSIONS: 'courses.legacy_data_researcher_permissions',
LEGACY_BETA_TESTER_PERMISSIONS: 'courses.legacy_beta_tester_permissions',
};

// Resource Type Definitions
export const RESOURCE_TYPES = {
LIBRARY: 'library',
COURSE: 'course',
} as const;

export type ResourceType = typeof RESOURCE_TYPES[keyof typeof RESOURCE_TYPES];
24 changes: 24 additions & 0 deletions src/authz-module/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,20 @@ export interface AssignTeamMembersRoleRequest {
scope: string;
}

export type ValidateUsersRequest = {
users: string[];
};

export type ValidateUsersResponse = {
validUsers: string[];
invalidUsers: string[];
summary: {
total: number;
validCount: number;
invalidCount: number;
};
};

export const getTeamMembers = async (object: string, querySettings: QuerySettings): Promise<GetTeamMembersResponse> => {
const url = new URL(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`));

Expand Down Expand Up @@ -77,6 +91,16 @@ export const assignTeamMembersRole = async (
return camelCaseObject(res.data);
};

export const validateUsers = async (
data: ValidateUsersRequest,
): Promise<ValidateUsersResponse> => {
const res = await getAuthenticatedHttpClient().post(
getApiUrl('/api/authz/v1/users/validate/'),
data,
);
return camelCaseObject(res.data);
};

// TODO: this should be replaced in the future with Console API
export const getLibrary = async (libraryId: string): Promise<LibraryMetadata> => {
const { data } = await getAuthenticatedHttpClient().get(getStudioApiUrl(`/api/libraries/v2/${libraryId}/`));
Expand Down
Loading