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
@@ -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