fix(i18n): say "sign in again" or "too many requests" on a refused save

A save after the session ran out, or into a rate limit, showed the form's
generic "could not save". The server already names both refusals in
meta.errorCode (auth.missing for an ended web session, the status codes
http.rate_limited, http.forbidden and http.invalid otherwise), but the
catalogs had no sentence for them, so the lookup fell through to the
fallback. They have one now in all ten locales, and a refusal that
reaches the client without a code (a reverse proxy's 429) is read by its
status the way the server labels it.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 19:48:27 +01:00
1 parent 4721914cac
commit 0639d28f6a
12 files changed
+137 -15

No files matched your search

+8 -1
View File
@@ -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": {
+8 -1
View File
@@ -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": {
+8 -1
View File
@@ -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": {
+8 -1
View File
@@ -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": {
+8 -1
View File
@@ -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": {
+8 -1
View File
@@ -143,7 +143,8 @@
"reproof": {
"required": "변경하려면 현재 비밀번호를 확인하세요.",
"failed": "확인에 실패했습니다. 아무것도 변경되지 않았습니다."
}
},
"missing": "세션이 만료되어 아무것도 변경되지 않았어요. 계속하려면 다시 로그인해 주세요."
},
"biomarkers": {
"unit": {
@@ -178,6 +179,12 @@
},
"aiRuns": {
"readerBusy": "문서 판독기가 다른 작업으로 바빠요. 1분 후에 다시 시도하세요."
},
"http": {
"unauthenticated": "세션이 만료되어 아무것도 변경되지 않았어요. 계속하려면 다시 로그인해 주세요.",
"rate_limited": "지금은 요청이 너무 많아 아무것도 변경되지 않았어요. 잠시 후 다시 시도해 주세요.",
"forbidden": "이 작업을 할 권한이 없어 아무것도 변경되지 않았어요.",
"invalid": "일부 입력값이 받아들여지지 않아 아무것도 변경되지 않았어요. 확인한 후 다시 시도해 주세요."
}
},
"doctorReport": {
+8 -1
View File
@@ -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": {
+8 -1
View File
@@ -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": {
+8 -1
View File
@@ -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": {
+8 -1
View File
@@ -143,7 +143,8 @@
"reproof": {
"required": "Подтвердите текущий пароль, чтобы изменить это.",
"failed": "Не подтверждено. Ничего не изменено."
}
},
"missing": "Сеанс завершён, поэтому ничего не изменено. Войдите снова, чтобы продолжить."
},
"biomarkers": {
"unit": {
@@ -178,6 +179,12 @@
},
"aiRuns": {
"readerBusy": "Средство чтения документов занято другой работой. Попробуйте через минуту."
},
"http": {
"unauthenticated": "Сеанс завершён, поэтому ничего не изменено. Войдите снова, чтобы продолжить.",
"rate_limited": "Сейчас слишком много запросов, поэтому ничего не изменено. Подождите немного и попробуйте снова.",
"forbidden": "У вас нет прав на это действие, поэтому ничего не изменено.",
"invalid": "Некоторые данные не приняты, поэтому ничего не изменено. Проверьте их и попробуйте снова."
}
},
"doctorReport": {
@@ -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}`));
}
});
});
});
+10 -5
View File
@@ -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);
}