mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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.
This commit is contained in:
1 parent
31c4440154
commit
8c9fbbdd2b
17 files changed
+116
-11
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}개",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<void>((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();
|
||||
}
|
||||
@@ -86,6 +86,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
"auth.account.deletion_pending",
|
||||
"auth.admin.required",
|
||||
"auth.already_authenticated",
|
||||
"auth.mfa.already_active",
|
||||
"auth.mfa.code_invalid",
|
||||
"auth.missing",
|
||||
"auth.passkey.last_method",
|
||||
|
||||
Reference in new issue
Block a user