From 48da92633a6f9c4200bec708d1f4e433f2b64135 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Tue, 21 Apr 2026 08:11:59 -0500 Subject: [PATCH] feat(authz): add role assignment wizard and normalize resource folder naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a multi-step AssignRoleWizard with DefineApplicationScopeStep, SelectUsersAndRoleStep, and HighlightedUsersInput components. Singularize roles-permissions subfolders (courses → course, libraries → library) for naming consistency. Update hooks, API, constants, and related tests. --- src/authz-module/authz-home/index.test.tsx | 2 +- .../components/AddRoleButton.test.tsx | 6 +- src/authz-module/components/AddRoleButton.tsx | 10 +- .../components/PermissionTable.test.tsx | 3 + src/authz-module/constants.test.ts | 34 +++ src/authz-module/constants.ts | 31 ++- src/authz-module/data/api.ts | 23 ++ src/authz-module/data/hooks.test.tsx | 215 +++++++++++++----- src/authz-module/data/hooks.ts | 15 ++ src/authz-module/index.scss | 37 +++ src/authz-module/index.tsx | 2 + .../LibrariesTeamManager.test.tsx | 9 +- .../LibrariesTeamManager.tsx | 8 +- .../LibrariesUserManager.test.tsx | 20 ++ .../LibrariesUserManager.tsx | 13 +- .../libraries-manager/context.tsx | 3 +- .../libraries-manager/utils.test.ts | 7 +- .../AssignRoleWizard.test.tsx | 163 +++++++++++++ .../AssignRoleWizard.tsx | 205 +++++++++++++++++ .../AssignRoleWizardPage.test.tsx | 87 +++++++ .../AssignRoleWizardPage.tsx | 35 +++ .../DefineApplicationScopeStep.test.tsx | 26 +++ .../components/DefineApplicationScopeStep.tsx | 14 ++ .../components/HighlightedUsersInput.test.tsx | 106 +++++++++ .../components/HighlightedUsersInput.tsx | 92 ++++++++ .../SelectUsersAndRoleStep.test.tsx | 130 +++++++++++ .../components/SelectUsersAndRoleStep.tsx | 135 +++++++++++ .../role-assignation-wizard/messages.ts | 139 +++++++++++ .../RolesPermissions.test.tsx | 6 +- .../roles-permissions/RolesPermissions.tsx | 8 +- .../{courses => course}/constants.ts | 21 +- .../{libraries => library}/constants.ts | 20 +- .../{libraries => library}/messages.ts | 0 .../{libraries => library}/utils.test.ts | 7 +- .../{libraries => library}/utils.ts | 0 src/setupTest.tsx | 3 + src/types.ts | 3 +- 37 files changed, 1527 insertions(+), 111 deletions(-) create mode 100644 src/authz-module/constants.test.ts create mode 100644 src/authz-module/role-assignation-wizard/AssignRoleWizard.test.tsx create mode 100644 src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx create mode 100644 src/authz-module/role-assignation-wizard/AssignRoleWizardPage.test.tsx create mode 100644 src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx create mode 100644 src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.test.tsx create mode 100644 src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.tsx create mode 100644 src/authz-module/role-assignation-wizard/components/HighlightedUsersInput.test.tsx create mode 100644 src/authz-module/role-assignation-wizard/components/HighlightedUsersInput.tsx create mode 100644 src/authz-module/role-assignation-wizard/components/SelectUsersAndRoleStep.test.tsx create mode 100644 src/authz-module/role-assignation-wizard/components/SelectUsersAndRoleStep.tsx create mode 100644 src/authz-module/role-assignation-wizard/messages.ts rename src/authz-module/roles-permissions/{courses => course}/constants.ts (94%) rename src/authz-module/roles-permissions/{libraries => library}/constants.ts (93%) rename src/authz-module/roles-permissions/{libraries => library}/messages.ts (100%) rename src/authz-module/roles-permissions/{libraries => library}/utils.test.ts (93%) rename src/authz-module/roles-permissions/{libraries => library}/utils.ts (100%) diff --git a/src/authz-module/authz-home/index.test.tsx b/src/authz-module/authz-home/index.test.tsx index d1526a23..4f186aa7 100644 --- a/src/authz-module/authz-home/index.test.tsx +++ b/src/authz-module/authz-home/index.test.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { screen } from '@testing-library/react'; import { useAllRoleAssignments, useOrgs, useScopes } from '@src/authz-module/data/hooks'; -import { ToastManagerProvider } from '@src/components/ToastManager/ToastManagerContext'; import { renderWithAllProviders } from '@src/setupTest'; import userEvent from '@testing-library/user-event'; +import { ToastManagerProvider } from '@src/components/ToastManager/ToastManagerContext'; import AuthzHome from './index'; import messages from './messages'; diff --git a/src/authz-module/components/AddRoleButton.test.tsx b/src/authz-module/components/AddRoleButton.test.tsx index 7765faf4..facfc718 100644 --- a/src/authz-module/components/AddRoleButton.test.tsx +++ b/src/authz-module/components/AddRoleButton.test.tsx @@ -76,7 +76,7 @@ describe('AddRoleButton', () => { await user.click(button); expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith(`/authz/assign-role?username=${presetUsername}`); + expect(mockNavigate).toHaveBeenCalledWith(`/authz/assign-role?users=${presetUsername}`); }); it('handles special characters in presetUsername correctly', async () => { @@ -88,7 +88,7 @@ describe('AddRoleButton', () => { await user.click(button); expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockNavigate).toHaveBeenCalledWith(`/authz/assign-role?username=${presetUsername}`); + expect(mockNavigate).toHaveBeenCalledWith(`/authz/assign-role?${new URLSearchParams({ users: presetUsername }).toString()}`); }); }); @@ -128,7 +128,7 @@ describe('AddRoleButton', () => { await user.click(button); expect(mockNavigate).toHaveBeenCalledTimes(3); - expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role?username=testuser'); + expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role?users=testuser'); }); }); }); diff --git a/src/authz-module/components/AddRoleButton.tsx b/src/authz-module/components/AddRoleButton.tsx index 8a44e750..6433741b 100644 --- a/src/authz-module/components/AddRoleButton.tsx +++ b/src/authz-module/components/AddRoleButton.tsx @@ -2,21 +2,23 @@ import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Button } from '@openedx/paragon'; import { Plus } from '@openedx/paragon/icons'; +import { useNavigate } from 'react-router-dom'; import baseMessages from '@src/authz-module/messages'; -import { useNavigate } from 'react-router-dom'; +import { buildWizardPath } from '@src/authz-module/constants'; interface AddRoleButtonProps { presetUsername?: string; + from?: string; } -const AddRoleButton = ({ presetUsername }: AddRoleButtonProps) => { +const AddRoleButton = ({ presetUsername, from }: AddRoleButtonProps) => { const intl = useIntl(); const navigate = useNavigate(); const handleClick = () => { - const assignRolePath = `/authz/assign-role${presetUsername ? `?username=${presetUsername}` : ''}`; - navigate(assignRolePath); + const path = buildWizardPath({ from, users: presetUsername }); + navigate(path); }; return ( diff --git a/src/authz-module/components/PermissionTable.test.tsx b/src/authz-module/components/PermissionTable.test.tsx index 25e96218..5946e90b 100644 --- a/src/authz-module/components/PermissionTable.test.tsx +++ b/src/authz-module/components/PermissionTable.test.tsx @@ -10,6 +10,7 @@ const mockRoles: Role[] = [ userCount: 0, permissions: [], role: '', + contextType: '', }, { name: 'Editor', @@ -17,6 +18,7 @@ const mockRoles: Role[] = [ userCount: 0, permissions: [], role: '', + contextType: '', }, { name: 'Viewer', @@ -24,6 +26,7 @@ const mockRoles: Role[] = [ userCount: 0, permissions: [], role: '', + contextType: '', }, ]; diff --git a/src/authz-module/constants.test.ts b/src/authz-module/constants.test.ts new file mode 100644 index 00000000..4ba99ad1 --- /dev/null +++ b/src/authz-module/constants.test.ts @@ -0,0 +1,34 @@ +import { buildWizardPath, ROUTES } from './constants'; + +const BASE = `${ROUTES.HOME_PATH}${ROUTES.ASSIGN_ROLE_WIZARD_PATH}`; + +describe('buildWizardPath', () => { + it('returns the base path when called with no arguments', () => { + expect(buildWizardPath()).toBe(BASE); + }); + + it('returns the base path when called with an empty options object', () => { + expect(buildWizardPath({})).toBe(BASE); + }); + + it('appends ?users= when only users is provided', () => { + expect(buildWizardPath({ users: 'alice' })).toBe(`${BASE}?users=alice`); + }); + + it('appends ?from= when only from is provided', () => { + expect(buildWizardPath({ from: '/authz/libraries/lib:123/alice' })) + .toBe(`${BASE}?from=%2Fauthz%2Flibraries%2Flib%3A123%2Falice`); + }); + + it('appends both users and from when both are provided', () => { + const result = buildWizardPath({ users: 'alice', from: '/authz/libraries/lib:123/alice' }); + const parsed = new URL(result, 'http://x'); + expect(parsed.pathname).toBe(BASE); + expect(parsed.searchParams.get('users')).toBe('alice'); + expect(parsed.searchParams.get('from')).toBe('/authz/libraries/lib:123/alice'); + }); + + it('omits the query string when users and from are both empty strings', () => { + expect(buildWizardPath({ users: '', from: '' })).toBe(BASE); + }); +}); diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index f94c2c56..4aeff139 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -1,4 +1,4 @@ -import { PermissionMetadata, ResourceMetadata, RoleMetadata } from 'types'; +import { PermissionMetadata, ResourceMetadata } from 'types'; export const CONTENT_LIBRARY_PERMISSIONS = { DELETE_LIBRARY: 'content_libraries.delete_library', @@ -62,15 +62,6 @@ export const CONTENT_COURSE_PERMISSIONS = { VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS: 'courses.view_global_staff_and_superadmins', }; -// 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.' }, @@ -105,9 +96,21 @@ export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({ })); export const ROUTES = { + HOME_PATH: '/authz', LIBRARIES_TEAM_PATH: '/libraries/:libraryId', LIBRARIES_USER_PATH: '/libraries/:libraryId/:username', AUDIT_USER_PATH: '/user/:username', + ASSIGN_ROLE_WIZARD_PATH: '/assign-role', +}; + +export const buildWizardPath = (options?: { users?: string; from?: string }) => { + const base = `${ROUTES.HOME_PATH}${ROUTES.ASSIGN_ROLE_WIZARD_PATH}`; + if (!options) { return base; } + const params = new URLSearchParams(); + if (options.users) { params.set('users', options.users); } + if (options.from) { params.set('from', options.from); } + const query = params.toString(); + return query ? `${base}?${query}` : base; }; export enum RoleOperationErrorStatus { @@ -141,3 +144,11 @@ export const TABLE_DEFAULT_PAGE_SIZE = 10; export const DEFAULT_FILTER_PAGE_SIZE = 5; export const ADMIN_ROLES = ['course_admin', 'library_admin']; + +// Resource Type Definitions +export const RESOURCE_TYPES = { + LIBRARY: 'library', + COURSE: 'course', +} as const; + +export type ResourceType = typeof RESOURCE_TYPES[keyof typeof RESOURCE_TYPES]; diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index a2c1d477..2ea23d88 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -83,6 +83,19 @@ export interface GetScopesResponse { previous: string | null; results:Array; } +export type ValidateUsersRequest = { + users: string[]; +}; + +export type ValidateUsersResponse = { + validUsers: string[]; + invalidUsers: string[]; + summary: { + total: number; + validCount: number; + invalidCount: number; + }; +}; export const getTeamMembers = async (object: string, querySettings: QuerySettings): Promise => { const url = new URL(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`)); @@ -111,6 +124,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}/`)); diff --git a/src/authz-module/data/hooks.test.tsx b/src/authz-module/data/hooks.test.tsx index 0736be91..3e966ae1 100644 --- a/src/authz-module/data/hooks.test.tsx +++ b/src/authz-module/data/hooks.test.tsx @@ -12,12 +12,26 @@ import { useOrgs, useScopes, useUserAssignedRoles, + useValidateUsers, } from './hooks'; 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, +})); + +jest.mock('@src/constants', () => ({ + appId: 'test-app', +})); + const mockMembers = { count: 2, results: [ @@ -202,6 +216,42 @@ describe('useTeamMembers', () => { expect(result.current.data).toEqual(mockMembers); }); + it('appends roles and search params when provided', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValue({ data: { count: 0, results: [] } }), + }); + + const { result } = renderHook( + () => useTeamMembers('lib:123', { ...mockQuerySettings, roles: 'admin', search: 'alice' }), + { wrapper: createWrapper() }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const mockGetFn = getAuthenticatedHttpClient().get as jest.Mock; + const calledUrl = new URL(mockGetFn.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 () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValue({ data: { count: 0, results: [] } }), + }); + + const { result } = renderHook( + () => useTeamMembers('lib:123', { ...mockQuerySettings, sortBy: 'username', order: 'asc' }), + { wrapper: createWrapper() }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const mockGetFn = getAuthenticatedHttpClient().get as jest.Mock; + const calledUrl = new URL(mockGetFn.mock.calls[0][0]); + expect(calledUrl.searchParams.get('sort_by')).toBe('username'); + expect(calledUrl.searchParams.get('order')).toBe('asc'); + }); + it('handles error when API call fails', async () => { getAuthenticatedHttpClient.mockReturnValue({ get: jest.fn().mockRejectedValue(new Error('API failure')), @@ -239,6 +289,31 @@ describe('useLibrary', () => { }); }); + it('maps allow_public_read to allowPublicRead', async () => { + const rawLibrary = { + id: 'lib:org/test', + org: 'org', + title: 'Test Library', + slug: 'test-library', + allow_public_read: true, + }; + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValueOnce({ data: rawLibrary }), + }); + + const { result } = renderHook(() => useLibrary('lib:org/test'), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + + expect(result.current.data).toEqual({ + id: 'lib:org/test', + org: 'org', + title: 'Test Library', + slug: 'test-library', + allowPublicRead: true, + }); + }); + it('throws on error', () => { getAuthenticatedHttpClient.mockReturnValue({ get: jest.fn().mockRejectedValue(new Error('Not found')), @@ -288,75 +363,105 @@ describe('usePermissionsByRole', () => { expect(e).toEqual(new Error('Not found')); } }); +}); - describe('useAssignTeamMembersRole', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('successfully adds team members', async () => { - const mockResponse = { - completed: [ - { - user: 'jdoe', - status: 'role_added', - }, - { - user: 'alice@example.com', - status: 'already_has_role', - }, - ], - errors: [], - }; - - getAuthenticatedHttpClient.mockReturnValue({ - put: jest.fn().mockResolvedValue({ data: mockResponse }), - }); +describe('useAssignTeamMembersRole', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); - const { result } = renderHook(() => useAssignTeamMembersRole(), { - wrapper: createWrapper(), - }); + it('successfully adds team members', async () => { + const mockResponse = { + completed: [ + { user: 'jdoe', status: 'role_added' }, + { user: 'alice@example.com', status: 'already_has_role' }, + ], + errors: [], + }; - const addTeamMemberData = { - scope: 'lib:123', - users: ['jdoe'], - role: 'author', - }; + getAuthenticatedHttpClient.mockReturnValue({ + put: jest.fn().mockResolvedValue({ data: mockResponse }), + }); - await act(async () => { - result.current.mutate({ data: addTeamMemberData }); - }); + const { result } = renderHook(() => useAssignTeamMembersRole(), { + wrapper: createWrapper(), + }); - await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await act(async () => { + result.current.mutate({ data: { scope: 'lib:123', users: ['jdoe'], role: 'author' } }); + }); - expect(getAuthenticatedHttpClient).toHaveBeenCalled(); - expect(result.current.data).toEqual(mockResponse); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(getAuthenticatedHttpClient).toHaveBeenCalled(); + expect(result.current.data).toEqual(mockResponse); + }); + + it('handles error when adding team members fails', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + put: jest.fn().mockRejectedValue(new Error('Failed to add members')), }); - it('handles error when adding team members fails', async () => { - getAuthenticatedHttpClient.mockReturnValue({ - put: jest.fn().mockRejectedValue(new Error('Failed to add members')), - }); + const { result } = renderHook(() => useAssignTeamMembersRole(), { + wrapper: createWrapper(), + }); - const { result } = renderHook(() => useAssignTeamMembersRole(), { - wrapper: createWrapper(), - }); + await act(async () => { + result.current.mutate({ data: { scope: 'lib:123', users: ['jdoe'], role: 'author' } }); + }); - const addTeamMemberData = { - scope: 'lib:123', - users: ['jdoe'], - role: 'author', - }; + await waitFor(() => expect(result.current.isError).toBe(true)); - await act(async () => { - result.current.mutate({ data: addTeamMemberData }); - }); + expect(getAuthenticatedHttpClient).toHaveBeenCalled(); + expect(result.current.error).toEqual(new Error('Failed to add members')); + }); +}); - await waitFor(() => expect(result.current.isError).toBe(true)); +describe('useValidateUsers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); - expect(getAuthenticatedHttpClient).toHaveBeenCalled(); - expect(result.current.error).toEqual(new Error('Failed to add members')); + 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')); }); }); diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts index 5b3ed7b4..7242fffd 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -10,6 +10,7 @@ import { getPermissionsByRole, getScopes, GetScopesResponse, getTeamMembers, GetTeamMembersResponse, PermissionsByRole, QuerySettings, revokeUserRoles, RevokeUserRolesRequest, getUserAssignedRoles, GetUserAssignmentsResponse, + validateUsers, ValidateUsersRequest, } from './api'; const authzQueryKeys = { @@ -99,6 +100,20 @@ 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 remove roles for a specific team member within a scope. * diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss index 074f1f74..34c408b4 100644 --- a/src/authz-module/index.scss +++ b/src/authz-module/index.scss @@ -75,6 +75,43 @@ } } +.highlighted-users-input { + &__layer { + font-family: inherit; + font-size: 1rem; + line-height: 1.5; + padding: 0.375rem 0.75rem; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: break-word; + width: 100%; + } + + &__overlay { + position: absolute; + inset: 0; + background: #fff; + border: 1px solid transparent; + border-radius: 0.25rem; + overflow: hidden; + pointer-events: none; + z-index: 0; + } + + &__textarea { + position: relative; + z-index: 1; + min-height: 100px; + resize: vertical; + caret-color: #212529; + + &--highlighted { + color: transparent; + background: transparent; + } + } +} + .toast-container { // Ensure toast appears above modal z-index: 1000; diff --git a/src/authz-module/index.tsx b/src/authz-module/index.tsx index 7fa21a68..24dd7f11 100644 --- a/src/authz-module/index.tsx +++ b/src/authz-module/index.tsx @@ -9,6 +9,7 @@ import { ToastManagerProvider } from '@src/components/ToastManager/ToastManagerC import { LibrariesUserManager, LibrariesLayout, LibrariesTeamManager } from './libraries-manager'; import AuthzHome from './authz-home'; import AuditUserPage from './audit-user'; +import AssignRoleWizardPage from './role-assignation-wizard/AssignRoleWizardPage'; import { ROUTES } from './constants'; import './index.scss'; @@ -34,6 +35,7 @@ const AuthZModule = () => ( path={ROUTES.AUDIT_USER_PATH} element={} /> + } /> } /> ({ default: () =>
Team member list
, })); -jest.mock('./components/AddNewTeamMemberModal', () => ({ - __esModule: true, - AddNewTeamMemberTrigger: () => , -})); - jest.mock('../components/RoleCard', () => ({ __esModule: true, default: ({ title, description, permissionsByResource }: { @@ -105,8 +100,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 wizard button is rendered + expect(screen.getByRole('button', { name: /Assign Role/i })).toBeInTheDocument(); }); it('renders role cards when "Roles" tab is selected', async () => { diff --git a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx index f8a7ed46..5131e1e9 100644 --- a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx @@ -6,19 +6,19 @@ import { import { useLibrary } from '@src/authz-module/data/hooks'; import { useLocation } from 'react-router-dom'; import { ROUTES } from '@src/authz-module/constants'; +import AddRoleButton from '@src/authz-module/components/AddRoleButton'; 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'; const LibrariesTeamManager = () => { const intl = useIntl(); - const { hash } = useLocation(); + const { hash, pathname } = useLocation(); const { libraryId, canManageTeam, roles, permissions, resources, } = useLibraryAuthZ(); @@ -51,7 +51,9 @@ const LibrariesTeamManager = () => { pageSubtitle={libraryId} actions={ [ - ...(canManageTeam ? [] : []), + ...(canManageTeam ? [ + , + ] : []), ] } > diff --git a/src/authz-module/libraries-manager/LibrariesUserManager.test.tsx b/src/authz-module/libraries-manager/LibrariesUserManager.test.tsx index 46553efb..48cb2a05 100644 --- a/src/authz-module/libraries-manager/LibrariesUserManager.test.tsx +++ b/src/authz-module/libraries-manager/LibrariesUserManager.test.tsx @@ -3,6 +3,7 @@ import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWrapper } from '@src/setupTest'; import { ToastManagerProvider } from '@src/components/ToastManager/ToastManagerContext'; +import { buildWizardPath } from '@src/authz-module/constants'; import LibrariesUserManager from './LibrariesUserManager'; import { useLibraryAuthZ } from './context'; import { useLibrary, useTeamMembers, useRevokeUserRoles } from '../data/hooks'; @@ -11,9 +12,13 @@ jest.mock('@edx/frontend-platform/logging', () => ({ logError: jest.fn(), })); +const mockNavigate = jest.fn(); + jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useParams: jest.fn(), + useNavigate: jest.fn(), + useLocation: jest.fn(), })); jest.mock('./context', () => ({ @@ -84,6 +89,10 @@ describe('LibrariesUserManager', () => { // Mock route params (useParams as jest.Mock).mockReturnValue({ username: 'testuser' }); + const { useNavigate, useLocation } = jest.requireMock('react-router-dom'); + useNavigate.mockReturnValue(mockNavigate); + useLocation.mockReturnValue({ pathname: '/authz/libraries/lib:123/testuser' }); + // Mock library authz context (useLibraryAuthZ as jest.Mock).mockReturnValue(defaultMockData); @@ -151,6 +160,17 @@ describe('LibrariesUserManager', () => { expect(screen.getByText('Assign Role')).toBeInTheDocument(); }); + it('navigates to the wizard with the current user and return path when Assign Role is clicked', async () => { + const user = userEvent.setup(); + renderComponent(); + + await user.click(screen.getByRole('button', { name: /Assign Role/i })); + + expect(mockNavigate).toHaveBeenCalledWith( + buildWizardPath({ users: 'testuser', from: '/authz/libraries/lib:123/testuser' }), + ); + }); + it('renders correct navigation link label and URL on breadcrumb', () => { renderComponent(); const navLinkManageAccess = screen.getByRole('link', { name: 'Manage Access' }); diff --git a/src/authz-module/libraries-manager/LibrariesUserManager.tsx b/src/authz-module/libraries-manager/LibrariesUserManager.tsx index 10fab16e..f06981c6 100644 --- a/src/authz-module/libraries-manager/LibrariesUserManager.tsx +++ b/src/authz-module/libraries-manager/LibrariesUserManager.tsx @@ -1,14 +1,14 @@ import { useEffect, useMemo, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Container, Skeleton } from '@openedx/paragon'; import { ROUTES } from '@src/authz-module/constants'; import { Role } from 'types'; import { useToastManager } from '@src/components/ToastManager/ToastManagerContext'; +import AddRoleButton from '@src/authz-module/components/AddRoleButton'; import AuthZLayout from '../components/AuthZLayout'; import { useLibraryAuthZ } from './context'; import RoleCard from '../components/RoleCard'; -import { AssignNewRoleTrigger } from './components/AssignNewRoleModal'; import ConfirmDeletionModal from './components/ConfirmDeletionModal'; import { useLibrary, useRevokeUserRoles, useTeamMembers } from '../data/hooks'; import { buildPermissionMatrixByRole } from './utils'; @@ -18,6 +18,7 @@ import messages from './messages'; const LibrariesUserManager = () => { const intl = useIntl(); const navigate = useNavigate(); + const { pathname } = useLocation(); const { username } = useParams(); const { libraryId, permissions, roles, resources, canManageTeam, @@ -153,12 +154,8 @@ const LibrariesUserManager = () => { activeLabel={user?.username || ''} pageTitle={user?.username || ''} pageSubtitle={user?.email || ''} - actions={user && canManageTeam - ? [ role.role)} - />] + actions={(user && canManageTeam) + ? [] : []} > diff --git a/src/authz-module/libraries-manager/context.tsx b/src/authz-module/libraries-manager/context.tsx index a6ad0035..9f9b972f 100644 --- a/src/authz-module/libraries-manager/context.tsx +++ b/src/authz-module/libraries-manager/context.tsx @@ -8,8 +8,9 @@ import { usePermissionsByRole } from '@src/authz-module/data/hooks'; import { PermissionMetadata, ResourceMetadata, Role } from 'types'; import { CustomErrors } from '@src/constants'; import { - CONTENT_LIBRARY_PERMISSIONS, libraryPermissions, libraryResourceTypes, libraryRolesMetadata, + CONTENT_LIBRARY_PERMISSIONS, libraryPermissions, libraryResourceTypes, } from '../constants'; +import { libraryRolesMetadata } from '../roles-permissions/library/constants'; const LIBRARY_TEAM_PERMISSIONS = [ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, diff --git a/src/authz-module/libraries-manager/utils.test.ts b/src/authz-module/libraries-manager/utils.test.ts index 07d6600a..71804dd6 100644 --- a/src/authz-module/libraries-manager/utils.test.ts +++ b/src/authz-module/libraries-manager/utils.test.ts @@ -15,13 +15,13 @@ const resources = [ ]; const roles = [ { - name: 'admin', permissions: ['create_library', 'edit_library'], userCount: 2, role: 'admin', description: '', + name: 'admin', permissions: ['create_library', 'edit_library'], userCount: 2, role: 'admin', description: '', contextType: '', }, { - name: 'editor', permissions: ['edit_library'], userCount: 2, role: 'editor', description: '', + name: 'editor', permissions: ['edit_library'], userCount: 2, role: 'editor', description: '', contextType: '', }, { - name: 'guest', permissions: [], userCount: 2, role: 'guest', description: '', + name: 'guest', permissions: [], userCount: 2, role: 'guest', description: '', contextType: '', }, ]; @@ -36,6 +36,7 @@ describe('buildPermissionsMatrix', () => { userCount: 2, role: 'editor', description: '', + contextType: '', permissions: ['edit_library'], resources: [ { diff --git a/src/authz-module/role-assignation-wizard/AssignRoleWizard.test.tsx b/src/authz-module/role-assignation-wizard/AssignRoleWizard.test.tsx new file mode 100644 index 00000000..1058d26e --- /dev/null +++ b/src/authz-module/role-assignation-wizard/AssignRoleWizard.test.tsx @@ -0,0 +1,163 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper } from '@src/setupTest'; +import { ToastManagerProvider } from '@src/components/ToastManager/ToastManagerContext'; +import { useValidateUsers } from '../data/hooks'; +import AssignRoleWizard from './AssignRoleWizard'; + +jest.mock('@edx/frontend-platform/logging'); +jest.mock('../data/hooks', () => ({ + useValidateUsers: jest.fn(), +})); + +const mockUseValidateUsers = useValidateUsers as jest.Mock; +const mockValidateMutateAsync = jest.fn(); + +const renderWizard = (props = {}) => renderWrapper( + + + , +); + +// selectors +const getUsersInput = () => screen.getByLabelText(/Add users by username or email/i); +const getRoleRadio = (name: RegExp) => screen.getByRole('radio', { name }); +const getNextButton = () => screen.getByRole('button', { name: /^Next$/i }); +const getCancelButton = () => screen.getByRole('button', { name: /^Cancel$/i }); +const getStep2Heading = () => screen.queryByRole('heading', { name: 'Step 2' }); + +describe('AssignRoleWizard — Step 1', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseValidateUsers.mockReturnValue({ + mutateAsync: mockValidateMutateAsync, + isPending: false, + }); + }); + + it('Cancel returns to the previous view', async () => { + const user = userEvent.setup(); + const onClose = jest.fn(); + renderWizard({ onClose }); + await user.click(getCancelButton()); + expect(onClose).toHaveBeenCalled(); + }); + + it('opens with the user pre-populated when provided via initialUsers', () => { + renderWizard({ initialUsers: 'alice' }); + expect(getUsersInput()).toHaveValue('alice'); + }); + + it('selecting a different role replaces the previous selection', async () => { + const user = userEvent.setup(); + renderWizard(); + await user.click(getRoleRadio(/Library Admin/i)); + await user.click(getRoleRadio(/Course Admin/i)); + expect(getRoleRadio(/Course Admin/i)).toBeChecked(); + expect(getRoleRadio(/Library Admin/i)).not.toBeChecked(); + }); + + it('blocks progression and highlights the unknown user', async () => { + const user = userEvent.setup(); + mockValidateMutateAsync.mockResolvedValue({ invalidUsers: ['ghost'], validUsers: [] }); + renderWizard(); + await user.type(getUsersInput(), 'ghost'); + await user.click(getRoleRadio(/Library Admin/i)); + await user.click(getNextButton()); + await waitFor(() => { + expect(screen.getByText(/not associated with an account/i)).toBeInTheDocument(); + expect(getStep2Heading()).not.toBeInTheDocument(); + }); + }); + + it('advances to Step 2 when all users are valid', async () => { + const user = userEvent.setup(); + mockValidateMutateAsync.mockResolvedValue({ invalidUsers: [], validUsers: ['alice'] }); + renderWizard(); + await user.type(getUsersInput(), 'alice'); + await user.click(getRoleRadio(/Library Admin/i)); + await user.click(getNextButton()); + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Step 2' })).toBeInTheDocument(); + }); + }); + + it('shows a network error and blocks progression when the validation call fails', async () => { + const user = userEvent.setup(); + mockValidateMutateAsync.mockRejectedValue(new Error('Network error')); + renderWizard(); + await user.type(getUsersInput(), 'alice'); + await user.click(getRoleRadio(/Library Admin/i)); + await user.click(getNextButton()); + await waitFor(() => { + // Toast should appear with retry option + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(getStep2Heading()).not.toBeInTheDocument(); + }); + }); + + it('clears the invalid user highlights when the input is edited', async () => { + const user = userEvent.setup(); + mockValidateMutateAsync.mockResolvedValue({ invalidUsers: ['ghost'], validUsers: [] }); + renderWizard(); + await user.type(getUsersInput(), 'ghost'); + await user.click(getRoleRadio(/Library Admin/i)); + await user.click(getNextButton()); + await waitFor(() => expect(screen.getByText(/not associated with an account/i)).toBeInTheDocument()); + await user.type(getUsersInput(), 'x'); + await waitFor(() => expect(screen.queryByText(/not associated with an account/i)).not.toBeInTheDocument()); + }); + + it('allows retry when validation call fails', async () => { + const user = userEvent.setup(); + mockValidateMutateAsync + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce({ invalidUsers: [], validUsers: ['alice'] }); + renderWizard(); + await user.type(getUsersInput(), 'alice'); + await user.click(getRoleRadio(/Library Admin/i)); + + // First attempt fails - toast appears with retry + await user.click(getNextButton()); + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()); + + // Click retry button + const retryButton = screen.getByRole('button', { name: /retry/i }); + await user.click(retryButton); + + // Should advance to step 2 on retry + await waitFor(() => { + expect(getStep2Heading()).toBeInTheDocument(); + }); + }); +}); + +describe('AssignRoleWizard — Step 2', () => { + const advanceToStep2 = async (user: ReturnType) => { + mockValidateMutateAsync.mockResolvedValue({ invalidUsers: [], validUsers: ['alice'] }); + await user.type(getUsersInput(), 'alice'); + await user.click(getRoleRadio(/Library Admin/i)); + await user.click(getNextButton()); + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Step 2' })).toBeInTheDocument(); + }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseValidateUsers.mockReturnValue({ + mutateAsync: mockValidateMutateAsync, + isPending: false, + }); + }); + + it('Back button returns to Step 1', async () => { + const user = userEvent.setup(); + renderWizard(); + await advanceToStep2(user); + await user.click(screen.getByRole('button', { name: /^Back$/i })); + await waitFor(() => { + expect(getUsersInput()).toBeInTheDocument(); + }); + }); +}); diff --git a/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx b/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx new file mode 100644 index 00000000..237bb768 --- /dev/null +++ b/src/authz-module/role-assignation-wizard/AssignRoleWizard.tsx @@ -0,0 +1,205 @@ +import { + useState, useCallback, useRef, useEffect, +} from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Stepper, Button, StatefulButton, Icon, +} from '@openedx/paragon'; +import { SpinnerSimple } from '@openedx/paragon/icons'; +import { RoleMetadata } from 'types'; +import { useToastManager } from '@src/components/ToastManager/ToastManagerContext'; +import SelectUsersAndRoleStep from './components/SelectUsersAndRoleStep'; +import DefineApplicationScopeStep from './components/DefineApplicationScopeStep'; +import { useValidateUsers } from '../data/hooks'; +import { libraryRolesMetadata } from '../roles-permissions/library/constants'; +import { courseRolesMetadata } from '../roles-permissions/course/constants'; +import messages from './messages'; + +// Default: all roles. Callers may pass a filtered subset once a permission- +// lookup API is available (e.g. only library roles when user lacks course scope). +const allRolesMetadata = [...courseRolesMetadata, ...libraryRolesMetadata]; + +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; + initialUsers?: string; + /** Filtered role list. Defaults to all roles (course + library). Pass a subset + * once a permission-lookup API is available to hide groups the caller cannot assign. */ + roles?: RoleMetadata[]; +} + +const parseUsers = (input: string): string[] => input + .split(',') + .map((u) => u.trim()) + .filter(Boolean); + +const getInitialState = (initialUsers: string) => ({ + activeStep: STEPS.SELECT_USERS_AND_ROLE as StepKey, + users: initialUsers, + selectedRole: null as string | null, + selectedScopes: new Set(), + invalidUsers: [] as string[], + validatedUsers: [] as string[], +}); + +const AssignRoleWizard = ({ onClose, initialUsers = '', roles = allRolesMetadata }: AssignRoleWizardProps) => { + const intl = useIntl(); + const { showErrorToast } = useToastManager(); + 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 [invalidUsers, setInvalidUsers] = useState([]); + const [validatedUsers, setValidatedUsers] = useState([]); + + const usersInputRef = useRef(null); + + const validateUsersMutation = useValidateUsers(); + + const handleUsersChange = useCallback((value: string) => { + setInvalidUsers((prev) => (prev.length > 0 ? [] : prev)); + setUsers(value); + }, []); + + const handleClose = () => { + const initialState = getInitialState(initialUsers); + setActiveStep(initialState.activeStep); + setUsers(initialState.users); + setSelectedRole(initialState.selectedRole); + setSelectedScopes(initialState.selectedScopes); + setInvalidUsers(initialState.invalidUsers); + setValidatedUsers(initialState.validatedUsers); + onClose(); + }; + + const validateUsersAndProceed = async () => { + if (validateUsersMutation.isPending) { return; } + const usersList = parseUsers(users); + if (usersList.length === 0 || !selectedRole) { return; } + + 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 (error) { + showErrorToast(error, validateUsersAndProceed); + } + }; + + 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; + }); + }, []); + + // TODO: replace with real assignment API call using validatedUsers, selectedRole, selectedScopes + const handleSave = () => { + if (!selectedRole || selectedScopes.size === 0 || validatedUsers.length === 0) { return; } + handleClose(); + }; + + useEffect(() => { + if (invalidUsers.length && usersInputRef.current) { + usersInputRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, [invalidUsers]); + + const parsedUsers = parseUsers(users); + const canProceed = parsedUsers.length > 0 && !!selectedRole; + const canSave = selectedScopes.size > 0; + + return ( + + + +
+ 0} + description={invalidUsers.length > 0 ? intl.formatMessage(messages['wizard.step.selectUsersAndRole.error']) : ''} + index={0} + > + + + + + + +
+ +
+ + + + }} + variant={invalidUsers.length ? 'danger' : 'primary'} + state={validateUsersMutation.isPending ? 'pending' : 'default'} + onClick={validateUsersAndProceed} + disabled={!canProceed || validateUsersMutation.isPending} + /> + + + + + + + }} + state="default" + onClick={handleSave} + disabled={!canSave} + /> + +
+
+ ); +}; + +export default AssignRoleWizard; diff --git a/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.test.tsx b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.test.tsx new file mode 100644 index 00000000..6f8c87b2 --- /dev/null +++ b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.test.tsx @@ -0,0 +1,87 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWrapper } from '@src/setupTest'; +import { ToastManagerProvider } from '@src/components/ToastManager/ToastManagerContext'; +import { useValidateUsers } from '../data/hooks'; +import AssignRoleWizardPage from './AssignRoleWizardPage'; + +jest.mock('@edx/frontend-platform/logging'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useSearchParams: jest.fn(), + useNavigate: jest.fn(), +})); + +jest.mock('../data/hooks', () => ({ + useValidateUsers: jest.fn(), +})); + +jest.mock('@edx/frontend-component-header', () => ({ + StudioHeader: () => null, +})); + +const mockUseValidateUsers = useValidateUsers as jest.Mock; + +const setupMocks = ({ users = '', from = '' } = {}) => { + const { useSearchParams, useNavigate } = jest.requireMock('react-router-dom'); + const params = new URLSearchParams(); + if (users) { params.set('users', users); } + if (from) { params.set('from', from); } + useSearchParams.mockReturnValue([params]); + const navigate = jest.fn(); + useNavigate.mockReturnValue(navigate); + return { navigate }; +}; + +const renderPage = () => renderWrapper( + + + , +); + +describe('AssignRoleWizardPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseValidateUsers.mockReturnValue({ + mutateAsync: jest.fn(), + isPending: false, + }); + }); + + it('renders the page with the wizard and title', () => { + setupMocks(); + renderPage(); + expect(screen.getByRole('heading', { name: 'Assign Role' })).toBeInTheDocument(); + expect(screen.getByLabelText(/Add users by username or email/i)).toBeInTheDocument(); + }); + + it('passes initialUsers from search params to the wizard', () => { + setupMocks({ users: 'alice,bob' }); + renderPage(); + expect(screen.getByLabelText(/Add users by username or email/i)).toHaveValue('alice,bob'); + }); + + it('navigates to home path when the wizard Cancel is clicked', async () => { + const { navigate } = setupMocks(); + const user = userEvent.setup(); + renderPage(); + await user.click(screen.getByRole('button', { name: /Cancel/i })); + expect(navigate).toHaveBeenCalledWith('/authz'); + }); + + it('navigates to the from= path when Cancel is clicked and a from param is present', async () => { + const { navigate } = setupMocks({ from: '/authz/libraries/lib:123/alice' }); + const user = userEvent.setup(); + renderPage(); + await user.click(screen.getByRole('button', { name: /Cancel/i })); + expect(navigate).toHaveBeenCalledWith('/authz/libraries/lib:123/alice'); + }); + + it('navigates to home path when from is an external URL', async () => { + const { navigate } = setupMocks({ from: 'https://evil.com' }); + const user = userEvent.setup(); + renderPage(); + await user.click(screen.getByRole('button', { name: /Cancel/i })); + expect(navigate).toHaveBeenCalledWith('/authz'); + }); +}); diff --git a/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx new file mode 100644 index 00000000..feb25942 --- /dev/null +++ b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx @@ -0,0 +1,35 @@ +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import AssignRoleWizard from './AssignRoleWizard'; +import AuthZLayout from '../components/AuthZLayout'; +import { ROUTES } from '../constants'; +import messages from './messages'; + +const AssignRoleWizardPage = () => { + const intl = useIntl(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const initialUsers = searchParams.get('users') || ''; + const raw = searchParams.get('from') ?? ''; + const returnTo = (raw.startsWith('/') && !raw.startsWith('//')) ? raw : ROUTES.HOME_PATH; + + return ( + + {/* TODO: pass a filtered `roles` prop once the permission-lookup API is available, + so the wizard only shows role groups the current user can assign. */} + navigate(returnTo)} + initialUsers={initialUsers} + /> + + ); +}; + +export default AssignRoleWizardPage; diff --git a/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.test.tsx b/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.test.tsx new file mode 100644 index 00000000..54f59e29 --- /dev/null +++ b/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react'; +import DefineApplicationScopeStep from './DefineApplicationScopeStep'; + +describe('DefineApplicationScopeStep', () => { + const defaultProps = { + selectedRole: 'library_admin', + selectedScopes: new Set(['lib:123']), + onScopeToggle: jest.fn(), + }; + + it('renders the Step 2 placeholder heading', () => { + render(); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Step 2'); + }); + + it('renders with null selectedRole', () => { + render( + , + ); + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); + }); +}); diff --git a/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.tsx b/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.tsx new file mode 100644 index 00000000..1f6f2375 --- /dev/null +++ b/src/authz-module/role-assignation-wizard/components/DefineApplicationScopeStep.tsx @@ -0,0 +1,14 @@ +interface DefineApplicationScopeStepProps { + selectedRole: string | null; + selectedScopes: Set; + onScopeToggle: (scopeId: string) => void; +} + +// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars +const DefineApplicationScopeStep = (_props: DefineApplicationScopeStepProps) => +// Placeholder for Step 2 - "Define Application Scope" +// TODO: implement scope selection UI using selectedRole, selectedScopes, onScopeToggle + + // eslint-disable-next-line implicit-arrow-linebreak +

Step 2

; +export default DefineApplicationScopeStep; diff --git a/src/authz-module/role-assignation-wizard/components/HighlightedUsersInput.test.tsx b/src/authz-module/role-assignation-wizard/components/HighlightedUsersInput.test.tsx new file mode 100644 index 00000000..7b9c2fed --- /dev/null +++ b/src/authz-module/role-assignation-wizard/components/HighlightedUsersInput.test.tsx @@ -0,0 +1,106 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import HighlightedUsersInput from './HighlightedUsersInput'; + +const defaultProps = { + id: 'users-input', + 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 invalidSpan = container.querySelector('[data-invalid]'); + expect(invalidSpan).toBeInTheDocument(); + expect(invalidSpan!.textContent).toContain('baduser'); + }); + + it('renders valid user parts in dark color', () => { + const { container } = render( + , + ); + const overlay = container.querySelector('[aria-hidden="true"]'); + const validSpan = Array.from(overlay!.querySelectorAll('span:not([data-invalid])')).find( + (s) => s.textContent?.includes('jdoe'), + ); + expect(validSpan).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).toHaveClass('highlighted-users-input__textarea--highlighted'); + }); + + it('adds is-invalid class when hasError is true', () => { + const { container } = render( + , + ); + expect(container.querySelector('textarea')).toHaveClass('is-invalid'); + }); + + it('does not add is-invalid class when hasError 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/role-assignation-wizard/components/HighlightedUsersInput.tsx b/src/authz-module/role-assignation-wizard/components/HighlightedUsersInput.tsx new file mode 100644 index 00000000..86a82aec --- /dev/null +++ b/src/authz-module/role-assignation-wizard/components/HighlightedUsersInput.tsx @@ -0,0 +1,92 @@ +import { + useMemo, useRef, useCallback, forwardRef, +} from 'react'; + +interface HighlightedUsersInputProps { + id: string; + value: string; + onChange: (val: string) => void; + invalidUsers: string[]; + placeholder?: string; + hasError?: boolean; + ariaDescribedBy?: string; +} + +const HighlightedUsersInput = forwardRef(({ + id, value, onChange, invalidUsers, placeholder, hasError = false, ariaDescribedBy, +}, ref) => { + 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 = useCallback((e: { currentTarget: HTMLTextAreaElement }) => { + 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 */} +