feat(authz): [FC-0099] create an custom error boundery (#11)

Create a custom error boundary for libraries team management workflow.
This commit is contained in:
Diana Olarte
2025-10-24 03:39:46 +11:00
committed by GitHub
parent b332f625d7
commit 2e1dc75f17
12 changed files with 274 additions and 18 deletions

1
.env
View File

@@ -1,6 +1,7 @@
NODE_ENV='production'
ACCESS_TOKEN_COOKIE_NAME=''
BASE_URL=''
COURSE_AUTHORING_MICROFRONTEND_URL= ''
CREDENTIALS_BASE_URL=''
CSRF_TOKEN_API_PATH=''
ECOMMERCE_BASE_URL=''

View File

@@ -2,6 +2,7 @@ NODE_ENV='development'
PORT=2025
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='http://localhost:2025'
COURSE_AUTHORING_MICROFRONTEND_URL= 'http://apps.local.openedx.io:2001/authoring'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
ECOMMERCE_BASE_URL='http://localhost:18130'
@@ -15,9 +16,10 @@ LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://local.openedx.io:8000/login_refresh'
SEGMENT_KEY=''
SITE_NAME=localhost
STUDIO_BASE_URL='http://studio.local.openedx.io:8001'
USER_INFO_COOKIE_NAME='edx-user-info'
APP_ID='admin-console'
MFE_CONFIG_API_URL=''

2
package-lock.json generated
View File

@@ -19,6 +19,7 @@
"lodash.debounce": "^4.0.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.1.2",
"react-router-dom": "^6.0.0"
},
"devDependencies": {
@@ -23798,7 +23799,6 @@
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.1.2.tgz",
"integrity": "sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.12.5"
},

View File

@@ -46,6 +46,7 @@
"lodash.debounce": "^4.0.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.1.2",
"react-router-dom": "^6.0.0"
},
"devDependencies": {

View File

@@ -1,23 +1,29 @@
import { Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import { ErrorBoundary } from '@edx/frontend-platform/react';
import { ErrorBoundary } from 'react-error-boundary';
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import LoadingPage from '@src/components/LoadingPage';
import LibrariesErrorFallback from '@src/authz-module/libraries-manager/ErrorPage';
import { LibrariesTeamManager, LibrariesUserManager, LibrariesLayout } from './libraries-manager';
import { ROUTES } from './constants';
import './index.scss';
const AuthZModule = () => (
<ErrorBoundary>
<Suspense fallback={<LoadingPage />}>
<Routes>
<Route element={<LibrariesLayout />}>
<Route path={ROUTES.LIBRARIES_TEAM_PATH} element={<LibrariesTeamManager />} />
<Route path={ROUTES.LIBRARIES_USER_PATH} element={<LibrariesUserManager />} />
</Route>
</Routes>
</Suspense>
</ErrorBoundary>
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary fallbackRender={LibrariesErrorFallback} onReset={reset}>
<Suspense fallback={<LoadingPage />}>
<Routes>
<Route element={<LibrariesLayout />}>
<Route path={ROUTES.LIBRARIES_TEAM_PATH} element={<LibrariesTeamManager />} />
<Route path={ROUTES.LIBRARIES_USER_PATH} element={<LibrariesUserManager />} />
</Route>
</Routes>
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
export default AuthZModule;

View File

@@ -0,0 +1,73 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ErrorBoundary } from 'react-error-boundary';
import { renderWrapper } from '@src/setupTest';
import LibrariesErrorFallback from './index';
const ThrowError = ({ error }: { error:Error }) => {
throw error;
return null;
};
describe('LibrariesErrorFallback', () => {
it('renders Access Denied for 401', () => {
const error = { name: '', message: 'NO_ACCESS', customAtributtes: { httpErrorStatus: 401 } };
renderWrapper(
<ErrorBoundary FallbackComponent={LibrariesErrorFallback}>
<ThrowError error={error} />
</ErrorBoundary>,
);
expect(screen.getByText(/Access Denied/i)).toBeInTheDocument();
expect(screen.getByText(/Back to Libraries/i)).toBeInTheDocument();
});
it('renders Not Found for 404', () => {
const error = { name: '', message: 'NOT_FOUND', customAtributtes: { httpErrorStatus: 404 } };
renderWrapper(
<ErrorBoundary FallbackComponent={LibrariesErrorFallback}>
<ThrowError error={error} />
</ErrorBoundary>,
);
expect(screen.getByText(/Page Not Found/i)).toBeInTheDocument();
expect(screen.getByText(/Back to Libraries/i)).toBeInTheDocument();
});
it('renders Server Error for 500 and shows reload', async () => {
const error = { name: '', message: 'SERVER_ERROR', customAtributtes: { httpErrorStatus: 500 } };
renderWrapper(
<ErrorBoundary FallbackComponent={LibrariesErrorFallback}>
<ThrowError error={error} />
</ErrorBoundary>,
);
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
expect(screen.getByText(/Reload Page/i)).toBeInTheDocument();
expect(screen.getByText(/Back to Libraries/i)).toBeInTheDocument();
});
it('renders generic error for other error error', () => {
const error = { name: '', message: 'SOMETHING_ELSE', customAtributtes: { httpErrorStatus: 418 } };
renderWrapper(
<ErrorBoundary FallbackComponent={LibrariesErrorFallback}>
<ThrowError error={error} />
</ErrorBoundary>,
);
expect(screen.getByText(/Error/i)).toBeInTheDocument();
expect(screen.getByText(/Back to Libraries/i)).toBeInTheDocument();
});
it('calls reload action if present', async () => {
// Simulate error with a refetch function
const refetch = jest.fn();
const error = {
name: '', message: 'SERVER_ERROR', customAtributtes: { httpErrorStatus: 500 }, refetch,
};
renderWrapper(
<ErrorBoundary FallbackComponent={LibrariesErrorFallback} onReset={refetch}>
<ThrowError error={error} />
</ErrorBoundary>,
);
const user = userEvent.setup();
await user.click(screen.getByText(/Reload Page/i));
expect(refetch).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,92 @@
import { useState } from 'react';
import { FallbackProps } from 'react-error-boundary';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button, Container, Hyperlink, Row,
} from '@openedx/paragon';
import { CustomErrors, ERROR_STATUS } from '@src/constants';
import messages from './messages';
const getErrorConfig = ({ errorMessage, errorStatus }) => {
if (errorMessage === CustomErrors.NO_ACCESS || ERROR_STATUS.NO_ACCESS.includes(errorStatus)) {
return ({
title: messages['error.page.title.noAccess'],
description: messages['error.page.message.noAccess'],
statusCode: errorStatus || ERROR_STATUS.NO_ACCESS[0],
showBackButton: true,
});
}
if (errorMessage === CustomErrors.NOT_FOUND || ERROR_STATUS.NOT_FOUND.includes(errorStatus)) {
return ({
title: messages['error.page.title.notFound'],
description: messages['error.page.message.notFound'],
statusCode: errorStatus || ERROR_STATUS.NOT_FOUND[0],
showBackButton: true,
});
}
if (errorMessage === CustomErrors.SERVER_ERROR || ERROR_STATUS.SERVER_ERROR.includes(errorStatus)) {
return ({
title: messages['error.page.title.server'],
description: messages['error.page.message.server'],
statusCode: errorStatus || ERROR_STATUS.SERVER_ERROR[0],
showBackButton: true,
showReloadButton: true,
});
}
return ({
title: messages['error.page.title.generic'],
description: messages['error.page.message.generic'],
showBackButton: true,
showReloadButton: true,
});
};
const ErrorPage = ({ error, resetErrorBoundary }: FallbackProps) => {
const intl = useIntl();
const [reloading, setReloading] = useState(false);
const errorStatus: number = error?.customAttributes?.httpErrorStatus;
const errorMessage: string = error?.message;
const {
title, description, statusCode, showBackButton, showReloadButton,
} = getErrorConfig({ errorMessage, errorStatus });
const handleReload = () => {
setReloading(true);
resetErrorBoundary();
};
return (
<Container className="d-flex flex-column align-items-center justify-content-center min-vh-100 bg-light-200">
<h1 className="display-4 text-primary-200">{statusCode}</h1>
<h1 className="text-primary">{intl.formatMessage(title)}</h1>
<p>{intl.formatMessage(description)}</p>
<Row>
{showReloadButton && (
<Button
className="m-2"
disabled={reloading}
onClick={handleReload}
>
{intl.formatMessage(messages['error.page.action.reload'])}
</Button>
)}
{showBackButton && (
<Button
as={Hyperlink}
destination={`${getConfig().COURSE_AUTHORING_MICROFRONTEND_URL}/libraries`}
className="m-2"
variant={showReloadButton ? 'outline-primary' : 'primary'}
>
{intl.formatMessage(messages['error.page.action.back'])}
</Button>
)}
</Row>
</Container>
);
};
const LibrariesErrorFallback = (props: FallbackProps) => <ErrorPage {...props} />;
export default LibrariesErrorFallback;

View File

@@ -0,0 +1,56 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'error.page.title.noAccess': {
id: 'error.page.tile.noAccess',
defaultMessage: 'Access Denied',
description: 'Title error when user does not have access to view',
},
'error.page.message.noAccess': {
id: 'error.page.message.noAccess',
defaultMessage: 'You do not have permission to view this page.',
description: 'Error message when user does not have access to view',
},
'error.page.title.notFound': {
id: 'error.page.tile.notFound',
defaultMessage: 'Page Not Found',
description: 'Error the resource is not found',
},
'error.page.message.notFound': {
id: 'error.page.message.notFound',
defaultMessage: 'The library you are looking for could not be found.',
description: 'Error message when the resource is not found',
},
'error.page.title.server': {
id: 'error.page.tile.server',
defaultMessage: 'Something went wrong',
description: 'Title for server error',
},
'error.page.message.server': {
id: 'error.page.message.server.error',
defaultMessage: 'We\'re experiencing an internal server problem. Please try again later',
description: 'Server error message for unexpected errors',
},
'error.page.title.generic': {
id: 'error.page.tile.generic',
defaultMessage: 'Something went wrong',
description: 'Title for unexpected error',
},
'error.page.message.generic': {
id: 'error.page.message.server',
defaultMessage: 'An unexpected error occurred. Please click the button below to refresh the page.',
description: 'Error message for unexpected errors',
},
'error.page.action.reload': {
id: 'error.page.action.reload',
defaultMessage: 'Reload Page',
description: 'Label for reload action',
},
'error.page.action.back': {
id: 'error.page.action.back',
defaultMessage: 'Back to Libraries',
description: 'Label for return to libraries action',
},
});
export default messages;

View File

@@ -4,6 +4,7 @@ import { useParams } from 'react-router-dom';
import { useValidateUserPermissions } from '@src/data/hooks';
import { renderWrapper } from '@src/setupTest';
import { usePermissionsByRole } from '@src/authz-module/data/hooks';
import { CustomErrors } from '@src/constants';
import { LibraryAuthZProvider, useLibraryAuthZ } from './context';
jest.mock('react-router-dom', () => ({
@@ -130,7 +131,7 @@ describe('LibraryAuthZProvider', () => {
<TestComponent />
</LibraryAuthZProvider>,
);
}).toThrow('NoAccess');
}).toThrow(CustomErrors.NO_ACCESS);
});
it('provides context when user can view but not manage team', () => {
@@ -161,7 +162,7 @@ describe('LibraryAuthZProvider', () => {
</LibraryAuthZProvider>
</ErrorBoundary>,
);
}).toThrow('MissingLibrary');
}).toThrow(CustomErrors.NOT_FOUND);
});
it('throws error when useLibraryAuthZ is used outside provider', () => {

View File

@@ -6,6 +6,7 @@ import { AppContext } from '@edx/frontend-platform/react';
import { useValidateUserPermissions } from '@src/data/hooks';
import { usePermissionsByRole } from '@src/authz-module/data/hooks';
import { PermissionMetadata, ResourceMetadata, Role } from 'types';
import { CustomErrors } from '@src/constants';
import { libraryPermissions, libraryResourceTypes, libraryRolesMetadata } from './constants';
const LIBRARY_TEAM_PERMISSIONS = ['view_library_team', 'manage_library_team'];
@@ -38,7 +39,7 @@ export const LibraryAuthZProvider: React.FC<AuthZProviderProps> = ({ children }:
// TODO: Implement a custom error view
if (!libraryId) {
throw new Error('MissingLibrary');
throw new Error(CustomErrors.NOT_FOUND);
}
const permissions = LIBRARY_TEAM_PERMISSIONS.map(action => ({ action, scope: libraryId }));
@@ -46,7 +47,7 @@ export const LibraryAuthZProvider: React.FC<AuthZProviderProps> = ({ children }:
const [{ allowed: canViewTeam }, { allowed: canManageTeam }] = userPermissions;
if (!canViewTeam && !canManageTeam) {
throw new Error('NoAccess');
throw new Error(CustomErrors.NO_ACCESS);
}
const { data: libraryRoles } = usePermissionsByRole(libraryId);

View File

@@ -1 +1,17 @@
export const appId = 'org.openedx.frontend.app.adminConsole';
export enum CustomErrors {
NO_ACCESS = 'NO_ACCESS',
NOT_FOUND = 'NOT_FOUND',
SERVER_ERROR = 'SERVER_ERROR',
}
type ErrorStatusCode = {
[key in CustomErrors]: number[];
};
export const ERROR_STATUS: ErrorStatusCode = {
[CustomErrors.NO_ACCESS]: [403, 401],
[CustomErrors.NOT_FOUND]: [404],
[CustomErrors.SERVER_ERROR]: [500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511],
};

View File

@@ -4,7 +4,7 @@ import { Route, Routes } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AppProvider, ErrorPage } from '@edx/frontend-platform/react';
import {
APP_INIT_ERROR, APP_READY, subscribe, initialize,
APP_INIT_ERROR, APP_READY, subscribe, initialize, mergeConfig,
} from '@edx/frontend-platform';
import AuthZModule from 'authz-module';
@@ -43,4 +43,11 @@ subscribe(APP_INIT_ERROR, (error) => {
initialize({
messages,
requireAuthenticatedUser: true,
handlers: {
config: () => {
mergeConfig({
COURSE_AUTHORING_MICROFRONTEND_URL: process.env.COURSE_AUTHORING_MICROFRONTEND_URL || null,
}, 'AdminConsoleAppConfig');
},
},
});