diff --git a/src/authz-module/authz-home/index.test.tsx b/src/authz-module/authz-home/index.test.tsx index ffb4841f..a40db7b5 100644 --- a/src/authz-module/authz-home/index.test.tsx +++ b/src/authz-module/authz-home/index.test.tsx @@ -1,8 +1,10 @@ import { screen } from '@testing-library/react'; -import { useAllRoleAssignments, useOrgs, useScopes } from '@src/authz-module/data/hooks'; +import { useTeamMembersAssignments, useOrgs, useScopes } from '@src/authz-module/data/hooks'; import { renderWithAllProviders } from '@src/testUtils'; import userEvent from '@testing-library/user-event'; import { ToastManagerProvider } from '@src/components/ToastManager/ToastManagerContext'; +import { useViewTeamPermissions } from '@src/authz-module/hooks/useViewTeamPermissions'; +import { CustomErrors } from '@src/constants'; import AuthzHome from './index'; import messages from './messages'; @@ -14,8 +16,12 @@ jest.mock('@src/authz-module/hooks/useCourseAuthoringFlag', () => ({ }), })); +jest.mock('@src/authz-module/hooks/useViewTeamPermissions', () => ({ + useViewTeamPermissions: jest.fn(), +})); + jest.mock('@src/authz-module/data/hooks', () => ({ - useAllRoleAssignments: jest.fn(), + useTeamMembersAssignments: jest.fn(), useOrgs: jest.fn(), useScopes: jest.fn(), })); @@ -38,6 +44,17 @@ const emptyScopesResponse = { isFetchingNextPage: false, }; +// Nothing is allowed while the check is in flight; cases override only what they are about. +const permissionsInFlight = { + isCourseViewAllowed: false, + isLibraryViewAllowed: false, + isLoading: true, +}; + +const mockViewTeamPermissions = (overrides: Partial = {}) => { + (useViewTeamPermissions as jest.Mock).mockReturnValue({ ...permissionsInFlight, ...overrides }); +}; + const renderAuthzHome = () => renderWithAllProviders( @@ -46,7 +63,10 @@ const renderAuthzHome = () => renderWithAllProviders( describe('AuthzHome', () => { beforeEach(() => { - (useAllRoleAssignments as jest.Mock).mockReturnValue(emptyResponse); + // Call history is asserted on below, so it must not carry over between cases. + jest.clearAllMocks(); + mockViewTeamPermissions({ isCourseViewAllowed: true, isLibraryViewAllowed: true, isLoading: false }); + (useTeamMembersAssignments as jest.Mock).mockReturnValue(emptyResponse); (useOrgs as jest.Mock).mockReturnValue(emptyResponse); (useScopes as jest.Mock).mockReturnValue(emptyScopesResponse); }); @@ -77,14 +97,44 @@ describe('AuthzHome', () => { expect(screen.getByRole('button', { name: 'Libraries' })).toBeInTheDocument(); }); + it('denies access to the whole page when the user may view neither courses nor libraries', () => { + mockViewTeamPermissions({ isLoading: false }); + + // Neither tab has anything to show, so the page hands off to the error boundary. + expect(() => renderAuthzHome()).toThrow(CustomErrors.NO_ACCESS); + }); + + it('shows a loader instead of the page while the permission check is in flight', () => { + mockViewTeamPermissions(); + + renderAuthzHome(); + + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.getByText('Loading')).toBeInTheDocument(); + // Nothing behind the gate renders yet, so no listing request is fired for a user + // who may turn out to be denied. + expect(screen.queryByText(messages['authz.manage.page.title'].defaultMessage)).not.toBeInTheDocument(); + expect(useTeamMembersAssignments).not.toHaveBeenCalled(); + }); + + it('renders the page when only library roles may be viewed', () => { + mockViewTeamPermissions({ isLibraryViewAllowed: true, isLoading: false }); + + renderAuthzHome(); + + expect(screen.getByText(messages['authz.manage.page.title'].defaultMessage)).toBeInTheDocument(); + }); + it('renders the TeamMembersTable component in the team members tab', () => { renderAuthzHome(); expect(screen.getByText(messages['authz.manage.page.title'].defaultMessage)).toBeInTheDocument(); - expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Username')).toBeInTheDocument(); expect(screen.getByText('Email')).toBeInTheDocument(); - expect(screen.getAllByText('Organization').length).toBe(2); // Header and org filter; - expect(screen.getAllByText('Scope').length).toBe(2); // Header and scope filter; - expect(screen.getAllByText('Role').length).toBe(2); // Header and role filter; + expect(screen.getByText('Assigned roles')).toBeInTheDocument(); + // Org, scope and role are filter controls only — they are no longer column headers. + expect(screen.getAllByText('Organization').length).toBe(1); + expect(screen.getAllByText('Scope').length).toBe(1); + expect(screen.getAllByText('Role').length).toBe(1); expect(screen.getByText('Actions')).toBeInTheDocument(); }); }); diff --git a/src/authz-module/authz-home/index.tsx b/src/authz-module/authz-home/index.tsx index 15250122..200c9e17 100644 --- a/src/authz-module/authz-home/index.tsx +++ b/src/authz-module/authz-home/index.tsx @@ -1,8 +1,11 @@ import { useIntl } from '@openedx/frontend-base'; import { Tab, Tabs } from '@openedx/paragon'; import { useLocation, useSearchParams } from 'react-router-dom'; -import TeamMembersTable from '@src/authz-module/team-members/TeamMembersTable'; +import { TeamMembersTable } from '@src/authz-module/team-members'; import AddRoleButton from '@src/authz-module/components/AddRoleButton'; +import { useViewTeamPermissions } from '@src/authz-module/hooks/useViewTeamPermissions'; +import { CustomErrors } from '@src/constants'; +import LoadingPage from '@src/components/LoadingPage'; import RolesPermissions from '@src/authz-module/roles-permissions/RolesPermissions'; import AuthZLayout from '@src/authz-module/components/AuthZLayout'; @@ -20,6 +23,27 @@ const AuthzHome = () => { const pageTitle = intl.formatMessage(messages['authz.manage.page.title']); + const { + isCourseViewAllowed, isLibraryViewAllowed, isLoading: isLoadingPermissions, + } = useViewTeamPermissions(); + + /** + * It gates the whole page whether the user is allowed to view the team members. If + * the user is not allowed, an error is thrown to display the access denied message. + * + * Rendering is held until the check settles rather than started optimistically, so a + * user who turns out to be denied never fires the listing and filter requests behind + * the page. Both flags read false while it is in flight, which would otherwise deny + * everyone for a frame. + */ + if (isLoadingPermissions) { + return ; + } + + if (!isCourseViewAllowed && !isLibraryViewAllowed) { + throw new Error(CustomErrors.NO_ACCESS); + } + return ( { + const mockOnClick = jest.fn(); + const defaultProps = { + label: 'View more details', + onClick: mockOnClick, + isExpanded: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('exposes the label as a button, not a link, since it triggers an action in place', () => { + renderWrapper(); + + expect(screen.getByRole('button', { name: 'View more details' })).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it('shows a chevron and announces itself as closed while collapsed', () => { + renderWrapper(); + + const button = screen.getByRole('button', { name: 'View more details' }); + expect(button.querySelector('svg')).toBeInTheDocument(); + expect(button).toHaveAttribute('aria-expanded', 'false'); + }); + + it('announces itself as open once expanded', () => { + renderWrapper(); + + const button = screen.getByRole('button', { name: 'View more details' }); + expect(button.querySelector('svg')).toBeInTheDocument(); + expect(button).toHaveAttribute('aria-expanded', 'true'); + }); + }); + + describe('user interactions', () => { + it('calls onClick handler when user clicks the button', async () => { + const user = userEvent.setup(); + renderWrapper(); + + await user.click(screen.getByRole('button', { name: 'View more details' })); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + it('handles multiple clicks correctly', async () => { + const user = userEvent.setup(); + renderWrapper(); + + const button = screen.getByRole('button', { name: 'View more details' }); + await user.click(button); + await user.click(button); + await user.click(button); + + expect(mockOnClick).toHaveBeenCalledTimes(3); + }); + + it('can be reached and activated with the keyboard alone', async () => { + const user = userEvent.setup(); + renderWrapper(); + + await user.tab(); + expect(screen.getByRole('button', { name: 'View more details' })).toHaveFocus(); + + await user.keyboard('{Enter}'); + expect(mockOnClick).toHaveBeenCalledTimes(1); + + await user.keyboard(' '); + expect(mockOnClick).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/authz-module/components/ExpandableButton.tsx b/src/authz-module/components/ExpandableButton.tsx new file mode 100644 index 00000000..d02f5cec --- /dev/null +++ b/src/authz-module/components/ExpandableButton.tsx @@ -0,0 +1,32 @@ +import { Button } from '@openedx/paragon'; +import { ExpandLess, ExpandMore } from '@openedx/paragon/icons'; + +interface ExpandableButtonProps { + label: string; + onClick: () => void; + isExpanded: boolean; +} + +/** + * Link-styled trigger that expands something in place: a table row, a list of items. + * + * A real ` +); + +export default ExpandableButton; diff --git a/src/authz-module/components/TableCells.test.tsx b/src/authz-module/components/TableCells.test.tsx index fbf116b9..31814ea3 100644 --- a/src/authz-module/components/TableCells.test.tsx +++ b/src/authz-module/components/TableCells.test.tsx @@ -3,8 +3,6 @@ import { initializeMocks, renderWrapper } from '@src/testUtils'; import userEvent from '@testing-library/user-event'; import { DataTableContext } from '@openedx/paragon'; import { - NameCell, - ViewActionCell, RoleCell, OrgCell, ScopeCell, @@ -29,233 +27,6 @@ describe('TableCells Components', () => { jest.clearAllMocks(); }); - describe('NameCell', () => { - const mockUserRole = { - isSuperadmin: false, - role: 'course_staff', - org: 'OpenedX', - scope: 'course-v1:OpenedX+DemoX+DemoCourse', - permissionCount: 27, - fullName: 'John Doe', - username: 'johndoe', - email: 'johndoe@example.com', - }; - const mockCellProps = { - row: { - id: '0', - original: mockUserRole, - }, - }; - beforeEach(() => { - initializeMocks(); - }); - - it('displays the full name when available', () => { - renderWrapper(); - expect(screen.getByText('John Doe')).toBeInTheDocument(); - }); - - it('displays username when full name is not available', () => { - const propsWithoutFullName = { - row: { - id: '0', - original: { - ...mockUserRole, - fullName: undefined, - }, - }, - }; - - renderWrapper(); - expect(screen.getByText('johndoe')).toBeInTheDocument(); - }); - - it('displays username when full name is empty string', () => { - const propsWithEmptyFullName = { - row: { - id: '0', - original: { - ...mockUserRole, - fullName: '', - }, - }, - }; - - renderWrapper(); - expect(screen.getByText('johndoe')).toBeInTheDocument(); - }); - - it('shows current user indicator when username matches authenticated user', () => { - const currentUserProps = { - row: { - id: '0', - original: { - ...mockUserRole, - username: 'testuser', - fullName: 'Test User', - }, - }, - }; - - renderWrapper(); - expect(screen.getByText('Test User')).toBeInTheDocument(); - expect(screen.getByText(/\(Me\)/)).toBeInTheDocument(); - }); - - it('does not show current user indicator when username does not match authenticated user', () => { - renderWrapper(); - expect(screen.getByText('John Doe')).toBeInTheDocument(); - expect(screen.queryByText(/\(Me\)/)).not.toBeInTheDocument(); - }); - - it('shows current user indicator with username fallback when no full name is provided', () => { - const currentUserPropsNoFullName = { - row: { - id: '0', - original: { - ...mockUserRole, - username: 'testuser', - fullName: undefined, - }, - }, - }; - - renderWrapper(); - expect(screen.getByText('testuser')).toBeInTheDocument(); - expect(screen.getByText(/\(Me\)/)).toBeInTheDocument(); - }); - - it('handles missing username in authenticated user gracefully', () => { - const contextWithoutUsername = { - authenticatedUser: { - username: undefined, - email: 'testuser@example.com', - }, - }; - - renderWrapper(, contextWithoutUsername); - expect(screen.getByText('John Doe')).toBeInTheDocument(); - expect(screen.queryByText(/\(Me\)/)).not.toBeInTheDocument(); - }); - }); - - describe('ViewActionCell', () => { - const mockUserRole = { - isSuperadmin: false, - role: 'course_staff', - org: 'OpenedX', - scope: 'course-v1:OpenedX+DemoX+DemoCourse', - permissionCount: 27, - fullName: 'John Doe', - username: 'johndoe', - email: 'johndoe@example.com', - }; - - const mockCellProps = { - row: { - id: '0', - original: mockUserRole, - }, - }; - beforeEach(() => { - initializeMocks(); - mockNavigate.mockClear(); - }); - - it('renders view action button', () => { - renderWrapper(); - const viewButton = screen.getByRole('button', { name: /view/i }); - expect(viewButton).toBeInTheDocument(); - }); - - it('has correct accessibility attributes', () => { - renderWrapper(); - const viewButton = screen.getByRole('button', { name: /view/i }); - expect(viewButton).toHaveAttribute('aria-label'); - }); - - it('navigates to user profile when clicked', async () => { - const user = userEvent.setup(); - renderWrapper(); - - const viewButton = screen.getByRole('button', { name: /view/i }); - await user.click(viewButton); - - expect(mockNavigate).toHaveBeenCalledWith('/admin-console/authz/user/johndoe'); - }); - - it('navigates with correct username for different user', async () => { - const user = userEvent.setup(); - const differentUserProps = { - row: { - id: '0', - original: { - ...mockUserRole, - username: 'janedoe', - }, - }, - }; - - renderWrapper(); - - const viewButton = screen.getByRole('button', { name: /view/i }); - await user.click(viewButton); - - expect(mockNavigate).toHaveBeenCalledWith('/admin-console/authz/user/janedoe'); - }); - - it('handles empty username gracefully', async () => { - const user = userEvent.setup(); - const emptyUsernameProps = { - row: { - id: '0', - original: { - ...mockUserRole, - username: '', - }, - }, - }; - - renderWrapper(); - - const viewButton = screen.getByRole('button', { name: /view/i }); - await user.click(viewButton); - - expect(mockNavigate).toHaveBeenCalledWith('/admin-console/authz/user/'); - }); - - it('handles special characters in username', async () => { - const user = userEvent.setup(); - const specialUsernameProps = { - row: { - id: '0', - original: { - ...mockUserRole, - username: 'user+with@special.chars', - }, - }, - }; - - renderWrapper(); - - const viewButton = screen.getByRole('button', { name: /view/i }); - await user.click(viewButton); - - expect(mockNavigate).toHaveBeenCalledWith('/admin-console/authz/user/user%2Bwith%40special.chars'); - }); - - it('disables the view action and shows a tooltip when course authoring is disabled for the course', async () => { - const user = userEvent.setup(); - renderWrapper( false} />); - - const viewButton = screen.getByRole('button', { name: /view/i }); - expect(viewButton).toBeDisabled(); - - await user.hover(viewButton); - expect(screen.getByText(/manage its team in Studio instead/i)).toBeInTheDocument(); - }); - }); - describe('RoleCell', () => { const mockCell = { getCellProps: jest.fn(() => ({ 'data-testid': 'role-cell' })), @@ -321,7 +92,7 @@ describe('TableCells Components', () => { }); describe('OrgCell', () => { - it('displays "All Organizations" for Django superuser role', () => { + it('displays "All platform" for Django superuser role', () => { const props = { value: 'Test Org', row: { @@ -335,11 +106,11 @@ describe('TableCells Components', () => { renderWrapper(); - expect(screen.getByText('All Organizations')).toBeInTheDocument(); + expect(screen.getByText('All platform')).toBeInTheDocument(); expect(screen.queryByText('Test Org')).not.toBeInTheDocument(); }); - it('displays "All Organizations" for Django global staff role', () => { + it('displays "All platform" for Django global staff role', () => { const props = { value: 'Test Org', row: { @@ -353,10 +124,30 @@ describe('TableCells Components', () => { renderWrapper(); - expect(screen.getByText('All Organizations')).toBeInTheDocument(); + expect(screen.getByText('All platform')).toBeInTheDocument(); expect(screen.queryByText('Test Org')).not.toBeInTheDocument(); }); + const orgProps = (original: { role: string; scope: string; org: string }) => ({ + value: original.org, + row: { id: '0', original: { permissionCount: 1, ...original } }, + column: { id: 'org' }, + }); + + it('displays "All platform" for a wildcard organization', () => { + renderWrapper(); + + expect(screen.getByText('All platform')).toBeInTheDocument(); + expect(screen.queryByText('*')).not.toBeInTheDocument(); + }); + + it('shows the organization for an organization-wide scope', () => { + renderWrapper(); + + expect(screen.getByText('MathDept')).toBeInTheDocument(); + expect(screen.queryByText('All platform')).not.toBeInTheDocument(); + }); + it('displays the actual org value for non-Django roles', () => { const props = { value: 'Test Organization', @@ -372,7 +163,7 @@ describe('TableCells Components', () => { renderWrapper(); expect(screen.getByText('Test Organization')).toBeInTheDocument(); - expect(screen.queryByText('All Organizations')).not.toBeInTheDocument(); + expect(screen.queryByText('All platform')).not.toBeInTheDocument(); }); }); @@ -413,6 +204,45 @@ describe('TableCells Components', () => { expect(screen.queryByText('course')).not.toBeInTheDocument(); }); + const scopeProps = ( + original: { role: string; scope: string; org: string }, + ) => ({ + value: 'unused', + row: { id: '0', original: { permissionCount: 1, ...original } }, + column: { id: 'scope' }, + }); + + it('names a platform-wide course scope instead of showing its wildcard key', () => { + renderWrapper(); + + expect(screen.getByText('All courses')).toBeInTheDocument(); + expect(screen.queryByText('course-v1:*')).not.toBeInTheDocument(); + }); + + it('names a platform-wide library scope', () => { + renderWrapper(); + + expect(screen.getByText('All libraries')).toBeInTheDocument(); + }); + + it('names an organization-wide course scope', () => { + renderWrapper(); + + expect(screen.getByText('All courses')).toBeInTheDocument(); + }); + + it('names an organization-wide library scope', () => { + renderWrapper(); + + expect(screen.getByText('All libraries')).toBeInTheDocument(); + }); + + it('shows the scope key when a wildcard belongs to a different organization', () => { + renderWrapper(); + + expect(screen.getByText('course-v1:MathDept+*')).toBeInTheDocument(); + }); + it('displays the actual scope value for non-Django roles', () => { const props = { value: 'Course Scope', @@ -670,6 +500,16 @@ describe('TableCells Components', () => { expect(screen.getByText(/view all permissions/i)).toBeInTheDocument(); }); + it('reports whether the permissions breakdown is open', () => { + const { rerender } = renderWrapper(); + expect(screen.getByRole('button', { name: /view all permissions/i })) + .toHaveAttribute('aria-expanded', 'false'); + + rerender(); + expect(screen.getByRole('button', { name: /hide all permissions/i })) + .toHaveAttribute('aria-expanded', 'true'); + }); + it('handles toggle expand functionality with accordion behavior', async () => { const user = userEvent.setup(); const mockToggleRowExpanded = jest.fn(); diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx index f1df74d1..77d46f42 100644 --- a/src/authz-module/components/TableCells.tsx +++ b/src/authz-module/components/TableCells.tsx @@ -1,33 +1,24 @@ -import { SiteContext, useIntl } from '@openedx/frontend-base'; +import { useIntl } from '@openedx/frontend-base'; import { - RemoveRedEye, - Delete, ExpandMore, + Delete, Info, } from '@openedx/paragon/icons'; import { UserRoleWithPermissions, RoleToDelete } from '@src/types'; -import { useNavigate } from 'react-router-dom'; -import { useContext, useMemo, type ComponentProps } from 'react'; +import { useMemo, type ComponentProps } from 'react'; import { - ADMIN_ROLES, buildAuditUserPath, DJANGO_MANAGED_ROLES, MAP_ROLE_KEY_TO_LABEL, + ADMIN_ROLES, ALL_ORGS_KEY, DJANGO_MANAGED_ROLES, getAggregateScopeType, + getScopeContextType, MAP_ROLE_KEY_TO_LABEL, } from '@src/authz-module/constants'; import { - Icon, IconButton, OverlayTrigger, Tooltip, DataTableContext, + Icon, IconButton, OverlayTrigger, Tooltip, type DataTableCellProps, } from '@openedx/paragon'; +import { useExclusiveRowExpansion } from '@src/authz-module/hooks/useExclusiveRowExpansion'; +import { getScopeResourceIcon } from '@src/authz-module/utils'; +import { AGGREGATE_SCOPE_LABELS } from '@src/authz-module/messages'; import { RESOURCE_ICONS } from './constants'; import messages from './messages'; -import ViewMoreLink from './ViewMoreLink'; - -interface ViewActionCellExtraProps { - isCourseEnabled: (scope: string) => boolean; -} - -interface DataTableInstance { - state?: { - expanded?: Record; - }; - toggleRowExpanded?: (rowId: string, expanded: boolean) => void; -} +import ExpandableButton from './ExpandableButton'; type CellProps = DataTableCellProps; type CellPropsWithValue = CellProps & { @@ -51,7 +42,7 @@ type DisabledCourseActionButtonProps = Pick, ' // A disabled button can't trigger its own tooltip (Paragon sets pointer-events: none on it), // so the OverlayTrigger must live on a wrapper element that still receives hover events. -const DisabledCourseActionButton = ({ +export const DisabledCourseActionButton = ({ src, alt, size, variant, }: DisabledCourseActionButtonProps) => { const { formatMessage } = useIntl(); @@ -77,58 +68,13 @@ const DisabledCourseActionButton = ({ ); }; -const NameCell = ({ row }: CellProps) => { - const intl = useIntl(); - const { authenticatedUser } = useContext(SiteContext); - const username = authenticatedUser?.username; - - if (row.original.username === username) { - return ( - - {row.original.fullName || row.original.username} - {intl.formatMessage(messages['authz.table.username.current'])} - - ); - } - return {row.original.fullName || row.original.username || ''}; -}; - -const ViewActionCell = ({ row, isCourseEnabled }: CellProps & Partial) => { - const { formatMessage } = useIntl(); - const navigate = useNavigate(); - const viewPath = buildAuditUserPath(row.original.username ?? ''); - const isCourseScope = !row.original.role?.startsWith('lib') && !DJANGO_MANAGED_ROLES.includes(row.original.role); - const isDisabled = isCourseEnabled !== undefined && isCourseScope && !isCourseEnabled(row.original.scope); - - if (isDisabled) { - return ( - - ); - } - - return ( - navigate(viewPath)} - /> - ); -}; - -const createViewActionCell = (extraProps: ViewActionCellExtraProps) => function customViewActionCell(cellProps) { - return ; -}; - const OrgCell = ({ value, row }: CellPropsWithValue) => { const { formatMessage } = useIntl(); + // The backend returns '*' as the org wildcard, meaning the role spans every organization. + const isAllOrgs = DJANGO_MANAGED_ROLES.includes(row.original.role) || value === ALL_ORGS_KEY; return ( - {DJANGO_MANAGED_ROLES.includes(row.original.role) ? formatMessage(messages['authz.user.table.org.all.organizations.label']) : value} + {isAllOrgs ? formatMessage(messages['authz.user.table.org.all.organizations.label']) : value} ); }; @@ -137,18 +83,21 @@ const ScopeCell = ({ row }: CellProps) => { const { formatMessage } = useIntl(); const { scopeText, iconSrc } = useMemo(() => { - if (DJANGO_MANAGED_ROLES.includes(row.original.role)) { + const { role, scope, org } = row.original; + if (DJANGO_MANAGED_ROLES.includes(role)) { return { scopeText: formatMessage(messages['authz.user.table.scope.global.label']), iconSrc: RESOURCE_ICONS.GLOBAL, }; } - const scopeIcon = row.original.role?.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE; + const aggregateType = getAggregateScopeType(scope, org); return { - scopeText: row.original.scope, - iconSrc: scopeIcon, + scopeText: aggregateType + ? formatMessage(AGGREGATE_SCOPE_LABELS[getScopeContextType(scope)]) + : scope, + iconSrc: getScopeResourceIcon(scope), }; - }, [row.original.role, row.original.scope, formatMessage]); + }, [row.original, formatMessage]); return ( @@ -185,33 +134,18 @@ const PermissionsCell = ({ row }: CellProps) => { const ViewAllPermissionsCell = ({ row }: CellProps) => { const { formatMessage } = useIntl(); - const instance = useContext(DataTableContext) as DataTableInstance; - const handleToggleExpanded = () => { - if (!row.isExpanded && instance) { - // Close all other expanded rows first - const expanded = instance.state?.expanded || {}; - Object.keys(expanded).forEach(rowId => { - if (rowId !== row.id && expanded[rowId]) { - instance.toggleRowExpanded?.(rowId, false); - } - }); - } - // Toggle the current row - row.toggleRowExpanded?.(); - }; + const toggleExpanded = useExclusiveRowExpansion(row); return ( -
- -
+ ); }; @@ -296,13 +230,10 @@ const createActionsCell = (extraProps: ActionsCellExtraProps) => function custom }; export { - NameCell, - ViewActionCell, RoleCell, OrgCell, ScopeCell, PermissionsCell, ViewAllPermissionsCell, createActionsCell, - createViewActionCell, }; diff --git a/src/authz-module/components/TableControlBar/TableControlBar.tsx b/src/authz-module/components/TableControlBar/TableControlBar.tsx index 1b09acec..677a37b9 100644 --- a/src/authz-module/components/TableControlBar/TableControlBar.tsx +++ b/src/authz-module/components/TableControlBar/TableControlBar.tsx @@ -1,4 +1,6 @@ -import { useContext, useEffect, useState } from 'react'; +import { + useContext, useEffect, useState, type ReactNode, +} from 'react'; import { useIntl } from '@openedx/frontend-base'; import { DataTableContext, @@ -37,9 +39,11 @@ const FILTER_GROUP_TO_ID = { interface TableControlBarProps { onFilterChange?: (filters: string[]) => void; + /** Optional count rendered right-aligned on the filter row, e.g. "Showing 10 users of 100." */ + countLabel?: ReactNode; } -const TableControlBar = ({ onFilterChange }: TableControlBarProps) => { +const TableControlBar = ({ onFilterChange, countLabel }: TableControlBarProps) => { const intl = useIntl(); // applied filters in the order they were selected by the user, to display on the control bar as chips const [chronologicalFilters, setChronologicalFilters] = useState([]); @@ -164,6 +168,7 @@ const TableControlBar = ({ onFilterChange }: TableControlBarProps) => { } return null; })} + {countLabel && {countLabel}} {chronologicalFilters.length > 0 && ( diff --git a/src/authz-module/components/TableFooter/TableFooter.tsx b/src/authz-module/components/TableFooter/TableFooter.tsx index 04a74d11..8845e524 100644 --- a/src/authz-module/components/TableFooter/TableFooter.tsx +++ b/src/authz-module/components/TableFooter/TableFooter.tsx @@ -3,7 +3,18 @@ import { useIntl } from '@openedx/frontend-base'; import { DataTableContext, Pagination, TableFooter } from '@openedx/paragon'; import messages from '@src/authz-module/components/messages'; -const Footer = () => { +const defaultShowingMessage = messages['authz.table.footer.items.showing.text']; + +interface FooterProps { + /** + * Overrides the "Showing X of Y." text — used by tables whose rows are not + * assignments (e.g. the team members table counts users). Receives `pageSize` + * and `itemCount`. + */ + showingMessage?: typeof defaultShowingMessage; +} + +const Footer = ({ showingMessage }: FooterProps) => { const { formatMessage } = useIntl(); const { pageCount, gotoPage, state, itemCount, rows, @@ -13,7 +24,7 @@ const Footer = () => { return ( - {formatMessage(messages['authz.table.footer.items.showing.text'], { pageSize: rows.length, itemCount })} + {formatMessage(showingMessage ?? defaultShowingMessage, { pageSize: rows.length, itemCount })} { - const mockOnClick = jest.fn(); - const defaultProps = { - label: 'View more details', - onClick: mockOnClick, - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('rendering', () => { - it('renders the link with the provided label', () => { - renderWrapper(); - - expect(screen.getByText('View more details')).toBeInTheDocument(); - }); - - it('renders without an icon when iconSrc is not provided', () => { - renderWrapper(); - - const link = screen.getByText('View more details'); - expect(link).toBeInTheDocument(); - // The icon should not be present when iconSrc is not provided - expect(link.querySelector('svg')).not.toBeInTheDocument(); - }); - - it('renders with an icon when iconSrc is provided', () => { - renderWrapper(); - - const link = screen.getByText('View more details'); - expect(link).toBeInTheDocument(); - // The icon should be present when iconSrc is provided - expect(link.querySelector('svg')).toBeInTheDocument(); - }); - }); - - describe('user interactions', () => { - it('calls onClick handler when user clicks the link', async () => { - const user = userEvent.setup(); - renderWrapper(); - - const link = screen.getByText('View more details'); - await user.click(link); - - expect(mockOnClick).toHaveBeenCalledTimes(1); - }); - - it('calls onClick handler when user clicks the link with an icon', async () => { - const user = userEvent.setup(); - renderWrapper(); - - const link = screen.getByText('View more details'); - await user.click(link); - - expect(mockOnClick).toHaveBeenCalledTimes(1); - }); - - it('handles multiple clicks correctly', async () => { - const user = userEvent.setup(); - renderWrapper(); - - const link = screen.getByText('View more details'); - await user.click(link); - await user.click(link); - await user.click(link); - - expect(mockOnClick).toHaveBeenCalledTimes(3); - }); - }); -}); diff --git a/src/authz-module/components/ViewMoreLink.tsx b/src/authz-module/components/ViewMoreLink.tsx deleted file mode 100644 index 61820cbb..00000000 --- a/src/authz-module/components/ViewMoreLink.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import { Hyperlink, Icon } from '@openedx/paragon'; - -interface ViewMoreLinkProps { - label: string; - onClick: () => void; - iconSrc?: React.ComponentType | undefined; -} - -const ViewMoreLink = ({ label, onClick, iconSrc }: ViewMoreLinkProps) => ( - { - e.preventDefault(); - onClick(); - }} - > - {label} - {iconSrc && ( - - )} - -); - -export default ViewMoreLink; diff --git a/src/authz-module/components/messages.ts b/src/authz-module/components/messages.ts index d4b35f57..dba96cfa 100644 --- a/src/authz-module/components/messages.ts +++ b/src/authz-module/components/messages.ts @@ -58,8 +58,8 @@ const messages = defineMessages({ }, 'authz.user.table.org.all.organizations.label': { id: 'authz.user.table.org.all.organizations.label', - defaultMessage: 'All Organizations', - description: 'Label for the "All Organizations" message on the user assignments table when a user has a django managed role assigned.', + defaultMessage: 'All platform', + description: 'Shown in place of an organization for a role that is not limited to one organization: a wildcard org, or a Django-managed role.', }, 'authz.user.table.scope.global.label': { id: 'authz.user.table.scope.global.label', @@ -86,11 +86,6 @@ const messages = defineMessages({ defaultMessage: 'View all permissions', description: 'Text for the link to view all permissions in the user table', }, - 'authz.table.username.current': { - id: 'authz.table.username.current', - defaultMessage: '(Me)', - description: 'Indicates the current user in the team members table', - }, 'authz.table.column.actions.view.title': { id: 'authz.table.column.actions.view.title', diff --git a/src/authz-module/constants.test.ts b/src/authz-module/constants.test.ts index 6423e965..0a00acb7 100644 --- a/src/authz-module/constants.test.ts +++ b/src/authz-module/constants.test.ts @@ -1,5 +1,6 @@ import { - AUTHZ_HOME_PATH, buildAuditUserPath, buildWizardPath, getOrgAggregateScopeKey, getPlatformAggregateScopeKey, ROUTES, + AUTHZ_HOME_PATH, buildAuditUserPath, buildWizardPath, getAggregateScopeType, + getOrgAggregateScopeKey, getPlatformAggregateScopeKey, ROUTES, } from './constants'; import type { ContextType } from './constants'; @@ -74,3 +75,36 @@ describe('getPlatformAggregateScopeKey', () => { expect(() => getPlatformAggregateScopeKey('unknown' as ContextType)).toThrow('Unknown contextType: "unknown"'); }); }); +describe('getAggregateScopeType', () => { + it('recognizes the platform-wide course scope', () => { + expect(getAggregateScopeType('course-v1:*', '*')).toBe('platform'); + }); + + it('recognizes the platform-wide library scope', () => { + expect(getAggregateScopeType('lib:*', '*')).toBe('platform'); + }); + + it('recognizes an org-wide course scope', () => { + expect(getAggregateScopeType('course-v1:MIT+*', 'MIT')).toBe('org'); + }); + + it('recognizes an org-wide library scope', () => { + expect(getAggregateScopeType('lib:MIT:*', 'MIT')).toBe('org'); + }); + + it('returns null for a single course scope', () => { + expect(getAggregateScopeType('course-v1:MIT+DemoX+2024', 'MIT')).toBeNull(); + }); + + it('returns null for a single library scope', () => { + expect(getAggregateScopeType('lib:MIT:demo', 'MIT')).toBeNull(); + }); + + it('returns null for an org-wide scope when the org is unknown', () => { + expect(getAggregateScopeType('course-v1:MIT+*')).toBeNull(); + }); + + it('returns null when the scope belongs to a different org', () => { + expect(getAggregateScopeType('course-v1:MIT+*', 'HarvardX')).toBeNull(); + }); +}); diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index 9dc9fcc0..731b8bce 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -28,14 +28,36 @@ export const getPlatformAggregateScopeKey = (contextType: ContextType): string = return scope; }; +/** The `org` an assignment carries when it spans every organization. */ +export const ALL_ORGS_KEY = '*'; + +/** + * The kind of resource a scope points at, read from the scope key itself rather than from + * the role that grants it, so it does not depend on role naming staying conventional. + */ +export const getScopeContextType = (scope: string): ContextType => ( + scope.startsWith('lib') ? CONTEXT_TYPES.LIBRARY : CONTEXT_TYPES.COURSE +); + +/** + * Tells whether a scope is one of the wildcard scopes, and which level it aggregates. + * + * Returns `null` for a scope pointing at a single course or library. The org slug is + * needed to recognize an org-level aggregate, since its key embeds the slug. + */ +export const getAggregateScopeType = (scope: string, org?: string | null): 'platform' | 'org' | null => { + const contextType = getScopeContextType(scope); + if (scope === getPlatformAggregateScopeKey(contextType)) { + return 'platform'; + } + if (org && scope === getOrgAggregateScopeKey(contextType, org)) { + return 'org'; + } + return null; +}; + export const DEFAULT_TOAST_DELAY = 5000; export const RETRY_TOAST_DELAY = 120_000; // 2 minutes -export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({ - username: 'skeleton', - name: '', - email: '', - roles: [], -})); /** Absolute base path of the module; src/routes.tsx derives its route prefix from this. */ export const AUTHZ_HOME_PATH = '/admin-console/authz'; diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index 48086962..77652cbb 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -1,6 +1,5 @@ import { - Org, Scope, - UserRole, + Org, Scope, TeamMember, UserRole, } from '@src/types'; import { camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base'; import { getApiUrl } from '@src/data/utils'; @@ -52,8 +51,8 @@ export interface AssignTeamMembersRoleRequest { scopes: string[]; } -export interface GetAllRoleAssignmentsResponse { - results: UserRole[]; +export interface GetTeamMembersAssignmentsResponse { + results: TeamMember[]; count: number; next: string | null; previous: string | null; @@ -141,8 +140,16 @@ export const revokeUserRoles = async ( return camelCaseObject(res.data); }; -export const getAllRoleAssignments = async (querySettings: QuerySettings): Promise => { - const url = new URL(getApiUrl('/api/authz/v1/assignments/')); +/** + * Fetches team members grouped by user: one entry per user carrying the first few of + * their role assignments plus `assignmentCount`, the number behind that slice. + * + * The role, org and scope filters apply to assignments before they are grouped, so they + * decide both which users come back and which of each user's assignments are listed and + * counted. + */ +export const getTeamMembersAssignments = async (querySettings: QuerySettings): Promise => { + const url = new URL(getApiUrl('/api/authz/v1/users/')); if (querySettings.roles) { url.searchParams.set('roles', querySettings.roles); @@ -160,6 +167,7 @@ export const getAllRoleAssignments = async (querySettings: QuerySettings): Promi url.searchParams.set('sort_by', querySettings.sortBy); url.searchParams.set('order', querySettings.order); } + // The endpoint defaults `assignments_limit` to 3; set it only to change that default. url.searchParams.set('page_size', querySettings.pageSize.toString()); url.searchParams.set('page', (querySettings.pageIndex + 1).toString()); diff --git a/src/authz-module/data/hooks.test.tsx b/src/authz-module/data/hooks.test.tsx index b6fc2d5c..d1421555 100644 --- a/src/authz-module/data/hooks.test.tsx +++ b/src/authz-module/data/hooks.test.tsx @@ -6,7 +6,7 @@ import type { QuerySettings } from './api'; import { useAssignTeamMembersRole, useRevokeUserRoles, - useAllRoleAssignments, + useTeamMembersAssignments, useOrgs, useScopes, useUserAssignedRoles, @@ -31,14 +31,18 @@ jest.mock('@src/constants', () => ({ const mockAssignments = { results: [ { - isSuperadmin: false, - role: 'course_staff', - org: 'OpenedX', - scope: 'course-v1:OpenedX+DemoX+DemoCourse', - permissionCount: 27, - fullName: 'John Doe', username: 'johndoe', + fullName: 'John Doe', email: 'johndoe@example.com', + assignmentCount: 5, + assignments: [ + { + role: 'course_staff', + org: 'OpenedX', + scope: 'course-v1:OpenedX+DemoX+DemoCourse', + permissionCount: 27, + }, + ], }, ], count: 1, @@ -510,19 +514,19 @@ describe('useRevokeUserRoles', () => { if (userRolesCall) { const { predicate } = userRolesCall; expect(predicate({ queryKey: ['test-app', 'authz', 'userRoles'] })).toBe(true); - expect(predicate({ queryKey: ['test-app', 'authz', 'teamMembers'] })).toBe(false); + expect(predicate({ queryKey: ['test-app', 'authz', 'teamMembersAssignments'] })).toBe(false); } - const allRoleAssignmentsCall = predicateCalls.find(call => { + const teamMembersAssignmentsCall = predicateCalls.find(call => { const { predicate } = call; - return predicate({ queryKey: ['test-app', 'authz', 'allRoleAssignments', {}] }); + return predicate({ queryKey: ['test-app', 'authz', 'teamMembersAssignments', {}] }); }); - expect(allRoleAssignmentsCall).toBeDefined(); + expect(teamMembersAssignmentsCall).toBeDefined(); - if (allRoleAssignmentsCall) { - const { predicate } = allRoleAssignmentsCall; - expect(predicate({ queryKey: ['test-app', 'authz', 'allRoleAssignments'] })).toBe(true); + if (teamMembersAssignmentsCall) { + const { predicate } = teamMembersAssignmentsCall; + expect(predicate({ queryKey: ['test-app', 'authz', 'teamMembersAssignments'] })).toBe(true); expect(predicate({ queryKey: ['test-app', 'authz', 'userRoles'] })).toBe(false); } @@ -530,21 +534,23 @@ describe('useRevokeUserRoles', () => { }); }); -describe('useAllRoleAssignments', () => { +describe('useTeamMembersAssignments', () => { beforeEach(() => { mockHttpClient().mockReturnValue({ get: jest.fn(() => Promise.resolve({ data: mockAssignments })), }); }); - it('fetches and returns role assignments', async () => { + it('fetches and returns team members with their nested assignments', async () => { const { result } = renderHook( - () => useAllRoleAssignments(mockQuerySettings), + () => useTeamMembersAssignments(mockQuerySettings), { wrapper: createWrapper() }, ); await waitFor(() => { expect(result.current.data?.results).toHaveLength(1); expect(result.current.data?.results[0].username).toBe('johndoe'); + expect(result.current.data?.results[0].assignmentCount).toBe(5); + expect(result.current.data?.results[0].assignments).toHaveLength(1); expect(result.current.data?.count).toBe(1); }); }); @@ -558,7 +564,7 @@ describe('useAllRoleAssignments', () => { })), }); const { result } = renderHook( - () => useAllRoleAssignments(mockQuerySettings), + () => useTeamMembersAssignments(mockQuerySettings), { wrapper: createWrapper() }, ); await waitFor(() => { diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts index 947c2a08..044c3086 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -3,8 +3,8 @@ import { } from '@tanstack/react-query'; import { appId } from '@src/constants'; import { - assignTeamMembersRole, AssignTeamMembersRoleRequest, getAllRoleAssignments, - GetAllRoleAssignmentsResponse, getOrgs, GetOrgsResponse, + assignTeamMembersRole, AssignTeamMembersRoleRequest, getTeamMembersAssignments, + GetTeamMembersAssignmentsResponse, getOrgs, GetOrgsResponse, getScopes, GetScopesResponse, QuerySettings, revokeUserRoles, RevokeUserRolesRequest, getUserAssignedRoles, GetUserAssignmentsResponse, validateUsers, ValidateUsersRequest, GetScopesParams, @@ -13,7 +13,7 @@ import { const authzQueryKeys = { all: [appId, 'authz'] as const, - allRoleAssignments: (querySettings?: QuerySettings) => [...authzQueryKeys.all, 'allRoleAssignments', querySettings] as const, + teamMembersAssignments: (querySettings?: QuerySettings) => [...authzQueryKeys.all, 'teamMembersAssignments', querySettings] as const, orgs: (search?: string, page?: number, pageSize?: number) => [...authzQueryKeys.all, 'organizations', search, page, pageSize] as const, scopes: (params?: Omit) => [...authzQueryKeys.all, 'scopes', params] as const, userRoles: (username?: string, querySettings?: QuerySettings) => [...authzQueryKeys.all, 'userRoles', username, querySettings] as const, @@ -38,7 +38,7 @@ export const useAssignTeamMembersRole = () => { if (!error) { queryClient.invalidateQueries({ queryKey: [...authzQueryKeys.all, 'userRoles'] }); queryClient.invalidateQueries({ - predicate: (query) => query.queryKey.includes('allRoleAssignments'), + predicate: (query) => query.queryKey.includes('teamMembersAssignments'), }); } }, @@ -77,34 +77,31 @@ export const useRevokeUserRoles = () => { predicate: (query) => query.queryKey.includes('userRoles'), }); queryClient.invalidateQueries({ - predicate: (query) => query.queryKey.includes('allRoleAssignments'), + predicate: (query) => query.queryKey.includes('teamMembersAssignments'), }); }, }); }; /** - * React Query hook to fetch all role assignments across scopes and roles, - * with support for filtering, sorting, and pagination. - * It retrieves a comprehensive list of user-role assignments based - * on the provided query settings. + * React Query hook to fetch team members grouped by user, with support for + * filtering, sorting, and pagination. Each result is one user carrying the first few of + * their role assignments plus how many there are; see `getTeamMembersAssignments` for + * how filters narrow both. * - * @param querySettings - Optional parameters for filtering by roles, scopes, + * @param querySettings - Parameters for filtering by roles, scopes, * organizations, search term, sorting, and pagination. * * @example - * const { data: roleAssignments } = useAllRoleAssignments({ roles: 'editor', pageSize: 20 }); + * const { data: teamMembers } = useTeamMembersAssignments({ roles: 'editor', pageSize: 20 }); */ -export const useAllRoleAssignments = (querySettings: QuerySettings) => { - const result = useQuery({ - queryKey: authzQueryKeys.allRoleAssignments(querySettings), - queryFn: () => getAllRoleAssignments(querySettings), +export const useTeamMembersAssignments = (querySettings: QuerySettings) => ( + useQuery({ + queryKey: authzQueryKeys.teamMembersAssignments(querySettings), + queryFn: () => getTeamMembersAssignments(querySettings), staleTime: 1000 * 60 * 30, // refetch after 30 minutes - retry: false, - refetchOnWindowFocus: false, - }); - return result; -}; + }) +); /** * React Query hook to fetch a paginated, searchable list of organizations. diff --git a/src/authz-module/hooks/useExclusiveRowExpansion.ts b/src/authz-module/hooks/useExclusiveRowExpansion.ts new file mode 100644 index 00000000..48e88c07 --- /dev/null +++ b/src/authz-module/hooks/useExclusiveRowExpansion.ts @@ -0,0 +1,30 @@ +import { useContext } from 'react'; +import { DataTableContext, type DataTableRow } from '@openedx/paragon'; + +interface DataTableInstance { + state?: { + expanded?: Record; + }; + toggleRowExpanded?: (rowId: string, expanded: boolean) => void; +} + +/** + * Returns a handler that toggles `row`, collapsing any other expanded row first so only one + * is open at a time. Paragon's `isExpandable` allows several, and a table whose rows expand + * into a nested table only reads clearly with one breakdown showing. + */ +export const useExclusiveRowExpansion = (row: DataTableRow) => { + const instance = useContext(DataTableContext) as DataTableInstance; + + return () => { + if (!row.isExpanded && instance) { + const expanded = instance.state?.expanded || {}; + Object.keys(expanded).forEach((rowId) => { + if (rowId !== row.id && expanded[rowId]) { + instance.toggleRowExpanded?.(rowId, false); + } + }); + } + row.toggleRowExpanded?.(); + }; +}; diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss index 856af828..2965c9b7 100644 --- a/src/authz-module/index.scss +++ b/src/authz-module/index.scss @@ -73,6 +73,120 @@ max-height: 80vh; } } + + // Where `.text-truncate` cuts; sized to the design's character budget. + .authz-cell-username { max-width: 15rem; } // ~24 characters + .authz-cell-email { max-width: 17rem; } // ~28 characters + + &.team-members-table { + // Every row is two lines tall: the assigned-roles cell stacks scope over org. + th, + td { + padding-top: var(--pgn-spacing-spacer-3); + padding-bottom: var(--pgn-spacing-spacer-3); + } + + // Keep the "+N more roles" label on one line. + td:nth-child(4) { + white-space: nowrap; + } + + /* + Auto-layout starves "Assigned roles" in favour of the short Username/Email values, + so widths are pinned. Below `md` these percentages would crush the toggle and action + columns, so the table falls back to content sizing and scrolls inside Paragon's + `.pgn__data-table-container`. + */ + @media (--pgn-size-breakpoint-min-width-md) { + .pgn__data-table { + table-layout: fixed; + } + + th:nth-child(1) { width: 16%; } // Username + th:nth-child(2) { width: 18%; } // Email + th:nth-child(3) { width: 46%; } // Assigned roles + th:nth-child(4) { width: 13%; } // "+N more roles" toggle + th:nth-child(5) { width: 7%; } // Actions + + td { + overflow: hidden; + } + + // The nested table sizes its own columns; it must not inherit the widths above. + .team-members-table__subtable { + .pgn__data-table { + table-layout: auto; + } + + th:nth-child(1), + th:nth-child(2), + th:nth-child(3) { + width: auto; + } + } + } + + // One line per row, so it keeps tighter padding. + .team-members-table__subtable { + th, + td { + padding-top: var(--pgn-spacing-spacer-2); + padding-bottom: var(--pgn-spacing-spacer-2); + } + } + } + + .team-members-table__subtable { + // The module turns `hr` horizontal below `sm`; this footer divider stays vertical. + hr { + border-right: var(--pgn-size-border-width) solid var(--pgn-color-border); + border-top: none; + height: 1rem; + width: 0; + } + + // The Card already pads; the module's blanket 1rem would double it up. + .pgn__data-table-wrapper { + padding: 0; + } + + // Matches PermissionTable's `bg-info-100 text-primary` band. Paragon renders its own + // `thead`, so the tokens are used rather than the utility classes. + thead th { + background-color: var(--pgn-color-info-100); + color: var(--pgn-color-primary-base); + } + + /* + Paragon stripes with `.pgn__data-table.is-striped tr:nth-child(2n)` — a descendant + selector, so the striped outer table reaches in and `isStriped={false}` on the inner + one cannot override it. The `:not()` keeps a nested table that wants stripes. + */ + .pgn__data-table:not(.is-striped) tr { + background-color: transparent; + + // With the stripes gone, rows need an explicit divider to stay readable. + &:not(:last-child) td { + border-bottom: var(--pgn-size-border-width) solid var(--pgn-color-border); + } + } + } + + // Floor the badge to the widest label, so "in" and the scope align down the column. + .authz-assigned-roles .authz-role-badge { min-width: 11rem; } + + .authz-scope-cell { + min-width: 0; // Lets the flex child shrink far enough for `.text-truncate` to cut. + max-width: 25rem; // org subline, ~40 characters + + &__name { + max-width: 22rem; // ~35 characters + } + + &__org { + line-height: 1.2; + } + } @media(--pgn-size-breakpoint-max-width-sm){ .flex-column-sm { @@ -132,4 +246,3 @@ .scope-list { max-height: 500px; } - diff --git a/src/authz-module/messages.ts b/src/authz-module/messages.ts index 70d74cba..901d9f52 100644 --- a/src/authz-module/messages.ts +++ b/src/authz-module/messages.ts @@ -42,6 +42,19 @@ const messages = defineMessages( defaultMessage: 'The request took too long.

Please check your connection and try again.', description: 'Request timeout message', }, + // Aggregate scope labels, shared by every table that lists a scope beside its + // organization. The scope column names only the kind of resource; the organization + // column says how far it reaches. The wizard keeps its own, longer wording. + 'authz.scope.aggregate.course': { + id: 'authz.scope.aggregate.course', + defaultMessage: 'All courses', + description: 'Scope column label for a role that covers every course, either across the platform or within one organization. The organization column shows which.', + }, + 'authz.scope.aggregate.library': { + id: 'authz.scope.aggregate.library', + defaultMessage: 'All libraries', + description: 'Scope column label for a role that covers every library, either across the platform or within one organization. The organization column shows which.', + }, 'authz.team.toast.retry.label': { id: 'authz.team.toast.retry.label', defaultMessage: 'Retry', @@ -50,4 +63,10 @@ const messages = defineMessages( }, ); +/** Aggregate scope labels keyed by the kind of resource the scope covers. */ +export const AGGREGATE_SCOPE_LABELS = { + course: messages['authz.scope.aggregate.course'], + library: messages['authz.scope.aggregate.library'], +}; + export default messages; diff --git a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts index 3c5f2737..f5ea1083 100644 --- a/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts +++ b/src/authz-module/role-assignation-wizard/hooks/useScopeListData.ts @@ -5,7 +5,7 @@ import { useOrgs, useScopes } from '@src/authz-module/data/hooks'; import { useCourseAuthoringFlag } from '@src/authz-module/hooks/useCourseAuthoringFlag'; import { getOrgAggregateScopeKey, getPlatformAggregateScopeKey } from '@src/authz-module/constants'; import type { ContextType } from '@src/authz-module/constants'; -import messages from '@src/authz-module/role-assignation-wizard/messages'; +import messages from '../messages'; import useScopePermissions from './useScopePermissions'; interface UseScopeListDataParams { diff --git a/src/authz-module/team-members/TeamMembersTable.test.tsx b/src/authz-module/team-members/TeamMembersTable.test.tsx index c9ba6ca4..fc206152 100644 --- a/src/authz-module/team-members/TeamMembersTable.test.tsx +++ b/src/authz-module/team-members/TeamMembersTable.test.tsx @@ -1,11 +1,12 @@ -import { screen, waitFor } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithAllProviders } from '@src/testUtils'; -import { useAllRoleAssignments, useOrgs, useScopes } from '@src/authz-module/data/hooks'; -import type { GetAllRoleAssignmentsResponse } from '@src/authz-module/data/api'; +import { useTeamMembersAssignments, useOrgs, useScopes } from '@src/authz-module/data/hooks'; +import type { GetTeamMembersAssignmentsResponse } from '@src/authz-module/data/api'; import { useViewTeamPermissions } from '@src/authz-module/hooks/useViewTeamPermissions'; import { useCourseAuthoringFlag } from '@src/authz-module/hooks/useCourseAuthoringFlag'; import { LIBRARY_ROLE_KEYS } from '@src/authz-module/roles-permissions'; +import { AUTHZ_HOME_PATH } from '@src/authz-module/constants'; import { ToastManagerProvider } from '@src/components/ToastManager/ToastManagerContext'; import TeamMembersTable from './TeamMembersTable'; @@ -15,8 +16,16 @@ jest.mock('@src/authz-module/hooks/useViewTeamPermissions', () => ({ const mockUseViewTeamPermissions = useViewTeamPermissions as jest.Mock; -const mockedAllRoleAssignments: { - data: GetAllRoleAssignmentsResponse | undefined; +const courseAssignment = { + role: 'course_staff', + org: 'OpenedX', + scope: 'course-v1:OpenedX+DemoX+DemoCourse', + scopeDisplayName: 'Open edX Demo Course', + permissionCount: 27, +}; + +const mockedTeamMembers: { + data: GetTeamMembersAssignmentsResponse | undefined; error: Error | null; isLoading: boolean; refetch: jest.Mock; @@ -24,24 +33,44 @@ const mockedAllRoleAssignments: { data: { results: [ { - isSuperadmin: false, - role: 'course_staff', - org: 'OpenedX', - scope: 'course-v1:OpenedX+DemoX+DemoCourse', - permissionCount: 27, - fullName: 'John Doe', username: 'johndoe', + fullName: 'John Doe', email: 'johndoe@example.com', + // More roles than the three returned, so the row advertises the remainder. + assignmentCount: 10, + assignments: [ + courseAssignment, + { + role: 'library_admin', + org: 'WGU', + scope: 'lib:WGU:CSPROB', + scopeDisplayName: 'Computer Science Problems', + permissionCount: 11, + }, + { + role: 'library_user', + org: 'WGU', + scope: 'lib:WGU:MATH', + scopeDisplayName: 'Mathematics Problems', + permissionCount: 4, + }, + ], }, { - isSuperadmin: true, - role: 'super_admin', - org: 'Global', - scope: 'system', - permissionCount: 100, - fullName: 'Jane Admin', username: 'janeadmin', + fullName: 'Jane Admin', email: 'jane@example.com', + // A single role: nothing further to reveal. + assignmentCount: 1, + assignments: [ + { + role: 'course_auditor', + org: 'OpenedX', + scope: 'course-v1:OpenedX+Other+Course', + scopeDisplayName: 'Another Course', + permissionCount: 3, + }, + ], }, ], count: 2, @@ -126,21 +155,25 @@ jest.mock('@src/authz-module/hooks/useCourseAuthoringFlag', () => ({ const mockUseCourseAuthoringFlag = useCourseAuthoringFlag as jest.Mock; jest.mock('@src/authz-module/data/hooks', () => ({ - useAllRoleAssignments: jest.fn(), + useTeamMembersAssignments: jest.fn(), useOrgs: jest.fn(), useScopes: jest.fn(), })); const mockApiResponses = ( - allAsignmentsResponse = mockedAllRoleAssignments, + teamMembersResponse = mockedTeamMembers, orgResponse = mockedOrgs, scopesResponse = mockedScopes, ) => { - (useAllRoleAssignments as jest.Mock).mockReturnValue(allAsignmentsResponse); + (useTeamMembersAssignments as jest.Mock).mockReturnValue(teamMembersResponse); (useOrgs as jest.Mock).mockReturnValue(orgResponse); (useScopes as jest.Mock).mockReturnValue(scopesResponse); }; +const renderTable = (props = {}) => renderWithAllProviders( + , +); + describe('TeamMembersTable', () => { beforeEach(() => { mockNavigate.mockClear(); @@ -156,53 +189,219 @@ describe('TeamMembersTable', () => { }); }); - it('renders table with role assignments data', async () => { - const presetScope = 'course-v1:OpenedX+DemoX+DemoCourse'; + it('renders one row per user', async () => { mockApiResponses(); - renderWithAllProviders(); + renderTable({ presetScope: 'course-v1:OpenedX+DemoX+DemoCourse' }); await waitFor(() => { - expect(screen.getByText('John Doe')).toBeInTheDocument(); - expect(screen.getByText('Jane Admin')).toBeInTheDocument(); + expect(screen.getByText('johndoe')).toBeInTheDocument(); + expect(screen.getByText('janeadmin')).toBeInTheDocument(); + // The column is Username: full names are carried by the API but not displayed. + expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); expect(screen.getByText('johndoe@example.com')).toBeInTheDocument(); expect(screen.getByText('jane@example.com')).toBeInTheDocument(); }); }); it('shows loading state initially', () => { - const allAsignmentsResponse = { ...mockedAllRoleAssignments, isLoading: true }; - mockApiResponses(allAsignmentsResponse); - renderWithAllProviders(); + mockApiResponses({ ...mockedTeamMembers, isLoading: true }); + renderTable(); expect(screen.getByRole('status')).toBeInTheDocument(); }); it('shows error toast message', () => { - const allAsignmentsResponse = { - ...mockedAllRoleAssignments, + mockApiResponses({ + ...mockedTeamMembers, isLoading: false, error: new Error('Failed to fetch'), + // @ts-expect-error - deliberately partial payload alongside the error data: { results: [] }, - }; - // @ts-expect-error:expected - mockApiResponses(allAsignmentsResponse); - renderWithAllProviders(); + }); + renderTable(); expect(screen.getByText(/Something went wrong on our end./)).toBeInTheDocument(); }); - it('renders table headers correctly', async () => { + + it('renders the user-grouped headers, keeping org, scope and role as filters only', async () => { mockApiResponses(); - renderWithAllProviders(); + renderTable(); await waitFor(() => { - expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Username')).toBeInTheDocument(); expect(screen.getByText('Email')).toBeInTheDocument(); - expect(screen.getAllByText('Organization').length).toBe(2); // Header and org filter; - expect(screen.getAllByText('Scope').length).toBe(2); // Header and scope filter; - expect(screen.getAllByText('Role').length).toBe(2); // Header and role filter; + expect(screen.getByText('Assigned roles')).toBeInTheDocument(); expect(screen.getByText('Actions')).toBeInTheDocument(); }); + // Only the filter buttons remain — these are no longer column headers. + expect(screen.getAllByText('Organization')).toHaveLength(1); + expect(screen.getAllByText('Scope')).toHaveLength(1); + expect(screen.getAllByText('Role')).toHaveLength(1); + }); + + it('renders the first assignment in the collapsed row, showing the scope name not its id', async () => { + mockApiResponses(); + renderTable(); + await waitFor(() => { + expect(screen.getByText('Course Staff')).toBeInTheDocument(); + expect(screen.getByText('Open edX Demo Course')).toBeInTheDocument(); + }); + expect(screen.queryByText('course-v1:OpenedX+DemoX+DemoCourse')).not.toBeInTheDocument(); + }); + + it('falls back to the scope id when the API sends no display name', async () => { + mockApiResponses({ + ...mockedTeamMembers, + data: { + ...mockedTeamMembers.data!, + results: [{ + ...mockedTeamMembers.data!.results[0], + assignments: [{ ...courseAssignment, scopeDisplayName: '' }], + assignmentCount: 1, + }], + count: 1, + }, + }); + renderTable(); + await waitFor(() => { + expect(screen.getByText('course-v1:OpenedX+DemoX+DemoCourse')).toBeInTheDocument(); + }); + }); + + it('counts the roles beyond the one already shown', async () => { + mockApiResponses(); + renderTable(); + // 10 total roles, one of them already on the row. + await waitFor(() => { + expect(screen.getByText('+9 more roles')).toBeInTheDocument(); + }); + }); + + it('omits the toggle for a user with a single role', async () => { + mockApiResponses(); + renderTable(); + await waitFor(() => { + expect(screen.getByText('janeadmin')).toBeInTheDocument(); + }); + expect(screen.queryByText('+0 more roles')).not.toBeInTheDocument(); + // Only John Doe's row offers an expansion. + expect(screen.getAllByText(/more roles?$/)).toHaveLength(1); + }); + + it('expands into a breakdown whose first row matches the collapsed badge', async () => { + const user = userEvent.setup(); + mockApiResponses(); + renderTable(); + await waitFor(() => { + expect(screen.getByText('+9 more roles')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('+9 more roles')); + + await waitFor(() => { + expect(screen.getByText('Hide roles')).toBeInTheDocument(); + }); + // The badge stays put, so the role now appears both on the row and in the breakdown. + expect(screen.getAllByText('Course Staff')).toHaveLength(2); + expect(screen.getByText('Library Admin')).toBeInTheDocument(); + expect(screen.getByText('Library User')).toBeInTheDocument(); + }); + + it('reports whether a member\'s role breakdown is open', async () => { + const user = userEvent.setup(); + mockApiResponses(); + renderTable(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /\+9 more roles/ })).toHaveAttribute('aria-expanded', 'false'); + }); + + await user.click(screen.getByText('+9 more roles')); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Hide roles/ })).toHaveAttribute('aria-expanded', 'true'); + }); + }); + + it('reports how many roles the listed rows were taken from in the breakdown footer', async () => { + const user = userEvent.setup(); + mockApiResponses(); + renderTable(); + await waitFor(() => { + expect(screen.getByText('+9 more roles')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('+9 more roles')); + + // Three assignments returned out of the user's ten. + await waitFor(() => { + expect(screen.getByText('Showing 03 of 10')).toBeInTheDocument(); + }); + expect(screen.getByRole('link', { name: /View all roles/ })).toHaveAttribute( + 'href', + `${AUTHZ_HOME_PATH}/user/johndoe`, + ); + }); + + it('hides "View all roles" when nothing is truncated', async () => { + const user = userEvent.setup(); + mockApiResponses({ + ...mockedTeamMembers, + data: { + ...mockedTeamMembers.data!, + results: [{ + ...mockedTeamMembers.data!.results[0], + assignmentCount: 3, + }], + count: 1, + }, + }); + renderTable(); + await waitFor(() => { + expect(screen.getByText('+2 more roles')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('+2 more roles')); + + await waitFor(() => { + expect(screen.getByText('Showing 03 of 03')).toBeInTheDocument(); + }); + expect(screen.queryByRole('link', { name: /View all roles/ })).not.toBeInTheDocument(); + }); + + it('keeps only one breakdown open, collapsing the previous row', async () => { + const user = userEvent.setup(); + // Both users need something to reveal, so either row can be expanded. + mockApiResponses({ + ...mockedTeamMembers, + data: { + ...mockedTeamMembers.data!, + results: mockedTeamMembers.data!.results.map((member) => ({ + ...member, + assignmentCount: 5, + assignments: [courseAssignment], + })), + }, + }); + renderTable(); + await waitFor(() => { + expect(screen.getAllByText('+4 more roles')).toHaveLength(2); + }); + + await user.click(screen.getAllByText('+4 more roles')[0]); + await waitFor(() => { + expect(screen.getAllByText('Hide roles')).toHaveLength(1); + }); + + await user.click(screen.getByText('+4 more roles')); + + // The second row took over: exactly one breakdown is open, and it is not the first. + await waitFor(() => { + expect(screen.getAllByText('Hide roles')).toHaveLength(1); + }); + const toggles = screen.getAllByText(/Hide roles|more roles?$/); + expect(toggles[0]).toHaveTextContent('+4 more roles'); + expect(toggles[1]).toHaveTextContent('Hide roles'); }); it('renders view action buttons for each user', async () => { mockApiResponses(); - renderWithAllProviders(); + renderTable(); await waitFor(() => { const viewButtons = screen.getAllByRole('button', { name: /view/i }); expect(viewButtons).toHaveLength(2); @@ -212,19 +411,71 @@ describe('TeamMembersTable', () => { it('navigates to user profile when view button is clicked', async () => { const user = userEvent.setup(); mockApiResponses(); - renderWithAllProviders(); + renderTable(); await waitFor(() => { - expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('johndoe')).toBeInTheDocument(); }); const viewButtons = screen.getAllByRole('button', { name: /view/i }); await user.click(viewButtons[0]); expect(mockNavigate).toHaveBeenCalledWith('/admin-console/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('keeps the view action enabled while any assignment is viewable', async () => { + // John Doe's course is disabled but his library roles are not; Jane Admin is library-only. + mockUseCourseAuthoringFlag.mockReturnValue({ + isCourseAuthoringEnabled: true, + isCourseEnabled: (scope: string) => scope !== 'course-v1:OpenedX+DemoX+DemoCourse', + isLoading: false, + }); + mockApiResponses(); + renderTable(); + await waitFor(() => { + const viewButtons = screen.getAllByRole('button', { name: /view/i }); + expect(viewButtons[0]).not.toBeDisabled(); + expect(viewButtons[1]).not.toBeDisabled(); + }); + }); + + it('disables the view action when every assignment sits in a disabled course', async () => { + mockUseCourseAuthoringFlag.mockReturnValue({ + isCourseAuthoringEnabled: true, + isCourseEnabled: () => false, + isLoading: false, + }); + mockApiResponses({ + ...mockedTeamMembers, + data: { + ...mockedTeamMembers.data!, + results: [{ + username: 'johndoe', + fullName: 'John Doe', + email: 'johndoe@example.com', + assignmentCount: 1, + assignments: [courseAssignment], + }], + count: 1, + }, + }); + renderTable(); + await waitFor(() => { + expect(screen.getByRole('button', { name: /view/i })).toBeDisabled(); + }); + }); + + it('reports the count in users', async () => { + mockApiResponses(); + const { container } = renderTable(); + await waitFor(() => { + expect(screen.getByText('johndoe')).toBeInTheDocument(); + }); + // Once on the control bar, once in the footer. + expect(within(container).getAllByText('Showing 2 users of 2.')).toHaveLength(2); + }); + + it('renders safely when team members data is undefined', () => { + mockApiResponses({ ...mockedTeamMembers, data: undefined }); + renderTable(); + expect(screen.queryByText('johndoe')).not.toBeInTheDocument(); }); it('filters to library roles only when course view is not allowed', async () => { @@ -234,31 +485,29 @@ describe('TeamMembersTable', () => { isLoading: false, }); mockApiResponses(); - renderWithAllProviders(); + renderTable(); await waitFor(() => { - expect(useAllRoleAssignments).toHaveBeenCalledWith( + expect(useTeamMembersAssignments).toHaveBeenCalledWith( expect.objectContaining({ roles: LIBRARY_ROLE_KEYS }), ); }); }); - it('disables the view action for course assignments in disabled scopes', async () => { - mockUseCourseAuthoringFlag.mockReturnValue({ - isCourseAuthoringEnabled: true, - isCourseEnabled: (scope: string) => scope !== 'course-v1:OpenedX+DemoX+DemoCourse', - isLoading: false, + it('announces a failure once, not once per effect pass', () => { + const serverError = Object.assign(new Error('Boom'), { + customAttributes: { httpErrorStatus: 500 }, }); - mockApiResponses(); - renderWithAllProviders(); - await waitFor(() => { - const viewButtons = screen.getAllByRole('button', { name: /view/i }); - expect(viewButtons[0]).toBeDisabled(); - expect(viewButtons[1]).not.toBeDisabled(); + mockApiResponses({ + ...mockedTeamMembers, isLoading: false, error: serverError, data: undefined, }); + + renderTable(); + + expect(screen.getAllByText(/We're experiencing technical difficulties./)).toHaveLength(1); }); it('handles empty data gracefully', async () => { - const allAsignmentsResponse = { + mockApiResponses({ data: { results: [], count: 0, @@ -268,11 +517,10 @@ describe('TeamMembersTable', () => { error: null, isLoading: false, refetch: jest.fn(), - }; - mockApiResponses(allAsignmentsResponse); - renderWithAllProviders(); + }); + renderTable(); await waitFor(() => { - expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); + expect(screen.queryByText('johndoe')).not.toBeInTheDocument(); }); }); }); diff --git a/src/authz-module/team-members/TeamMembersTable.tsx b/src/authz-module/team-members/TeamMembersTable.tsx index b4b30a36..d907743b 100644 --- a/src/authz-module/team-members/TeamMembersTable.tsx +++ b/src/authz-module/team-members/TeamMembersTable.tsx @@ -9,25 +9,46 @@ import { 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 { useCourseAuthoringFlag } from '@src/authz-module/hooks/useCourseAuthoringFlag'; 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'; import ScopesFilter from '@src/authz-module/components/TableControlBar/ScopesFilter'; import TableControlBar from '@src/authz-module/components/TableControlBar/TableControlBar'; import { getCellHeader } from '@src/authz-module/utils'; -import { - createViewActionCell, NameCell, OrgCell, RoleCell, ScopeCell, -} from '@src/authz-module/components/TableCells'; -import { useAllRoleAssignments } from '@src/authz-module/data/hooks'; + +import { useTeamMembersAssignments } from '@src/authz-module/data/hooks'; +import type { GetTeamMembersAssignmentsResponse } from '@src/authz-module/data/api'; import { TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants'; import messages from './messages'; import TableFooter from '@src/authz-module/components/TableFooter/TableFooter'; +import AssignedRolesCell from './components/AssignedRolesCell'; +import { EmailCell, NameCell } from './components/TeamMemberCells'; +import MoreRolesToggle from './components/MoreRolesToggle'; +import UserAssignmentsSubTable from './components/UserAssignmentsSubTable'; +import TeamMemberViewActionCell from './components/TeamMemberViewActionCell'; interface TeamMembersTableProps { presetScope?: string; } +const toastedErrors = new WeakSet(); + +/** + * Stable stand-in for a query that has not resolved. A fresh object literal here would + * hand `DataTable` a new `data` array on every render, and react-table resets its + * expanded-row state whenever `data` changes identity. + */ +const EMPTY_ASSIGNMENTS: GetTeamMembersAssignmentsResponse = { + results: [], count: 0, next: null, previous: null, +}; + +// Org, scope and role stay in the column set — TableControlBar derives its filter +// controls from the columns — but are hidden, since the design surfaces them inside +// each user's role breakdown instead of as top-level columns. They carry no accessor +// (a user row has no single org/scope/role), so each opts into filtering with +// `defaultCanFilter`, which react-table otherwise infers from the accessor. +const HIDDEN_FILTER_COLUMNS = ['org', 'scope', 'role']; + const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { const intl = useIntl(); const { showErrorToast } = useToastManager(); @@ -47,7 +68,6 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { const { querySettings, handleTableFetch } = useQuerySettings(initialQuerySettings); const { isCourseViewAllowed } = useViewTeamPermissions(); - const { isCourseEnabled } = useCourseAuthoringFlag(); const effectiveQuerySettings = useMemo(() => { if (isCourseViewAllowed || querySettings.roles) return querySettings; @@ -55,18 +75,20 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { }, [isCourseViewAllowed, querySettings]); const { - data: { results: roleAssignments, count } = { results: [], count: 0 }, - isLoading: isLoadingAllRoleAssignments, + data: { results: teamMembers, count } = EMPTY_ASSIGNMENTS, + isLoading: isLoadingTeamMembers, error, refetch, - } = useAllRoleAssignments(effectiveQuerySettings); - - const viewActionCell = useMemo(() => createViewActionCell({ isCourseEnabled }), [isCourseEnabled]); + } = useTeamMembersAssignments(effectiveQuerySettings); const initialFilters = presetScope ? [{ id: 'scope', value: [presetScope] }] : []; + /** + * Only transient failures reach here. + */ useEffect(() => { - if (error) { + if (error && !toastedErrors.has(error)) { + toastedErrors.add(error); showErrorToast(error, refetch); } }, [error, showErrorToast, refetch]); @@ -77,8 +99,14 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { useEffect(() => () => fetchData.cancel(), [fetchData]); + const showingUsersLabel = intl.formatMessage( + messages['authz.team.members.table.showing.users.text'], + { pageSize: teamMembers.length, itemCount: count }, + ); + return ( { manualSortBy numBreakoutFilters={4} fetchData={fetchData} - data={roleAssignments} + data={teamMembers} itemCount={count} pageCount={pageCount} - initialState={{ pageSize: TABLE_DEFAULT_PAGE_SIZE, filters: initialFilters }} - isLoading={isLoadingAllRoleAssignments} + initialState={{ + pageSize: TABLE_DEFAULT_PAGE_SIZE, + filters: initialFilters, + hiddenColumns: HIDDEN_FILTER_COLUMNS, + }} + isLoading={isLoadingTeamMembers} + renderRowSubComponent={({ row }) => ( + + )} additionalColumns={[ + { + id: 'moreRoles', + Header: '', + Cell: MoreRolesToggle, + }, { id: 'action', Header: intl.formatMessage(messages['authz.team.members.table.column.actions.title']), - Cell: viewActionCell, + Cell: TeamMemberViewActionCell, }, ]} columns={ [ { id: 'username', - Header: intl.formatMessage(messages['authz.team.members.table.column.name.title']), + Header: intl.formatMessage(messages['authz.team.members.table.column.username.title']), accessor: 'username', Cell: NameCell, filter: 'text', @@ -113,33 +153,41 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { { Header: intl.formatMessage(messages['authz.team.members.table.column.email.title']), accessor: 'email', + Cell: EmailCell, disableFilters: true, filter: 'text', Filter: TextFilter, }, { + id: 'assignedRoles', + Header: intl.formatMessage(messages['authz.team.members.table.column.assigned.roles.title']), + Cell: AssignedRolesCell, + disableFilters: true, + disableSortBy: true, + }, + { + id: 'org', Header: getCellHeader('org', intl.formatMessage(messages['authz.team.members.table.column.organization.title']), columnsWithFiltersApplied), - accessor: 'org', - Cell: OrgCell, filter: 'includesValue', + defaultCanFilter: true, Filter: OrgFilter, filterButtonText: intl.formatMessage(messages['authz.team.members.table.column.organization.title']), filterOrder: 2, }, { + id: 'scope', Header: getCellHeader('scope', intl.formatMessage(messages['authz.team.members.table.column.scope.title']), columnsWithFiltersApplied), - accessor: 'scope', - Cell: ScopeCell, filter: 'includesValue', + defaultCanFilter: true, Filter: ScopesFilter, filterButtonText: intl.formatMessage(messages['authz.team.members.table.column.scope.title']), filterOrder: 4, }, { + id: 'role', Header: getCellHeader('role', intl.formatMessage(messages['authz.team.members.table.column.role.title']), columnsWithFiltersApplied), - accessor: 'role', filter: 'includesValue', - Cell: RoleCell, + defaultCanFilter: true, Filter: RolesFilter, filterButtonText: intl.formatMessage(messages['authz.team.members.table.column.role.title']), filterOrder: 3, @@ -147,9 +195,9 @@ const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => { ] } > - + - + ); }; diff --git a/src/authz-module/team-members/components/AssignedRolesCell.test.tsx b/src/authz-module/team-members/components/AssignedRolesCell.test.tsx new file mode 100644 index 00000000..3971e3a6 --- /dev/null +++ b/src/authz-module/team-members/components/AssignedRolesCell.test.tsx @@ -0,0 +1,121 @@ +import { screen } from '@testing-library/react'; +import { renderWrapper } from '@src/testUtils'; +import type { TeamMember, TeamMemberAssignment } from '@src/types'; +import AssignedRolesCell from './AssignedRolesCell'; + +const courseAssignment: TeamMemberAssignment = { + role: 'course_staff', + org: 'OpenedX', + scope: 'course-v1:OpenedX+DemoX+DemoCourse', + scopeDisplayName: 'Open edX Demo Course', + permissionCount: 27, +}; + +const rowFor = (assignments: TeamMemberAssignment[], overrides: Partial = {}) => ({ + row: { + original: { + username: 'johndoe', + fullName: 'John Doe', + email: 'johndoe@example.com', + assignmentCount: assignments.length, + assignments, + ...overrides, + }, + }, +}); + +describe('AssignedRolesCell', () => { + it('names the role, the scope it applies to, and the organization', () => { + renderWrapper(); + + expect(screen.getByText('Course Staff')).toBeInTheDocument(); + expect(screen.getByText('Open edX Demo Course')).toBeInTheDocument(); + expect(screen.getByText('OpenedX')).toBeInTheDocument(); + }); + + it('shows only the first assignment when the user has several', () => { + const libraryAssignment: TeamMemberAssignment = { + role: 'library_admin', + org: 'WGU', + scope: 'lib:WGU:CSPROB', + scopeDisplayName: 'Computer Science Problems', + permissionCount: 11, + }; + renderWrapper(); + + expect(screen.getByText('Course Staff')).toBeInTheDocument(); + expect(screen.queryByText('Library Admin')).not.toBeInTheDocument(); + }); + + it('falls back to the scope id when the API resolved no display name', () => { + renderWrapper(); + + expect(screen.getByText('course-v1:OpenedX+DemoX+DemoCourse')).toBeInTheDocument(); + }); + + it('summarises a platform-wide course scope as the whole platform', () => { + renderWrapper( + + ); + + // Named once, on the scope line: the organization line is dropped rather than repeating it. + expect(screen.getAllByText('All platform')).toHaveLength(1); + // The resource kind belongs to the breakdown. + expect(screen.queryByText('All courses')).not.toBeInTheDocument(); + expect(screen.queryByText('course-v1:*')).not.toBeInTheDocument(); + }); + + it('summarises a platform-wide library scope the same way', () => { + renderWrapper( + + ); + + expect(screen.getByText('All platform')).toBeInTheDocument(); + expect(screen.queryByText('All libraries on the platform')).not.toBeInTheDocument(); + }); + + it('summarises an organization-wide scope as just the organization', () => { + renderWrapper( + + ); + + // Named once, on the scope line, rather than repeated on an organization line below. + expect(screen.getAllByText('MathDept')).toHaveLength(1); + expect(screen.queryByText('All courses in this organization')).not.toBeInTheDocument(); + }); + + it('summarises an organization-wide library scope the same way', () => { + renderWrapper( + + ); + + expect(screen.getAllByText('MathDept')).toHaveLength(1); + expect(screen.queryByText('All libraries in this organization')).not.toBeInTheDocument(); + }); + + it('shows the raw role key when the API sends a role the UI has no label for', () => { + renderWrapper(); + + expect(screen.getByText('course_unmapped')).toBeInTheDocument(); + }); + + it('renders nothing for a user whose assignments were not returned', () => { + const { container } = renderWrapper( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/authz-module/team-members/components/AssignedRolesCell.tsx b/src/authz-module/team-members/components/AssignedRolesCell.tsx new file mode 100644 index 00000000..d8a4ebc1 --- /dev/null +++ b/src/authz-module/team-members/components/AssignedRolesCell.tsx @@ -0,0 +1,93 @@ +import { FormattedMessage, useIntl } from '@openedx/frontend-base'; +import { Icon } from '@openedx/paragon'; +import { Business, Person } from '@openedx/paragon/icons'; +import { + ALL_ORGS_KEY, getAggregateScopeType, MAP_ROLE_KEY_TO_LABEL, +} from '@src/authz-module/constants'; +import { getScopeResourceIcon } from '@src/authz-module/utils'; +import componentMessages from '@src/authz-module/components/messages'; +import { RESOURCE_ICONS } from '@src/authz-module/components/constants'; +import type { TeamMember } from '@src/types'; +import messages from '../messages'; + +interface AssignedRolesCellProps { + row: { original: TeamMember }; +} + +/** The role pill: a light rounded block with the person icon, not a Paragon Chip. */ +export const RoleBadge = ({ role }: { role: string }) => ( +
+ + {MAP_ROLE_KEY_TO_LABEL[role] || role} +
+); + +/** + * Collapsed-row cell: the user's first assignment, as "[Role] In " with the + * organization on a subline. + * + * Renders `assignments[0]` exactly as the API returns it — the sub-table's first row must + * match it, so no client-side reordering happens here. The badge stays visible while the + * row is expanded, for visual continuity. + */ +const AssignedRolesCell = ({ row }: AssignedRolesCellProps) => { + const { formatMessage } = useIntl(); + const [assignment] = row.original.assignments ?? []; + + if (!assignment) { + return null; + } + + const { + role, scope, scopeDisplayName, org, + } = assignment; + // An aggregate scope covers every course/library across the platform or within one org, + // so it names no single resource and the API sends an empty display name for it. + /* + * A wildcard scope names no single resource, so the row summarises it by reach alone: + * the organization it covers, or the whole platform. The kind of resource ("All + * courses in this organization") is left to the breakdown, and the organization line + * is dropped since the scope line already says it. + */ + const aggregateType = getAggregateScopeType(scope, org); + let scopeIcon = getScopeResourceIcon(scope); + let scopeText = scopeDisplayName || scope; + if (aggregateType === 'platform') { + scopeIcon = RESOURCE_ICONS.GLOBAL; + scopeText = formatMessage(componentMessages['authz.user.table.org.all.organizations.label']); + } else if (aggregateType === 'org') { + scopeIcon = Business; + scopeText = org; + } + + const orgText = org === ALL_ORGS_KEY + ? formatMessage(componentMessages['authz.user.table.org.all.organizations.label']) + : org; + + return ( +
+ , + scope: ( +
+ + + {scopeText} + + {!aggregateType && ( + + + {orgText} + + )} +
+ ), + }} + /> +
+ ); +}; + +export default AssignedRolesCell; diff --git a/src/authz-module/team-members/components/MoreRolesToggle.tsx b/src/authz-module/team-members/components/MoreRolesToggle.tsx new file mode 100644 index 00000000..7033ca7c --- /dev/null +++ b/src/authz-module/team-members/components/MoreRolesToggle.tsx @@ -0,0 +1,39 @@ +import { useIntl } from '@openedx/frontend-base'; +import type { DataTableRow } from '@openedx/paragon'; +import ExpandableButton from '@src/authz-module/components/ExpandableButton'; +import { useExclusiveRowExpansion } from '@src/authz-module/hooks/useExclusiveRowExpansion'; +import type { TeamMember } from '@src/types'; +import messages from '../messages'; + +interface MoreRolesToggleProps { + row: DataTableRow; +} + +/** + * Expands a team member row to reveal their full role breakdown. + * + * The count is `assignmentCount - 1`: the collapsed row already shows the first role, so + * only the remaining ones are "more". Renders nothing when the user has a single role, + * since there is nothing further to reveal. + */ +const MoreRolesToggle = ({ row }: MoreRolesToggleProps) => { + const { formatMessage } = useIntl(); + const toggleExpanded = useExclusiveRowExpansion(row); + const { assignmentCount } = row.original; + + if (!assignmentCount || assignmentCount <= 1) { + return null; + } + + return ( + + ); +}; + +export default MoreRolesToggle; diff --git a/src/authz-module/team-members/components/TeamMemberCells.test.tsx b/src/authz-module/team-members/components/TeamMemberCells.test.tsx new file mode 100644 index 00000000..04617904 --- /dev/null +++ b/src/authz-module/team-members/components/TeamMemberCells.test.tsx @@ -0,0 +1,86 @@ +import { screen } from '@testing-library/react'; +import { initializeMocks, renderWrapper } from '@src/testUtils'; +import { EmailCell, NameCell } from './TeamMemberCells'; + +describe('NameCell', () => { + const teamMember = { + username: 'johndoe', + fullName: 'John Doe', + email: 'johndoe@example.com', + assignmentCount: 1, + assignments: [], + }; + const mockCellProps = { + row: { + id: '0', + original: teamMember, + }, + }; + beforeEach(() => { + initializeMocks(); + }); + + it('displays the username, never the full name', () => { + renderWrapper(); + expect(screen.getByText('johndoe')).toBeInTheDocument(); + expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); + }); + + it('renders nothing when the user has no username', () => { + const propsWithBlankUsername = { + row: { + id: '0', + original: { + ...teamMember, + username: '', + }, + }, + }; + + renderWrapper(); + expect(screen.queryByText('John Doe')).not.toBeInTheDocument(); + }); + + it('shows current user indicator when username matches authenticated user', () => { + const currentUserProps = { + row: { + id: '0', + original: { + ...teamMember, + username: 'testuser', + fullName: 'Test User', + }, + }, + }; + + renderWrapper(); + expect(screen.getByText('testuser')).toBeInTheDocument(); + expect(screen.getByText(/\(Me\)/)).toBeInTheDocument(); + }); + + it('does not show current user indicator when username does not match authenticated user', () => { + renderWrapper(); + expect(screen.getByText('johndoe')).toBeInTheDocument(); + expect(screen.queryByText(/\(Me\)/)).not.toBeInTheDocument(); + }); + + it('handles missing username in authenticated user gracefully', () => { + const contextWithoutUsername = { + authenticatedUser: { + username: undefined, + email: 'testuser@example.com', + }, + }; + + renderWrapper(, contextWithoutUsername); + expect(screen.getByText('johndoe')).toBeInTheDocument(); + expect(screen.queryByText(/\(Me\)/)).not.toBeInTheDocument(); + }); +}); + +describe('EmailCell', () => { + it('displays the email address', () => { + renderWrapper(); + expect(screen.getByText('johndoe@example.com')).toBeInTheDocument(); + }); +}); diff --git a/src/authz-module/team-members/components/TeamMemberCells.tsx b/src/authz-module/team-members/components/TeamMemberCells.tsx new file mode 100644 index 00000000..397884ef --- /dev/null +++ b/src/authz-module/team-members/components/TeamMemberCells.tsx @@ -0,0 +1,42 @@ +import { useContext } from 'react'; +import { FormattedMessage, SiteContext } from '@openedx/frontend-base'; +import type { TeamMember } from '@src/types'; +import messages from '../messages'; + +interface NameCellProps { + row: { original: TeamMember }; +} + +interface EmailCellProps { + value: string; +} + +/** + * Username column. Shows the username verbatim — the API also sends `fullName`, but this + * column is headed "Username" and is what the table sorts and searches on. The row for the + * signed-in user is marked so they can find themselves in the list. + */ +export const NameCell = ({ row }: NameCellProps) => { + const { authenticatedUser } = useContext(SiteContext); + const { username = '' } = row.original; + const isCurrentUser = username === authenticatedUser?.username; + // The cell is muted so the bare "(Me)" reads as a marker; the username is content. + const name = {username}; + + return ( + + {isCurrentUser + ? ( + + ) + : name} + + ); +}; + +export const EmailCell = ({ value }: EmailCellProps) => ( + {value} +); diff --git a/src/authz-module/team-members/components/TeamMemberViewActionCell.test.tsx b/src/authz-module/team-members/components/TeamMemberViewActionCell.test.tsx new file mode 100644 index 00000000..412c16d6 --- /dev/null +++ b/src/authz-module/team-members/components/TeamMemberViewActionCell.test.tsx @@ -0,0 +1,129 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { initializeMocks, renderWrapper } from '@src/testUtils'; +import type { TeamMember } from '@src/types'; +import { AUTHZ_HOME_PATH } from '@src/authz-module/constants'; +import { useCourseAuthoringFlag } from '@src/authz-module/hooks/useCourseAuthoringFlag'; +import TeamMemberViewActionCell from './TeamMemberViewActionCell'; + +jest.mock('@src/authz-module/hooks/useCourseAuthoringFlag', () => ({ + useCourseAuthoringFlag: jest.fn(), +})); + +const mockCourseAuthoringFlag = (isCourseEnabled: (scope: string) => boolean) => { + (useCourseAuthoringFlag as jest.Mock).mockReturnValue({ + isCourseAuthoringEnabled: true, + isCourseEnabled, + isLoading: false, + }); +}; + +const mockNavigate = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +const courseAssignment = { + role: 'course_staff', + org: 'OpenedX', + scope: 'course-v1:OpenedX+DemoX+DemoCourse', + scopeDisplayName: 'Open edX Demo Course', + permissionCount: 27, +}; + +const libraryAssignment = { + role: 'library_admin', + org: 'WGU', + scope: 'lib:WGU:CSPROB', + scopeDisplayName: 'Computer Science Problems', + permissionCount: 11, +}; + +const teamMember: TeamMember = { + username: 'johndoe', + fullName: 'John Doe', + email: 'johndoe@example.com', + assignmentCount: 1, + assignments: [courseAssignment], +}; + +const cellPropsFor = (overrides: Partial = {}) => ({ + row: { original: { ...teamMember, ...overrides } }, +}); + +describe('TeamMemberViewActionCell', () => { + beforeEach(() => { + mockCourseAuthoringFlag(() => true); + initializeMocks(); + mockNavigate.mockClear(); + }); + + it('renders an accessible view action button', () => { + renderWrapper(); + const viewButton = screen.getByRole('button', { name: /view/i }); + expect(viewButton).toBeInTheDocument(); + expect(viewButton).toHaveAttribute('aria-label'); + }); + + it('navigates to the audit page for the row user', async () => { + const user = userEvent.setup(); + renderWrapper(); + + await user.click(screen.getByRole('button', { name: /view/i })); + + expect(mockNavigate).toHaveBeenCalledWith(`${AUTHZ_HOME_PATH}/user/johndoe`); + }); + + it('handles special characters in the username', async () => { + const user = userEvent.setup(); + renderWrapper(); + + await user.click(screen.getByRole('button', { name: /view/i })); + + expect(mockNavigate).toHaveBeenCalledWith(`${AUTHZ_HOME_PATH}/user/user%2Bwith%40special.chars`); + }); + + it('stays enabled when at least one assignment is viewable', () => { + mockCourseAuthoringFlag(() => false); + renderWrapper( + , + ); + expect(screen.getByRole('button', { name: /view/i })).not.toBeDisabled(); + }); + + it('disables the action with a tooltip when every assignment sits in a disabled course', async () => { + const user = userEvent.setup(); + mockCourseAuthoringFlag(() => false); + renderWrapper(); + + const viewButton = screen.getByRole('button', { name: /view/i }); + expect(viewButton).toBeDisabled(); + + await user.hover(viewButton); + expect(screen.getByText(/manage its team in Studio instead/i)).toBeInTheDocument(); + }); + + it('stays enabled when the returned assignments are only part of the user total', () => { + // Every returned assignment sits in a disabled course, but seven more were not returned. + mockCourseAuthoringFlag(() => false); + renderWrapper( + , + ); + expect(screen.getByRole('button', { name: /view/i })).not.toBeDisabled(); + }); + + it('never calls the flag check with a missing scope', () => { + const isCourseEnabled = jest.fn(() => true); + mockCourseAuthoringFlag(isCourseEnabled); + renderWrapper(); + + expect(isCourseEnabled).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: /view/i })).not.toBeDisabled(); + }); +}); diff --git a/src/authz-module/team-members/components/TeamMemberViewActionCell.tsx b/src/authz-module/team-members/components/TeamMemberViewActionCell.tsx new file mode 100644 index 00000000..850c363f --- /dev/null +++ b/src/authz-module/team-members/components/TeamMemberViewActionCell.tsx @@ -0,0 +1,64 @@ +import { useIntl } from '@openedx/frontend-base'; +import { IconButton } from '@openedx/paragon'; +import { Visibility } from '@openedx/paragon/icons'; +import { useNavigate } from 'react-router-dom'; +import { buildAuditUserPath, CONTEXT_TYPES, getScopeContextType } from '@src/authz-module/constants'; +import { useCourseAuthoringFlag } from '@src/authz-module/hooks/useCourseAuthoringFlag'; +import { DisabledCourseActionButton } from '@src/authz-module/components/TableCells'; +import componentMessages from '@src/authz-module/components/messages'; +import type { TeamMember, TeamMemberAssignment } from '@src/types'; + +interface TeamMemberViewActionCellProps { + row: { original: TeamMember }; +} + +const isViewable = (assignment: TeamMemberAssignment, isCourseEnabled: (scope: string) => boolean) => { + if (getScopeContextType(assignment.scope) === CONTEXT_TYPES.LIBRARY) { + return true; + } + return isCourseEnabled(assignment.scope); +}; + +/** + * Opens a team member's audit page. + * + * Rows are users rather than single assignments, so the course-authoring flag is evaluated + * across the user's assignments: the action stays enabled while at least one of them is + * viewable, and is only disabled when every one sits in a course that hasn't moved to the + * new roles experience. `assignments` is capped by `assignments_limit`, so a user whose + * total exceeds the returned slice may hold viewable roles it does not contain; those rows + * stay enabled rather than being blocked on incomplete evidence. + */ +const TeamMemberViewActionCell = ({ row }: TeamMemberViewActionCellProps) => { + const { formatMessage } = useIntl(); + const navigate = useNavigate(); + // Read here rather than threaded down from the table: the flag is a react-query query, + // so every row shares one request no matter how many cells ask for it. + const { isCourseEnabled } = useCourseAuthoringFlag(); + const { assignments = [], assignmentCount, username } = row.original; + + const hasViewableAssignment = assignments.length === 0 + || assignmentCount > assignments.length + || assignments.some((assignment) => isViewable(assignment, isCourseEnabled)); + + if (!hasViewableAssignment) { + return ( + + ); + } + + return ( + navigate(buildAuditUserPath(username))} + /> + ); +}; + +export default TeamMemberViewActionCell; diff --git a/src/authz-module/team-members/components/UserAssignmentsSubTable.test.tsx b/src/authz-module/team-members/components/UserAssignmentsSubTable.test.tsx new file mode 100644 index 00000000..4bc9ef4e --- /dev/null +++ b/src/authz-module/team-members/components/UserAssignmentsSubTable.test.tsx @@ -0,0 +1,105 @@ +import { screen, within } from '@testing-library/react'; +import { renderWrapper } from '@src/testUtils'; +import type { TeamMember, TeamMemberAssignment } from '@src/types'; +import { AUTHZ_HOME_PATH } from '@src/authz-module/constants'; +import UserAssignmentsSubTable from './UserAssignmentsSubTable'; + +const courseAssignment: TeamMemberAssignment = { + role: 'course_staff', + org: 'OpenedX', + scope: 'course-v1:OpenedX+DemoX+DemoCourse', + scopeDisplayName: 'Open edX Demo Course', + permissionCount: 27, +}; + +const libraryAssignment: TeamMemberAssignment = { + role: 'library_admin', + org: 'WGU', + scope: 'lib:WGU:CSPROB', + scopeDisplayName: 'Computer Science Problems', + permissionCount: 11, +}; + +const rowFor = (assignments: TeamMemberAssignment[], assignmentCount = assignments.length) => ({ + row: { + original: { + username: 'johndoe', + fullName: 'John Doe', + email: 'johndoe@example.com', + assignmentCount, + assignments, + } as TeamMember, + }, +}); + +describe('UserAssignmentsSubTable', () => { + it('lists one row per returned assignment, in the order the API sent them', () => { + renderWrapper(); + + const rows = screen.getAllByRole('row').slice(1); // drop the header row + expect(rows).toHaveLength(2); + expect(within(rows[0]).getByText('Course Staff')).toBeInTheDocument(); + expect(within(rows[1]).getByText('Library Admin')).toBeInTheDocument(); + }); + + it('names each scope and organization', () => { + renderWrapper(); + + expect(screen.getByText('Open edX Demo Course')).toBeInTheDocument(); + expect(screen.getByText('OpenedX')).toBeInTheDocument(); + }); + + it('falls back to the scope id when the API resolved no display name', () => { + renderWrapper(); + + expect(screen.getByText('course-v1:OpenedX+DemoX+DemoCourse')).toBeInTheDocument(); + }); + + it('names wildcard scopes instead of showing their keys', () => { + renderWrapper( + + ); + + // The scope column names only the kind of resource... + expect(screen.getAllByText('All courses')).toHaveLength(2); + expect(screen.getAllByText('All libraries')).toHaveLength(2); + // ...and the organization column says how far it reaches. + expect(screen.getAllByText('All platform')).toHaveLength(2); + expect(screen.getAllByText('MathDept')).toHaveLength(2); + expect(screen.queryByText('course-v1:*')).not.toBeInTheDocument(); + }); + + it('reports how many of the user total are listed', () => { + renderWrapper(); + + expect(screen.getByText('Showing 02 of 07')).toBeInTheDocument(); + }); + + it('offers the audit page when the listing is truncated', () => { + renderWrapper(); + + expect(screen.getByRole('link', { name: /View all roles/ })) + .toHaveAttribute('href', `${AUTHZ_HOME_PATH}/user/johndoe`); + }); + + it('omits the audit link when every role is already listed', () => { + renderWrapper(); + + expect(screen.getByText('Showing 02 of 02')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /View all roles/ })).not.toBeInTheDocument(); + }); +}); diff --git a/src/authz-module/team-members/components/UserAssignmentsSubTable.tsx b/src/authz-module/team-members/components/UserAssignmentsSubTable.tsx new file mode 100644 index 00000000..96d1056a --- /dev/null +++ b/src/authz-module/team-members/components/UserAssignmentsSubTable.tsx @@ -0,0 +1,133 @@ +import { useMemo } from 'react'; +import { useIntl } from '@openedx/frontend-base'; +import { + Card, DataTable, Icon, TableFooter, +} from '@openedx/paragon'; +import { ArrowForward, Business } from '@openedx/paragon/icons'; +import { Link } from 'react-router-dom'; +import { + ALL_ORGS_KEY, buildAuditUserPath, getAggregateScopeType, getScopeContextType, +} from '@src/authz-module/constants'; +import { getScopeResourceIcon } from '@src/authz-module/utils'; +import componentMessages from '@src/authz-module/components/messages'; +import { RESOURCE_ICONS } from '@src/authz-module/components/constants'; +import type { TeamMember, TeamMemberAssignment } from '@src/types'; +import { AGGREGATE_SCOPE_LABELS } from '@src/authz-module/messages'; +import messages from '../messages'; +import { RoleBadge } from './AssignedRolesCell'; + +interface UserAssignmentsSubTableProps { + row: { original: TeamMember }; +} + +interface AssignmentCellProps { + row: { original: TeamMemberAssignment }; +} + +// Same badge as the collapsed row above, so the breakdown reads consistently. +const RoleBadgeCell = ({ row: assignmentRow }: AssignmentCellProps) => ( + +); + +const ScopeNameCell = ({ row: assignmentRow }: AssignmentCellProps) => { + const { formatMessage } = useIntl(); + const { scope, scopeDisplayName, org } = assignmentRow.original; + const aggregateType = getAggregateScopeType(scope, org); + const scopeText = aggregateType + ? formatMessage(AGGREGATE_SCOPE_LABELS[getScopeContextType(scope)]) + : scopeDisplayName || scope; + + return ( + + + {scopeText} + + ); +}; + +// Mirrors ScopeNameCell's icon treatment so all three columns read alike. A wildcard org +// reaches past any one organization, so it gets the same globe and label as the row above. +const OrgIconCell = ({ row: assignmentRow }: AssignmentCellProps) => { + const { formatMessage } = useIntl(); + const { org } = assignmentRow.original; + const isAllOrgs = org === ALL_ORGS_KEY; + const orgText = isAllOrgs + ? formatMessage(componentMessages['authz.user.table.org.all.organizations.label']) + : org; + + return ( + + + {orgText} + + ); +}; + +/** + * Role breakdown revealed when a team member row is expanded. + * + * Lists the assignments the API returned, in order, so the first entry matches the badge + * still shown in the collapsed row above. The footer total is `assignmentCount`, which + * may exceed the rows listed here — `View all roles` leads to the audit page when it + * does. + */ +const UserAssignmentsSubTable = ({ row }: UserAssignmentsSubTableProps) => { + const { formatMessage } = useIntl(); + const { assignments = [], assignmentCount, username } = row.original; + const hasMoreAssignments = assignmentCount > assignments.length; + + const columns = useMemo(() => [ + { + id: 'role', + Header: formatMessage(messages['authz.team.members.subtable.column.role.title']), + accessor: 'role', + Cell: RoleBadgeCell, + }, + { + id: 'scope', + Header: formatMessage(messages['authz.team.members.subtable.column.scope.title']), + accessor: 'scope', + Cell: ScopeNameCell, + }, + { + id: 'org', + Header: formatMessage(messages['authz.team.members.subtable.column.organization.title']), + accessor: 'org', + Cell: OrgIconCell, + }, + ], [formatMessage]); + + return ( + + + + +
+ + {formatMessage(messages['authz.team.members.subtable.showing.text'], { + shown: String(assignments.length).padStart(2, '0'), + total: String(assignmentCount).padStart(2, '0'), + })} + + {hasMoreAssignments && ( + <> + {/* Reuses the module's vertical `hr` divider (see index.scss). */} +
+ + {formatMessage(messages['authz.team.members.subtable.view.all.roles'])} + + + + )} +
+
+
+
+ ); +}; + +export default UserAssignmentsSubTable; diff --git a/src/authz-module/team-members/index.ts b/src/authz-module/team-members/index.ts new file mode 100644 index 00000000..adbf500d --- /dev/null +++ b/src/authz-module/team-members/index.ts @@ -0,0 +1 @@ +export { default as TeamMembersTable } from './TeamMembersTable'; diff --git a/src/authz-module/team-members/messages.ts b/src/authz-module/team-members/messages.ts index 5aa02d6c..f6eeceb4 100644 --- a/src/authz-module/team-members/messages.ts +++ b/src/authz-module/team-members/messages.ts @@ -1,10 +1,15 @@ import { defineMessages } from '@openedx/frontend-base'; const messages = defineMessages({ - 'authz.team.members.table.column.name.title': { - id: 'authz.team.members.table.column.name.title', - defaultMessage: 'Name', - description: 'Team members table name column header', + 'authz.team.members.table.column.username.title': { + id: 'authz.team.members.table.column.username.title', + defaultMessage: 'Username', + description: 'Team members table username column header', + }, + 'authz.team.members.table.username.current': { + id: 'authz.team.members.table.username.current', + defaultMessage: '{username} (Me)', + description: 'Indicates the current user in the team members table', }, 'authz.team.members.table.column.email.title': { id: 'authz.team.members.table.column.email.title', @@ -31,7 +36,56 @@ const messages = defineMessages({ defaultMessage: 'Actions', description: 'Team members table actions column header', }, - + 'authz.team.members.table.column.assigned.roles.title': { + id: 'authz.team.members.table.column.assigned.roles.title', + defaultMessage: 'Assigned roles', + description: 'Team members table assigned roles column header', + }, + 'authz.team.members.table.assigned.roles': { + id: 'authz.team.members.table.assigned.roles', + defaultMessage: '{role} in {scope}', + description: 'One role assignment on a team member row, e.g. "Course Admin in Introduction to Data Analysis". {role} is the role badge and {scope} the course or library it applies to, so both the order and the connecting word can be changed per language.', + }, + 'authz.team.members.table.more.roles': { + id: 'authz.team.members.table.more.roles', + defaultMessage: '+{count, plural, one {# more role} other {# more roles}}', + description: 'Link expanding a team member row to reveal their remaining roles. The count excludes the role already shown in the collapsed row.', + }, + 'authz.team.members.table.hide.roles': { + id: 'authz.team.members.table.hide.roles', + defaultMessage: 'Hide roles', + description: 'Link collapsing an expanded team member row', + }, + 'authz.team.members.subtable.column.role.title': { + id: 'authz.team.members.subtable.column.role.title', + defaultMessage: 'Role', + description: 'Role column header of the nested assignments table inside an expanded team member row', + }, + 'authz.team.members.subtable.column.scope.title': { + id: 'authz.team.members.subtable.column.scope.title', + defaultMessage: 'Scope', + description: 'Scope column header of the nested assignments table inside an expanded team member row', + }, + 'authz.team.members.subtable.column.organization.title': { + id: 'authz.team.members.subtable.column.organization.title', + defaultMessage: 'Organization', + description: 'Organization column header of the nested assignments table inside an expanded team member row', + }, + 'authz.team.members.subtable.showing.text': { + id: 'authz.team.members.subtable.showing.text', + defaultMessage: 'Showing {shown} of {total}', + description: 'Footer of the nested assignments table stating how many of the user total number of roles are listed', + }, + 'authz.team.members.subtable.view.all.roles': { + id: 'authz.team.members.subtable.view.all.roles', + defaultMessage: 'View all roles', + description: 'Link taking the user to the full profile listing every role assigned to a team member', + }, + 'authz.team.members.table.showing.users.text': { + id: 'authz.team.members.table.showing.users.text', + defaultMessage: 'Showing {pageSize, plural, one {# user} other {# users}} of {itemCount}.', + description: 'Text stating how many team members are listed on the current page out of the total count', + }, }); export default messages; diff --git a/src/authz-module/utils.test.tsx b/src/authz-module/utils.test.tsx index 743bc5d4..3e0fbb66 100644 --- a/src/authz-module/utils.test.tsx +++ b/src/authz-module/utils.test.tsx @@ -1,6 +1,9 @@ import { screen } from '@testing-library/react'; import { initializeMocks, renderWrapper } from '@src/testUtils'; -import { getCellHeader, getScopeManageAction, getScopeManageActionPermission } from './utils'; +import { + getCellHeader, getScopeManageAction, getScopeManageActionPermission, getScopeResourceIcon, +} from './utils'; +import { RESOURCE_ICONS } from './components/constants'; import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from './roles-permissions'; const renderCellHeader = (columnId: string, columnTitle: string, filtersApplied: string[]) => { @@ -81,6 +84,24 @@ describe('utils', () => { }); }); + describe('getScopeResourceIcon', () => { + it('picks the library icon for a library scope', () => { + expect(getScopeResourceIcon('lib:MathDept:ALGEBRA')).toBe(RESOURCE_ICONS.LIBRARY); + }); + + it('picks the library icon for an aggregate library scope', () => { + expect(getScopeResourceIcon('lib:*')).toBe(RESOURCE_ICONS.LIBRARY); + }); + + it('picks the course icon for a course scope', () => { + expect(getScopeResourceIcon('course-v1:MathDept+ALG101+2026')).toBe(RESOURCE_ICONS.COURSE); + }); + + it('picks the course icon for an aggregate course scope', () => { + expect(getScopeResourceIcon('course-v1:*')).toBe(RESOURCE_ICONS.COURSE); + }); + }); + describe('getScopeManageAction', () => { it('returns MANAGE_LIBRARY_TEAM for library scopes', () => { expect(getScopeManageAction('lib:testorg:library-123')).toBe( diff --git a/src/authz-module/utils.tsx b/src/authz-module/utils.tsx index fd2a422e..caf951a8 100644 --- a/src/authz-module/utils.tsx +++ b/src/authz-module/utils.tsx @@ -1,5 +1,7 @@ import { Icon } from '@openedx/paragon'; import { FilterList } from '@openedx/paragon/icons'; +import { RESOURCE_ICONS } from './components/constants'; +import { CONTEXT_TYPES, getScopeContextType } from './constants'; import { CONTENT_COURSE_PERMISSIONS, CONTENT_LIBRARY_PERMISSIONS } from './roles-permissions'; /** @@ -31,16 +33,19 @@ export const getCellHeader = (columnId: string, columnTitle: string, filtersAppl return columnTitle; }; -export const getScopeManageAction = (scope: string) => { - if (scope.startsWith('lib')) { - return CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM; - } - if (scope.startsWith('course')) { - return CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM; - } - // Default fallback or throw error for unknown scopes - return CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM; -}; +/** + * Picks the course or library icon for a scope from the scope key itself. + */ +export const getScopeResourceIcon = (scope: string) => ( + getScopeContextType(scope) === CONTEXT_TYPES.LIBRARY ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE +); + +/** Unrecognised scopes fall back to the course permission, as `getScopeContextType` does. */ +export const getScopeManageAction = (scope: string) => ( + getScopeContextType(scope) === CONTEXT_TYPES.LIBRARY + ? CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM + : CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM +); export const getScopeManageActionPermission = (scope: string) => { const action = getScopeManageAction(scope); diff --git a/src/components/LoadingPage.tsx b/src/components/LoadingPage.tsx index fabd8cc4..1e4b596f 100644 --- a/src/components/LoadingPage.tsx +++ b/src/components/LoadingPage.tsx @@ -1,14 +1,23 @@ +import { useIntl } from '@openedx/frontend-base'; import { Spinner, Container } from '@openedx/paragon'; +import messages from './messages'; -const LoadingPage = () => ( - - - -); +/** + * Full-page loading state. + */ +const LoadingPage = () => { + const intl = useIntl(); + + return ( + + + + ); +}; export default LoadingPage; diff --git a/src/components/messages.ts b/src/components/messages.ts new file mode 100644 index 00000000..7f874f64 --- /dev/null +++ b/src/components/messages.ts @@ -0,0 +1,11 @@ +import { defineMessages } from '@openedx/frontend-base'; + +const messages = defineMessages({ + 'loading.page.screenreader.text': { + id: 'loading.page.screenreader.text', + defaultMessage: 'Loading', + description: 'Announced by screen readers while a full-page loading spinner is shown.', + }, +}); + +export default messages; diff --git a/src/types.ts b/src/types.ts index d605c420..598bae1e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,26 +7,6 @@ export interface PermissionValidationResponse extends PermissionValidationReques allowed: boolean; } -// Libraries AuthZ types -export interface TeamMember { - username: string; - fullName: string; - email: string; - roles: string[]; - createdAt: string; - scope: { resource: string; type: 'COURSE' | 'LIBRARY' | 'GLOBAL' }; - organization: string; - role: string; -} - -export interface LibraryMetadata { - id: string; - org: string; - title: string; - slug: string; - allowPublicRead: boolean; -} - export interface RoleMetadata { role: string; name: string; @@ -107,3 +87,34 @@ export interface RoleToDelete { export type UserRoleWithPermissions = UserRole & { canManageScope?: boolean; }; + +/** + * A single role assignment nested under a team member in the user-grouped listing. + * Mirrors `results[].assignments[]` of `GET /api/authz/v1/users/`. + */ +export interface TeamMemberAssignment { + role: string; + org: string; + /** Scope external key (e.g. `course-v1:Org+Course+Run`). Identifies the scope and is + * what the scope filter sends back to the API — not shown to the user. */ + scope: string; + /** Human-readable scope name shown in the table. The API returns an empty string for + * glob scopes and for scopes whose backing course or library no longer exists, so + * callers fall back to `scope`. */ + scopeDisplayName: string; + permissionCount: number; +} + +/** + * A team member as returned by the user-grouped assignments endpoint: one entry per + * user, carrying the first few of their assignments. `assignmentCount` counts every + * assignment behind that slice, so it can exceed `assignments.length`. Both are scoped + * to the active role, org and scope filters and to what the caller may view. + */ +export interface TeamMember { + username: string; + fullName: string; + email: string; + assignmentCount: number; + assignments: TeamMemberAssignment[]; +}