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:
parent
67362461e4
commit
672abea1be
@ -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>
|
||||||
|
<div className="min-w-0">
|
||||||
<p className="text-body-sm text-red-800">{error}</p>
|
<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"
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
...options.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
|
||||||
|
|||||||
50
apps/frontend/src/lib/api/errors.ts
Normal file
50
apps/frontend/src/lib/api/errors.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user