diff --git a/.env b/.env index fb27c774..00c7c433 100644 --- a/.env +++ b/.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='' diff --git a/.env.development b/.env.development index 9c029de7..67b3cf11 100644 --- a/.env.development +++ b/.env.development @@ -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='' diff --git a/package-lock.json b/package-lock.json index 32b73312..d364fb68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,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": { @@ -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" }, diff --git a/package.json b/package.json index e0e23aac..50ac43b4 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/authz-module/index.tsx b/src/authz-module/index.tsx index 3f7d0172..d6e42188 100644 --- a/src/authz-module/index.tsx +++ b/src/authz-module/index.tsx @@ -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 = () => ( - - }> - - }> - } /> - } /> - - - - + + {({ reset }) => ( + + }> + + }> + } /> + } /> + + + + + )} + ); export default AuthZModule; diff --git a/src/authz-module/libraries-manager/ErrorPage/index.test.tsx b/src/authz-module/libraries-manager/ErrorPage/index.test.tsx new file mode 100644 index 00000000..3c08a3da --- /dev/null +++ b/src/authz-module/libraries-manager/ErrorPage/index.test.tsx @@ -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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + const user = userEvent.setup(); + await user.click(screen.getByText(/Reload Page/i)); + expect(refetch).toHaveBeenCalled(); + }); +}); diff --git a/src/authz-module/libraries-manager/ErrorPage/index.tsx b/src/authz-module/libraries-manager/ErrorPage/index.tsx new file mode 100644 index 00000000..65d3f155 --- /dev/null +++ b/src/authz-module/libraries-manager/ErrorPage/index.tsx @@ -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 ( + +

{statusCode}

+

{intl.formatMessage(title)}

+

{intl.formatMessage(description)}

+ + {showReloadButton && ( + + )} + {showBackButton && ( + + )} + + +
+ ); +}; + +const LibrariesErrorFallback = (props: FallbackProps) => ; +export default LibrariesErrorFallback; diff --git a/src/authz-module/libraries-manager/ErrorPage/messages.ts b/src/authz-module/libraries-manager/ErrorPage/messages.ts new file mode 100644 index 00000000..9d86ff5b --- /dev/null +++ b/src/authz-module/libraries-manager/ErrorPage/messages.ts @@ -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; diff --git a/src/authz-module/libraries-manager/context.test.tsx b/src/authz-module/libraries-manager/context.test.tsx index 28f039d9..e4b7a676 100644 --- a/src/authz-module/libraries-manager/context.test.tsx +++ b/src/authz-module/libraries-manager/context.test.tsx @@ -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', () => ({ @@ -112,7 +113,7 @@ describe('LibraryAuthZProvider', () => { , ); - }).toThrow('NoAccess'); + }).toThrow(CustomErrors.NO_ACCESS); }); it('provides context when user can view but not manage team', () => { @@ -141,7 +142,7 @@ describe('LibraryAuthZProvider', () => { , ); - }).toThrow('MissingLibrary'); + }).toThrow(CustomErrors.NOT_FOUND); }); it('throws error when useLibraryAuthZ is used outside provider', () => { diff --git a/src/authz-module/libraries-manager/context.tsx b/src/authz-module/libraries-manager/context.tsx index 419744d1..18ac0c1b 100644 --- a/src/authz-module/libraries-manager/context.tsx +++ b/src/authz-module/libraries-manager/context.tsx @@ -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 = ({ 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 = ({ 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); diff --git a/src/constants.ts b/src/constants.ts index 0d9b840d..9a3fed44 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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], +}; diff --git a/src/index.tsx b/src/index.tsx index 04085945..cbd7cf93 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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'); + }, + }, });