diff --git a/src/authz-module/authz-home/index.test.tsx b/src/authz-module/authz-home/index.test.tsx
index 43f3da1d..818984da 100644
--- a/src/authz-module/authz-home/index.test.tsx
+++ b/src/authz-module/authz-home/index.test.tsx
@@ -1,40 +1,74 @@
import React from 'react';
import { screen } from '@testing-library/react';
-import { renderWrapper } from '@src/setupTest';
+import { useAllRoleAssignments, useOrgs, useScopes } from '@src/authz-module/data/hooks';
+import { ToastManagerProvider } from '@src/authz-module/libraries-manager/ToastManagerContext';
+import { renderWithAllProviders } from '@src/setupTest';
+import userEvent from '@testing-library/user-event';
import AuthzHome from './index';
+import messages from './messages';
-jest.mock('../components/AuthZLayout', () => function MockAuthZLayout({ children }: { children: React.ReactNode }) {
- return
{children}
;
-});
+jest.mock('@src/authz-module/data/hooks', () => ({
+ useAllRoleAssignments: jest.fn(),
+ useOrgs: jest.fn(),
+ useScopes: jest.fn(),
+}));
-jest.mock('../roles-permissions/RolesPermissions', () => function MockRolesPermissions() {
- return Roles & Permissions Content
;
-});
+const emptyResponse = {
+ data: {
+ results: [], count: 0, next: null, previous: null,
+ },
+ error: null,
+ isLoading: false,
+ refetch: jest.fn(),
+};
-jest.mock('@openedx/paragon', () => ({
- Tab: ({ children, title } : { children: React.ReactNode, title: string }) => {title}: {children}
,
- Tabs: ({ children }: { children: React.ReactNode }) => {children}
,
-}));
+const renderAuthzHome = () => renderWithAllProviders(
+
+
+ ,
+);
describe('AuthzHome', () => {
+ beforeEach(() => {
+ (useAllRoleAssignments as jest.Mock).mockReturnValue(emptyResponse);
+ (useOrgs as jest.Mock).mockReturnValue(emptyResponse);
+ (useScopes as jest.Mock).mockReturnValue(emptyResponse);
+ });
+
it('renders without crashing', () => {
- renderWrapper();
+ renderAuthzHome();
});
it('renders the main layout and tabs', () => {
- renderWrapper();
- expect(screen.getByTestId('authz-layout')).toBeInTheDocument();
- expect(screen.getByTestId('tabs')).toBeInTheDocument();
+ renderAuthzHome();
+ expect(screen.getByText(messages['authz.manage.page.title'].defaultMessage)).toBeInTheDocument();
+ expect(screen.getByText(messages['authz.tabs.permissionsRoles'].defaultMessage)).toBeInTheDocument();
+ expect(screen.getByText(messages['authz.tabs.team'].defaultMessage)).toBeInTheDocument();
});
it('renders both tab panels', () => {
- renderWrapper();
- const tabs = screen.getAllByTestId('tab');
- expect(tabs).toHaveLength(2);
+ renderAuthzHome();
+ expect(screen.getByText(messages['authz.tabs.permissionsRoles'].defaultMessage)).toBeInTheDocument();
+ expect(screen.getByText(messages['authz.tabs.team'].defaultMessage)).toBeInTheDocument();
+ expect(screen.getAllByRole('tab')).toHaveLength(3); // 2 + tab invisible for more...
+ });
+
+ it('renders the RolesPermissions component in the permissions tab', async () => {
+ const user = userEvent.setup();
+ renderAuthzHome();
+ await user.click(screen.getByText(messages['authz.tabs.permissionsRoles'].defaultMessage));
+ expect(screen.getByRole('button', { name: 'Courses' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Libraries' })).toBeInTheDocument();
});
- it('renders the RolesPermissions component in the permissions tab', () => {
- renderWrapper();
- expect(screen.getByTestId('roles-permissions')).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('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('Actions')).toBeInTheDocument();
});
});
diff --git a/src/authz-module/authz-home/index.tsx b/src/authz-module/authz-home/index.tsx
index f6cc7169..27a1a7fd 100644
--- a/src/authz-module/authz-home/index.tsx
+++ b/src/authz-module/authz-home/index.tsx
@@ -1,6 +1,8 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { Tab, Tabs } from '@openedx/paragon';
-import { useLocation } from 'react-router-dom';
+import { useLocation, useSearchParams } from 'react-router-dom';
+import TeamMembersTable from '@src/authz-module/team-members/TeamMembersTable';
+import AddRoleButton from '@src/authz-module/components/AddRoleButton';
import RolesPermissions from '../roles-permissions/RolesPermissions';
import AuthZLayout from '../components/AuthZLayout';
@@ -9,24 +11,19 @@ import messages from './messages';
const AuthzHome = () => {
const { hash } = useLocation();
const intl = useIntl();
+ const [searchParams] = useSearchParams();
+ const presetScope = searchParams.get('scope') || undefined;
- const rootBreadcrumb = intl.formatMessage(messages['authz.breadcrumb.root']) || '';
const pageTitle = intl.formatMessage(messages['authz.manage.page.title']);
return (
-
+
,
- // ]
+ [
]
}
>
{
defaultActiveKey={hash ? 'permissionsRoles' : 'team'}
className="bg-light-100 px-5"
>
-
- {/* TODO: once TeamTable is refactored we can call it here. For now, this tab will be empty. */}
- {/* */}
+
+
diff --git a/src/authz-module/authz-home/messages.ts b/src/authz-module/authz-home/messages.ts
index 39eb74af..bccd2d64 100644
--- a/src/authz-module/authz-home/messages.ts
+++ b/src/authz-module/authz-home/messages.ts
@@ -3,23 +3,23 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'authz.manage.page.title': {
id: 'authz.manage.page.title',
- defaultMessage: 'Library Team Management',
- description: 'Libraries AuthZ page title',
+ defaultMessage: 'Roles and Permissions Management',
+ description: 'AuthZ home page title',
},
'authz.breadcrumb.root': {
id: 'authz.breadcrumb.root',
defaultMessage: 'Manage Access',
- description: 'Libraries AuthZ root breadcrumb',
+ description: 'AuthZ root breadcrumb',
},
'authz.tabs.team': {
id: 'authz.tabs.team',
defaultMessage: 'Team Members',
- description: 'Libraries AuthZ title for the team management tab',
+ description: 'AuthZ title for the team management tab',
},
'authz.tabs.permissionsRoles': {
id: 'authz.tabs.permissionsRoles',
defaultMessage: 'Roles and Permissions',
- description: 'Libraries AuthZ title for the roles tab',
+ description: 'AuthZ title for the roles tab',
},
});
diff --git a/src/authz-module/components/AddRoleButton.test.tsx b/src/authz-module/components/AddRoleButton.test.tsx
new file mode 100644
index 00000000..7765faf4
--- /dev/null
+++ b/src/authz-module/components/AddRoleButton.test.tsx
@@ -0,0 +1,134 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useNavigate } from 'react-router-dom';
+import { initializeMockApp } from '@edx/frontend-platform/testing';
+import { renderWrapper } from '@src/setupTest';
+import AddRoleButton from './AddRoleButton';
+
+// Mock react-router-dom navigation
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useNavigate: jest.fn(),
+}));
+
+describe('AddRoleButton', () => {
+ const mockNavigate = jest.fn();
+
+ beforeAll(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 1,
+ username: 'testuser',
+ email: 'test@example.com',
+ },
+ });
+ });
+
+ beforeEach(() => {
+ (useNavigate as jest.Mock).mockReturnValue(mockNavigate);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('renders the assign role button with correct text', () => {
+ renderWrapper();
+
+ const button = screen.getByRole('button', { name: /assign role/i });
+ expect(button).toBeInTheDocument();
+ });
+
+ it('displays the plus icon', () => {
+ renderWrapper();
+
+ const button = screen.getByRole('button', { name: /assign role/i });
+ expect(button.querySelector('svg')).toBeInTheDocument();
+ });
+
+ it('renders correctly when presetUsername is provided', () => {
+ renderWrapper();
+
+ const button = screen.getByRole('button', { name: /assign role/i });
+ expect(button).toBeInTheDocument();
+ });
+ });
+
+ describe('navigation behavior', () => {
+ it('navigates to assign role page without username when clicked', async () => {
+ const user = userEvent.setup();
+ renderWrapper();
+
+ const button = screen.getByRole('button', { name: /assign role/i });
+ await user.click(button);
+
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role');
+ });
+
+ it('navigates to assign role page with username query parameter when presetUsername is provided', async () => {
+ const user = userEvent.setup();
+ const presetUsername = 'john.doe';
+ renderWrapper();
+
+ const button = screen.getByRole('button', { name: /assign role/i });
+ await user.click(button);
+
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).toHaveBeenCalledWith(`/authz/assign-role?username=${presetUsername}`);
+ });
+
+ it('handles special characters in presetUsername correctly', async () => {
+ const user = userEvent.setup();
+ const presetUsername = 'user@example.com';
+ renderWrapper();
+
+ const button = screen.getByRole('button', { name: /assign role/i });
+ await user.click(button);
+
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).toHaveBeenCalledWith(`/authz/assign-role?username=${presetUsername}`);
+ });
+ });
+
+ describe('user interactions', () => {
+ it('responds to keyboard navigation', async () => {
+ const user = userEvent.setup();
+ renderWrapper();
+
+ const button = screen.getByRole('button', { name: /assign role/i });
+
+ await user.tab();
+ expect(button).toHaveFocus();
+
+ await user.keyboard('{Enter}');
+ expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role');
+ });
+
+ it('responds to spacebar activation', async () => {
+ const user = userEvent.setup();
+ renderWrapper();
+
+ const button = screen.getByRole('button', { name: /assign role/i });
+ button.focus();
+
+ await user.keyboard(' ');
+ expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role');
+ });
+
+ it('handles multiple clicks gracefully', async () => {
+ const user = userEvent.setup();
+ renderWrapper();
+
+ const button = screen.getByRole('button', { name: /assign role/i });
+
+ await user.click(button);
+ await user.click(button);
+ await user.click(button);
+
+ expect(mockNavigate).toHaveBeenCalledTimes(3);
+ expect(mockNavigate).toHaveBeenCalledWith('/authz/assign-role?username=testuser');
+ });
+ });
+});
diff --git a/src/authz-module/components/AddRoleButton.tsx b/src/authz-module/components/AddRoleButton.tsx
new file mode 100644
index 00000000..8a44e750
--- /dev/null
+++ b/src/authz-module/components/AddRoleButton.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Button } from '@openedx/paragon';
+import { Plus } from '@openedx/paragon/icons';
+
+import baseMessages from '@src/authz-module/messages';
+import { useNavigate } from 'react-router-dom';
+
+interface AddRoleButtonProps {
+ presetUsername?: string;
+}
+
+const AddRoleButton = ({ presetUsername }: AddRoleButtonProps) => {
+ const intl = useIntl();
+ const navigate = useNavigate();
+
+ const handleClick = () => {
+ const assignRolePath = `/authz/assign-role${presetUsername ? `?username=${presetUsername}` : ''}`;
+ navigate(assignRolePath);
+ };
+
+ return (
+
+ );
+};
+
+export default AddRoleButton;
diff --git a/src/authz-module/components/AuthZLayout.tsx b/src/authz-module/components/AuthZLayout.tsx
index 9845b90c..06b572cc 100644
--- a/src/authz-module/components/AuthZLayout.tsx
+++ b/src/authz-module/components/AuthZLayout.tsx
@@ -14,9 +14,10 @@ interface AuthZLayoutProps extends AuthZTitleProps {
const AuthZLayout = ({ children, context, ...props }: AuthZLayoutProps) => (
<>
{children}
diff --git a/src/authz-module/components/AuthZTitle.tsx b/src/authz-module/components/AuthZTitle.tsx
index a169c840..c4aaa734 100644
--- a/src/authz-module/components/AuthZTitle.tsx
+++ b/src/authz-module/components/AuthZTitle.tsx
@@ -21,7 +21,7 @@ interface Action {
}
export interface AuthZTitleProps {
- activeLabel: string;
+ activeLabel?: string;
pageTitle: string;
pageSubtitle: string | ReactNode;
navLinks?: BreadcrumbLink[];
@@ -53,8 +53,8 @@ const AuthZTitle = ({
{pageTitle}
{typeof pageSubtitle === 'string'
- ? <>
{pageSubtitle}
>
- : <>
{pageSubtitle}
>}
+ ? <> { pageSubtitle !== '' &&
}
{pageSubtitle}
>
+ : <>{ pageSubtitle !== '' &&
}
{pageSubtitle}
>}
diff --git a/src/authz-module/components/TableCells.test.tsx b/src/authz-module/components/TableCells.test.tsx
new file mode 100644
index 00000000..00e7ad3d
--- /dev/null
+++ b/src/authz-module/components/TableCells.test.tsx
@@ -0,0 +1,420 @@
+import { screen } from '@testing-library/react';
+import { initializeMockApp } from '@edx/frontend-platform/testing';
+import { renderWrapper } from '@src/setupTest';
+import userEvent from '@testing-library/user-event';
+import {
+ NameCell,
+ ViewActionCell,
+ RoleCell,
+ OrgCell,
+ ScopeCell,
+} from './TableCells';
+
+const mockNavigate = jest.fn();
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useNavigate: () => mockNavigate,
+}));
+
+describe('TableCells Components', () => {
+ beforeAll(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 1,
+ username: 'testuser',
+ email: 'test@example.com',
+ },
+ });
+ });
+
+ afterEach(() => {
+ 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: {
+ original: mockUserRole,
+ },
+ };
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 1,
+ username: 'testuser',
+ email: 'testuser@example.com',
+ },
+ });
+ });
+
+ 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: {
+ original: {
+ ...mockUserRole,
+ fullName: undefined,
+ },
+ },
+ };
+
+ renderWrapper();
+ expect(screen.getByText('johndoe')).toBeInTheDocument();
+ });
+
+ it('displays username when full name is empty string', () => {
+ const propsWithEmptyFullName = {
+ row: {
+ original: {
+ ...mockUserRole,
+ fullName: '',
+ },
+ },
+ };
+
+ renderWrapper();
+ expect(screen.getByText('johndoe')).toBeInTheDocument();
+ });
+
+ it('shows current user indicator when username matches authenticated user', () => {
+ const currentUserProps = {
+ row: {
+ 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: {
+ 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: {
+ original: mockUserRole,
+ },
+ };
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 1,
+ username: 'testuser',
+ email: 'testuser@example.com',
+ },
+ });
+ 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('/authz/user/johndoe');
+ });
+
+ it('navigates with correct username for different user', async () => {
+ const user = userEvent.setup();
+ const differentUserProps = {
+ row: {
+ original: {
+ ...mockUserRole,
+ username: 'janedoe',
+ },
+ },
+ };
+
+ renderWrapper();
+
+ const viewButton = screen.getByRole('button', { name: /view/i });
+ await user.click(viewButton);
+
+ expect(mockNavigate).toHaveBeenCalledWith('/authz/user/janedoe');
+ });
+
+ it('handles empty username gracefully', async () => {
+ const user = userEvent.setup();
+ const emptyUsernameProps = {
+ row: {
+ original: {
+ ...mockUserRole,
+ username: '',
+ },
+ },
+ };
+
+ renderWrapper();
+
+ const viewButton = screen.getByRole('button', { name: /view/i });
+ await user.click(viewButton);
+
+ expect(mockNavigate).toHaveBeenCalledWith('/authz/user/');
+ });
+
+ it('handles special characters in username', async () => {
+ const user = userEvent.setup();
+ const specialUsernameProps = {
+ row: {
+ original: {
+ ...mockUserRole,
+ username: 'user+with@special.chars',
+ },
+ },
+ };
+
+ renderWrapper();
+
+ const viewButton = screen.getByRole('button', { name: /view/i });
+ await user.click(viewButton);
+
+ expect(mockNavigate).toHaveBeenCalledWith('/authz/user/user+with@special.chars');
+ });
+ });
+
+ describe('RoleCell', () => {
+ const mockCell = {
+ getCellProps: jest.fn(() => ({ 'data-testid': 'role-cell' })),
+ };
+
+ it('renders the role label for a mapped role', () => {
+ const props = {
+ value: 'library_admin',
+ cell: mockCell,
+ row: {
+ original: {
+ role: 'library_admin', org: 'Test Org', scope: 'Test Scope', permissionCount: 1,
+ },
+ },
+ column: { id: 'role' },
+ };
+
+ renderWrapper();
+
+ expect(screen.getByText('Library Admin')).toBeInTheDocument();
+ expect(mockCell.getCellProps).toHaveBeenCalledWith({ 'data-role': 'Library Admin' });
+ });
+
+ it('renders empty string for unmapped role', () => {
+ const props = {
+ value: 'unknown_role',
+ cell: mockCell,
+ row: {
+ original: {
+ role: 'unknown_role', org: 'Test Org', scope: 'Test Scope', permissionCount: 1,
+ },
+ },
+ column: { id: 'role' },
+ };
+
+ renderWrapper();
+
+ const cellElement = screen.getByTestId('role-cell');
+ expect(cellElement).toHaveTextContent('');
+ expect(mockCell.getCellProps).toHaveBeenCalledWith({ 'data-role': '' });
+ });
+
+ it('applies cell props correctly', () => {
+ const props = {
+ value: 'course_staff',
+ cell: mockCell,
+ row: {
+ original: {
+ role: 'course_staff', org: 'Test Org', scope: 'Test Scope', permissionCount: 1,
+ },
+ },
+ column: { id: 'role' },
+ };
+
+ renderWrapper();
+
+ expect(screen.getByText('Course Staff')).toBeInTheDocument();
+ expect(mockCell.getCellProps).toHaveBeenCalledWith({ 'data-role': 'Course Staff' });
+ });
+ });
+
+ describe('OrgCell', () => {
+ it('displays "All Organizations" for Django superuser role', () => {
+ const props = {
+ value: 'Test Org',
+ row: {
+ original: {
+ role: 'django.superuser', org: 'Test Org', scope: 'Test Scope', permissionCount: 1,
+ },
+ },
+ column: { id: 'org' },
+ };
+
+ renderWrapper();
+
+ expect(screen.getByText('All Organizations')).toBeInTheDocument();
+ expect(screen.queryByText('Test Org')).not.toBeInTheDocument();
+ });
+
+ it('displays "All Organizations" for Django global staff role', () => {
+ const props = {
+ value: 'Test Org',
+ row: {
+ original: {
+ role: 'django.globalstaff', org: 'Test Org', scope: 'Test Scope', permissionCount: 1,
+ },
+ },
+ column: { id: 'org' },
+ };
+
+ renderWrapper();
+
+ expect(screen.getByText('All Organizations')).toBeInTheDocument();
+ expect(screen.queryByText('Test Org')).not.toBeInTheDocument();
+ });
+
+ it('displays the actual org value for non-Django roles', () => {
+ const props = {
+ value: 'Test Organization',
+ row: {
+ original: {
+ role: 'library_admin', org: 'Test Organization', scope: 'Test Scope', permissionCount: 1,
+ },
+ },
+ column: { id: 'org' },
+ };
+
+ renderWrapper();
+
+ expect(screen.getByText('Test Organization')).toBeInTheDocument();
+ expect(screen.queryByText('All Organizations')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('ScopeCell', () => {
+ it('displays "Global" for Django superuser role', () => {
+ const props = {
+ value: 'library',
+ row: {
+ original: {
+ role: 'django.superuser', org: 'Test Org', scope: 'Test Scope', permissionCount: 1,
+ },
+ },
+ column: { id: 'scope' },
+ };
+
+ renderWrapper();
+
+ expect(screen.getByText('Global')).toBeInTheDocument();
+ expect(screen.queryByText('library')).not.toBeInTheDocument();
+ });
+
+ it('displays "Global" for Django global staff role', () => {
+ const props = {
+ value: 'course',
+ row: {
+ original: {
+ role: 'django.globalstaff', org: 'Test Org', scope: 'Test Scope', permissionCount: 1,
+ },
+ },
+ column: { id: 'scope' },
+ };
+
+ renderWrapper();
+
+ expect(screen.getByText('Global')).toBeInTheDocument();
+ expect(screen.queryByText('course')).not.toBeInTheDocument();
+ });
+
+ it('displays the actual scope value for non-Django roles', () => {
+ const props = {
+ value: 'Course Scope',
+ row: {
+ original: {
+ role: 'course_admin', org: 'Test Org', scope: 'Course Scope', permissionCount: 1,
+ },
+ },
+ column: { id: 'scope' },
+ };
+
+ renderWrapper();
+
+ expect(screen.getByText('Course Scope')).toBeInTheDocument();
+ expect(screen.queryByText('Global')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx
new file mode 100644
index 00000000..a1a36947
--- /dev/null
+++ b/src/authz-module/components/TableCells.tsx
@@ -0,0 +1,96 @@
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Icon, IconButton } from '@openedx/paragon';
+import { AppContext } from '@edx/frontend-platform/react';
+import {
+ RemoveRedEye,
+} from '@openedx/paragon/icons';
+import { TableCellValue, AppContextType, UserRole } from '@src/types';
+import { useNavigate } from 'react-router-dom';
+import { useContext, useMemo } from 'react';
+import { DJANGO_MANAGED_ROLES, MAP_ROLE_KEY_TO_LABEL } from '@src/authz-module/constants';
+import messages from './messages';
+import { RESOURCE_ICONS } from './constants';
+
+type CellProps = TableCellValue;
+type CellPropsWithValue = CellProps & {
+ value: string;
+};
+type ExtendedCellProps = CellPropsWithValue & {
+ cell: {
+ getCellProps: (props?: Record) => Record;
+ };
+};
+
+const NameCell = ({ row }: CellProps) => {
+ const intl = useIntl();
+ const { authenticatedUser } = useContext(AppContext) as AppContextType;
+ 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 }: CellProps) => {
+ const { formatMessage } = useIntl();
+ const navigate = useNavigate();
+ const viewPath = `/authz/user/${row.original.username}`;
+ return (
+ navigate(viewPath)}
+ />
+ );
+};
+
+const OrgCell = ({ value, row }: CellPropsWithValue) => {
+ const { formatMessage } = useIntl();
+ return (
+
+ {DJANGO_MANAGED_ROLES.includes(row.original.role) ? formatMessage(messages['authz.user.table.org.all.organizations.label']) : value}
+
+ );
+};
+
+const ScopeCell = ({ row }: CellProps) => {
+ const { formatMessage } = useIntl();
+
+ const { scopeText, iconSrc } = useMemo(() => {
+ if (DJANGO_MANAGED_ROLES.includes(row.original.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;
+ return {
+ scopeText: row.original.scope,
+ iconSrc: scopeIcon,
+ };
+ }, [row.original.role, row.original.scope, formatMessage]);
+
+ return (
+
+ {iconSrc && }
+ {scopeText}
+
+ );
+};
+
+const RoleCell = ({ value, cell }: ExtendedCellProps) => (
+
+ {MAP_ROLE_KEY_TO_LABEL[value] || ''}
+
+);
+
+export {
+ NameCell, ViewActionCell, ScopeCell, RoleCell, OrgCell,
+};
diff --git a/src/authz-module/components/TableControlBar/MultipleChoiceFilter.test.tsx b/src/authz-module/components/TableControlBar/MultipleChoiceFilter.test.tsx
new file mode 100644
index 00000000..ab56c751
--- /dev/null
+++ b/src/authz-module/components/TableControlBar/MultipleChoiceFilter.test.tsx
@@ -0,0 +1,164 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { renderWrapper } from '@src/setupTest';
+import MultipleChoiceFilter from './MultipleChoiceFilter';
+
+describe('MultipleChoiceFilter', () => {
+ const defaultProps = {
+ filterButtonText: 'Test Filter',
+ filterChoices: [
+ { displayName: 'Option 1', value: 'option1' },
+ { displayName: 'Option 2', value: 'option2', description: 'desc' },
+ ],
+ filterValue: [],
+ setFilter: jest.fn(),
+ };
+
+ const groupedChoices = [
+ { displayName: 'Group A Option', value: 'groupA1', groupName: 'Group A' },
+ { displayName: 'Group B Option', value: 'groupB1', groupName: 'Group B' },
+ ];
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders without crashing', () => {
+ renderWrapper();
+ expect(screen.getByText('Test Filter')).toBeInTheDocument();
+ });
+
+ it('displays filter button text', () => {
+ renderWrapper();
+ expect(screen.getByText('Custom Filter')).toBeInTheDocument();
+ });
+
+ it('shows count when items are selected', () => {
+ renderWrapper();
+ expect(screen.getByText('Test Filter (1)')).toBeInTheDocument();
+ });
+
+ it('opens dropdown menu when clicked', async () => {
+ const user = userEvent.setup();
+ renderWrapper();
+ const button = screen.getByText('Test Filter');
+ await user.click(button);
+ expect(screen.getByText('Option 1')).toBeInTheDocument();
+ expect(screen.getByText('Option 2')).toBeInTheDocument();
+ });
+
+ it('handles disabled state', () => {
+ renderWrapper();
+ expect(screen.getByText('Test Filter')).toBeInTheDocument();
+ });
+
+ it('displays search input when searchable', async () => {
+ const user = userEvent.setup();
+ renderWrapper();
+ const button = screen.getByText('Test Filter');
+ await user.click(button);
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+ });
+
+ it('handles grouped choices', async () => {
+ const user = userEvent.setup();
+ renderWrapper();
+ const button = screen.getByText('Test Filter');
+ await user.click(button);
+ expect(screen.getByText('Group A')).toBeInTheDocument();
+ expect(screen.getByText('Group B')).toBeInTheDocument();
+ const CheckA1 = screen.getByText('Group A Option');
+ await user.click(CheckA1);
+ expect(defaultProps.setFilter).toHaveBeenCalledWith(
+ ['groupA1'],
+ {
+ groupName: 'test filter',
+ value: 'groupA1',
+ displayName: 'Group A Option',
+ },
+ );
+ });
+
+ it('calls setFilter when option is selected', async () => {
+ const user = userEvent.setup();
+ const mockSetFilter = jest.fn();
+ renderWrapper();
+ const button = screen.getByText('Test Filter');
+ await user.click(button);
+ const option = screen.getByText('Option 1');
+ await user.click(option);
+ expect(mockSetFilter).toHaveBeenCalled();
+ });
+
+ it('adds option to selection on first click', async () => {
+ const user = userEvent.setup();
+ const mockSetFilter = jest.fn();
+ renderWrapper();
+ const button = screen.getByText('Test Filter');
+ await user.click(button);
+ const checkbox = screen.getByLabelText('Option 1');
+ await user.click(checkbox);
+ expect(mockSetFilter).toHaveBeenCalledWith(
+ ['option1'],
+ {
+ groupName: 'test filter',
+ value: 'option1',
+ displayName: 'Option 1',
+ },
+ );
+ });
+
+ it('removes option from selection when already selected', async () => {
+ const user = userEvent.setup();
+ const mockSetFilter = jest.fn();
+ renderWrapper();
+ const button = screen.getByText('Test Filter (1)');
+ await user.click(button);
+ const checkbox = screen.getByLabelText('Option 1');
+ await user.click(checkbox);
+ expect(mockSetFilter).toHaveBeenCalledWith(
+ [],
+ {
+ groupName: 'test filter',
+ value: 'option1',
+ displayName: 'Option 1',
+ },
+ );
+ });
+
+ it('handles multiple selections correctly', async () => {
+ const user = userEvent.setup();
+ const mockSetFilter = jest.fn();
+ renderWrapper();
+ const button = screen.getByText('Test Filter (1)');
+ await user.click(button);
+ const checkbox = screen.getByLabelText('Option 2');
+ await user.click(checkbox);
+ expect(mockSetFilter).toHaveBeenCalledWith(
+ ['option1', 'option2'],
+ {
+ groupName: 'test filter',
+ value: 'option2',
+ displayName: 'Option 2',
+ },
+ );
+ });
+
+ it('calls onSearchChange when search input changes', async () => {
+ const user = userEvent.setup();
+ const mockOnSearchChange = jest.fn();
+ renderWrapper(
+ ,
+ );
+ const button = screen.getByRole('button', { name: /test filter/i });
+ await user.click(button);
+ const searchInput = screen.getByRole('textbox');
+ await user.type(searchInput, 'test search');
+ expect(mockOnSearchChange).toHaveBeenCalled();
+ expect(mockOnSearchChange).toHaveBeenLastCalledWith('test search');
+ });
+});
diff --git a/src/authz-module/components/TableControlBar/MultipleChoiceFilter.tsx b/src/authz-module/components/TableControlBar/MultipleChoiceFilter.tsx
new file mode 100644
index 00000000..cb77b67c
--- /dev/null
+++ b/src/authz-module/components/TableControlBar/MultipleChoiceFilter.tsx
@@ -0,0 +1,144 @@
+import {
+ Dropdown, Form, Icon, Stack,
+} from '@openedx/paragon';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { FilterList, Info, Search } from '@openedx/paragon/icons';
+import { useState } from 'react';
+import messages from '../messages';
+import { FilterChoice, MultipleChoiceFilterProps } from './types';
+
+const MultipleChoiceFilter = ({
+ filterButtonText,
+ filterChoices,
+ filterValue,
+ setFilter,
+ isGrouped = false,
+ isSearchable = false,
+ onSearchChange,
+ iconSrc,
+ disabled = false,
+}: MultipleChoiceFilterProps) => {
+ const [searchValue, setSearchValue] = useState(undefined);
+ const { formatMessage } = useIntl();
+
+ const checkedBoxes = filterValue || [];
+ const handleClickCheckbox = (value, displayName) => {
+ const newValue = {
+ groupName: filterButtonText?.toLocaleLowerCase() || '',
+ value,
+ displayName,
+ };
+ if (checkedBoxes.includes(value)) {
+ const newCheckedBoxes = checkedBoxes.filter((val) => val !== value);
+ return setFilter(newCheckedBoxes, newValue);
+ }
+ const newCheckedBoxes = [...checkedBoxes, value];
+ return setFilter(newCheckedBoxes, newValue);
+ };
+
+ const getGroupedChoices = () => {
+ const groupedFilterChoices = filterChoices.reduce((groups, choice) => {
+ const groupName = choice.groupName || 'Ungrouped';
+ const icon = choice.groupIcon || undefined;
+ if (!groups.has(groupName)) {
+ groups.set(groupName, { groupName, options: [], icon });
+ }
+ groups.get(groupName)!.options.push({
+ displayName: choice.displayName,
+ value: choice.value,
+ description: choice.description,
+ });
+ return groups;
+ }, new Map; icon?: any }>());
+ return Array.from(groupedFilterChoices.values());
+ };
+
+ return (
+
+ 0 ? 'primary' : 'outline-primary'}>
+
+ {iconSrc && }
+ {filterButtonText}
+ {checkedBoxes.length > 0 && ` (${checkedBoxes.length})`}
+
+
+
+
+
+ {isSearchable && (
+ }
+ placeholder={formatMessage(messages['authz.table.controlbar.search'])}
+ onChange={(e) => {
+ setSearchValue(e.target.value);
+ onSearchChange?.(e.target.value);
+ }}
+ value={searchValue}
+ />
+ )}
+
+
+ {formatMessage(messages['authz.table.controlbar.filters.items.showing'], { current: filterChoices.length, total: filterChoices.length })}
+
+ {!isGrouped ? filterChoices.map(({
+ displayName, value, description,
+ }) => (
+ handleClickCheckbox(value, displayName)}
+ aria-label={displayName}
+ disabled={checkedBoxes.includes(value) ? false : disabled}
+ >
+
+ {displayName}
+ { description && {description} }
+
+
+ ))
+ : getGroupedChoices().map(({ groupName, icon, options }) => (
+
+
+ {icon && }
+ {groupName}
+
+ {options.map(({ displayName, value, description }) => (
+
handleClickCheckbox(value, displayName)}
+ disabled={checkedBoxes.includes(value) ? false : disabled}
+ aria-label={displayName}
+ >
+
+ {displayName}
+ { description && {description} }
+
+
+ ))}
+
+ ))}
+ { isSearchable && (
+
+ {formatMessage(messages['authz.table.controlbar.filters.more.results'])}
+
+
+ )}
+
+
+
+
+ );
+};
+
+export default MultipleChoiceFilter;
diff --git a/src/authz-module/components/TableControlBar/OrgFilter.test.tsx b/src/authz-module/components/TableControlBar/OrgFilter.test.tsx
new file mode 100644
index 00000000..228b9d69
--- /dev/null
+++ b/src/authz-module/components/TableControlBar/OrgFilter.test.tsx
@@ -0,0 +1,63 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { renderWrapper } from '@src/setupTest';
+import OrgFilter from './OrgFilter';
+
+jest.mock('@src/authz-module/data/hooks', () => ({
+ useOrgs: () => ({
+ data: {
+ count: 2,
+ next: null,
+ previous: null,
+ results: [
+ { id: 'org1', name: 'Organization 1' },
+ { id: 'org2', name: 'Organization 2' },
+ ],
+ },
+ }),
+}));
+
+describe('OrgFilter', () => {
+ const defaultProps = {
+ filterButtonText: 'Organizations',
+ filterValue: [],
+ setFilter: jest.fn(),
+ disabled: false,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders without crashing', () => {
+ renderWrapper();
+ expect(screen.getByText('Organizations')).toBeInTheDocument();
+ });
+
+ it('handles disabled state', () => {
+ renderWrapper();
+ expect(screen.getByText('Organizations')).toBeInTheDocument();
+ });
+
+ it('displays filter options', () => {
+ renderWrapper();
+ expect(screen.getByText('Organizations')).toBeInTheDocument();
+ });
+
+ it('handles search input', async () => {
+ const user = userEvent.setup();
+ renderWrapper();
+ // Look for search input if it exists
+ const searchInputs = screen.queryAllByRole('textbox');
+ if (searchInputs.length > 0) {
+ await user.type(searchInputs[0], 'test search');
+ expect(searchInputs[0]).toHaveValue('test search');
+ }
+ });
+
+ it('calls setFilter when filter changes', () => {
+ const mockSetFilter = jest.fn();
+ renderWrapper();
+ expect(screen.getByText('Organizations')).toBeInTheDocument();
+ });
+});
diff --git a/src/authz-module/components/TableControlBar/OrgFilter.tsx b/src/authz-module/components/TableControlBar/OrgFilter.tsx
new file mode 100644
index 00000000..cc0cd52e
--- /dev/null
+++ b/src/authz-module/components/TableControlBar/OrgFilter.tsx
@@ -0,0 +1,42 @@
+import React, { useMemo } from 'react';
+import { Business } from '@openedx/paragon/icons';
+import { useOrgs } from '@src/authz-module/data/hooks';
+import { DEFAULT_FILTER_PAGE_SIZE } from '@src/authz-module/constants';
+import { MultipleChoiceFilterProps } from './types';
+import MultipleChoiceFilter from './MultipleChoiceFilter';
+
+type OrgFilterProps = Omit;
+
+const OrgFilter = ({
+ filterButtonText, filterValue, setFilter, disabled,
+}: OrgFilterProps) => {
+ const [searchValue, setSearchValue] = React.useState(undefined);
+ const {
+ data: orgsData = {
+ count: 0, next: null, previous: null, results: [],
+ },
+ } = useOrgs(searchValue, 1, DEFAULT_FILTER_PAGE_SIZE);
+ const filterChoices = useMemo(() => orgsData?.results?.map((org) => ({
+ displayName: org.name,
+ value: org.shortName,
+ })) || [], [orgsData]);
+
+ const handleSearchChange = (value: string) => {
+ setSearchValue(value);
+ };
+
+ return (
+
+ );
+};
+
+export default OrgFilter;
diff --git a/src/authz-module/components/TableControlBar/RolesFilter.test.tsx b/src/authz-module/components/TableControlBar/RolesFilter.test.tsx
new file mode 100644
index 00000000..1ba0724f
--- /dev/null
+++ b/src/authz-module/components/TableControlBar/RolesFilter.test.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import { screen } from '@testing-library/react';
+import { renderWrapper } from '@src/setupTest';
+import RolesFilter from './RolesFilter';
+
+describe('RolesFilter', () => {
+ const defaultProps = {
+ filterButtonText: 'Roles',
+ filterValue: [],
+ setFilter: jest.fn(),
+ disabled: false,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders without crashing', () => {
+ renderWrapper();
+ expect(screen.getByText('Roles')).toBeInTheDocument();
+ });
+
+ it('handles disabled state', () => {
+ renderWrapper();
+ expect(screen.getByText('Roles')).toBeInTheDocument();
+ });
+
+ it('displays filter button text', () => {
+ renderWrapper();
+ expect(screen.getByText('Select Roles')).toBeInTheDocument();
+ });
+
+ it('calls setFilter when filter changes', () => {
+ const mockSetFilter = jest.fn();
+ renderWrapper();
+ expect(screen.getByText('Roles')).toBeInTheDocument();
+ });
+});
diff --git a/src/authz-module/components/TableControlBar/RolesFilter.tsx b/src/authz-module/components/TableControlBar/RolesFilter.tsx
new file mode 100644
index 00000000..d0e061e9
--- /dev/null
+++ b/src/authz-module/components/TableControlBar/RolesFilter.tsx
@@ -0,0 +1,28 @@
+import { useMemo } from 'react';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Person } from '@openedx/paragon/icons';
+import MultipleChoiceFilter from './MultipleChoiceFilter';
+import { MultipleChoiceFilterProps } from './types';
+import { getRolesFiltersOptions } from '../constants';
+
+type RolesFilterProps = Omit;
+
+const RolesFilter = ({
+ filterButtonText, filterValue, setFilter, disabled,
+}: RolesFilterProps) => {
+ const intl = useIntl();
+ const rolesOptions = useMemo(() => getRolesFiltersOptions(intl), [intl]);
+ return (
+
+ );
+};
+
+export default RolesFilter;
diff --git a/src/authz-module/components/TableControlBar/ScopesFilter.test.tsx b/src/authz-module/components/TableControlBar/ScopesFilter.test.tsx
new file mode 100644
index 00000000..18654ad4
--- /dev/null
+++ b/src/authz-module/components/TableControlBar/ScopesFilter.test.tsx
@@ -0,0 +1,67 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { renderWrapper } from '@src/setupTest';
+import ScopesFilter from './ScopesFilter';
+
+jest.mock('@src/authz-module/data/hooks', () => ({
+ useScopes: () => ({
+ data: {
+ results: [
+ {
+ externalKey: 'course:123',
+ name: 'Test Course',
+ organization: { name: 'Test Org' },
+ },
+ {
+ externalKey: 'library:456',
+ name: 'Test Library',
+ organization: { name: 'Another Org' },
+ },
+ ],
+ },
+ }),
+}));
+
+describe('ScopesFilter', () => {
+ const defaultProps = {
+ filterButtonText: 'Scopes',
+ filterValue: [],
+ setFilter: jest.fn(),
+ disabled: false,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders without crashing', () => {
+ renderWrapper();
+ expect(screen.getByText('Scopes')).toBeInTheDocument();
+ });
+
+ it('handles disabled state', () => {
+ renderWrapper();
+ expect(screen.getByText('Scopes')).toBeInTheDocument();
+ });
+
+ it('displays filter button text', () => {
+ renderWrapper();
+ expect(screen.getByText('Select Scopes')).toBeInTheDocument();
+ });
+
+ it('handles search input', async () => {
+ const user = userEvent.setup();
+ renderWrapper();
+ const searchInputs = screen.queryAllByRole('textbox');
+ if (searchInputs.length > 0) {
+ await user.type(searchInputs[0], 'test search');
+ expect(searchInputs[0]).toHaveValue('test search');
+ }
+ });
+
+ it('calls setFilter when filter changes', () => {
+ const mockSetFilter = jest.fn();
+ renderWrapper();
+ expect(screen.getByText('Scopes')).toBeInTheDocument();
+ });
+});
diff --git a/src/authz-module/components/TableControlBar/ScopesFilter.tsx b/src/authz-module/components/TableControlBar/ScopesFilter.tsx
new file mode 100644
index 00000000..9661438d
--- /dev/null
+++ b/src/authz-module/components/TableControlBar/ScopesFilter.tsx
@@ -0,0 +1,54 @@
+import React, { useMemo, useState } from 'react';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { LocationOn } from '@openedx/paragon/icons';
+import { useScopes } from '@src/authz-module/data/hooks';
+import { DEFAULT_FILTER_PAGE_SIZE } from '@src/authz-module/constants';
+import { MultipleChoiceFilterProps } from './types';
+import MultipleChoiceFilter from './MultipleChoiceFilter';
+import { RESOURCE_ICONS } from '../constants';
+import messages from '../messages';
+
+type ScopesFilterProps = Omit;
+
+const ScopesFilter = ({
+ filterButtonText, filterValue, setFilter, disabled,
+}: ScopesFilterProps) => {
+ const { formatMessage } = useIntl();
+ const [searchValue, setSearchValue] = useState(undefined);
+ const { data: scopesData = { results: [] } } = useScopes(searchValue, 1, DEFAULT_FILTER_PAGE_SIZE);
+
+ const filterChoices = useMemo(() => scopesData.results.map((scope) => {
+ const scopeIcon = scope.externalKey?.startsWith('lib') ? RESOURCE_ICONS.LIBRARY : RESOURCE_ICONS.COURSE;
+ let groupName = formatMessage(messages['authz.team.members.table.group.courses']);
+ if (scope.externalKey?.startsWith('lib')) {
+ groupName = formatMessage(messages['authz.team.members.table.group.libraries']);
+ }
+ return {
+ displayName: scope.displayName,
+ value: scope.externalKey,
+ description: scope.org?.shortName,
+ groupName,
+ groupIcon: scopeIcon,
+ };
+ }), [scopesData, formatMessage]);
+
+ const handleSearchChange = (value: string) => {
+ setSearchValue(value);
+ };
+
+ return (
+
+ );
+};
+
+export default ScopesFilter;
diff --git a/src/authz-module/components/TableControlBar/SearchFilter.test.tsx b/src/authz-module/components/TableControlBar/SearchFilter.test.tsx
new file mode 100644
index 00000000..f66a7a8e
--- /dev/null
+++ b/src/authz-module/components/TableControlBar/SearchFilter.test.tsx
@@ -0,0 +1,50 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { renderWrapper } from '@src/setupTest';
+import SearchFilter from './SearchFilter';
+
+describe('SearchFilter', () => {
+ const defaultProps = {
+ filterValue: '',
+ setFilter: jest.fn(),
+ placeholder: 'Search...',
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders without crashing', () => {
+ renderWrapper();
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+ });
+
+ it('displays placeholder text', () => {
+ renderWrapper();
+ expect(screen.getByPlaceholderText('Search users...')).toBeInTheDocument();
+ });
+
+ it('displays current filter value', () => {
+ renderWrapper();
+ expect(screen.getByDisplayValue('test query')).toBeInTheDocument();
+ });
+
+ it('calls setFilter when input changes', async () => {
+ const user = userEvent.setup();
+ const mockSetFilter = jest.fn();
+ renderWrapper();
+ const input = screen.getByRole('textbox');
+ await user.type(input, 'search text');
+ expect(mockSetFilter).toHaveBeenCalled();
+ });
+
+ it('handles empty filter value', () => {
+ renderWrapper();
+ expect(screen.getByRole('textbox')).toHaveValue('');
+ });
+
+ it('handles undefined filter value', () => {
+ renderWrapper();
+ expect(screen.getByRole('textbox')).toHaveValue('');
+ });
+});
diff --git a/src/authz-module/components/TableControlBar/SearchFilter.tsx b/src/authz-module/components/TableControlBar/SearchFilter.tsx
new file mode 100644
index 00000000..b2480f1d
--- /dev/null
+++ b/src/authz-module/components/TableControlBar/SearchFilter.tsx
@@ -0,0 +1,30 @@
+import {
+ Form,
+ Icon,
+} from '@openedx/paragon';
+import { Search } from '@openedx/paragon/icons';
+
+interface SearchFilterProps {
+ filterValue: string;
+ setFilter: (value: string) => void;
+ placeholder: string;
+}
+
+const SearchFilter = ({
+ filterValue, setFilter, placeholder,
+}: SearchFilterProps) => (
+
+ }
+ value={filterValue || ''}
+ type="text"
+ onChange={e => {
+ setFilter(e.target.value || undefined); // Set undefined to remove the filter entirely
+ }}
+ placeholder={placeholder}
+ />
+
+);
+
+export default SearchFilter;
diff --git a/src/authz-module/components/TableControlBar/TableControlBar.test.tsx b/src/authz-module/components/TableControlBar/TableControlBar.test.tsx
new file mode 100644
index 00000000..a706e15e
--- /dev/null
+++ b/src/authz-module/components/TableControlBar/TableControlBar.test.tsx
@@ -0,0 +1,370 @@
+import { screen } from '@testing-library/react';
+import { renderWrapper } from '@src/setupTest';
+import { DataTableContext, TextFilter } from '@openedx/paragon';
+import userEvent from '@testing-library/user-event';
+import TableControlBar from './TableControlBar';
+import RolesFilter from './RolesFilter';
+
+const mockSetAllFilters = jest.fn();
+const mockOnFilterChange = jest.fn();
+
+const mockColumns = [
+ {
+ id: 'role',
+ canFilter: true,
+ Filter: () => null,
+ setFilter: jest.fn(),
+ filterOrder: 1,
+ },
+ {
+ id: 'org',
+ canFilter: true,
+ Filter: () => null,
+ setFilter: jest.fn(),
+ filterOrder: 2,
+ },
+];
+
+const mockState = {
+ filters: [
+ { id: 'role', value: ['admin'] },
+ { id: 'org', value: ['org1'] },
+ ],
+};
+
+jest.mock('@src/authz-module/data/hooks', () => ({
+ useOrgs: () => ({
+ data: {
+ count: 0, next: null, previous: null, results: [],
+ },
+ }),
+ useScopes: () => ({ data: { scopes: [] } }),
+}));
+
+describe('TableControlBar', () => {
+ const mockDataTableContext = {
+ columns: mockColumns,
+ setAllFilters: mockSetAllFilters,
+ state: mockState,
+ };
+
+ const renderWithContext = (component, contextOverride = {}) => {
+ const context = { ...mockDataTableContext, ...contextOverride };
+ return renderWrapper(
+
+ {component}
+ ,
+ );
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockSetAllFilters.mockClear();
+ mockOnFilterChange.mockClear();
+ });
+
+ it('renders without crashing', () => {
+ renderWithContext();
+ const container = document.querySelector('.authz-table-control-bar');
+ expect(container).toBeInTheDocument();
+ });
+
+ it('renders roles filter when configured', async () => {
+ const user = userEvent.setup();
+ const contextWithRolesFilter = {
+ columns: [
+ {
+ id: 'roles',
+ Header: 'Roles',
+ Filter: RolesFilter,
+ canFilter: true,
+ filterButtonText: 'Select Roles',
+ setFilter: jest.fn(),
+ },
+ ],
+ };
+
+ renderWithContext(, contextWithRolesFilter);
+ const rolesButton = screen.getByText('Select Roles');
+ expect(rolesButton).toBeInTheDocument();
+ await user.click(rolesButton);
+ const superAdminOption = screen.getByRole('checkbox', { name: /Super Admin/i });
+ expect(superAdminOption).toBeInTheDocument();
+ await user.click(superAdminOption);
+ expect(contextWithRolesFilter.columns[0].setFilter).toHaveBeenCalled();
+ });
+
+ it('renders search filter when configured', () => {
+ const contextWithTextFilter = {
+ columns: [
+ {
+ id: 'search',
+ Header: 'Search Field',
+ Filter: TextFilter,
+ canFilter: true,
+ filterValue: '',
+ setFilter: jest.fn(),
+ },
+ ],
+ };
+
+ renderWithContext(, contextWithTextFilter);
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+ });
+
+ it('displays filter chips when filters are applied', () => {
+ const contextWithAppliedFilters = {
+ state: {
+ filters: [
+ { id: 'roles', value: ['Admin'] },
+ ],
+ },
+ };
+
+ renderWithContext(
+ ,
+ contextWithAppliedFilters,
+ );
+
+ expect(screen.getByText('Filtered by:')).toBeInTheDocument();
+ expect(screen.getByText('Admin')).toBeInTheDocument();
+ });
+
+ it('shows clear all button when multiple filters applied', async () => {
+ const user = userEvent.setup();
+ const contextWithMultipleFilters = {
+ state: {
+ filters: [
+ { id: 'roles', value: ['Admin'] },
+ { id: 'org', value: ['TestOrg'] },
+ ],
+ },
+ };
+
+ renderWithContext(
+ ,
+ contextWithMultipleFilters,
+ );
+ const clearButton = screen.getByText('Clear filters');
+ expect(clearButton).toBeInTheDocument();
+ await user.click(clearButton);
+ expect(mockSetAllFilters).toHaveBeenCalledWith([]);
+ expect(screen.queryByText('Clear filters')).not.toBeInTheDocument();
+ });
+
+ it('calls onFilterChange callback when provided', () => {
+ const contextWithFilters = {
+ state: {
+ filters: [{ id: 'test', value: ['value'] }],
+ },
+ };
+
+ renderWithContext(
+ ,
+ contextWithFilters,
+ );
+
+ expect(mockOnFilterChange).toHaveBeenCalledWith(['test']);
+ });
+
+ it('handles empty columns gracefully', () => {
+ renderWithContext();
+ const container = document.querySelector('.authz-table-control-bar');
+ expect(container).toBeInTheDocument();
+ expect(screen.queryByText('Filter by')).not.toBeInTheDocument();
+ });
+ it('handles empty columns gracefully', () => {
+ renderWithContext();
+ const container = document.querySelector('.authz-table-control-bar');
+ expect(container).toBeInTheDocument();
+ expect(screen.queryByText('Filter by')).not.toBeInTheDocument();
+ });
+
+ it('generates keys using column id when available', () => {
+ const contextWithIdColumn = {
+ columns: [
+ {
+ id: 'test-id',
+ accessor: 'test-accessor',
+ Header: 'Test',
+ Filter: RolesFilter,
+ canFilter: true,
+ filterButtonText: 'Test Filter',
+ setFilter: jest.fn(),
+ },
+ ],
+ };
+
+ renderWithContext(, contextWithIdColumn);
+ expect(screen.getByText('Test Filter')).toBeInTheDocument();
+ });
+
+ it('generates keys using column accessor when id is not available', () => {
+ const contextWithAccessorColumn = {
+ columns: [
+ {
+ accessor: 'test-accessor',
+ Header: 'Test',
+ Filter: RolesFilter,
+ canFilter: true,
+ filterButtonText: 'Test Filter',
+ setFilter: jest.fn(),
+ },
+ ],
+ };
+
+ renderWithContext(, contextWithAccessorColumn);
+ expect(screen.getByText('Test Filter')).toBeInTheDocument();
+ });
+
+ it('handles chronological filter logic for adding new filters', () => {
+ const mockSetFilter = jest.fn();
+ const contextWithFilter = {
+ columns: [
+ {
+ id: 'roles',
+ Header: 'Roles',
+ Filter: RolesFilter,
+ canFilter: true,
+ filterButtonText: 'Select Roles',
+ setFilter: mockSetFilter,
+ },
+ ],
+ };
+
+ renderWithContext(, contextWithFilter);
+
+ const handleSetFilters = mockSetFilter.mock.calls[0]?.[0];
+ if (handleSetFilters) {
+ const newFilter = { groupName: 'roles', value: 'admin', displayName: 'Admin' };
+ handleSetFilters(['admin'], newFilter);
+ }
+ });
+
+ it('handles chronological filter logic for removing existing filters', () => {
+ const mockSetFilter = jest.fn();
+ const contextWithFilter = {
+ columns: [
+ {
+ id: 'roles',
+ Header: 'Roles',
+ Filter: RolesFilter,
+ canFilter: true,
+ filterButtonText: 'Select Roles',
+ setFilter: mockSetFilter,
+ },
+ ],
+ };
+
+ renderWithContext(
+ ,
+ contextWithFilter,
+ );
+
+ const handleSetFilters = mockSetFilter.mock.calls[0]?.[0];
+ if (handleSetFilters) {
+ const existingFilter = { groupName: 'roles', value: 'admin', displayName: 'Admin' };
+ handleSetFilters([], existingFilter);
+ }
+ });
+
+ it('tests onIconAfterClick functionality directly', () => {
+ const contextWithAppliedFilters = {
+ setAllFilters: mockSetAllFilters,
+ state: {
+ filters: [
+ { id: 'role', value: ['admin', 'user'] },
+ { id: 'org', value: ['TestOrg'] },
+ ],
+ },
+ };
+
+ renderWithContext(
+ ,
+ contextWithAppliedFilters,
+ );
+ const chipElement = screen.getByText('admin').closest('.pgn__chip');
+ expect(chipElement).toBeInTheDocument();
+ const closeButton = chipElement?.querySelector('button');
+ if (closeButton) {
+ closeButton.click();
+ }
+ expect(mockSetAllFilters).toHaveBeenCalledWith([
+ { id: 'role', value: ['user'] },
+ { id: 'org', value: ['TestOrg'] },
+ ]);
+ });
+
+ it('displays warning alert when filter limit is reached', () => {
+ const maxFilters = Array.from({ length: 10 }, (_, index) => ({
+ id: `filter${index}`,
+ value: [`value${index}`],
+ }));
+
+ const contextWithMaxFilters = {
+ setAllFilters: mockSetAllFilters,
+ state: {
+ filters: maxFilters,
+ },
+ };
+
+ renderWithContext(
+ ,
+ contextWithMaxFilters,
+ );
+ const warningAlert = screen.getByRole('alert');
+ expect(warningAlert).toBeInTheDocument();
+ expect(warningAlert).toHaveClass('alert-warning');
+ });
+
+ it('manages setChronologicalFilters state correctly when removing filters', () => {
+ const mockSetFilter = jest.fn();
+ const contextWithFilter = {
+ columns: [
+ {
+ id: 'roles',
+ Header: 'Roles',
+ Filter: RolesFilter,
+ canFilter: true,
+ filterButtonText: 'Select Roles',
+ setFilter: mockSetFilter,
+ },
+ ],
+ };
+
+ renderWithContext(
+ ,
+ contextWithFilter,
+ );
+ const handleSetFilters = mockSetFilter.mock.calls[0]?.[0];
+ if (handleSetFilters) {
+ const existingFilter = { groupName: 'roles', value: 'admin', displayName: 'Admin' };
+ handleSetFilters([], existingFilter);
+ expect(mockSetFilter).toHaveBeenCalled();
+ }
+ });
+
+ it('removes a filter chip when close icon is clicked', async () => {
+ renderWithContext();
+ const chip = screen.getByText('admin');
+ const closeButton = chip.parentElement?.querySelector('button');
+ const user = userEvent.setup();
+ await user.click(closeButton as HTMLElement);
+ expect(mockSetAllFilters).toHaveBeenCalledWith([
+ { id: 'role', value: [] },
+ { id: 'org', value: ['org1'] },
+ ]);
+ });
+
+ it('removes the correct filter chip for org', async () => {
+ renderWithContext();
+ const chip = screen.getByText('org1');
+ const closeButton = chip.parentElement?.querySelector('button');
+ const user = userEvent.setup();
+ await user.click(closeButton as HTMLElement);
+ expect(mockSetAllFilters).toHaveBeenCalledWith([
+ { id: 'role', value: ['admin'] },
+ { id: 'org', value: [] },
+ ]);
+ });
+});
diff --git a/src/authz-module/components/TableControlBar/TableControlBar.tsx b/src/authz-module/components/TableControlBar/TableControlBar.tsx
new file mode 100644
index 00000000..3d937c0b
--- /dev/null
+++ b/src/authz-module/components/TableControlBar/TableControlBar.tsx
@@ -0,0 +1,199 @@
+import { useContext, useEffect, useState } from 'react';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ DataTableContext,
+ Stack,
+ TextFilter,
+ Button,
+ Chip,
+ Alert,
+ Icon,
+} from '@openedx/paragon';
+import {
+ Business, Close, LocationOn, Person,
+ Warning,
+} from '@openedx/paragon/icons';
+
+import { MAX_TABLE_FILTERS_APPLIED } from '@src/authz-module/constants';
+import MultipleChoiceFilter from './MultipleChoiceFilter';
+import SearchFilter from './SearchFilter';
+import messages from '../messages';
+import RolesFilter from './RolesFilter';
+import OrgFilter from './OrgFilter';
+import ScopesFilter from './ScopesFilter';
+import { FilterChoice } from './types';
+
+const FILTER_CHIPS_ICONS = {
+ role: Person,
+ organization: Business,
+ scope: LocationOn,
+};
+
+const FILTER_GROUP_TO_ID = {
+ role: 'role',
+ organization: 'org',
+ scope: 'scope',
+};
+
+interface TableControlBarProps {
+ onFilterChange?: (filters: string[]) => void;
+}
+
+const TableControlBar = ({ onFilterChange }: 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([]);
+ const [filtersLimitReached, setFiltersLimitReached] = useState(false);
+ const {
+ columns,
+ setAllFilters,
+ state,
+ // @ts-ignore-next-line - Paragon's DataTableContext is not typed
+ } = useContext(DataTableContext);
+
+ useEffect(() => {
+ if (state.filters.length > 0) {
+ const formattedInitialFilters = state.filters.map((filter) => ({
+ groupName: filter.id,
+ value: filter.value[0] || '',
+ displayName: filter.value[0] || '',
+ }));
+ setChronologicalFilters(formattedInitialFilters);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ setFiltersLimitReached(chronologicalFilters.length >= MAX_TABLE_FILTERS_APPLIED);
+ if (onFilterChange) {
+ onFilterChange(state.filters.map((filter) => filter.id) || []);
+ }
+ }, [chronologicalFilters, onFilterChange, state.filters]);
+
+ const availableFilters = columns.filter((column) => column.canFilter)
+ .sort((a, b) => (a.filterOrder || 0) - (b.filterOrder || 0));
+
+ const columnTextFilterHeaders = columns
+ .filter((column) => column.Filter === TextFilter)
+ .map((column) => column.Header);
+
+ const getSearchPlaceholder = () => intl.formatMessage(messages['authz.table.controlbar.search.by.fields'], {
+ firstField: columnTextFilterHeaders[0] || '',
+ secondField: columnTextFilterHeaders[1] || '',
+ });
+
+ const handleCloseFilter = (filterName, filterValue) => {
+ const actualFilterId = FILTER_GROUP_TO_ID[filterName] || filterName;
+ const filterGroup = state.filters.find((filter) => filter.id === actualFilterId);
+ const newFilterValue = filterGroup?.value.filter(item => item !== filterValue) || [];
+ setAllFilters(state.filters.map(item => (
+ item.id !== actualFilterId ? item : { id: item.id, value: newFilterValue })));
+ setChronologicalFilters((prevFilters) => prevFilters.filter((filter) => filter.value !== filterValue));
+ };
+
+ const handleSetFilters = (setFilter) => (allFilters: string[], newFilter: FilterChoice) => {
+ setFilter(allFilters);
+ setChronologicalFilters((prevFilters) => {
+ if (!prevFilters.find((filter) => filter.value === newFilter.value)) {
+ return [...prevFilters, newFilter];
+ }
+ return prevFilters.filter((filter) => filter.value !== newFilter.value);
+ });
+ };
+
+ const clearAllFilters = () => {
+ setAllFilters([]);
+ setChronologicalFilters([]);
+ };
+ return (
+
+
+ {availableFilters.map((column) => {
+ const { Filter } = column;
+ if (Filter === RolesFilter) {
+ return (
+
+ );
+ }
+ if (Filter === OrgFilter) {
+ return (
+
+ );
+ }
+ if (Filter === MultipleChoiceFilter) {
+ return (
+
+ );
+ }
+ if (Filter === ScopesFilter) {
+ return (
+ filter.id === 'scope')?.value || null}
+ />
+ );
+ }
+
+ if (Filter === TextFilter) {
+ return (
+
+ );
+ }
+ return null;
+ })}
+
+
+ {chronologicalFilters.length > 0 && (
+
+ {intl.formatMessage(messages['authz.table.controlbar.filterby.label'])}
+
+ {chronologicalFilters.map((filter) => (
+ handleCloseFilter(filter.groupName, filter.value)}
+ >
+ {filter.displayName}
+
+ ))}
+ {chronologicalFilters.length > 1 && (
+
+ )}
+
+ )}
+ { filtersLimitReached && (
+
+
+
+ {intl.formatMessage(messages['authz.table.controlbar.filters.limit.reached'])}
+
+
+ )}
+
+
+ );
+};
+
+export default TableControlBar;
diff --git a/src/authz-module/components/TableControlBar/types.ts b/src/authz-module/components/TableControlBar/types.ts
new file mode 100644
index 00000000..686f1361
--- /dev/null
+++ b/src/authz-module/components/TableControlBar/types.ts
@@ -0,0 +1,19 @@
+export type FilterChoice = {
+ groupName?: string;
+ groupIcon?: React.ComponentType<{}>;
+ displayName: string;
+ value: string;
+ description?: string;
+};
+
+export interface MultipleChoiceFilterProps {
+ filterButtonText: string;
+ filterChoices: Array;
+ filterValue: string[] | undefined;
+ setFilter: (value: string[], newItem: FilterChoice) => void;
+ isGrouped?: boolean;
+ isSearchable?: boolean;
+ onSearchChange?: (value: string) => void;
+ iconSrc?: React.ComponentType<{}> | undefined;
+ disabled?: boolean;
+}
diff --git a/src/authz-module/components/TableFooter/TableFooter.test.tsx b/src/authz-module/components/TableFooter/TableFooter.test.tsx
new file mode 100644
index 00000000..b03f4efe
--- /dev/null
+++ b/src/authz-module/components/TableFooter/TableFooter.test.tsx
@@ -0,0 +1,195 @@
+import React from 'react';
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { DataTableContext } from '@openedx/paragon';
+import { initializeMockApp } from '@edx/frontend-platform/testing';
+import { renderWrapper } from '@src/setupTest';
+import Footer from './TableFooter';
+
+describe('TableFooter', () => {
+ const mockGotoPage = jest.fn();
+
+ const defaultDataTableContext = {
+ pageCount: 5,
+ gotoPage: mockGotoPage,
+ state: {
+ pageIndex: 0,
+ pageSize: 10,
+ },
+ itemCount: 42,
+ rows: [
+ { id: 1, name: 'Item 1' },
+ { id: 2, name: 'Item 2' },
+ { id: 3, name: 'Item 3' },
+ ],
+ };
+
+ const renderFooter = (contextOverrides = {}) => {
+ const contextValue = {
+ ...defaultDataTableContext,
+ ...contextOverrides,
+ };
+
+ return renderWrapper(
+
+
+ ,
+ );
+ };
+
+ beforeAll(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 1,
+ username: 'testuser',
+ email: 'test@example.com',
+ },
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('pagination text display', () => {
+ it('displays correct showing text with current page items', () => {
+ renderFooter();
+
+ expect(screen.getByText('Showing 3 of 42.')).toBeInTheDocument();
+ });
+
+ it('displays showing text with different row count', () => {
+ const moreRows = [
+ ...defaultDataTableContext.rows,
+ { id: 4, name: 'Item 4' },
+ { id: 5, name: 'Item 5' },
+ ];
+
+ renderFooter({
+ rows: moreRows,
+ itemCount: 100,
+ });
+
+ expect(screen.getByText('Showing 5 of 100.')).toBeInTheDocument();
+ });
+
+ it('displays showing text when on last page with fewer items', () => {
+ renderFooter({
+ state: {
+ pageIndex: 4,
+ pageSize: 10,
+ },
+ rows: [{ id: 41, name: 'Item 41' }, { id: 42, name: 'Item 42' }],
+ itemCount: 42,
+ });
+
+ expect(screen.getByText('Showing 2 of 42.')).toBeInTheDocument();
+ });
+ });
+
+ describe('pagination controls', () => {
+ it('displays pagination with correct current page', () => {
+ renderFooter();
+
+ const pagination = screen.getByRole('navigation');
+ expect(pagination).toBeInTheDocument();
+
+ const currentPageButton = screen.getByRole('button', { name: '1 of 5' });
+ expect(currentPageButton).toBeInTheDocument();
+ });
+
+ it('navigates to different page when next button is clicked', async () => {
+ const user = userEvent.setup();
+ renderFooter();
+
+ const page3Button = screen.getByRole('button', { name: 'Next, Page 2' });
+ await user.click(page3Button);
+
+ expect(mockGotoPage).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows correct current page when on different page', () => {
+ renderFooter({
+ state: {
+ pageIndex: 2,
+ pageSize: 10,
+ },
+ });
+
+ const currentPageButton = screen.getByRole('button', { name: '3 of 5' });
+ expect(currentPageButton).toBeInTheDocument();
+ });
+ });
+
+ describe('user interactions', () => {
+ it('handles keyboard navigation on pagination', async () => {
+ const user = userEvent.setup();
+ renderFooter();
+
+ // Tab to the next page button and activate with Enter
+ const nextPageButton = screen.getByRole('button', { name: 'Next, Page 2' });
+ nextPageButton.focus();
+ await user.keyboard('{Enter}');
+
+ expect(mockGotoPage).toHaveBeenCalledWith(1);
+ });
+
+ it('handles space key activation on pagination buttons', async () => {
+ const user = userEvent.setup();
+ renderFooter();
+
+ const nextPageButton = screen.getByRole('button', { name: 'Next, Page 2' });
+ nextPageButton.focus();
+ await user.keyboard(' ');
+
+ expect(mockGotoPage).toHaveBeenCalledWith(1);
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles single page scenario', () => {
+ renderFooter({
+ pageCount: 1,
+ state: {
+ pageIndex: 0,
+ pageSize: 10,
+ },
+ itemCount: 3,
+ });
+
+ expect(screen.getByText('Showing 3 of 3.')).toBeInTheDocument();
+
+ const page1Button = screen.queryByRole('button', { name: /1 of 1/ });
+ expect(page1Button).not.toBeInTheDocument();
+ });
+
+ it('handles empty results', () => {
+ renderFooter({
+ rows: [],
+ itemCount: 0,
+ pageCount: 1,
+ state: {
+ pageIndex: 0,
+ pageSize: 10,
+ },
+ });
+
+ expect(screen.getByText('Showing 0 of 0.')).toBeInTheDocument();
+ });
+
+ it('handles large page counts correctly', () => {
+ renderFooter({
+ pageCount: 10,
+ state: {
+ pageIndex: 5,
+ pageSize: 10,
+ },
+ itemCount: 95,
+ });
+
+ const currentPageButton = screen.getByRole('button', { name: '6 of 10' });
+ expect(currentPageButton).toBeInTheDocument();
+ expect(screen.getByText('Showing 3 of 95.')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/authz-module/components/TableFooter/TableFooter.tsx b/src/authz-module/components/TableFooter/TableFooter.tsx
new file mode 100644
index 00000000..26b3a974
--- /dev/null
+++ b/src/authz-module/components/TableFooter/TableFooter.tsx
@@ -0,0 +1,28 @@
+import React, { useContext } from 'react';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { DataTableContext, Pagination, TableFooter } from '@openedx/paragon';
+import messages from '../messages';
+
+const Footer = () => {
+ const { formatMessage } = useIntl();
+ const {
+ pageCount, gotoPage, state, itemCount, rows,
+ // @ts-ignore-next-line - Paragon's DataTableContext is not typed
+ } = useContext(DataTableContext);
+ const { pageIndex } = state;
+ return (
+
+
+ {formatMessage(messages['authz.table.footer.items.showing.text'], { pageSize: rows.length, itemCount })}
+
+ gotoPage(pageNum - 1)}
+ />
+
+ );
+};
+
+export default Footer;
diff --git a/src/authz-module/components/constants.ts b/src/authz-module/components/constants.ts
new file mode 100644
index 00000000..185eecaf
--- /dev/null
+++ b/src/authz-module/components/constants.ts
@@ -0,0 +1,74 @@
+import { IntlShape } from '@edx/frontend-platform/i18n';
+import { Language, LibraryBooks, School } from '@openedx/paragon/icons';
+import messages from './messages';
+
+export const getRolesFiltersOptions = (intl: IntlShape) => [
+ {
+ groupName: intl.formatMessage(messages['authz.team.members.table.group.global']),
+ groupIcon: Language,
+ displayName: 'Super Admin',
+ value: 'super_admin',
+ },
+ {
+ groupName: intl.formatMessage(messages['authz.team.members.table.group.global']),
+ groupIcon: Language,
+ displayName: 'Global Staff',
+ value: 'global_staff',
+ },
+
+ {
+ groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']),
+ groupIcon: School,
+ displayName: 'Course Admin',
+ value: 'course_admin',
+ },
+ {
+ groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']),
+ groupIcon: School,
+ displayName: 'Course Staff',
+ value: 'course_staff',
+ },
+ {
+ groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']),
+ groupIcon: School,
+ displayName: 'Course Editor',
+ value: 'course_editor',
+ },
+ {
+ groupName: intl.formatMessage(messages['authz.team.members.table.group.courses']),
+ groupIcon: School,
+ displayName: 'Course Auditor',
+ value: 'course_auditor',
+ },
+
+ {
+ groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']),
+ groupIcon: LibraryBooks,
+ displayName: 'Library Admin',
+ value: 'library_admin',
+ },
+ {
+ groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']),
+ groupIcon: LibraryBooks,
+ displayName: 'Library Author',
+ value: 'library_author',
+ },
+ {
+ groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']),
+ groupIcon: LibraryBooks,
+ displayName: 'Library Collaborator',
+ value: 'library_collaborator',
+ },
+ {
+ groupName: intl.formatMessage(messages['authz.team.members.table.group.libraries']),
+ groupIcon: LibraryBooks,
+ displayName: 'Library User',
+ value: 'library_user',
+ },
+];
+
+export const RESOURCE_ICONS = {
+ COURSE: School,
+ LIBRARY: LibraryBooks,
+ GLOBAL: Language,
+};
diff --git a/src/authz-module/components/messages.ts b/src/authz-module/components/messages.ts
index 707dde7c..541008f1 100644
--- a/src/authz-module/components/messages.ts
+++ b/src/authz-module/components/messages.ts
@@ -21,6 +21,82 @@ const messages = defineMessages({
defaultMessage: 'Scroll to top',
description: 'Alt text for the scroll to top anchor button',
},
+ 'authz.table.controlbar.clearFilters': {
+ id: 'authz.table.controlbar.clearFilters',
+ defaultMessage: 'Clear filters',
+ description: 'Button to clear all active filters in the table',
+ },
+ '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.',
+ },
+ 'authz.table.controlbar.search': {
+ id: 'authz.table.controlbar.search',
+ defaultMessage: 'Search',
+ description: 'Search placeholder for two specific fields',
+ },
+ 'authz.user.table.scope.global.label': {
+ id: 'authz.user.table.scope.global.label',
+ defaultMessage: 'Global',
+ description: 'Label for the "Global" scope in the user assignments table when a user has a django managed role assigned.',
+ },
+ 'authz.table.controlbar.search.by.fields': {
+ id: 'authz.table.controlbar.search.by.fields',
+ defaultMessage: 'Search by {firstField} or {secondField}',
+ description: 'Search placeholder for two specific fields',
+ },
+ 'authz.table.controlbar.filterby.label': {
+ id: 'authz.table.controlbar.filterby.label',
+ defaultMessage: 'Filtered by: ',
+ description: 'Label for active filters in the table',
+ },
+ 'authz.table.controlbar.filters.limit.reached': {
+ id: 'authz.table.controlbar.filters.limit.reached',
+ defaultMessage: '10 filter limit reached. Remove one of the applied filters so you can select another one.',
+ description: 'Message displayed when the user reaches the applied filters limit',
+ },
+ 'authz.table.controlbar.filters.items.showing': {
+ id: 'authz.table.controlbar.filters.limit.reached',
+ defaultMessage: 'Showing {current} of {total}.',
+ description: 'Message displayed when the user reaches the applied filters limit',
+ },
+ 'authz.table.footer.items.showing.text': {
+ id: 'authz.table.footer.items.showing.text',
+ defaultMessage: 'Showing {pageSize} of {itemCount}.',
+ description: 'Message displayed when the user reaches the applied filters limit',
+ },
+ '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',
+ defaultMessage: 'View',
+ description: 'Team members table view action text',
+ },
+ 'authz.team.members.table.group.courses': {
+ id: 'authz.team.members.table.group.courses',
+ defaultMessage: 'Courses',
+ description: 'Label for the "Courses" group in the team members table filters',
+ },
+ 'authz.team.members.table.group.libraries': {
+ id: 'authz.team.members.table.group.libraries',
+ defaultMessage: 'Libraries',
+ description: 'Label for the "Libraries" group in the team members table filters',
+ },
+ 'authz.team.members.table.group.global': {
+ id: 'authz.team.members.table.group.global',
+ defaultMessage: 'Global',
+ description: 'Label for the "Global" group in the team members table filters',
+ },
+ 'authz.table.controlbar.filters.more.results': {
+ id: 'authz.table.controlbar.filters.more.results',
+ defaultMessage: 'Search to show more',
+ description: 'Message displayed when there are more results available than currently shown',
+ },
});
export default messages;
diff --git a/src/authz-module/components/utils.test.tsx b/src/authz-module/components/utils.test.tsx
new file mode 100644
index 00000000..8c879710
--- /dev/null
+++ b/src/authz-module/components/utils.test.tsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import { screen } from '@testing-library/react';
+import { initializeMockApp } from '@edx/frontend-platform/testing';
+import { renderWrapper } from '@src/setupTest';
+import { getCellHeader } from './utils';
+
+const renderCellHeader = (columnId: string, columnTitle: string, filtersApplied: string[]) => {
+ const component = getCellHeader(columnId, columnTitle, filtersApplied);
+ return renderWrapper({component}
);
+};
+
+describe('getCellHeader', () => {
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 1,
+ username: 'testuser',
+ email: 'testuser@example.com',
+ },
+ });
+ });
+
+ it('displays column title without filter icon when no filters are applied', () => {
+ const { container } = renderCellHeader('scope', 'Scope', []);
+
+ expect(screen.getByText('Scope')).toBeInTheDocument();
+ expect(container.querySelector('svg')).not.toBeInTheDocument();
+ });
+
+ it('displays column title without filter icon when column is not in filters applied', () => {
+ const { container } = renderCellHeader('scope', 'Scope', ['role', 'organization']);
+
+ expect(screen.getByText('Scope')).toBeInTheDocument();
+ expect(container.querySelector('svg')).not.toBeInTheDocument();
+ });
+
+ it('displays column title with filter icon when column has filter applied', () => {
+ const { container } = renderCellHeader('scope', 'Scope', ['scope', 'role']);
+
+ expect(screen.getByText('Scope')).toBeInTheDocument();
+ expect(container.querySelector('svg')).toBeInTheDocument();
+ });
+
+ it('displays filter icon only for matching column when multiple filters applied', () => {
+ const { container } = renderCellHeader('organization', 'Organization', ['scope', 'organization', 'role']);
+
+ expect(screen.getByText('Organization')).toBeInTheDocument();
+ expect(container.querySelector('svg')).toBeInTheDocument();
+ });
+
+ it('handles empty column title', () => {
+ const { container } = renderCellHeader('scope', '', ['scope']);
+
+ expect(container.querySelector('svg')).toBeInTheDocument();
+ });
+
+ it('handles special characters in column title', () => {
+ const { container } = renderCellHeader('scope', 'Scope & Context', ['scope']);
+
+ expect(screen.getByText('Scope & Context')).toBeInTheDocument();
+ expect(container.querySelector('svg')).toBeInTheDocument();
+ });
+
+ it('is case sensitive when matching column ID', () => {
+ const { container } = renderCellHeader('scope', 'Scope', ['SCOPE', 'Role']);
+
+ expect(screen.getByText('Scope')).toBeInTheDocument();
+ expect(container.querySelector('svg')).not.toBeInTheDocument();
+ });
+
+ it('handles long column titles with filters', () => {
+ const { container } = renderCellHeader('organization', 'Very Long Organization Column Title', ['organization']);
+
+ expect(screen.getByText('Very Long Organization Column Title')).toBeInTheDocument();
+ expect(container.querySelector('svg')).toBeInTheDocument();
+ });
+
+ it('displays correct structure when filter is applied', () => {
+ renderCellHeader('role', 'Role', ['role']);
+
+ const container = screen.getByText('Role').closest('span');
+ expect(container).toHaveClass('d-flex', 'flex-row', 'align-items-center');
+ });
+});
diff --git a/src/authz-module/components/utils.tsx b/src/authz-module/components/utils.tsx
new file mode 100644
index 00000000..4d0bd222
--- /dev/null
+++ b/src/authz-module/components/utils.tsx
@@ -0,0 +1,14 @@
+import { Icon } from '@openedx/paragon';
+import { FilterList } from '@openedx/paragon/icons';
+
+export const getCellHeader = (columnId: string, columnTitle: string, filtersApplied: string[]) => {
+ if (filtersApplied.includes(columnId)) {
+ return (
+
+
+ {columnTitle}
+
+ );
+ }
+ return columnTitle;
+};
diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts
index 3256bc2c..6cd023df 100644
--- a/src/authz-module/constants.ts
+++ b/src/authz-module/constants.ts
@@ -1,6 +1,7 @@
export const ROUTES = {
LIBRARIES_TEAM_PATH: '/libraries/:libraryId',
LIBRARIES_USER_PATH: '/libraries/:libraryId/:username',
+
};
export enum RoleOperationErrorStatus {
@@ -10,3 +11,24 @@ export enum RoleOperationErrorStatus {
ROLE_ASSIGNMENT_ERROR = 'role_assignment_error',
ROLE_REMOVAL_ERROR = 'role_removal_error',
}
+
+export const MAX_TABLE_FILTERS_APPLIED = 10;
+
+export const MAP_ROLE_KEY_TO_LABEL: Record = {
+ library_admin: 'Library Admin',
+ library_author: 'Library Author',
+ library_contributor: 'Library Contributor',
+ library_user: 'Library User',
+ course_admin: 'Course Admin',
+ course_staff: 'Course Staff',
+ course_editor: 'Course Editor',
+ course_auditor: 'Course Auditor',
+ 'django.superuser': 'Super Admin',
+ 'django.globalstaff': 'Global Staff',
+};
+
+export const DJANGO_MANAGED_ROLES = ['django.superuser', 'django.globalstaff'];
+
+export const TABLE_DEFAULT_PAGE_SIZE = 10;
+
+export const DEFAULT_FILTER_PAGE_SIZE = 5;
diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts
index bf5ff1ae..d5eb8437 100644
--- a/src/authz-module/data/api.ts
+++ b/src/authz-module/data/api.ts
@@ -1,10 +1,15 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
-import { LibraryMetadata, TeamMember } from '@src/types';
+import {
+ LibraryMetadata, Org, Scope, TeamMember,
+ UserRole,
+} from '@src/types';
import { camelCaseObject } from '@edx/frontend-platform';
import { getApiUrl, getStudioApiUrl } from '@src/data/utils';
export interface QuerySettings {
roles: string | null;
+ scopes: string | null;
+ organizations: string | null;
search: string | null;
order: string | null;
sortBy: string | null;
@@ -50,6 +55,27 @@ export interface AssignTeamMembersRoleRequest {
scope: string;
}
+export interface GetAllRoleAssignmentsResponse {
+ results: UserRole[];
+ count: number;
+ next: string | null;
+ previous: string | null;
+}
+
+export interface GetOrgsResponse {
+ count: number;
+ next: string | null;
+ previous: string | null;
+ results:Array;
+}
+
+export interface GetScopesResponse {
+ count: number;
+ next: string | null;
+ previous: string | null;
+ results:Array;
+}
+
export const getTeamMembers = async (object: string, querySettings: QuerySettings): Promise => {
const url = new URL(getApiUrl(`/api/authz/v1/roles/users/?scope=${object}`));
@@ -108,3 +134,60 @@ export const revokeUserRoles = async (
const res = await getAuthenticatedHttpClient().delete(url.toString());
return camelCaseObject(res.data);
};
+
+export const getAllRoleAssignments = async (querySettings: QuerySettings)
+: Promise => {
+ const url = new URL(getApiUrl('/api/authz/v1/assignments/'));
+
+ if (querySettings.roles) {
+ url.searchParams.set('roles', querySettings.roles);
+ }
+ if (querySettings.scopes) {
+ url.searchParams.set('scopes', querySettings.scopes);
+ }
+ if (querySettings.organizations) {
+ url.searchParams.set('orgs', querySettings.organizations);
+ }
+ if (querySettings.search) {
+ url.searchParams.set('search', querySettings.search);
+ }
+ if (querySettings.sortBy && querySettings.order) {
+ url.searchParams.set('sort_by', querySettings.sortBy);
+ url.searchParams.set('order', querySettings.order);
+ }
+ url.searchParams.set('page_size', querySettings.pageSize.toString());
+ url.searchParams.set('page', (querySettings.pageIndex + 1).toString());
+
+ const { data } = await getAuthenticatedHttpClient().get(url);
+ return camelCaseObject(data);
+};
+
+export const getOrgs = async (search?: string, page?: number, pageSize?: number): Promise => {
+ const url = new URL(getApiUrl('/api/authz/v1/orgs/'));
+ if (search !== undefined) {
+ url.searchParams.set('search', search);
+ }
+ if (page !== undefined) {
+ url.searchParams.set('page', page.toString());
+ }
+ if (pageSize !== undefined) {
+ url.searchParams.set('page_size', pageSize.toString());
+ }
+ const { data } = await getAuthenticatedHttpClient().get(url);
+ return camelCaseObject(data);
+};
+
+export const getScopes = async (search?: string, page?: number, pageSize?: number): Promise => {
+ const url = new URL(getApiUrl('/api/authz/v1/scopes/'));
+ if (search !== undefined) {
+ url.searchParams.set('search', search);
+ }
+ if (page !== undefined) {
+ url.searchParams.set('page', page.toString());
+ }
+ if (pageSize !== undefined) {
+ url.searchParams.set('page_size', pageSize.toString());
+ }
+ const { data } = await getAuthenticatedHttpClient().get(url);
+ return camelCaseObject(data);
+};
diff --git a/src/authz-module/data/hooks.test.tsx b/src/authz-module/data/hooks.test.tsx
index d55c7e0a..7fa8f9ea 100644
--- a/src/authz-module/data/hooks.test.tsx
+++ b/src/authz-module/data/hooks.test.tsx
@@ -4,6 +4,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
useLibrary, usePermissionsByRole, useTeamMembers, useAssignTeamMembersRole, useRevokeUserRoles,
+ useAllRoleAssignments,
+ useOrgs,
+ useScopes,
} from './hooks';
jest.mock('@edx/frontend-platform/auth', () => ({
@@ -35,6 +38,70 @@ const mockLibrary = {
slug: 'test-library',
};
+const mockAssignments = {
+ results: [
+ {
+ isSuperadmin: false,
+ role: 'course_staff',
+ org: 'OpenedX',
+ scope: 'course-v1:OpenedX+DemoX+DemoCourse',
+ permissionCount: 27,
+ fullName: 'John Doe',
+ username: 'johndoe',
+ email: 'johndoe@example.com',
+ },
+ ],
+ count: 1,
+ next: null,
+ previous: null,
+};
+
+const mockOrgs = {
+ count: 2,
+ next: null,
+ previous: null,
+ results: [
+ { id: 'org1', name: 'Organization 1' },
+ { id: 'org2', name: 'Organization 2' },
+ ],
+};
+
+const mockScopes = {
+ count: 2,
+ next: null,
+ previous: null,
+ results: [
+ {
+ externalKey: 'course-v1:OpenedX+DemoX+DemoCourse',
+ displayName: 'Open edX Demo Course',
+ org: {
+ id: 1,
+ created: '2026-04-02T19:30:36.779095Z',
+ modified: '2026-04-02T19:30:36.779095Z',
+ name: 'OpenedX',
+ shortName: 'OpenedX',
+ description: '',
+ logo: null,
+ active: true,
+ },
+ },
+ {
+ externalKey: 'lib:WGU:CSPROB',
+ displayName: 'Computer Science Problems',
+ org: {
+ id: 2,
+ created: '2026-04-02T19:31:21.196446Z',
+ modified: '2026-04-02T19:31:21.196446Z',
+ name: 'WGU',
+ shortName: 'WGU',
+ description: '',
+ logo: null,
+ active: true,
+ },
+ },
+ ],
+};
+
const mockQuerySettings = {
roles: null,
search: null,
@@ -340,3 +407,123 @@ describe('useRevokeUserRoles', () => {
expect(calledUrl.searchParams.get('scope')).toBe(revokeRoleData.scope);
});
});
+
+describe('useAllRoleAssignments', () => {
+ beforeEach(() => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ get: jest.fn(() => Promise.resolve({ data: mockAssignments })),
+ });
+ });
+
+ it('fetches and returns role assignments', async () => {
+ const { result } = renderHook(
+ () => useAllRoleAssignments({
+ roles: null,
+ scopes: null,
+ organizations: null,
+ search: null,
+ order: null,
+ sortBy: null,
+ pageSize: 10,
+ pageIndex: 0,
+ }),
+ { wrapper: createWrapper() },
+ );
+ await waitFor(() => {
+ expect(result.current.data?.results).toHaveLength(1);
+ expect(result.current.data?.results[0].username).toBe('johndoe');
+ expect(result.current.data?.count).toBe(1);
+ });
+ });
+
+ it('handles empty results', async () => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValueOnce({
+ get: jest.fn(() => Promise.resolve({
+ data: {
+ results: [], count: 0, next: null, previous: null,
+ },
+ })),
+ });
+ const { result } = renderHook(
+ () => useAllRoleAssignments({
+ roles: null,
+ scopes: null,
+ organizations: null,
+ search: null,
+ order: null,
+ sortBy: null,
+ pageSize: 10,
+ pageIndex: 0,
+ }),
+ { wrapper: createWrapper() },
+ );
+ await waitFor(() => {
+ expect(result.current.data?.results).toEqual([]);
+ expect(result.current.data?.count).toBe(0);
+ });
+ });
+});
+
+describe('useOrgs', () => {
+ beforeEach(() => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ get: jest.fn(() => Promise.resolve({ data: mockOrgs })),
+ });
+ });
+
+ it('fetches and returns organizations', async () => {
+ const { result } = renderHook(() => useOrgs(), { wrapper: createWrapper() });
+ await waitFor(() => {
+ expect(result.current.data?.results).toHaveLength(2);
+ expect(result.current.data?.results[0].name).toBe('Organization 1');
+ expect(result.current.data?.count).toBe(2);
+ });
+ });
+
+ it('handles empty results', async () => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValueOnce({
+ get: jest.fn(() => Promise.resolve({
+ data: {
+ count: 0, next: null, previous: null, results: [],
+ },
+ })),
+ });
+ const { result } = renderHook(() => useOrgs(), { wrapper: createWrapper() });
+ await waitFor(() => {
+ expect(result.current.data?.results).toEqual([]);
+ expect(result.current.data?.count).toBe(0);
+ });
+ });
+});
+
+describe('useScopes', () => {
+ beforeEach(() => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ get: jest.fn(() => Promise.resolve({ data: mockScopes })),
+ });
+ });
+
+ it('fetches and returns scopes', async () => {
+ const { result } = renderHook(() => useScopes(), { wrapper: createWrapper() });
+ await waitFor(() => {
+ expect(result.current.data?.results).toHaveLength(2);
+ expect(result.current.data?.results[0].displayName).toBe('Open edX Demo Course');
+ expect(result.current.data?.count).toBe(2);
+ });
+ });
+
+ it('handles empty results', async () => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValueOnce({
+ get: jest.fn(() => Promise.resolve({
+ data: {
+ count: 0, next: null, previous: null, results: [],
+ },
+ })),
+ });
+ const { result } = renderHook(() => useScopes(), { wrapper: createWrapper() });
+ await waitFor(() => {
+ expect(result.current.data?.results).toEqual([]);
+ expect(result.current.data?.count).toBe(0);
+ });
+ });
+});
diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts
index bc5090e0..8b0cfa1d 100644
--- a/src/authz-module/data/hooks.ts
+++ b/src/authz-module/data/hooks.ts
@@ -4,8 +4,11 @@ import {
import { appId } from '@src/constants';
import { LibraryMetadata } from '@src/types';
import {
- assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getPermissionsByRole, getTeamMembers,
- GetTeamMembersResponse, PermissionsByRole, QuerySettings, revokeUserRoles, RevokeUserRolesRequest,
+ assignTeamMembersRole, AssignTeamMembersRoleRequest, getAllRoleAssignments,
+ GetAllRoleAssignmentsResponse, getLibrary, getOrgs, GetOrgsResponse,
+ getPermissionsByRole, getScopes, GetScopesResponse, getTeamMembers,
+ GetTeamMembersResponse, PermissionsByRole, QuerySettings, revokeUserRoles,
+ RevokeUserRolesRequest,
} from './api';
const authzQueryKeys = {
@@ -15,6 +18,9 @@ const authzQueryKeys = {
...authzQueryKeys.teamMembersAll(scope), querySettings] as const,
permissionsByRole: (scope: string) => [...authzQueryKeys.all, 'permissionsByRole', scope] as const,
library: (libraryId: string) => [...authzQueryKeys.all, 'library', libraryId] as const,
+ allRoleAssignments: (querySettings?: QuerySettings) => [...authzQueryKeys.all, 'allRoleAssignments', querySettings] as const,
+ orgs: (search?: string, page?: number, pageSize?: number) => [...authzQueryKeys.all, 'organizations', search, page, pageSize] as const,
+ scopes: (search?: string, page?: number, pageSize?: number) => [...authzQueryKeys.all, 'scopes', search, page, pageSize] as const,
};
/**
@@ -110,3 +116,54 @@ export const useRevokeUserRoles = () => {
},
});
};
+
+/**
+ * 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.
+ *
+ * @param querySettings - Optional parameters for filtering by roles, scopes,
+ * organizations, search term, sorting, and pagination.
+ *
+ * @example
+ * const { data: roleAssignments } = useAllRoleAssignments({ roles: 'editor', pageSize: 20 });
+ */
+export const useAllRoleAssignments = (querySettings: QuerySettings) => {
+ const result = useQuery({
+ queryKey: authzQueryKeys.allRoleAssignments(querySettings),
+ queryFn: () => getAllRoleAssignments(querySettings),
+ staleTime: 1000 * 60 * 30, // refetch after 30 minutes
+ retry: false,
+ refetchOnWindowFocus: false,
+ });
+ return result;
+};
+
+/**
+ * React query hook to fetch the list of organizations for the organization filter component.
+ * @param search - The search term to filter organizations.
+ * @returns The list of organizations matching the search term.
+ */
+export const useOrgs = (search?: string, page?: number, pageSize?: number) => {
+ const result = useQuery({
+ queryKey: authzQueryKeys.orgs(search, page, pageSize),
+ queryFn: () => getOrgs(search, page, pageSize),
+ refetchOnWindowFocus: false,
+ });
+ return result;
+};
+
+/*
+ * React query hook to fetch the list of scopes for the scope filter component.
+ * @param search - The search term to filter scopes.
+ * @returns The list of scopes matching the search term.
+ */
+export const useScopes = (search?: string, page?: number, pageSize?: number) => {
+ const result = useQuery({
+ queryKey: authzQueryKeys.scopes(search, page, pageSize),
+ queryFn: () => getScopes(search, page, pageSize),
+ refetchOnWindowFocus: false,
+ });
+ return result;
+};
diff --git a/src/authz-module/hooks/useQuerySettings.test.tsx b/src/authz-module/hooks/useQuerySettings.test.tsx
new file mode 100644
index 00000000..6a342fa6
--- /dev/null
+++ b/src/authz-module/hooks/useQuerySettings.test.tsx
@@ -0,0 +1,464 @@
+import { renderHook, act } from '@testing-library/react';
+import { QuerySettings } from '@src/authz-module/data/api';
+import { useQuerySettings } from './useQuerySettings';
+
+describe('useQuerySettings', () => {
+ const defaultQuerySettings: QuerySettings = {
+ roles: null,
+ search: null,
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: null,
+ order: null,
+ scopes: null,
+ organizations: null,
+ };
+
+ it('should initialize with default query settings when no initial settings provided', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ expect(result.current.querySettings).toEqual(defaultQuerySettings);
+ expect(typeof result.current.handleTableFetch).toBe('function');
+ });
+
+ it('should initialize with custom initial query settings', () => {
+ const customInitialSettings: QuerySettings = {
+ roles: 'admin,editor',
+ search: 'test-user',
+ pageSize: 20,
+ pageIndex: 2,
+ sortBy: 'username',
+ order: 'asc',
+ scopes: null,
+ organizations: null,
+ };
+
+ const { result } = renderHook(() => useQuerySettings(customInitialSettings));
+
+ expect(result.current.querySettings).toEqual(customInitialSettings);
+ });
+
+ it('should update query settings when handleTableFetch is called with new filters', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 15,
+ pageIndex: 1,
+ sortBy: [{ id: 'username', desc: false }],
+ filters: [
+ { id: 'role', value: ['admin', 'editor'] },
+ { id: 'name', value: 'john' },
+ ],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings).toEqual({
+ roles: 'admin,editor',
+ search: 'john',
+ pageSize: 15,
+ pageIndex: 1,
+ sortBy: 'username',
+ order: 'asc',
+ scopes: null,
+ organizations: null,
+ });
+ });
+
+ it('should handle descending sort order by adding minus prefix', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [{ id: 'email', desc: true }],
+ filters: [],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings.order).toBe('desc');
+ });
+
+ it('should convert camelCase sort field to snake_case', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [{ id: 'firstName', desc: false }],
+ filters: [],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings.sortBy).toBe('first_name');
+ });
+
+ it('should convert camelCase sort field to snake_case with descending order', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [{ id: 'lastName', desc: true }],
+ filters: [],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings.order).toBe('desc');
+ });
+
+ it('should handle empty filters by setting values to null', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [],
+ filters: [],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings).toEqual({
+ roles: null,
+ search: null,
+ pageSize: 10,
+ pageIndex: 0,
+ order: null,
+ sortBy: null,
+ organizations: null,
+ scopes: null,
+ });
+ });
+
+ it('should handle empty roles filter array by setting roles to null', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [],
+ filters: [
+ { id: 'roles', value: [] },
+ { id: 'username', value: '' },
+ ],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings).toEqual({
+ roles: null,
+ search: null,
+ pageSize: 10,
+ pageIndex: 0,
+ order: null,
+ sortBy: null,
+ organizations: null,
+ scopes: null,
+ });
+ });
+
+ it('should handle missing filters by setting default values', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [],
+ filters: [
+ { id: 'roles', value: undefined },
+ { id: 'username', value: undefined },
+ ],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings).toEqual({
+ roles: null,
+ search: null,
+ pageSize: 10,
+ pageIndex: 0,
+ order: null,
+ sortBy: null,
+ organizations: null,
+ scopes: null,
+ });
+ });
+
+ it('should use default pagination values when not provided', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ sortBy: [],
+ filters: [],
+ } as any; // Missing pageSize and pageIndex
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings.pageSize).toBe(10);
+ expect(result.current.querySettings.pageIndex).toBe(0);
+ });
+
+ it('should not update state if settings have not changed', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [],
+ filters: [],
+ };
+
+ const initialSettings = result.current.querySettings;
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ // Should be the same object reference since no changes occurred
+ expect(result.current.querySettings).toBe(initialSettings);
+ });
+
+ it('should update state when settings have changed', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const initialSettings = result.current.querySettings;
+
+ const tableFilters = {
+ pageSize: 20, // Different from default
+ pageIndex: 0,
+ sortBy: [],
+ filters: [],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ // Should be a different object reference since pageSize changed
+ expect(result.current.querySettings).not.toBe(initialSettings);
+ expect(result.current.querySettings.pageSize).toBe(20);
+ });
+
+ it('should handle complex filter combinations', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 25,
+ pageIndex: 3,
+ sortBy: [{ id: 'userRole', desc: true }],
+ filters: [
+ { id: 'role', value: ['admin', 'editor', 'viewer'] },
+ { id: 'name', value: 'test@example.com' },
+ { id: 'otherFilter', value: 'ignored' }, // Should be ignored
+ { id: 'org', value: ['org1', 'org2'] },
+ { id: 'scope', value: ['scope1', 'scope2'] },
+ ],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings).toEqual({
+ roles: 'admin,editor,viewer',
+ search: 'test@example.com',
+ pageSize: 25,
+ pageIndex: 3,
+ order: 'desc',
+ sortBy: 'user_role',
+ organizations: 'org1,org2',
+ scopes: 'scope1,scope2',
+
+ });
+ });
+
+ it('should handle multiple camelCase words in sort field', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [{ id: 'userFirstLastName', desc: false }],
+ filters: [],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings.sortBy).toBe('user_first_last_name');
+ });
+
+ it('should preserve handleTableFetch function reference across renders', () => {
+ const { result, rerender } = renderHook(() => useQuerySettings());
+
+ const initialHandleTableFetch = result.current.handleTableFetch;
+
+ rerender();
+
+ expect(result.current.handleTableFetch).toBe(initialHandleTableFetch);
+ });
+
+ it('should handle whitespace-only search values as provided', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const tableFilters = {
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [],
+ filters: [
+ { id: 'name', value: ' ' }, // Whitespace only
+ ],
+ };
+
+ act(() => {
+ result.current.handleTableFetch(tableFilters);
+ });
+
+ expect(result.current.querySettings.search).toBe(' ');
+ });
+
+ it('should detect changes in roles filter', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ // First set some roles
+ act(() => {
+ result.current.handleTableFetch({
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [],
+ filters: [{ id: 'role', value: ['admin'] }],
+ });
+ });
+
+ const settingsAfterFirstUpdate = result.current.querySettings;
+
+ // Then change roles
+ act(() => {
+ result.current.handleTableFetch({
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [],
+ filters: [{ id: 'role', value: ['editor'] }],
+ });
+ });
+
+ expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate);
+ expect(result.current.querySettings.roles).toBe('editor');
+ });
+
+ it('should detect changes in search filter', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ // First set a search term
+ act(() => {
+ result.current.handleTableFetch({
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [],
+ filters: [{ id: 'name', value: 'john' }],
+ });
+ });
+
+ const settingsAfterFirstUpdate = result.current.querySettings;
+
+ // Then change search term
+ act(() => {
+ result.current.handleTableFetch({
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [],
+ filters: [{ id: 'name', value: 'jane' }],
+ });
+ });
+
+ expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate);
+ expect(result.current.querySettings.search).toBe('jane');
+ });
+
+ it('should detect changes in ordering', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ // First set ordering
+ act(() => {
+ result.current.handleTableFetch({
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [{ id: 'username', desc: false }],
+ filters: [],
+ });
+ });
+
+ const settingsAfterFirstUpdate = result.current.querySettings;
+
+ // Then change ordering
+ act(() => {
+ result.current.handleTableFetch({
+ pageSize: 10,
+ pageIndex: 0,
+ sortBy: [{ id: 'email', desc: true }],
+ filters: [],
+ });
+ });
+
+ expect(result.current.querySettings).not.toBe(settingsAfterFirstUpdate);
+ expect(result.current.querySettings.sortBy).toBe('email');
+ expect(result.current.querySettings.order).toBe('desc');
+ });
+
+ it('should detect changes in pageSize', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const initialSettings = result.current.querySettings;
+
+ act(() => {
+ result.current.handleTableFetch({
+ pageSize: 50,
+ pageIndex: 0,
+ sortBy: [],
+ filters: [],
+ });
+ });
+
+ expect(result.current.querySettings).not.toBe(initialSettings);
+ expect(result.current.querySettings.pageSize).toBe(50);
+ });
+
+ it('should detect changes in pageIndex', () => {
+ const { result } = renderHook(() => useQuerySettings());
+
+ const initialSettings = result.current.querySettings;
+
+ act(() => {
+ result.current.handleTableFetch({
+ pageSize: 10,
+ pageIndex: 5,
+ sortBy: [],
+ filters: [],
+ });
+ });
+
+ expect(result.current.querySettings).not.toBe(initialSettings);
+ expect(result.current.querySettings.pageIndex).toBe(5);
+ });
+});
diff --git a/src/authz-module/hooks/useQuerySettings.tsx b/src/authz-module/hooks/useQuerySettings.tsx
new file mode 100644
index 00000000..33675c82
--- /dev/null
+++ b/src/authz-module/hooks/useQuerySettings.tsx
@@ -0,0 +1,96 @@
+import { useCallback, useState } from 'react';
+import { QuerySettings } from '@src/authz-module/data/api';
+
+interface DataTableFilters {
+ pageSize: number;
+ pageIndex: number;
+ sortBy: Array<{ id: string; desc: boolean }>;
+ filters: Array<{ id: string; value: any }>;
+}
+
+interface UseQuerySettingsReturn {
+ querySettings: QuerySettings;
+ handleTableFetch: (tableFilters: DataTableFilters) => void;
+}
+
+enum SortOrderKeys {
+ ASC = 'asc',
+ DESC = 'desc',
+}
+
+/**
+ * Custom hook to manage query settings for table data fetching
+ * Converts DataTable filter/sort/pagination settings to API query parameters
+ * and manages URL synchronization
+ *
+ * @param initialQuerySettings - Initial query settings
+ * @returns Object containing querySettings and handleTableFetch function
+ */
+export const useQuerySettings = (
+ initialQuerySettings: QuerySettings = {
+ roles: null,
+ scopes: null,
+ organizations: null,
+ search: null,
+ pageSize: 10,
+ pageIndex: 0,
+ order: null,
+ sortBy: null,
+ },
+): UseQuerySettingsReturn => {
+ const [querySettings, setQuerySettings] = useState(initialQuerySettings);
+
+ const handleTableFetch = useCallback((tableFilters: DataTableFilters) => {
+ setQuerySettings((prevSettings) => {
+ // Extract filters
+ const rolesFilter = tableFilters.filters?.find((filter) => filter.id === 'role')?.value?.join(',') ?? '';
+ const searchFilter = tableFilters.filters?.find((filter) => filter.id === 'name')?.value ?? '';
+ const organizationsFilter = tableFilters.filters?.find((filter) => filter.id === 'org')?.value?.join(',') ?? '';
+ const scopesFilter = tableFilters.filters?.find((filter) => filter.id === 'scope')?.value?.join(',') ?? '';
+
+ // Extract pagination
+ const { pageSize = 10, pageIndex = 0 } = tableFilters;
+
+ // Extract and convert sorting
+ let sortByOption = '';
+ let sortByOrder = '';
+ if (tableFilters.sortBy?.length) {
+ sortByOption = tableFilters.sortBy[0]?.id.replace(/([A-Z])/g, '_$1').toLowerCase();
+ sortByOrder = tableFilters.sortBy[0]?.desc ? SortOrderKeys.DESC : SortOrderKeys.ASC;
+ }
+
+ const newQuerySettings: QuerySettings = {
+ roles: rolesFilter || null,
+ scopes: scopesFilter || null,
+ organizations: organizationsFilter || null,
+ search: searchFilter || null,
+ sortBy: sortByOption || null,
+ order: sortByOrder || null,
+ pageSize,
+ pageIndex,
+ };
+
+ const hasChanged = (
+ prevSettings.roles !== newQuerySettings.roles
+ || prevSettings.scopes !== newQuerySettings.scopes
+ || prevSettings.organizations !== newQuerySettings.organizations
+ || prevSettings.search !== newQuerySettings.search
+ || prevSettings.pageSize !== newQuerySettings.pageSize
+ || prevSettings.pageIndex !== newQuerySettings.pageIndex
+ || prevSettings.sortBy !== newQuerySettings.sortBy
+ || prevSettings.order !== newQuerySettings.order
+ );
+
+ if (!hasChanged) {
+ return prevSettings; // No change, prevent unnecessary update
+ }
+
+ return newQuerySettings;
+ });
+ }, []);
+
+ return {
+ querySettings,
+ handleTableFetch,
+ };
+};
diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss
index 4c6ef34f..93543517 100644
--- a/src/authz-module/index.scss
+++ b/src/authz-module/index.scss
@@ -1,19 +1,24 @@
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
-.authz-libraries {
+.authz-module {
--height-action-divider: 30px;
- hr {
- border-top: var(--pgn-size-border-width) solid var(--pgn-color-border);
- width: 100%;
+ .pgn__data-table-wrapper {
+ padding: 1rem;
}
- @media (--pgn-size-breakpoint-min-width-lg) {
- hr {
- border-right: var(--pgn-size-border-width) solid var(--pgn-color-border);
- height: var(--height-action-divider);
- width: 0;
- }
+ .filters .dropdown-toggle::after {
+ display: none !important;
+ }
+ .pgn__data-table tr:has(td[data-role="Super Admin"]),
+ .pgn__data-table tr:has(td[data-role="Global Staff"]) {
+ background-color: var(--pgn-color-primary-200);
+ }
+
+ hr {
+ border-right: var(--pgn-size-border-width) solid var(--pgn-color-border);
+ height: var(--height-action-divider);
+ width: 0;
}
.tab-content {
diff --git a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx
index 0ed2346a..63aba40e 100644
--- a/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx
+++ b/src/authz-module/libraries-manager/components/AddNewTeamMemberModal/AddNewTeamMemberTrigger.tsx
@@ -3,7 +3,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, useToggle } from '@openedx/paragon';
import { Plus } from '@openedx/paragon/icons';
-import { PutAssignTeamMembersRoleResponse } from 'authz-module/data/api';
+import { PutAssignTeamMembersRoleResponse } from '@src/authz-module/data/api';
import { useAssignTeamMembersRole } from '@src/authz-module/data/hooks';
import { RoleOperationErrorStatus } from '@src/authz-module/constants';
import { AppToast, useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext';
diff --git a/src/authz-module/libraries-manager/components/TeamTable/index.tsx b/src/authz-module/libraries-manager/components/TeamTable/index.tsx
index 3763bd8d..5b1bc893 100644
--- a/src/authz-module/libraries-manager/components/TeamTable/index.tsx
+++ b/src/authz-module/libraries-manager/components/TeamTable/index.tsx
@@ -12,6 +12,7 @@ import { useTeamMembers } from '@src/authz-module/data/hooks';
import { useLibraryAuthZ } from '@src/authz-module/libraries-manager/context';
import { useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext';
import { SKELETON_ROWS } from '@src/authz-module/libraries-manager/constants';
+import { TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants';
import { useQuerySettings } from './hooks/useQuerySettings';
import TableControlBar from './components/TableControlBar';
import messages from './messages';
@@ -19,8 +20,6 @@ import {
ActionCell, EmailCell, NameCell, RolesCell,
} from './components/Cells';
-const DEFAULT_PAGE_SIZE = 10;
-
const TeamTable = () => {
const intl = useIntl();
const {
@@ -39,7 +38,7 @@ const TeamTable = () => {
}
const rows = isError ? [] : (teamMembers?.results || SKELETON_ROWS);
- const pageCount = teamMembers?.count ? Math.ceil(teamMembers.count / DEFAULT_PAGE_SIZE) : 1;
+ const pageCount = teamMembers?.count ? Math.ceil(teamMembers.count / TABLE_DEFAULT_PAGE_SIZE) : 1;
const adaptedFilterChoices = useMemo(
() => roles.map((role) => ({
@@ -68,7 +67,7 @@ const TeamTable = () => {
data={rows}
itemCount={teamMembers?.count || 0}
pageCount={pageCount}
- initialState={{ pageSize: DEFAULT_PAGE_SIZE }}
+ initialState={{ pageSize: TABLE_DEFAULT_PAGE_SIZE }}
additionalColumns={[
{
id: 'action',
diff --git a/src/authz-module/libraries-manager/constants.ts b/src/authz-module/libraries-manager/constants.ts
index d1368961..787edfb9 100644
--- a/src/authz-module/libraries-manager/constants.ts
+++ b/src/authz-module/libraries-manager/constants.ts
@@ -1,20 +1,26 @@
import { PermissionMetadata, ResourceMetadata, RoleMetadata } from 'types';
+import {
+ Group, CollectionsBookmark, Notes, AutoAwesomeMosaic,
+} from '@openedx/paragon/icons';
export const CONTENT_LIBRARY_PERMISSIONS = {
DELETE_LIBRARY: 'content_libraries.delete_library',
MANAGE_LIBRARY_TAGS: 'content_libraries.manage_library_tags',
VIEW_LIBRARY: 'content_libraries.view_library',
+ CREATE_LIBRARY_CONTENT: 'content_libraries.create_library_content',
EDIT_LIBRARY_CONTENT: 'content_libraries.edit_library_content',
+ DELETE_LIBRARY_CONTENT: 'content_libraries.delete_library_content',
PUBLISH_LIBRARY_CONTENT: 'content_libraries.publish_library_content',
REUSE_LIBRARY_CONTENT: 'content_libraries.reuse_library_content',
+ IMPORT_LIBRARY_CONTENT: 'content_libraries.import_library_content',
+
+ MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team',
+ VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team',
CREATE_LIBRARY_COLLECTION: 'content_libraries.create_library_collection',
EDIT_LIBRARY_COLLECTION: 'content_libraries.edit_library_collection',
DELETE_LIBRARY_COLLECTION: 'content_libraries.delete_library_collection',
-
- MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team',
- VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team',
};
// Note: this information will eventually come from the backend API
@@ -27,27 +33,114 @@ export const libraryRolesMetadata: RoleMetadata[] = [
];
export const libraryResourceTypes: ResourceMetadata[] = [
- { key: 'library', label: 'Library', description: 'Permissions related to the library as a whole.' },
- { key: 'library_content', label: 'Content', description: 'Permissions to create, edit, delete, and publish individual content items within the library.' },
- { key: 'library_collection', label: 'Collection', description: 'Permissions to create, edit, and delete content collections within the library.' },
- { key: 'library_team', label: 'Team', description: 'Permissions to manage user access and roles within the library.' },
+ {
+ key: 'library', label: 'Library', description: 'Permissions related to the library as a whole.', icon: CollectionsBookmark,
+ },
+ {
+ key: 'library_content', label: 'Content', description: 'Permissions to create, edit, delete, and publish individual content items within the library.', icon: Notes,
+ },
+ {
+ key: 'library_team', label: 'Team', description: 'Permissions to manage user access and roles within the library.', icon: Group,
+ },
+ {
+ key: 'library_collection', label: 'Collection', description: 'Permissions to create, edit, and delete content collections within the library.', icon: AutoAwesomeMosaic,
+ },
];
export const libraryPermissions: PermissionMetadata[] = [
- { key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY, resource: 'library', description: 'Allows the user to delete the library and all its contents.' },
- { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, resource: 'library', description: 'Add or remove tags from content.' },
{ key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, resource: 'library', description: 'View content, search, filter, and sort within the library.' },
+ { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, resource: 'library', description: 'Add or remove tags from content.' },
+ { key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY, resource: 'library', description: 'Allows the user to delete the library and all its contents.' },
+ { key: CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_CONTENT, resource: 'library_content', description: 'Create content within the library.' },
{ key: CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT, resource: 'library_content', description: 'Edit content in draft mode' },
+ { key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_CONTENT, resource: 'library_content', description: 'Delete content within the library.' },
{ key: CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT, resource: 'library_content', description: 'Publish content, making it available for reuse' },
{ key: CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT, resource: 'library_content', description: 'Reuse published content within a course.' },
+ { key: CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT, resource: 'library_content', description: 'Import content from courses.' },
+
+ { key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, resource: 'library_team', description: 'View the list of users who have access to the library.' },
+ { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, resource: 'library_team', description: 'Add, remove, and assign roles to users within the library.' },
{ key: CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Create new collections within a library.' },
{ key: CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Add or remove content from existing collections.' },
{ key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION, resource: 'library_collection', description: 'Delete entire collections from the library.' },
+];
+
+export const rolesLibraryObject = [
+ {
+ role: 'library_admin',
+ permissions: [
+ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY,
+ CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS,
+ CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY,
+ CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM,
+ CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM,
+ CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION,
+ CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION,
+ CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION,
+ CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT,
+ ],
+ userCount: 1,
+ name: 'Library Admin',
+ description: 'The Library Admin has full control over the library, including managing users, modifying content, and handling publishing workflows. They ensure content is properly maintained and accessible as needed.',
+ },
+ {
+ role: 'library_author',
+ permissions: [
+ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY,
+ CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS,
+ CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM,
+ CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION,
+ CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION,
+ CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION,
+ CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT,
+ ],
+ userCount: 1,
+ name: 'Library Author',
+ description: 'The Library Author is responsible for creating, editing, and publishing content within a library. They can manage tags and collections but cannot delete libraries or manage users.',
+ },
+ {
+ role: 'library_contributor',
+ permissions: [
+ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY,
+ CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS,
+ CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM,
+ CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION,
+ CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION,
+ CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION,
+ CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT,
- { key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, resource: 'library_team', description: 'View the list of users who have access to the library.' },
- { key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, resource: 'library_team', description: 'Add, remove, and assign roles to users within the library.' },
+ ],
+ userCount: 1,
+ name: 'Library Contributor',
+ description: 'The Library Contributor can create and edit content within a library but cannot publish it. They support the authoring process while leaving final publishing to Authors or Admins.',
+ },
+ {
+ role: 'library_user',
+ permissions: [
+ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY,
+ CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT,
+ CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM,
+ ],
+ userCount: 1,
+ name: 'Library User',
+ description: 'The Library User can view and reuse content but cannot edit or delete anything.',
+ },
];
export const DEFAULT_TOAST_DELAY = 5000;
diff --git a/src/authz-module/libraries-manager/messages.ts b/src/authz-module/libraries-manager/messages.ts
index 9ed3f7d4..bbb2ad41 100644
--- a/src/authz-module/libraries-manager/messages.ts
+++ b/src/authz-module/libraries-manager/messages.ts
@@ -4,27 +4,72 @@ const messages = defineMessages({
'library.authz.manage.page.title': {
id: 'library.authz.manage.page.title',
defaultMessage: 'Library Team Management',
- description: 'Libreries AuthZ page title',
+ description: 'Libraries AuthZ page title',
},
'library.authz.breadcrumb.root': {
id: 'library.authz.breadcrumb.root',
defaultMessage: 'Manage Access',
- description: 'Libreries AuthZ root breafcrumb',
+ description: 'Libraries AuthZ root breadcrumb',
},
'library.authz.tabs.team': {
id: 'library.authz.tabs.team',
defaultMessage: 'Team Members',
- description: 'Libreries AuthZ title for the team management tab',
+ description: 'Libraries AuthZ title for the team management tab',
},
'library.authz.tabs.roles': {
id: 'library.authz.tabs.roles',
defaultMessage: 'Roles',
- description: 'Libreries AuthZ title for the roles tab',
+ description: 'Libraries AuthZ title for the roles tab',
},
'library.authz.tabs.permissions': {
id: 'library.authz.tabs.permissions',
defaultMessage: 'Permissions',
- description: 'Libreries AuthZ title for the permissions tab',
+ description: 'Libraries AuthZ title for the permissions tab',
+ },
+ 'library.authz.tabs.permissionsRoles': {
+ id: 'library.authz.tabs.permissionsRoles',
+ defaultMessage: 'Roles and Permissions',
+ description: 'Libraries AuthZ title for the permissions and roles tab',
+ },
+ 'library.authz.tabs.permissionsRoles.courses.alert.title': {
+ id: 'library.authz.tabs.permissionsRoles.courses.alert.title',
+ defaultMessage: 'Course Roles',
+ description: 'Libraries AuthZ title for the course roles alert',
+ },
+ 'library.authz.tabs.permissionsRoles.courses.tab': {
+ id: 'library.authz.tabs.permissionsRoles.courses.tab',
+ defaultMessage: 'Courses',
+ description: 'Libraries AuthZ title for the course roles tab',
+ },
+ 'library.authz.tabs.permissionsRoles.libraries.tab': {
+ id: 'library.authz.tabs.permissionsRoles.libraries.tab',
+ defaultMessage: 'Libraries',
+ description: 'Libraries AuthZ title for the libraries roles tab',
+ },
+ 'library.authz.tabs.permissionsRoles.libraries.tab.title': {
+ id: 'library.authz.tabs.permissionsRoles.libraries.tab.title',
+ defaultMessage: 'Library Roles',
+ description: 'Libraries AuthZ title for the library roles table',
+ },
+ 'library.authz.tabs.permissionsRoles.courses.tab.title': {
+ id: 'library.authz.tabs.permissionsRoles.courses.tab.title',
+ defaultMessage: 'Course Roles',
+ description: 'Libraries AuthZ title for the course roles table',
+ },
+ 'library.authz.tabs.permissionsRoles.courses.alert.note': {
+ id: 'library.authz.tabs.permissionsRoles.courses.alert.note',
+ defaultMessage: 'Note:',
+ description: 'Libraries AuthZ note for the course roles alert',
+ },
+ 'library.authz.tabs.permissionsRoles.courses.alert.description': {
+ id: 'library.authz.tabs.permissionsRoles.courses.alert.description',
+ defaultMessage: 'This list shows the permissions currently available in Authoring Studio. Some roles may grant additional permissions manages outside this interface.',
+ description: 'Libraries AuthZ description for the course roles alert',
+ },
+ 'library.authz.tabs.permissionsRoles.courses.alert.link': {
+ id: 'library.authz.tabs.permissionsRoles.courses.alert.link',
+ defaultMessage: 'See full documentation',
+ description: 'Libraries AuthZ link for the course roles alert',
},
'library.authz.team.remove.user.toast.success.description': {
id: 'library.authz.team.remove.user.toast.success.description',
@@ -44,12 +89,12 @@ const messages = defineMessages({
'library.authz.team.toast.502.error.message': {
id: 'library.authz.team.toast.502.error.message',
defaultMessage: 'We\'re having trouble connecting to our services.
Please try again later.',
- description: 'Libraries bad getaway error message',
+ description: 'Libraries bad gateway error message',
},
'library.authz.team.toast.503.error.message': {
id: 'library.authz.team.toast.503.error.message',
defaultMessage: 'The service is temporarily unavailable.
Please try again in a few moments.',
- description: 'Libraries service temporary unabailable message',
+ description: 'Libraries service temporary unavailable message',
},
'library.authz.team.toast.408.error.message': {
id: 'library.authz.team.toast.408.error.message',
diff --git a/src/authz-module/messages.ts b/src/authz-module/messages.ts
new file mode 100644
index 00000000..88229f8c
--- /dev/null
+++ b/src/authz-module/messages.ts
@@ -0,0 +1,13 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages(
+ {
+ 'authz.management.assign.role.title': {
+ id: 'authz.management.assign.role.title',
+ defaultMessage: 'Assign Role',
+ description: 'Text for the assign role button',
+ },
+ },
+);
+
+export default messages;
diff --git a/src/authz-module/team-members/TeamMembersTable.test.tsx b/src/authz-module/team-members/TeamMembersTable.test.tsx
new file mode 100644
index 00000000..7619b88c
--- /dev/null
+++ b/src/authz-module/team-members/TeamMembersTable.test.tsx
@@ -0,0 +1,212 @@
+import React from 'react';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { renderWithAllProviders } from '@src/setupTest';
+import { useAllRoleAssignments, useOrgs, useScopes } from '@src/authz-module/data/hooks';
+import { ToastManagerProvider } from '@src/authz-module/libraries-manager/ToastManagerContext';
+import TeamMembersTable from './TeamMembersTable';
+
+const mockedAllRoleAssignments = {
+ data: {
+ results: [
+ {
+ isSuperadmin: false,
+ role: 'course_staff',
+ org: 'OpenedX',
+ scope: 'course-v1:OpenedX+DemoX+DemoCourse',
+ permissionCount: 27,
+ fullName: 'John Doe',
+ username: 'johndoe',
+ email: 'johndoe@example.com',
+ },
+ {
+ isSuperadmin: true,
+ role: 'super_admin',
+ org: 'Global',
+ scope: 'system',
+ permissionCount: 100,
+ fullName: 'Jane Admin',
+ username: 'janeadmin',
+ email: 'jane@example.com',
+ },
+ ],
+ count: 2,
+ next: null,
+ previous: null,
+ },
+ error: null,
+ isLoading: false,
+ refetch: jest.fn(),
+};
+
+const mockedOrgs = {
+ data: {
+ count: 2,
+ next: null,
+ previous: null,
+ results: [
+ { id: 'org1', name: 'Organization 1' },
+ { id: 'org2', name: 'Organization 2' },
+ ],
+ },
+ error: null,
+ isLoading: false,
+ refetch: jest.fn(),
+};
+
+const mockedScopes = {
+ data: {
+ count: 2,
+ next: null,
+ previous: null,
+ results: [
+ {
+ externalKey: 'course-v1:OpenedX+DemoX+DemoCourse',
+ displayName: 'Open edX Demo Course',
+ org: {
+ id: 1,
+ created: '2026-04-02T19:30:36.779095Z',
+ modified: '2026-04-02T19:30:36.779095Z',
+ name: 'OpenedX',
+ shortName: 'OpenedX',
+ description: '',
+ logo: null,
+ active: true,
+ },
+ },
+ {
+ externalKey: 'lib:WGU:CSPROB',
+ displayName: 'Computer Science Problems',
+ org: {
+ id: 2,
+ created: '2026-04-02T19:31:21.196446Z',
+ modified: '2026-04-02T19:31:21.196446Z',
+ name: 'WGU',
+ shortName: 'WGU',
+ description: '',
+ logo: null,
+ active: true,
+ },
+ },
+ ],
+ },
+ error: null,
+ isLoading: false,
+ refetch: jest.fn(),
+};
+
+const mockNavigate = jest.fn();
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useNavigate: () => mockNavigate,
+}));
+
+jest.mock('@edx/frontend-platform/logging', () => ({
+ logError: jest.fn(),
+}));
+
+jest.mock('@src/authz-module/data/hooks', () => ({
+ useAllRoleAssignments: jest.fn(),
+ useOrgs: jest.fn(),
+ useScopes: jest.fn(),
+}));
+
+const mockApiResponses = (
+ allAsignmentsResponse = mockedAllRoleAssignments,
+ orgResponse = mockedOrgs,
+ scopesResponse = mockedScopes,
+) => {
+ (useAllRoleAssignments as jest.Mock).mockReturnValue(allAsignmentsResponse);
+ (useOrgs as jest.Mock).mockReturnValue(orgResponse);
+ (useScopes as jest.Mock).mockReturnValue(scopesResponse);
+};
+
+describe('TeamMembersTable', () => {
+ beforeEach(() => {
+ mockNavigate.mockClear();
+ });
+
+ it('renders table with role assignments data', async () => {
+ const presetScope = 'course-v1:OpenedX+DemoX+DemoCourse';
+ mockApiResponses();
+ renderWithAllProviders();
+ await waitFor(() => {
+ expect(screen.getByText('John Doe')).toBeInTheDocument();
+ expect(screen.getByText('Jane Admin')).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();
+ expect(screen.getByRole('status')).toBeInTheDocument();
+ });
+
+ it('shows error toast message', () => {
+ const allAsignmentsResponse = {
+ ...mockedAllRoleAssignments,
+ isLoading: false,
+ error: new Error('Failed to fetch'),
+ data: { results: [] },
+ };
+ // @ts-ignore
+ mockApiResponses(allAsignmentsResponse);
+ renderWithAllProviders();
+ expect(screen.getByText(/Something went wrong on our end./)).toBeInTheDocument();
+ });
+ it('renders table headers correctly', async () => {
+ mockApiResponses();
+ renderWithAllProviders();
+ await waitFor(() => {
+ expect(screen.getByText('Name')).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('Actions')).toBeInTheDocument();
+ });
+ });
+
+ it('renders view action buttons for each user', async () => {
+ mockApiResponses();
+ renderWithAllProviders();
+ await waitFor(() => {
+ const viewButtons = screen.getAllByRole('button', { name: /view/i });
+ expect(viewButtons).toHaveLength(2);
+ });
+ });
+
+ it('navigates to user profile when view button is clicked', async () => {
+ const user = userEvent.setup();
+ mockApiResponses();
+ renderWithAllProviders();
+ await waitFor(() => {
+ expect(screen.getByText('John Doe')).toBeInTheDocument();
+ });
+ const viewButtons = screen.getAllByRole('button', { name: /view/i });
+ await user.click(viewButtons[0]);
+ expect(mockNavigate).toHaveBeenCalledWith('/authz/user/johndoe');
+ });
+
+ it('handles empty data gracefully', async () => {
+ const allAsignmentsResponse = {
+ data: {
+ results: [],
+ count: 0,
+ next: null,
+ previous: null,
+ },
+ error: null,
+ isLoading: false,
+ refetch: jest.fn(),
+ };
+ mockApiResponses(allAsignmentsResponse);
+ renderWithAllProviders();
+ await waitFor(() => {
+ expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/authz-module/team-members/TeamMembersTable.tsx b/src/authz-module/team-members/TeamMembersTable.tsx
new file mode 100644
index 00000000..efa930fe
--- /dev/null
+++ b/src/authz-module/team-members/TeamMembersTable.tsx
@@ -0,0 +1,145 @@
+import { useEffect, useMemo, useState } from 'react';
+import debounce from 'lodash.debounce';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ DataTable,
+ TextFilter,
+} from '@openedx/paragon';
+
+import { useToastManager } from '@src/authz-module/libraries-manager/ToastManagerContext';
+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/components/utils';
+import {
+ ViewActionCell, NameCell, OrgCell, RoleCell, ScopeCell,
+} from '@src/authz-module/components/TableCells';
+import { useAllRoleAssignments } from '@src/authz-module/data/hooks';
+import { TABLE_DEFAULT_PAGE_SIZE } from '@src/authz-module/constants';
+import messages from './messages';
+import TableFooter from '../components/TableFooter/TableFooter';
+
+interface TeamMembersTableProps {
+ presetScope?: string;
+}
+
+const TeamMembersTable = ({ presetScope }: TeamMembersTableProps) => {
+ const intl = useIntl();
+ const { showErrorToast } = useToastManager();
+ const [columnsWithFiltersApplied, setColumnsWithFiltersApplied] = useState([]);
+
+ const initialQuerySettings = presetScope ? {
+ scopes: presetScope,
+ pageSize: TABLE_DEFAULT_PAGE_SIZE,
+ pageIndex: 0,
+ roles: null,
+ organizations: null,
+ search: null,
+ order: null,
+ sortBy: null,
+ } : undefined;
+
+ const { querySettings, handleTableFetch } = useQuerySettings(initialQuerySettings);
+
+ const {
+ data: { results: roleAssignments, count } = { results: [], count: 0 },
+ isLoading: isLoadingAllRoleAssignments,
+ error,
+ refetch,
+ } = useAllRoleAssignments(querySettings);
+
+ const initialFilters = presetScope ? [{ id: 'scope', value: [presetScope] }] : [];
+
+ useEffect(() => {
+ if (error) {
+ showErrorToast(error, refetch);
+ }
+ }, [error, showErrorToast, refetch]);
+
+ const pageCount = Math.ceil(count / TABLE_DEFAULT_PAGE_SIZE);
+
+ const fetchData = useMemo(() => debounce(handleTableFetch, 500), [handleTableFetch]);
+
+ useEffect(() => () => fetchData.cancel(), [fetchData]);
+
+ return (
+
+ );
+};
+
+export default TeamMembersTable;
diff --git a/src/authz-module/team-members/messages.ts b/src/authz-module/team-members/messages.ts
new file mode 100644
index 00000000..30eebf70
--- /dev/null
+++ b/src/authz-module/team-members/messages.ts
@@ -0,0 +1,37 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+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.email.title': {
+ id: 'authz.team.members.table.column.email.title',
+ defaultMessage: 'Email',
+ description: 'Team members table email column header',
+ },
+ 'authz.team.members.table.column.organization.title': {
+ id: 'authz.team.members.table.column.organization.title',
+ defaultMessage: 'Organization',
+ description: 'Team members table organization column header',
+ },
+ 'authz.team.members.table.column.scope.title': {
+ id: 'authz.team.members.table.column.scope.title',
+ defaultMessage: 'Scope',
+ description: 'Team members table scope column header',
+ },
+ 'authz.team.members.table.column.role.title': {
+ id: 'authz.team.members.table.column.role.title',
+ defaultMessage: 'Role',
+ description: 'Team members table role column header',
+ },
+ 'authz.team.members.table.column.actions.title': {
+ id: 'authz.team.members.table.column.actions.title',
+ defaultMessage: 'Actions',
+ description: 'Team members table actions column header',
+ },
+
+});
+
+export default messages;
diff --git a/src/setupTest.tsx b/src/setupTest.tsx
index 9f051cbb..a6375170 100644
--- a/src/setupTest.tsx
+++ b/src/setupTest.tsx
@@ -5,6 +5,7 @@ import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { AppContext } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const mockAppContext = {
authenticatedUser: {
@@ -20,6 +21,29 @@ interface WrapperProps {
children: ReactNode;
}
+export const renderWithAllProviders = (ui, options = {}) => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+ const Wrapper = ({ children }: WrapperProps) => (
+
+
+
+
+ {children}
+
+
+
+
+ );
+
+ return render(ui, { wrapper: Wrapper, ...options });
+};
+
export const renderWrapper = (ui, options = {}) => {
const Wrapper = ({ children }: WrapperProps) => (
diff --git a/src/types.ts b/src/types.ts
index 27d78f9f..1ce1c194 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -14,6 +14,9 @@ export interface TeamMember {
email: string;
roles: string[];
createdAt: string;
+ scope: { resource: string, type: 'COURSE' | 'LIBRARY' | 'GLOBAL' };
+ organization: string;
+ role: string;
}
export interface LibraryMetadata {
@@ -49,6 +52,18 @@ export type PermissionMetadata = {
description?: string;
};
+export type Org = {
+ id: string;
+ name: string;
+ shortName: string;
+};
+
+export type Scope = {
+ externalKey: string;
+ displayName: string;
+ org: Org;
+};
+
// Permissions Matrix
export type EnrichedPermission = PermissionMetadata & {
@@ -84,3 +99,21 @@ export interface TableCellValue {
original: T;
};
}
+
+export type AppContextType = {
+ authenticatedUser: {
+ username: string;
+ email: string;
+ };
+};
+
+export interface UserRole {
+ isSuperadmin?: boolean;
+ role: string;
+ org: string;
+ scope: string;
+ permissionCount: number;
+ fullName?: string;
+ username?: string;
+ email?: string;
+}