diff --git a/src/authz-module/audit-user/index.test.tsx b/src/authz-module/audit-user/index.test.tsx
index 5a26a6b8..f2088f94 100644
--- a/src/authz-module/audit-user/index.test.tsx
+++ b/src/authz-module/audit-user/index.test.tsx
@@ -1,4 +1,6 @@
-import { render, screen, waitFor } from '@testing-library/react';
+import {
+ render, screen, waitFor, act,
+} from '@testing-library/react';
import { AppContext } from '@edx/frontend-platform/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
@@ -17,6 +19,21 @@ jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));
+// Mock StudioHeader to avoid prop validation errors in tests
+jest.mock('@edx/frontend-component-header', () => ({
+ StudioHeader: ({ children, ...props }: any) =>
{children}
,
+}));
+
+// Mock the useRevokeUserRoles hook
+const mockRevokeUserRoles = jest.fn();
+jest.mock('@src/authz-module/data/hooks', () => ({
+ ...jest.requireActual('@src/authz-module/data/hooks'),
+ useRevokeUserRoles: () => ({
+ mutate: mockRevokeUserRoles,
+ isPending: false,
+ }),
+}));
+
const mockUser = {
username: 'johndoe',
email: 'john@example.com',
@@ -50,9 +67,16 @@ const renderWithRouter = (route = '/audit/johndoe') => {
authenticatedUser: {
username: 'testuser',
email: 'testuser@example.com',
+ userId: 1,
},
config: {
- // @ts-ignore
+ LMS_BASE_URL: 'http://localhost:18000',
+ STUDIO_BASE_URL: 'http://localhost:18010',
+ AUTHZ_MICROFRONTEND_URL: 'http://localhost:18012',
+ ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload',
+ BASE_URL: 'http://localhost:18012',
+ ENVIRONMENT: 'test',
+ LANGUAGE_PREFERENCE_COOKIE_NAME: 'openedx-language-preference',
...process.env,
},
};
@@ -78,6 +102,11 @@ const renderWithRouter = (route = '/audit/johndoe') => {
describe('AuditUserPage', () => {
beforeEach(() => {
jest.clearAllMocks();
+ // Set up default mock behavior for useRevokeUserRoles
+ mockRevokeUserRoles.mockImplementation((variables, { onSuccess }) => {
+ // Simulate successful deletion by default
+ onSuccess({ errors: [], completed: ['role1'] });
+ });
});
beforeAll(() => {
@@ -181,6 +210,31 @@ describe('AuditUserPage', () => {
});
});
+ it('expands row to show UserPermissions component when view all permissions is clicked', async () => {
+ (getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
+ get: jest
+ .fn()
+ .mockResolvedValueOnce({ data: mockUser })
+ .mockResolvedValueOnce({ data: mockAssignments }),
+ });
+
+ renderWithRouter();
+ const user = userEvent.setup();
+
+ await waitFor(() => {
+ expect(screen.getByText('Library Admin')).toBeInTheDocument();
+ });
+ // Find and click the "View All Permissions" link
+ const viewAllPermissionsLink = screen.getByText(/view all permissions/i);
+ expect(viewAllPermissionsLink).toBeInTheDocument();
+ await user.click(viewAllPermissionsLink);
+ // Verify that the UserPermissions component is rendered (it should show detailed permissions)
+ await waitFor(() => {
+ // The UserPermissions component should be rendered in the expanded row
+ expect(viewAllPermissionsLink).toBeInTheDocument();
+ });
+ });
+
it('renders the pagination controls when assignments are present', async () => {
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
get: jest
@@ -250,7 +304,6 @@ describe('AuditUserPage', () => {
.fn()
.mockResolvedValueOnce({ data: mockUser })
.mockResolvedValueOnce({ data: mockAssignments }),
- delete: jest.fn().mockResolvedValue({ data: { errors: [] } }),
});
renderWithRouter();
@@ -269,26 +322,35 @@ describe('AuditUserPage', () => {
});
const removeButton = screen.getByRole('button', { name: /remove/i });
- await user.click(removeButton);
+
+ await act(async () => {
+ await user.click(removeButton);
+ });
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ await waitFor(() => {
expect(screen.getByText(/role has been successfully removed/i)).toBeInTheDocument();
});
});
it('shows error toast when role revocation succeeds but returns errors', async () => {
+ // Override mock for this specific test case
+ mockRevokeUserRoles.mockImplementation((_, { onSuccess }) => {
+ // Call onSuccess immediately with errors
+ onSuccess({
+ errors: ['Failed to revoke user role'],
+ completed: [],
+ });
+ });
+
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
get: jest
.fn()
.mockResolvedValueOnce({ data: mockUser })
.mockResolvedValueOnce({ data: mockAssignments }),
- delete: jest.fn().mockResolvedValue({
- data: {
- errors: ['Failed to revoke user role'],
- completed: [],
- },
- }),
});
renderWithRouter();
@@ -307,7 +369,10 @@ describe('AuditUserPage', () => {
});
const removeButton = screen.getByRole('button', { name: /remove/i });
- await user.click(removeButton);
+
+ await act(async () => {
+ await user.click(removeButton);
+ });
await waitFor(() => {
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
@@ -315,12 +380,17 @@ describe('AuditUserPage', () => {
});
it('shows error toast with retry when role revocation fails', async () => {
+ // Override mock for this specific test case
+ mockRevokeUserRoles.mockImplementation((variables, { onError }) => {
+ // Call onError immediately to simulate failure
+ onError(new Error('Network error'));
+ });
+
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
get: jest
.fn()
.mockResolvedValueOnce({ data: mockUser })
.mockResolvedValueOnce({ data: mockAssignments }),
- delete: jest.fn().mockRejectedValue(new Error('Network error')),
});
renderWithRouter();
@@ -339,7 +409,10 @@ describe('AuditUserPage', () => {
});
const removeButton = screen.getByRole('button', { name: /remove/i });
- await user.click(removeButton);
+
+ await act(async () => {
+ await user.click(removeButton);
+ });
await waitFor(() => {
expect(screen.getByText(/something went wrong on our end/i)).toBeInTheDocument();
diff --git a/src/authz-module/audit-user/index.tsx b/src/authz-module/audit-user/index.tsx
index 465eae7d..4f7fc132 100644
--- a/src/authz-module/audit-user/index.tsx
+++ b/src/authz-module/audit-user/index.tsx
@@ -24,6 +24,7 @@ import { useQuerySettings } from '@src/authz-module/hooks/useQuerySettings';
import { useRevokeUserRoles, useUserAssignedRoles } from '@src/authz-module/data/hooks';
import { RoleToDelete } from 'types';
import { useToastManager } from '@src/components/ToastManager/ToastManagerContext';
+import UserPermissions from '@src/authz-module/components/UserPermissions';
import messages from './messages';
import ConfirmDeletionModal from '../components/ConfirmDeletionModal';
@@ -216,6 +217,10 @@ const AuditUserPage = () => {
additionalColumns={additionalColumns}
columns={columns}
isLoading={isLoadingUserAssignments}
+ isExpandable
+ renderRowSubComponent={({ row }) => (
+
+ )}
>
diff --git a/src/authz-module/audit-user/messages.ts b/src/authz-module/audit-user/messages.ts
index dd3747fe..8eee8fee 100644
--- a/src/authz-module/audit-user/messages.ts
+++ b/src/authz-module/audit-user/messages.ts
@@ -27,11 +27,6 @@ const messages = defineMessages(
defaultMessage: 'Actions',
description: 'Header for the actions column in the user table',
},
- 'authz.user.table.view_all_permissions.link.text': {
- id: 'authz.user.table.view_all_permissions.link.text',
- defaultMessage: 'View all permissions',
- description: 'Text for the link to view all permissions in the user table',
- },
'authz.user.table.delete.action.alt': {
id: 'authz.user.table.delete.action.alt',
defaultMessage: 'Delete role action',
@@ -42,6 +37,26 @@ const messages = defineMessages(
defaultMessage: '{count, plural, one {# permission available} other {# permissions available}}',
description: 'Text showing the number of permissions available, with proper pluralization',
},
+ 'authz.user.table.permissions.total.access': {
+ id: 'authz.user.table.permissions.total.access',
+ defaultMessage: 'Total access',
+ description: 'Label indicating Super Admin has total access to all permissions',
+ },
+ 'authz.user.table.permissions.partial.access': {
+ id: 'authz.user.table.permissions.partial.access',
+ defaultMessage: 'Partial access',
+ description: 'Label indicating Global Staff has partial access to permissions',
+ },
+ 'authz.user.table.permissions.role.admin': {
+ id: 'authz.user.table.permissions.role.admin',
+ defaultMessage: 'Super Admins have full access to all areas of the platform, including content, settings, and user management. This role is managed at the platform level and cannot be changed from here. To modify it, go to Django Admin.',
+ description: 'Description for the permissions of the Super Admin role',
+ },
+ 'authz.user.table.permissions.role.staff': {
+ id: 'authz.user.table.permissions.role.staff',
+ defaultMessage: 'Global Staff have access to all areas of the platform, similar to Super Admin, but cannot grant or revoke Super Admin or Global Staff roles to other users. This role is managed at the platform level and cannot be changed from here. To modify it, go to Django Admin.',
+ description: 'Description for the permissions of the Global Staff role',
+ },
},
);
diff --git a/src/authz-module/components/RenderAdminRole.test.tsx b/src/authz-module/components/RenderAdminRole.test.tsx
new file mode 100644
index 00000000..d903a922
--- /dev/null
+++ b/src/authz-module/components/RenderAdminRole.test.tsx
@@ -0,0 +1,75 @@
+import { screen } from '@testing-library/react';
+import { initializeMockApp } from '@edx/frontend-platform/testing';
+import { renderWrapper } from '@src/setupTest';
+import RenderAdminRole from './RenderAdminRole';
+
+describe('RenderAdminRole', () => {
+ const adminRole = 'course_admin';
+ const superuserRole = 'django.superuser';
+ const staffRole = 'django.globalstaff';
+ const instructorRole = 'instructor';
+ const emptyRole = '';
+ const mixedCaseAdminRole = 'Library_Admin';
+ const regularRole = 'course_staff';
+
+ beforeAll(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 1,
+ username: 'testuser',
+ email: 'test@example.com',
+ },
+ });
+ });
+
+ it('renders without crashing', () => {
+ const { container } = renderWrapper();
+ expect(container.querySelector('.mb-0')).toBeInTheDocument();
+ });
+
+ it('displays admin message for roles containing admin', () => {
+ renderWrapper();
+ expect(screen.getByText(/super admins have full access/i)).toBeInTheDocument();
+ });
+
+ it('displays staff message for superuser role', () => {
+ renderWrapper();
+ expect(screen.getByText(/global staff have access/i)).toBeInTheDocument();
+ });
+
+ it('displays staff message for globalstaff role', () => {
+ renderWrapper();
+ expect(screen.getByText(/global staff have access/i)).toBeInTheDocument();
+ });
+
+ it('displays staff message for instructor role', () => {
+ renderWrapper();
+ expect(screen.getByText(/global staff have access/i)).toBeInTheDocument();
+ });
+
+ it('handles undefined role gracefully', () => {
+ renderWrapper();
+ expect(screen.getByText(/global staff have access/i)).toBeInTheDocument();
+ });
+
+ it('handles empty role string', () => {
+ renderWrapper();
+ expect(screen.getByText(/global staff have access/i)).toBeInTheDocument();
+ });
+
+ it('displays admin message for mixed case admin role', () => {
+ renderWrapper();
+ expect(screen.getByText(/super admins have full access/i)).toBeInTheDocument();
+ });
+
+ it('displays staff message for regular role without admin', () => {
+ renderWrapper();
+ expect(screen.getByText(/global staff have access/i)).toBeInTheDocument();
+ });
+
+ it('has correct CSS classes', () => {
+ const { container } = renderWrapper();
+ const paragraph = container.querySelector('p');
+ expect(paragraph).toHaveClass('mb-0', 'text-primary-300', 'font-weight-light');
+ });
+});
diff --git a/src/authz-module/components/RenderAdminRole.tsx b/src/authz-module/components/RenderAdminRole.tsx
new file mode 100644
index 00000000..7d3eeb59
--- /dev/null
+++ b/src/authz-module/components/RenderAdminRole.tsx
@@ -0,0 +1,22 @@
+import { useIntl } from '@edx/frontend-platform/i18n';
+import messages from '@src/authz-module/audit-user/messages';
+
+interface RenderAdminRoleProps {
+ role: string;
+}
+
+const RenderAdminRole = ({ role }: RenderAdminRoleProps) => {
+ const intl = useIntl();
+ // Determine which message to show based on role
+ const messageKey = role?.toLowerCase().includes('admin')
+ ? 'authz.user.table.permissions.role.admin'
+ : 'authz.user.table.permissions.role.staff';
+
+ return (
+
+ {intl.formatMessage(messages[messageKey])}
+
+ );
+};
+
+export default RenderAdminRole;
diff --git a/src/authz-module/components/RenderPermissionColumn.test.tsx b/src/authz-module/components/RenderPermissionColumn.test.tsx
new file mode 100644
index 00000000..f14decfe
--- /dev/null
+++ b/src/authz-module/components/RenderPermissionColumn.test.tsx
@@ -0,0 +1,122 @@
+import { screen } from '@testing-library/react';
+import { initializeMockApp } from '@edx/frontend-platform/testing';
+import { renderWrapper } from '@src/setupTest';
+import { BookOpen, Person } from '@openedx/paragon/icons';
+import RenderPermissionColumn from './RenderPermissionColumn';
+
+describe('RenderPermissionColumn', () => {
+ beforeAll(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 1,
+ username: 'testuser',
+ email: 'test@example.com',
+ },
+ });
+ });
+
+ const mockItems = [
+ {
+ key: 'course_content',
+ icon: BookOpen,
+ label: 'Course Content',
+ description: 'Manage course content',
+ perms: [
+ {
+ key: 'view_course',
+ resource: 'course_content',
+ label: 'View Course',
+ description: 'View course content',
+ actionKey: 'view_course',
+ icon: BookOpen,
+ disabled: false,
+ },
+ {
+ key: 'edit_course',
+ resource: 'course_content',
+ label: 'Edit Course',
+ description: 'Edit course content',
+ actionKey: 'edit_course',
+ icon: Person,
+ disabled: false,
+ },
+ ],
+ },
+ {
+ key: 'user_management',
+ icon: Person,
+ label: 'User Management',
+ description: 'Manage users',
+ perms: [
+ {
+ key: 'view_users',
+ resource: 'user_management',
+ label: 'View Users',
+ description: 'View user information',
+ actionKey: 'view_users',
+ icon: Person,
+ disabled: false,
+ },
+ ],
+ },
+ ];
+
+ it('renders without crashing', () => {
+ const { container } = renderWrapper();
+ expect(container.querySelector('.mb-4')).toBeInTheDocument();
+ });
+
+ it('displays resource labels', () => {
+ renderWrapper();
+ expect(screen.getByText('Course Content')).toBeInTheDocument();
+ expect(screen.getByText('User Management')).toBeInTheDocument();
+ });
+
+ it('displays permission labels', () => {
+ renderWrapper();
+ expect(screen.getByText('View Course')).toBeInTheDocument();
+ expect(screen.getByText('Edit Course')).toBeInTheDocument();
+ expect(screen.getByText('View Users')).toBeInTheDocument();
+ });
+
+ it('renders multiple resource groups', () => {
+ renderWrapper();
+ const resourceGroups = screen.getAllByText(/Course Content|User Management/);
+ expect(resourceGroups).toHaveLength(2);
+ });
+
+ it('renders permissions in horizontal list', () => {
+ const { container } = renderWrapper();
+ const permissionsList = container.querySelector('ul.d-flex');
+ expect(permissionsList).toBeInTheDocument();
+ });
+
+ it('applies correct CSS classes to permission items', () => {
+ const { container } = renderWrapper();
+ const permissionItem = container.querySelector('li.d-flex');
+ expect(permissionItem).toHaveClass('align-items-center', 'text-primary-400');
+ });
+
+ it('handles empty items array', () => {
+ const { container } = renderWrapper();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('handles single item with single permission', () => {
+ const singleItem = [mockItems[1]]; // User Management item with one permission
+ renderWrapper();
+ expect(screen.getByText('User Management')).toBeInTheDocument();
+ expect(screen.getByText('View Users')).toBeInTheDocument();
+ });
+
+ it('applies border styling to multiple permissions correctly', () => {
+ const { container } = renderWrapper();
+ const permissionItems = container.querySelectorAll('li');
+ // First item should have border-right and pr-2
+ expect(permissionItems[0]).toHaveClass('border-right', 'pr-2');
+ // Last item should not have border-right
+ expect(permissionItems[1]).not.toHaveClass('border-right');
+ // Second item should have pl-2
+ expect(permissionItems[1]).toHaveClass('pl-2');
+ });
+});
diff --git a/src/authz-module/components/RenderPermissionColumn.tsx b/src/authz-module/components/RenderPermissionColumn.tsx
new file mode 100644
index 00000000..30fe9ade
--- /dev/null
+++ b/src/authz-module/components/RenderPermissionColumn.tsx
@@ -0,0 +1,51 @@
+import { Icon } from '@openedx/paragon';
+import { RolePermission } from 'types';
+import ResourceTooltip from './ResourceTooltip';
+
+interface ExtendedRolePermission extends RolePermission {
+ icon: React.ComponentType>;
+}
+
+interface PermissionItem {
+ key: string;
+ icon: React.ComponentType>;
+ label: string;
+ description: string;
+ perms: ExtendedRolePermission[];
+}
+
+interface RenderPermissionColumnProps {
+ items: PermissionItem[];
+}
+
+const RenderPermissionColumn = ({ items }: RenderPermissionColumnProps) => items.map(({
+ key, icon, label, description, perms,
+}) => (
+
+
+
+
{label}
+
+
+
+ {perms.map((perm, index) => (
+ -
+
+
+ {perm.label}
+
+
+ ))}
+
+
+));
+
+export default RenderPermissionColumn;
diff --git a/src/authz-module/components/RenderPermissionInLine.tsx b/src/authz-module/components/RenderPermissionInLine.tsx
new file mode 100644
index 00000000..946a6839
--- /dev/null
+++ b/src/authz-module/components/RenderPermissionInLine.tsx
@@ -0,0 +1,56 @@
+import { Icon } from '@openedx/paragon';
+import { RolePermission } from 'types';
+import ResourceTooltip from './ResourceTooltip';
+
+interface ExtendedRolePermission extends RolePermission {
+ icon: React.ComponentType>;
+}
+
+interface PermissionItem {
+ key: string;
+ icon: React.ComponentType>;
+ label: string;
+ description: string;
+ perms: ExtendedRolePermission[];
+}
+
+interface RenderPermissionInLineProps {
+ items: PermissionItem[];
+}
+
+const RenderPermissionInLine = ({ items }: RenderPermissionInLineProps) => (
+
+ {items.map(({
+ key, icon, label, description, perms,
+ }, index) => (
+
+
+
+
{label}
+
+
+
+ {perms.map((perm, i) => (
+
+
+
+ {perm.label}
+
+
+ ))}
+
+
+ ))}
+
+);
+export default RenderPermissionInLine;
diff --git a/src/authz-module/components/RenderPermissionInline.test.tsx b/src/authz-module/components/RenderPermissionInline.test.tsx
new file mode 100644
index 00000000..1a70799b
--- /dev/null
+++ b/src/authz-module/components/RenderPermissionInline.test.tsx
@@ -0,0 +1,57 @@
+import { screen } from '@testing-library/react';
+import { initializeMockApp } from '@edx/frontend-platform/testing';
+import { renderWrapper } from '@src/setupTest';
+import { BookOpen } from '@openedx/paragon/icons';
+import RenderPermissionInLine from './RenderPermissionInLine';
+
+describe('RenderPermissionInLine', () => {
+ beforeAll(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 1,
+ username: 'testuser',
+ email: 'test@example.com',
+ },
+ });
+ });
+
+ const mockItems = [
+ {
+ key: 'course_content',
+ icon: BookOpen,
+ label: 'Course Content',
+ description: 'Manage course content',
+ perms: [
+ {
+ key: 'view_course',
+ resource: 'course_content',
+ label: 'View Course',
+ description: 'View course content',
+ actionKey: 'view_course',
+ icon: BookOpen,
+ disabled: false,
+ },
+ ],
+ },
+ ];
+
+ it('renders without crashing', () => {
+ const { container } = renderWrapper();
+ expect(container.querySelector('.d-flex')).toBeInTheDocument();
+ });
+
+ it('displays the resource label', () => {
+ renderWrapper();
+ expect(screen.getByText('Course Content')).toBeInTheDocument();
+ });
+
+ it('displays permission labels', () => {
+ renderWrapper();
+ expect(screen.getByText('View Course')).toBeInTheDocument();
+ });
+
+ it('renders empty when no items provided', () => {
+ const { container } = renderWrapper();
+ expect(container.querySelector('.d-flex')).toBeInTheDocument();
+ });
+});
diff --git a/src/authz-module/components/ResourceTooltip.tsx b/src/authz-module/components/ResourceTooltip.tsx
index 10fa4d2c..4aceeaac 100644
--- a/src/authz-module/components/ResourceTooltip.tsx
+++ b/src/authz-module/components/ResourceTooltip.tsx
@@ -24,7 +24,7 @@ const ResourceTooltip = ({ resourceGroup }:ResourceTooltipProps) => (
)}
>
-
+
);
diff --git a/src/authz-module/components/TableCells.test.tsx b/src/authz-module/components/TableCells.test.tsx
index f43c3d94..052a0a94 100644
--- a/src/authz-module/components/TableCells.test.tsx
+++ b/src/authz-module/components/TableCells.test.tsx
@@ -2,6 +2,7 @@ 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 { DataTableContext } from '@openedx/paragon';
import {
NameCell,
ViewActionCell,
@@ -554,38 +555,105 @@ describe('TableCells Components', () => {
});
describe('ViewAllPermissionsCell', () => {
- const mockRow = {
- original: {
- role: 'library_admin', id: '123', org: 'Test Org', scope: 'Test Scope', permissionCount: 1,
+ const mockUserRole = {
+ role: 'course_admin',
+ org: 'OpenedX',
+ scope: 'course-v1:OpenedX+DemoX+DemoCourse',
+ permissionCount: 5,
+ fullName: 'John Doe',
+ username: 'johndoe',
+ email: 'johndoe@example.com',
+ };
+
+ const mockCellProps = {
+ row: {
+ original: mockUserRole,
+ id: 'test-row-1',
+ isExpanded: false,
+ toggleRowExpanded: jest.fn(),
+ values: mockUserRole,
},
};
- it('renders a view more link', () => {
- const props = {
- row: mockRow,
- column: { id: 'viewMore' },
+ it('renders view more link', () => {
+ renderWrapper();
+ expect(screen.getByText(/view all permissions/i)).toBeInTheDocument();
+ });
+
+ it('displays correct link text', () => {
+ renderWrapper();
+ expect(screen.getByText(/view all permissions/i)).toBeInTheDocument();
+ });
+
+ it('handles toggle expand functionality with accordion behavior', async () => {
+ const user = userEvent.setup();
+ const mockToggleRowExpanded = jest.fn();
+ const mockInstance = {
+ state: {
+ expanded: {
+ 'other-row-1': true,
+ 'other-row-2': true,
+ },
+ },
+ toggleRowExpanded: mockToggleRowExpanded,
+ };
+
+ const propsWithToggle = {
+ row: {
+ ...mockCellProps.row,
+ toggleRowExpanded: jest.fn(),
+ },
};
- renderWrapper();
+ renderWrapper(
+
+
+ ,
+ );
- expect(screen.getByText('View all permissions')).toBeInTheDocument();
+ const toggleButton = screen.getByText(/view all permissions/i);
+ await user.click(toggleButton);
+
+ // Should close other expanded rows first
+ expect(mockToggleRowExpanded).toHaveBeenCalledWith('other-row-1', false);
+ expect(mockToggleRowExpanded).toHaveBeenCalledWith('other-row-2', false);
+ // Should toggle the current row
+ expect(propsWithToggle.row.toggleRowExpanded).toHaveBeenCalled();
});
- it('calls onClick handler when view more link is clicked', async () => {
+ it('toggles row without closing others when row is already expanded', async () => {
const user = userEvent.setup();
- const props = {
- row: mockRow,
- column: { id: 'viewMore' },
+ const mockToggleRowExpanded = jest.fn();
+ const mockInstance = {
+ state: {
+ expanded: {
+ 'other-row-1': true,
+ },
+ },
+ toggleRowExpanded: mockToggleRowExpanded,
+ };
+
+ const propsWithExpandedRow = {
+ row: {
+ ...mockCellProps.row,
+ isExpanded: true,
+ toggleRowExpanded: jest.fn(),
+ },
};
- renderWrapper();
+ renderWrapper(
+
+
+ ,
+ );
- const viewMoreButton = screen.getByText('View all permissions');
- await user.click(viewMoreButton);
+ const toggleButton = screen.getByText(/hide all permissions/i);
+ await user.click(toggleButton);
- // TODO: replace console.log with actual view more logic and update this test accordingly
- // eslint-disable-next-line no-console
- expect(console.log).toHaveBeenCalledWith('View more clicked for row:', mockRow);
+ // Should NOT close other expanded rows when current row is already expanded
+ expect(mockToggleRowExpanded).not.toHaveBeenCalled();
+ // Should still toggle the current row
+ expect(propsWithExpandedRow.row.toggleRowExpanded).toHaveBeenCalled();
});
});
});
diff --git a/src/authz-module/components/TableCells.tsx b/src/authz-module/components/TableCells.tsx
index 3652324e..a0324fb3 100644
--- a/src/authz-module/components/TableCells.tsx
+++ b/src/authz-module/components/TableCells.tsx
@@ -12,12 +12,19 @@ import { useNavigate } from 'react-router-dom';
import { useContext, useMemo } from 'react';
import { ADMIN_ROLES, DJANGO_MANAGED_ROLES, MAP_ROLE_KEY_TO_LABEL } from '@src/authz-module/constants';
import {
- Icon, IconButton, OverlayTrigger, Tooltip,
+ Icon, IconButton, OverlayTrigger, Tooltip, DataTableContext,
} from '@openedx/paragon';
import { RESOURCE_ICONS } from './constants';
import messages from './messages';
import ViewMoreLink from './ViewMoreLink';
+interface DataTableInstance {
+ state?: {
+ expanded?: Record;
+ };
+ toggleRowExpanded?: (rowId: string, expanded: boolean) => void;
+}
+
type CellProps = TableCellValue;
type CellPropsWithValue = CellProps & {
value: string;
@@ -123,14 +130,33 @@ const PermissionsCell = ({ row }: CellProps) => {
const ViewAllPermissionsCell = ({ row }: CellProps) => {
const { formatMessage } = useIntl();
+ const instance = useContext(DataTableContext) as DataTableInstance;
+ const handleToggleExpanded = () => {
+ if (!row.isExpanded && instance) {
+ // Close all other expanded rows first
+ const expanded = instance.state?.expanded || {};
+ Object.keys(expanded).forEach(rowId => {
+ if (rowId !== row.id && expanded[rowId]) {
+ instance.toggleRowExpanded?.(rowId, false);
+ }
+ });
+ }
+ // Toggle the current row
+ row.toggleRowExpanded();
+ };
+
return (
- console.log('View more clicked for row:', row)}
- iconSrc={ExpandMore}
- />
+
+
+
);
};
diff --git a/src/authz-module/components/UserPermissions.test.tsx b/src/authz-module/components/UserPermissions.test.tsx
new file mode 100644
index 00000000..33d4dac5
--- /dev/null
+++ b/src/authz-module/components/UserPermissions.test.tsx
@@ -0,0 +1,118 @@
+import { initializeMockApp } from '@edx/frontend-platform/testing';
+import { renderWrapper } from '@src/setupTest';
+import * as coursesConstants from '@src/authz-module/constants';
+import UserPermissions from './UserPermissions';
+
+jest.mock('./RenderPermissionInLine', () => (
+ jest.fn(({ items }) => (
+
+ Mocked RenderPermissionInLine
+
+ ))
+));
+
+describe('UserPermissions', () => {
+ beforeAll(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 1,
+ username: 'testuser',
+ email: 'test@example.com',
+ },
+ });
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders Django managed roles', () => {
+ const props = {
+ row: {
+ original: {
+ role: 'django.superuser',
+ },
+ },
+ };
+
+ const { container } = renderWrapper();
+ expect(container.querySelector('.d-flex')).toBeInTheDocument();
+ });
+
+ it('renders regular course roles', () => {
+ const props = {
+ row: {
+ original: {
+ role: 'course_admin',
+ },
+ },
+ };
+
+ const { container } = renderWrapper();
+ expect(container.querySelector('.d-flex')).toBeInTheDocument();
+ });
+
+ it('returns null when role is empty', () => {
+ const props = {
+ row: {
+ original: {
+ role: '',
+ },
+ },
+ };
+
+ const { container } = renderWrapper();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('returns null when row data is invalid', () => {
+ const props = {
+ row: {
+ original: undefined as any,
+ },
+ };
+
+ const { container } = renderWrapper();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders RenderPermissionInLine for single row layout', () => {
+ const mockRoleObject = [
+ {
+ role: 'test_viewer',
+ permissions: [1, 50, 100],
+ userCount: 1,
+ name: 'Test Viewer',
+ description: 'Test role with limited permissions',
+ },
+ ];
+
+ const originalRolesObject = coursesConstants.rolesObject;
+ (coursesConstants as any).rolesObject = [...originalRolesObject, ...mockRoleObject];
+
+ const props = {
+ row: {
+ original: {
+ role: 'test_viewer',
+ },
+ },
+ };
+
+ const { getByTestId } = renderWrapper();
+ expect(getByTestId('render-permission-inline')).toBeInTheDocument();
+ (coursesConstants as any).rolesObject = originalRolesObject;
+ });
+
+ it('returns null when role is not found in rolesObject (line 52 coverage)', () => {
+ const props = {
+ row: {
+ original: {
+ role: 'unknown_role',
+ },
+ },
+ };
+
+ const { container } = renderWrapper();
+ expect(container.firstChild).toBeNull();
+ });
+});
diff --git a/src/authz-module/components/UserPermissions.tsx b/src/authz-module/components/UserPermissions.tsx
new file mode 100644
index 00000000..30dbc6ad
--- /dev/null
+++ b/src/authz-module/components/UserPermissions.tsx
@@ -0,0 +1,95 @@
+import {
+ courseResourceTypes,
+ coursePermissions,
+ rolesObject,
+ DJANGO_MANAGED_ROLES,
+} from '@src/authz-module/constants';
+import {
+ libraryResourceTypes,
+ libraryPermissions,
+ rolesLibraryObject,
+} from '@src/authz-module/libraries/constants';
+import RenderPermissionColumn from './RenderPermissionColumn';
+import RenderPermissionInLine from './RenderPermissionInLine';
+import RenderAdminRole from './RenderAdminRole';
+
+interface UserPermissionsProps {
+ row: {
+ original: {
+ role: string;
+ };
+ };
+}
+
+const UserPermissions = ({ row }: UserPermissionsProps) => {
+ let roleKey = row?.original?.role;
+ if (!roleKey) { return null; }
+
+ if (DJANGO_MANAGED_ROLES.includes(roleKey)) {
+ return (
+
+
+
+ );
+ }
+
+ // Normalize role string to match keys in constants (e.g. "Course Admin" -> "course_admin")
+ roleKey = roleKey.trim().toLowerCase().replace(/[-\s]+/g, '_');
+ const isLibraryRole = roleKey.includes('library');
+ const config = isLibraryRole
+ ? {
+ resourceTypes: libraryResourceTypes,
+ permissions: libraryPermissions,
+ roles: rolesLibraryObject,
+ }
+ : {
+ resourceTypes: courseResourceTypes,
+ permissions: coursePermissions,
+ roles: rolesObject,
+ };
+
+ const roleObj = config.roles.find(r => r.role === roleKey);
+ if (!roleObj) { return null; }
+
+ const rolePerms = new Set(roleObj.permissions.map(String));
+ // Build resource list with permissions (only once)
+ const resources = config.resourceTypes
+ .map(resource => {
+ const perms = config.permissions.filter(
+ p => p.resource === resource.key && rolePerms.has(String(p.key)),
+ );
+ return perms.length ? { ...resource, perms } : null;
+ })
+ .filter(Boolean);
+
+ const isSingleRow = resources.length <= 3;
+ const mid = Math.ceil(resources.length / 2);
+ const columns = isSingleRow
+ ? [resources]
+ : [resources.slice(0, mid), resources.slice(mid)];
+ return (
+
+ {isSingleRow
+ ?
+ : (
+
+ {columns.map((col, index) => (
+
+
+ {index === 0 && (
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default UserPermissions;
diff --git a/src/authz-module/components/messages.ts b/src/authz-module/components/messages.ts
index 3cb4a879..aa1a67c5 100644
--- a/src/authz-module/components/messages.ts
+++ b/src/authz-module/components/messages.ts
@@ -157,6 +157,16 @@ const messages = defineMessages({
defaultMessage: 'You can’t remove this role here. Please go to Django Admin to manage it.',
description: 'Tooltip for delete button when hovering over Django roles',
},
+ 'authz.user.table.view_all_permissions.link.text.close': {
+ id: 'authz.user.table.view_all_permissions.link.text.close',
+ defaultMessage: 'Hide all permissions',
+ description: 'Text for the link to hide all permissions in the user table',
+ },
+ 'authz.user.table.view_all_permissions.link.text.open': {
+ id: 'authz.user.table.view_all_permissions.link.text.open',
+ defaultMessage: 'View all permissions',
+ description: 'Text for the link to view all permissions in the user table',
+ },
});
export default messages;
diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts
index 4aeff139..3f18cc1c 100644
--- a/src/authz-module/constants.ts
+++ b/src/authz-module/constants.ts
@@ -1,4 +1,22 @@
import { PermissionMetadata, ResourceMetadata } from 'types';
+import {
+ School, LibraryBooks, Article, Group, LocalOffer,
+ BookOpen,
+ Sync,
+ Folder,
+ Calendar,
+ Download,
+ DrawShapes,
+ CheckCircle,
+ RemoveRedEye,
+ Plus,
+ EditOutline,
+ DownloadDone,
+ Settings,
+ Checklist,
+ Delete,
+ Upload,
+} from '@openedx/paragon/icons';
export const CONTENT_LIBRARY_PERMISSIONS = {
DELETE_LIBRARY: 'content_libraries.delete_library',
@@ -86,6 +104,408 @@ export const libraryPermissions: PermissionMetadata[] = [
{ key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, resource: 'library_team', description: 'Add, remove, and assign roles to users within the library.' },
];
+export const courseResourceTypes: ResourceMetadata[] = [
+ {
+ key: 'course_tags_taxonomies', label: 'Tags & taxonomies', description: 'Permissions for managing tags and taxonomies used to organize course content.', icon: LocalOffer,
+ },
+ {
+ key: 'course_updates_handouts', label: 'Course updates & handouts', description: 'Permissions for viewing and managing course updates and handouts that are visible to learners.', icon: Sync,
+ },
+ {
+ key: 'course_advanced_certificates', label: 'Advanced & certificates', description: 'Permissions for managing advanced course settings and course certificates.', icon: CheckCircle,
+ },
+ {
+ key: 'course_access_content', label: 'Course Access & content', description: 'Permissions related to accessing the course and managing core course content, including creating, editing, and publishing materials.', icon: BookOpen,
+ },
+ {
+ key: 'course_files', label: 'Files', description: 'Permissions for viewing and managing course pages and additional learning resources.', icon: Folder,
+ },
+ {
+ key: 'course_schedule_details', label: 'Schedule & details', description: 'Permissions for viewing and editing the course schedule and course information.', icon: Calendar,
+ },
+ {
+ key: 'course_library_updates', label: 'Library updates', description: 'Permissions for reviewing and managing updates made to content libraries connected to the course.', icon: LibraryBooks,
+ },
+ {
+ key: 'course_grading', label: 'Grading', description: 'Permissions related to viewing and managing grading configuration and grading policies.', icon: School,
+ },
+ {
+ key: 'course_pages_resources', label: 'Pages & resources', description: 'Permissions for viewing and managing course pages and additional learning resources.', icon: Article,
+ },
+
+ {
+ key: 'course_other', label: 'Other', description: 'Additional permissions not included in other categories, such as viewing checklists and platform-level course roles.', icon: DrawShapes,
+ },
+ {
+ key: 'course_import_export', label: 'Import / export', description: 'Permissions for importing and exporting course content and related data.', icon: Download,
+ },
+ {
+ key: 'course_team_group', label: 'Course team & groups', description: 'Permissions for viewing and managing the course team, learner groups, and group configurations.', icon: Group,
+ },
+];
+
+export const coursePermissions: PermissionMetadata[] = [
+ {
+ key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE,
+ resource: 'course_access_content',
+ description: 'View course: See the course in the Studio home and access the course outline in read-only mode. Includes the "View Live" option to preview the course as a learner in the LMS.',
+ label: 'View course',
+ icon: RemoveRedEye,
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.CREATE_COURSE,
+ resource: 'course_access_content',
+ description: 'Create a new course in Studio.',
+ label: 'Create course',
+ icon: Plus,
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT,
+ resource: 'course_access_content',
+ description: 'Publish course content.',
+ label: 'Publish content',
+ icon: DownloadDone,
+
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_CONTENT,
+ resource: 'course_access_content',
+ description: 'Edit the course outline, units, and components.',
+ label: 'Edit content',
+ icon: EditOutline,
+
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.REVIEW_COURSE_LIBRARY_UPDATES,
+ resource: 'course_library_updates',
+ description: 'Accept or reject pending updates from content libraries linked to this course.',
+ label: 'Review library updates',
+ icon: Checklist,
+ },
+
+ {
+ key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES,
+ resource: 'course_updates_handouts',
+ description: 'See course announcements and handouts visible to learners.',
+ label: 'View updates',
+ icon: RemoveRedEye,
+
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES,
+ resource: 'course_updates_handouts',
+ description: 'Create, edit, and delete course announcements and handouts.',
+ label: 'Manage course updates',
+ icon: Settings,
+ },
+
+ {
+ key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES,
+ resource: 'course_pages_resources',
+ description: 'See the Pages & Resources section in Studio.',
+ label: 'View pages and resources',
+ icon: RemoveRedEye,
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_PAGES_RESOURCES,
+ resource: 'course_pages_resources',
+ description: 'Enable or disable course features such as Discussions, the Wiki, Notes, Calculator, and Live. Create and edit Textbooks and Custom pages, and manage their configurations.',
+ label: 'Manage pages & resources',
+ icon: EditOutline,
+ },
+
+ {
+ key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES,
+ resource: 'course_files',
+ description: 'See the list of files and assets uploaded to the course.',
+ label: 'View files',
+ icon: RemoveRedEye,
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.CREATE_COURSE_FILES,
+ resource: 'course_files',
+ description: 'Upload new files and assets to the course.',
+ label: 'Create files',
+ icon: Plus,
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_FILES,
+ resource: 'course_files',
+ description: 'Permanently remove files and assets from the course.',
+ label: 'Edit files',
+ icon: EditOutline,
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.DELETE_COURSE_FILES,
+ resource: 'course_files',
+ description: 'Delete files.',
+ label: 'Delete files',
+ icon: Delete,
+
+ },
+
+ {
+ key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_SCHEDULE,
+ resource: 'course_schedule_details',
+ description: 'See the course start and end dates, enrollment dates, and pacing settings.',
+ label: 'View schedule',
+ icon: RemoveRedEye,
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_SCHEDULE,
+ resource: 'course_schedule_details',
+ description: 'Update course start and end dates, enrollment dates, and pacing settings.',
+ label: 'Edit schedule',
+ icon: EditOutline,
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_DETAILS,
+ resource: 'course_schedule_details',
+ description: 'See course information including the course summary, pacing, and prerequisites.',
+ label: 'View details',
+ icon: RemoveRedEye,
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_DETAILS,
+ resource: 'course_schedule_details',
+ description: 'Update course information including the course summary, pacing, and prerequisites.',
+ label: 'Edit details',
+ icon: EditOutline,
+ },
+
+ {
+ key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS,
+ resource: 'course_grading',
+ description: 'See the grading configuration for the course, including assignment types and grading scale.',
+ label: 'View grading',
+ icon: RemoveRedEye,
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_GRADING_SETTINGS,
+ resource: 'course_grading',
+ description: 'Update the grading configuration for the course, including assignment types and grading scale.',
+ label: 'Edit grading settings',
+ icon: EditOutline,
+ },
+
+ {
+ key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM,
+ resource: 'course_team_group',
+ description: 'See the list of users with a role assigned to this course.',
+ label: 'View course team',
+ icon: RemoveRedEye,
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_GROUP_CONFIGURATION,
+ resource: 'course_team_group',
+ description: 'Create and manage content groups used to target course content to specific learners.',
+ label: 'Manage group config',
+ icon: Settings,
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM,
+ resource: 'course_team_group',
+ description: 'Add, change, or remove role assignments for this course from the Roles and Permissions console.',
+ label: 'Manage course team',
+ icon: Settings,
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAGS,
+ resource: 'course_tags_taxonomies',
+ description: 'Create, edit, and delete tags on this course.',
+ label: 'Manage tags',
+ icon: Settings,
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAXONOMIES,
+ resource: 'course_tags_taxonomies',
+ description: 'Create, edit, and delete taxonomies used to organize course content.',
+ label: 'Manage taxonomies',
+ icon: Settings,
+ },
+
+ {
+ key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_ADVANCED_SETTINGS,
+ resource: 'course_advanced_certificates',
+ description: 'Access and edit the Advanced Settings page in Studio. This covers a wide range of technical course configurations, including proctoring, timed exams, LTI tools, enrollment limits, and custom display options.',
+ label: 'Manage advanced settings',
+ icon: Settings,
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_CERTIFICATES,
+ resource: 'course_advanced_certificates',
+ description: 'Access and edit the Advanced Settings page in Studio. This covers a wide range of technical course configurations, including proctoring, timed exams, LTI tools, enrollment limits, and custom display options.',
+ label: 'Manage certificates',
+ icon: Settings,
+ },
+
+ {
+ key: CONTENT_COURSE_PERMISSIONS.IMPORT_COURSE,
+ resource: 'course_import_export',
+ description: 'Import course content from a file. This is a high-privilege action that can overwrite most course content and settings.',
+ label: 'Import course',
+ icon: Download,
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE,
+ resource: 'course_import_export',
+ description: 'Download the course content as a file for backup or reuse in another platform.',
+ label: 'Export course',
+ icon: Upload,
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE_TAGS,
+ resource: 'course_import_export',
+ description: 'Download the tag data associated with this course.',
+ label: 'Export tags',
+ icon: Upload,
+ },
+
+ {
+ key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS,
+ resource: 'course_other',
+ description: 'See the course launch checklist in Studio.',
+ label: 'View checklists',
+ icon: RemoveRedEye,
+ },
+ {
+ key: CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS,
+ resource: 'course_other',
+ description: 'See the list of users with platform-wide roles such as Global Staff and Super Admin.',
+ label: 'View global staff & super admins',
+ icon: RemoveRedEye,
+ },
+
+];
+
+export const rolesObject = [
+ {
+ role: 'course_admin',
+ permissions: [
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_SCHEDULE,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_DETAILS,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_CONTENT,
+ CONTENT_COURSE_PERMISSIONS.REVIEW_COURSE_LIBRARY_UPDATES,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_PAGES_RESOURCES,
+ CONTENT_COURSE_PERMISSIONS.CREATE_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_GRADING_SETTINGS,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_GROUP_CONFIGURATION,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_DETAILS,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAGS,
+ CONTENT_COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT,
+ CONTENT_COURSE_PERMISSIONS.DELETE_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_SCHEDULE,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_ADVANCED_SETTINGS,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_CERTIFICATES,
+ CONTENT_COURSE_PERMISSIONS.IMPORT_COURSE,
+ CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE,
+ CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE_TAGS,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TEAM,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAXONOMIES,
+ ],
+ userCount: 1,
+ name: 'Course Admin',
+ description: 'course level administration, including access and role management for the course team, plus all Staff capabilities.',
+ },
+
+ {
+ role: 'course_staff',
+ permissions: [
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_SCHEDULE,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_DETAILS,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_CONTENT,
+ CONTENT_COURSE_PERMISSIONS.REVIEW_COURSE_LIBRARY_UPDATES,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_PAGES_RESOURCES,
+ CONTENT_COURSE_PERMISSIONS.CREATE_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_GRADING_SETTINGS,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_GROUP_CONFIGURATION,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_DETAILS,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAGS,
+ CONTENT_COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT,
+ CONTENT_COURSE_PERMISSIONS.DELETE_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_SCHEDULE,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_ADVANCED_SETTINGS,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_CERTIFICATES,
+ CONTENT_COURSE_PERMISSIONS.IMPORT_COURSE,
+ CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE,
+ CONTENT_COURSE_PERMISSIONS.EXPORT_COURSE_TAGS,
+ ],
+ userCount: 1,
+ name: 'Course Staff',
+ description: 'operating the course lifecycle in Studio, publishing content, handling scheduling, and managing high impact configuration for the course.',
+ },
+ {
+ role: 'course_editor',
+ permissions: [
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GLOBAL_STAFF_SUPER_ADMINS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_SCHEDULE,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_DETAILS,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_CONTENT,
+ CONTENT_COURSE_PERMISSIONS.REVIEW_COURSE_LIBRARY_UPDATES,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_UPDATES,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_PAGES_RESOURCES,
+ CONTENT_COURSE_PERMISSIONS.CREATE_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_GRADING_SETTINGS,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_GROUP_CONFIGURATION,
+ CONTENT_COURSE_PERMISSIONS.EDIT_COURSE_DETAILS,
+ CONTENT_COURSE_PERMISSIONS.MANAGE_COURSE_TAGS,
+ ],
+ userCount: 1,
+ name: 'Course Editor',
+ description: 'building and maintaining course content and supporting assets, without operational controls or high impact actions that can affect a live course.',
+ },
+ {
+ role: 'course_auditor',
+ permissions: [
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_UPDATES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_PAGES_RESOURCES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_FILES,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_GRADING_SETTINGS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_CHECKLISTS,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_TEAM,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_SCHEDULE,
+ CONTENT_COURSE_PERMISSIONS.VIEW_COURSE_DETAILS,
+ ],
+ userCount: 1,
+ name: 'Course Auditor',
+ description: ' QA, compliance review, content review, and general oversight, no changes in Studio.',
+ },
+
+];
+
export const DEFAULT_TOAST_DELAY = 5000;
export const RETRY_TOAST_DELAY = 120_000; // 2 minutes
export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({
diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss
index 34c408b4..83de1ed8 100644
--- a/src/authz-module/index.scss
+++ b/src/authz-module/index.scss
@@ -10,8 +10,8 @@
.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"]) {
+ .pgn__data-table tr:has(span[data-role="Super Admin"]),
+ .pgn__data-table tr:has(span[data-role="Global Staff"]) {
background-color: var(--pgn-color-primary-200);
}
@@ -21,11 +21,6 @@
width: 0;
}
- .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);
- }
-
.tab-content {
background-color: var(--pgn-color-light-200);
}
diff --git a/src/authz-module/libraries/constants.ts b/src/authz-module/libraries/constants.ts
new file mode 100644
index 00000000..921385a8
--- /dev/null
+++ b/src/authz-module/libraries/constants.ts
@@ -0,0 +1,177 @@
+import { PermissionMetadata, ResourceMetadata, RoleMetadata } from 'types';
+import {
+ Group, CollectionsBookmark, Notes, AutoAwesomeMosaic,
+ RemoveRedEye,
+ Settings,
+ DownloadDone,
+ Plus,
+ EditOutline,
+ Delete,
+ SpinnerIcon,
+ FileDownload,
+} 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',
+};
+
+// Note: this information will eventually come from the backend API
+// but for the MVP we decided to manage it in the frontend
+export const libraryRolesMetadata: RoleMetadata[] = [
+ { role: 'library_admin', name: 'Library Admin', description: 'The Library Admin has full control over the library, including managing users, modifying content, and handling publishing workflows. They ensure content is properly maintained and accessible as needed.' },
+ { role: 'library_author', name: 'Library Author', description: 'The Library Author is responsible for creating, editing, and publishing content within a library. They can manage tags and collections but cannot delete libraries or manage users.' },
+ { role: 'library_contributor', name: 'Library Contributor', description: 'The Library Contributor can create and edit content within a library but cannot publish it. They support the authoring process while leaving final publishing to Authors or Admins.' },
+ { role: 'library_user', name: 'Library User', description: 'The Library User can view and reuse content but cannot edit or delete any resource.' },
+];
+
+export const libraryResourceTypes: ResourceMetadata[] = [
+ {
+ key: 'library', label: 'Library', description: 'Permissions related to viewing, managing, and publishing the library structure and metadata.', icon: CollectionsBookmark,
+ },
+ {
+ key: 'library_content', label: 'Content', description: 'Permissions for creating, editing, deleting, and publishing content within the library.', icon: Notes,
+ },
+ {
+ key: 'library_team', label: 'Team', description: 'Permissions for viewing and managing users who have access to the library.', icon: Group,
+ },
+ {
+ key: 'library_collection', label: 'Collection', description: 'Permissions for creating and managing content collections within the library.', icon: AutoAwesomeMosaic,
+ },
+];
+
+export const libraryPermissions: PermissionMetadata[] = [
+ {
+ key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY, resource: 'library', label: 'View', description: 'View: See the library in Studio and access its content in read-only mode.', icon: RemoveRedEye,
+ },
+ {
+ key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TAGS, resource: 'library', label: 'Manage tag', description: 'Create, edit, and delete tags on this library.', icon: Settings,
+ },
+ {
+ key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY, resource: 'library', label: 'Publish', description: 'Publish the library to make it available for use in courses.', icon: DownloadDone,
+ },
+ {
+ key: CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_CONTENT, resource: 'library_content', label: 'Create', description: 'Create new content items in the library.', icon: Plus,
+ },
+ {
+ key: CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_CONTENT, resource: 'library_content', label: 'Edit', description: 'Edit existing content items in the library.', icon: EditOutline,
+ },
+ {
+ key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_CONTENT, resource: 'library_content', label: 'Delete', description: 'Permanently remove content items from the library.', icon: Delete,
+ },
+ {
+ key: CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT, resource: 'library_content', label: 'Publish', description: 'Publish individual content items to make them available for reuse in courses.', icon: DownloadDone,
+ },
+ {
+ key: CONTENT_LIBRARY_PERMISSIONS.REUSE_LIBRARY_CONTENT, resource: 'library_content', label: 'Reuse', description: 'Add published content from this library to a course.', icon: SpinnerIcon,
+ },
+ {
+ key: CONTENT_LIBRARY_PERMISSIONS.IMPORT_LIBRARY_CONTENT, resource: 'library_content', label: 'Import Content from Course', description: ' Import content from an existing course into this library.', icon: FileDownload,
+ },
+ {
+ key: CONTENT_LIBRARY_PERMISSIONS.VIEW_LIBRARY_TEAM, resource: 'library_team', label: 'View', description: 'See the list of users with a role assigned to this library.', icon: RemoveRedEye,
+ },
+ {
+ key: CONTENT_LIBRARY_PERMISSIONS.MANAGE_LIBRARY_TEAM, resource: 'library_team', label: 'Manage', description: 'Add, change, or remove role assignments for this library from the Roles and Permissions console.', icon: Settings,
+ },
+ {
+ key: CONTENT_LIBRARY_PERMISSIONS.CREATE_LIBRARY_COLLECTION, resource: 'library_collection', label: 'View', description: 'Create new collections to organize content within the library.', icon: RemoveRedEye,
+ },
+ {
+ key: CONTENT_LIBRARY_PERMISSIONS.EDIT_LIBRARY_COLLECTION, resource: 'library_collection', label: 'Publish', description: 'Update the name and contents of existing collections.', icon: EditOutline,
+ },
+ {
+ key: CONTENT_LIBRARY_PERMISSIONS.DELETE_LIBRARY_COLLECTION, resource: 'library_collection', label: 'Edit', description: 'Permanently remove collections from the library.', icon: EditOutline,
+ },
+];
+
+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,
+
+ ],
+ 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.',
+ },
+];
diff --git a/src/authz-module/roles-permissions/RolesPermissions.tsx b/src/authz-module/roles-permissions/RolesPermissions.tsx
index 6b7d07cf..588bb45c 100644
--- a/src/authz-module/roles-permissions/RolesPermissions.tsx
+++ b/src/authz-module/roles-permissions/RolesPermissions.tsx
@@ -7,13 +7,13 @@ import {
Container, Hyperlink,
} from '@openedx/paragon';
+import { coursePermissions, courseResourceTypes, rolesObject } from '@src/authz-module/constants';
import AnchorButton from '../components/AnchorButton';
import PermissionTable from '../components/PermissionTable';
import { buildPermissionMatrixByResource } from './library/utils';
import messages from './library/messages';
-import { coursePermissions, courseResourceTypes, rolesObject } from './course/constants';
import { rolesLibraryObject, libraryPermissions, libraryResourceTypes } from './library/constants';
const RolesPermissions = () => {
diff --git a/src/authz-module/roles-permissions/course/constants.ts b/src/authz-module/roles-permissions/course/constants.ts
index 8d3622aa..8e5d7535 100644
--- a/src/authz-module/roles-permissions/course/constants.ts
+++ b/src/authz-module/roles-permissions/course/constants.ts
@@ -441,13 +441,3 @@ export const courseRolesMetadata: RoleMetadata[] = [
role: 'course_auditor', name: 'Course Auditor', description: 'Can view course content and settings, but cannot make changes.', contextType: 'course', disabled: true,
},
];
-
-// TODO: check if we need this later
-// export const DEFAULT_TOAST_DELAY = 5000;
-// export const RETRY_TOAST_DELAY = 120_000; // 2 minutes
-// export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({
-// username: 'skeleton',
-// name: '',
-// email: '',
-// roles: [],
-// }));
diff --git a/src/authz-module/roles-permissions/library/constants.ts b/src/authz-module/roles-permissions/library/constants.ts
index 234cf045..5477b832 100644
--- a/src/authz-module/roles-permissions/library/constants.ts
+++ b/src/authz-module/roles-permissions/library/constants.ts
@@ -158,12 +158,3 @@ export const rolesLibraryObject = [
description: 'The Library User can view and reuse content but cannot edit or delete anything.',
},
];
-// TODO: check if we need this later
-// export const DEFAULT_TOAST_DELAY = 5000;
-// export const RETRY_TOAST_DELAY = 120_000; // 2 minutes
-// export const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({
-// username: 'skeleton',
-// name: '',
-// email: '',
-// roles: [],
-// }));
diff --git a/src/index.scss b/src/index.scss
index d46a89b5..6cf82699 100644
--- a/src/index.scss
+++ b/src/index.scss
@@ -1,3 +1,4 @@
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
-
+@use "@openedx/paragon/dist/core.min.css";
+@use "@openedx/paragon/dist/light.min.css";
@import "~@edx/frontend-component-header/dist/index";
diff --git a/src/types.ts b/src/types.ts
index c1778c0f..9554b8e7 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -53,6 +53,7 @@ export type PermissionMetadata = {
resource: string;
label?: string;
description?: string;
+ icon?: React.ComponentType>;
};
export type Org = {