feat(authz): [FC-0099] create LibrariesUserManager view to manage roles for a specific user (#6)
* refactor: use Link from react-router in Breadcrumb links * feat: create RoleCard component This is a reusable component, that display a card for each role with a collapsible showing the associated permissions. The permissions, are organized by resource and enable/disable. * feat: create the user management view * feat: integrate the LibrariesUserManagement view in the application * style: add ts doc to buildPermissionsByRoleMatrix function * feat: add loading state to the cards roles * style: nit space * fix: nit extra comment and validation * test: update fireEvent with useEvent
This commit is contained in:
@@ -1,7 +1,12 @@
|
|||||||
import React from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
import AuthZTitle, { AuthZTitleProps } from './AuthZTitle';
|
import AuthZTitle, { AuthZTitleProps } from './AuthZTitle';
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
Link: ({ children, to }:{ children:ReactNode, to:string }) => <a href={to}>{children}</a>,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('AuthZTitle', () => {
|
describe('AuthZTitle', () => {
|
||||||
const defaultProps: AuthZTitleProps = {
|
const defaultProps: AuthZTitleProps = {
|
||||||
activeLabel: 'Current Page',
|
activeLabel: 'Current Page',
|
||||||
@@ -24,8 +29,9 @@ describe('AuthZTitle', () => {
|
|||||||
|
|
||||||
render(<AuthZTitle {...defaultProps} navLinks={navLinks} />);
|
render(<AuthZTitle {...defaultProps} navLinks={navLinks} />);
|
||||||
|
|
||||||
navLinks.forEach(({ label }) => {
|
navLinks.forEach(({ label, to }) => {
|
||||||
expect(screen.getByText(label)).toBeInTheDocument();
|
expect(screen.getByText(label)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(label)).toHaveAttribute('href', expect.stringContaining(to));
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText(defaultProps.activeLabel)).toBeInTheDocument();
|
expect(screen.getByText(defaultProps.activeLabel)).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Breadcrumb, Col, Container, Row, Button, Badge,
|
Breadcrumb, Col, Container, Row, Button, Badge,
|
||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
@@ -26,6 +27,7 @@ const AuthZTitle = ({
|
|||||||
}: AuthZTitleProps) => (
|
}: AuthZTitleProps) => (
|
||||||
<Container className="p-5 bg-light-100">
|
<Container className="p-5 bg-light-100">
|
||||||
<Breadcrumb
|
<Breadcrumb
|
||||||
|
linkAs={Link}
|
||||||
links={navLinks}
|
links={navLinks}
|
||||||
activeLabel={activeLabel}
|
activeLabel={activeLabel}
|
||||||
/>
|
/>
|
||||||
|
|||||||
41
src/authz-module/components/RoleCard/PermissionsRow.tsx
Normal file
41
src/authz-module/components/RoleCard/PermissionsRow.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { ComponentType } from 'react';
|
||||||
|
import {
|
||||||
|
Chip, Col, Row,
|
||||||
|
} from '@openedx/paragon';
|
||||||
|
import { actionsDictionary, ActionKey } from './constants';
|
||||||
|
|
||||||
|
interface Action {
|
||||||
|
key: string;
|
||||||
|
label?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PermissionRowProps {
|
||||||
|
resourceLabel: string;
|
||||||
|
actions: Action[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PermissionRow = ({ resourceLabel, actions }: PermissionRowProps) => (
|
||||||
|
<Row className="row align-items-center border px-2 py-2">
|
||||||
|
<Col md={3}>
|
||||||
|
<span className="small font-weight-bold">{resourceLabel}</span>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<div className="w-100 d-flex flex-wrap">
|
||||||
|
{actions.map(action => (
|
||||||
|
<Chip
|
||||||
|
key={action.key}
|
||||||
|
iconBefore={actionsDictionary[action.key as ActionKey] as ComponentType}
|
||||||
|
disabled={action.disabled}
|
||||||
|
className="mr-4 my-2 px-3 bg-primary-100 border-0 permission-chip"
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default PermissionRow;
|
||||||
18
src/authz-module/components/RoleCard/constants.ts
Normal file
18
src/authz-module/components/RoleCard/constants.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import {
|
||||||
|
Add, Delete, DownloadDone, Edit, ManageAccounts, Sync, Tag, Visibility,
|
||||||
|
} from '@openedx/paragon/icons';
|
||||||
|
|
||||||
|
export const actionsDictionary = {
|
||||||
|
create: Add,
|
||||||
|
edit: Edit,
|
||||||
|
delete: Delete,
|
||||||
|
import: Sync,
|
||||||
|
publish: DownloadDone,
|
||||||
|
view: Visibility,
|
||||||
|
reuse: Sync,
|
||||||
|
tag: Tag,
|
||||||
|
team: ManageAccounts,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionKey = keyof typeof actionsDictionary;
|
||||||
|
export const actionKeys = Object.keys(actionsDictionary);
|
||||||
89
src/authz-module/components/RoleCard/index.test.tsx
Normal file
89
src/authz-module/components/RoleCard/index.test.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
import { renderWrapper } from '@src/setupTest';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import RoleCard from '.';
|
||||||
|
|
||||||
|
jest.mock('@openedx/paragon/icons', () => ({
|
||||||
|
Delete: () => <svg data-testid="delete-icon" />,
|
||||||
|
Person: () => <svg data-testid="person-icon" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./constants', () => ({
|
||||||
|
actionsDictionary: {
|
||||||
|
view: () => <svg data-testid="view-icon" />,
|
||||||
|
manage: () => <svg data-testid="manage-icon" />,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('RoleCard', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
title: 'Admin',
|
||||||
|
objectName: 'Test Library',
|
||||||
|
description: 'Can manage everything',
|
||||||
|
showDelete: true,
|
||||||
|
userCounter: 2,
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'library',
|
||||||
|
label: 'Library Resource',
|
||||||
|
actions: [
|
||||||
|
{ key: 'view', label: 'View' },
|
||||||
|
{ key: 'manage', label: 'Manage', disabled: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('renders all role card sections correctly', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWrapper(<RoleCard {...defaultProps} />);
|
||||||
|
|
||||||
|
// Title
|
||||||
|
expect(screen.getByText('Admin')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// User counter with icon
|
||||||
|
expect(screen.getByText('2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('person-icon')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Subtitle (object name)
|
||||||
|
expect(screen.getByText('Test Library')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Description
|
||||||
|
expect(screen.getByText('Can manage everything')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Delete button
|
||||||
|
expect(screen.getByRole('button', { name: /delete role action/i })).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Collapsible title
|
||||||
|
expect(screen.getByText('Permissions')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Permissions'));
|
||||||
|
|
||||||
|
// Resource label
|
||||||
|
expect(screen.getByText('Library Resource')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Action chips
|
||||||
|
expect(screen.getByText('View')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Manage')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Action icons
|
||||||
|
expect(screen.getByTestId('view-icon')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('manage-icon')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show delete button when showDelete is false', () => {
|
||||||
|
renderWrapper(<RoleCard {...defaultProps} showDelete={false} />);
|
||||||
|
expect(screen.queryByRole('button', { name: /delete role action/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles no userCounter gracefully', () => {
|
||||||
|
renderWrapper(<RoleCard {...defaultProps} userCounter={null} />);
|
||||||
|
expect(screen.queryByTestId('person-icon')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('2')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty permissions gracefully', () => {
|
||||||
|
renderWrapper(<RoleCard {...defaultProps} permissions={[]} />);
|
||||||
|
expect(screen.queryByText('Library Resource')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
68
src/authz-module/components/RoleCard/index.tsx
Normal file
68
src/authz-module/components/RoleCard/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
import {
|
||||||
|
Card, Collapsible, Container, Icon, IconButton,
|
||||||
|
} from '@openedx/paragon';
|
||||||
|
import { Delete, Person } from '@openedx/paragon/icons';
|
||||||
|
import PermissionRow from './PermissionsRow';
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
interface CardTitleProps {
|
||||||
|
title: string;
|
||||||
|
userCounter?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoleCardProps extends CardTitleProps {
|
||||||
|
objectName?: string | null;
|
||||||
|
description: string;
|
||||||
|
showDelete?: boolean;
|
||||||
|
permissions: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CardTitle = ({ title, userCounter }: CardTitleProps) => (
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<span className="mr-4 text-primary">{title}</span>
|
||||||
|
{userCounter !== null && (
|
||||||
|
<span className="d-flex align-items-center font-weight-normal">
|
||||||
|
<Icon src={Person} className="mr-1" />
|
||||||
|
{userCounter}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RoleCard = ({
|
||||||
|
title, objectName, description, showDelete, permissions, userCounter,
|
||||||
|
}: RoleCardProps) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="container-mw-lg mx-auto mb-4">
|
||||||
|
<Card.Header
|
||||||
|
title={<CardTitle title={title} userCounter={userCounter} />}
|
||||||
|
subtitle={(objectName && <span className="text-info-400 lead">{objectName}</span>) || ''}
|
||||||
|
actions={
|
||||||
|
showDelete && <IconButton variant="danger" alt="Delete role action" src={Delete} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Card.Section>
|
||||||
|
{description}
|
||||||
|
</Card.Section>
|
||||||
|
<Collapsible
|
||||||
|
title={intl.formatMessage(messages['authz.permissions.title'])}
|
||||||
|
>
|
||||||
|
<Container>
|
||||||
|
{permissions.map(({ key, label, actions }) => (
|
||||||
|
<PermissionRow
|
||||||
|
key={`${title}-${key}`}
|
||||||
|
resourceLabel={label}
|
||||||
|
actions={actions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
))}
|
||||||
|
</Container>
|
||||||
|
</Collapsible>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RoleCard;
|
||||||
51
src/authz-module/components/RoleCard/messages.ts
Normal file
51
src/authz-module/components/RoleCard/messages.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
'authz.permissions.title': {
|
||||||
|
id: 'authz.permissions.title',
|
||||||
|
defaultMessage: 'Permissions',
|
||||||
|
description: 'Title for the permissions section in the role card',
|
||||||
|
},
|
||||||
|
'authz.permissions.actions.create': {
|
||||||
|
id: 'authz.permissions.actions.create',
|
||||||
|
defaultMessage: 'Create {resource}',
|
||||||
|
description: 'Default label for the create action',
|
||||||
|
},
|
||||||
|
'authz.permissions.actions.edit': {
|
||||||
|
id: 'authz.permissions.actions.edit',
|
||||||
|
defaultMessage: 'Edit {resource}',
|
||||||
|
description: 'Default label for the edit action',
|
||||||
|
},
|
||||||
|
'authz.permissions.actions.import': {
|
||||||
|
id: 'authz.permissions.actions.import',
|
||||||
|
defaultMessage: 'Import {resource}',
|
||||||
|
description: 'Default label for the import action',
|
||||||
|
},
|
||||||
|
'authz.permissions.actions.delete': {
|
||||||
|
id: 'authz.permissions.actions.delete',
|
||||||
|
defaultMessage: 'Delete {resource}',
|
||||||
|
description: 'Default label for the delete action',
|
||||||
|
},
|
||||||
|
'authz.permissions.actions.manage': {
|
||||||
|
id: 'authz.permissions.actions.manage',
|
||||||
|
defaultMessage: 'Manage {resource}',
|
||||||
|
description: 'Default label for the manage action',
|
||||||
|
},
|
||||||
|
'authz.permissions.actions.publish': {
|
||||||
|
id: 'authz.permissions.actions.publish',
|
||||||
|
defaultMessage: 'Publish {resource}',
|
||||||
|
description: 'Default label for the publish action',
|
||||||
|
},
|
||||||
|
'authz.permissions.actions.view': {
|
||||||
|
id: 'authz.permissions.actions.view',
|
||||||
|
defaultMessage: 'View {resource}',
|
||||||
|
description: 'Default label for the view action',
|
||||||
|
},
|
||||||
|
'authz.permissions.actions.reuse': {
|
||||||
|
id: 'authz.permissions.actions.reuse',
|
||||||
|
defaultMessage: 'Reuse {resource}',
|
||||||
|
description: 'Default label for the reuse action',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default messages;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export const ROUTES = {
|
export const ROUTES = {
|
||||||
LIBRARIES_TEAM_PATH: '/libraries/:libraryId',
|
LIBRARIES_TEAM_PATH: '/libraries/:libraryId',
|
||||||
LIBRARIES_USER_PATH: '/libraries/user/:username',
|
LIBRARIES_USER_PATH: '/libraries/:libraryId/:username',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,4 +7,31 @@
|
|||||||
.tab-content {
|
.tab-content {
|
||||||
background-color: var(--pgn-color-light-200);
|
background-color: var(--pgn-color-light-200);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
.collapsible-card {
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
.collapsible-body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-trigger {
|
||||||
|
background-color: var(--pgn-color-info-100);
|
||||||
|
border: none;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
color: var(--pgn-color-primary-base);
|
||||||
|
padding: 1rem 2rem 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-chip {
|
||||||
|
.pgn__chip__label {
|
||||||
|
font-weight: var(--pgn-typography-font-weight-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: var(--pgn-size-icon-xs);
|
||||||
|
height: var(--pgn-size-icon-xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import { ComponentType, lazy } from 'react';
|
import { ComponentType, lazy } from 'react';
|
||||||
import { render, screen, waitFor } from '@testing-library/react';
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter, Outlet } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||||
import AuthZModule from './index';
|
import AuthZModule from './index';
|
||||||
|
|
||||||
// eslint-disable-next-line no-promise-executor-return
|
jest.mock('./libraries-manager', () => ({
|
||||||
jest.mock('./libraries-manager/LibrariesTeamManager', () => lazy(() => new Promise<{ default: ComponentType<any> }>(resolve => setTimeout(() => resolve({ default: () => <div data-testid="libraries-manager">Loaded</div> }), 100))));
|
// eslint-disable-next-line no-promise-executor-return
|
||||||
|
LibrariesLayout: lazy(() => new Promise<{ default: ComponentType<any> }>(resolve => setTimeout(() => resolve({ default: () => <div data-testid="layout"><Outlet /></div> }), 100))),
|
||||||
|
LibrariesTeamManager: () => <div data-testid="libraries-manager">Libraries Team Page</div>,
|
||||||
|
LibrariesUserManager: () => <div data-testid="libraries-user-manager">Libraries User Page</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
const createTestQueryClient = () => new QueryClient({
|
const createTestQueryClient = () => new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -16,6 +21,12 @@ const createTestQueryClient = () => new QueryClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('AuthZModule', () => {
|
describe('AuthZModule', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
initializeMockApp({
|
||||||
|
authenticatedUser: { username: 'testuser' },
|
||||||
|
});
|
||||||
|
});
|
||||||
it('renders LoadingPage then LibrariesTeamManager when route matches', async () => {
|
it('renders LoadingPage then LibrariesTeamManager when route matches', async () => {
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
const path = '/libraries/lib:123';
|
const path = '/libraries/lib:123';
|
||||||
@@ -34,4 +45,20 @@ describe('AuthZModule', () => {
|
|||||||
expect(screen.getByTestId('libraries-manager')).toBeInTheDocument();
|
expect(screen.getByTestId('libraries-manager')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders LoadingPage then LibrariesUserManager when user route matches', async () => {
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
|
const path = '/libraries/lib:123/testuser';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={[path]}>
|
||||||
|
<AuthZModule />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('libraries-user-manager')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Suspense } from 'react';
|
|||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import { ErrorBoundary } from '@edx/frontend-platform/react';
|
import { ErrorBoundary } from '@edx/frontend-platform/react';
|
||||||
import LoadingPage from '@src/components/LoadingPage';
|
import LoadingPage from '@src/components/LoadingPage';
|
||||||
import { LibrariesTeamManager } from './libraries-manager';
|
import { LibrariesTeamManager, LibrariesUserManager, LibrariesLayout } from './libraries-manager';
|
||||||
import { ROUTES } from './constants';
|
import { ROUTES } from './constants';
|
||||||
|
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
@@ -11,7 +11,10 @@ const AuthZModule = () => (
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Suspense fallback={<LoadingPage />}>
|
<Suspense fallback={<LoadingPage />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path={ROUTES.LIBRARIES_TEAM_PATH} element={<LibrariesTeamManager />} />
|
<Route element={<LibrariesLayout />}>
|
||||||
|
<Route path={ROUTES.LIBRARIES_TEAM_PATH} element={<LibrariesTeamManager />} />
|
||||||
|
<Route path={ROUTES.LIBRARIES_USER_PATH} element={<LibrariesUserManager />} />
|
||||||
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { Tab, Tabs } from '@openedx/paragon';
|
|||||||
import { useLibrary } from '@src/authz-module/data/hooks';
|
import { useLibrary } from '@src/authz-module/data/hooks';
|
||||||
import TeamTable from './components/TeamTable';
|
import TeamTable from './components/TeamTable';
|
||||||
import AuthZLayout from '../components/AuthZLayout';
|
import AuthZLayout from '../components/AuthZLayout';
|
||||||
import { LibraryAuthZProvider, useLibraryAuthZ } from './context';
|
import { useLibraryAuthZ } from './context';
|
||||||
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const LibrariesAuthZTeamView = () => {
|
const LibrariesTeamManager = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { libraryId } = useLibraryAuthZ();
|
const { libraryId } = useLibraryAuthZ();
|
||||||
const { data: library } = useLibrary(libraryId);
|
const { data: library } = useLibrary(libraryId);
|
||||||
@@ -42,10 +42,5 @@ const LibrariesAuthZTeamView = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const LibrariesTeamManager = () => (
|
|
||||||
<LibraryAuthZProvider>
|
|
||||||
<LibrariesAuthZTeamView />
|
|
||||||
</LibraryAuthZProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default LibrariesTeamManager;
|
export default LibrariesTeamManager;
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
import { renderWrapper } from '@src/setupTest';
|
||||||
|
import LibrariesUserManager from './LibrariesUserManager';
|
||||||
|
import { useLibraryAuthZ } from './context';
|
||||||
|
import { useLibrary, useTeamMembers } from '../data/hooks';
|
||||||
|
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useParams: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./context', () => ({
|
||||||
|
useLibraryAuthZ: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../data/hooks', () => ({
|
||||||
|
useLibrary: jest.fn(),
|
||||||
|
useTeamMembers: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('../components/RoleCard', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({ title, description }: { title: string, description: string }) => (
|
||||||
|
<div data-testid="role-card">
|
||||||
|
<div>{title}</div>
|
||||||
|
<div>{description}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('LibrariesUserManager', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Mock route params
|
||||||
|
(useParams as jest.Mock).mockReturnValue({ username: 'testuser' });
|
||||||
|
|
||||||
|
// Mock library authz context
|
||||||
|
(useLibraryAuthZ as jest.Mock).mockReturnValue({
|
||||||
|
libraryId: 'lib:123',
|
||||||
|
permissions: [{ key: 'view' }, { key: 'reuse' }],
|
||||||
|
roles: [
|
||||||
|
{
|
||||||
|
role: 'admin',
|
||||||
|
name: 'Admin',
|
||||||
|
description: 'Administrator Role',
|
||||||
|
permissions: ['view', 'reuse'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
{ key: 'library', label: 'Library', description: '' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock library data
|
||||||
|
(useLibrary as jest.Mock).mockReturnValue({
|
||||||
|
data: {
|
||||||
|
title: 'Test Library',
|
||||||
|
org: 'Test Org',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock team members
|
||||||
|
(useTeamMembers as jest.Mock).mockReturnValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
username: 'testuser',
|
||||||
|
email: 'testuser@example.com',
|
||||||
|
roles: ['admin'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the user roles correctly', () => {
|
||||||
|
renderWrapper(<LibrariesUserManager />);
|
||||||
|
|
||||||
|
// Breadcrumb check
|
||||||
|
expect(screen.getByText('Manage Access')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Library Team Management')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('listitem', { current: 'page' })).toHaveTextContent('testuser');
|
||||||
|
// Page title and subtitle
|
||||||
|
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('testuser');
|
||||||
|
expect(screen.getByRole('paragraph')).toHaveTextContent('testuser@example.com');
|
||||||
|
|
||||||
|
// RoleCard rendering
|
||||||
|
expect(screen.getByTestId('role-card')).toHaveTextContent('Admin');
|
||||||
|
expect(screen.getByTestId('role-card')).toHaveTextContent('Administrator Role');
|
||||||
|
});
|
||||||
|
});
|
||||||
65
src/authz-module/libraries-manager/LibrariesUserManager.tsx
Normal file
65
src/authz-module/libraries-manager/LibrariesUserManager.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
import { Container, Skeleton } from '@openedx/paragon';
|
||||||
|
import { ROUTES } from '@src/authz-module/constants';
|
||||||
|
import AuthZLayout from '../components/AuthZLayout';
|
||||||
|
import { useLibraryAuthZ } from './context';
|
||||||
|
import RoleCard from '../components/RoleCard';
|
||||||
|
import { useLibrary, useTeamMembers } from '../data/hooks';
|
||||||
|
import { buildPermissionsByRoleMatrix } from './utils';
|
||||||
|
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
|
const LibrariesUserManager = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { username } = useParams();
|
||||||
|
const {
|
||||||
|
libraryId, permissions, roles, resources,
|
||||||
|
} = useLibraryAuthZ();
|
||||||
|
const { data: library } = useLibrary(libraryId);
|
||||||
|
const rootBreadcrumb = intl.formatMessage(messages['library.authz.breadcrumb.root']) || '';
|
||||||
|
const pageManageTitle = intl.formatMessage(messages['library.authz.manage.page.title']);
|
||||||
|
|
||||||
|
const { data: teamMembers, isLoading } = useTeamMembers(libraryId);
|
||||||
|
const user = teamMembers?.find(member => member.username === username);
|
||||||
|
const userRoles = useMemo(() => {
|
||||||
|
const assignedRoles = roles.filter(role => user?.roles.includes(role.role))
|
||||||
|
.map(role => ({
|
||||||
|
...role,
|
||||||
|
permissions: buildPermissionsByRoleMatrix({
|
||||||
|
rolePermissions: role.permissions, permissions, resources, intl,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
return assignedRoles;
|
||||||
|
}, [roles, user?.roles, permissions, resources, intl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="authz-libraries">
|
||||||
|
<AuthZLayout
|
||||||
|
context={{ id: libraryId, title: library.title, org: library.org }}
|
||||||
|
navLinks={[{ label: rootBreadcrumb }, { label: pageManageTitle, to: `/authz/${ROUTES.LIBRARIES_TEAM_PATH.replace(':libraryId', libraryId)}` }]}
|
||||||
|
activeLabel={user?.username || ''}
|
||||||
|
pageTitle={user?.username || ''}
|
||||||
|
pageSubtitle={<p>{user?.email}</p>}
|
||||||
|
actions={[]}
|
||||||
|
>
|
||||||
|
<Container className="bg-light-200 p-5">
|
||||||
|
{isLoading ? <Skeleton count={2} height={200} /> : null}
|
||||||
|
{userRoles && userRoles.map(role => (
|
||||||
|
<RoleCard
|
||||||
|
key={`${role}-${username}`}
|
||||||
|
title={role.name}
|
||||||
|
objectName={library.title}
|
||||||
|
description={role.description}
|
||||||
|
showDelete
|
||||||
|
permissions={role.permissions as any[]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Container>
|
||||||
|
</AuthZLayout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LibrariesUserManager;
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { ROUTES } from '@src/authz-module/constants';
|
|
||||||
import { renderWrapper } from '@src/setupTest';
|
import { renderWrapper } from '@src/setupTest';
|
||||||
import { useTeamMembers } from '@src/authz-module/data/hooks';
|
import { useTeamMembers } from '@src/authz-module/data/hooks';
|
||||||
import TeamTable from './TeamTable';
|
import TeamTable from './TeamTable';
|
||||||
@@ -91,7 +90,7 @@ describe('TeamTable', () => {
|
|||||||
|
|
||||||
await userEvent.click(editButtons[0]);
|
await userEvent.click(editButtons[0]);
|
||||||
expect(mockNavigate).toHaveBeenCalledWith(
|
expect(mockNavigate).toHaveBeenCalledWith(
|
||||||
`/authz/${ROUTES.LIBRARIES_USER_PATH.replace(':username', 'bob')}`,
|
`/authz/libraries/${mockAuthZ.libraryId}/bob`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
import { Edit } from '@openedx/paragon/icons';
|
import { Edit } from '@openedx/paragon/icons';
|
||||||
import { TableCellValue, TeamMember } from '@src/types';
|
import { TableCellValue, TeamMember } from '@src/types';
|
||||||
import { ROUTES } from '@src/authz-module/constants';
|
|
||||||
import { useTeamMembers } from '@src/authz-module/data/hooks';
|
import { useTeamMembers } from '@src/authz-module/data/hooks';
|
||||||
import { useLibraryAuthZ } from '../context';
|
import { useLibraryAuthZ } from '../context';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
@@ -81,8 +80,7 @@ const TeamTable = () => {
|
|||||||
iconBefore={Edit}
|
iconBefore={Edit}
|
||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
size="sm"
|
||||||
// TODO: update the view with the team member view
|
onClick={() => navigate(`/authz/libraries/${libraryId}/${row.original.username}`)}
|
||||||
onClick={() => navigate(`/authz/${ROUTES.LIBRARIES_USER_PATH.replace(':username', row.original.username)}`)}
|
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages['authz.libraries.team.table.edit.action'])}
|
{intl.formatMessage(messages['authz.libraries.team.table.edit.action'])}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import LibrariesTeamManager from './LibrariesTeamManager';
|
|
||||||
|
|
||||||
export {
|
|
||||||
LibrariesTeamManager,
|
|
||||||
};
|
|
||||||
12
src/authz-module/libraries-manager/index.tsx
Normal file
12
src/authz-module/libraries-manager/index.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import LibrariesTeamManager from './LibrariesTeamManager';
|
||||||
|
import LibrariesUserManager from './LibrariesUserManager';
|
||||||
|
import { LibraryAuthZProvider } from './context';
|
||||||
|
|
||||||
|
const LibrariesLayout = () => (<LibraryAuthZProvider><Outlet /></LibraryAuthZProvider>);
|
||||||
|
|
||||||
|
export {
|
||||||
|
LibrariesLayout,
|
||||||
|
LibrariesTeamManager,
|
||||||
|
LibrariesUserManager,
|
||||||
|
};
|
||||||
23
src/authz-module/libraries-manager/utils.test.ts
Normal file
23
src/authz-module/libraries-manager/utils.test.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { buildPermissionsByRoleMatrix } from './utils';
|
||||||
|
|
||||||
|
describe('buildPermissionsByRoleMatrix', () => {
|
||||||
|
it('returns permissions matrix for given role', () => {
|
||||||
|
const rolePermissions = ['create_library'];
|
||||||
|
const permissions = [
|
||||||
|
{ key: 'create_library', resource: 'library', label: 'Create Library' },
|
||||||
|
{ key: 'edit_library', resource: 'library', label: 'Edit Library' },
|
||||||
|
];
|
||||||
|
const resources = [
|
||||||
|
{ key: 'library', label: 'Library', description: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const intl = { formatMessage: jest.fn((msg: any) => msg.defaultMessage) };
|
||||||
|
const matrix = buildPermissionsByRoleMatrix({
|
||||||
|
rolePermissions, permissions, resources, intl,
|
||||||
|
}) as Array<{ key: string; actions: Array<{ disabled: boolean }> }>;
|
||||||
|
expect(matrix[0].key).toBe('library');
|
||||||
|
expect(matrix[0].actions.length).toBe(2);
|
||||||
|
expect(matrix[0].actions[0].disabled).toBe(false);
|
||||||
|
expect(matrix[0].actions[1].disabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
45
src/authz-module/libraries-manager/utils.ts
Normal file
45
src/authz-module/libraries-manager/utils.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { actionKeys } from '@src/authz-module/components/RoleCard/constants';
|
||||||
|
import actionMessages from '../components/RoleCard/messages';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a permission matrix for a role.
|
||||||
|
*
|
||||||
|
* Builds a permission matrix grouped by resource, mapping each action to its display label
|
||||||
|
* and enabled/disabled state based on the role's allowed permissions.
|
||||||
|
*
|
||||||
|
* @param rolePermissions - Array of permission keys allowed for the current role.
|
||||||
|
* @param permissions - Permissions metadata.
|
||||||
|
* @param resources - Resources metadata.
|
||||||
|
* @param intl - the i18n function to enable label translations.
|
||||||
|
* @returns An array of permission groupings by resource with action-level details.
|
||||||
|
*/
|
||||||
|
const buildPermissionsByRoleMatrix = ({
|
||||||
|
rolePermissions, permissions, resources, intl,
|
||||||
|
}) => {
|
||||||
|
const permissionsMatrix = {};
|
||||||
|
const allowedPermissions = new Set(rolePermissions);
|
||||||
|
|
||||||
|
permissions.forEach((permission) => {
|
||||||
|
const resourceLabel = resources.find(r => r.key === permission.resource)?.label || permission.resource;
|
||||||
|
const actionKey = actionKeys.find(action => permission.key.includes(action));
|
||||||
|
let messageKey = `authz.permissions.actions.${actionKey}`;
|
||||||
|
let messageResource = '';
|
||||||
|
|
||||||
|
permissionsMatrix[permission.resource] = permissionsMatrix[permission.resource]
|
||||||
|
|| { key: permission.resource, label: resourceLabel, actions: [] };
|
||||||
|
|
||||||
|
if (actionKey === 'tag' || actionKey === 'team') {
|
||||||
|
messageKey = 'authz.permissions.actions.manage';
|
||||||
|
messageResource = actionKey === 'tag' ? 'Tags' : messageResource;
|
||||||
|
}
|
||||||
|
|
||||||
|
permissionsMatrix[permission.resource].actions.push({
|
||||||
|
key: actionKey,
|
||||||
|
label: permission.label || intl.formatMessage(actionMessages[messageKey], { resource: messageResource }),
|
||||||
|
disabled: !allowedPermissions.has(permission.key),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Object.values(permissionsMatrix);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { buildPermissionsByRoleMatrix };
|
||||||
Reference in New Issue
Block a user