diff --git a/messages/de.json b/messages/de.json index 4dcc47c51..c35f312d1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -143,7 +143,8 @@ "reproof": { "required": "Bestätige dein aktuelles Passwort, um das zu ändern.", "failed": "Die Bestätigung ist fehlgeschlagen. Es wurde nichts geändert." - } + }, + "missing": "Deine Sitzung ist abgelaufen, daher wurde nichts geändert. Melde dich erneut an, um weiterzumachen." }, "biomarkers": { "unit": { @@ -178,6 +179,12 @@ }, "aiRuns": { "readerBusy": "Der Dokumentleser ist gerade mit anderen Aufgaben beschäftigt. Versuch es in einer Minute noch einmal." + }, + "http": { + "unauthenticated": "Deine Sitzung ist abgelaufen, daher wurde nichts geändert. Melde dich erneut an, um weiterzumachen.", + "rate_limited": "Gerade gibt es zu viele Anfragen, daher wurde nichts geändert. Warte kurz und versuche es erneut.", + "forbidden": "Dafür fehlt dir die Berechtigung, daher wurde nichts geändert.", + "invalid": "Einige Angaben wurden nicht angenommen, daher wurde nichts geändert. Prüfe sie und versuche es erneut." } }, "doctorReport": { diff --git a/messages/en.json b/messages/en.json index 6a5c05ac9..29d5ddf0b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -143,7 +143,8 @@ "reproof": { "required": "Confirm your current password to change this.", "failed": "That did not verify. Nothing was changed." - } + }, + "missing": "Your session has ended, so nothing was changed. Sign in again to continue." }, "biomarkers": { "unit": { @@ -178,6 +179,12 @@ }, "aiRuns": { "readerBusy": "The document reader is busy with other work. Try again in a minute." + }, + "http": { + "unauthenticated": "Your session has ended, so nothing was changed. Sign in again to continue.", + "rate_limited": "There are too many requests just now, so nothing was changed. Wait a moment and try again.", + "forbidden": "You don't have permission for this, so nothing was changed.", + "invalid": "Some of the entries were not accepted, so nothing was changed. Check them and try again." } }, "doctorReport": { diff --git a/messages/es.json b/messages/es.json index a2b612bad..71ee4da7e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -143,7 +143,8 @@ "reproof": { "required": "Confirma tu contraseña actual para cambiar esto.", "failed": "La verificación ha fallado. No se ha cambiado nada." - } + }, + "missing": "Tu sesión ha caducado, así que no se ha cambiado nada. Inicia sesión de nuevo para continuar." }, "biomarkers": { "unit": { @@ -178,6 +179,12 @@ }, "aiRuns": { "readerBusy": "El lector de documentos está ocupado con otras tareas. Inténtalo de nuevo en un minuto." + }, + "http": { + "unauthenticated": "Tu sesión ha caducado, así que no se ha cambiado nada. Inicia sesión de nuevo para continuar.", + "rate_limited": "Hay demasiadas solicitudes ahora mismo, así que no se ha cambiado nada. Espera un momento y vuelve a intentarlo.", + "forbidden": "No tienes permiso para esto, así que no se ha cambiado nada.", + "invalid": "Algunos datos no se aceptaron, así que no se ha cambiado nada. Revísalos y vuelve a intentarlo." } }, "doctorReport": { diff --git a/messages/fr.json b/messages/fr.json index af97df036..8d7b7a40c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -143,7 +143,8 @@ "reproof": { "required": "Confirmez votre mot de passe actuel pour modifier ceci.", "failed": "La vérification a échoué. Rien n'a été modifié." - } + }, + "missing": "Votre session a expiré, rien n'a donc été modifié. Reconnectez-vous pour continuer." }, "biomarkers": { "unit": { @@ -178,6 +179,12 @@ }, "aiRuns": { "readerBusy": "Le lecteur de documents est occupé par d'autres tâches. Réessayez dans une minute." + }, + "http": { + "unauthenticated": "Votre session a expiré, rien n'a donc été modifié. Reconnectez-vous pour continuer.", + "rate_limited": "Il y a trop de requêtes pour le moment, rien n'a donc été modifié. Patientez un instant puis réessayez.", + "forbidden": "Vous n'avez pas l'autorisation pour cette action, rien n'a donc été modifié.", + "invalid": "Certaines saisies n'ont pas été acceptées, rien n'a donc été modifié. Vérifiez-les puis réessayez." } }, "doctorReport": { diff --git a/messages/it.json b/messages/it.json index fed51449c..02478f1e1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -143,7 +143,8 @@ "reproof": { "required": "Conferma la password attuale per modificarlo.", "failed": "La verifica non è riuscita. Non è stato modificato nulla." - } + }, + "missing": "La tua sessione è scaduta, quindi non è stato modificato nulla. Accedi di nuovo per continuare." }, "biomarkers": { "unit": { @@ -178,6 +179,12 @@ }, "aiRuns": { "readerBusy": "Il lettore di documenti è occupato con altre attività. Riprova tra un minuto." + }, + "http": { + "unauthenticated": "La tua sessione è scaduta, quindi non è stato modificato nulla. Accedi di nuovo per continuare.", + "rate_limited": "Ci sono troppe richieste in questo momento, quindi non è stato modificato nulla. Attendi un attimo e riprova.", + "forbidden": "Non hai l'autorizzazione per farlo, quindi non è stato modificato nulla.", + "invalid": "Alcuni dati non sono stati accettati, quindi non è stato modificato nulla. Controllali e riprova." } }, "doctorReport": { diff --git a/messages/ko.json b/messages/ko.json index 77eee65e5..fd4860a74 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -143,7 +143,8 @@ "reproof": { "required": "변경하려면 현재 비밀번호를 확인하세요.", "failed": "확인에 실패했습니다. 아무것도 변경되지 않았습니다." - } + }, + "missing": "세션이 만료되어 아무것도 변경되지 않았어요. 계속하려면 다시 로그인해 주세요." }, "biomarkers": { "unit": { @@ -178,6 +179,12 @@ }, "aiRuns": { "readerBusy": "문서 판독기가 다른 작업으로 바빠요. 1분 후에 다시 시도하세요." + }, + "http": { + "unauthenticated": "세션이 만료되어 아무것도 변경되지 않았어요. 계속하려면 다시 로그인해 주세요.", + "rate_limited": "지금은 요청이 너무 많아 아무것도 변경되지 않았어요. 잠시 후 다시 시도해 주세요.", + "forbidden": "이 작업을 할 권한이 없어 아무것도 변경되지 않았어요.", + "invalid": "일부 입력값이 받아들여지지 않아 아무것도 변경되지 않았어요. 확인한 후 다시 시도해 주세요." } }, "doctorReport": { diff --git a/messages/nl.json b/messages/nl.json index 1df8e98bf..6680b0928 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -143,7 +143,8 @@ "reproof": { "required": "Bevestig je huidige wachtwoord om dit te wijzigen.", "failed": "De verificatie is mislukt. Er is niets gewijzigd." - } + }, + "missing": "Je sessie is verlopen, dus er is niets gewijzigd. Meld je opnieuw aan om verder te gaan." }, "biomarkers": { "unit": { @@ -178,6 +179,12 @@ }, "aiRuns": { "readerBusy": "De documentlezer is met ander werk bezig. Probeer het over een minuut opnieuw." + }, + "http": { + "unauthenticated": "Je sessie is verlopen, dus er is niets gewijzigd. Meld je opnieuw aan om verder te gaan.", + "rate_limited": "Er zijn net te veel verzoeken, dus er is niets gewijzigd. Wacht even en probeer het opnieuw.", + "forbidden": "Je hebt hier geen toestemming voor, dus er is niets gewijzigd.", + "invalid": "Sommige gegevens zijn niet geaccepteerd, dus er is niets gewijzigd. Controleer ze en probeer het opnieuw." } }, "doctorReport": { diff --git a/messages/pl.json b/messages/pl.json index fa9457d77..02ca079b0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -143,7 +143,8 @@ "reproof": { "required": "Potwierdź obecne hasło, aby to zmienić.", "failed": "Weryfikacja nie powiodła się. Nic nie zostało zmienione." - } + }, + "missing": "Twoja sesja wygasła, więc nic nie zostało zmienione. Zaloguj się ponownie, aby kontynuować." }, "biomarkers": { "unit": { @@ -178,6 +179,12 @@ }, "aiRuns": { "readerBusy": "Czytnik dokumentów jest zajęty innymi zadaniami. Spróbuj ponownie za minutę." + }, + "http": { + "unauthenticated": "Twoja sesja wygasła, więc nic nie zostało zmienione. Zaloguj się ponownie, aby kontynuować.", + "rate_limited": "W tej chwili jest zbyt wiele zapytań, więc nic nie zostało zmienione. Zaczekaj chwilę i spróbuj ponownie.", + "forbidden": "Nie masz do tego uprawnień, więc nic nie zostało zmienione.", + "invalid": "Niektóre dane nie zostały przyjęte, więc nic nie zostało zmienione. Sprawdź je i spróbuj ponownie." } }, "doctorReport": { diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 6abf9a204..18df65df1 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -143,7 +143,8 @@ "reproof": { "required": "Confirme sua senha atual para alterar isto.", "failed": "A verificação não foi concluída. Nada foi alterado." - } + }, + "missing": "Sua sessão terminou, então nada foi alterado. Entre novamente para continuar." }, "biomarkers": { "unit": { @@ -178,6 +179,12 @@ }, "aiRuns": { "readerBusy": "O leitor de documentos está ocupado com outras tarefas. Tente novamente em um minuto." + }, + "http": { + "unauthenticated": "Sua sessão terminou, então nada foi alterado. Entre novamente para continuar.", + "rate_limited": "Há solicitações demais neste momento, então nada foi alterado. Aguarde um pouco e tente de novo.", + "forbidden": "Você não tem permissão para isso, então nada foi alterado.", + "invalid": "Alguns dados não foram aceitos, então nada foi alterado. Confira e tente de novo." } }, "doctorReport": { diff --git a/messages/ru.json b/messages/ru.json index 159cf4fab..4cf579664 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -143,7 +143,8 @@ "reproof": { "required": "Подтвердите текущий пароль, чтобы изменить это.", "failed": "Не подтверждено. Ничего не изменено." - } + }, + "missing": "Сеанс завершён, поэтому ничего не изменено. Войдите снова, чтобы продолжить." }, "biomarkers": { "unit": { @@ -178,6 +179,12 @@ }, "aiRuns": { "readerBusy": "Средство чтения документов занято другой работой. Попробуйте через минуту." + }, + "http": { + "unauthenticated": "Сеанс завершён, поэтому ничего не изменено. Войдите снова, чтобы продолжить.", + "rate_limited": "Сейчас слишком много запросов, поэтому ничего не изменено. Подождите немного и попробуйте снова.", + "forbidden": "У вас нет прав на это действие, поэтому ничего не изменено.", + "invalid": "Некоторые данные не приняты, поэтому ничего не изменено. Проверьте их и попробуйте снова." } }, "doctorReport": { diff --git a/src/lib/api/__tests__/localized-error.test.ts b/src/lib/api/__tests__/localized-error.test.ts index df0597295..b828bf842 100644 --- a/src/lib/api/__tests__/localized-error.test.ts +++ b/src/lib/api/__tests__/localized-error.test.ts @@ -113,4 +113,51 @@ describe("localizedApiError", () => { ), ).toBe(translator(locale as Catalog)("measurements.saveError")); }); + + describe("a refusal the client can act on", () => { + const enT = translator(en as Catalog); + + it.each([ + ["an ended session", 401, "auth.missing", "Sign in again"], + ["a rate limit", 429, "http.rate_limited", "too many requests"], + ["a missing right", 403, "http.forbidden", "permission"], + ["a rejected entry", 422, "http.invalid", "Check them"], + ])("names %s instead of the save fallback", (_, status, code, text) => { + const error = new ApiError("raw English server prose", status, { + errorCode: code, + }); + expect(localizedApiError(error, enT, "measurements.saveError")).toMatch( + new RegExp(text, "i"), + ); + }); + + it("reads a refusal without a code by its status", () => { + // A reverse proxy's 429 carries no envelope at all. + const error = new ApiError("Request failed (429)", 429); + expect(localizedApiError(error, enT, "measurements.saveError")).toBe( + enT("apiErrors.http.rate_limited"), + ); + }); + + it.each([ + ["de", de], + ["es", es], + ["fr", fr], + ["it", itCatalog], + ["pl", pl], + ])("has its own sentence in %s", (_, locale) => { + const localT = translator(locale as Catalog); + for (const code of [ + "auth.missing", + "http.unauthenticated", + "http.rate_limited", + "http.forbidden", + "http.invalid", + ]) { + const sentence = localT(`apiErrors.${code}`); + expect(sentence).not.toBe(`apiErrors.${code}`); + expect(sentence).not.toBe(enT(`apiErrors.${code}`)); + } + }); + }); }); diff --git a/src/lib/api/localized-error.ts b/src/lib/api/localized-error.ts index 085429ee2..ee102ad73 100644 --- a/src/lib/api/localized-error.ts +++ b/src/lib/api/localized-error.ts @@ -1,21 +1,26 @@ import { ApiError } from "./api-fetch"; +import { defaultErrorCodeForStatus } from "./default-error-codes"; import { translateCode } from "@/lib/i18n/translate-code"; type Translate = (key: string) => string; /** * Resolve an API envelope's stable error code through the locale catalog. - * Missing/unknown codes, network failures, and non-API errors deliberately - * collapse to the caller's safe domain fallback; server prose is never shown. + * A refusal without a code of its own (a proxy's 429, an answer without the + * envelope) is read by its status, the way the server labels one, so an + * ended session or a rate limit is named as such. Unknown codes, network + * failures, and non-API errors deliberately collapse to the caller's safe + * domain fallback; server prose is never shown. */ export function localizedApiError( error: unknown, t: Translate, fallbackKey: string, ): string { - const errorCode = - error instanceof ApiError && typeof error.meta?.errorCode === "string" + const errorCode = !(error instanceof ApiError) + ? null + : typeof error.meta?.errorCode === "string" ? error.meta.errorCode - : null; + : defaultErrorCodeForStatus(error.status); return translateCode(t, "apiErrors", errorCode, fallbackKey); }