Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -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=''
Expand Down
4 changes: 3 additions & 1 deletion .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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=''
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@tanstack/react-query": "5.89.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-error-boundary": "^4.1.2",
"react-router-dom": "^6.0.0"
},
"devDependencies": {
Expand Down
28 changes: 17 additions & 11 deletions src/authz-module/index.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 73 additions & 0 deletions src/authz-module/libraries-manager/ErrorPage/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 92 additions & 0 deletions src/authz-module/libraries-manager/ErrorPage/index.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 56 additions & 0 deletions src/authz-module/libraries-manager/ErrorPage/messages.ts
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 3 additions & 2 deletions src/authz-module/libraries-manager/context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,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', () => ({
Expand Down Expand Up @@ -112,7 +113,7 @@ describe('LibraryAuthZProvider', () => {
<TestComponent />
</LibraryAuthZProvider>,
);
}).toThrow('NoAccess');
}).toThrow(CustomErrors.NO_ACCESS);
});

it('provides context when user can view but not manage team', () => {
Expand Down Expand Up @@ -141,7 +142,7 @@ describe('LibraryAuthZProvider', () => {
<TestComponent />
</LibraryAuthZProvider>,
);
}).toThrow('MissingLibrary');
}).toThrow(CustomErrors.NOT_FOUND);
});

it('throws error when useLibraryAuthZ is used outside provider', () => {
Expand Down
5 changes: 3 additions & 2 deletions src/authz-module/libraries-manager/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -38,15 +39,15 @@ 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 }));

const { data: userPermissions } = useValidateUserPermissions(permissions);
const [{ allowed: canViewTeam }, { allowed: canManageTeam }] = userPermissions;

if (!canViewTeam && !canManageTeam) {
throw new Error('NoAccess');
throw new Error(CustomErrors.NO_ACCESS);
}

const { data: libraryRoles } = usePermissionsByRole(libraryId);
Expand Down
16 changes: 16 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -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],
};
Loading