diff --git a/jest.config.js b/jest.config.js index 5eb885d..5d3f71e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,6 +6,9 @@ module.exports = createConfig('jest', { setupFilesAfterEnv: [ '/src/setupTest.jsx', ], + moduleNameMapper: { + '^@src/(.*)$': '/src/$1', + }, coveragePathIgnorePatterns: [ 'src/setupTest.jsx', 'src/i18n', diff --git a/package-lock.json b/package-lock.json index d5a6a29..32b7331 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.6.4", "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18", "@types/react-dom": "^18" } @@ -8002,6 +8003,20 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tokens-studio/sd-transforms": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@tokens-studio/sd-transforms/-/sd-transforms-1.3.0.tgz", diff --git a/package.json b/package.json index f761ac6..e0e23aa 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "scripts": { "build": "fedx-scripts webpack", "i18n_extract": "fedx-scripts formatjs extract", - "lint": "fedx-scripts eslint --ext .js --ext .jsx .", - "lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .", + "lint": "fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .", + "lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx .", "snapshot": "fedx-scripts jest --updateSnapshot", "start": "fedx-scripts webpack-dev-server --progress", "start:with-theme": "paragon install-theme && npm start && npm install", @@ -37,10 +37,10 @@ }, "dependencies": { "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", - "@openedx/frontend-plugin-framework": "^1.7.0", "@edx/frontend-component-header": "^6.4.0", "@edx/frontend-platform": "^8.3.0", "@edx/openedx-atlas": "^0.7.0", + "@openedx/frontend-plugin-framework": "^1.7.0", "@openedx/paragon": "^23.4.5", "@tanstack/react-query": "5.89.0", "react": "^18.3.1", @@ -49,12 +49,13 @@ }, "devDependencies": { "@edx/browserslist-config": "^1.1.1", - "@openedx/frontend-build": "14.6.2", "@edx/typescript-config": "1.1.0", - "@types/react": "^18", - "@types/react-dom": "^18", + "@openedx/frontend-build": "14.6.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.6.4", - "@testing-library/react": "^16.3.0" + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^18", + "@types/react-dom": "^18" } } diff --git a/src/authz-module/components/AuthZLayout.tsx b/src/authz-module/components/AuthZLayout.tsx new file mode 100644 index 0000000..9845b90 --- /dev/null +++ b/src/authz-module/components/AuthZLayout.tsx @@ -0,0 +1,27 @@ +import { ReactNode } from 'react'; +import { StudioHeader } from '@edx/frontend-component-header'; +import AuthZTitle, { AuthZTitleProps } from './AuthZTitle'; + +interface AuthZLayoutProps extends AuthZTitleProps { + children: ReactNode; + context: { + id: string; + org: string; + title: string; + } +} + +const AuthZLayout = ({ children, context, ...props }: AuthZLayoutProps) => ( + <> + + + {children} + + +); + +export default AuthZLayout; diff --git a/src/authz-module/components/AuthZTitle.test.tsx b/src/authz-module/components/AuthZTitle.test.tsx new file mode 100644 index 0000000..460cf88 --- /dev/null +++ b/src/authz-module/components/AuthZTitle.test.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import AuthZTitle, { AuthZTitleProps } from './AuthZTitle'; + +describe('AuthZTitle', () => { + const defaultProps: AuthZTitleProps = { + activeLabel: 'Current Page', + pageTitle: 'Page Title', + pageSubtitle: 'Page Subtitle', + }; + + it('renders without optional fields', () => { + render(); + expect(screen.getByText(defaultProps.activeLabel)).toBeInTheDocument(); + expect(screen.getByText(defaultProps.pageTitle)).toBeInTheDocument(); + expect(screen.getByText(defaultProps.pageSubtitle as string)).toBeInTheDocument(); + }); + + it('renders breadcrumb with links and active label', () => { + const navLinks = [ + { label: 'Root', to: '/' }, + { label: 'Section', to: '/section' }, + ]; + + render(); + + navLinks.forEach(({ label }) => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + expect(screen.getByText(defaultProps.activeLabel)).toBeInTheDocument(); + }); + + it('renders page title', () => { + render(); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(defaultProps.pageTitle); + }); + + it('renders page subtitle as ReactNode', () => { + const subtitleNode =
Custom Subtitle
; + render(); + expect(screen.getByTestId('custom-subtitle')).toBeInTheDocument(); + }); + + it('renders action buttons and triggers onClick', () => { + const onClick1 = jest.fn(); + const onClick2 = jest.fn(); + const actions = [ + { label: 'Save', onClick: onClick1 }, + { label: 'Cancel', onClick: onClick2 }, + ]; + + render(); + + actions.forEach(({ label, onClick }) => { + const button = screen.getByRole('button', { name: label }); + expect(button).toBeInTheDocument(); + fireEvent.click(button); + expect(onClick).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/authz-module/components/AuthZTitle.tsx b/src/authz-module/components/AuthZTitle.tsx new file mode 100644 index 0000000..6224ea9 --- /dev/null +++ b/src/authz-module/components/AuthZTitle.tsx @@ -0,0 +1,50 @@ +import { ReactNode } from 'react'; +import { + Breadcrumb, Col, Container, Row, Button, Badge, +} from '@openedx/paragon'; + +interface BreadcrumbLink { + label: string; + to?: string; +} + +interface Action { + label: string; + onClick: () => void; +} + +export interface AuthZTitleProps { + activeLabel: string; + pageTitle: string; + pageSubtitle: string | ReactNode; + navLinks?: BreadcrumbLink[]; + actions?: Action[]; +} + +const AuthZTitle = ({ + activeLabel, navLinks = [], pageTitle, pageSubtitle, actions = [], +}: AuthZTitleProps) => ( + + + + +

{pageTitle}

+ {typeof pageSubtitle === 'string' + ?

{pageSubtitle}

+ : pageSubtitle} + + +
+ { + actions.map(({ label, onClick }) => ) + } +
+ +
+
+); + +export default AuthZTitle; diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts new file mode 100644 index 0000000..7864147 --- /dev/null +++ b/src/authz-module/constants.ts @@ -0,0 +1,4 @@ +export const ROUTES = { + LIBRARIES_TEAM_PATH: '/libraries/:libraryId', + LIBRARIES_USER_PATH: '/libraries/user/:username', +}; diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts new file mode 100644 index 0000000..7529c60 --- /dev/null +++ b/src/authz-module/data/api.ts @@ -0,0 +1,26 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { LibraryMetadata, TeamMember } from '@src/types'; +import { camelCaseObject } from '@edx/frontend-platform'; +import { getApiUrl, getStudioApiUrl } from '@src/data/utils'; + +export interface GetTeamMembersResponse { + members: TeamMember[]; + totalCount: number; +} + +// TODO: replece api path once is created +export const getTeamMembers = async (object: string): Promise => { + const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users?scope=${object}`)); + return camelCaseObject(data.results); +}; + +// TODO: this should be replaced in the future with Console API +export const getLibrary = async (libraryId: string): Promise => { + const { data } = await getAuthenticatedHttpClient().get(getStudioApiUrl(`/api/libraries/v2/${libraryId}/`)); + return { + id: data.id, + org: data.org, + title: data.title, + slug: data.slug, + }; +}; diff --git a/src/authz-module/data/hooks.test.tsx b/src/authz-module/data/hooks.test.tsx new file mode 100644 index 0000000..28aced0 --- /dev/null +++ b/src/authz-module/data/hooks.test.tsx @@ -0,0 +1,125 @@ +import { ReactNode } from 'react'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { useLibrary, useTeamMembers } from './hooks'; + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), +})); + +const mockMembers = [ + { + displayName: 'Alice', + username: 'user1', + email: 'alice@example.com', + roles: ['admin', 'author'], + }, + { + displayName: 'Bob', + username: 'user2', + email: 'bob@example.com', + roles: ['collaborator'], + }, +]; + +const mockLibrary = { + id: 'lib:123', + org: 'demo-org', + title: 'Test Library', + slug: 'test-library', +}; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + + ); + + return wrapper; +}; + +describe('useTeamMembers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns data when API call succeeds', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValue({ data: { results: mockMembers } }), + }); + + const { result } = renderHook(() => useTeamMembers('lib:123'), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(getAuthenticatedHttpClient).toHaveBeenCalled(); + expect(result.current.data).toEqual(mockMembers); + }); + + it('handles error when API call fails', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockRejectedValue(new Error('API failure')), + }); + + const { result } = renderHook(() => useTeamMembers('lib:123'), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(getAuthenticatedHttpClient).toHaveBeenCalled(); + expect(result.current.error).toBeDefined(); + expect(result.current.data).toBeUndefined(); + }); +}); + +describe('useLibrary', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns metadata on success', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValueOnce({ data: mockLibrary }), + }); + + const { result } = renderHook( + () => useLibrary('lib123'), + { wrapper: createWrapper() }, + ); + await waitFor(() => { + expect(result.current.data).toEqual(mockLibrary); + expect(getAuthenticatedHttpClient).toHaveBeenCalled(); + }); + }); + + it('throws on error', () => { + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockRejectedValue(new Error('Not found')), + }); + + const wrapper = createWrapper(); + try { + act(() => { + renderHook(() => useLibrary('lib123'), { wrapper }); + }); + } catch (e) { + expect(e).toEqual(new Error('Not found')); + } + + expect(getAuthenticatedHttpClient).toHaveBeenCalled(); + }); +}); diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts new file mode 100644 index 0000000..e374c09 --- /dev/null +++ b/src/authz-module/data/hooks.ts @@ -0,0 +1,42 @@ +import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import { appId } from '@src/constants'; +import { LibraryMetadata, TeamMember } from '@src/types'; +import { getLibrary, getTeamMembers } from './api'; + +const authzQueryKeys = { + all: [appId, 'authz'] as const, + teamMembers: (object: string) => [...authzQueryKeys.all, 'teamMembers', object] as const, + library: (libraryId: string) => [...authzQueryKeys.all, 'library', libraryId] as const, +}; + +/** + * React Query hook to fetch all team members for a specific object/scope. + * It retrieves the full list of members who have access to the given scope. + * + * @param object - The unique identifier of the object/scope + * + * @example + * ```tsx + * const { data: teamMembers, isLoading, isError } = useTeamMembers('lib:123'); + * ``` + */ +export const useTeamMembers = (object: string) => useQuery({ + queryKey: authzQueryKeys.teamMembers(object), + queryFn: () => getTeamMembers(object), + staleTime: 1000 * 60 * 30, // refetch after 30 minutes +}); + +/** + * React Query hook to retrieve the information of the current library. + * + * @param libraryId - The unique ID of the library. + * + * @example + * const { data } = useLibrary('lib:123',); + * + */ +export const useLibrary = (libraryId: string) => useSuspenseQuery({ + queryKey: authzQueryKeys.library(libraryId), + queryFn: () => getLibrary(libraryId), + retry: false, +}); diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss new file mode 100644 index 0000000..70ca529 --- /dev/null +++ b/src/authz-module/index.scss @@ -0,0 +1,10 @@ +.authz-libraries { + .pgn__breadcrumb li:first-child a { + color: var(--pgn-color-breadcrumb-active); + text-decoration: none; + } + + .tab-content { + background-color: var(--pgn-color-light-200); + } +} diff --git a/src/authz-module/index.test.tsx b/src/authz-module/index.test.tsx new file mode 100644 index 0000000..5b5ab02 --- /dev/null +++ b/src/authz-module/index.test.tsx @@ -0,0 +1,37 @@ +import { ComponentType, lazy } from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import AuthZModule from './index'; + +// eslint-disable-next-line no-promise-executor-return +jest.mock('./libraries-manager/LibrariesTeamManager', () => lazy(() => new Promise<{ default: ComponentType }>(resolve => setTimeout(() => resolve({ default: () =>
Loaded
}), 100)))); + +const createTestQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +describe('AuthZModule', () => { + it('renders LoadingPage then LibrariesTeamManager when route matches', async () => { + const queryClient = createTestQueryClient(); + const path = '/libraries/lib:123'; + + render( + + + + + , + ); + + expect(screen.getByTestId('loading-page')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByTestId('libraries-manager')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/authz-module/index.tsx b/src/authz-module/index.tsx index 0eacb4a..2812df3 100644 --- a/src/authz-module/index.tsx +++ b/src/authz-module/index.tsx @@ -1,5 +1,20 @@ +import { Suspense } from 'react'; +import { Routes, Route } from 'react-router-dom'; +import { ErrorBoundary } from '@edx/frontend-platform/react'; +import LoadingPage from '@src/components/LoadingPage'; +import { LibrariesTeamManager } from './libraries-manager'; +import { ROUTES } from './constants'; + +import './index.scss'; + const AuthZModule = () => ( -
AuthZ Module
+ + }> + + } /> + + + ); export default AuthZModule; diff --git a/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx new file mode 100644 index 0000000..ad3a8d9 --- /dev/null +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx @@ -0,0 +1,67 @@ +import { screen } from '@testing-library/react'; +import { renderWrapper } from '@src/setupTest'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { useLibrary } from '@src/authz-module/data/hooks'; +import { useLibraryAuthZ } from './context'; +import LibrariesTeamManager from './LibrariesTeamManager'; + +jest.mock('./context', () => { + const actual = jest.requireActual('./context'); + return { + ...actual, + useLibraryAuthZ: jest.fn(), + LibraryAuthZProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, + }; +}); +const mockedUseLibraryAuthZ = useLibraryAuthZ as jest.Mock; + +jest.mock('@src/authz-module/data/hooks', () => ({ + useLibrary: jest.fn(), +})); + +jest.mock('./components/TeamTable', () => ({ + __esModule: true, + default: () =>
MockTeamTable
, +})); + +describe('LibrariesTeamManager', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + username: 'admin', + }, + }); + mockedUseLibraryAuthZ.mockReturnValue({ + libraryId: 'lib-001', + libraryName: 'Mock Library', + libraryOrg: 'MockOrg', + username: 'mockuser', + roles: ['admin'], + permissions: [], + canManageTeam: true, + }); + + (useLibrary as jest.Mock).mockReturnValue({ + data: { + title: 'Test Library', + org: 'Test Org', + }, + }); + }); + + it('renders tabs and layout content correctly', () => { + renderWrapper(); + + // Tabs + expect(screen.getByRole('tab', { name: /Team Members/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Roles/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Permissions/i })).toBeInTheDocument(); + + // Breadcrumb/page title + expect(screen.getByText('Manage Access')).toBeInTheDocument(); // from intl.formatMessage + expect(screen.getByText('lib-001')).toBeInTheDocument(); // subtitle + + // TeamTable is rendered + expect(screen.getByTestId('team-table')).toBeInTheDocument(); + }); +}); diff --git a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx new file mode 100644 index 0000000..ae81a3e --- /dev/null +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx @@ -0,0 +1,51 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Tab, Tabs } from '@openedx/paragon'; +import { useLibrary } from '@src/authz-module/data/hooks'; +import TeamTable from './components/TeamTable'; +import AuthZLayout from '../components/AuthZLayout'; +import { LibraryAuthZProvider, useLibraryAuthZ } from './context'; + +import messages from './messages'; + +const LibrariesAuthZTeamView = () => { + const intl = useIntl(); + const { libraryId } = useLibraryAuthZ(); + const { data: library } = useLibrary(libraryId); + const rootBradecrumb = intl.formatMessage(messages['library.authz.breadcrumb.root']) || ''; + const pageTitle = intl.formatMessage(messages['library.authz.manage.page.title']); + return ( +
+ + + + + + + Role tab. + + + Permissions tab. + + + +
+ ); +}; +const LibrariesTeamManager = () => ( + + + +); + +export default LibrariesTeamManager; diff --git a/src/authz-module/libraries-manager/components/TeamTable.test.tsx b/src/authz-module/libraries-manager/components/TeamTable.test.tsx new file mode 100644 index 0000000..a802e22 --- /dev/null +++ b/src/authz-module/libraries-manager/components/TeamTable.test.tsx @@ -0,0 +1,124 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ROUTES } from '@src/authz-module/constants'; +import { renderWrapper } from '@src/setupTest'; +import { useTeamMembers } from '@src/authz-module/data/hooks'; +import TeamTable from './TeamTable'; +import { useLibraryAuthZ } from '../context'; + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +jest.mock('@src/authz-module/data/hooks', () => ({ + useTeamMembers: jest.fn(), +})); + +jest.mock('../context', () => ({ + useLibraryAuthZ: jest.fn(), +})); + +describe('TeamTable', () => { + const mockTeamMembers = [ + { + email: 'alice@example.com', + roles: ['Admin', 'Editor'], + username: 'alice', + }, + { + email: 'bob@example.com', + roles: ['Viewer'], + username: 'bob', + }, + ]; + + const mockAuthZ = { + libraryId: 'lib:123', + canManageTeam: true, + username: 'alice', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows skeletons while loading', () => { + (useTeamMembers as jest.Mock).mockReturnValue({ + data: null, + isLoading: true, + }); + (useLibraryAuthZ as jest.Mock).mockReturnValue(mockAuthZ); + + renderWrapper(); + + const skeletons = screen.getAllByText('', { selector: '[aria-busy="true"]' }); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it('renders team member data after loading', () => { + (useTeamMembers as jest.Mock).mockReturnValue({ + data: mockTeamMembers, + isLoading: false, + }); + (useLibraryAuthZ as jest.Mock).mockReturnValue(mockAuthZ); + + renderWrapper(); + + expect(screen.getByText('alice')).toBeInTheDocument(); + expect(screen.getByText('alice@example.com')).toBeInTheDocument(); + expect(screen.getByText('Admin')).toBeInTheDocument(); + expect(screen.getByText('Editor')).toBeInTheDocument(); + + expect(screen.getByText('bob')).toBeInTheDocument(); + expect(screen.getByText('bob@example.com')).toBeInTheDocument(); + expect(screen.getByText('Viewer')).toBeInTheDocument(); + }); + + it('renders Edit button only for users with than can manage team members (current user can not edit themselves)', async () => { + (useTeamMembers as jest.Mock).mockReturnValue({ + data: mockTeamMembers, + isLoading: false, + }); + (useLibraryAuthZ as jest.Mock).mockReturnValue(mockAuthZ); + + renderWrapper(); + + const editButtons = screen.queryAllByText('Edit'); + // Should not find Edit button for current user + expect(editButtons).toHaveLength(1); + + await userEvent.click(editButtons[0]); + expect(mockNavigate).toHaveBeenCalledWith( + `/authz/${ROUTES.LIBRARIES_USER_PATH.replace(':username', 'bob')}`, + ); + }); + + it('does not render Edit button if canManageTeam is false', () => { + (useTeamMembers as jest.Mock).mockReturnValue({ + data: mockTeamMembers, + isLoading: false, + }); + (useLibraryAuthZ as jest.Mock).mockReturnValue({ + ...mockAuthZ, + canManageTeam: false, + }); + + renderWrapper(); + + expect(screen.queryByText('Edit')).not.toBeInTheDocument(); + }); + + it('does not render Edit button while loading', () => { + (useTeamMembers as jest.Mock).mockReturnValue({ + data: null, + isLoading: true, + }); + (useLibraryAuthZ as jest.Mock).mockReturnValue(mockAuthZ); + + renderWrapper(); + + expect(screen.queryByText('Edit')).not.toBeInTheDocument(); + }); +}); diff --git a/src/authz-module/libraries-manager/components/TeamTable.tsx b/src/authz-module/libraries-manager/components/TeamTable.tsx new file mode 100644 index 0000000..cca1d3c --- /dev/null +++ b/src/authz-module/libraries-manager/components/TeamTable.tsx @@ -0,0 +1,118 @@ +import { useNavigate } from 'react-router-dom'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + DataTable, Button, Chip, Skeleton, +} from '@openedx/paragon'; +import { Edit } from '@openedx/paragon/icons'; +import { TableCellValue, TeamMember } from '@src/types'; +import { ROUTES } from '@src/authz-module/constants'; +import { useTeamMembers } from '@src/authz-module/data/hooks'; +import { useLibraryAuthZ } from '../context'; +import messages from './messages'; + +const SKELETON_ROWS = Array.from({ length: 10 }).map(() => ({ + username: 'skeleton', + name: '', + email: '', + roles: [], +})); + +type CellProps = TableCellValue; + +const EmailCell = ({ row }: CellProps) => (row.original?.username === SKELETON_ROWS[0].username ? ( + +) : ( + row.original.email +)); + +const NameCell = ({ row }: CellProps) => { + const intl = useIntl(); + const { username } = useLibraryAuthZ(); + + if (row.original.username === SKELETON_ROWS[0].username) { + return ; + } + + if (row.original.username === username) { + return ( + + {username} + {intl.formatMessage(messages['library.authz.team.table.username.current'])} + + ); + } + return row.original.username; +}; + +const RolesCell = ({ row }: CellProps) => (row.original.username === SKELETON_ROWS[0].username ? ( + +) : ( + row.original.roles.map((role) => ( + {role} + )) +)); + +const TeamTable = () => { + const intl = useIntl(); + const { libraryId, canManageTeam, username } = useLibraryAuthZ(); + + // TODO: Display error in the notification system + const { + data: teamMembers, isLoading, isError, + } = useTeamMembers(libraryId); + + const rows = isError ? [] : (teamMembers || SKELETON_ROWS); + + const navigate = useNavigate(); + + return ( + ( + canManageTeam && row.original.username !== username && !isLoading ? ( + + ) : null), + }, + ]} + initialState={{ + pageSize: 10, + }} + columns={ + [ + { + Header: intl.formatMessage(messages['library.authz.team.table.username']), + accessor: 'username', + Cell: NameCell, + }, + { + Header: intl.formatMessage(messages['library.authz.team.table.email']), + accessor: 'email', + Cell: EmailCell, + }, + { + Header: intl.formatMessage(messages['library.authz.team.table.roles']), + accessor: 'roles', + Cell: RolesCell, + }, + ] + } + /> + ); +}; + +export default TeamTable; diff --git a/src/authz-module/libraries-manager/components/messages.ts b/src/authz-module/libraries-manager/components/messages.ts new file mode 100644 index 0000000..709ad1e --- /dev/null +++ b/src/authz-module/libraries-manager/components/messages.ts @@ -0,0 +1,36 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + 'library.authz.team.table.username': { + id: 'library.authz.team.table.username', + defaultMessage: 'Username', + description: 'Libraries team management table username column header', + }, + 'library.authz.team.table.username.current': { + id: 'library.authz.team.table.username.current', + defaultMessage: ' (Me)', + description: 'Libraries team management table indicative of current user', + }, + 'library.authz.team.table.email': { + id: 'library.team.table.email', + defaultMessage: 'Email', + description: 'Libraries team management table email column header', + }, + 'library.authz.team.table.roles': { + id: 'library.authz.team.table.roles', + defaultMessage: 'Roles', + description: 'Libraries team management table roles column header', + }, + 'library.authz.team.table.action': { + id: 'library.authz.team.table.action', + defaultMessage: 'Action', + description: 'Libraries team management table action column header', + }, + 'authz.libraries.team.table.edit.action': { + id: 'authz.libraries.team.table.edit.action', + defaultMessage: 'Edit', + description: 'Edit action', + }, +}); + +export default messages; diff --git a/src/authz-module/libraries-manager/context.test.tsx b/src/authz-module/libraries-manager/context.test.tsx new file mode 100644 index 0000000..f133f41 --- /dev/null +++ b/src/authz-module/libraries-manager/context.test.tsx @@ -0,0 +1,108 @@ +import { screen } from '@testing-library/react'; +import { useParams } from 'react-router-dom'; +import { useValidateUserPermissions } from '@src/data/hooks'; +import { renderWrapper } from '@src/setupTest'; +import { LibraryAuthZProvider, useLibraryAuthZ } from './context'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn(), +})); + +jest.mock('@src/data/hooks', () => ({ + useValidateUserPermissions: jest.fn(), +})); + +const TestComponent = () => { + const context = useLibraryAuthZ(); + return ( +
+
{context.username}
+
{context.libraryId}
+
{context.canManageTeam ? 'true' : 'false'}
+
+ ); +}; + +describe('LibraryAuthZProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useParams as jest.Mock).mockReturnValue({ libraryId: 'lib123' }); + }); + + it('provides the correct context values to consumers', () => { + (useValidateUserPermissions as jest.Mock).mockReturnValue({ + data: [ + { allowed: true }, // canViewTeam + { allowed: true }, // canManageTeam + ], + }); + + renderWrapper( + + + , + ); + + expect(screen.getByTestId('username')).toHaveTextContent('testuser'); + expect(screen.getByTestId('libraryId')).toHaveTextContent('lib123'); + expect(screen.getByTestId('canManageTeam')).toHaveTextContent('true'); + }); + + it('throws error when user lacks both view and manage permissions', () => { + (useValidateUserPermissions as jest.Mock).mockReturnValue({ + data: [ + { allowed: false }, // canViewTeam + { allowed: false }, // canManageTeam + ], + }); + + expect(() => { + renderWrapper( + + + , + ); + }).toThrow('NoAccess'); + }); + + it('provides context when user can view but not manage team', () => { + (useValidateUserPermissions as jest.Mock).mockReturnValue({ + data: [ + { allowed: true }, // canViewTeam + { allowed: false }, // canManageTeam + ], + }); + + renderWrapper( + + + , + ); + + expect(screen.getByTestId('canManageTeam')).toHaveTextContent('false'); + }); + + it('throws error when libraryId is missing', () => { + (useParams as jest.Mock).mockReturnValue({}); // No libraryId + + expect(() => { + renderWrapper( + + + , + ); + }).toThrow('MissingLibrary'); + }); + + it('throws error when useLibraryAuthZ is used outside provider', () => { + const BrokenComponent = () => { + useLibraryAuthZ(); + return null; + }; + + expect(() => { + renderWrapper(); + }).toThrow('useLibraryAuthZ must be used within an LibraryAuthZProvider'); + }); +}); diff --git a/src/authz-module/libraries-manager/context.tsx b/src/authz-module/libraries-manager/context.tsx new file mode 100644 index 0000000..b013e6b --- /dev/null +++ b/src/authz-module/libraries-manager/context.tsx @@ -0,0 +1,69 @@ +import { + createContext, useContext, useMemo, ReactNode, +} from 'react'; +import { useParams } from 'react-router-dom'; +import { AppContext } from '@edx/frontend-platform/react'; +import { useValidateUserPermissions } from '@src/data/hooks'; + +const LIBRARY_TEAM_PERMISSIONS = ['act:view_library_team', 'act:manage_library_team']; + +export type AppContextType = { + authenticatedUser: { + username: string; + email: string; + }; +}; + +type LibraryAuthZContextType = { + canManageTeam: boolean; + username: string; + libraryId: string; + roles: string[]; + permissions: string[]; +}; + +const LibraryAuthZContext = createContext(undefined); + +type AuthZProviderProps = { + children: ReactNode; +}; + +export const LibraryAuthZProvider: React.FC = ({ children }:AuthZProviderProps) => { + const { libraryId } = useParams<{ libraryId: string }>(); + const { authenticatedUser } = useContext(AppContext) as AppContextType; + + // TODO: Implement a custom error view + if (!libraryId) { + throw new Error('MissingLibrary'); + } + const permissions = LIBRARY_TEAM_PERMISSIONS.map(action => ({ action, object: libraryId })); + + const { data: userPermissions } = useValidateUserPermissions(permissions); + const [{ allowed: canViewTeam }, { allowed: canManageTeam }] = userPermissions; + + if (!canViewTeam && !canManageTeam) { + throw new Error('NoAccess'); + } + + const value = useMemo((): LibraryAuthZContextType => ({ + username: authenticatedUser.username, + libraryId, + roles: [], + permissions: [], + canManageTeam, + }), [libraryId, authenticatedUser.username, canManageTeam]); + + return ( + + {children} + + ); +}; + +export const useLibraryAuthZ = (): LibraryAuthZContextType => { + const context = useContext(LibraryAuthZContext); + if (context === undefined) { + throw new Error('useLibraryAuthZ must be used within an LibraryAuthZProvider'); + } + return context; +}; diff --git a/src/authz-module/libraries-manager/index.ts b/src/authz-module/libraries-manager/index.ts new file mode 100644 index 0000000..b12092e --- /dev/null +++ b/src/authz-module/libraries-manager/index.ts @@ -0,0 +1,5 @@ +import LibrariesTeamManager from './LibrariesTeamManager'; + +export { + LibrariesTeamManager, +}; diff --git a/src/authz-module/libraries-manager/messages.ts b/src/authz-module/libraries-manager/messages.ts new file mode 100644 index 0000000..acba544 --- /dev/null +++ b/src/authz-module/libraries-manager/messages.ts @@ -0,0 +1,31 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + 'library.authz.manage.page.title': { + id: 'library.authz.manage.page.title', + defaultMessage: 'Library Team Management', + description: 'Libreries AuthZ page title', + }, + 'library.authz.breadcrumb.root': { + id: 'library.authz.breadcrumb.root', + defaultMessage: 'Manage Access', + description: 'Libreries AuthZ root breafcrumb', + }, + 'library.authz.tabs.team': { + id: 'library.authz.tabs.team', + defaultMessage: 'Team Members', + description: 'Libreries AuthZ title for the team management tab', + }, + 'library.authz.tabs.roles': { + id: 'library.authz.tabs.roles', + defaultMessage: 'Roles', + description: 'Libreries AuthZ title for the roles tab', + }, + 'library.authz.tabs.permissions': { + id: 'library.authz.tabs.permissions', + defaultMessage: 'Permissions', + description: 'Libreries AuthZ title for the permissions tab', + }, +}); + +export default messages; diff --git a/src/components/LoadingPage.tsx b/src/components/LoadingPage.tsx new file mode 100644 index 0000000..047546f --- /dev/null +++ b/src/components/LoadingPage.tsx @@ -0,0 +1,14 @@ +import { Spinner, Container } from '@openedx/paragon'; + +const LoadingPage = () => ( + + + +); + +export default LoadingPage; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..0d9b840 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1 @@ +export const appId = 'org.openedx.frontend.app.adminConsole'; diff --git a/src/data/api.ts b/src/data/api.ts new file mode 100644 index 0000000..b3d3953 --- /dev/null +++ b/src/data/api.ts @@ -0,0 +1,10 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { PermissionValidationRequest, PermissionValidationResponse } from '@src/types'; +import { getApiUrl } from './utils'; + +export const validateUserPermissions = async ( + validations: PermissionValidationRequest[], +): Promise => { + const { data } = await getAuthenticatedHttpClient().post(getApiUrl('/api/authz/v1/permissions/validate/me'), validations); + return data; +}; diff --git a/src/data/hooks.test.tsx b/src/data/hooks.test.tsx new file mode 100644 index 0000000..7dc20ec --- /dev/null +++ b/src/data/hooks.test.tsx @@ -0,0 +1,97 @@ +import { act, ReactNode } from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { useValidateUserPermissions } from './hooks'; + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), +})); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + return wrapper; +}; + +const permissions = [ + { + action: 'act:read', + object: 'lib:test-lib', + scope: 'org:OpenedX', + }, +]; + +const mockValidPermissions = [ + { action: 'act:read', object: 'lib:test-lib', allowed: true }, +]; + +const mockInvalidPermissions = [ + { action: 'act:read', object: 'lib:test-lib', allowed: false }, +]; + +describe('useValidateUserPermissions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns allowed true when permissions are valid', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + post: jest.fn().mockResolvedValueOnce({ data: mockValidPermissions }), + }); + + const { result } = renderHook(() => useValidateUserPermissions(permissions), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current).toBeDefined()); + + expect(getAuthenticatedHttpClient).toHaveBeenCalled(); + expect(result.current.data[0].allowed).toBe(true); + }); + + it('returns allowed false when permissions are invalid', async () => { + getAuthenticatedHttpClient.mockReturnValue({ + post: jest.fn().mockResolvedValue({ data: mockInvalidPermissions }), + }); + + const { result } = renderHook(() => useValidateUserPermissions(permissions), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current).toBeDefined()); + + expect(getAuthenticatedHttpClient).toHaveBeenCalled(); + expect(result.current.data[0].allowed).toBe(false); + }); + + it('handles error when the API call fails', async () => { + const mockError = new Error('API Error'); + + getAuthenticatedHttpClient.mockReturnValue({ + post: jest.fn().mockRejectedValue(new Error('API Error')), + }); + + try { + act(() => { + renderHook(() => useValidateUserPermissions(permissions), { + wrapper: createWrapper(), + }); + }); + } catch (error) { + expect(error).toEqual(mockError); // Check for the expected error + } + }); +}); diff --git a/src/data/hooks.ts b/src/data/hooks.ts new file mode 100644 index 0000000..80d4154 --- /dev/null +++ b/src/data/hooks.ts @@ -0,0 +1,34 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; +import { PermissionValidationRequest, PermissionValidationResponse } from '@src/types'; +import { appId } from '@src/constants'; +import { validateUserPermissions } from './api'; + +const adminConsoleQueryKeys = { + all: [appId] as const, + permissions: (permissions: PermissionValidationRequest[]) => [...adminConsoleQueryKeys.all, 'validatePermissions', permissions] as const, +}; + +/** + * React Query hook to validate if the current user has permissions over a certain object in the instance. + * It helps to: + * - Determine whether the current user can access certain object. + * - Provide role-based rendering logic for UI components. + * + * @param permissions - The array of objects and actions to validate. + * + * @example + * const { data } = useValidateUserPermissions([{ + "action": "act:read", + "object": "lib:test-lib", + "scope": "org:OpenedX" + }]); + * if (data[0].allowed) { ... } + * + */ +export const useValidateUserPermissions = ( + permissions: PermissionValidationRequest[], +) => useSuspenseQuery({ + queryKey: adminConsoleQueryKeys.permissions(permissions), + queryFn: () => validateUserPermissions(permissions), + retry: false, +}); diff --git a/src/data/utils.ts b/src/data/utils.ts new file mode 100644 index 0000000..8676ba1 --- /dev/null +++ b/src/data/utils.ts @@ -0,0 +1,4 @@ +import { getConfig } from '@edx/frontend-platform'; + +export const getApiUrl = (path: string) => `${getConfig().LMS_BASE_URL}${path || ''}`; +export const getStudioApiUrl = (path: string) => `${getConfig().STUDIO_BASE_URL}${path || ''}`; diff --git a/src/index.tsx b/src/index.tsx index 6170656..0408594 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,11 +6,11 @@ import { AppProvider, ErrorPage } from '@edx/frontend-platform/react'; import { APP_INIT_ERROR, APP_READY, subscribe, initialize, } from '@edx/frontend-platform'; +import AuthZModule from 'authz-module'; import messages from './i18n'; import './index.scss'; -import AuthZModule from 'authz-module'; const queryClient = new QueryClient(); @@ -24,7 +24,7 @@ subscribe(APP_READY, () => { } /> - + , ); diff --git a/src/setupTest.jsx b/src/setupTest.jsx index 3da246c..a944cc8 100644 --- a/src/setupTest.jsx +++ b/src/setupTest.jsx @@ -1,10 +1,36 @@ /* eslint-disable import/no-extraneous-dependencies */ import '@testing-library/jest-dom'; import { render } from '@testing-library/react'; -import { AppProvider } from '@edx/frontend-platform/react'; +import { BrowserRouter } from 'react-router-dom'; +import { AppContext } from '@edx/frontend-platform/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +const mockAppContext = { + authenticatedUser: { + username: 'testuser', + email: 'testuser@example.com', + }, + config: { + ...process.env, + }, +}; export const renderWrapper = (children) => render( - - {children} - , + + + + {children} + + + , ); + +class ResizeObserver { + observe() {} + + unobserve() {} + + disconnect() {} +} + +global.ResizeObserver = ResizeObserver; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..6ce5b7f --- /dev/null +++ b/src/types.ts @@ -0,0 +1,30 @@ +export interface PermissionValidationRequest { + action: string; + object?: string; + scope?: string; +} + +export interface PermissionValidationResponse extends PermissionValidationRequest { + allowed: boolean; +} + +// Libraries AuthZ types +export interface TeamMember { + username: string; + email: string; + roles: string[]; +} + +export interface LibraryMetadata { + id: string; + org: string; + title: string; + slug: string; +} + +// Paragon table type +export interface TableCellValue { + row: { + original: T; + }; +}