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:
1
.env
1
.env
@@ -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=''
|
||||
|
||||
@@ -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
2
package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
73
src/authz-module/libraries-manager/ErrorPage/index.test.tsx
Normal file
73
src/authz-module/libraries-manager/ErrorPage/index.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
92
src/authz-module/libraries-manager/ErrorPage/index.tsx
Normal file
92
src/authz-module/libraries-manager/ErrorPage/index.tsx
Normal 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;
|
||||
56
src/authz-module/libraries-manager/ErrorPage/messages.ts
Normal file
56
src/authz-module/libraries-manager/ErrorPage/messages.ts
Normal 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;
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user