diff --git a/src/authz-module/audit-user/index.tsx b/src/authz-module/audit-user/index.tsx index 0619402a..b5aaa221 100644 --- a/src/authz-module/audit-user/index.tsx +++ b/src/authz-module/audit-user/index.tsx @@ -14,6 +14,8 @@ import { import AuthZLayout from '@src/authz-module/components/AuthZLayout'; import { useNavigate, useParams } from 'react-router-dom'; import { useUserAccount, useValidateUserPermissionsNonSuspense } from '@src/data/hooks'; +import { LIBRARY_ROLE_KEYS } from '@src/authz-module/roles-permissions'; +import { useViewTeamPermissions } from '@src/authz-module/hooks/useViewTeamPermissions'; import baseMessages from '@src/authz-module/messages'; import AddRoleButton from '@src/authz-module/components/AddRoleButton'; import { @@ -42,9 +44,17 @@ const AuditUserPage = () => { isLoading: isLoadingUser, data: user, isError: isErrorUser, error: errorUser, } = useUserAccount(username); const { querySettings, handleTableFetch } = useQuerySettings(); + + const { isCourseViewAllowed } = useViewTeamPermissions(); + + const effectiveQuerySettings = useMemo(() => { + if (isCourseViewAllowed || querySettings.roles) { return querySettings; } + return { ...querySettings, roles: LIBRARY_ROLE_KEYS }; + }, [isCourseViewAllowed, querySettings]); + const { isLoading: isLoadingUserAssignments, data: { results: userAssignments, count } = { results: [], count: 0 }, - } = useUserAssignedRoles(username, querySettings); + } = useUserAssignedRoles(username, effectiveQuerySettings); const [roleToDelete, setRoleToDelete] = useState(null); const [showConfirmDeletionModal, setShowConfirmDeletionModal] = useState(false); const { diff --git a/src/authz-module/components/TableControlBar/RolesFilter.test.tsx b/src/authz-module/components/TableControlBar/RolesFilter.test.tsx index a4be6eb1..d0f9be3b 100644 --- a/src/authz-module/components/TableControlBar/RolesFilter.test.tsx +++ b/src/authz-module/components/TableControlBar/RolesFilter.test.tsx @@ -91,7 +91,7 @@ describe('RolesFilter', () => { it('shows no role options while permissions are still loading', async () => { const user = userEvent.setup(); - mockUsePermissions.mockReturnValue({ data: undefined }); + mockUsePermissions.mockReturnValue({ data: undefined, isLoading: true }); renderWrapper(); const menu = await openDropdown(user); expect(menu.queryByText('Courses')).not.toBeInTheDocument(); diff --git a/src/authz-module/components/TableControlBar/RolesFilter.tsx b/src/authz-module/components/TableControlBar/RolesFilter.tsx index db66a8c2..95c6bd03 100644 --- a/src/authz-module/components/TableControlBar/RolesFilter.tsx +++ b/src/authz-module/components/TableControlBar/RolesFilter.tsx @@ -1,8 +1,7 @@ import { useMemo } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Person } from '@openedx/paragon/icons'; -import { useValidateUserPermissionsNonSuspense } from '@src/data/hooks'; -import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from '@src/authz-module/roles-permissions'; +import { useViewTeamPermissions } from '@src/authz-module/hooks/useViewTeamPermissions'; import { CONTEXT_TYPES } from '@src/authz-module/constants'; import MultipleChoiceFilter from './MultipleChoiceFilter'; import { MultipleChoiceFilterProps } from './types'; @@ -14,27 +13,16 @@ const RolesFilter = ({ filterButtonText, filterValue, setFilter, disabled, }: RolesFilterProps) => { const intl = useIntl(); - const { data: permissions } = useValidateUserPermissionsNonSuspense([ - { action: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM }, - { action: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM }, - ]); + const { isCourseViewAllowed, isLibraryViewAllowed, isLoading } = useViewTeamPermissions(); - // Only show role groups for the domains the user can view. Global roles stay - // hidden until a platform-wide permission is available to gate them on. - const allowedContexts = useMemo(() => { - const contexts = new Set(); - permissions?.forEach((p) => { - if (!p.allowed) { return; } - if (p.action === CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM) { contexts.add(CONTEXT_TYPES.LIBRARY); } - if (p.action === CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM) { contexts.add(CONTEXT_TYPES.COURSE); } + const rolesOptions = useMemo(() => { + if (isLoading) { return []; } + return getRolesFiltersOptions(intl).filter((option) => { + if (option.contextType === CONTEXT_TYPES.COURSE) { return isCourseViewAllowed; } + if (option.contextType === CONTEXT_TYPES.LIBRARY) { return isLibraryViewAllowed; } + return false; }); - return contexts; - }, [permissions]); - - const rolesOptions = useMemo( - () => getRolesFiltersOptions(intl).filter((option) => allowedContexts.has(option.contextType)), - [intl, allowedContexts], - ); + }, [intl, isCourseViewAllowed, isLibraryViewAllowed, isLoading]); return ( ({ + useValidateUserPermissionsNonSuspense: jest.fn(), +})); + +const mockUsePermissions = useValidateUserPermissionsNonSuspense as jest.Mock; + jest.mock('@src/authz-module/data/hooks', () => ({ - useScopes: () => ({ + useScopes: jest.fn(() => ({ data: { pages: [ { results: [ { - externalKey: 'course:123', - name: 'Test Course', - organization: { name: 'Test Org' }, + externalKey: 'course-v1:org+course+run', + displayName: 'Test Course', + org: { shortName: 'TestOrg' }, }, { - externalKey: 'library:456', - name: 'Test Library', - organization: { name: 'Another Org' }, + externalKey: 'lib:org:library', + displayName: 'Test Library', + org: { shortName: 'TestOrg' }, }, ], }, ], }, - }), + })), })); +const mockUseScopes = useScopes as jest.Mock; + +const permissionsData = ({ library, course }: { library?: boolean; course?: boolean }) => [ + { action: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, allowed: !!library }, + { action: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, allowed: !!course }, +]; + describe('ScopesFilter', () => { const defaultProps = { filterButtonText: 'Scopes', @@ -36,6 +52,7 @@ describe('ScopesFilter', () => { beforeEach(() => { jest.clearAllMocks(); + mockUsePermissions.mockReturnValue({ data: permissionsData({ library: true, course: true }) }); }); it('renders without crashing', () => { @@ -68,4 +85,27 @@ describe('ScopesFilter', () => { renderWrapper(); expect(screen.getByText('Scopes')).toBeInTheDocument(); }); + + it('fetches all scope types when the user can view courses', () => { + renderWrapper(); + expect(mockUseScopes).toHaveBeenCalledWith( + expect.not.objectContaining({ scopeType: 'library' }), + ); + }); + + it('fetches only library scopes when the user cannot view courses', () => { + mockUsePermissions.mockReturnValue({ data: permissionsData({ library: true, course: false }) }); + renderWrapper(); + expect(mockUseScopes).toHaveBeenCalledWith( + expect.objectContaining({ scopeType: 'library' }), + ); + }); + + it('defaults to showing only library scopes while permissions are loading', () => { + mockUsePermissions.mockReturnValue({ data: undefined, isLoading: true }); + renderWrapper(); + expect(mockUseScopes).toHaveBeenCalledWith( + expect.objectContaining({ scopeType: 'library' }), + ); + }); }); diff --git a/src/authz-module/components/TableControlBar/ScopesFilter.tsx b/src/authz-module/components/TableControlBar/ScopesFilter.tsx index a93854a6..1457a8cd 100644 --- a/src/authz-module/components/TableControlBar/ScopesFilter.tsx +++ b/src/authz-module/components/TableControlBar/ScopesFilter.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { LocationOn } from '@openedx/paragon/icons'; +import { useViewTeamPermissions } from '@src/authz-module/hooks/useViewTeamPermissions'; import { useScopes } from '@src/authz-module/data/hooks'; import { DEFAULT_FILTER_PAGE_SIZE } from '@src/authz-module/constants'; import { MultipleChoiceFilterProps } from './types'; @@ -15,7 +16,14 @@ const ScopesFilter = ({ }: ScopesFilterProps) => { const { formatMessage } = useIntl(); const [searchValue, setSearchValue] = useState(undefined); - const { data: scopesData } = useScopes({ search: searchValue, pageSize: DEFAULT_FILTER_PAGE_SIZE }); + + const { isCourseViewAllowed } = useViewTeamPermissions(); + + const { data: scopesData } = useScopes({ + search: searchValue, + pageSize: DEFAULT_FILTER_PAGE_SIZE, + ...(isCourseViewAllowed ? {} : { scopeType: 'library' }), + }); const filterChoices = useMemo(() => (scopesData?.pages?.flatMap((p) => p.results) ?? []).map((scope) => { const scopeIcon = scope.externalKey?.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE; diff --git a/src/authz-module/hooks/useViewTeamPermissions.ts b/src/authz-module/hooks/useViewTeamPermissions.ts new file mode 100644 index 00000000..84b73f4c --- /dev/null +++ b/src/authz-module/hooks/useViewTeamPermissions.ts @@ -0,0 +1,18 @@ +import { useValidateUserPermissionsNonSuspense } from '@src/data/hooks'; +import { + CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS, VIEW_TEAM_PERMISSIONS, +} from '@src/authz-module/roles-permissions'; + +export const useViewTeamPermissions = () => { + const { data: permissions, isLoading } = useValidateUserPermissionsNonSuspense(VIEW_TEAM_PERMISSIONS); + + const isCourseViewAllowed = permissions + ? permissions.some((p) => p.action === CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM && p.allowed) + : false; + + const isLibraryViewAllowed = permissions + ? permissions.some((p) => p.action === CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM && p.allowed) + : false; + + return { isCourseViewAllowed, isLibraryViewAllowed, isLoading }; +}; diff --git a/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.test.tsx b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.test.tsx index 4197518d..63a953a8 100644 --- a/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.test.tsx +++ b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.test.tsx @@ -49,6 +49,26 @@ const allowAllPermissions = { isLoading: false, }; +const mockPermissions = ( + manageData: typeof allowAllPermissions, + { courseViewAllowed = true } = {}, +) => (permissions: { action: string }[]) => { + const isViewCall = permissions.some( + (p) => p.action === CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM + || p.action === CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, + ); + if (isViewCall) { + return { + data: [ + { action: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, allowed: true }, + { action: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM, allowed: courseViewAllowed }, + ], + isLoading: false, + }; + } + return manageData; +}; + const setupMocks = ({ users = '', from = '' } = {}) => { const { useSearchParams, useNavigate } = jest.requireMock('react-router-dom'); const params = new URLSearchParams(); @@ -73,7 +93,7 @@ describe('AssignRoleWizardPage', () => { mutateAsync: jest.fn(), isPending: false, }); - mockUseValidatePermissions.mockReturnValue(allowAllPermissions); + mockUseValidatePermissions.mockImplementation(mockPermissions(allowAllPermissions)); }); it('renders the page with the wizard and title', () => { @@ -147,10 +167,10 @@ describe('AssignRoleWizardPage', () => { }); it.each(scopeRoles)('shows only the roles for the allowed scope %#', ({ action, roles }) => { - mockUseValidatePermissions.mockReturnValue({ + mockUseValidatePermissions.mockImplementation(mockPermissions({ data: scopeRoles.map((scope) => ({ action: scope.action, allowed: scope.action === action })), isLoading: false, - }); + })); setupMocks(); renderPage(); @@ -165,10 +185,10 @@ describe('AssignRoleWizardPage', () => { }); it('shows no roles when no scope is allowed', () => { - mockUseValidatePermissions.mockReturnValue({ + mockUseValidatePermissions.mockImplementation(mockPermissions({ data: scopeRoles.map(({ action }) => ({ action, allowed: false })), isLoading: false, - }); + })); setupMocks(); renderPage(); allRoles.forEach((role) => { @@ -177,15 +197,29 @@ describe('AssignRoleWizardPage', () => { }); it('ignores allowed permissions whose action is not a known role scope', () => { - mockUseValidatePermissions.mockReturnValue({ + mockUseValidatePermissions.mockImplementation(mockPermissions({ data: [{ action: 'some.unrelated.permission', allowed: true }], isLoading: false, - }); + })); setupMocks(); renderPage(); allRoles.forEach((role) => { expect(screen.queryByText(role.name)).not.toBeInTheDocument(); }); }); + + it('hides course roles when VIEW_COURSE_TEAM is not allowed even if MANAGE_COURSE_TEAM is allowed', () => { + mockUseValidatePermissions.mockImplementation( + mockPermissions(allowAllPermissions, { courseViewAllowed: false }), + ); + setupMocks(); + renderPage(); + courseRolesMetadata.forEach((role) => { + expect(screen.queryByText(role.name)).not.toBeInTheDocument(); + }); + libraryRolesMetadata.forEach((role) => { + expect(screen.getByText(role.name)).toBeInTheDocument(); + }); + }); }); }); diff --git a/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx index 58cc7f20..a5bd050e 100644 --- a/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx +++ b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx @@ -9,6 +9,7 @@ import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS, courseRolesMetadata, libraryRolesMetadata, MANAGE_TEAM_PERMISSIONS, } from '../roles-permissions'; +import { useViewTeamPermissions } from '../hooks/useViewTeamPermissions'; const AssignRoleWizardPage = () => { const intl = useIntl(); @@ -23,12 +24,15 @@ const AssignRoleWizardPage = () => { ? `${ROUTES.HOME_PATH}/user/${presetUser}` : returnTo; - const { data: permissionValidationResponse } = useValidateUserPermissionsNonSuspense(MANAGE_TEAM_PERMISSIONS); + const { data: managePermissions } = useValidateUserPermissionsNonSuspense(MANAGE_TEAM_PERMISSIONS); + const { isCourseViewAllowed } = useViewTeamPermissions(); - const rolesAssignable = permissionValidationResponse?.flatMap((p) => { + const rolesAssignable = managePermissions?.flatMap((p) => { if (!p.allowed) { return []; } if (p.action === CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM) { return libraryRolesMetadata; } - if (p.action === CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM) { return courseRolesMetadata; } + if (p.action === CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM) { + return isCourseViewAllowed ? courseRolesMetadata : []; + } return []; }); diff --git a/src/authz-module/roles-permissions/index.ts b/src/authz-module/roles-permissions/index.ts index 54ef22cc..982a59e3 100644 --- a/src/authz-module/roles-permissions/index.ts +++ b/src/authz-module/roles-permissions/index.ts @@ -1,5 +1,5 @@ import { CONTENT_COURSE_PERMISSIONS } from './course/constants'; -import { CONTENT_LIBRARY_PERMISSIONS } from './library/constants'; +import { CONTENT_LIBRARY_PERMISSIONS, libraryRolesMetadata as _libraryRolesMetadata } from './library/constants'; export { CONTENT_LIBRARY_PERMISSIONS, @@ -9,6 +9,8 @@ export { rolesLibraryObject, } from './library/constants'; +export const LIBRARY_ROLE_KEYS = _libraryRolesMetadata.map((r) => r.role).join(','); + export { CONTENT_COURSE_PERMISSIONS, courseResourceTypes, @@ -21,3 +23,8 @@ export const MANAGE_TEAM_PERMISSIONS: { action: string }[] = [ { action: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM }, { action: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM }, ]; + +export const VIEW_TEAM_PERMISSIONS: { action: string }[] = [ + { action: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM }, + { action: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM }, +]; diff --git a/src/authz-module/team-members/TeamMembersTable.test.tsx b/src/authz-module/team-members/TeamMembersTable.test.tsx index ba3aecfa..7b873d12 100644 --- a/src/authz-module/team-members/TeamMembersTable.test.tsx +++ b/src/authz-module/team-members/TeamMembersTable.test.tsx @@ -2,10 +2,24 @@ import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithAllProviders } from '@src/setupTest'; import { useAllRoleAssignments, useOrgs, useScopes } from '@src/authz-module/data/hooks'; +import type { GetAllRoleAssignmentsResponse } from '@src/authz-module/data/api'; +import { useViewTeamPermissions } from '@src/authz-module/hooks/useViewTeamPermissions'; +import { LIBRARY_ROLE_KEYS } from '@src/authz-module/roles-permissions'; import { ToastManagerProvider } from '@src/components/ToastManager/ToastManagerContext'; import TeamMembersTable from './TeamMembersTable'; -const mockedAllRoleAssignments = { +jest.mock('@src/authz-module/hooks/useViewTeamPermissions', () => ({ + useViewTeamPermissions: jest.fn(), +})); + +const mockUseViewTeamPermissions = useViewTeamPermissions as jest.Mock; + +const mockedAllRoleAssignments: { + data: GetAllRoleAssignmentsResponse | undefined; + error: Error | null; + isLoading: boolean; + refetch: jest.Mock; +} = { data: { results: [ { @@ -127,6 +141,11 @@ const mockApiResponses = ( describe('TeamMembersTable', () => { beforeEach(() => { mockNavigate.mockClear(); + mockUseViewTeamPermissions.mockReturnValue({ + isCourseViewAllowed: true, + isLibraryViewAllowed: true, + isLoading: false, + }); }); it('renders table with role assignments data', async () => { @@ -194,6 +213,27 @@ describe('TeamMembersTable', () => { expect(mockNavigate).toHaveBeenCalledWith('/authz/user/johndoe'); }); + it('renders safely when role assignments data is undefined', () => { + mockApiResponses({ ...mockedAllRoleAssignments, data: undefined }); + renderWithAllProviders(); + expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); + }); + + it('filters to library roles only when course view is not allowed', async () => { + mockUseViewTeamPermissions.mockReturnValue({ + isCourseViewAllowed: false, + isLibraryViewAllowed: true, + isLoading: false, + }); + mockApiResponses(); + renderWithAllProviders(); + await waitFor(() => { + expect(useAllRoleAssignments).toHaveBeenCalledWith( + expect.objectContaining({ roles: LIBRARY_ROLE_KEYS }), + ); + }); + }); + it('handles empty data gracefully', async () => { const allAsignmentsResponse = { data: { diff --git a/src/authz-module/team-members/TeamMembersTable.tsx b/src/authz-module/team-members/TeamMembersTable.tsx index dfdbce56..a683e0ff 100644 --- a/src/authz-module/team-members/TeamMembersTable.tsx +++ b/src/authz-module/team-members/TeamMembersTable.tsx @@ -7,6 +7,8 @@ import { } from '@openedx/paragon'; import { useToastManager } from '@src/components/ToastManager/ToastManagerContext'; +import { LIBRARY_ROLE_KEYS } from '@src/authz-module/roles-permissions'; +import { useViewTeamPermissions } from '@src/authz-module/hooks/useViewTeamPermissions'; import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings'; import OrgFilter from '@src/authz-module/components/TableControlBar/OrgFilter'; import RolesFilter from '@src/authz-module/components/TableControlBar/RolesFilter'; @@ -43,12 +45,19 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { const { querySettings, handleTableFetch } = useQuerySettings(initialQuerySettings); + const { isCourseViewAllowed } = useViewTeamPermissions(); + + const effectiveQuerySettings = useMemo(() => { + if (isCourseViewAllowed || querySettings.roles) { return querySettings; } + return { ...querySettings, roles: LIBRARY_ROLE_KEYS }; + }, [isCourseViewAllowed, querySettings]); + const { data: { results: roleAssignments, count } = { results: [], count: 0 }, isLoading: isLoadingAllRoleAssignments, error, refetch, - } = useAllRoleAssignments(querySettings); + } = useAllRoleAssignments(effectiveQuerySettings); const initialFilters = presetScope ? [{ id: 'scope', value: [presetScope] }] : [];