From 8c9fbbdd2bdd167c81af5cddb3290ff1ec58e9d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 12:58:52 +0100 Subject: [PATCH] fix(errors): name three refusals that fell back to a generic sentence Since a codeless refusal reads by its status, three answers lost their meaning. A failed ChatGPT sign-in poll said "Error saving" instead of asking to start the sign-in again; it now falls back to the sign-in's own failure sentence. A dose-history import still running when the card stopped watching said "Nothing was written" although the job may still write; that case now has its own sentence. Setting up an authenticator while one is active answered "setup failed"; the route now sends `auth.mfa.already_active`, which says to disable the active one first, in every locale. --- messages/de.json | 4 +++ messages/en.json | 4 +++ messages/es.json | 4 +++ messages/fr.json | 4 +++ messages/it.json | 4 +++ messages/ko.json | 4 +++ messages/nl.json | 4 +++ messages/pl.json | 4 +++ messages/pt-BR.json | 4 +++ messages/ru.json | 4 +++ src/app/api/auth/me/mfa/totp/setup/route.ts | 8 +++++- .../settings/ai/codex-provider-form.tsx | 4 +-- .../import-panel/dose-history-import-card.tsx | 25 ++++++++++++++----- src/lib/api/__tests__/localized-error.test.ts | 19 ++++++++++++++ .../__tests__/intake-import-poll.test.ts | 20 ++++++++++++++- src/lib/medications/intake-import-poll.ts | 10 +++++++- src/lib/openapi/error-codes.ts | 1 + 17 files changed, 116 insertions(+), 11 deletions(-) diff --git a/messages/de.json b/messages/de.json index 1e9dc7a3b..72af59b85 100644 --- a/messages/de.json +++ b/messages/de.json @@ -148,6 +148,9 @@ "missing": "Deine Sitzung ist abgelaufen, daher wurde nichts geändert. Melde dich erneut an, um weiterzumachen.", "passkey": { "last_method": "Dieser Passkey ist deine einzige Anmeldemöglichkeit. Lege zuerst ein Passwort fest oder füge einen weiteren Passkey hinzu." + }, + "mfa": { + "already_active": "Für dieses Konto ist bereits eine Authenticator-App eingerichtet. Deaktiviere sie zuerst, um eine neue einzurichten." } }, "biomarkers": { @@ -5956,6 +5959,7 @@ "inProgress": "Ein Import des Einnahmeverlaufs läuft bereits. Auf sein Ende warten.", "workerUnavailable": "Der Hintergrunddienst ist nicht verfügbar, es wurde nichts importiert.", "failed": "Der Import ist fehlgeschlagen. Es wurde nichts geschrieben.", + "stillRunning": "Der Import dauert länger als üblich und kann noch fertig werden. Sieh dir deine Einnahmen in ein paar Minuten noch einmal an.", "columnsHeading": "Was mit jeder Spalte passiert", "jsonOnly": "nur JSON", "previewLine": "{rows} Zeilen gelesen, {queued} würden importiert, {refused} abgewiesen", diff --git a/messages/en.json b/messages/en.json index 1a5813a7f..e9a22bc8a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -148,6 +148,9 @@ "missing": "Your session has ended, so nothing was changed. Sign in again to continue.", "passkey": { "last_method": "This passkey is your only way to sign in. Set a password or add another passkey first." + }, + "mfa": { + "already_active": "An authenticator app is already set up for this account. Disable it first to set up a new one." } }, "biomarkers": { @@ -5956,6 +5959,7 @@ "inProgress": "A dose-history import is already running. Wait for it to finish.", "workerUnavailable": "The background worker is not available, so nothing was imported.", "failed": "The import failed. Nothing was written.", + "stillRunning": "The import is taking longer than usual and may still finish. Look at your doses again in a few minutes.", "columnsHeading": "What each column does", "jsonOnly": "JSON only", "previewLine": "{rows} rows read, {queued} would be imported, {refused} refused", diff --git a/messages/es.json b/messages/es.json index 24e1a2b7f..6a78e4f48 100644 --- a/messages/es.json +++ b/messages/es.json @@ -148,6 +148,9 @@ "missing": "Tu sesión ha caducado, así que no se ha cambiado nada. Inicia sesión de nuevo para continuar.", "passkey": { "last_method": "Esta clave de acceso es tu única forma de iniciar sesión. Primero establece una contraseña o añade otra clave de acceso." + }, + "mfa": { + "already_active": "Esta cuenta ya tiene una aplicación de autenticación configurada. Desactívala primero para configurar una nueva." } }, "biomarkers": { @@ -5956,6 +5959,7 @@ "inProgress": "Ya hay una importación de historial en curso. Espera a que termine.", "workerUnavailable": "El proceso en segundo plano no está disponible, así que no se importó nada.", "failed": "La importación falló. No se escribió nada.", + "stillRunning": "La importación está tardando más de lo habitual y aún puede terminar. Vuelve a mirar tus dosis dentro de unos minutos.", "columnsHeading": "Qué hace cada columna", "jsonOnly": "solo JSON", "previewLine": "{rows} filas leídas, {queued} se importarían, {refused} rechazadas", diff --git a/messages/fr.json b/messages/fr.json index 81372b683..a1605cce0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -148,6 +148,9 @@ "missing": "Votre session a expiré, rien n'a donc été modifié. Reconnectez-vous pour continuer.", "passkey": { "last_method": "Cette clé d’accès est votre seul moyen de connexion. Définissez d’abord un mot de passe ou ajoutez une autre clé d’accès." + }, + "mfa": { + "already_active": "Une application d'authentification est déjà configurée pour ce compte. Désactivez-la d'abord pour en configurer une nouvelle." } }, "biomarkers": { @@ -5956,6 +5959,7 @@ "inProgress": "Un import d'historique est déjà en cours. Attendez qu'il se termine.", "workerUnavailable": "Le service d'arrière-plan est indisponible, rien n'a été importé.", "failed": "L'import a échoué. Rien n'a été écrit.", + "stillRunning": "L'import prend plus de temps que d'habitude et peut encore aboutir. Revenez voir vos prises dans quelques minutes.", "columnsHeading": "Ce que devient chaque colonne", "jsonOnly": "JSON uniquement", "previewLine": "{rows} lignes lues, {queued} seraient importées, {refused} refusées", diff --git a/messages/it.json b/messages/it.json index 393f83a57..2bb134ad0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -148,6 +148,9 @@ "missing": "La tua sessione è scaduta, quindi non è stato modificato nulla. Accedi di nuovo per continuare.", "passkey": { "last_method": "Questa passkey è il tuo unico modo per accedere. Imposta prima una password o aggiungi un’altra passkey." + }, + "mfa": { + "already_active": "Per questo account è già configurata un'app di autenticazione. Disattivala prima di configurarne una nuova." } }, "biomarkers": { @@ -5956,6 +5959,7 @@ "inProgress": "Un'importazione dello storico è già in corso. Attendi che finisca.", "workerUnavailable": "Il processo in background non è disponibile, quindi non è stato importato nulla.", "failed": "L'importazione è fallita. Non è stato scritto nulla.", + "stillRunning": "L'importazione richiede più tempo del solito e potrebbe ancora completarsi. Ricontrolla le tue assunzioni tra qualche minuto.", "columnsHeading": "Cosa fa ogni colonna", "jsonOnly": "solo JSON", "previewLine": "{rows} righe lette, {queued} da importare, {refused} rifiutate", diff --git a/messages/ko.json b/messages/ko.json index c02d82e0a..ef1b5cdca 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -148,6 +148,9 @@ "missing": "세션이 만료되어 아무것도 변경되지 않았어요. 계속하려면 다시 로그인해 주세요.", "passkey": { "last_method": "이 패스키가 유일한 로그인 수단이에요. 먼저 비밀번호를 설정하거나 패스키를 하나 더 추가하세요." + }, + "mfa": { + "already_active": "이 계정에는 이미 인증 앱이 설정되어 있어요. 새로 설정하려면 먼저 해제하세요." } }, "biomarkers": { @@ -5956,6 +5959,7 @@ "inProgress": "복용 이력 불러오기가 이미 진행 중이에요. 끝날 때까지 기다려 주세요.", "workerUnavailable": "백그라운드 작업자를 쓸 수 없어서 아무것도 불러오지 못했어요.", "failed": "불러오지 못했어요. 아무것도 저장되지 않았어요.", + "stillRunning": "불러오기가 평소보다 오래 걸리고 있어요. 아직 끝날 수 있으니 몇 분 뒤에 복용 기록을 다시 확인해 주세요.", "columnsHeading": "각 열의 역할", "jsonOnly": "JSON 전용", "previewLine": "읽은 행 {rows}개, 불러올 행 {queued}개, 거부된 행 {refused}개", diff --git a/messages/nl.json b/messages/nl.json index 4e9615f22..c58a5138d 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -148,6 +148,9 @@ "missing": "Je sessie is verlopen, dus er is niets gewijzigd. Meld je opnieuw aan om verder te gaan.", "passkey": { "last_method": "Deze passkey is je enige manier om in te loggen. Stel eerst een wachtwoord in of voeg nog een passkey toe." + }, + "mfa": { + "already_active": "Voor dit account is al een authenticator-app ingesteld. Schakel die eerst uit om een nieuwe in te stellen." } }, "biomarkers": { @@ -5956,6 +5959,7 @@ "inProgress": "Er loopt al een import van doseringsgeschiedenis. Wacht tot die klaar is.", "workerUnavailable": "De achtergrondverwerking is niet beschikbaar, dus er is niets geïmporteerd.", "failed": "De import is mislukt. Er is niets opgeslagen.", + "stillRunning": "De import duurt langer dan normaal en kan nog afronden. Kijk over een paar minuten opnieuw naar je innames.", "columnsHeading": "Wat elke kolom doet", "jsonOnly": "Alleen JSON", "previewLine": "{rows} rijen gelezen, {queued} zouden worden geïmporteerd, {refused} geweigerd", diff --git a/messages/pl.json b/messages/pl.json index 0a2b41502..d252e10a8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -148,6 +148,9 @@ "missing": "Twoja sesja wygasła, więc nic nie zostało zmienione. Zaloguj się ponownie, aby kontynuować.", "passkey": { "last_method": "Ten klucz dostępu to Twój jedyny sposób logowania. Najpierw ustaw hasło albo dodaj kolejny klucz dostępu." + }, + "mfa": { + "already_active": "To konto ma już skonfigurowaną aplikację uwierzytelniającą. Najpierw ją wyłącz, aby skonfigurować nową." } }, "biomarkers": { @@ -5956,6 +5959,7 @@ "inProgress": "Import historii dawek już trwa. Poczekaj, aż się zakończy.", "workerUnavailable": "Proces w tle jest niedostępny, więc nic nie zostało zaimportowane.", "failed": "Import nie udał się. Nic nie zostało zapisane.", + "stillRunning": "Import trwa dłużej niż zwykle i może się jeszcze zakończyć. Sprawdź swoje dawki ponownie za kilka minut.", "columnsHeading": "Co się dzieje z każdą kolumną", "jsonOnly": "tylko JSON", "previewLine": "Odczytane wiersze: {rows}, do zaimportowania: {queued}, odrzucone: {refused}", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 41ad972f5..0126af071 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -148,6 +148,9 @@ "missing": "Sua sessão terminou, então nada foi alterado. Entre novamente para continuar.", "passkey": { "last_method": "Esta chave de acesso é sua única forma de entrar. Defina uma senha ou adicione outra chave de acesso primeiro." + }, + "mfa": { + "already_active": "Esta conta já tem um aplicativo autenticador configurado. Desative-o primeiro para configurar um novo." } }, "biomarkers": { @@ -5956,6 +5959,7 @@ "inProgress": "Uma importação de histórico de doses já está em andamento. Espere ela terminar.", "workerUnavailable": "O worker em segundo plano não está disponível, então nada foi importado.", "failed": "A importação falhou. Nada foi gravado.", + "stillRunning": "A importação está demorando mais que o normal e ainda pode terminar. Confira suas doses de novo em alguns minutos.", "columnsHeading": "O que cada coluna faz", "jsonOnly": "Somente JSON", "previewLine": "{rows} linhas lidas, {queued} seriam importadas, {refused} recusadas", diff --git a/messages/ru.json b/messages/ru.json index 5d8632c7c..2ded3fb25 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -148,6 +148,9 @@ "missing": "Сеанс завершён, поэтому ничего не изменено. Войдите снова, чтобы продолжить.", "passkey": { "last_method": "Этот ключ доступа остаётся вашим единственным способом входа. Сначала задайте пароль или добавьте ещё один ключ доступа." + }, + "mfa": { + "already_active": "Для этого аккаунта уже настроено приложение-аутентификатор. Сначала выключите его, чтобы настроить новое." } }, "biomarkers": { @@ -5956,6 +5959,7 @@ "inProgress": "Импорт истории доз уже выполняется. Дождитесь его завершения.", "workerUnavailable": "Фоновый обработчик недоступен, поэтому ничего не импортировано.", "failed": "Импорт не удался. Ничего не записано.", + "stillRunning": "Импорт идёт дольше обычного и ещё может завершиться. Проверьте свои приёмы через несколько минут.", "columnsHeading": "Что делает каждый столбец", "jsonOnly": "Только JSON", "previewLine": "Прочитано строк: {rows}, будет импортировано: {queued}, отклонено: {refused}", diff --git a/src/app/api/auth/me/mfa/totp/setup/route.ts b/src/app/api/auth/me/mfa/totp/setup/route.ts index 9c94d919e..06c78fd52 100644 --- a/src/app/api/auth/me/mfa/totp/setup/route.ts +++ b/src/app/api/auth/me/mfa/totp/setup/route.ts @@ -37,6 +37,8 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { generateTotpSecret, buildOtpauthUri } from "@/lib/auth/mfa/totp"; import { checkCookieEnrollmentProof } from "@/lib/auth/existing-factor-proof"; +const TOTP_ALREADY_ACTIVE_CODE = "auth.mfa.already_active"; + export const dynamic = "force-dynamic"; const SETUP_RATE_LIMIT = 5; @@ -64,7 +66,11 @@ export const POST = apiHandler(async (req: Request) => { // live secret out from under the user's authenticator. if (user.totpConfirmedAt) { annotate({ action: { name: "auth.mfa.totp.setup.already_active" } }); - throw new HttpError(409, "A second factor is already active"); + throw new HttpError( + 409, + "A second factor is already active", + TOTP_ALREADY_ACTIVE_CODE, + ); } if (auth.transport === "cookie") { diff --git a/src/components/settings/ai/codex-provider-form.tsx b/src/components/settings/ai/codex-provider-form.tsx index ddb8820d4..1378084c5 100644 --- a/src/components/settings/ai/codex-provider-form.tsx +++ b/src/components/settings/ai/codex-provider-form.tsx @@ -89,7 +89,7 @@ export function CodexProviderForm({ intervalSeconds: data.intervalSeconds, }); } catch (err) { - setMsg(localizedApiError(err, t, "settings.savingError")); + setMsg(localizedApiError(err, t, "settings.codexConnectionFailed")); setMsgType("error"); setDevicePolling(false); } @@ -117,7 +117,7 @@ export function CodexProviderForm({ if (!cancelled) setTimeout(tick, intervalMs); } catch (err) { if (cancelled) return; - setMsg(localizedApiError(err, t, "settings.savingError")); + setMsg(localizedApiError(err, t, "settings.codexConnectionFailed")); setMsgType("error"); setDeviceCode(null); setDevicePolling(false); diff --git a/src/components/settings/import-panel/dose-history-import-card.tsx b/src/components/settings/import-panel/dose-history-import-card.tsx index 1b31532e4..cd5bf5ab7 100644 --- a/src/components/settings/import-panel/dose-history-import-card.tsx +++ b/src/components/settings/import-panel/dose-history-import-card.tsx @@ -31,7 +31,10 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { waitForMedicationIntakeImport } from "@/lib/medications/intake-import-poll"; +import { + MedicationIntakeImportTimeout, + waitForMedicationIntakeImport, +} from "@/lib/medications/intake-import-poll"; import { invalidateMedicationReads } from "@/lib/query-keys"; import { DoseHistoryColumnRulings } from "./dose-history-columns"; @@ -93,6 +96,17 @@ function looksLikeJson(text: string): boolean { return head === "[" || head === "{"; } +/** + * The sentence for an import that did not report a result. A failed run or + * a transport failure wrote nothing; a job still running when the watch gave + * up may still write, so it must not read as nothing written. + */ +function importFailureKey(err: unknown): string { + return err instanceof MedicationIntakeImportTimeout + ? "settings.sections.export.import.doseHistory.stillRunning" + : "settings.sections.export.import.doseHistory.failed"; +} + export function DoseHistoryImportCard() { const { t } = useTranslations(); const fatalMessageFor = useFatalMessage(); @@ -191,12 +205,11 @@ export function DoseHistoryImportCard() { skippedDetailsOmitted: result.skippedDetailsOmitted, }); void invalidateMedicationReads(queryClient); - } catch { + } catch (err) { // A failed job's reason is the worker's English and adds nothing; - // anything else is a transport failure. Either way the run did not - // write, and the card says so rather than leaving the last verdict on - // screen. - setError(t("settings.sections.export.import.doseHistory.failed")); + // anything else is a transport failure. The card says what that means + // rather than leaving the last verdict on screen. + setError(t(importFailureKey(err))); setVerdict(null); } finally { setBusy(false); diff --git a/src/lib/api/__tests__/localized-error.test.ts b/src/lib/api/__tests__/localized-error.test.ts index 33bc278f0..3be2a4630 100644 --- a/src/lib/api/__tests__/localized-error.test.ts +++ b/src/lib/api/__tests__/localized-error.test.ts @@ -126,6 +126,21 @@ describe("localizedApiError", () => { ["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"], + ["a mistyped proof", 401, "auth.reproof.failed", "did not verify"], + [ + "a password where a second factor is needed", + 422, + "auth.reproof.too_weak", + "second factor", + ], + [ + "an authenticator already set up", + 409, + "auth.mfa.already_active", + "Disable it first", + ], + ["nothing to test with", 422, "ai.provider.none", "No AI provider"], + ["a spent AI budget", 429, "ai.budget.exceeded", "budget is used up"], ])("names %s instead of the save fallback", (_, status, code, text) => { const error = new ApiError("raw English server prose", status, { errorCode: code, @@ -157,6 +172,10 @@ describe("localizedApiError", () => { "http.rate_limited", "http.forbidden", "http.invalid", + "auth.reproof.too_weak", + "auth.mfa.already_active", + "ai.provider.none", + "ai.budget.exceeded", ]) { const sentence = localT(`apiErrors.${code}`); expect(sentence).not.toBe(`apiErrors.${code}`); diff --git a/src/lib/medications/__tests__/intake-import-poll.test.ts b/src/lib/medications/__tests__/intake-import-poll.test.ts index 3088ec569..2117fd5d1 100644 --- a/src/lib/medications/__tests__/intake-import-poll.test.ts +++ b/src/lib/medications/__tests__/intake-import-poll.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -import { waitForMedicationIntakeImport } from "../intake-import-poll"; +import { + MedicationIntakeImportTimeout, + waitForMedicationIntakeImport, +} from "../intake-import-poll"; describe("waitForMedicationIntakeImport", () => { it("polls queued and running jobs until the durable result is done", async () => { @@ -53,4 +56,19 @@ describe("waitForMedicationIntakeImport", () => { expect(readStatus).toHaveBeenCalledOnce(); expect(wait).not.toHaveBeenCalled(); }); + + it("tells a watch that gave up from a job that failed", async () => { + // The job may still write, so the card must not say nothing was written. + const readStatus = vi.fn().mockResolvedValue({ + status: "running", + result: null, + }); + await expect( + waitForMedicationIntakeImport("/api/import/job-1/status", { + readStatus, + wait: vi.fn().mockResolvedValue(undefined), + maxAttempts: 2, + }), + ).rejects.toBeInstanceOf(MedicationIntakeImportTimeout); + }); }); diff --git a/src/lib/medications/intake-import-poll.ts b/src/lib/medications/intake-import-poll.ts index 9c9af0050..c11117422 100644 --- a/src/lib/medications/intake-import-poll.ts +++ b/src/lib/medications/intake-import-poll.ts @@ -29,6 +29,14 @@ export class MedicationIntakeImportError extends Error { } } +/** The job did not settle while the client watched; it may still write. */ +export class MedicationIntakeImportTimeout extends MedicationIntakeImportError { + constructor() { + super("Medication intake import timed out"); + this.name = "MedicationIntakeImportTimeout"; + } +} + const waitForNextPoll = () => new Promise((resolve) => setTimeout(resolve, 1_000)); @@ -52,5 +60,5 @@ export async function waitForMedicationIntakeImport( if (attempt + 1 < maxAttempts) await wait(); } - throw new MedicationIntakeImportError("Medication intake import timed out"); + throw new MedicationIntakeImportTimeout(); } diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index ac7344c5a..8a1df35c2 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -86,6 +86,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "auth.account.deletion_pending", "auth.admin.required", "auth.already_authenticated", + "auth.mfa.already_active", "auth.mfa.code_invalid", "auth.missing", "auth.passkey.last_method",