diff --git a/apps/frontend/app/[locale]/register/page.tsx b/apps/frontend/app/[locale]/register/page.tsx index 1797076..c0d4645 100644 --- a/apps/frontend/app/[locale]/register/page.tsx +++ b/apps/frontend/app/[locale]/register/page.tsx @@ -5,6 +5,7 @@ import { useSearchParams } from 'next/navigation'; import { Link, useRouter } from '@/i18n/navigation'; import Image from 'next/image'; import { useTranslations } from 'next-intl'; +import { apiErrorMessage, apiErrorReference } from '@/lib/api/errors'; import { Eye, EyeOff } from 'lucide-react'; import { register } from '@/lib/api'; import { verifyInvitation, type InvitationResponse } from '@/lib/api/invitations'; @@ -98,6 +99,8 @@ function RegisterPageContent() { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); + // Reference d'incident renvoyee par l'API, a transmettre au support. + const [errorReference, setErrorReference] = useState(); const [invitationToken, setInvitationToken] = useState(null); const [invitation, setInvitation] = useState(null); @@ -137,6 +140,7 @@ function RegisterPageContent() { const handleStep1 = (e: React.FormEvent) => { e.preventDefault(); setError(''); + setErrorReference(undefined); const err = validateStep1(); if (err) { setError(err); @@ -162,6 +166,7 @@ function RegisterPageContent() { const handleStep2 = (e: React.FormEvent) => { e.preventDefault(); setError(''); + setErrorReference(undefined); const err = validateStep2(); if (err) { setError(err); @@ -173,6 +178,7 @@ function RegisterPageContent() { const handleFinalSubmit = async () => { setIsLoading(true); setError(''); + setErrorReference(undefined); try { await register({ @@ -197,8 +203,19 @@ function RegisterPageContent() { }), }); router.push('/dashboard'); - } catch (err: any) { - setError(err.message || t('errors.generic')); + } catch (err: unknown) { + // « Internal server error » et « Failed to fetch » ne veulent rien dire + // pour la personne qui cree son compte : on affiche ce qui s'est passe et + // ce qu'elle peut faire. + setError( + apiErrorMessage(err, { + network: t('errors.network'), + serviceUnavailable: t('errors.serviceUnavailable'), + unexpected: t('errors.unexpected'), + fallback: t('errors.generic'), + }) + ); + setErrorReference(apiErrorReference(err)); } finally { setIsLoading(false); } @@ -457,7 +474,14 @@ function RegisterPageContent() { d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> -

{error}

+
+

{error}

+ {errorReference && ( +

+ {t('errors.reference', { reference: errorReference })} +

+ )} +
)} @@ -759,6 +783,7 @@ function RegisterPageContent() { onClick={() => { setStep(1); setError(''); + setErrorReference(undefined); }} disabled={isLoading} className="btn-secondary flex-1 text-lg disabled:opacity-50" diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index 59b5395..5472d55 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -4611,7 +4611,11 @@ }, "errors": { "emailTaken": "This email address is already in use", - "generic": "Error while creating the account" + "generic": "Error while creating the account", + "network": "The server could not be reached. Check your connection and try again.", + "serviceUnavailable": "The service is temporarily unavailable. Try again in a moment; if the problem persists, email support@xpeditis.com.", + "unexpected": "Something went wrong on our side. Your account was not created. Try again, and if it happens again, send the reference below to support.", + "reference": "Reference to give support: {reference}" }, "sidePanel": { "titleInvitation": "Join your team", diff --git a/apps/frontend/messages/fr.json b/apps/frontend/messages/fr.json index 0932d43..3ebdf3c 100644 --- a/apps/frontend/messages/fr.json +++ b/apps/frontend/messages/fr.json @@ -4611,7 +4611,11 @@ }, "errors": { "emailTaken": "Cette adresse email est déjà utilisée", - "generic": "Erreur lors de la création du compte" + "generic": "Erreur lors de la création du compte", + "network": "Impossible de joindre le serveur. Vérifiez votre connexion, puis réessayez.", + "serviceUnavailable": "Service momentanément indisponible. Réessayez dans quelques instants ; si le problème persiste, écrivez à support@xpeditis.com.", + "unexpected": "Une erreur inattendue s’est produite de notre côté. Votre compte n’a pas été créé. Réessayez, et si cela se reproduit, transmettez la référence ci-dessous au support.", + "reference": "Référence à communiquer au support : {reference}" }, "sidePanel": { "titleInvitation": "Rejoignez votre équipe", diff --git a/apps/frontend/src/lib/api/client.ts b/apps/frontend/src/lib/api/client.ts index 7f12967..676c1b4 100644 --- a/apps/frontend/src/lib/api/client.ts +++ b/apps/frontend/src/lib/api/client.ts @@ -107,6 +107,11 @@ export function createMultipartHeaders(_includeAuth = true): HeadersInit { * API Error */ export class ApiError extends Error { + /** Code machine renvoyé par l'API, ou `network_error` si elle est injoignable. */ + public readonly code?: string; + /** Référence d'incident à transmettre au support. */ + public readonly reference?: string; + constructor( message: string, public statusCode: number, @@ -114,6 +119,8 @@ export class ApiError extends Error { ) { super(message); this.name = 'ApiError'; + this.code = response?.code; + this.reference = response?.reference; } } @@ -127,13 +134,20 @@ export async function apiRequest( ): Promise { const url = `${API_BASE_URL}${endpoint}`; - const response = await fetch(url, { - ...options, - credentials: 'include', - headers: { - ...options.headers, - }, - }); + let response: Response; + try { + response = await fetch(url, { + ...options, + credentials: 'include', + headers: { + ...options.headers, + }, + }); + } catch { + // Serveur injoignable, DNS, coupure réseau : `fetch` rejette avec un + // « Failed to fetch » qui n'a rien à faire sous les yeux d'un utilisateur. + throw new ApiError('', 0, { code: 'network_error' }); + } // Handle 401 Unauthorized - token expired // Skip auto-redirect for auth endpoints (login, register, refresh) - they handle their own errors diff --git a/apps/frontend/src/lib/api/errors.ts b/apps/frontend/src/lib/api/errors.ts new file mode 100644 index 0000000..a7481f8 --- /dev/null +++ b/apps/frontend/src/lib/api/errors.ts @@ -0,0 +1,50 @@ +import { ApiError } from './client'; + +/** + * Message d'erreur destiné à un écran. + * + * Une page ne doit jamais afficher « Internal server error », ni « Failed to + * fetch », ni une trace : ces textes ne disent pas ce qui s'est passé, ne + * disent pas quoi faire, et n'existent dans aucune langue. + * + * L'ordre de préférence : + * + * 1. Le **code** renvoyé par l'API, traduit ici — c'est le cas des pannes + * (`service_unavailable`, `unexpected_error`) et de la perte de réseau. + * 2. Le **message** du serveur, déjà traduit et rédigé pour l'utilisateur : + * « Cette adresse email est déjà utilisée », les erreurs de validation… + * 3. Un repli fourni par l'appelant. + */ +export interface ApiErrorLabels { + network: string; + serviceUnavailable: string; + unexpected: string; + fallback: string; +} + +export function apiErrorMessage(error: unknown, labels: ApiErrorLabels): string { + if (error instanceof ApiError) { + switch (error.code) { + case 'network_error': + return labels.network; + case 'service_unavailable': + return labels.serviceUnavailable; + case 'unexpected_error': + return labels.unexpected; + } + + // Une liste, c'est la validation champ par champ : la première suffit à + // corriger, les suivantes sont déjà signalées sous les champs. + const message = error.response?.message; + if (Array.isArray(message) && message.length) return String(message[0]); + if (typeof message === 'string' && message) return message; + if (error.message) return error.message; + } + + return labels.fallback; +} + +/** Référence d'incident à montrer sous le message, quand le serveur en donne une. */ +export function apiErrorReference(error: unknown): string | undefined { + return error instanceof ApiError ? error.reference : undefined; +}