fix(ui): traduire les erreurs d API au lieu de les afficher brutes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
This commit is contained in:
David 2026-09-07 21:40:55 +02:00
parent 67362461e4
commit 672abea1be
5 changed files with 109 additions and 12 deletions

View File

@ -5,6 +5,7 @@ import { useSearchParams } from 'next/navigation';
import { Link, useRouter } from '@/i18n/navigation'; import { Link, useRouter } from '@/i18n/navigation';
import Image from 'next/image'; import Image from 'next/image';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { apiErrorMessage, apiErrorReference } from '@/lib/api/errors';
import { Eye, EyeOff } from 'lucide-react'; import { Eye, EyeOff } from 'lucide-react';
import { register } from '@/lib/api'; import { register } from '@/lib/api';
import { verifyInvitation, type InvitationResponse } from '@/lib/api/invitations'; import { verifyInvitation, type InvitationResponse } from '@/lib/api/invitations';
@ -98,6 +99,8 @@ function RegisterPageContent() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
// Reference d'incident renvoyee par l'API, a transmettre au support.
const [errorReference, setErrorReference] = useState<string | undefined>();
const [invitationToken, setInvitationToken] = useState<string | null>(null); const [invitationToken, setInvitationToken] = useState<string | null>(null);
const [invitation, setInvitation] = useState<InvitationResponse | null>(null); const [invitation, setInvitation] = useState<InvitationResponse | null>(null);
@ -137,6 +140,7 @@ function RegisterPageContent() {
const handleStep1 = (e: React.FormEvent) => { const handleStep1 = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
setErrorReference(undefined);
const err = validateStep1(); const err = validateStep1();
if (err) { if (err) {
setError(err); setError(err);
@ -162,6 +166,7 @@ function RegisterPageContent() {
const handleStep2 = (e: React.FormEvent) => { const handleStep2 = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
setErrorReference(undefined);
const err = validateStep2(); const err = validateStep2();
if (err) { if (err) {
setError(err); setError(err);
@ -173,6 +178,7 @@ function RegisterPageContent() {
const handleFinalSubmit = async () => { const handleFinalSubmit = async () => {
setIsLoading(true); setIsLoading(true);
setError(''); setError('');
setErrorReference(undefined);
try { try {
await register({ await register({
@ -197,8 +203,19 @@ function RegisterPageContent() {
}), }),
}); });
router.push('/dashboard'); router.push('/dashboard');
} catch (err: any) { } catch (err: unknown) {
setError(err.message || t('errors.generic')); // « 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 { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -457,7 +474,14 @@ function RegisterPageContent() {
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/> />
</svg> </svg>
<p className="text-body-sm text-red-800">{error}</p> <div className="min-w-0">
<p className="text-body-sm text-red-800">{error}</p>
{errorReference && (
<p className="mt-1 text-body-xs text-red-700">
{t('errors.reference', { reference: errorReference })}
</p>
)}
</div>
</div> </div>
)} )}
@ -759,6 +783,7 @@ function RegisterPageContent() {
onClick={() => { onClick={() => {
setStep(1); setStep(1);
setError(''); setError('');
setErrorReference(undefined);
}} }}
disabled={isLoading} disabled={isLoading}
className="btn-secondary flex-1 text-lg disabled:opacity-50" className="btn-secondary flex-1 text-lg disabled:opacity-50"

View File

@ -4611,7 +4611,11 @@
}, },
"errors": { "errors": {
"emailTaken": "This email address is already in use", "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": { "sidePanel": {
"titleInvitation": "Join your team", "titleInvitation": "Join your team",

View File

@ -4611,7 +4611,11 @@
}, },
"errors": { "errors": {
"emailTaken": "Cette adresse email est déjà utilisée", "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": { "sidePanel": {
"titleInvitation": "Rejoignez votre équipe", "titleInvitation": "Rejoignez votre équipe",

View File

@ -107,6 +107,11 @@ export function createMultipartHeaders(_includeAuth = true): HeadersInit {
* API Error * API Error
*/ */
export class ApiError extends 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( constructor(
message: string, message: string,
public statusCode: number, public statusCode: number,
@ -114,6 +119,8 @@ export class ApiError extends Error {
) { ) {
super(message); super(message);
this.name = 'ApiError'; this.name = 'ApiError';
this.code = response?.code;
this.reference = response?.reference;
} }
} }
@ -127,13 +134,20 @@ export async function apiRequest<T>(
): Promise<T> { ): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`; const url = `${API_BASE_URL}${endpoint}`;
const response = await fetch(url, { let response: Response;
...options, try {
credentials: 'include', response = await fetch(url, {
headers: { ...options,
...options.headers, 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 // Handle 401 Unauthorized - token expired
// Skip auto-redirect for auth endpoints (login, register, refresh) - they handle their own errors // Skip auto-redirect for auth endpoints (login, register, refresh) - they handle their own errors

View File

@ -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;
}