️(frontend) redirect unmanaged 5xx to dedicated /500 page

Add /500 with coffee illustration; replace inline TextErrors for API 5xx
This commit is contained in:
Cyril 2026-04-07 10:49:01 +02:00
parent 31fea43729
commit 9a5d81f983
No known key found for this signature in database
GPG key ID: D5E8474B0AB0064A
11 changed files with 234 additions and 171 deletions

View file

@ -33,6 +33,48 @@ test.describe('Doc Routing', () => {
await expect(page).toHaveURL(/\/docs\/$/);
});
test('checks 500 refresh retries original document request', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(page, 'doc-routing-500', browserName, 1);
await verifyDocName(page, docTitle);
const docId = page.url().split('/docs/')[1]?.split('/')[0];
// While true, every doc GET fails (including React Query retries) so we
// reliably land on /500. Set to false before refresh so the doc loads again.
let failDocumentGet = true;
await page.route(/\**\/documents\/\**/, async (route) => {
const request = route.request();
if (
failDocumentGet &&
request.method().includes('GET') &&
docId &&
request.url().includes(`/documents/${docId}/`)
) {
await route.fulfill({
status: 500,
json: { detail: 'Internal Server Error' },
});
} else {
await route.continue();
}
});
await page.reload();
await expect(page).toHaveURL(/\/500\/?\?from=/, { timeout: 15000 });
const refreshButton = page.getByRole('button', { name: 'Refresh page' });
await expect(refreshButton).toBeVisible();
failDocumentGet = false;
await refreshButton.click();
await verifyDocName(page, docTitle);
});
test('checks 404 on docs/[id] page', async ({ page }) => {
await page.waitForTimeout(300);

View file

@ -0,0 +1,134 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import Head from 'next/head';
import Image, { StaticImageData } from 'next/image';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { Box, Icon, StyledLink, Text } from '@/components';
const StyledButton = styled(Button)`
width: fit-content;
`;
interface ErrorPageProps {
image: StaticImageData;
description: string;
refreshTarget?: string;
showReload?: boolean;
}
const getSafeRefreshUrl = (target?: string): string | undefined => {
if (!target) {
return undefined;
}
if (typeof window === 'undefined') {
return target.startsWith('/') && !target.startsWith('//')
? target
: undefined;
}
try {
const url = new URL(target, window.location.origin);
if (url.origin !== window.location.origin) {
return undefined;
}
return url.pathname + url.search + url.hash;
} catch {
return undefined;
}
};
export const ErrorPage = ({
image,
description,
refreshTarget,
showReload,
}: ErrorPageProps) => {
const { t } = useTranslation();
const errorTitle = t('An unexpected error occurred.');
const safeTarget = getSafeRefreshUrl(refreshTarget);
return (
<>
<Head>
<title>
{errorTitle} - {t('Docs')}
</title>
<meta
property="og:title"
content={`${errorTitle} - ${t('Docs')}`}
key="title"
/>
</Head>
<Box
$align="center"
$margin="auto"
$gap="md"
$padding={{ bottom: '2rem' }}
>
<Text as="h1" $textAlign="center" className="sr-only">
{errorTitle} - {t('Docs')}
</Text>
<Image
src={image}
alt=""
width={300}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<Text
as="p"
$textAlign="center"
$maxWidth="350px"
$theme="neutral"
$margin="0"
>
{description}
</Text>
<Box $direction="row" $gap="sm">
<StyledLink href="/">
<StyledButton
color="neutral"
icon={
<Icon
iconName="house"
variant="symbols-outlined"
$withThemeInherited
/>
}
>
{t('Home')}
</StyledButton>
</StyledLink>
{(safeTarget || showReload) && (
<StyledButton
color="neutral"
variant="bordered"
icon={
<Icon
iconName="refresh"
variant="symbols-outlined"
$withThemeInherited
/>
}
onClick={() =>
safeTarget
? window.location.assign(safeTarget)
: window.location.reload()
}
>
{t('Refresh page')}
</StyledButton>
)}
</Box>
</Box>
</>
);
};

View file

@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { Box, Text, TextType } from '@/components';
import { useHttpErrorMessages } from '@/hooks';
const AlertStyled = styled(Alert)`
& .c__button--tertiary:hover {
@ -17,7 +16,6 @@ interface TextErrorsProps extends TextType {
defaultMessage?: string;
icon?: ReactNode;
canClose?: boolean;
status?: number;
}
export const TextErrors = ({
@ -25,7 +23,6 @@ export const TextErrors = ({
defaultMessage,
icon,
canClose = false,
status,
...textProps
}: TextErrorsProps) => {
return (
@ -38,7 +35,6 @@ export const TextErrors = ({
<TextOnlyErrors
causes={causes}
defaultMessage={defaultMessage}
status={status}
{...textProps}
/>
</AlertStyled>
@ -48,39 +44,9 @@ export const TextErrors = ({
export const TextOnlyErrors = ({
causes,
defaultMessage,
status,
...textProps
}: TextErrorsProps) => {
const { t } = useTranslation();
const httpError = useHttpErrorMessages(status);
if (httpError) {
return (
<Box $direction="column" $gap="0.2rem">
<Text
as="h1"
$theme="error"
$textAlign="center"
$margin="0"
$size="1rem"
$weight="unset"
{...textProps}
>
{httpError.title}
</Text>
<Text
as="p"
$theme="error"
$textAlign="center"
$margin="0"
$size="0.875rem"
{...textProps}
>
{httpError.detail}
</Text>
</Box>
);
}
return (
<Box $direction="column" $gap="0.2rem">

View file

@ -4,6 +4,7 @@ export * from './Card';
export * from './DropButton';
export * from './dropdown-menu/DropdownMenu';
export * from './Emoji/EmojiPicker';
export * from './ErrorPage';
export * from './quick-search';
export * from './Icon';
export * from './InfiniteScroll';

View file

@ -58,7 +58,6 @@ export const DocVersionEditor = ({
<Box $margin="large" className="--docs--doc-version-editor-error">
<TextErrors
causes={error.cause}
status={error.status}
icon={
error.status === 502 ? (
<Text

View file

@ -62,7 +62,6 @@ const VersionListState = ({
>
<TextErrors
causes={error.cause}
status={error.status}
icon={
error.status === 502 ? (
<Icon iconName="wifi_off" $theme="danger" />

View file

@ -1,5 +1,4 @@
export * from './useClipboard';
export * from './useCmdK';
export * from './useDate';
export * from './useHttpErrorMessages';
export * from './useKeyboardAction';

View file

@ -1,26 +0,0 @@
import { useTranslation } from 'react-i18next';
export const useHttpErrorMessages = (status?: number) => {
const { t } = useTranslation();
const messages: Record<number, { title: string; detail: string }> = {
500: {
title: t('500 - Internal Server Error'),
detail: t('The server met an unexpected condition.'),
},
502: {
title: t('502 - Bad Gateway'),
detail: t(
'The server received an invalid response. Please check your connection and try again.',
),
},
503: {
title: t('503 - Service Unavailable'),
detail: t(
'The service is temporarily unavailable. Please try again later.',
),
},
};
return status ? messages[status] : undefined;
};

View file

@ -0,0 +1,32 @@
import { useRouter } from 'next/router';
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import error_img from '@/assets/icons/error-coffee.png';
import { ErrorPage } from '@/components';
import { PageLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
const { query } = useRouter();
const from = Array.isArray(query.from) ? query.from[0] : query.from;
const refreshTarget =
from?.startsWith('/') && !from.startsWith('//') ? from : undefined;
return (
<ErrorPage
image={error_img}
description={t(
'An unexpected error occurred. Go grab a coffee or try to refresh the page.',
)}
refreshTarget={refreshTarget}
/>
);
};
Page.getLayout = function getLayout(page: ReactElement) {
return <PageLayout withFooter={false}>{page}</PageLayout>;
};
export default Page;

View file

@ -1,100 +1,22 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import * as Sentry from '@sentry/nextjs';
import { NextPageContext } from 'next';
import NextError from 'next/error';
import Head from 'next/head';
import Image from 'next/image';
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import error_img from '@/assets/icons/error-planetes.png';
import { Box, Icon, StyledLink, Text } from '@/components';
import { ErrorPage } from '@/components';
import { PageLayout } from '@/layouts';
const StyledButton = styled(Button)`
width: fit-content;
`;
const Error = () => {
const { t } = useTranslation();
const errorTitle = t('An unexpected error occurred.');
return (
<>
<Head>
<title>
{errorTitle} - {t('Docs')}
</title>
<meta
property="og:title"
content={`${errorTitle} - ${t('Docs')}`}
key="title"
/>
</Head>
<Box
$align="center"
$margin="auto"
$gap="md"
$padding={{ bottom: '2rem' }}
>
<Text as="h2" $textAlign="center" className="sr-only">
{errorTitle} - {t('Docs')}
</Text>
<Image
src={error_img}
alt=""
width={300}
style={{
maxWidth: '100%',
height: 'auto',
}}
/>
<Text
as="p"
$textAlign="center"
$maxWidth="350px"
$theme="neutral"
$margin="0"
>
{errorTitle}
</Text>
<Box $direction="row" $gap="sm">
<StyledLink href="/">
<StyledButton
color="neutral"
icon={
<Icon
iconName="house"
variant="symbols-outlined"
$withThemeInherited
/>
}
>
{t('Home')}
</StyledButton>
</StyledLink>
<StyledButton
color="neutral"
variant="bordered"
icon={
<Icon
iconName="refresh"
variant="symbols-outlined"
$withThemeInherited
/>
}
onClick={() => window.location.reload()}
>
{t('Refresh page')}
</StyledButton>
</Box>
</Box>
</>
<ErrorPage
image={error_img}
description={t('An unexpected error occurred.')}
showReload
/>
);
};

View file

@ -6,7 +6,7 @@ import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Loading, TextErrors } from '@/components';
import { Loading } from '@/components';
import { DEFAULT_QUERY_RETRY } from '@/core';
import {
Doc,
@ -105,7 +105,7 @@ const DocPage = ({ id }: DocProps) => {
const { setCurrentDoc } = useDocStore();
const { addTask } = useBroadcastStore();
const queryClient = useQueryClient();
const { replace } = useRouter();
const { replace, asPath } = useRouter();
useCollaboration(doc?.id, doc?.content);
const { t } = useTranslation();
const { authenticated } = useAuth();
@ -191,44 +191,39 @@ const DocPage = ({ id }: DocProps) => {
}, [addTask, doc?.id, queryClient]);
useEffect(() => {
if (!isError || !error?.status || ![404, 401].includes(error.status)) {
if (!isError || !error?.status || [403].includes(error.status)) {
return;
}
let replacePath = `/${error.status}`;
if (error.status === 401) {
if (authenticated) {
queryClient.setQueryData([KEY_AUTH], null);
}
setAuthUrl();
void replace('/401');
return;
}
void replace(replacePath);
}, [isError, error?.status, replace, authenticated, queryClient]);
if (error.status === 404) {
void replace('/404');
return;
}
if (error.status === 502) {
void replace('/offline');
return;
}
const fromPath = encodeURIComponent(asPath);
void replace(`/500?from=${fromPath}`);
}, [isError, error?.status, replace, authenticated, queryClient, asPath]);
if (isError && error?.status) {
if ([404, 401].includes(error.status)) {
return <Loading />;
}
if (error.status === 403) {
return <DocPage403 id={id} />;
}
return (
<Box $margin="large">
<TextErrors
causes={error.cause}
status={error.status}
icon={
error.status === 502 ? (
<Icon iconName="wifi_off" $theme="danger" $withThemeInherited />
) : undefined
}
/>
</Box>
);
return <Loading />;
}
if (!doc) {