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:
Marc-André Bombeck committed 2026-11-12 12:58:52 +01:00
1 parent 31c4440154
commit 8c9fbbdd2b
17 files changed
+116 -11

No files matched your search

+7 -1
View File
@@ -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);
});
});
+9 -1
View File
@@ -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();
}
+1
View File
@@ -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",