From 4fbd9750f761ea284b7b741cb44bd081ae800413 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Wed, 24 Sep 2025 20:56:34 +1000 Subject: [PATCH 01/17] test: add testing-library user event , utils and update path resolver --- jest.config.js | 3 +++ package-lock.json | 15 +++++++++++++++ package.json | 11 ++++++----- src/setupTest.jsx | 34 ++++++++++++++++++++++++++++++---- 4 files changed, 54 insertions(+), 9 deletions(-) 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..5223eb4 100644 --- a/package.json +++ b/package.json @@ -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/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; From 75bd0d7ebb69e800eae71ff64301492b10c4f9ba Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Wed, 24 Sep 2025 21:57:55 +1000 Subject: [PATCH 02/17] feat: create a loading page and the AuthZModule --- src/authz-module/constants.ts | 25 +++++++++++++++++++ src/authz-module/index.test.tsx | 43 +++++++++++++++++++++++++++++++++ src/authz-module/index.tsx | 17 ++++++++++++- src/components/LoadingPage.tsx | 16 ++++++++++++ 4 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 src/authz-module/constants.ts create mode 100644 src/authz-module/index.test.tsx create mode 100644 src/components/LoadingPage.tsx diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts new file mode 100644 index 0000000..2da6c73 --- /dev/null +++ b/src/authz-module/constants.ts @@ -0,0 +1,25 @@ +export interface TeamMember { + displayName: string; + username: string; + email: string; + roles: string[]; +} + +export interface LibraryMetadata { + id: string; + org: string; + title: string; + slug: string; +} + +export interface TableCellValue { + row: { + original: T; + }; +} + + +export const ROUTES = { + LIBRARIES_TEAM_PATH: '/libraries/:libraryId', + LIBRARIES_USER_PATH: '/libraries/user/:username' +}; diff --git a/src/authz-module/index.test.tsx b/src/authz-module/index.test.tsx new file mode 100644 index 0000000..dcc018b --- /dev/null +++ b/src/authz-module/index.test.tsx @@ -0,0 +1,43 @@ +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'; + +jest.mock('./libraries-manager/LibrariesAuthZManager', () => { + return 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 LibrariesAuthZManager 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..b42e041 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 LibrariesAuthZManager from './libraries-manager/LibrariesAuthZManager'; +import { ROUTES } from './constants'; + +import './index.scss'; + const AuthZModule = () => ( -
AuthZ Module
+ + }> + + } /> + + + ); export default AuthZModule; diff --git a/src/components/LoadingPage.tsx b/src/components/LoadingPage.tsx new file mode 100644 index 0000000..bba9389 --- /dev/null +++ b/src/components/LoadingPage.tsx @@ -0,0 +1,16 @@ +import { Spinner, Container } from '@openedx/paragon'; + +const LoadingPage= () => { + return ( + + + + ); +}; + +export default LoadingPage; From 24806b7169965b1bd756eab53b1ce04cb4a1dcaf Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Wed, 24 Sep 2025 22:58:56 +1000 Subject: [PATCH 03/17] feat: create app utilities for the API url resolution and permission validation --- .../useValidateUserPermissions.test.tsx | 99 +++++++++++++++++++ src/helpers/useValidateUserPermissions.ts | 44 +++++++++ src/helpers/utils.ts | 4 + 3 files changed, 147 insertions(+) create mode 100644 src/helpers/useValidateUserPermissions.test.tsx create mode 100644 src/helpers/useValidateUserPermissions.ts create mode 100644 src/helpers/utils.ts diff --git a/src/helpers/useValidateUserPermissions.test.tsx b/src/helpers/useValidateUserPermissions.test.tsx new file mode 100644 index 0000000..c063c9b --- /dev/null +++ b/src/helpers/useValidateUserPermissions.test.tsx @@ -0,0 +1,99 @@ +import { act, ReactNode } from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useValidateUserPermissions } from './useValidateUserPermissions'; + +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 + return; + } + }); +}); diff --git a/src/helpers/useValidateUserPermissions.ts b/src/helpers/useValidateUserPermissions.ts new file mode 100644 index 0000000..9a09604 --- /dev/null +++ b/src/helpers/useValidateUserPermissions.ts @@ -0,0 +1,44 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { getApiUrl } from './utils'; + +export interface PermissionValidationRequest { + action: string; + object?: string; + scope?: string; +} + +export interface PermissionValidationResponse extends PermissionValidationRequest{ + allowed: boolean; +} + +const validateUserPermissions = async (validations: PermissionValidationRequest[]): Promise => { + const { data } = await getAuthenticatedHttpClient().post(getApiUrl(`/api/authz/v1/permissions/validate/me`), validations); + return data; +}; + + +/** + * 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 } = useValidateTeamMember([{ + "action": "act:read", + "object": "lib:test-lib", + "scope": "org:OpenedX" + }]); + * if (data[0].allowed) { ... } + * + */ +export const useValidateUserPermissions = (permissions: PermissionValidationRequest[]) => { + return useSuspenseQuery({ + queryKey: ['validate-user-permissions', permissions], + queryFn: () => validateUserPermissions(permissions), + retry: false, + }); +} \ No newline at end of file diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts new file mode 100644 index 0000000..8676ba1 --- /dev/null +++ b/src/helpers/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 || ''}`; From 62936595aee5b3e40d0c0e68312cc338f1b9bac4 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Wed, 24 Sep 2025 22:59:34 +1000 Subject: [PATCH 04/17] feat: create layaut module components --- src/authz-module/components/AuthZLayout.tsx | 27 ++++++++ .../components/AuthZTitle.test.tsx | 62 +++++++++++++++++++ src/authz-module/components/AuthZTitle.tsx | 50 +++++++++++++++ src/authz-module/index.scss | 6 ++ 4 files changed, 145 insertions(+) create mode 100644 src/authz-module/components/AuthZLayout.tsx create mode 100644 src/authz-module/components/AuthZTitle.test.tsx create mode 100644 src/authz-module/components/AuthZTitle.tsx create mode 100644 src/authz-module/index.scss 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/index.scss b/src/authz-module/index.scss new file mode 100644 index 0000000..4aada25 --- /dev/null +++ b/src/authz-module/index.scss @@ -0,0 +1,6 @@ +.authz-libraries { + .pgn__breadcrumb li:first-child a { + color: var(--pgn-color-breadcrumb-active); + text-decoration: none; + } +} From 7390387835afde44505e8cfa8e67348565a74b6d Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Wed, 24 Sep 2025 23:00:22 +1000 Subject: [PATCH 05/17] feat: create the libraries context --- .../libraries-manager/context.test.tsx | 125 ++++++++++++++++++ .../libraries-manager/context.tsx | 76 +++++++++++ .../libraries-manager/data/api.ts | 26 ++++ .../libraries-manager/data/hooks.test.tsx | 115 ++++++++++++++++ .../libraries-manager/data/hooks.ts | 37 ++++++ 5 files changed, 379 insertions(+) create mode 100644 src/authz-module/libraries-manager/context.test.tsx create mode 100644 src/authz-module/libraries-manager/context.tsx create mode 100644 src/authz-module/libraries-manager/data/api.ts create mode 100644 src/authz-module/libraries-manager/data/hooks.test.tsx create mode 100644 src/authz-module/libraries-manager/data/hooks.ts 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..6bfc719 --- /dev/null +++ b/src/authz-module/libraries-manager/context.test.tsx @@ -0,0 +1,125 @@ +import { screen } from '@testing-library/react'; +import { useParams } from 'react-router-dom'; +import { useValidateUserPermissions } from '@src/helpers/useValidateUserPermissions'; +import { renderWrapper } from '@src/setupTest'; +import { useLibrary } from './data/hooks'; +import { LibraryAuthZProvider, useLibraryAuthZ } from './context'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn(), +})); + +jest.mock('@src/helpers/useValidateUserPermissions', () => ({ + useValidateUserPermissions: jest.fn(), +})); + +jest.mock('./data/hooks', () => ({ + useLibrary: jest.fn(), +})); + +const TestComponent = () => { + const context = useLibraryAuthZ(); + return ( +
+
{context.username}
+
{context.libraryId}
+
{context.canManageTeam ? 'true' : 'false'}
+
{context.libraryName}
+
{context.libraryOrg}
+
+ ); +}; + +describe('LibraryAuthZProvider', () => { + + beforeEach(() => { + jest.clearAllMocks(); + (useParams as jest.Mock).mockReturnValue({ libraryId: 'lib123' }); + + (useLibrary as jest.Mock).mockReturnValue({ + data: { + title: 'Test Library', + org: 'Test Org', + }, + }); + }); + + 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'); + expect(screen.getByTestId('libraryName')).toHaveTextContent('Test Library'); + expect(screen.getByTestId('libraryOrg')).toHaveTextContent('Test Org'); + }); + + 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..9fada0d --- /dev/null +++ b/src/authz-module/libraries-manager/context.tsx @@ -0,0 +1,76 @@ +import { + createContext, useContext, useMemo, ReactNode, +} from 'react'; +import { useParams } from 'react-router-dom'; +import { AppContext } from '@edx/frontend-platform/react'; +import { useValidateUserPermissions } from '@src/helpers/useValidateUserPermissions'; +import { useLibrary } from './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[]; + libraryName: string; + libraryOrg: string; +}; + +const LibraryAuthZContext = createContext(undefined); + +type AuthZProviderProps = { + children: ReactNode; +}; + +export const LibraryAuthZProvider: React.FC = ({ children }) => { + 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 { data: libraryMetadata } = useLibrary(libraryId); + + const value = useMemo((): LibraryAuthZContextType => ({ + username: authenticatedUser.username, + libraryId, + libraryName: libraryMetadata.title, + libraryOrg: libraryMetadata.org, + 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/data/api.ts b/src/authz-module/libraries-manager/data/api.ts new file mode 100644 index 0000000..b0fd811 --- /dev/null +++ b/src/authz-module/libraries-manager/data/api.ts @@ -0,0 +1,26 @@ +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { LibraryMetadata, TeamMember } from '@src/authz-module/constants'; +import { getApiUrl, getStudioApiUrl } from '@src/helpers/utils'; + +export interface GetTeamMembersResponse { + members: TeamMember[]; + totalCount: number; +} + +// TODO: replece api path once is created +export const getTeamMembers = async (libraryId: string): Promise => { + const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users?scope=${libraryId}`)); + return 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/libraries-manager/data/hooks.test.tsx b/src/authz-module/libraries-manager/data/hooks.test.tsx new file mode 100644 index 0000000..a5b7734 --- /dev/null +++ b/src/authz-module/libraries-manager/data/hooks.test.tsx @@ -0,0 +1,115 @@ +import { ReactNode } from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useLibrary, useTeamMembers } from './hooks'; +import * as api from './api'; + +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 () => { + jest.spyOn(api, 'getTeamMembers').mockResolvedValue(mockMembers); + + const { result } = renderHook(() => useTeamMembers('lib:123'), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(api.getTeamMembers).toHaveBeenCalledWith('lib:123'); + expect(result.current.data).toEqual(mockMembers); + }); + + it('handles error when API call fails', async () => { + jest + .spyOn(api, 'getTeamMembers') + .mockRejectedValue(new Error('API failure')); + + const { result } = renderHook(() => useTeamMembers('lib:123'), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expect(api.getTeamMembers).toHaveBeenCalledWith('lib:123'); + expect(result.current.error).toBeDefined(); + expect(result.current.data).toBeUndefined(); + }); +}); + +describe('useLibrary', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns metadata on success', async () => { + jest.spyOn(api, 'getLibrary').mockResolvedValue(mockLibrary); + + const { result } = renderHook( + () => useLibrary('lib123'), + { wrapper: createWrapper() }, + ); + await waitFor(() => { + expect(result.current.data).toEqual(mockLibrary); + expect(api.getLibrary).toHaveBeenCalledWith('lib123'); + }); + }); + + it('throws on error', () => { + jest + .spyOn(api, 'getLibrary') + .mockRejectedValue(new Error('Not found')); + + const wrapper = createWrapper(); + try { + renderHook(() => useLibrary('lib123'), { wrapper }); + } catch (e) { + expect(e).toEqual(new Error('Not found')); + } + + expect(api.getLibrary).toHaveBeenCalledWith('lib123'); + }); +}); diff --git a/src/authz-module/libraries-manager/data/hooks.ts b/src/authz-module/libraries-manager/data/hooks.ts new file mode 100644 index 0000000..9289b63 --- /dev/null +++ b/src/authz-module/libraries-manager/data/hooks.ts @@ -0,0 +1,37 @@ +import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import { LibraryMetadata, TeamMember } from '@src/authz-module/constants'; +import { getLibrary, getTeamMembers } from './api'; + +/** + * React Query hook to fetch all team members for a specific library. + * It retrieves the full list of members who have access to the given library. + * + * @param libraryId - The unique identifier of the library + * + * @example + * ```tsx + * const { data: teamMembers, isLoading, isError } = useTeamMembers('lib:123'); + * ``` + */ +export const useTeamMembers = (libraryId: string) => useQuery({ + queryKey: ['team-members', libraryId], + queryFn: () => getTeamMembers(libraryId), + staleTime: 1000 * 60 * 30, // refetch after 30 minutes +}); + +/** + * React Query hook to retrive the inforation of the current library. + * + * @param libraryId - The unique ID of the library. + * + * @example + * const { data, isLoading, isError } = useLibrary('lib:123',); + * + */ +export function useLibrary(libraryId: string) { + return useSuspenseQuery({ + queryKey: ['library-metadata', libraryId], + queryFn: () => getLibrary(libraryId), + retry: false, + }); +} From cb49cfebb675be4a86d50eb4d261ceaad6d7f31f Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Wed, 24 Sep 2025 23:00:59 +1000 Subject: [PATCH 06/17] feat: create the table team management table --- .../components/TeamTable.test.tsx | 126 ++++++++++++++++++ .../components/TeamTable.tsx | 105 +++++++++++++++ .../libraries-manager/components/messages.ts | 31 +++++ 3 files changed, 262 insertions(+) create mode 100644 src/authz-module/libraries-manager/components/TeamTable.test.tsx create mode 100644 src/authz-module/libraries-manager/components/TeamTable.tsx create mode 100644 src/authz-module/libraries-manager/components/messages.ts 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..d9cf678 --- /dev/null +++ b/src/authz-module/libraries-manager/components/TeamTable.test.tsx @@ -0,0 +1,126 @@ +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 TeamTable from './TeamTable'; +import { useTeamMembers } from '../data/hooks'; +import { useLibraryAuthZ } from '../context'; + +const mockNavigate = jest.fn(); +jest.mock('react-router', () => ({ + ...jest.requireActual('react-router'), + useNavigate: () => mockNavigate, +})); + +jest.mock('../data/hooks', () => ({ + useTeamMembers: jest.fn(), +})); + +jest.mock('../context', () => ({ + useLibraryAuthZ: jest.fn(), +})); + +describe('TeamTable', () => { + const mockTeamMembers = [ + { + displayName: 'Alice', + email: 'alice@example.com', + roles: ['Admin', 'Editor'], + username: 'alice', + }, + { + displayName: 'Bob', + 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', 'alice')}`, + ); + }); + + 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..48a7dad --- /dev/null +++ b/src/authz-module/libraries-manager/components/TeamTable.tsx @@ -0,0 +1,105 @@ +import { useMemo } from 'react'; +import { useNavigate } from 'react-router'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + DataTable, Button, Chip, Skeleton, +} from '@openedx/paragon'; +import { Edit } from '@openedx/paragon/icons'; +import { ROUTES, TableCellValue, TeamMember } from '@src/authz-module/constants'; +import { useTeamMembers } from '../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) => (row.original.username === SKELETON_ROWS[0].username ? ( + +) : ( + row.original.displayName +)); + +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(); + + const columns = useMemo(() => [ + { + Header: intl.formatMessage(messages['library.authz.team.table.display.name']), + accessor: 'displayName', + 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, + }, + ], [isLoading]); + + return ( + ( + canManageTeam && row.original.username !== username && !isLoading ? ( + + ) : null), + }, + ]} + initialState={{ + pageSize: 10, + }} + columns={columns} + /> + ); +}; + +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..70181e4 --- /dev/null +++ b/src/authz-module/libraries-manager/components/messages.ts @@ -0,0 +1,31 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + 'library.authz.team.table.display.name': { + id: 'library.authz.team.table.display.name', + defaultMessage: 'Name', + description: 'Libraries team management table name column header', + }, + '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; From 6c67dc0a753fb0b7fbc50d5f9eb48fda6a71fe2f Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Wed, 24 Sep 2025 23:02:12 +1000 Subject: [PATCH 07/17] feat: add the libraries authz management view --- .../LibrariesAuthZManager.test.tsx | 55 +++++++++++++++++++ .../LibrariesAuthZManager.tsx | 49 +++++++++++++++++ .../libraries-manager/messages.ts | 31 +++++++++++ 3 files changed, 135 insertions(+) create mode 100644 src/authz-module/libraries-manager/LibrariesAuthZManager.test.tsx create mode 100644 src/authz-module/libraries-manager/LibrariesAuthZManager.tsx create mode 100644 src/authz-module/libraries-manager/messages.ts diff --git a/src/authz-module/libraries-manager/LibrariesAuthZManager.test.tsx b/src/authz-module/libraries-manager/LibrariesAuthZManager.test.tsx new file mode 100644 index 0000000..991c740 --- /dev/null +++ b/src/authz-module/libraries-manager/LibrariesAuthZManager.test.tsx @@ -0,0 +1,55 @@ +import { screen } from '@testing-library/react'; +import LibrariesAuthZManager from './LibrariesAuthZManager'; +import { useLibraryAuthZ } from './context'; +import { renderWrapper } from '@src/setupTest'; +import { initializeMockApp } from '@edx/frontend-platform/testing'; + +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('./components/TeamTable', () => ({ + __esModule: true, + default: () =>
MockTeamTable
, +})); + +describe('LibrariesAuthZManager', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + username: 'admin' + } + }) + mockedUseLibraryAuthZ.mockReturnValue({ + libraryId: 'lib-001', + libraryName: 'Mock Library', + libraryOrg: 'MockOrg', + username: 'mockuser', + roles: ['admin'], + permissions: [], + canManageTeam: true, + }); + }); + + it('renders tabs and layout content correctly', () => { + renderWrapper(
hola
); + + // 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/LibrariesAuthZManager.tsx b/src/authz-module/libraries-manager/LibrariesAuthZManager.tsx new file mode 100644 index 0000000..202b2db --- /dev/null +++ b/src/authz-module/libraries-manager/LibrariesAuthZManager.tsx @@ -0,0 +1,49 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Tab, Tabs } from '@openedx/paragon'; +import TeamTable from './components/TeamTable'; +import AuthZLayout from '../components/AuthZLayout'; +import { LibraryAuthZProvider, useLibraryAuthZ } from './context'; + +import messages from './messages'; + +const LibrariesAuthZView = () => { + const intl = useIntl(); + const { libraryId, libraryName, libraryOrg } = useLibraryAuthZ(); + 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 LibrariesAuthZManager = () => ( + + + +); + +export default LibrariesAuthZManager; 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; From a6f96e33e9a239696c679d2d718cee85684e6b59 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Thu, 25 Sep 2025 01:39:51 +1000 Subject: [PATCH 08/17] test: improve coverage --- .../libraries-manager/data/hooks.test.tsx | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/src/authz-module/libraries-manager/data/hooks.test.tsx b/src/authz-module/libraries-manager/data/hooks.test.tsx index a5b7734..940168d 100644 --- a/src/authz-module/libraries-manager/data/hooks.test.tsx +++ b/src/authz-module/libraries-manager/data/hooks.test.tsx @@ -1,8 +1,12 @@ import { ReactNode } from 'react'; -import { renderHook, waitFor } from '@testing-library/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'; -import * as api from './api'; + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), +})); const mockMembers = [ { @@ -51,7 +55,9 @@ describe('useTeamMembers', () => { }); it('returns data when API call succeeds', async () => { - jest.spyOn(api, 'getTeamMembers').mockResolvedValue(mockMembers); + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValue({ data: { results: mockMembers } }), + }); const { result } = renderHook(() => useTeamMembers('lib:123'), { wrapper: createWrapper(), @@ -59,14 +65,14 @@ describe('useTeamMembers', () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(api.getTeamMembers).toHaveBeenCalledWith('lib:123'); + expect(getAuthenticatedHttpClient).toHaveBeenCalled(); expect(result.current.data).toEqual(mockMembers); }); it('handles error when API call fails', async () => { - jest - .spyOn(api, 'getTeamMembers') - .mockRejectedValue(new Error('API failure')); + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockRejectedValue(new Error('API failure')), + }); const { result } = renderHook(() => useTeamMembers('lib:123'), { wrapper: createWrapper(), @@ -74,7 +80,7 @@ describe('useTeamMembers', () => { await waitFor(() => expect(result.current.isError).toBe(true)); - expect(api.getTeamMembers).toHaveBeenCalledWith('lib:123'); + expect(getAuthenticatedHttpClient).toHaveBeenCalled(); expect(result.current.error).toBeDefined(); expect(result.current.data).toBeUndefined(); }); @@ -86,7 +92,9 @@ describe('useLibrary', () => { }); it('returns metadata on success', async () => { - jest.spyOn(api, 'getLibrary').mockResolvedValue(mockLibrary); + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockResolvedValueOnce({ data: mockLibrary }), + }); const { result } = renderHook( () => useLibrary('lib123'), @@ -94,22 +102,24 @@ describe('useLibrary', () => { ); await waitFor(() => { expect(result.current.data).toEqual(mockLibrary); - expect(api.getLibrary).toHaveBeenCalledWith('lib123'); + expect(getAuthenticatedHttpClient).toHaveBeenCalled(); }); }); it('throws on error', () => { - jest - .spyOn(api, 'getLibrary') - .mockRejectedValue(new Error('Not found')); + getAuthenticatedHttpClient.mockReturnValue({ + get: jest.fn().mockRejectedValue(new Error('Not found')), + }); const wrapper = createWrapper(); try { - renderHook(() => useLibrary('lib123'), { wrapper }); + act(()=>{ + renderHook(() => useLibrary('lib123'), { wrapper }); + }) } catch (e) { expect(e).toEqual(new Error('Not found')); } - expect(api.getLibrary).toHaveBeenCalledWith('lib123'); + expect(getAuthenticatedHttpClient).toHaveBeenCalled(); }); }); From 39686a0c7e7a5a0bcb0a00411024474878fef28b Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Thu, 25 Sep 2025 13:41:41 +1000 Subject: [PATCH 09/17] refactor: use a better name for the team management view --- src/authz-module/index.test.tsx | 4 ++-- src/authz-module/index.tsx | 4 ++-- ...uthZManager.test.tsx => LibrariesTeamManager.test.tsx} | 6 +++--- ...LibrariesAuthZManager.tsx => LibrariesTeamManager.tsx} | 8 ++++---- src/authz-module/libraries-manager/data/hooks.ts | 4 ++-- src/authz-module/libraries-manager/index.ts | 5 +++++ 6 files changed, 18 insertions(+), 13 deletions(-) rename src/authz-module/libraries-manager/{LibrariesAuthZManager.test.tsx => LibrariesTeamManager.test.tsx} (90%) rename src/authz-module/libraries-manager/{LibrariesAuthZManager.tsx => LibrariesTeamManager.tsx} (91%) create mode 100644 src/authz-module/libraries-manager/index.ts diff --git a/src/authz-module/index.test.tsx b/src/authz-module/index.test.tsx index dcc018b..e476608 100644 --- a/src/authz-module/index.test.tsx +++ b/src/authz-module/index.test.tsx @@ -4,7 +4,7 @@ import { MemoryRouter } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import AuthZModule from './index'; -jest.mock('./libraries-manager/LibrariesAuthZManager', () => { +jest.mock('./libraries-manager/LibrariesTeamManager', () => { return lazy(() => new Promise<{ default: ComponentType }>(resolve => setTimeout(() => resolve({ default: () =>
Loaded
}), 100) @@ -22,7 +22,7 @@ const createTestQueryClient = () => }); describe('AuthZModule', () => { - it('renders LoadingPage then LibrariesAuthZManager when route matches', async () => { + it('renders LoadingPage then LibrariesTeamManager when route matches', async () => { const queryClient = createTestQueryClient(); const path = '/libraries/lib:123'; diff --git a/src/authz-module/index.tsx b/src/authz-module/index.tsx index b42e041..e95a136 100644 --- a/src/authz-module/index.tsx +++ b/src/authz-module/index.tsx @@ -2,7 +2,7 @@ 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 LibrariesAuthZManager from './libraries-manager/LibrariesAuthZManager'; +import { LibrariesTeamManager } from './libraries-manager/'; import { ROUTES } from './constants'; import './index.scss'; @@ -11,7 +11,7 @@ const AuthZModule = () => ( }> - } /> + } /> diff --git a/src/authz-module/libraries-manager/LibrariesAuthZManager.test.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx similarity index 90% rename from src/authz-module/libraries-manager/LibrariesAuthZManager.test.tsx rename to src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx index 991c740..93a3aa7 100644 --- a/src/authz-module/libraries-manager/LibrariesAuthZManager.test.tsx +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx @@ -1,5 +1,5 @@ import { screen } from '@testing-library/react'; -import LibrariesAuthZManager from './LibrariesAuthZManager'; +import LibrariesTeamManager from './LibrariesTeamManager'; import { useLibraryAuthZ } from './context'; import { renderWrapper } from '@src/setupTest'; import { initializeMockApp } from '@edx/frontend-platform/testing'; @@ -19,7 +19,7 @@ jest.mock('./components/TeamTable', () => ({ default: () =>
MockTeamTable
, })); -describe('LibrariesAuthZManager', () => { +describe('LibrariesTeamManager', () => { beforeEach(() => { initializeMockApp({ authenticatedUser: { @@ -38,7 +38,7 @@ describe('LibrariesAuthZManager', () => { }); it('renders tabs and layout content correctly', () => { - renderWrapper(
hola
); + renderWrapper(); // Tabs expect(screen.getByRole('tab', { name: /Team Members/i })).toBeInTheDocument(); diff --git a/src/authz-module/libraries-manager/LibrariesAuthZManager.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx similarity index 91% rename from src/authz-module/libraries-manager/LibrariesAuthZManager.tsx rename to src/authz-module/libraries-manager/LibrariesTeamManager.tsx index 202b2db..ae2a7f7 100644 --- a/src/authz-module/libraries-manager/LibrariesAuthZManager.tsx +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx @@ -6,7 +6,7 @@ import { LibraryAuthZProvider, useLibraryAuthZ } from './context'; import messages from './messages'; -const LibrariesAuthZView = () => { +const LibrariesAuthZTeamView = () => { const intl = useIntl(); const { libraryId, libraryName, libraryOrg } = useLibraryAuthZ(); const rootBradecrumb = intl.formatMessage(messages['library.authz.breadcrumb.root']) || ''; @@ -40,10 +40,10 @@ const LibrariesAuthZView = () => { ); }; -const LibrariesAuthZManager = () => ( +const LibrariesTeamManager = () => ( - + ); -export default LibrariesAuthZManager; +export default LibrariesTeamManager; diff --git a/src/authz-module/libraries-manager/data/hooks.ts b/src/authz-module/libraries-manager/data/hooks.ts index 9289b63..e9e8b07 100644 --- a/src/authz-module/libraries-manager/data/hooks.ts +++ b/src/authz-module/libraries-manager/data/hooks.ts @@ -25,10 +25,10 @@ export const useTeamMembers = (libraryId: string) => useQuery { return useSuspenseQuery({ queryKey: ['library-metadata', libraryId], queryFn: () => getLibrary(libraryId), diff --git a/src/authz-module/libraries-manager/index.ts b/src/authz-module/libraries-manager/index.ts new file mode 100644 index 0000000..b06a073 --- /dev/null +++ b/src/authz-module/libraries-manager/index.ts @@ -0,0 +1,5 @@ +import LibrariesTeamManager from "./LibrariesTeamManager"; + +export { + LibrariesTeamManager, +} \ No newline at end of file From 3a27943cbe047bbee3f10b003ee12384f2af8df1 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Thu, 25 Sep 2025 14:46:22 +1000 Subject: [PATCH 10/17] style: fix color for background tabs --- src/authz-module/index.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/authz-module/index.scss b/src/authz-module/index.scss index 4aada25..70ca529 100644 --- a/src/authz-module/index.scss +++ b/src/authz-module/index.scss @@ -3,4 +3,8 @@ color: var(--pgn-color-breadcrumb-active); text-decoration: none; } + + .tab-content { + background-color: var(--pgn-color-light-200); + } } From 08a1f68f80bd07a5ef292d34ce51d7dd370ba88e Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Thu, 25 Sep 2025 23:06:00 +1000 Subject: [PATCH 11/17] fix: convert data to camel case in the api return for getTeamMembers --- src/authz-module/libraries-manager/data/api.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/authz-module/libraries-manager/data/api.ts b/src/authz-module/libraries-manager/data/api.ts index b0fd811..11fd85b 100644 --- a/src/authz-module/libraries-manager/data/api.ts +++ b/src/authz-module/libraries-manager/data/api.ts @@ -1,5 +1,6 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { LibraryMetadata, TeamMember } from '@src/authz-module/constants'; +import { camelCaseObject } from '@edx/frontend-platform'; import { getApiUrl, getStudioApiUrl } from '@src/helpers/utils'; export interface GetTeamMembersResponse { @@ -10,7 +11,7 @@ export interface GetTeamMembersResponse { // TODO: replece api path once is created export const getTeamMembers = async (libraryId: string): Promise => { const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users?scope=${libraryId}`)); - return data.results; + return camelCaseObject(data.results); }; From fc62f15c5eb536b326f4e9b9988c5a25488151ca Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Fri, 26 Sep 2025 15:24:06 +1000 Subject: [PATCH 12/17] style: fix linter issues and data location --- src/authz-module/constants.ts | 21 ------------- .../{libraries-manager => }/data/api.ts | 8 ++--- .../data/hooks.test.tsx | 0 .../{libraries-manager => }/data/hooks.ts | 16 +++++----- .../components/TeamTable.test.tsx | 4 +-- .../components/TeamTable.tsx | 5 +-- .../libraries-manager/context.test.tsx | 8 ++--- .../libraries-manager/context.tsx | 4 +-- src/components/LoadingPage.tsx | 14 ++++----- src/data/api.ts | 8 +++++ .../hooks.test.tsx} | 6 ++-- .../hooks.ts} | 28 ++++------------- src/{helpers => data}/utils.ts | 0 src/types.ts | 31 +++++++++++++++++++ 14 files changed, 78 insertions(+), 75 deletions(-) rename src/authz-module/{libraries-manager => }/data/api.ts (72%) rename src/authz-module/{libraries-manager => }/data/hooks.test.tsx (100%) rename src/authz-module/{libraries-manager => }/data/hooks.ts (60%) create mode 100644 src/data/api.ts rename src/{helpers/useValidateUserPermissions.test.tsx => data/hooks.test.tsx} (97%) rename src/{helpers/useValidateUserPermissions.ts => data/hooks.ts} (51%) rename src/{helpers => data}/utils.ts (100%) create mode 100644 src/types.ts diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index 2da6c73..18bad6b 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -1,24 +1,3 @@ -export interface TeamMember { - displayName: string; - username: string; - email: string; - roles: string[]; -} - -export interface LibraryMetadata { - id: string; - org: string; - title: string; - slug: string; -} - -export interface TableCellValue { - row: { - original: T; - }; -} - - export const ROUTES = { LIBRARIES_TEAM_PATH: '/libraries/:libraryId', LIBRARIES_USER_PATH: '/libraries/user/:username' diff --git a/src/authz-module/libraries-manager/data/api.ts b/src/authz-module/data/api.ts similarity index 72% rename from src/authz-module/libraries-manager/data/api.ts rename to src/authz-module/data/api.ts index 11fd85b..7e4498c 100644 --- a/src/authz-module/libraries-manager/data/api.ts +++ b/src/authz-module/data/api.ts @@ -1,7 +1,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { LibraryMetadata, TeamMember } from '@src/authz-module/constants'; +import { LibraryMetadata, TeamMember } from '@src/types'; import { camelCaseObject } from '@edx/frontend-platform'; -import { getApiUrl, getStudioApiUrl } from '@src/helpers/utils'; +import { getApiUrl, getStudioApiUrl } from '@src/data/utils'; export interface GetTeamMembersResponse { members: TeamMember[]; @@ -9,8 +9,8 @@ export interface GetTeamMembersResponse { } // TODO: replece api path once is created -export const getTeamMembers = async (libraryId: string): Promise => { - const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users?scope=${libraryId}`)); +export const getTeamMembers = async (object: string): Promise => { + const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users?scope=${object}`)); return camelCaseObject(data.results); }; diff --git a/src/authz-module/libraries-manager/data/hooks.test.tsx b/src/authz-module/data/hooks.test.tsx similarity index 100% rename from src/authz-module/libraries-manager/data/hooks.test.tsx rename to src/authz-module/data/hooks.test.tsx diff --git a/src/authz-module/libraries-manager/data/hooks.ts b/src/authz-module/data/hooks.ts similarity index 60% rename from src/authz-module/libraries-manager/data/hooks.ts rename to src/authz-module/data/hooks.ts index e9e8b07..1228b04 100644 --- a/src/authz-module/libraries-manager/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -1,26 +1,26 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; -import { LibraryMetadata, TeamMember } from '@src/authz-module/constants'; +import { LibraryMetadata, TeamMember } from '@src/types'; import { getLibrary, getTeamMembers } from './api'; /** - * React Query hook to fetch all team members for a specific library. - * It retrieves the full list of members who have access to the given library. + * 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 libraryId - The unique identifier of the library + * @param object - The unique identifier of the object/scope * * @example * ```tsx * const { data: teamMembers, isLoading, isError } = useTeamMembers('lib:123'); * ``` */ -export const useTeamMembers = (libraryId: string) => useQuery({ - queryKey: ['team-members', libraryId], - queryFn: () => getTeamMembers(libraryId), +export const useTeamMembers = (object: string) => useQuery({ + queryKey: ['team-members', object], + queryFn: () => getTeamMembers(object), staleTime: 1000 * 60 * 30, // refetch after 30 minutes }); /** - * React Query hook to retrive the inforation of the current library. + * React Query hook to retrieve the information of the current library. * * @param libraryId - The unique ID of the library. * diff --git a/src/authz-module/libraries-manager/components/TeamTable.test.tsx b/src/authz-module/libraries-manager/components/TeamTable.test.tsx index d9cf678..434c048 100644 --- a/src/authz-module/libraries-manager/components/TeamTable.test.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import { ROUTES } from '@src/authz-module/constants'; import { renderWrapper } from '@src/setupTest'; import TeamTable from './TeamTable'; -import { useTeamMembers } from '../data/hooks'; +import { useTeamMembers } from '@src/authz-module/data/hooks'; import { useLibraryAuthZ } from '../context'; const mockNavigate = jest.fn(); @@ -12,7 +12,7 @@ jest.mock('react-router', () => ({ useNavigate: () => mockNavigate, })); -jest.mock('../data/hooks', () => ({ +jest.mock('@src/authz-module/data/hooks', () => ({ useTeamMembers: jest.fn(), })); diff --git a/src/authz-module/libraries-manager/components/TeamTable.tsx b/src/authz-module/libraries-manager/components/TeamTable.tsx index 48a7dad..a751123 100644 --- a/src/authz-module/libraries-manager/components/TeamTable.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable.tsx @@ -5,8 +5,9 @@ import { DataTable, Button, Chip, Skeleton, } from '@openedx/paragon'; import { Edit } from '@openedx/paragon/icons'; -import { ROUTES, TableCellValue, TeamMember } from '@src/authz-module/constants'; -import { useTeamMembers } from '../data/hooks'; +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'; diff --git a/src/authz-module/libraries-manager/context.test.tsx b/src/authz-module/libraries-manager/context.test.tsx index 6bfc719..8a3be4b 100644 --- a/src/authz-module/libraries-manager/context.test.tsx +++ b/src/authz-module/libraries-manager/context.test.tsx @@ -1,8 +1,8 @@ import { screen } from '@testing-library/react'; import { useParams } from 'react-router-dom'; -import { useValidateUserPermissions } from '@src/helpers/useValidateUserPermissions'; +import { useValidateUserPermissions } from '@src/data/hooks'; import { renderWrapper } from '@src/setupTest'; -import { useLibrary } from './data/hooks'; +import { useLibrary } from '../data/hooks'; import { LibraryAuthZProvider, useLibraryAuthZ } from './context'; jest.mock('react-router-dom', () => ({ @@ -10,11 +10,11 @@ jest.mock('react-router-dom', () => ({ useParams: jest.fn(), })); -jest.mock('@src/helpers/useValidateUserPermissions', () => ({ +jest.mock('@src/data/hooks', () => ({ useValidateUserPermissions: jest.fn(), })); -jest.mock('./data/hooks', () => ({ +jest.mock('../data/hooks', () => ({ useLibrary: jest.fn(), })); diff --git a/src/authz-module/libraries-manager/context.tsx b/src/authz-module/libraries-manager/context.tsx index 9fada0d..1eddb64 100644 --- a/src/authz-module/libraries-manager/context.tsx +++ b/src/authz-module/libraries-manager/context.tsx @@ -3,8 +3,8 @@ import { } from 'react'; import { useParams } from 'react-router-dom'; import { AppContext } from '@edx/frontend-platform/react'; -import { useValidateUserPermissions } from '@src/helpers/useValidateUserPermissions'; -import { useLibrary } from './data/hooks'; +import { useValidateUserPermissions } from '@src/data/hooks'; +import { useLibrary } from '../data/hooks'; const LIBRARY_TEAM_PERMISSIONS = ['act:view_library_team', 'act:manage_library_team']; diff --git a/src/components/LoadingPage.tsx b/src/components/LoadingPage.tsx index bba9389..bdcc6ea 100644 --- a/src/components/LoadingPage.tsx +++ b/src/components/LoadingPage.tsx @@ -1,14 +1,14 @@ import { Spinner, Container } from '@openedx/paragon'; -const LoadingPage= () => { +const LoadingPage = () => { return ( - + ); }; diff --git a/src/data/api.ts b/src/data/api.ts new file mode 100644 index 0000000..f35ebd8 --- /dev/null +++ b/src/data/api.ts @@ -0,0 +1,8 @@ +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; +}; \ No newline at end of file diff --git a/src/helpers/useValidateUserPermissions.test.tsx b/src/data/hooks.test.tsx similarity index 97% rename from src/helpers/useValidateUserPermissions.test.tsx rename to src/data/hooks.test.tsx index c063c9b..799fc96 100644 --- a/src/helpers/useValidateUserPermissions.test.tsx +++ b/src/data/hooks.test.tsx @@ -1,8 +1,8 @@ import { act, ReactNode } from 'react'; import { renderHook, waitFor } from '@testing-library/react'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { useValidateUserPermissions } from './useValidateUserPermissions'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { useValidateUserPermissions } from './hooks'; jest.mock('@edx/frontend-platform/auth', () => ({ getAuthenticatedHttpClient: jest.fn(), @@ -25,6 +25,7 @@ const createWrapper = () => { return wrapper; }; + const permissions = [ { action: 'act:read', @@ -51,7 +52,6 @@ describe('useValidateUserPermissions', () => { post: jest.fn().mockResolvedValueOnce({ data: mockValidPermissions }), }); - const { result } = renderHook(() => useValidateUserPermissions(permissions), { wrapper: createWrapper(), }); diff --git a/src/helpers/useValidateUserPermissions.ts b/src/data/hooks.ts similarity index 51% rename from src/helpers/useValidateUserPermissions.ts rename to src/data/hooks.ts index 9a09604..2ef03d9 100644 --- a/src/helpers/useValidateUserPermissions.ts +++ b/src/data/hooks.ts @@ -1,33 +1,17 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { getApiUrl } from './utils'; - -export interface PermissionValidationRequest { - action: string; - object?: string; - scope?: string; -} - -export interface PermissionValidationResponse extends PermissionValidationRequest{ - allowed: boolean; -} - -const validateUserPermissions = async (validations: PermissionValidationRequest[]): Promise => { - const { data } = await getAuthenticatedHttpClient().post(getApiUrl(`/api/authz/v1/permissions/validate/me`), validations); - return data; -}; - +import { useSuspenseQuery } from '@tanstack/react-query'; +import { PermissionValidationRequest, PermissionValidationResponse } from '@src/types'; +import { validateUserPermissions } from './api'; /** * React Query hook to validate if the current user has permissions over a certain object in the instance. - * It helps to: + * 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 } = useValidateTeamMember([{ + * const { data } = userValidateUserPermissions([{ "action": "act:read", "object": "lib:test-lib", "scope": "org:OpenedX" @@ -41,4 +25,4 @@ export const useValidateUserPermissions = (permissions: PermissionValidationRequ queryFn: () => validateUserPermissions(permissions), retry: false, }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/src/helpers/utils.ts b/src/data/utils.ts similarity index 100% rename from src/helpers/utils.ts rename to src/data/utils.ts diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..f913d14 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,31 @@ +export interface PermissionValidationRequest { + action: string; + object?: string; + scope?: string; +}; + +export interface PermissionValidationResponse extends PermissionValidationRequest{ + allowed: boolean; +}; + +// Libraries AuthZ types +export interface TeamMember { + displayName: string; + 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; + }; +} From 6b1db4a3448ed6bb7eda54911d62aa10de1257ef Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Fri, 26 Sep 2025 17:21:30 +1000 Subject: [PATCH 13/17] refactor: create Query Keys factories --- src/authz-module/data/hooks.ts | 12 ++++++++++-- src/constants.ts | 1 + src/data/hooks.ts | 12 +++++++++--- 3 files changed, 20 insertions(+), 5 deletions(-) create mode 100644 src/constants.ts diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts index 1228b04..5c2e5b2 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -1,7 +1,15 @@ 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. @@ -14,7 +22,7 @@ import { getLibrary, getTeamMembers } from './api'; * ``` */ export const useTeamMembers = (object: string) => useQuery({ - queryKey: ['team-members', object], + queryKey: authzQueryKeys.teamMembers(object), queryFn: () => getTeamMembers(object), staleTime: 1000 * 60 * 30, // refetch after 30 minutes }); @@ -30,7 +38,7 @@ export const useTeamMembers = (object: string) => useQuery( */ export const useLibrary = (libraryId: string) => { return useSuspenseQuery({ - queryKey: ['library-metadata', libraryId], + queryKey: authzQueryKeys.library(libraryId), queryFn: () => getLibrary(libraryId), retry: false, }); diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..b69ffcb --- /dev/null +++ b/src/constants.ts @@ -0,0 +1 @@ +export const appId = 'org.openedx.frontend.app.adminConsole'; \ No newline at end of file diff --git a/src/data/hooks.ts b/src/data/hooks.ts index 2ef03d9..7fe9a83 100644 --- a/src/data/hooks.ts +++ b/src/data/hooks.ts @@ -1,6 +1,12 @@ import { useSuspenseQuery } from '@tanstack/react-query'; import { PermissionValidationRequest, PermissionValidationResponse } from '@src/types'; import { validateUserPermissions } from './api'; +import { appId } from '@src/constants'; + +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. @@ -11,7 +17,7 @@ import { validateUserPermissions } from './api'; * @param permissions - The array of objects and actions to validate. * * @example - * const { data } = userValidateUserPermissions([{ + * const { data } = useValidateUserPermissions([{ "action": "act:read", "object": "lib:test-lib", "scope": "org:OpenedX" @@ -19,9 +25,9 @@ import { validateUserPermissions } from './api'; * if (data[0].allowed) { ... } * */ -export const useValidateUserPermissions = (permissions: PermissionValidationRequest[]) => { +export const useValidateUserPermissions = (permissions: PermissionValidationRequest[]) => { return useSuspenseQuery({ - queryKey: ['validate-user-permissions', permissions], + queryKey: adminConsoleQueryKeys.permissions(permissions), queryFn: () => validateUserPermissions(permissions), retry: false, }); From 80ad2139975c135dc4b8cb713abefaede297daa7 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Fri, 26 Sep 2025 17:10:49 +1000 Subject: [PATCH 14/17] style: single quote imports --- src/authz-module/data/hooks.ts | 2 +- src/authz-module/libraries-manager/index.ts | 4 ++-- src/constants.ts | 2 +- src/data/api.ts | 2 +- src/data/hooks.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts index 5c2e5b2..a9f82e5 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -1,5 +1,5 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; -import { appId } from "@src/constants"; +import { appId } from '@src/constants'; import { LibraryMetadata, TeamMember } from '@src/types'; import { getLibrary, getTeamMembers } from './api'; diff --git a/src/authz-module/libraries-manager/index.ts b/src/authz-module/libraries-manager/index.ts index b06a073..a085b74 100644 --- a/src/authz-module/libraries-manager/index.ts +++ b/src/authz-module/libraries-manager/index.ts @@ -1,5 +1,5 @@ -import LibrariesTeamManager from "./LibrariesTeamManager"; +import LibrariesTeamManager from './LibrariesTeamManager'; export { LibrariesTeamManager, -} \ No newline at end of file +} diff --git a/src/constants.ts b/src/constants.ts index b69ffcb..0d9b840 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1 +1 @@ -export const appId = 'org.openedx.frontend.app.adminConsole'; \ No newline at end of file +export const appId = 'org.openedx.frontend.app.adminConsole'; diff --git a/src/data/api.ts b/src/data/api.ts index f35ebd8..cd2349f 100644 --- a/src/data/api.ts +++ b/src/data/api.ts @@ -5,4 +5,4 @@ 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; -}; \ No newline at end of file +}; diff --git a/src/data/hooks.ts b/src/data/hooks.ts index 7fe9a83..1d587f7 100644 --- a/src/data/hooks.ts +++ b/src/data/hooks.ts @@ -31,4 +31,4 @@ export const useValidateUserPermissions = (permissions: PermissionValidationRequ queryFn: () => validateUserPermissions(permissions), retry: false, }); -}; \ No newline at end of file +}; From bb288f6f7195c6f8196eadc16c7d6087cef84417 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Fri, 26 Sep 2025 17:43:25 +1000 Subject: [PATCH 15/17] refactor: useLibrary directly in the componets to access the metadata --- .../LibrariesTeamManager.test.tsx | 12 ++++++++++++ .../libraries-manager/LibrariesTeamManager.tsx | 6 ++++-- .../libraries-manager/context.test.tsx | 16 ---------------- src/authz-module/libraries-manager/context.tsx | 6 ------ 4 files changed, 16 insertions(+), 24 deletions(-) diff --git a/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx index 93a3aa7..95b1be9 100644 --- a/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx @@ -3,6 +3,7 @@ import LibrariesTeamManager from './LibrariesTeamManager'; import { useLibraryAuthZ } from './context'; import { renderWrapper } from '@src/setupTest'; import { initializeMockApp } from '@edx/frontend-platform/testing'; +import { useLibrary } from '@src/authz-module/data/hooks'; jest.mock('./context', () => { const actual = jest.requireActual('./context'); @@ -14,6 +15,10 @@ jest.mock('./context', () => { }); const mockedUseLibraryAuthZ = useLibraryAuthZ as jest.Mock; +jest.mock('@src/authz-module/data/hooks', () => ({ + useLibrary: jest.fn(), +})); + jest.mock('./components/TeamTable', () => ({ __esModule: true, default: () =>
MockTeamTable
, @@ -35,6 +40,13 @@ describe('LibrariesTeamManager', () => { permissions: [], canManageTeam: true, }); + + (useLibrary as jest.Mock).mockReturnValue({ + data: { + title: 'Test Library', + org: 'Test Org', + }, + }); }); it('renders tabs and layout content correctly', () => { diff --git a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx index ae2a7f7..28db7a3 100644 --- a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx @@ -2,19 +2,21 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Tab, Tabs } from '@openedx/paragon'; import TeamTable from './components/TeamTable'; import AuthZLayout from '../components/AuthZLayout'; +import { useLibrary } from '@src/authz-module/data/hooks'; import { LibraryAuthZProvider, useLibraryAuthZ } from './context'; import messages from './messages'; const LibrariesAuthZTeamView = () => { const intl = useIntl(); - const { libraryId, libraryName, libraryOrg } = useLibraryAuthZ(); + 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 (
({ @@ -14,10 +13,6 @@ jest.mock('@src/data/hooks', () => ({ useValidateUserPermissions: jest.fn(), })); -jest.mock('../data/hooks', () => ({ - useLibrary: jest.fn(), -})); - const TestComponent = () => { const context = useLibraryAuthZ(); return ( @@ -25,8 +20,6 @@ const TestComponent = () => {
{context.username}
{context.libraryId}
{context.canManageTeam ? 'true' : 'false'}
-
{context.libraryName}
-
{context.libraryOrg}
); }; @@ -36,13 +29,6 @@ describe('LibraryAuthZProvider', () => { beforeEach(() => { jest.clearAllMocks(); (useParams as jest.Mock).mockReturnValue({ libraryId: 'lib123' }); - - (useLibrary as jest.Mock).mockReturnValue({ - data: { - title: 'Test Library', - org: 'Test Org', - }, - }); }); it('provides the correct context values to consumers', () => { @@ -62,8 +48,6 @@ describe('LibraryAuthZProvider', () => { expect(screen.getByTestId('username')).toHaveTextContent('testuser'); expect(screen.getByTestId('libraryId')).toHaveTextContent('lib123'); expect(screen.getByTestId('canManageTeam')).toHaveTextContent('true'); - expect(screen.getByTestId('libraryName')).toHaveTextContent('Test Library'); - expect(screen.getByTestId('libraryOrg')).toHaveTextContent('Test Org'); }); it('throws error when user lacks both view and manage permissions', () => { diff --git a/src/authz-module/libraries-manager/context.tsx b/src/authz-module/libraries-manager/context.tsx index 1eddb64..149d3a5 100644 --- a/src/authz-module/libraries-manager/context.tsx +++ b/src/authz-module/libraries-manager/context.tsx @@ -21,8 +21,6 @@ type LibraryAuthZContextType = { libraryId: string; roles: string[]; permissions: string[]; - libraryName: string; - libraryOrg: string; }; const LibraryAuthZContext = createContext(undefined); @@ -48,13 +46,9 @@ export const LibraryAuthZProvider: React.FC = ({ children }) throw new Error('NoAccess'); } - const { data: libraryMetadata } = useLibrary(libraryId); - const value = useMemo((): LibraryAuthZContextType => ({ username: authenticatedUser.username, libraryId, - libraryName: libraryMetadata.title, - libraryOrg: libraryMetadata.org, roles: [], permissions: [], canManageTeam, From b2eb92b8f2192ac8bae295996f20bfed610e20b2 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Mon, 29 Sep 2025 18:18:03 +1000 Subject: [PATCH 16/17] style: update linter validation and fix lint issues --- package.json | 4 +- src/authz-module/constants.ts | 4 +- src/authz-module/data/api.ts | 1 - src/authz-module/data/hooks.test.tsx | 4 +- src/authz-module/data/hooks.ts | 13 +++--- src/authz-module/index.test.tsx | 24 ++++------ src/authz-module/index.tsx | 2 +- .../LibrariesTeamManager.test.tsx | 10 ++--- .../LibrariesTeamManager.tsx | 4 +- .../components/TeamTable.test.tsx | 8 ++-- .../components/TeamTable.tsx | 45 +++++++++---------- .../libraries-manager/context.test.tsx | 13 +++--- .../libraries-manager/context.tsx | 3 +- src/authz-module/libraries-manager/index.ts | 2 +- src/components/LoadingPage.tsx | 22 +++++---- src/data/api.ts | 6 ++- src/data/hooks.test.tsx | 4 +- src/data/hooks.ts | 16 +++---- src/index.tsx | 4 +- src/types.ts | 14 +++--- 20 files changed, 94 insertions(+), 109 deletions(-) diff --git a/package.json b/package.json index 5223eb4..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", diff --git a/src/authz-module/constants.ts b/src/authz-module/constants.ts index 18bad6b..7864147 100644 --- a/src/authz-module/constants.ts +++ b/src/authz-module/constants.ts @@ -1,4 +1,4 @@ export const ROUTES = { LIBRARIES_TEAM_PATH: '/libraries/:libraryId', - LIBRARIES_USER_PATH: '/libraries/user/:username' -}; + LIBRARIES_USER_PATH: '/libraries/user/:username', +}; diff --git a/src/authz-module/data/api.ts b/src/authz-module/data/api.ts index 7e4498c..7529c60 100644 --- a/src/authz-module/data/api.ts +++ b/src/authz-module/data/api.ts @@ -14,7 +14,6 @@ export const getTeamMembers = async (object: string): Promise => { 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}/`)); diff --git a/src/authz-module/data/hooks.test.tsx b/src/authz-module/data/hooks.test.tsx index 940168d..28aced0 100644 --- a/src/authz-module/data/hooks.test.tsx +++ b/src/authz-module/data/hooks.test.tsx @@ -113,9 +113,9 @@ describe('useLibrary', () => { const wrapper = createWrapper(); try { - act(()=>{ + act(() => { renderHook(() => useLibrary('lib123'), { wrapper }); - }) + }); } catch (e) { expect(e).toEqual(new Error('Not found')); } diff --git a/src/authz-module/data/hooks.ts b/src/authz-module/data/hooks.ts index a9f82e5..e374c09 100644 --- a/src/authz-module/data/hooks.ts +++ b/src/authz-module/data/hooks.ts @@ -3,7 +3,6 @@ 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, @@ -36,10 +35,8 @@ export const useTeamMembers = (object: string) => useQuery( * const { data } = useLibrary('lib:123',); * */ -export const useLibrary = (libraryId: string) => { - return useSuspenseQuery({ - queryKey: authzQueryKeys.library(libraryId), - queryFn: () => getLibrary(libraryId), - retry: false, - }); -} +export const useLibrary = (libraryId: string) => useSuspenseQuery({ + queryKey: authzQueryKeys.library(libraryId), + queryFn: () => getLibrary(libraryId), + retry: false, +}); diff --git a/src/authz-module/index.test.tsx b/src/authz-module/index.test.tsx index e476608..5b5ab02 100644 --- a/src/authz-module/index.test.tsx +++ b/src/authz-module/index.test.tsx @@ -4,22 +4,16 @@ import { MemoryRouter } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import AuthZModule from './index'; -jest.mock('./libraries-manager/LibrariesTeamManager', () => { - return lazy(() => - new Promise<{ default: ComponentType }>(resolve => - setTimeout(() => resolve({ default: () =>
Loaded
}), 100) - ) - ); -}); +// 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, - }, +const createTestQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, }, - }); + }, +}); describe('AuthZModule', () => { it('renders LoadingPage then LibrariesTeamManager when route matches', async () => { @@ -31,7 +25,7 @@ describe('AuthZModule', () => { - + , ); expect(screen.getByTestId('loading-page')).toBeInTheDocument(); diff --git a/src/authz-module/index.tsx b/src/authz-module/index.tsx index e95a136..2812df3 100644 --- a/src/authz-module/index.tsx +++ b/src/authz-module/index.tsx @@ -2,7 +2,7 @@ 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 { LibrariesTeamManager } from './libraries-manager'; import { ROUTES } from './constants'; import './index.scss'; diff --git a/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx index 95b1be9..ad3a8d9 100644 --- a/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.test.tsx @@ -1,9 +1,9 @@ import { screen } from '@testing-library/react'; -import LibrariesTeamManager from './LibrariesTeamManager'; -import { useLibraryAuthZ } from './context'; 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'); @@ -28,9 +28,9 @@ describe('LibrariesTeamManager', () => { beforeEach(() => { initializeMockApp({ authenticatedUser: { - username: 'admin' - } - }) + username: 'admin', + }, + }); mockedUseLibraryAuthZ.mockReturnValue({ libraryId: 'lib-001', libraryName: 'Mock Library', diff --git a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx index 28db7a3..ae81a3e 100644 --- a/src/authz-module/libraries-manager/LibrariesTeamManager.tsx +++ b/src/authz-module/libraries-manager/LibrariesTeamManager.tsx @@ -1,8 +1,8 @@ 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 { useLibrary } from '@src/authz-module/data/hooks'; import { LibraryAuthZProvider, useLibraryAuthZ } from './context'; import messages from './messages'; @@ -10,7 +10,7 @@ import messages from './messages'; const LibrariesAuthZTeamView = () => { const intl = useIntl(); const { libraryId } = useLibraryAuthZ(); - const { data: library } = useLibrary(libraryId) + 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 ( diff --git a/src/authz-module/libraries-manager/components/TeamTable.test.tsx b/src/authz-module/libraries-manager/components/TeamTable.test.tsx index 434c048..32ba801 100644 --- a/src/authz-module/libraries-manager/components/TeamTable.test.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable.test.tsx @@ -2,13 +2,13 @@ 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 TeamTable from './TeamTable'; import { useTeamMembers } from '@src/authz-module/data/hooks'; +import TeamTable from './TeamTable'; import { useLibraryAuthZ } from '../context'; const mockNavigate = jest.fn(); -jest.mock('react-router', () => ({ - ...jest.requireActual('react-router'), +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), useNavigate: () => mockNavigate, })); @@ -93,7 +93,7 @@ describe('TeamTable', () => { await userEvent.click(editButtons[0]); expect(mockNavigate).toHaveBeenCalledWith( - `/authz/${ROUTES.LIBRARIES_USER_PATH.replace(':username', 'alice')}`, + `/authz/${ROUTES.LIBRARIES_USER_PATH.replace(':username', 'bob')}`, ); }); diff --git a/src/authz-module/libraries-manager/components/TeamTable.tsx b/src/authz-module/libraries-manager/components/TeamTable.tsx index a751123..e008855 100644 --- a/src/authz-module/libraries-manager/components/TeamTable.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable.tsx @@ -1,5 +1,4 @@ -import { useMemo } from 'react'; -import { useNavigate } from 'react-router'; +import { useNavigate } from 'react-router-dom'; import { useIntl } from '@edx/frontend-platform/i18n'; import { DataTable, Button, Chip, Skeleton, @@ -46,31 +45,13 @@ const TeamTable = () => { // TODO: Display error in the notification system const { - data: teamMembers, isLoading, isError + data: teamMembers, isLoading, isError, } = useTeamMembers(libraryId); const rows = isError ? [] : (teamMembers || SKELETON_ROWS); const navigate = useNavigate(); - const columns = useMemo(() => [ - { - Header: intl.formatMessage(messages['library.authz.team.table.display.name']), - accessor: 'displayName', - 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, - }, - ], [isLoading]); - return ( { variant="link" size="sm" // TODO: update the view with the team member view - onClick={() => navigate(`/authz/${ROUTES.LIBRARIES_USER_PATH.replace(':username', username)}`)} + onClick={() => navigate(`/authz/${ROUTES.LIBRARIES_USER_PATH.replace(':username', row.original.username)}`)} > {intl.formatMessage(messages['authz.libraries.team.table.edit.action'])} @@ -98,7 +79,25 @@ const TeamTable = () => { initialState={{ pageSize: 10, }} - columns={columns} + columns={ + [ + { + Header: intl.formatMessage(messages['library.authz.team.table.display.name']), + accessor: 'displayName', + 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, + }, + ] + } /> ); }; diff --git a/src/authz-module/libraries-manager/context.test.tsx b/src/authz-module/libraries-manager/context.test.tsx index 8cf3521..f133f41 100644 --- a/src/authz-module/libraries-manager/context.test.tsx +++ b/src/authz-module/libraries-manager/context.test.tsx @@ -25,7 +25,6 @@ const TestComponent = () => { }; describe('LibraryAuthZProvider', () => { - beforeEach(() => { jest.clearAllMocks(); (useParams as jest.Mock).mockReturnValue({ libraryId: 'lib123' }); @@ -42,7 +41,7 @@ describe('LibraryAuthZProvider', () => { renderWrapper( - + , ); expect(screen.getByTestId('username')).toHaveTextContent('testuser'); @@ -62,7 +61,7 @@ describe('LibraryAuthZProvider', () => { renderWrapper( - + , ); }).toThrow('NoAccess'); }); @@ -70,7 +69,7 @@ describe('LibraryAuthZProvider', () => { it('provides context when user can view but not manage team', () => { (useValidateUserPermissions as jest.Mock).mockReturnValue({ data: [ - { allowed: true }, // canViewTeam + { allowed: true }, // canViewTeam { allowed: false }, // canManageTeam ], }); @@ -78,7 +77,7 @@ describe('LibraryAuthZProvider', () => { renderWrapper( - + , ); expect(screen.getByTestId('canManageTeam')).toHaveTextContent('false'); @@ -91,8 +90,8 @@ describe('LibraryAuthZProvider', () => { renderWrapper( - - );; + , + ); }).toThrow('MissingLibrary'); }); diff --git a/src/authz-module/libraries-manager/context.tsx b/src/authz-module/libraries-manager/context.tsx index 149d3a5..b013e6b 100644 --- a/src/authz-module/libraries-manager/context.tsx +++ b/src/authz-module/libraries-manager/context.tsx @@ -4,7 +4,6 @@ import { import { useParams } from 'react-router-dom'; import { AppContext } from '@edx/frontend-platform/react'; import { useValidateUserPermissions } from '@src/data/hooks'; -import { useLibrary } from '../data/hooks'; const LIBRARY_TEAM_PERMISSIONS = ['act:view_library_team', 'act:manage_library_team']; @@ -29,7 +28,7 @@ type AuthZProviderProps = { children: ReactNode; }; -export const LibraryAuthZProvider: React.FC = ({ children }) => { +export const LibraryAuthZProvider: React.FC = ({ children }:AuthZProviderProps) => { const { libraryId } = useParams<{ libraryId: string }>(); const { authenticatedUser } = useContext(AppContext) as AppContextType; diff --git a/src/authz-module/libraries-manager/index.ts b/src/authz-module/libraries-manager/index.ts index a085b74..b12092e 100644 --- a/src/authz-module/libraries-manager/index.ts +++ b/src/authz-module/libraries-manager/index.ts @@ -2,4 +2,4 @@ import LibrariesTeamManager from './LibrariesTeamManager'; export { LibrariesTeamManager, -} +}; diff --git a/src/components/LoadingPage.tsx b/src/components/LoadingPage.tsx index bdcc6ea..047546f 100644 --- a/src/components/LoadingPage.tsx +++ b/src/components/LoadingPage.tsx @@ -1,16 +1,14 @@ import { Spinner, Container } from '@openedx/paragon'; -const LoadingPage = () => { - return ( - - - - ); -}; +const LoadingPage = () => ( + + + +); export default LoadingPage; diff --git a/src/data/api.ts b/src/data/api.ts index cd2349f..b3d3953 100644 --- a/src/data/api.ts +++ b/src/data/api.ts @@ -2,7 +2,9 @@ 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); +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 index 799fc96..7dc20ec 100644 --- a/src/data/hooks.test.tsx +++ b/src/data/hooks.test.tsx @@ -90,10 +90,8 @@ describe('useValidateUserPermissions', () => { wrapper: createWrapper(), }); }); - } catch (error) { - expect(error).toEqual(mockError); // Check for the expected error - return; + expect(error).toEqual(mockError); // Check for the expected error } }); }); diff --git a/src/data/hooks.ts b/src/data/hooks.ts index 1d587f7..80d4154 100644 --- a/src/data/hooks.ts +++ b/src/data/hooks.ts @@ -1,7 +1,7 @@ import { useSuspenseQuery } from '@tanstack/react-query'; import { PermissionValidationRequest, PermissionValidationResponse } from '@src/types'; -import { validateUserPermissions } from './api'; import { appId } from '@src/constants'; +import { validateUserPermissions } from './api'; const adminConsoleQueryKeys = { all: [appId] as const, @@ -25,10 +25,10 @@ const adminConsoleQueryKeys = { * if (data[0].allowed) { ... } * */ -export const useValidateUserPermissions = (permissions: PermissionValidationRequest[]) => { - return useSuspenseQuery({ - queryKey: adminConsoleQueryKeys.permissions(permissions), - queryFn: () => validateUserPermissions(permissions), - retry: false, - }); -}; +export const useValidateUserPermissions = ( + permissions: PermissionValidationRequest[], +) => useSuspenseQuery({ + queryKey: adminConsoleQueryKeys.permissions(permissions), + queryFn: () => validateUserPermissions(permissions), + retry: false, +}); 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/types.ts b/src/types.ts index f913d14..0d4acfe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,12 +1,12 @@ export interface PermissionValidationRequest { - action: string; - object?: string; - scope?: string; -}; + action: string; + object?: string; + scope?: string; +} -export interface PermissionValidationResponse extends PermissionValidationRequest{ - allowed: boolean; -}; +export interface PermissionValidationResponse extends PermissionValidationRequest { + allowed: boolean; +} // Libraries AuthZ types export interface TeamMember { From 7cb528b363721ba7506faabbfdf95f64acbdb329 Mon Sep 17 00:00:00 2001 From: Diana Olarte Date: Mon, 29 Sep 2025 21:26:47 +1000 Subject: [PATCH 17/17] fix: display username intead of name --- .../components/TeamTable.test.tsx | 6 ++--- .../components/TeamTable.tsx | 27 ++++++++++++++----- .../libraries-manager/components/messages.ts | 13 ++++++--- src/types.ts | 1 - 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/authz-module/libraries-manager/components/TeamTable.test.tsx b/src/authz-module/libraries-manager/components/TeamTable.test.tsx index 32ba801..a802e22 100644 --- a/src/authz-module/libraries-manager/components/TeamTable.test.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable.test.tsx @@ -23,13 +23,11 @@ jest.mock('../context', () => ({ describe('TeamTable', () => { const mockTeamMembers = [ { - displayName: 'Alice', email: 'alice@example.com', roles: ['Admin', 'Editor'], username: 'alice', }, { - displayName: 'Bob', email: 'bob@example.com', roles: ['Viewer'], username: 'bob', @@ -68,12 +66,12 @@ describe('TeamTable', () => { renderWrapper(); - expect(screen.getByText('Alice')).toBeInTheDocument(); + 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')).toBeInTheDocument(); expect(screen.getByText('bob@example.com')).toBeInTheDocument(); expect(screen.getByText('Viewer')).toBeInTheDocument(); }); diff --git a/src/authz-module/libraries-manager/components/TeamTable.tsx b/src/authz-module/libraries-manager/components/TeamTable.tsx index e008855..cca1d3c 100644 --- a/src/authz-module/libraries-manager/components/TeamTable.tsx +++ b/src/authz-module/libraries-manager/components/TeamTable.tsx @@ -25,11 +25,24 @@ const EmailCell = ({ row }: CellProps) => (row.original?.username === SKELETON_R row.original.email )); -const NameCell = ({ row }: CellProps) => (row.original.username === SKELETON_ROWS[0].username ? ( - -) : ( - row.original.displayName -)); +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 ? ( @@ -82,8 +95,8 @@ const TeamTable = () => { columns={ [ { - Header: intl.formatMessage(messages['library.authz.team.table.display.name']), - accessor: 'displayName', + Header: intl.formatMessage(messages['library.authz.team.table.username']), + accessor: 'username', Cell: NameCell, }, { diff --git a/src/authz-module/libraries-manager/components/messages.ts b/src/authz-module/libraries-manager/components/messages.ts index 70181e4..709ad1e 100644 --- a/src/authz-module/libraries-manager/components/messages.ts +++ b/src/authz-module/libraries-manager/components/messages.ts @@ -1,10 +1,15 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ - 'library.authz.team.table.display.name': { - id: 'library.authz.team.table.display.name', - defaultMessage: 'Name', - description: 'Libraries team management table name column header', + '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', diff --git a/src/types.ts b/src/types.ts index 0d4acfe..6ce5b7f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,7 +10,6 @@ export interface PermissionValidationResponse extends PermissionValidationReques // Libraries AuthZ types export interface TeamMember { - displayName: string; username: string; email: string; roles: string[];