diff --git a/.eslintrc.js b/.eslintrc.js index b4e44031..1916e679 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,4 +1,8 @@ // eslint-disable-next-line import/no-extraneous-dependencies const { createConfig } = require('@openedx/frontend-build'); -module.exports = createConfig('eslint'); +module.exports = createConfig('eslint', { + rules: { + 'react/jsx-filename-extension': [1, { extensions: ['.jsx', '.tsx'] }], + }, +}); diff --git a/src/authz-module/constants.test.ts b/src/authz-module/constants.test.ts new file mode 100644 index 00000000..e5f474d2 --- /dev/null +++ b/src/authz-module/constants.test.ts @@ -0,0 +1,197 @@ +import { + getRolesMetadata, + getPermissions, + getResourceTypes, + RESOURCE_TYPES, + RoleOperationErrorStatus, + ROUTES, + libraryRolesMetadata, + courseRolesMetadata, + libraryPermissions, + libraryResourceTypes, + CONTENT_LIBRARY_PERMISSIONS, + COURSE_PERMISSIONS, +} from './constants'; + +describe('ROUTES', () => { + it('defines the expected paths', () => { + expect(ROUTES.LIBRARIES_TEAM_PATH).toBe('/libraries/:libraryId'); + expect(ROUTES.LIBRARIES_USER_PATH).toBe('/libraries/:libraryId/:username'); + expect(ROUTES.ASSIGN_ROLE_WIZARD_PATH).toBe('/assign-role'); + }); +}); + +describe('RoleOperationErrorStatus', () => { + it('has expected enum values', () => { + expect(RoleOperationErrorStatus.USER_NOT_FOUND).toBe('user_not_found'); + expect(RoleOperationErrorStatus.USER_ALREADY_HAS_ROLE).toBe('user_already_has_role'); + expect(RoleOperationErrorStatus.USER_DOES_NOT_HAVE_ROLE).toBe('user_does_not_have_role'); + expect(RoleOperationErrorStatus.ROLE_ASSIGNMENT_ERROR).toBe('role_assignment_error'); + expect(RoleOperationErrorStatus.ROLE_REMOVAL_ERROR).toBe('role_removal_error'); + }); +}); + +describe('getRolesMetadata', () => { + it('returns library roles for LIBRARY resource type', () => { + expect(getRolesMetadata(RESOURCE_TYPES.LIBRARY)).toEqual(libraryRolesMetadata); + expect(getRolesMetadata(RESOURCE_TYPES.LIBRARY)).toHaveLength(4); + }); + + it('returns course roles for COURSE resource type', () => { + expect(getRolesMetadata(RESOURCE_TYPES.COURSE)).toEqual(courseRolesMetadata); + expect(getRolesMetadata(RESOURCE_TYPES.COURSE)).toHaveLength(4); + }); + + it('returns empty array for unknown resource type', () => { + // @ts-expect-error testing invalid input + expect(getRolesMetadata('unknown')).toEqual([]); + }); +}); + +describe('getPermissions', () => { + it('returns library permissions for LIBRARY resource type', () => { + expect(getPermissions(RESOURCE_TYPES.LIBRARY)).toEqual(libraryPermissions); + expect(getPermissions(RESOURCE_TYPES.LIBRARY).length).toBeGreaterThan(0); + }); + + it('returns empty array for COURSE resource type', () => { + expect(getPermissions(RESOURCE_TYPES.COURSE)).toEqual([]); + }); +}); + +describe('getResourceTypes', () => { + it('returns library resource types for LIBRARY resource type', () => { + expect(getResourceTypes(RESOURCE_TYPES.LIBRARY)).toEqual(libraryResourceTypes); + expect(getResourceTypes(RESOURCE_TYPES.LIBRARY)).toHaveLength(4); + }); + + it('returns empty array for COURSE resource type', () => { + expect(getResourceTypes(RESOURCE_TYPES.COURSE)).toEqual([]); + }); +}); + +describe('libraryRolesMetadata', () => { + it('includes all expected library roles', () => { + const roles = libraryRolesMetadata.map((r) => r.role); + expect(roles).toContain('library_admin'); + expect(roles).toContain('library_author'); + expect(roles).toContain('library_contributor'); + expect(roles).toContain('library_user'); + }); + + it('all library roles have contextType "library"', () => { + libraryRolesMetadata.forEach((r) => { + expect(r.contextType).toBe('library'); + }); + }); +}); + +describe('courseRolesMetadata', () => { + it('includes expected course roles', () => { + const roles = courseRolesMetadata.map((r) => r.role); + expect(roles).toContain('course_admin'); + expect(roles).toContain('course_staff'); + expect(roles).toContain('course_editor'); + expect(roles).toContain('course_auditor'); + }); + + it('all course roles have contextType "course"', () => { + courseRolesMetadata.forEach((r) => { + expect(r.contextType).toBe('course'); + }); + }); + + it('course_editor and course_auditor are disabled', () => { + const editor = courseRolesMetadata.find((r) => r.role === 'course_editor'); + const auditor = courseRolesMetadata.find((r) => r.role === 'course_auditor'); + expect(editor?.disabled).toBe(true); + expect(auditor?.disabled).toBe(true); + }); + + it('course_admin and course_staff are not disabled', () => { + const admin = courseRolesMetadata.find((r) => r.role === 'course_admin'); + const staff = courseRolesMetadata.find((r) => r.role === 'course_staff'); + expect(admin?.disabled).toBeUndefined(); + expect(staff?.disabled).toBeUndefined(); + }); +}); + +describe('CONTENT_LIBRARY_PERMISSIONS', () => { + it('defines all expected permission keys', () => { + expect(CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY).toBe('content_libraries.delete_library'); + expect(CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS).toBe('content_libraries.manage_library_tags'); + expect(CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY).toBe('content_libraries.view_library'); + expect(CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT).toBe('content_libraries.edit_library_content'); + expect(CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT).toBe('content_libraries.publish_library_content'); + expect(CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT).toBe('content_libraries.reuse_library_content'); + expect(CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION).toBe('content_libraries.create_library_collection'); + expect(CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION).toBe('content_libraries.edit_library_collection'); + expect(CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION).toBe('content_libraries.delete_library_collection'); + expect(CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM).toBe('content_libraries.manage_library_team'); + expect(CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM).toBe('content_libraries.view_library_team'); + }); +}); + +describe('COURSE_PERMISSIONS', () => { + it('defines view permissions', () => { + expect(COURSE_PERMISSIONS.VIEW_COURSE).toBe('courses.view_course'); + expect(COURSE_PERMISSIONS.VIEW_COURSE_UPDATES).toBe('courses.view_course_updates'); + expect(COURSE_PERMISSIONS.VIEW_PAGES_AND_RESOURCES).toBe('courses.view_pages_and_resources'); + expect(COURSE_PERMISSIONS.VIEW_FILES).toBe('courses.view_files'); + expect(COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS).toBe('courses.view_grading_settings'); + expect(COURSE_PERMISSIONS.VIEW_CHECKLISTS).toBe('courses.view_checklists'); + expect(COURSE_PERMISSIONS.VIEW_COURSE_TEAM).toBe('courses.view_course_team'); + expect(COURSE_PERMISSIONS.VIEW_SCHEDULE_AND_DETAILS).toBe('courses.view_schedule_and_details'); + }); + + it('defines edit permissions', () => { + expect(COURSE_PERMISSIONS.EDIT_COURSE_CONTENT).toBe('courses.edit_course_content'); + expect(COURSE_PERMISSIONS.MANAGE_LIBRARY_UPDATES).toBe('courses.manage_library_updates'); + expect(COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES).toBe('courses.manage_course_updates'); + expect(COURSE_PERMISSIONS.MANAGE_PAGES_AND_RESOURCES).toBe('courses.manage_pages_and_resources'); + expect(COURSE_PERMISSIONS.CREATE_FILES).toBe('courses.create_files'); + expect(COURSE_PERMISSIONS.EDIT_FILES).toBe('courses.edit_files'); + expect(COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS).toBe('courses.edit_grading_settings'); + expect(COURSE_PERMISSIONS.MANAGE_GROUP_CONFIGURATIONS).toBe('courses.manage_group_configurations'); + expect(COURSE_PERMISSIONS.EDIT_DETAILS).toBe('courses.edit_details'); + expect(COURSE_PERMISSIONS.MANAGE_TAGS).toBe('courses.manage_tags'); + }); + + it('defines publish and lifecycle permissions', () => { + expect(COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT).toBe('courses.publish_course_content'); + expect(COURSE_PERMISSIONS.DELETE_FILES).toBe('courses.delete_files'); + expect(COURSE_PERMISSIONS.EDIT_SCHEDULE).toBe('courses.edit_schedule'); + expect(COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS).toBe('courses.manage_advanced_settings'); + expect(COURSE_PERMISSIONS.MANAGE_CERTIFICATES).toBe('courses.manage_certificates'); + expect(COURSE_PERMISSIONS.IMPORT_COURSE).toBe('courses.import_course'); + expect(COURSE_PERMISSIONS.EXPORT_COURSE).toBe('courses.export_course'); + expect(COURSE_PERMISSIONS.EXPORT_TAGS).toBe('courses.export_tags'); + }); + + it('defines team and taxonomy permissions', () => { + expect(COURSE_PERMISSIONS.MANAGE_COURSE_TEAM).toBe('courses.manage_course_team'); + expect(COURSE_PERMISSIONS.MANAGE_TAXONOMIES).toBe('courses.manage_taxonomies'); + }); + + it('defines legacy role permissions', () => { + expect(COURSE_PERMISSIONS.LEGACY_STAFF_ROLE_PERMISSIONS).toBe('courses.legacy_staff_role_permissions'); + expect(COURSE_PERMISSIONS.LEGACY_INSTRUCTOR_ROLE_PERMISSIONS).toBe('courses.legacy_instructor_role_permissions'); + expect(COURSE_PERMISSIONS.LEGACY_LIMITED_STAFF_ROLE_PERMISSIONS).toBe('courses.legacy_limited_staff_role_permissions'); + expect(COURSE_PERMISSIONS.LEGACY_DATA_RESEARCHER_PERMISSIONS).toBe('courses.legacy_data_researcher_permissions'); + expect(COURSE_PERMISSIONS.LEGACY_BETA_TESTER_PERMISSIONS).toBe('courses.legacy_beta_tester_permissions'); + }); +}); + +describe('getPermissions default case', () => { + it('returns empty array for unknown resource type', () => { + // @ts-expect-error testing invalid input + expect(getPermissions('unknown')).toEqual([]); + }); +}); + +describe('getResourceTypes default case', () => { + it('returns empty array for unknown resource type', () => { + // @ts-expect-error testing invalid input + expect(getResourceTypes('unknown')).toEqual([]); + }); +}); diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index 3256bc2c..cfd71857 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -1,6 +1,7 @@ export const ROUTES = { LIBRARIES_TEAM_PATH: '/libraries/:libraryId', LIBRARIES_USER_PATH: '/libraries/:libraryId/:username', + ASSIGN_ROLE_WIZARD_PATH: '/assign-role', }; export enum RoleOperationErrorStatus { @@ -10,3 +11,163 @@ export enum RoleOperationErrorStatus { ROLE_ASSIGNMENT_ERROR = 'role_assignment_error', ROLE_REMOVAL_ERROR = 'role_removal_error', } + +// Content Library Permission Keys +export const CONTENT_LIBRARY_PERMISSIONS = { + DELETE_LIBRARY: 'content_libraries.delete_library', + MANAGE_LIBRARY_TAGS: 'content_libraries.manage_library_tags', + VIEW_LIBRARY: 'content_libraries.view_library', + + EDIT_LIBRARY_CONTENT: 'content_libraries.edit_library_content', + PUBLISH_LIBRARY_CONTENT: 'content_libraries.publish_library_content', + REUSE_LIBRARY_CONTENT: 'content_libraries.reuse_library_content', + + CREATE_LIBRARY_COLLECTION: 'content_libraries.create_library_collection', + EDIT_LIBRARY_COLLECTION: 'content_libraries.edit_library_collection', + DELETE_LIBRARY_COLLECTION: 'content_libraries.delete_library_collection', + + MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team', + VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team', +}; + +// Note: this information will eventually come from the backend API +// but for the MVP we decided to manage it in the frontend +export const libraryRolesMetadata = [ + { + role: 'library_admin', name: 'Library Admin', description: 'Can create and manage content libraries, including access and structure.', contextType: 'library', + }, + { + role: 'library_author', name: 'Library Author', description: 'Can create and edit library content, but cannot manage access.', contextType: 'library', + }, + { + role: 'library_contributor', name: 'Library Contributor', description: 'Can contribute and update library content shared with them.', contextType: 'library', + }, + { + role: 'library_user', name: 'Library User', description: 'Can view and use library content, but cannot edit it.', contextType: 'library', + }, +]; + +export const libraryResourceTypes = [ + { key: 'library', label: 'Library', description: 'Permissions related to the library as a whole.' }, + { key: 'library_content', label: 'Content', description: 'Permissions to create, edit, delete, and publish individual content items within the library.' }, + { key: 'library_collection', label: 'Collection', description: 'Permissions to create, edit, and delete content collections within the library.' }, + { key: 'library_team', label: 'Team', description: 'Permissions to manage user access and roles within the library.' }, +]; + +export const libraryPermissions = [ + { key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY, resource: 'library', description: 'Allows the user to delete the library and all its contents.' }, + { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, resource: 'library', description: 'Add or remove tags from content.' }, + { key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, resource: 'library', description: 'View content, search, filter, and sort within the library.' }, + + { key: CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT, resource: 'library_content', description: 'Edit content in draft mode' }, + { key: CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT, resource: 'library_content', description: 'Publish content, making it available for reuse' }, + { key: CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT, resource: 'library_content', description: 'Reuse published content within a course.' }, + + { key: CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Create new collections within a library.' }, + { key: CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Add or remove content from existing collections.' }, + { key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Delete entire collections from the library.' }, + + { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, resource: 'library_team', description: 'Add, remove, and assign roles to users within the library.' }, + { key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, resource: 'library_team', description: 'View the list of users who have access to the library.' }, +]; + +// 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]; + +export const courseRolesMetadata = [ + { + role: 'course_admin', name: 'Course Admin', description: 'Can manage the course team and all course settings.', contextType: 'course', + }, + { + role: 'course_staff', name: 'Course Staff', description: 'Can publish content and manage the course lifecycle in Studio.', contextType: 'course', + }, + { + role: 'course_editor', name: 'Course Editor', description: 'Can create and edit course content, but cannot publish or change critical course settings.', contextType: 'course', disabled: true, + }, + { + role: 'course_auditor', name: 'Course Auditor', description: 'Can view course content and settings, but cannot make changes.', contextType: 'course', disabled: true, + }, +]; + +// Get roles metadata by resource type +export const getRolesMetadata = (resourceType: ResourceType) => { + switch (resourceType) { + case RESOURCE_TYPES.LIBRARY: + return libraryRolesMetadata; + case RESOURCE_TYPES.COURSE: + return courseRolesMetadata; + default: + return []; + } +}; + +// Get permissions by resource type +export const getPermissions = (resourceType: ResourceType) => { + switch (resourceType) { + case RESOURCE_TYPES.LIBRARY: + return libraryPermissions; + default: + return []; + } +}; + +// Get resource types by resource type +export const getResourceTypes = (resourceType: ResourceType) => { + switch (resourceType) { + case RESOURCE_TYPES.LIBRARY: + return libraryResourceTypes; + default: + return []; + } +}; diff --git a/src/authz-module/data/api.test.ts b/src/authz-module/data/api.test.ts new file mode 100644 index 00000000..04213e40 --- /dev/null +++ b/src/authz-module/data/api.test.ts @@ -0,0 +1,288 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { + getTeamMembers, + assignTeamMembersRole, + validateUsers, + getLibrary, + getPermissionsByRole, + revokeUserRoles, + getScopes, + getOrganizations, +} from './api'; + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), +})); + +jest.mock('@src/data/utils', () => ({ + getApiUrl: (path: string) => `http://localhost:8000${path}`, + getStudioApiUrl: (path: string) => `http://localhost:8010${path}`, +})); + +jest.mock('@edx/frontend-platform', () => ({ + camelCaseObject: (obj: unknown) => obj, +})); + +const mockGet = jest.fn(); +const mockPost = jest.fn(); +const mockPut = jest.fn(); +const mockDelete = jest.fn(); + +const baseQuerySettings = { + roles: null, + search: null, + order: null, + sortBy: null, + pageSize: 10, + pageIndex: 0, +}; + +beforeEach(() => { + jest.clearAllMocks(); + (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({ + get: mockGet, + post: mockPost, + put: mockPut, + delete: mockDelete, + }); +}); + +describe('getTeamMembers', () => { + it('builds URL with required params and returns data', async () => { + const mockData = { count: 1, results: [{ username: 'user1' }] }; + mockGet.mockResolvedValue({ data: mockData }); + + const result = await getTeamMembers('lib:123', baseQuerySettings); + + expect(mockGet).toHaveBeenCalled(); + const calledUrl = new URL(mockGet.mock.calls[0][0]); + expect(calledUrl.searchParams.get('scope')).toBe('lib:123'); + expect(calledUrl.searchParams.get('page_size')).toBe('10'); + expect(calledUrl.searchParams.get('page')).toBe('1'); + expect(result).toEqual(mockData); + }); + + it('appends roles and search params when provided', async () => { + mockGet.mockResolvedValue({ data: { count: 0, results: [] } }); + + await getTeamMembers('lib:123', { + ...baseQuerySettings, + roles: 'admin', + search: 'alice', + }); + + const calledUrl = new URL(mockGet.mock.calls[0][0]); + expect(calledUrl.searchParams.get('roles')).toBe('admin'); + expect(calledUrl.searchParams.get('search')).toBe('alice'); + }); + + it('appends sort params when sortBy and order are provided', async () => { + mockGet.mockResolvedValue({ data: { count: 0, results: [] } }); + + await getTeamMembers('lib:123', { + ...baseQuerySettings, + sortBy: 'username', + order: 'asc', + }); + + const calledUrl = new URL(mockGet.mock.calls[0][0]); + expect(calledUrl.searchParams.get('sort_by')).toBe('username'); + expect(calledUrl.searchParams.get('order')).toBe('asc'); + }); +}); + +describe('assignTeamMembersRole', () => { + it('sends PUT request and returns camelCased data', async () => { + const mockResponse = { completed: [{ userIdentifier: 'jdoe', status: 'role_added' }], errors: [] }; + mockPut.mockResolvedValue({ data: mockResponse }); + + const result = await assignTeamMembersRole({ users: ['jdoe'], role: 'admin', scope: 'lib:123' }); + + expect(mockPut).toHaveBeenCalledWith( + 'http://localhost:8000/api/authz/v1/roles/users/', + { users: ['jdoe'], role: 'admin', scope: 'lib:123' }, + ); + expect(result).toEqual(mockResponse); + }); +}); + +describe('validateUsers', () => { + it('sends POST request and returns valid/invalid users', async () => { + const mockResponse = { validUsers: ['jdoe'], invalidUsers: ['unknown'] }; + mockPost.mockResolvedValue({ data: mockResponse }); + + const result = await validateUsers({ users: ['jdoe', 'unknown'] }); + + expect(mockPost).toHaveBeenCalledWith( + 'http://localhost:8000/api/authz/v1/users/validate', + { users: ['jdoe', 'unknown'] }, + ); + expect(result).toEqual(mockResponse); + }); + + it('returns empty lists when all users are valid', async () => { + const mockResponse = { validUsers: ['jdoe', 'alice'], invalidUsers: [] }; + mockPost.mockResolvedValue({ data: mockResponse }); + + const result = await validateUsers({ users: ['jdoe', 'alice'] }); + expect(result.invalidUsers).toHaveLength(0); + expect(result.validUsers).toHaveLength(2); + }); +}); + +describe('getLibrary', () => { + it('fetches library and maps fields correctly', async () => { + const mockData = { + id: 'lib:org/test', + org: 'org', + title: 'Test Library', + slug: 'test-library', + allow_public_read: true, + }; + mockGet.mockResolvedValue({ data: mockData }); + + const result = await getLibrary('lib:org/test'); + + expect(mockGet).toHaveBeenCalledWith('http://localhost:8010/api/libraries/v2/lib:org/test/'); + expect(result).toEqual({ + id: 'lib:org/test', + org: 'org', + title: 'Test Library', + slug: 'test-library', + allowPublicRead: true, + }); + }); +}); + +describe('getPermissionsByRole', () => { + it('fetches roles with scope param and returns results', async () => { + const mockRoles = [{ role: 'admin', permissions: ['perm1'], userCount: 2 }]; + mockGet.mockResolvedValue({ data: { results: mockRoles } }); + + const result = await getPermissionsByRole('lib:123'); + + const calledUrl = new URL(mockGet.mock.calls[0][0]); + expect(calledUrl.searchParams.get('scope')).toBe('lib:123'); + expect(result).toEqual(mockRoles); + }); +}); + +describe('revokeUserRoles', () => { + it('sends DELETE with correct query params', async () => { + const mockResponse = { completed: [{ userIdentifiers: 'jdoe', status: 'role_removed' }], errors: [] }; + mockDelete.mockResolvedValue({ data: mockResponse }); + + const result = await revokeUserRoles({ users: 'jdoe', role: 'admin', scope: 'lib:123' }); + + expect(mockDelete).toHaveBeenCalled(); + const calledUrl = new URL(mockDelete.mock.calls[0][0]); + expect(calledUrl.searchParams.get('users')).toBe('jdoe'); + expect(calledUrl.searchParams.get('role')).toBe('admin'); + expect(calledUrl.searchParams.get('scope')).toBe('lib:123'); + expect(result).toEqual(mockResponse); + }); +}); + +describe('getScopes', () => { + const mockScopesData = { + results: [{ + id: 'lib:123', name: 'Test Library', org: 'testorg', contextType: 'library', + }], + count: 1, + next: null, + previous: null, + }; + + it('builds URL with default page and pageSize when no optional params', async () => { + mockGet.mockResolvedValue({ data: mockScopesData }); + + const result = await getScopes({}); + + const calledUrl = new URL(mockGet.mock.calls[0][0]); + expect(calledUrl.searchParams.get('page')).toBe('1'); + expect(calledUrl.searchParams.get('page_size')).toBe('10'); + expect(calledUrl.searchParams.get('search')).toBeNull(); + expect(calledUrl.searchParams.get('org')).toBeNull(); + expect(calledUrl.searchParams.get('management_permission_only')).toBeNull(); + expect(result).toEqual(mockScopesData); + }); + + it('appends search param when provided', async () => { + mockGet.mockResolvedValue({ data: mockScopesData }); + + await getScopes({ search: 'mylib' }); + + const calledUrl = new URL(mockGet.mock.calls[0][0]); + expect(calledUrl.searchParams.get('search')).toBe('mylib'); + }); + + it('appends org param when provided', async () => { + mockGet.mockResolvedValue({ data: mockScopesData }); + + await getScopes({ org: 'testorg' }); + + const calledUrl = new URL(mockGet.mock.calls[0][0]); + expect(calledUrl.searchParams.get('org')).toBe('testorg'); + }); + + it('appends management_permission_only when set to true', async () => { + mockGet.mockResolvedValue({ data: mockScopesData }); + + await getScopes({ managementPermissionOnly: true }); + + const calledUrl = new URL(mockGet.mock.calls[0][0]); + expect(calledUrl.searchParams.get('management_permission_only')).toBe('true'); + }); + + it('uses provided page and pageSize', async () => { + mockGet.mockResolvedValue({ data: mockScopesData }); + + await getScopes({ page: 3, pageSize: 25 }); + + const calledUrl = new URL(mockGet.mock.calls[0][0]); + expect(calledUrl.searchParams.get('page')).toBe('3'); + expect(calledUrl.searchParams.get('page_size')).toBe('25'); + }); + + it('does not append managementPermissionOnly when false', async () => { + mockGet.mockResolvedValue({ data: mockScopesData }); + + await getScopes({ managementPermissionOnly: false }); + + const calledUrl = new URL(mockGet.mock.calls[0][0]); + expect(calledUrl.searchParams.get('management_permission_only')).toBeNull(); + }); +}); + +describe('getOrganizations', () => { + it('returns organizations from data.results when present', async () => { + const mockData = { + results: [{ org: 'org1', name: 'Org One' }, { org: 'org2', name: 'Org Two' }], + }; + mockGet.mockResolvedValue({ data: mockData }); + + const result = await getOrganizations(); + + const calledUrl = new URL(mockGet.mock.calls[0][0]); + expect(calledUrl.pathname).toBe('/api/authz/v1/organizations/'); + expect(result).toEqual(mockData.results); + }); + + it('falls back to data directly when results is not present', async () => { + const mockData = [{ org: 'org1', name: 'Org One' }]; + mockGet.mockResolvedValue({ data: mockData }); + + const result = await getOrganizations(); + + expect(result).toEqual(mockData); + }); + + it('accepts contextType param (currently unused in URL but accepted)', async () => { + mockGet.mockResolvedValue({ data: { results: [] } }); + + const result = await getOrganizations('course'); + + expect(result).toEqual([]); + expect(mockGet).toHaveBeenCalled(); + }); +}); diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index bf5ff1ae..dce93f8c 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -50,6 +50,16 @@ export interface AssignTeamMembersRoleRequest { scope: string; } +// TODO: Validate Users API +export type ValidateUsersRequest = { + users: string[]; +}; + +export type ValidateUsersResponse = { + validUsers: string[]; + invalidUsers: string[]; +}; + export const getTeamMembers = async (object: string, querySettings: QuerySettings): Promise => { const url = new URL(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`)); @@ -77,6 +87,16 @@ export const assignTeamMembersRole = async ( return camelCaseObject(res.data); }; +export const validateUsers = async ( + data: ValidateUsersRequest, +): Promise => { + 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 => { const { data } = await getAuthenticatedHttpClient().get(getStudioApiUrl(`/api/libraries/v2/${libraryId}/`)); @@ -96,6 +116,54 @@ export const getPermissionsByRole = async (scope: string): Promise => { + const url = new URL(getApiUrl('/api/authz/v1/scopes/')); + if (params.contextType) { url.searchParams.set('context_type', params.contextType); } + if (params.search) { url.searchParams.set('search', params.search); } + if (params.org) { url.searchParams.set('org', params.org); } + if (params.managementPermissionOnly) { url.searchParams.set('management_permission_only', 'true'); } + url.searchParams.set('page', (params.page ?? 1).toString()); + url.searchParams.set('page_size', (params.pageSize ?? 10).toString()); + const { data } = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(data); +}; + +export const getOrganizations = async (contextType?: string): Promise => { + const url = new URL(getApiUrl('/api/authz/v1/organizations/')); + if (contextType) { url.searchParams.set('context_type', contextType); } + const { data } = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(data.results ?? data); +}; + export const revokeUserRoles = async ( data: RevokeUserRolesRequest, ): Promise => { diff --git a/src/authz-module/data/hooks.test.tsx b/src/authz-module/data/hooks.test.tsx index d55c7e0a..b656284f 100644 --- a/src/authz-module/data/hooks.test.tsx +++ b/src/authz-module/data/hooks.test.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { useLibrary, usePermissionsByRole, useTeamMembers, useAssignTeamMembersRole, useRevokeUserRoles, + useValidateUsers, useScopes, useOrganizations, useManagedScopeOrgs, } from './hooks'; jest.mock('@edx/frontend-platform/auth', () => ({ @@ -241,6 +242,231 @@ describe('usePermissionsByRole', () => { }); }); +describe('useValidateUsers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('successfully validates users', async () => { + const mockResponse = { + validUsers: ['jdoe'], + invalidUsers: ['unknown_user'], + }; + + getAuthenticatedHttpClient.mockReturnValue({ + post: jest.fn().mockResolvedValue({ data: mockResponse }), + }); + + const { result } = renderHook(() => useValidateUsers(), { + wrapper: createWrapper(), + }); + + await act(async () => { + result.current.mutate({ data: { users: ['jdoe', 'unknown_user'] } }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(getAuthenticatedHttpClient).toHaveBeenCalled(); + expect(result.current.data).toEqual(mockResponse); + }); + + it('handles error when validation fails', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + post: jest.fn().mockRejectedValue(new Error('Validation failed')), + }); + + const { result } = renderHook(() => useValidateUsers(), { + wrapper: createWrapper(), + }); + + await act(async () => { + result.current.mutate({ data: { users: ['jdoe'] } }); + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(result.current.error).toEqual(new Error('Validation failed')); + }); +}); + +describe('useScopes', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const makeScopesResponse = (next: string | null = null) => ({ + results: [{ + id: 'lib:123', name: 'Test Library', org: 'testorg', contextType: 'library', + }], + count: 1, + next, + previous: null, + }); + + it('returns pages data on success', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValue({ data: makeScopesResponse() }), + }); + + const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.pages).toHaveLength(1); + expect(result.current.data?.pages[0].results).toHaveLength(1); + }); + + it('hasNextPage is false when next is null', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValue({ data: makeScopesResponse(null) }), + }); + + const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.hasNextPage).toBe(false); + }); + + it('hasNextPage is true when next URL has page param', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValue({ + data: makeScopesResponse('http://localhost:8000/api/authz/v1/scopes/?page=2'), + }), + }); + + const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.hasNextPage).toBe(true); + }); + + it('hasNextPage is false when next URL has no page param', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValue({ + data: makeScopesResponse('http://localhost:8000/api/authz/v1/scopes/'), + }), + }); + + const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.hasNextPage).toBe(false); + }); + + it('hasNextPage is false when next is an invalid URL', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValue({ + data: makeScopesResponse('not-a-valid-url'), + }), + }); + + const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.hasNextPage).toBe(false); + }); + + it('handles error when API call fails', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockRejectedValue(new Error('Network error')), + }); + + const { result } = renderHook(() => useScopes({}), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error).toBeDefined(); + }); +}); + +describe('useOrganizations', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns organizations on success', async () => { + const mockOrgs = [{ org: 'org1', name: 'Org One' }]; + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValue({ data: { results: mockOrgs } }), + }); + + const { result } = renderHook(() => useOrganizations('library'), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockOrgs); + }); + + it('handles error when API fails', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockRejectedValue(new Error('Failed')), + }); + + const { result } = renderHook(() => useOrganizations(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); +}); + +describe('useManagedScopeOrgs', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not fetch when contextType is undefined', async () => { + const mockGet = jest.fn(); + getAuthenticatedHttpClient.mockReturnValue({ get: mockGet }); + + const { result } = renderHook(() => useManagedScopeOrgs(undefined), { wrapper: createWrapper() }); + + // Query is disabled, so it should not be loading or have fetched + expect(result.current.isFetching).toBe(false); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it('fetches and returns a Set of orgs when contextType is provided', async () => { + const mockScopesResponse = { + results: [ + { + id: 'lib:123', name: 'Lib 1', org: 'org1', contextType: 'library', + }, + { + id: 'lib:456', name: 'Lib 2', org: 'org2', contextType: 'library', + }, + { + id: 'lib:789', name: 'Lib 3', org: '', contextType: 'library', + }, + ], + count: 3, + next: null, + previous: null, + }; + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValue({ data: mockScopesResponse }), + }); + + const { result } = renderHook(() => useManagedScopeOrgs('library'), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const orgs = result.current.data as Set; + expect(orgs.has('org1')).toBe(true); + expect(orgs.has('org2')).toBe(true); + // empty string org is filtered out + expect(orgs.has('')).toBe(false); + expect(orgs.size).toBe(2); + }); + + it('handles error when API fails', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockRejectedValue(new Error('API error')), + }); + + const { result } = renderHook(() => useManagedScopeOrgs('course'), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); +}); + describe('useRevokeUserRoles', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts index bc5090e0..dcce8146 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -1,11 +1,13 @@ import { - useMutation, useQuery, useQueryClient, useSuspenseQuery, + useInfiniteQuery, useMutation, useQuery, useQueryClient, useSuspenseQuery, } from '@tanstack/react-query'; import { appId } from '@src/constants'; import { LibraryMetadata } from '@src/types'; import { - assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getPermissionsByRole, getTeamMembers, - GetTeamMembersResponse, PermissionsByRole, QuerySettings, revokeUserRoles, RevokeUserRolesRequest, + assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getOrganizations, + getPermissionsByRole, getScopes, GetScopesParams, GetScopesResponse, getTeamMembers, + GetTeamMembersResponse, OrganizationItem, PermissionsByRole, QuerySettings, revokeUserRoles, + RevokeUserRolesRequest, validateUsers, ValidateUsersRequest, } from './api'; const authzQueryKeys = { @@ -91,6 +93,55 @@ export const useAssignTeamMembersRole = () => { }); }; +/** + * React Query hook to validate users exist without assigning roles. + * It checks if the provided usernames/email addresses are valid. + * + * @example + * const { mutate: validateUsers } = useValidateUsers(); + * validateUsers({ data: { users: ['jdoe', 'jane@example.com'] } }); + */ +export const useValidateUsers = () => useMutation({ + mutationFn: async ({ data }: { + data: ValidateUsersRequest + }) => validateUsers(data), +}); + +/** + * React Query hook to fetch a paginated, searchable list of scopes (courses or libraries). + * Uses infinite query to support infinite scroll. + * + * @param params - Filter params: contextType, search, org, pageSize + */ +export const useScopes = (params: Omit) => useInfiniteQuery({ + queryKey: [...authzQueryKeys.all, 'scopes', params], + queryFn: ({ pageParam }) => getScopes({ ...params, page: pageParam as number }), + getNextPageParam: (lastPage) => { + if (!lastPage.next) { return undefined; } + try { + const nextUrl = new URL(lastPage.next); + const page = nextUrl.searchParams.get('page'); + return page ? parseInt(page, 10) : undefined; + } catch { + return undefined; + } + }, + initialPageParam: 1, + staleTime: 1000 * 60 * 5, +}); + +/** + * React Query hook to fetch the list of organizations for a given context type. + * Used to populate the Organization filter dropdown in the scope selector. + * + * @param contextType - 'course' | 'library' + */ +export const useOrganizations = (contextType?: string) => useQuery({ + queryKey: [...authzQueryKeys.all, 'organizations', contextType], + queryFn: () => getOrganizations(contextType), + staleTime: 1000 * 60 * 30, +}); + /** * React Query hook to remove roles for a specific team member within a scope. * @@ -110,3 +161,20 @@ export const useRevokeUserRoles = () => { }, }); }; + +/** + * Fetches all scopes the current user has management permissions over and returns + * the set of orgs derived from those scopes. Used to determine which org-level and + * platform-wide "All..." aggregate options to show in the scope selector. + * + * @param contextType - 'course' | 'library' + */ +export const useManagedScopeOrgs = (contextType?: string) => useQuery({ + queryKey: [...authzQueryKeys.all, 'managedScopeOrgs', contextType], + queryFn: async () => { + const data = await getScopes({ contextType, managementPermissionOnly: true, pageSize: 100 }); + return new Set(data.results.map((s) => s.org).filter(Boolean)); + }, + enabled: !!contextType, + staleTime: 1000 * 60 * 30, +}); diff --git a/src/authz-module/index.tsx b/src/authz-module/index.tsx index 42fd8843..854b2fb9 100644 --- a/src/authz-module/index.tsx +++ b/src/authz-module/index.tsx @@ -6,6 +6,7 @@ import LoadingPage from '@src/components/LoadingPage'; import LibrariesErrorFallback from '@src/authz-module/libraries-manager/ErrorPage'; import { ToastManagerProvider } from './libraries-manager/ToastManagerContext'; import { LibrariesTeamManager, LibrariesUserManager, LibrariesLayout } from './libraries-manager'; +import AssignRoleWizardPage from './wizard/AssignRoleWizardPage'; import { ROUTES } from './constants'; import './index.scss'; @@ -21,6 +22,7 @@ const AuthZModule = () => ( } /> } /> + } /> diff --git a/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx index 0e64ca76..abf89408 100644 --- a/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx @@ -3,11 +3,18 @@ import userEvent from '@testing-library/user-event'; import { renderWrapper } from '@src/setupTest'; import { initializeMockApp } from '@edx/frontend-platform/testing'; import { useLibrary, useUpdateLibrary } from '@src/authz-module/data/hooks'; +import { useNavigate, useLocation } from 'react-router-dom'; import { useLibraryAuthZ } from './context'; import LibrariesTeamManager from './LibrariesTeamManager'; import { ToastManagerProvider } from './ToastManagerContext'; import { CONTENT_LIBRARY_PERMISSIONS } from './constants'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: jest.fn(), + useLocation: jest.fn().mockReturnValue({ hash: '' }), +})); + jest.mock('./context', () => { const actual = jest.requireActual('./context'); return { @@ -29,11 +36,6 @@ jest.mock('./components/TeamTable', () => ({ default: () =>
Team member list
, })); -jest.mock('./components/AddNewTeamMemberModal', () => ({ - __esModule: true, - AddNewTeamMemberTrigger: () => , -})); - jest.mock('../components/RoleCard', () => ({ __esModule: true, default: ({ title, description, permissionsByResource }: { @@ -58,6 +60,7 @@ describe('LibrariesTeamManager', () => { allowPublicRead: false, }; const mutate = jest.fn(); + const mockNavigate = jest.fn(); const libraryAuthZContext = { libraryId: libraryData.id, libraryName: libraryData.title, @@ -95,6 +98,8 @@ describe('LibrariesTeamManager', () => { mutate, isPending: false, }); + (useNavigate as jest.Mock).mockReturnValue(mockNavigate); + (useLocation as jest.Mock).mockReturnValue({ hash: '' }); }); it('renders tabs and layout content correctly', () => { @@ -111,8 +116,8 @@ describe('LibrariesTeamManager', () => { // TeamTable is rendered expect(screen.getByRole('table', { name: 'Team Members Table' })).toBeInTheDocument(); - // AddNewTeamMemberTrigger is rendered - expect(screen.getByRole('button', { name: 'Add Team Member' })).toBeInTheDocument(); + // Assign Role button is rendered + expect(screen.getByRole('button', { name: 'Assign Role' })).toBeInTheDocument(); }); it('renders role cards when "Roles" tab is selected', async () => { @@ -141,7 +146,7 @@ describe('LibrariesTeamManager', () => { const permissionsTab = await screen.findByRole('tab', { name: /permissions/i }); await user.click(permissionsTab); - const tablePermissionMatrix = await screen.getByRole('table'); + const tablePermissionMatrix = screen.getByRole('table'); const matrixScope = within(tablePermissionMatrix); expect(matrixScope.getByText('Library')).toBeInTheDocument(); @@ -157,4 +162,25 @@ describe('LibrariesTeamManager', () => { // TODO: Update expected URL when dedicated Manage Access page is created expect(navLink).toHaveAttribute('href', '/authz/libraries/lib-001'); }); + + it('navigates to assign role wizard when "Assign Role" button is clicked', async () => { + const user = userEvent.setup(); + renderTeamManager(); + await user.click(screen.getByRole('button', { name: 'Assign Role' })); + expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role?scope=lib-001'); + }); + + it('does not render Assign Role button when canManageTeam is false', () => { + mockedUseLibraryAuthZ.mockReturnValue({ ...libraryAuthZContext, canManageTeam: false }); + renderTeamManager(); + expect(screen.queryByRole('button', { name: 'Assign Role' })).not.toBeInTheDocument(); + }); + + it('defaults to permissions tab when hash is present in location', () => { + (useLocation as jest.Mock).mockReturnValue({ hash: '#permissions' }); + renderTeamManager(); + // Tabs renders with defaultActiveKey="permissions" when hash is truthy + const permissionsTab = screen.getByRole('tab', { name: /permissions/i }); + expect(permissionsTab).toBeInTheDocument(); + }); }); diff --git a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx index fa4b2c60..b4df5a49 100644 --- a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx @@ -1,17 +1,17 @@ import { useMemo } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { - Container, Skeleton, Tab, Tabs, + Container, Skeleton, Tab, Tabs, Button, } from '@openedx/paragon'; import { useLibrary } from '@src/authz-module/data/hooks'; -import { useLocation } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { ROUTES } from '@src/authz-module/constants'; +import { Plus } from '@openedx/paragon/icons'; import TeamTable from './components/TeamTable'; import AuthZLayout from '../components/AuthZLayout'; import RoleCard from '../components/RoleCard'; import PermissionTable from '../components/PermissionTable'; import { useLibraryAuthZ } from './context'; -import { AddNewTeamMemberTrigger } from './components/AddNewTeamMemberModal'; import { buildPermissionMatrixByResource, buildPermissionMatrixByRole } from './utils'; import messages from './messages'; @@ -19,6 +19,7 @@ import messages from './messages'; const LibrariesTeamManager = () => { const intl = useIntl(); const { hash } = useLocation(); + const navigate = useNavigate(); const { libraryId, canManageTeam, roles, permissions, resources, } = useLibraryAuthZ(); @@ -27,6 +28,11 @@ const LibrariesTeamManager = () => { const pageTitle = intl.formatMessage(messages['library.authz.manage.page.title']); const teamMembersPath = `/authz${ROUTES.LIBRARIES_TEAM_PATH.replace(':libraryId', libraryId)}`; + // Handler to navigate to Assign Role Wizard + const handleNavigateToWizard = () => { + navigate(`/authz/assign-role?scope=${libraryId}`); + }; + const [libraryPermissionsByRole, libraryPermissionsByResource] = useMemo(() => { if (!roles && !permissions && !resources) { return [null, null]; } const permissionsByRole = buildPermissionMatrixByRole({ @@ -50,9 +56,18 @@ const LibrariesTeamManager = () => { pageTitle={pageTitle} pageSubtitle={libraryId} actions={ - [ - ...(canManageTeam ? [] : []), - ] + canManageTeam + ? [ + , + ] + : [] } > ({ logError: jest.fn(), })); +const mockNavigate = jest.fn(); + jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useParams: jest.fn(), + useNavigate: () => mockNavigate, })); jest.mock('./context', () => ({ @@ -162,6 +165,80 @@ describe('LibrariesUserManager', () => { expect(navLinkLibraryTeamManagement).toHaveAttribute('href', '/authz/libraries/lib:123'); }); + describe('Navigation guards', () => { + it('redirects to team path when canManageTeam is false', () => { + (useLibraryAuthZ as jest.Mock).mockReturnValue({ + ...defaultMockData, + canManageTeam: false, + }); + + renderComponent(); + + expect(mockNavigate).toHaveBeenCalledWith('/authz/libraries/lib:123'); + }); + + it('redirects to team path when user is not found after loading completes', async () => { + (useTeamMembers as jest.Mock).mockReturnValue({ + data: { results: [] }, + isLoading: false, + isFetching: false, + }); + + renderComponent(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/authz/libraries/lib:123'); + }); + }); + + it('does not redirect while member data is still fetching', () => { + (useTeamMembers as jest.Mock).mockReturnValue({ + data: undefined, + isLoading: true, + isFetching: true, + }); + + renderComponent(); + + // navigate should only be called for canManageTeam=true case (not at all here) + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); + + describe('Loading state', () => { + it('renders skeleton while loading team member data', () => { + (useTeamMembers as jest.Mock).mockReturnValue({ + data: undefined, + isLoading: true, + isFetching: true, + }); + + renderComponent(); + + // Just verify the component renders without crashing in loading state + expect(screen.queryByText('Admin')).not.toBeInTheDocument(); + }); + }); + + describe('Assign role button', () => { + it('navigates to assign-role wizard when Assign Role button is clicked', async () => { + const user = userEvent.setup(); + renderComponent(); + + // There are two "Assign Role" elements: the mocked AssignNewRoleTrigger and the real Button + // The real Button navigates; use getAllByText and click the one that is a , + ] : []} > diff --git a/src/authz-module/libraries-manager/constants.ts b/src/authz-module/libraries-manager/constants.ts index d1368961..02e88dfd 100644 --- a/src/authz-module/libraries-manager/constants.ts +++ b/src/authz-module/libraries-manager/constants.ts @@ -1,55 +1,17 @@ -import { PermissionMetadata, ResourceMetadata, RoleMetadata } from 'types'; - -export const CONTENT_LIBRARY_PERMISSIONS = { - DELETE_LIBRARY: 'content_libraries.delete_library', - MANAGE_LIBRARY_TAGS: 'content_libraries.manage_library_tags', - VIEW_LIBRARY: 'content_libraries.view_library', - - EDIT_LIBRARY_CONTENT: 'content_libraries.edit_library_content', - PUBLISH_LIBRARY_CONTENT: 'content_libraries.publish_library_content', - REUSE_LIBRARY_CONTENT: 'content_libraries.reuse_library_content', - - CREATE_LIBRARY_COLLECTION: 'content_libraries.create_library_collection', - EDIT_LIBRARY_COLLECTION: 'content_libraries.edit_library_collection', - DELETE_LIBRARY_COLLECTION: 'content_libraries.delete_library_collection', - - MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team', - VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team', +import { + CONTENT_LIBRARY_PERMISSIONS, + libraryRolesMetadata, + libraryResourceTypes, + libraryPermissions, +} from '../constants'; + +export { + CONTENT_LIBRARY_PERMISSIONS, + libraryRolesMetadata, + libraryResourceTypes, + libraryPermissions, }; -// Note: this information will eventually come from the backend API -// but for the MVP we decided to manage it in the frontend -export const libraryRolesMetadata: RoleMetadata[] = [ - { 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.' }, - { 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.' }, - { 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.' }, - { role: 'library_user', name: 'Library User', description: 'The Library User can view and reuse content but cannot edit or delete any resource.' }, -]; - -export const libraryResourceTypes: ResourceMetadata[] = [ - { key: 'library', label: 'Library', description: 'Permissions related to the library as a whole.' }, - { key: 'library_content', label: 'Content', description: 'Permissions to create, edit, delete, and publish individual content items within the library.' }, - { key: 'library_collection', label: 'Collection', description: 'Permissions to create, edit, and delete content collections within the library.' }, - { key: 'library_team', label: 'Team', description: 'Permissions to manage user access and roles within the library.' }, -]; - -export const libraryPermissions: PermissionMetadata[] = [ - { key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY, resource: 'library', description: 'Allows the user to delete the library and all its contents.' }, - { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, resource: 'library', description: 'Add or remove tags from content.' }, - { key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, resource: 'library', description: 'View content, search, filter, and sort within the library.' }, - - { key: CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT, resource: 'library_content', description: 'Edit content in draft mode' }, - { key: CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT, resource: 'library_content', description: 'Publish content, making it available for reuse' }, - { key: CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT, resource: 'library_content', description: 'Reuse published content within a course.' }, - - { key: CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Create new collections within a library.' }, - { key: CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Add or remove content from existing collections.' }, - { key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Delete entire collections from the library.' }, - - { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, resource: 'library_team', description: 'View the list of users who have access to the library.' }, - { key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, resource: 'library_team', description: 'Add, remove, and assign roles to users within the library.' }, -]; - export const DEFAULT_TOAST_DELAY = 5000; export const RETRY_TOAST_DELAY = 120_000; // 2 minutes export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({ diff --git a/src/authz-module/libraries-manager/messages.ts b/src/authz-module/libraries-manager/messages.ts index 9ed3f7d4..699eaa9e 100644 --- a/src/authz-module/libraries-manager/messages.ts +++ b/src/authz-module/libraries-manager/messages.ts @@ -11,6 +11,11 @@ const messages = defineMessages({ defaultMessage: 'Manage Access', description: 'Libreries AuthZ root breafcrumb', }, + 'library.authz.manage.add.role.button': { + id: 'library.authz.manage.add.role.button', + defaultMessage: 'Assign Role', + description: 'Button to add a new role', + }, 'library.authz.tabs.team': { id: 'library.authz.tabs.team', defaultMessage: 'Team Members', diff --git a/src/authz-module/wizard/AssignRoleWizard.test.tsx b/src/authz-module/wizard/AssignRoleWizard.test.tsx new file mode 100644 index 00000000..d826f10a --- /dev/null +++ b/src/authz-module/wizard/AssignRoleWizard.test.tsx @@ -0,0 +1,407 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper } from '@src/setupTest'; +import { useValidateUsers, useAssignTeamMembersRole } from '../data/hooks'; +import { useValidateUserPermissions } from '../../data/hooks'; +import { useToastManager } from '../libraries-manager/ToastManagerContext'; +import AssignRoleWizard from './AssignRoleWizard'; + +// Mock Paragon Stepper so all steps and action rows are always rendered +jest.mock('@openedx/paragon', () => { + const actual = jest.requireActual('@openedx/paragon'); + const MockActionRow = Object.assign( + ({ children }: { children: React.ReactNode }) =>
{children}
, + { Spacer: () => null }, + ); + const MockStepper = Object.assign( + ({ children }: { children: React.ReactNode }) =>
{children}
, + { + Header: () => null, + Step: ({ children, onClick, eventKey }: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + children: any; onClick?: () => void; eventKey: string; + }) => ( +
+ + {children} +
+ ), + ActionRow: MockActionRow, + }, + ); + return { ...actual, Stepper: MockStepper }; +}); + +jest.mock('./SelectUsersAndRoleStep', () => ({ + __esModule: true, + default: ({ + users, setUsers, setSelectedRole, invalidUsers, validationError, + }: { + users: string; + setUsers: (v: string) => void; + setSelectedRole: (v: string) => void; + invalidUsers: string[]; + validationError: string | null; + }) => ( +
+ setUsers(e.target.value)} + /> + + {invalidUsers.length > 0 && ( +
{invalidUsers.join(', ')}
+ )} + {validationError &&
{validationError}
} +
+ ), +})); + +jest.mock('./DefineApplicationScopeStep', () => ({ + __esModule: true, + default: ({ onScopeToggle }: { onScopeToggle: (id: string) => void }) => ( + + ), +})); + +jest.mock('../data/hooks', () => ({ + useValidateUsers: jest.fn(), + useAssignTeamMembersRole: jest.fn(), +})); + +jest.mock('../../data/hooks', () => ({ + useValidateUserPermissions: jest.fn(), +})); + +jest.mock('../libraries-manager/ToastManagerContext', () => ({ + useToastManager: jest.fn(), +})); + +const mockUseValidateUsers = useValidateUsers as jest.Mock; +const mockUseAssignTeamMembersRole = useAssignTeamMembersRole as jest.Mock; +const mockUseValidateUserPermissions = useValidateUserPermissions as jest.Mock; +const mockUseToastManager = useToastManager as jest.Mock; + +const mockShowToast = jest.fn(); +const mockShowErrorToast = jest.fn(); +const mockValidateMutateAsync = jest.fn(); +const mockAssignMutateAsync = jest.fn(); + +const defaultPermissionsData = [ + { action: 'content_libraries.manage_library_team', scope: 'lib:123', allowed: true }, + { action: 'courses.manage_course_team', scope: 'lib:123', allowed: false }, +]; + +const renderWizard = (props = {}) => renderWrapper( + , +); + +describe('AssignRoleWizard', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockUseValidateUsers.mockReturnValue({ + mutateAsync: mockValidateMutateAsync, + isPending: false, + }); + + mockUseAssignTeamMembersRole.mockReturnValue({ + mutateAsync: mockAssignMutateAsync, + isPending: false, + }); + + mockUseValidateUserPermissions.mockReturnValue({ + data: defaultPermissionsData, + }); + + mockUseToastManager.mockReturnValue({ + showToast: mockShowToast, + showErrorToast: mockShowErrorToast, + }); + }); + + it('renders the wizard with step 1 content', () => { + renderWizard(); + expect(screen.getByTestId('stepper')).toBeInTheDocument(); + expect(screen.getByTestId('users-input')).toBeInTheDocument(); + }); + + it('renders Cancel and Next buttons from step 1 action row', () => { + renderWizard(); + expect(screen.getByRole('button', { name: /Next/i })).toBeInTheDocument(); + // Cancel appears in both action rows + expect(screen.getAllByRole('button', { name: /Cancel/i })).toHaveLength(2); + }); + + it('Next button is disabled when users field is empty', () => { + renderWizard(); + expect(screen.getByRole('button', { name: /Next/i })).toBeDisabled(); + }); + + it('Next button is disabled when role is not selected', async () => { + const user = userEvent.setup(); + renderWizard(); + await user.type(screen.getByTestId('users-input'), 'alice'); + expect(screen.getByRole('button', { name: /Next/i })).toBeDisabled(); + }); + + it('Next button is enabled when users and role are both set', async () => { + const user = userEvent.setup(); + renderWizard(); + await user.type(screen.getByTestId('users-input'), 'alice'); + await user.click(screen.getByTestId('select-role')); + expect(screen.getByRole('button', { name: /Next/i })).not.toBeDisabled(); + }); + + it('calls validateUsers when Next is clicked with valid input', async () => { + const user = userEvent.setup(); + mockValidateMutateAsync.mockResolvedValue({ invalidUsers: [], validUsers: ['alice'] }); + + renderWizard(); + await user.type(screen.getByTestId('users-input'), 'alice'); + await user.click(screen.getByTestId('select-role')); + await user.click(screen.getByRole('button', { name: /Next/i })); + + await waitFor(() => { + expect(mockValidateMutateAsync).toHaveBeenCalledWith({ + data: { users: ['alice'] }, + }); + }); + }); + + it('shows invalid users in step 1 when validation returns invalid users', async () => { + const user = userEvent.setup(); + mockValidateMutateAsync.mockResolvedValue({ invalidUsers: ['baduser'], validUsers: [] }); + + renderWizard(); + await user.type(screen.getByTestId('users-input'), 'baduser'); + await user.click(screen.getByTestId('select-role')); + await user.click(screen.getByRole('button', { name: /Next/i })); + + await waitFor(() => { + expect(screen.getByTestId('invalid-users')).toBeInTheDocument(); + expect(screen.getByTestId('invalid-users')).toHaveTextContent('baduser'); + }); + }); + + it('shows validation error on network failure', async () => { + const user = userEvent.setup(); + mockValidateMutateAsync.mockRejectedValue(new Error('Network error')); + + renderWizard(); + await user.type(screen.getByTestId('users-input'), 'alice'); + await user.click(screen.getByTestId('select-role')); + await user.click(screen.getByRole('button', { name: /Next/i })); + + await waitFor(() => { + expect(screen.getByTestId('validation-error')).toBeInTheDocument(); + expect(screen.getByTestId('validation-error')).toHaveTextContent( + 'An error occurred while validating users. Please try again.', + ); + }); + }); + + it('clears invalid users when user edits the input', async () => { + const user = userEvent.setup(); + mockValidateMutateAsync.mockResolvedValue({ invalidUsers: ['baduser'], validUsers: [] }); + + renderWizard(); + await user.type(screen.getByTestId('users-input'), 'baduser'); + await user.click(screen.getByTestId('select-role')); + await user.click(screen.getByRole('button', { name: /Next/i })); + + await waitFor(() => { + expect(screen.getByTestId('invalid-users')).toBeInTheDocument(); + }); + + // Edit the input to clear invalid users + await user.type(screen.getByTestId('users-input'), 'x'); + await waitFor(() => { + expect(screen.queryByTestId('invalid-users')).not.toBeInTheDocument(); + }); + }); + + it('Cancel button calls onClose', async () => { + const user = userEvent.setup(); + const onClose = jest.fn(); + renderWizard({ onClose }); + + const cancelButtons = screen.getAllByRole('button', { name: /Cancel/i }); + await user.click(cancelButtons[0]); + expect(onClose).toHaveBeenCalled(); + }); + + it('Save button is disabled when no scopes selected', () => { + renderWizard(); + expect(screen.getByRole('button', { name: /Save/i })).toBeDisabled(); + }); + + it('Save button is enabled after a scope is toggled', async () => { + const user = userEvent.setup(); + renderWizard(); + await user.click(screen.getByTestId('toggle-scope')); + expect(screen.getByRole('button', { name: /Save/i })).not.toBeDisabled(); + }); + + it('calls assignRole for each scope on Save', async () => { + const user = userEvent.setup(); + mockValidateMutateAsync.mockResolvedValue({ invalidUsers: [], validUsers: ['alice'] }); + mockAssignMutateAsync.mockResolvedValue({ completed: [], errors: [] }); + + renderWizard(); + await user.type(screen.getByTestId('users-input'), 'alice'); + await user.click(screen.getByTestId('select-role')); + await user.click(screen.getByRole('button', { name: /Next/i })); + await waitFor(() => expect(mockValidateMutateAsync).toHaveBeenCalled()); + await user.click(screen.getByTestId('toggle-scope')); + await user.click(screen.getByRole('button', { name: /Save/i })); + + await waitFor(() => { + expect(mockAssignMutateAsync).toHaveBeenCalledWith({ + data: { users: ['alice'], role: 'library_admin', scope: 'lib:123' }, + }); + }); + }); + + it('shows success toast and calls onClose after successful save', async () => { + const user = userEvent.setup(); + const onClose = jest.fn(); + mockValidateMutateAsync.mockResolvedValue({ invalidUsers: [], validUsers: ['alice'] }); + mockAssignMutateAsync.mockResolvedValue({ completed: [], errors: [] }); + + renderWizard({ onClose }); + await user.type(screen.getByTestId('users-input'), 'alice'); + await user.click(screen.getByTestId('select-role')); + await user.click(screen.getByRole('button', { name: /Next/i })); + await waitFor(() => expect(mockValidateMutateAsync).toHaveBeenCalled()); + await user.click(screen.getByTestId('toggle-scope')); + await user.click(screen.getByRole('button', { name: /Save/i })); + + await waitFor(() => { + expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })); + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('shows error toast when save fails', async () => { + const user = userEvent.setup(); + const saveError = new Error('Server error'); + mockValidateMutateAsync.mockResolvedValue({ invalidUsers: [], validUsers: ['alice'] }); + mockAssignMutateAsync.mockRejectedValue(saveError); + + renderWizard(); + await user.type(screen.getByTestId('users-input'), 'alice'); + await user.click(screen.getByTestId('select-role')); + await user.click(screen.getByRole('button', { name: /Next/i })); + await waitFor(() => expect(mockValidateMutateAsync).toHaveBeenCalled()); + await user.click(screen.getByTestId('toggle-scope')); + await user.click(screen.getByRole('button', { name: /Save/i })); + + await waitFor(() => { + expect(mockShowErrorToast).toHaveBeenCalled(); + }); + }); + + it('initialUsers prop pre-fills the users field', async () => { + const user = userEvent.setup(); + renderWizard({ initialUsers: 'prefilled_user' }); + expect(screen.getByTestId('users-input')).toHaveValue('prefilled_user'); + // Next is still disabled without a role selected + expect(screen.getByRole('button', { name: /Next/i })).toBeDisabled(); + // Selecting a role enables Next since users are already pre-filled + await user.click(screen.getByTestId('select-role')); + expect(screen.getByRole('button', { name: /Next/i })).not.toBeDisabled(); + }); + + it('filters roles based on user permissions (library allowed, course not)', () => { + // Only library roles should be passed to SelectUsersAndRoleStep + // This is verified through the hook being called with permissionChecks + renderWizard(); + expect(mockUseValidateUserPermissions).toHaveBeenCalledWith([ + { action: 'content_libraries.manage_library_team', scope: 'lib:123' }, + { action: 'courses.manage_course_team', scope: 'lib:123' }, + ]); + }); + + it('toggles scope off when same scope is toggled again', async () => { + const user = userEvent.setup(); + mockAssignMutateAsync.mockResolvedValue({ completed: [], errors: [] }); + + renderWizard(); + // Toggle scope on + await user.click(screen.getByTestId('toggle-scope')); + expect(screen.getByRole('button', { name: /Save/i })).not.toBeDisabled(); + + // Toggle scope off + await user.click(screen.getByTestId('toggle-scope')); + expect(screen.getByRole('button', { name: /Save/i })).toBeDisabled(); + }); + + it('does not call validateUsers when users list is empty', async () => { + const user = userEvent.setup(); + renderWizard(); + // Only select role, no users + await user.click(screen.getByTestId('select-role')); + await user.click(screen.getByRole('button', { name: /Next/i })); + // Button should be disabled, so click won't trigger mutation + expect(mockValidateMutateAsync).not.toHaveBeenCalled(); + }); + + it('Back button is always rendered (step 2 action row)', () => { + renderWizard(); + // With mocked Stepper, both action rows are visible + expect(screen.getByRole('button', { name: /Back/i })).toBeInTheDocument(); + }); + + it('Next button is disabled when validateUsers mutation is pending', async () => { + mockUseValidateUsers.mockReturnValue({ + mutateAsync: mockValidateMutateAsync, + isPending: true, + }); + + renderWizard(); + expect(await screen.findByRole('button', { name: /Validating/i })).toBeDisabled(); + }); + + it('Save button is disabled when assignRole mutation is pending', async () => { + const user = userEvent.setup(); + mockUseAssignTeamMembersRole.mockReturnValue({ + mutateAsync: mockAssignMutateAsync, + isPending: true, + }); + + renderWizard(); + await user.click(screen.getByTestId('toggle-scope')); + expect(await screen.findByRole('button', { name: /Saving/i })).toBeDisabled(); + }); + + it('Back button navigates to step 1 when clicked', async () => { + const user = userEvent.setup(); + renderWizard(); + // Back button is always visible in mock; clicking it should not throw + await user.click(screen.getByRole('button', { name: /Back/i })); + // Step 1 content (users-input) remains in the document (mock renders all steps) + expect(screen.getByTestId('users-input')).toBeInTheDocument(); + }); + + it('clicking step 1 header sets active step to select-users-and-role', async () => { + const user = userEvent.setup(); + renderWizard(); + await user.click(screen.getByTestId('step-header-select-users-and-role')); + expect(screen.getByTestId('users-input')).toBeInTheDocument(); + }); + + it('clicking step 2 header sets active step to define-application-scope', async () => { + const user = userEvent.setup(); + renderWizard(); + await user.click(screen.getByTestId('step-header-define-application-scope')); + expect(screen.getByTestId('toggle-scope')).toBeInTheDocument(); + }); +}); diff --git a/src/authz-module/wizard/AssignRoleWizard.tsx b/src/authz-module/wizard/AssignRoleWizard.tsx new file mode 100644 index 00000000..44326d35 --- /dev/null +++ b/src/authz-module/wizard/AssignRoleWizard.tsx @@ -0,0 +1,224 @@ +import { + useState, useCallback, useEffect, useMemo, +} from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Stepper, Button, Container, StatefulButton, Icon, +} from '@openedx/paragon'; +import { SpinnerSimple } from '@openedx/paragon/icons'; +import SelectUsersAndRoleStep from './SelectUsersAndRoleStep'; +import DefineApplicationScopeStep from './DefineApplicationScopeStep'; +import { useValidateUsers, useAssignTeamMembersRole } from '../data/hooks'; +import { + CONTENT_LIBRARY_PERMISSIONS, COURSE_PERMISSIONS, courseRolesMetadata, libraryRolesMetadata, +} from '../constants'; +import { useToastManager } from '../libraries-manager/ToastManagerContext'; +import { useValidateUserPermissions } from '../../data/hooks'; +import messages from './messages'; + +const allRolesMetadata = [...courseRolesMetadata, ...libraryRolesMetadata]; + +const CONTEXT_BY_ACTION: Record = { + [CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM]: 'library', + [COURSE_PERMISSIONS.MANAGE_COURSE_TEAM]: 'course', +}; + +const STEPS = { + SELECT_USERS_AND_ROLE: 'select-users-and-role', + DEFINE_APPLICATION_SCOPE: 'define-application-scope', +} as const; + +type StepKey = typeof STEPS[keyof typeof STEPS]; + +interface AssignRoleWizardProps { + onClose: () => void; + scope: string; + initialUsers?: string; +} + +const AssignRoleWizard = ({ onClose, scope, initialUsers = '' }: AssignRoleWizardProps) => { + const intl = useIntl(); + const [activeStep, setActiveStep] = useState(STEPS.SELECT_USERS_AND_ROLE); + const [users, setUsers] = useState(initialUsers); + const [selectedRole, setSelectedRole] = useState(null); + const [selectedScopes, setSelectedScopes] = useState>(new Set()); + + const [validationError, setValidationError] = useState(null); + const [invalidUsers, setInvalidUsers] = useState([]); + const [validatedUsers, setValidatedUsers] = useState([]); + + const validateUsersMutation = useValidateUsers(); + const assignRoleMutation = useAssignTeamMembersRole(); + const { showToast, showErrorToast } = useToastManager(); + + // Filter role groups based on what the current user is allowed to manage + const permissionChecks = useMemo(() => [ + { action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, scope }, + { action: COURSE_PERMISSIONS.MANAGE_COURSE_TEAM, scope }, + ], [scope]); + + const { data: permissionsData } = useValidateUserPermissions(permissionChecks); + + // Clear highlights as soon as the user edits the input + useEffect(() => { + if (invalidUsers.length > 0) { + setInvalidUsers([]); + } + }, [users]); // eslint-disable-line react-hooks/exhaustive-deps + + const filteredRoles = useMemo(() => { + const allowedContextTypes = new Set( + permissionsData + .filter((p) => p.allowed) + .map((p) => CONTEXT_BY_ACTION[p.action]) + .filter(Boolean), + ); + return allRolesMetadata.filter((r) => allowedContextTypes.has(r.contextType || '')); + }, [permissionsData]); + + useEffect(() => { + if (filteredRoles.length === 0) { + onClose(); + } + }, [filteredRoles.length, onClose]); + + const handleClose = () => { + setActiveStep(STEPS.SELECT_USERS_AND_ROLE); + setUsers(''); + setSelectedRole(null); + setSelectedScopes(new Set()); + setValidationError(null); + setInvalidUsers([]); + setValidatedUsers([]); + onClose(); + }; + + const parseUsers = (input: string): string[] => input + .split(',') + .map((u) => u.trim()) + .filter(Boolean); + + const validateUsersAndProceed = async () => { + const usersList = parseUsers(users); + if (usersList.length === 0 || !selectedRole) { return; } + + setValidationError(null); + setInvalidUsers([]); + + try { + const result = await validateUsersMutation.mutateAsync({ data: { users: usersList } }); + if (result.invalidUsers?.length > 0) { + setInvalidUsers(result.invalidUsers); + } else { + setValidatedUsers(usersList); + setActiveStep(STEPS.DEFINE_APPLICATION_SCOPE); + } + } catch { + setValidationError(intl.formatMessage(messages['wizard.validate.error'])); + } + }; + + const handleScopeToggle = useCallback((scopeId: string) => { + setSelectedScopes((prev) => { + const next = new Set(prev); + if (next.has(scopeId)) { next.delete(scopeId); } else { next.add(scopeId); } + return next; + }); + }, []); + + const handleSave = async () => { + if (!selectedRole || selectedScopes.size === 0 || validatedUsers.length === 0) { return; } + + try { + await Promise.all( + Array.from(selectedScopes).map((selectedScope) => assignRoleMutation.mutateAsync({ + data: { users: validatedUsers, role: selectedRole, scope: selectedScope }, + })), + ); + showToast({ message: intl.formatMessage(messages['wizard.save.success']), type: 'success', delay: 5000 }); + handleClose(); + } catch (error: unknown) { + showErrorToast(error, handleSave); + } + }; + + const canProceed = users.trim() && selectedRole && !validateUsersMutation.isPending; + const canSave = selectedScopes.size > 0 && !assignRoleMutation.isPending; + + return ( + + + + + setActiveStep(STEPS.SELECT_USERS_AND_ROLE)} + eventKey={STEPS.SELECT_USERS_AND_ROLE} + title={intl.formatMessage(messages['wizard.step.selectUsersAndRole.title'])} + > + + + + setActiveStep(STEPS.DEFINE_APPLICATION_SCOPE)} + eventKey={STEPS.DEFINE_APPLICATION_SCOPE} + title={intl.formatMessage(messages['wizard.step.defineScope.title'])} + > + + + + +
+ + + + }} + state={validateUsersMutation.isPending ? 'pending' : 'default'} + onClick={validateUsersAndProceed} + disabled={!canProceed || validateUsersMutation.isPending} + /> + + + + + + + }} + state={assignRoleMutation.isPending ? 'pending' : 'default'} + onClick={handleSave} + disabled={!canSave} + /> + +
+
+ ); +}; + +export default AssignRoleWizard; diff --git a/src/authz-module/wizard/AssignRoleWizardPage.test.tsx b/src/authz-module/wizard/AssignRoleWizardPage.test.tsx new file mode 100644 index 00000000..b5fae1fd --- /dev/null +++ b/src/authz-module/wizard/AssignRoleWizardPage.test.tsx @@ -0,0 +1,113 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper } from '@src/setupTest'; +import { useLibrary } from '../data/hooks'; +import AssignRoleWizardPage from './AssignRoleWizardPage'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useSearchParams: jest.fn(), + useNavigate: jest.fn(), +})); + +jest.mock('../data/hooks', () => ({ + useLibrary: jest.fn(), +})); + +jest.mock('./AssignRoleWizard', () => ({ + __esModule: true, + default: ({ scope, initialUsers, onClose }: { scope: string; initialUsers?: string; onClose: () => void }) => ( +
+ + Assign Role Wizard +
+ ), +})); + +jest.mock('../components/AuthZLayout', () => ({ + __esModule: true, + default: ({ children, pageTitle }: { children: React.ReactNode; pageTitle: string }) => ( +
+

{pageTitle}

+ {children} +
+ ), +})); + +const mockUseLibrary = useLibrary as jest.Mock; + +const setupMocks = ({ + scope = 'lib:123', + users = '', + library = { + id: 'lib:123', title: 'Test Library', org: 'org', slug: 'test-lib', + }, +} = {}) => { + const { useSearchParams, useNavigate } = jest.requireMock('react-router-dom'); + useSearchParams.mockReturnValue([new URLSearchParams(`scope=${scope}&users=${users}`)]); + useNavigate.mockReturnValue(jest.fn()); + mockUseLibrary.mockReturnValue({ data: library }); +}; + +describe('AssignRoleWizardPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns null when scope is missing from search params', () => { + const { useSearchParams, useNavigate } = jest.requireMock('react-router-dom'); + useSearchParams.mockReturnValue([new URLSearchParams('')]); + useNavigate.mockReturnValue(jest.fn()); + mockUseLibrary.mockReturnValue({ data: null }); + + const { container } = renderWrapper(); + expect(container.firstChild).toBeNull(); + }); + + it('returns null when library data is not available', () => { + const { useSearchParams, useNavigate } = jest.requireMock('react-router-dom'); + useSearchParams.mockReturnValue([new URLSearchParams('scope=lib:123')]); + useNavigate.mockReturnValue(jest.fn()); + mockUseLibrary.mockReturnValue({ data: null }); + + const { container } = renderWrapper(); + expect(container.firstChild).toBeNull(); + }); + + it('renders the wizard when scope and library are present', () => { + setupMocks(); + renderWrapper(); + expect(screen.getByTestId('assign-role-wizard')).toBeInTheDocument(); + }); + + it('passes scope to the wizard', () => { + setupMocks({ scope: 'lib:456' }); + renderWrapper(); + expect(screen.getByTestId('assign-role-wizard')).toHaveAttribute('data-scope', 'lib:456'); + }); + + it('passes initialUsers from search params to the wizard', () => { + setupMocks({ users: 'alice,bob' }); + renderWrapper(); + expect(screen.getByTestId('assign-role-wizard')).toHaveAttribute('data-users', 'alice,bob'); + }); + + it('renders the layout with Assign Role title', () => { + setupMocks(); + renderWrapper(); + expect(screen.getByRole('heading', { name: 'Assign Role' })).toBeInTheDocument(); + }); + + it('navigates to team members path when wizard onClose is triggered', async () => { + setupMocks(); + const mockNavigate = jest.fn(); + const { useNavigate } = jest.requireMock('react-router-dom'); + useNavigate.mockReturnValue(mockNavigate); + + const user = userEvent.setup(); + renderWrapper(); + await user.click(screen.getByTestId('wizard-close')); + + expect(mockNavigate).toHaveBeenCalledWith('/authz/libraries/lib:123'); + }); +}); diff --git a/src/authz-module/wizard/AssignRoleWizardPage.tsx b/src/authz-module/wizard/AssignRoleWizardPage.tsx new file mode 100644 index 00000000..2b6df3d8 --- /dev/null +++ b/src/authz-module/wizard/AssignRoleWizardPage.tsx @@ -0,0 +1,44 @@ +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import AssignRoleWizard from './AssignRoleWizard'; +import AuthZLayout from '../components/AuthZLayout'; +import { useLibrary } from '../data/hooks'; +import { ROUTES } from '../constants'; +import messages from './messages'; + +const AssignRoleWizardPage = () => { + const navigate = useNavigate(); + const intl = useIntl(); + const [searchParams] = useSearchParams(); + const scope = searchParams.get('scope') || ''; + const initialUsers = searchParams.get('users') || ''; + + const { data: library } = useLibrary(scope); + + if (!scope || !library) { return null; } + + const teamMembersPath = `/authz${ROUTES.LIBRARIES_TEAM_PATH.replace(':libraryId', scope)}`; + + const handleCancel = () => { + navigate(teamMembersPath); + }; + + return ( + + + + ); +}; + +export default AssignRoleWizardPage; diff --git a/src/authz-module/wizard/DefineApplicationScopeStep.test.tsx b/src/authz-module/wizard/DefineApplicationScopeStep.test.tsx new file mode 100644 index 00000000..827aa020 --- /dev/null +++ b/src/authz-module/wizard/DefineApplicationScopeStep.test.tsx @@ -0,0 +1,467 @@ +import { screen, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper } from '@src/setupTest'; +import DefineApplicationScopeStep from './DefineApplicationScopeStep'; +import { useScopes, useOrganizations, useManagedScopeOrgs } from '../data/hooks'; + +jest.mock('../data/hooks', () => ({ + useScopes: jest.fn(), + useOrganizations: jest.fn(), + useManagedScopeOrgs: jest.fn(), +})); + +// IntersectionObserver is used for infinite scroll +const mockObserve = jest.fn(); +const mockDisconnect = jest.fn(); +beforeAll(() => { + window.IntersectionObserver = jest.fn().mockImplementation(() => ({ + observe: mockObserve, + disconnect: mockDisconnect, + unobserve: jest.fn(), + })); +}); + +const makeScopesHook = (overrides = {}) => ({ + data: { + pages: [{ + results: [], count: 0, next: null, previous: null, + }], + }, + fetchNextPage: jest.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + isError: false, + ...overrides, +}); + +const defaultOrgs = [ + { org: 'org1', name: 'Organization One' }, + { org: 'org2', name: 'Organization Two' }, +]; + +const defaultManagedOrgs = new Set(['org1', 'org2']); + +const defaultProps = { + selectedRole: 'library_admin', + selectedScopes: new Set(), + onScopeToggle: jest.fn(), +}; + +const renderComponent = (props = {}) => renderWrapper( + , +); + +describe('DefineApplicationScopeStep', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockObserve.mockClear(); + mockDisconnect.mockClear(); + (useScopes as jest.Mock).mockReturnValue(makeScopesHook()); + (useOrganizations as jest.Mock).mockReturnValue({ data: defaultOrgs }); + (useManagedScopeOrgs as jest.Mock).mockReturnValue({ data: defaultManagedOrgs }); + }); + + describe('Title and layout', () => { + it('renders the step title "Where It Applies"', () => { + renderComponent(); + expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Where It Applies'); + }); + + it('renders the search input', () => { + renderComponent(); + expect(screen.getByPlaceholderText('Search')).toBeInTheDocument(); + }); + + it('renders count display', () => { + renderComponent(); + expect(screen.getByText(/Showing 0 of 0/)).toBeInTheDocument(); + }); + }); + + describe('Context type filter badge', () => { + it('shows "Libraries" badge for a library role', () => { + renderComponent({ selectedRole: 'library_admin' }); + expect(screen.getByText('Filter applied:')).toBeInTheDocument(); + expect(screen.getByText('Libraries')).toBeInTheDocument(); + }); + + it('shows "Courses" badge for a course role', () => { + renderComponent({ selectedRole: 'course_admin' }); + expect(screen.getByText('Courses')).toBeInTheDocument(); + }); + + it('does not show filter badge when selectedRole is null', () => { + renderComponent({ selectedRole: null }); + expect(screen.queryByText('Filter applied:')).not.toBeInTheDocument(); + }); + }); + + describe('Loading state', () => { + it('shows loading spinner when isLoading is true', () => { + (useScopes as jest.Mock).mockReturnValue(makeScopesHook({ isLoading: true })); + renderComponent(); + expect(screen.getByText('Loading scopes...')).toBeInTheDocument(); + }); + + it('shows loading-more spinner when isFetchingNextPage is true', () => { + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ isFetchingNextPage: true }), + ); + renderComponent(); + expect(screen.getByText('Loading more...')).toBeInTheDocument(); + }); + }); + + describe('Empty state', () => { + it('shows "No scopes found." when there are no results', () => { + renderComponent(); + expect(screen.getByText('No scopes found.')).toBeInTheDocument(); + }); + }); + + describe('Scope list rendering', () => { + const makeScope = (id: string, name: string, org: string) => ({ + id, + name, + org, + contextType: 'library' as const, + }); + + it('renders platform-level scopes (no org) as standalone checkboxes', () => { + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ + data: { + pages: [{ + results: [makeScope('lib:platform', 'Platform Library', '')], + count: 1, + next: null, + previous: null, + }], + }, + }), + ); + renderComponent(); + expect(screen.getByLabelText('Platform Library')).toBeInTheDocument(); + }); + + it('renders scopes grouped by org in OrgSection', () => { + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ + data: { + pages: [{ + results: [makeScope('lib:org1/lib1', 'Library One', 'org1')], + count: 1, + next: null, + previous: null, + }], + }, + }), + ); + renderComponent(); + expect(screen.getByText('Org: Organization One')).toBeInTheDocument(); + expect(screen.getByLabelText('Library One')).toBeInTheDocument(); + }); + + it('checkbox is checked when scope is in selectedScopes', () => { + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ + data: { + pages: [{ + results: [makeScope('lib:org1/lib1', 'Library One', 'org1')], + count: 1, + next: null, + previous: null, + }], + }, + }), + ); + renderComponent({ selectedScopes: new Set(['lib:org1/lib1']) }); + expect(screen.getByLabelText('Library One')).toBeChecked(); + }); + + it('checkbox is unchecked when scope is not in selectedScopes', () => { + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ + data: { + pages: [{ + results: [makeScope('lib:org1/lib1', 'Library One', 'org1')], + count: 1, + next: null, + previous: null, + }], + }, + }), + ); + renderComponent({ selectedScopes: new Set() }); + expect(screen.getByLabelText('Library One')).not.toBeChecked(); + }); + + it('calls onScopeToggle with scope id when checkbox is clicked', async () => { + const onScopeToggle = jest.fn(); + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ + data: { + pages: [{ + results: [makeScope('lib:org1/lib1', 'Library One', 'org1')], + count: 1, + next: null, + previous: null, + }], + }, + }), + ); + renderComponent({ onScopeToggle }); + const checkbox = screen.getByLabelText('Library One'); + await userEvent.click(checkbox); + expect(onScopeToggle).toHaveBeenCalledWith('lib:org1/lib1'); + }); + + it('shows scope description when present', () => { + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ + data: { + pages: [{ + results: [{ ...makeScope('lib:org1/lib1', 'Library One', 'org1'), description: 'A test description' }], + count: 1, + next: null, + previous: null, + }], + }, + }), + ); + renderComponent(); + expect(screen.getByText('A test description')).toBeInTheDocument(); + }); + + it('shows correct count "Showing X of Y"', () => { + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ + data: { + pages: [{ + results: [makeScope('lib:org1/lib1', 'Library One', 'org1')], + count: 5, + next: null, + previous: null, + }], + }, + }), + ); + renderComponent(); + expect(screen.getByText('Showing 1 of 5.')).toBeInTheDocument(); + }); + }); + + describe('Aggregate scope items', () => { + const makeScope = (id: string, name: string, org: string) => ({ + id, name, org, contextType: 'library' as const, + }); + + it('shows org aggregate option when org is in managedOrgs and contextType is set', () => { + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ + data: { + pages: [{ + results: [makeScope('lib:org1/lib1', 'Library One', 'org1')], + count: 1, + next: null, + previous: null, + }], + }, + }), + ); + // org1 is in managedOrgs (Set(['org1', 'org2'])) + renderComponent({ selectedRole: 'library_admin' }); + expect(screen.getByText('All libraries in this organization')).toBeInTheDocument(); + }); + + it('does not show org aggregate when org is not in managedOrgs', () => { + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ + data: { + pages: [{ + results: [makeScope('lib:org3/lib1', 'Library One', 'org3')], + count: 1, + next: null, + previous: null, + }], + }, + }), + ); + (useManagedScopeOrgs as jest.Mock).mockReturnValue({ data: new Set(['org1']) }); + renderComponent({ selectedRole: 'library_admin' }); + expect(screen.queryByText('All libraries in this organization')).not.toBeInTheDocument(); + }); + + it('shows platform aggregate when all orgs are managed', () => { + (useScopes as jest.Mock).mockReturnValue(makeScopesHook()); + // All orgs in defaultOrgs (org1, org2) are in managedOrgs (org1, org2) + (useManagedScopeOrgs as jest.Mock).mockReturnValue({ data: new Set(['org1', 'org2']) }); + renderComponent({ selectedRole: 'library_admin' }); + expect(screen.getByText('All libraries in Platform')).toBeInTheDocument(); + }); + + it('does not show platform aggregate when some org is not managed', () => { + (useManagedScopeOrgs as jest.Mock).mockReturnValue({ data: new Set(['org1']) }); + // org2 is not in managedOrgs so hasPlatformPermission is false + renderComponent({ selectedRole: 'library_admin' }); + expect(screen.queryByText('All libraries in Platform')).not.toBeInTheDocument(); + }); + + it('does not show platform aggregate when selectedRole is null', () => { + renderComponent({ selectedRole: null }); + expect(screen.queryByText('All libraries in Platform')).not.toBeInTheDocument(); + }); + + it('shows "All courses in Platform" for course context type', () => { + (useManagedScopeOrgs as jest.Mock).mockReturnValue({ data: new Set(['org1', 'org2']) }); + renderComponent({ selectedRole: 'course_admin' }); + expect(screen.getByText('All courses in Platform')).toBeInTheDocument(); + }); + }); + + describe('Organization dropdown', () => { + it('renders organization dropdown button', () => { + renderComponent(); + expect(screen.getByText('Organization')).toBeInTheDocument(); + }); + + it('shows all organizations in dropdown', async () => { + renderComponent(); + const toggle = screen.getByText('Organization'); + await userEvent.click(toggle); + await waitFor(() => { + expect(screen.getByText('Organization One')).toBeInTheDocument(); + expect(screen.getByText('Organization Two')).toBeInTheDocument(); + expect(screen.getByText('All Organizations')).toBeInTheDocument(); + }); + }); + + it('updates org filter when organization is selected', async () => { + renderComponent(); + const toggle = screen.getByText('Organization'); + await userEvent.click(toggle); + await waitFor(() => screen.getByText('Organization One')); + await userEvent.click(screen.getByText('Organization One')); + // After selecting org1, useScopes is called with org: 'org1' + expect(useScopes).toHaveBeenLastCalledWith(expect.objectContaining({ org: 'org1' })); + }); + + it('clears org filter when "All Organizations" is selected', async () => { + renderComponent(); + const toggle = screen.getByText('Organization'); + await userEvent.click(toggle); + await waitFor(() => screen.getByText('All Organizations')); + await userEvent.click(screen.getByText('All Organizations')); + expect(useScopes).toHaveBeenLastCalledWith(expect.objectContaining({ org: undefined })); + }); + }); + + describe('Search input', () => { + it('updates search state when typing', () => { + renderComponent(); + const searchInput = screen.getByPlaceholderText('Search'); + fireEvent.change(searchInput, { target: { value: 'mylib' } }); + expect(searchInput).toHaveValue('mylib'); + }); + }); + + describe('OrgSection collapse/expand', () => { + const makeScope = (id: string, name: string, org: string) => ({ + id, name, org, contextType: 'library' as const, + }); + + it('starts expanded and collapses when header clicked', async () => { + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ + data: { + pages: [{ + results: [makeScope('lib:org1/lib1', 'Library One', 'org1')], + count: 1, + next: null, + previous: null, + }], + }, + }), + ); + renderComponent(); + + // Initially visible + expect(screen.getByLabelText('Library One')).toBeInTheDocument(); + + // Click the org header button to collapse + const orgHeader = screen.getByText('Org: Organization One').closest('button')!; + await userEvent.click(orgHeader); + + // Now the scope should be hidden + expect(screen.queryByLabelText('Library One')).not.toBeInTheDocument(); + }); + + it('expands again after second click', async () => { + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ + data: { + pages: [{ + results: [makeScope('lib:org1/lib1', 'Library One', 'org1')], + count: 1, + next: null, + previous: null, + }], + }, + }), + ); + renderComponent(); + + const orgHeader = screen.getByText('Org: Organization One').closest('button')!; + await userEvent.click(orgHeader); + await userEvent.click(orgHeader); + + expect(screen.getByLabelText('Library One')).toBeInTheDocument(); + }); + }); + + describe('IntersectionObserver for infinite scroll', () => { + it('sets up IntersectionObserver on the load-more div', () => { + renderComponent(); + expect(window.IntersectionObserver).toHaveBeenCalled(); + expect(mockObserve).toHaveBeenCalled(); + }); + + it('calls fetchNextPage when intersection is triggered with hasNextPage=true', () => { + const fetchNextPage = jest.fn(); + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ hasNextPage: true, fetchNextPage }), + ); + + let intersectionCallback: (entries: IntersectionObserverEntry[]) => void; + (window.IntersectionObserver as jest.Mock).mockImplementation((cb) => { + intersectionCallback = cb; + return { observe: mockObserve, disconnect: mockDisconnect, unobserve: jest.fn() }; + }); + + renderComponent(); + + // Simulate intersection + intersectionCallback!([{ isIntersecting: true } as IntersectionObserverEntry]); + expect(fetchNextPage).toHaveBeenCalled(); + }); + + it('does not call fetchNextPage when hasNextPage is false', () => { + const fetchNextPage = jest.fn(); + (useScopes as jest.Mock).mockReturnValue( + makeScopesHook({ hasNextPage: false, fetchNextPage }), + ); + + let intersectionCallback: (entries: IntersectionObserverEntry[]) => void; + (window.IntersectionObserver as jest.Mock).mockImplementation((cb) => { + intersectionCallback = cb; + return { observe: mockObserve, disconnect: mockDisconnect, unobserve: jest.fn() }; + }); + + renderComponent(); + + intersectionCallback!([{ isIntersecting: true } as IntersectionObserverEntry]); + expect(fetchNextPage).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/authz-module/wizard/DefineApplicationScopeStep.tsx b/src/authz-module/wizard/DefineApplicationScopeStep.tsx new file mode 100644 index 00000000..b8d4fedf --- /dev/null +++ b/src/authz-module/wizard/DefineApplicationScopeStep.tsx @@ -0,0 +1,352 @@ +import { + useState, useEffect, useRef, useMemo, +} from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Form, Spinner, Dropdown, Icon, Badge, + Stack, +} from '@openedx/paragon'; +import { + Search, FilterList, ExpandLess, ExpandMore, +} from '@openedx/paragon/icons'; +import { useScopes, useOrganizations, useManagedScopeOrgs } from '../data/hooks'; +import { courseRolesMetadata, libraryRolesMetadata } from '../constants'; +import { ScopeItem } from '../data/api'; +import messages from './messages'; + +const allRolesMetadata = [...courseRolesMetadata, ...libraryRolesMetadata]; + +function getContextType(role: string | null): string | undefined { + if (!role) { return undefined; } + return allRolesMetadata.find((r) => r.role === role)?.contextType; +} + +function getContextLabel(contextType: string | undefined): string { + if (contextType === 'course') { return 'Courses'; } + if (contextType === 'library') { return 'Libraries'; } + return 'Items'; +} + +interface ScopeCheckboxItemProps { + scope: ScopeItem; + checked: boolean; + onToggle: (scopeId: string) => void; +} + +const ScopeCheckboxItem = ({ scope, checked, onToggle }: ScopeCheckboxItemProps) => ( +
+ onToggle(scope.id)} + style={{ + width: '16px', height: '16px', marginTop: '2px', flexShrink: 0, cursor: 'pointer', + }} + /> + +
+); + +interface OrgSectionProps { + orgName: string; + scopes: ScopeItem[]; + selectedScopes: Set; + onScopeToggle: (scopeId: string) => void; + aggregateScopeItem?: ScopeItem; +} + +const OrgSection = ({ + orgName, scopes, selectedScopes, onScopeToggle, aggregateScopeItem, +}: OrgSectionProps) => { + const [collapsed, setCollapsed] = useState(false); + + return ( +
+ + + {!collapsed && ( +
+ {aggregateScopeItem && ( + + )} + {scopes.map((scope) => ( + + ))} +
+ )} +
+ ); +}; + +interface DefineApplicationScopeStepProps { + selectedRole: string | null; + selectedScopes: Set; + onScopeToggle: (scopeId: string) => void; +} + +const DefineApplicationScopeStep = ({ + selectedRole, + selectedScopes, + onScopeToggle, +}: DefineApplicationScopeStepProps) => { + const intl = useIntl(); + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [selectedOrg, setSelectedOrg] = useState(''); + const loadMoreRef = useRef(null); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(search), 300); + return () => clearTimeout(timer); + }, [search]); + + const contextType = getContextType(selectedRole); + const contextLabel = getContextLabel(contextType); + + const { + data: scopesData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = useScopes({ + contextType, + search: debouncedSearch || undefined, + org: selectedOrg || undefined, + }); + + const { data: organizations } = useOrganizations(contextType); + const { data: managedOrgs } = useManagedScopeOrgs(contextType); + + const allowedOrgAggregates = managedOrgs ?? new Set(); + + const hasPlatformPermission = !!organizations?.length + && !!managedOrgs + && organizations.every((o) => managedOrgs.has(o.org)); + + const allScopes = useMemo( + () => scopesData?.pages.flatMap((page) => page.results) ?? [], + [scopesData], + ); + + const totalCount = scopesData?.pages[0]?.count ?? 0; + + const platformScopes = useMemo( + () => allScopes.filter((s) => !s.org), + [allScopes], + ); + + const scopesByOrg = useMemo(() => { + const orgScopes = allScopes.filter((s) => !!s.org); + const grouped = orgScopes.reduce>((acc, scope) => { + if (!acc[scope.org]) { acc[scope.org] = []; } + acc[scope.org].push(scope); + return acc; + }, {}); + + Object.keys(grouped).forEach((org) => { + grouped[org].sort((a, b) => { + const aIsAll = a.name.startsWith('All ') ? 0 : 1; + const bIsAll = b.name.startsWith('All ') ? 0 : 1; + return aIsAll - bIsAll; + }); + }); + return grouped; + }, [allScopes]); + + const orderedOrgs = useMemo(() => Object.keys(scopesByOrg), [scopesByOrg]); + + useEffect(() => { + const el = loadMoreRef.current; + if (!el) { return undefined; } + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage && !isError) { + fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, isError, fetchNextPage]); + + const selectedOrgLabel = organizations?.find((o) => o.org === selectedOrg)?.name + || organizations?.find((o) => o.org === selectedOrg)?.org + || 'Organization'; + + const aggregateLabel = contextType === 'course' + ? 'All courses in this organization' + : 'All libraries in this organization'; + + const aggregateDescription = contextType === 'course' + ? 'Includes current and future courses' + : 'Includes current and future libraries'; + + const platformAggregateLabel = contextType === 'course' + ? 'All courses in Platform' + : 'All libraries in Platform'; + + const platformAggregateScopeItem: ScopeItem | null = (hasPlatformPermission && contextType) + ? { + id: '*', + name: platformAggregateLabel, + description: aggregateDescription, + org: '', + contextType: contextType as 'course' | 'library', + } + : null; + + return ( +
+

{intl.formatMessage(messages['wizard.step.defineScope.title'])}

+ + {/* Search + Organization filter + count */} +
+
+
+ + setSearch(e.target.value)} + placeholder="Search" + trailingElement={} + /> + +
+ + + + + {selectedOrg ? selectedOrgLabel : 'Organization'} + + + setSelectedOrg('')} active={!selectedOrg}> + All Organizations + + {organizations?.map((org) => ( + setSelectedOrg(org.org)} + active={selectedOrg === org.org} + > + {org.name || org.org} + + ))} + + +
+ + + Showing {allScopes.length} of {totalCount}. + +
+ + {/* Active filter chip */} + {contextType && ( + + Filter applied: + + {contextLabel} + + + )} + +
+ + {/* Scopes list */} +
+ {isLoading ? ( +
+ +
+ ) : ( + <> + {/* Platform-wide aggregate option (permission-gated) */} + {platformAggregateScopeItem && ( + + )} + + {platformScopes.map((scope) => ( + + ))} + + {orderedOrgs.map((org) => { + const aggregateScopeItem: ScopeItem | undefined = (allowedOrgAggregates.has(org) && contextType) + ? { + id: `org:${org}`, + name: aggregateLabel, + description: aggregateDescription, + org, + contextType: contextType as 'course' | 'library', + } + : undefined; + + return ( + o.org === org)?.name || org} + scopes={scopesByOrg[org]} + selectedScopes={selectedScopes} + onScopeToggle={onScopeToggle} + aggregateScopeItem={aggregateScopeItem} + /> + ); + })} + + {allScopes.length === 0 && ( +

No scopes found.

+ )} + +
+ + {isFetchingNextPage && ( +
+ +
+ )} + + )} +
+
+ ); +}; + +export default DefineApplicationScopeStep; diff --git a/src/authz-module/wizard/HighlightedUsersInput.test.tsx b/src/authz-module/wizard/HighlightedUsersInput.test.tsx new file mode 100644 index 00000000..159e72c2 --- /dev/null +++ b/src/authz-module/wizard/HighlightedUsersInput.test.tsx @@ -0,0 +1,108 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import HighlightedUsersInput from './HighlightedUsersInput'; + +const defaultProps = { + value: '', + onChange: jest.fn(), + invalidUsers: [], + placeholder: 'Enter usernames', +}; + +describe('HighlightedUsersInput', () => { + beforeEach(() => jest.clearAllMocks()); + + it('renders a textarea', () => { + render(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('does not render the overlay when no invalidUsers', () => { + const { container } = render(); + expect(container.querySelector('[aria-hidden="true"]')).not.toBeInTheDocument(); + }); + + it('renders the overlay when there are invalidUsers', () => { + const { container } = render( + , + ); + expect(container.querySelector('[aria-hidden="true"]')).toBeInTheDocument(); + }); + + it('highlights invalid user parts in red', () => { + const { container } = render( + , + ); + const overlay = container.querySelector('[aria-hidden="true"]'); + const spans = overlay!.querySelectorAll('span'); + const redSpan = Array.from(spans).find((s) => (s as HTMLElement).style.color === 'rgb(198, 40, 40)'); + expect(redSpan).toBeDefined(); + expect(redSpan!.textContent).toContain('baduser'); + }); + + it('renders valid user parts in dark color', () => { + const { container } = render( + , + ); + const overlay = container.querySelector('[aria-hidden="true"]'); + const spans = overlay!.querySelectorAll('span'); + const darkSpan = Array.from(spans).find( + (s) => (s as HTMLElement).style.color === 'rgb(33, 37, 41)' && s.textContent?.includes('jdoe'), + ); + expect(darkSpan).toBeDefined(); + }); + + it('shows placeholder when no invalid users', () => { + render(); + expect(screen.getByPlaceholderText('Enter usernames')).toBeInTheDocument(); + }); + + it('hides placeholder when overlay is active', () => { + render( + , + ); + expect(screen.queryByPlaceholderText('Enter usernames')).not.toBeInTheDocument(); + }); + + it('makes textarea text transparent when overlay is active', () => { + const { container } = render( + , + ); + const textarea = container.querySelector('textarea'); + expect(textarea!.style.color).toBe('transparent'); + }); + + it('adds is-invalid class when hasNetworkError is true', () => { + const { container } = render( + , + ); + expect(container.querySelector('textarea')).toHaveClass('is-invalid'); + }); + + it('does not add is-invalid class when hasNetworkError is false', () => { + const { container } = render( + , + ); + expect(container.querySelector('textarea')).not.toHaveClass('is-invalid'); + }); + + it('calls onChange when user types', async () => { + const handleChange = jest.fn(); + render(); + await userEvent.type(screen.getByRole('textbox'), 'alice'); + expect(handleChange).toHaveBeenCalled(); + }); + + it('syncs overlay scroll on textarea scroll', () => { + const { container } = render( + , + ); + const textarea = container.querySelector('textarea')!; + const overlay = container.querySelector('[aria-hidden="true"]') as HTMLElement; + + Object.defineProperty(textarea, 'scrollTop', { value: 50, writable: true }); + fireEvent.scroll(textarea, { target: { scrollTop: 50 } }); + + expect(overlay.scrollTop).toBe(50); + }); +}); diff --git a/src/authz-module/wizard/HighlightedUsersInput.tsx b/src/authz-module/wizard/HighlightedUsersInput.tsx new file mode 100644 index 00000000..e8a3f5ba --- /dev/null +++ b/src/authz-module/wizard/HighlightedUsersInput.tsx @@ -0,0 +1,106 @@ +import { useMemo, useRef } from 'react'; + +// Shared styles between overlay and textarea to keep text positions in sync +const INPUT_STYLE: React.CSSProperties = { + fontFamily: 'inherit', + fontSize: '1rem', + lineHeight: '1.5', + padding: '0.375rem 0.75rem', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + overflowWrap: 'break-word', + boxSizing: 'border-box', + width: '100%', +}; + +interface HighlightedUsersInputProps { + value: string; + onChange: (val: string) => void; + invalidUsers: string[]; + placeholder?: string; + hasNetworkError?: boolean; +} + +const HighlightedUsersInput = ({ + value, onChange, invalidUsers, placeholder, hasNetworkError = false, +}: HighlightedUsersInputProps) => { + const overlayRef = useRef(null); + + const invalidSet = useMemo( + () => new Set(invalidUsers.map((u) => u.trim())), + [invalidUsers], + ); + const hasHighlights = invalidSet.size > 0; + + const renderedParts = useMemo(() => { + if (!hasHighlights) { return null; } + let offset = 0; + return value.split(/(,)/).map((part) => { + const key = offset; + offset += part.length; + if (part === ',') { return ,; } + const trimmed = part.trim(); + const isInvalid = trimmed.length > 0 && invalidSet.has(trimmed); + return ( + + {part} + + ); + }); + }, [value, invalidSet, hasHighlights]); + + const handleScroll = (e: React.UIEvent) => { + if (overlayRef.current) { + overlayRef.current.scrollTop = e.currentTarget.scrollTop; + } + }; + + return ( +
+ {/* Highlight layer — sits behind the transparent textarea */} + {hasHighlights && ( + + )} + + {/* Actual textarea — text is transparent when overlay is active */} +