From fdd8aef6dc65f45391ab4f1461287fdc8badb6b4 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Fri, 3 Jul 2026 13:43:08 -0500 Subject: [PATCH 1/4] fix: hide course authoring content when enable_course_authoring is off --- src/authz-module/audit-user/index.tsx | 16 +++++- .../TableControlBar/RolesFilter.tsx | 9 ++- .../TableControlBar/ScopesFilter.test.tsx | 56 ++++++++++++++++--- .../TableControlBar/ScopesFilter.tsx | 14 ++++- src/authz-module/roles-permissions/index.ts | 5 ++ .../team-members/TeamMembersTable.tsx | 19 ++++++- 6 files changed, 102 insertions(+), 17 deletions(-) diff --git a/src/authz-module/audit-user/index.tsx b/src/authz-module/audit-user/index.tsx index 0619402a..8ff233ad 100644 --- a/src/authz-module/audit-user/index.tsx +++ b/src/authz-module/audit-user/index.tsx @@ -14,6 +14,7 @@ import { import AuthZLayout from '@src/authz-module/components/AuthZLayout'; import { useNavigate, useParams } from 'react-router-dom'; import { useUserAccount, useValidateUserPermissionsNonSuspense } from '@src/data/hooks'; +import { CONTENT_COURSE_PERMISSIONS, VIEW_TEAM_PERMISSIONS, libraryRolesMetadata } from '@src/authz-module/roles-permissions'; import baseMessages from '@src/authz-module/messages'; import AddRoleButton from '@src/authz-module/components/AddRoleButton'; import { @@ -32,6 +33,8 @@ import messages from './messages'; import ConfirmDeletionModal from '../components/ConfirmDeletionModal'; import { getCellHeader, getScopeManageActionPermission } from '../utils'; +const LIBRARY_ROLE_KEYS = libraryRolesMetadata.map((r) => r.role).join(','); + const AuditUserPage = () => { const { formatMessage } = useIntl(); const [columnsWithFiltersApplied, setColumnsWithFiltersApplied] = useState([]); @@ -42,9 +45,20 @@ const AuditUserPage = () => { isLoading: isLoadingUser, data: user, isError: isErrorUser, error: errorUser, } = useUserAccount(username); const { querySettings, handleTableFetch } = useQuerySettings(); + + const { data: permissions } = useValidateUserPermissionsNonSuspense(VIEW_TEAM_PERMISSIONS); + const isCourseViewAllowed = permissions + ? permissions.some((p) => p.action === CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM && p.allowed) + : true; + + 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.tsx b/src/authz-module/components/TableControlBar/RolesFilter.tsx index db66a8c2..da746461 100644 --- a/src/authz-module/components/TableControlBar/RolesFilter.tsx +++ b/src/authz-module/components/TableControlBar/RolesFilter.tsx @@ -2,7 +2,9 @@ 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 { + CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS, VIEW_TEAM_PERMISSIONS, +} from '@src/authz-module/roles-permissions'; import { CONTEXT_TYPES } from '@src/authz-module/constants'; import MultipleChoiceFilter from './MultipleChoiceFilter'; import { MultipleChoiceFilterProps } from './types'; @@ -14,10 +16,7 @@ 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 { data: permissions } = useValidateUserPermissionsNonSuspense(VIEW_TEAM_PERMISSIONS); // 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. diff --git a/src/authz-module/components/TableControlBar/ScopesFilter.test.tsx b/src/authz-module/components/TableControlBar/ScopesFilter.test.tsx index e2d0cbf0..a2ec6f3d 100644 --- a/src/authz-module/components/TableControlBar/ScopesFilter.test.tsx +++ b/src/authz-module/components/TableControlBar/ScopesFilter.test.tsx @@ -1,31 +1,47 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWrapper } from '@src/setupTest'; +import { useValidateUserPermissionsNonSuspense } from '@src/data/hooks'; +import { useScopes } from '@src/authz-module/data/hooks'; +import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from '@src/authz-module/roles-permissions'; import ScopesFilter from './ScopesFilter'; +jest.mock('@src/data/hooks', () => ({ + 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 all scopes while permissions are loading', () => { + mockUsePermissions.mockReturnValue({ data: undefined }); + renderWrapper(); + expect(mockUseScopes).toHaveBeenCalledWith( + expect.not.objectContaining({ scopeType: 'library' }), + ); + }); }); diff --git a/src/authz-module/components/TableControlBar/ScopesFilter.tsx b/src/authz-module/components/TableControlBar/ScopesFilter.tsx index a93854a6..4de700a9 100644 --- a/src/authz-module/components/TableControlBar/ScopesFilter.tsx +++ b/src/authz-module/components/TableControlBar/ScopesFilter.tsx @@ -1,6 +1,8 @@ import { useMemo, useState } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { LocationOn } from '@openedx/paragon/icons'; +import { useValidateUserPermissionsNonSuspense } from '@src/data/hooks'; +import { CONTENT_COURSE_PERMISSIONS, VIEW_TEAM_PERMISSIONS } from '@src/authz-module/roles-permissions'; import { useScopes } from '@src/authz-module/data/hooks'; import { DEFAULT_FILTER_PAGE_SIZE } from '@src/authz-module/constants'; import { MultipleChoiceFilterProps } from './types'; @@ -15,7 +17,17 @@ const ScopesFilter = ({ }: ScopesFilterProps) => { const { formatMessage } = useIntl(); const [searchValue, setSearchValue] = useState(undefined); - const { data: scopesData } = useScopes({ search: searchValue, pageSize: DEFAULT_FILTER_PAGE_SIZE }); + + const { data: permissions } = useValidateUserPermissionsNonSuspense(VIEW_TEAM_PERMISSIONS); + const isCourseViewAllowed = permissions + ? permissions.some((p) => p.action === CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM && p.allowed) + : true; + + 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/roles-permissions/index.ts b/src/authz-module/roles-permissions/index.ts index 54ef22cc..c4723f54 100644 --- a/src/authz-module/roles-permissions/index.ts +++ b/src/authz-module/roles-permissions/index.ts @@ -21,3 +21,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.tsx b/src/authz-module/team-members/TeamMembersTable.tsx index dfdbce56..c705db57 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 { useValidateUserPermissionsNonSuspense } from '@src/data/hooks'; +import { CONTENT_COURSE_PERMISSIONS, VIEW_TEAM_PERMISSIONS, libraryRolesMetadata } from '@src/authz-module/roles-permissions'; 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'; @@ -18,9 +20,12 @@ import { } from '@src/authz-module/components/TableCells'; import { useAllRoleAssignments } from '@src/authz-module/data/hooks'; import { TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants'; +import { UserRole } from '@src/types'; import messages from './messages'; import TableFooter from '../components/TableFooter/TableFooter'; +const LIBRARY_ROLE_KEYS = libraryRolesMetadata.map((r) => r.role).join(','); + interface TeamMembersTableProps { presetScope?: string; } @@ -43,12 +48,22 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { const { querySettings, handleTableFetch } = useQuerySettings(initialQuerySettings); + const { data: permissions } = useValidateUserPermissionsNonSuspense(VIEW_TEAM_PERMISSIONS); + const isCourseViewAllowed = permissions + ? permissions.some((p) => p.action === CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM && p.allowed) + : true; + + 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 }, + data: { results: roleAssignments, count } = { results: [] as UserRole[], count: 0 }, isLoading: isLoadingAllRoleAssignments, error, refetch, - } = useAllRoleAssignments(querySettings); + } = useAllRoleAssignments(effectiveQuerySettings); const initialFilters = presetScope ? [{ id: 'scope', value: [presetScope] }] : []; From 537f02619f7c1b7f513851c6beb9f21a2a5915aa Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Fri, 3 Jul 2026 13:43:35 -0500 Subject: [PATCH 2/4] fix: enhance role assignment logic to conditionally hide course roles based on view permissions --- .../AssignRoleWizardPage.test.tsx | 48 ++++++++++++++++--- .../AssignRoleWizardPage.tsx | 15 ++++-- 2 files changed, 52 insertions(+), 11 deletions(-) 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..5685774d 100644 --- a/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx +++ b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx @@ -7,7 +7,7 @@ import { ROUTES } from '../constants'; import messages from './messages'; import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS, courseRolesMetadata, libraryRolesMetadata, - MANAGE_TEAM_PERMISSIONS, + MANAGE_TEAM_PERMISSIONS, VIEW_TEAM_PERMISSIONS, } from '../roles-permissions'; const AssignRoleWizardPage = () => { @@ -23,12 +23,19 @@ const AssignRoleWizardPage = () => { ? `${ROUTES.HOME_PATH}/user/${presetUser}` : returnTo; - const { data: permissionValidationResponse } = useValidateUserPermissionsNonSuspense(MANAGE_TEAM_PERMISSIONS); + const { data: managePermissions } = useValidateUserPermissionsNonSuspense(MANAGE_TEAM_PERMISSIONS); + const { data: viewPermissions } = useValidateUserPermissionsNonSuspense(VIEW_TEAM_PERMISSIONS); - const rolesAssignable = permissionValidationResponse?.flatMap((p) => { + const isCourseViewAllowed = viewPermissions + ? viewPermissions.some((p) => p.action === CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM && p.allowed) + : true; + + 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 []; }); From b049a3795792e7233821c5958d78ee1eabd7e91c Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Fri, 3 Jul 2026 13:44:02 -0500 Subject: [PATCH 3/4] fix: refactor permission handling to use custom hook for team view permissions --- src/authz-module/audit-user/index.tsx | 10 ++---- .../TableControlBar/RolesFilter.test.tsx | 2 +- .../TableControlBar/RolesFilter.tsx | 29 +++++---------- .../TableControlBar/ScopesFilter.tsx | 8 ++--- .../hooks/useViewTeamPermissions.ts | 18 ++++++++++ .../AssignRoleWizardPage.tsx | 9 ++--- src/authz-module/roles-permissions/index.ts | 4 ++- .../team-members/TeamMembersTable.test.tsx | 35 +++++++++++++++++++ .../team-members/TeamMembersTable.tsx | 11 ++---- 9 files changed, 77 insertions(+), 49 deletions(-) create mode 100644 src/authz-module/hooks/useViewTeamPermissions.ts diff --git a/src/authz-module/audit-user/index.tsx b/src/authz-module/audit-user/index.tsx index 8ff233ad..b5aaa221 100644 --- a/src/authz-module/audit-user/index.tsx +++ b/src/authz-module/audit-user/index.tsx @@ -14,7 +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 { CONTENT_COURSE_PERMISSIONS, VIEW_TEAM_PERMISSIONS, libraryRolesMetadata } from '@src/authz-module/roles-permissions'; +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 { @@ -33,8 +34,6 @@ import messages from './messages'; import ConfirmDeletionModal from '../components/ConfirmDeletionModal'; import { getCellHeader, getScopeManageActionPermission } from '../utils'; -const LIBRARY_ROLE_KEYS = libraryRolesMetadata.map((r) => r.role).join(','); - const AuditUserPage = () => { const { formatMessage } = useIntl(); const [columnsWithFiltersApplied, setColumnsWithFiltersApplied] = useState([]); @@ -46,10 +45,7 @@ const AuditUserPage = () => { } = useUserAccount(username); const { querySettings, handleTableFetch } = useQuerySettings(); - const { data: permissions } = useValidateUserPermissionsNonSuspense(VIEW_TEAM_PERMISSIONS); - const isCourseViewAllowed = permissions - ? permissions.some((p) => p.action === CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM && p.allowed) - : true; + const { isCourseViewAllowed } = useViewTeamPermissions(); const effectiveQuerySettings = useMemo(() => { if (isCourseViewAllowed || querySettings.roles) { return querySettings; } 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 da746461..95c6bd03 100644 --- a/src/authz-module/components/TableControlBar/RolesFilter.tsx +++ b/src/authz-module/components/TableControlBar/RolesFilter.tsx @@ -1,10 +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, VIEW_TEAM_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'; @@ -16,24 +13,16 @@ const RolesFilter = ({ filterButtonText, filterValue, setFilter, disabled, }: RolesFilterProps) => { const intl = useIntl(); - const { data: permissions } = useValidateUserPermissionsNonSuspense(VIEW_TEAM_PERMISSIONS); + 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 ( (undefined); - const { data: permissions } = useValidateUserPermissionsNonSuspense(VIEW_TEAM_PERMISSIONS); - const isCourseViewAllowed = permissions - ? permissions.some((p) => p.action === CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM && p.allowed) - : true; + const { isCourseViewAllowed } = useViewTeamPermissions(); const { data: scopesData } = useScopes({ search: searchValue, diff --git a/src/authz-module/hooks/useViewTeamPermissions.ts b/src/authz-module/hooks/useViewTeamPermissions.ts new file mode 100644 index 00000000..00d94674 --- /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) + : true; + + const isLibraryViewAllowed = permissions + ? permissions.some((p) => p.action === CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM && p.allowed) + : true; + + return { isCourseViewAllowed, isLibraryViewAllowed, isLoading }; +}; diff --git a/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx index 5685774d..a5bd050e 100644 --- a/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx +++ b/src/authz-module/role-assignation-wizard/AssignRoleWizardPage.tsx @@ -7,8 +7,9 @@ import { ROUTES } from '../constants'; import messages from './messages'; import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS, courseRolesMetadata, libraryRolesMetadata, - MANAGE_TEAM_PERMISSIONS, VIEW_TEAM_PERMISSIONS, + MANAGE_TEAM_PERMISSIONS, } from '../roles-permissions'; +import { useViewTeamPermissions } from '../hooks/useViewTeamPermissions'; const AssignRoleWizardPage = () => { const intl = useIntl(); @@ -24,11 +25,7 @@ const AssignRoleWizardPage = () => { : returnTo; const { data: managePermissions } = useValidateUserPermissionsNonSuspense(MANAGE_TEAM_PERMISSIONS); - const { data: viewPermissions } = useValidateUserPermissionsNonSuspense(VIEW_TEAM_PERMISSIONS); - - const isCourseViewAllowed = viewPermissions - ? viewPermissions.some((p) => p.action === CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM && p.allowed) - : true; + const { isCourseViewAllowed } = useViewTeamPermissions(); const rolesAssignable = managePermissions?.flatMap((p) => { if (!p.allowed) { return []; } diff --git a/src/authz-module/roles-permissions/index.ts b/src/authz-module/roles-permissions/index.ts index c4723f54..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, diff --git a/src/authz-module/team-members/TeamMembersTable.test.tsx b/src/authz-module/team-members/TeamMembersTable.test.tsx index ba3aecfa..9f9cd834 100644 --- a/src/authz-module/team-members/TeamMembersTable.test.tsx +++ b/src/authz-module/team-members/TeamMembersTable.test.tsx @@ -2,9 +2,17 @@ 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 { 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'; +jest.mock('@src/authz-module/hooks/useViewTeamPermissions', () => ({ + useViewTeamPermissions: jest.fn(), +})); + +const mockUseViewTeamPermissions = useViewTeamPermissions as jest.Mock; + const mockedAllRoleAssignments = { data: { results: [ @@ -127,6 +135,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 +207,28 @@ describe('TeamMembersTable', () => { expect(mockNavigate).toHaveBeenCalledWith('/authz/user/johndoe'); }); + it('renders safely when role assignments data is undefined', () => { + // @ts-ignore + 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 c705db57..b03fcd3b 100644 --- a/src/authz-module/team-members/TeamMembersTable.tsx +++ b/src/authz-module/team-members/TeamMembersTable.tsx @@ -7,8 +7,8 @@ import { } from '@openedx/paragon'; import { useToastManager } from '@src/components/ToastManager/ToastManagerContext'; -import { useValidateUserPermissionsNonSuspense } from '@src/data/hooks'; -import { CONTENT_COURSE_PERMISSIONS, VIEW_TEAM_PERMISSIONS, libraryRolesMetadata } from '@src/authz-module/roles-permissions'; +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'; @@ -24,8 +24,6 @@ import { UserRole } from '@src/types'; import messages from './messages'; import TableFooter from '../components/TableFooter/TableFooter'; -const LIBRARY_ROLE_KEYS = libraryRolesMetadata.map((r) => r.role).join(','); - interface TeamMembersTableProps { presetScope?: string; } @@ -48,10 +46,7 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { const { querySettings, handleTableFetch } = useQuerySettings(initialQuerySettings); - const { data: permissions } = useValidateUserPermissionsNonSuspense(VIEW_TEAM_PERMISSIONS); - const isCourseViewAllowed = permissions - ? permissions.some((p) => p.action === CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM && p.allowed) - : true; + const { isCourseViewAllowed } = useViewTeamPermissions(); const effectiveQuerySettings = useMemo(() => { if (isCourseViewAllowed || querySettings.roles) { return querySettings; } From 92351b07e3b32267d0d07a16fa730feace5150dc Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Fri, 3 Jul 2026 13:54:51 -0500 Subject: [PATCH 4/4] refactor: address feedback --- .../components/TableControlBar/ScopesFilter.test.tsx | 6 +++--- src/authz-module/hooks/useViewTeamPermissions.ts | 4 ++-- src/authz-module/team-members/TeamMembersTable.test.tsx | 9 +++++++-- src/authz-module/team-members/TeamMembersTable.tsx | 3 +-- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/authz-module/components/TableControlBar/ScopesFilter.test.tsx b/src/authz-module/components/TableControlBar/ScopesFilter.test.tsx index a2ec6f3d..95eaac5e 100644 --- a/src/authz-module/components/TableControlBar/ScopesFilter.test.tsx +++ b/src/authz-module/components/TableControlBar/ScopesFilter.test.tsx @@ -101,11 +101,11 @@ describe('ScopesFilter', () => { ); }); - it('defaults to showing all scopes while permissions are loading', () => { - mockUsePermissions.mockReturnValue({ data: undefined }); + it('defaults to showing only library scopes while permissions are loading', () => { + mockUsePermissions.mockReturnValue({ data: undefined, isLoading: true }); renderWrapper(); expect(mockUseScopes).toHaveBeenCalledWith( - expect.not.objectContaining({ scopeType: 'library' }), + expect.objectContaining({ scopeType: 'library' }), ); }); }); diff --git a/src/authz-module/hooks/useViewTeamPermissions.ts b/src/authz-module/hooks/useViewTeamPermissions.ts index 00d94674..84b73f4c 100644 --- a/src/authz-module/hooks/useViewTeamPermissions.ts +++ b/src/authz-module/hooks/useViewTeamPermissions.ts @@ -8,11 +8,11 @@ export const useViewTeamPermissions = () => { const isCourseViewAllowed = permissions ? permissions.some((p) => p.action === CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM && p.allowed) - : true; + : false; const isLibraryViewAllowed = permissions ? permissions.some((p) => p.action === CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM && p.allowed) - : true; + : false; return { isCourseViewAllowed, isLibraryViewAllowed, isLoading }; }; diff --git a/src/authz-module/team-members/TeamMembersTable.test.tsx b/src/authz-module/team-members/TeamMembersTable.test.tsx index 9f9cd834..7b873d12 100644 --- a/src/authz-module/team-members/TeamMembersTable.test.tsx +++ b/src/authz-module/team-members/TeamMembersTable.test.tsx @@ -2,6 +2,7 @@ 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'; @@ -13,7 +14,12 @@ jest.mock('@src/authz-module/hooks/useViewTeamPermissions', () => ({ const mockUseViewTeamPermissions = useViewTeamPermissions as jest.Mock; -const mockedAllRoleAssignments = { +const mockedAllRoleAssignments: { + data: GetAllRoleAssignmentsResponse | undefined; + error: Error | null; + isLoading: boolean; + refetch: jest.Mock; +} = { data: { results: [ { @@ -208,7 +214,6 @@ describe('TeamMembersTable', () => { }); it('renders safely when role assignments data is undefined', () => { - // @ts-ignore mockApiResponses({ ...mockedAllRoleAssignments, data: undefined }); renderWithAllProviders(); expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); diff --git a/src/authz-module/team-members/TeamMembersTable.tsx b/src/authz-module/team-members/TeamMembersTable.tsx index b03fcd3b..a683e0ff 100644 --- a/src/authz-module/team-members/TeamMembersTable.tsx +++ b/src/authz-module/team-members/TeamMembersTable.tsx @@ -20,7 +20,6 @@ import { } from '@src/authz-module/components/TableCells'; import { useAllRoleAssignments } from '@src/authz-module/data/hooks'; import { TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants'; -import { UserRole } from '@src/types'; import messages from './messages'; import TableFooter from '../components/TableFooter/TableFooter'; @@ -54,7 +53,7 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { }, [isCourseViewAllowed, querySettings]); const { - data: { results: roleAssignments, count } = { results: [] as UserRole[], count: 0 }, + data: { results: roleAssignments, count } = { results: [], count: 0 }, isLoading: isLoadingAllRoleAssignments, error, refetch,