Merge branch 'release/v1.47.3' into release/v1.47.4

# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/test-as-never.json
#	src/__tests__/baselines/version-stamp-comments.json
This commit is contained in:
Marc-André Bombeck committed 2026-11-12 13:27:52 +01:00
commit 031379d966
384 files changed
+7908 -5710

No files matched your search

@@ -0,0 +1,112 @@
/**
* Settings lists the Coach entry only while the Coach can answer, like the
* sidebar and the bottom bar, and Settings → AI then keeps the stored Coach
* memory in reach.
*
* Both used to read the raw module map, which answers the operator's switch
* and Hide Coach but not a missing provider or missing consent: the settings
* nav kept a Coach entry the sidebar had already dropped. The two now read
* the same map (`useNavModules`), so the memory's own entry and the fallback
* under Settings → AI cannot both go.
*
* The shell's module filter runs after hydration only, so `useMounted` is
* mocked to the hydrated answer.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const h = vi.hoisted(() => ({
coachAvailable: true,
active: null as Record<string, unknown> | null,
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/settings/ai",
useRouter: () => ({ replace: () => {}, push: () => {} }),
}));
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
role: "USER",
modules: { coach: true },
accountAccess: { active: h.active },
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
vi.mock("@/hooks/use-ai-capability", () => ({
useAiCapability: () => ({
available: h.coachAvailable,
reason: h.coachAvailable ? null : "no_provider",
onDeviceAllowed: true,
}),
}));
// Settings → AI's cards have their own suites; only the memory slot is
// under test here.
vi.mock("@/components/ai/ai-consent-request", () => ({
AiConsentRequest: () => null,
}));
vi.mock("@/components/settings/ai/ai-insights-card", () => ({
AiInsightsCard: () => null,
}));
vi.mock("@/components/settings/ai/operator-off-notice", () => ({
OperatorOffNotice: () => null,
}));
vi.mock("@/components/insights/ai-setup-hint", () => ({
AiSetupHint: () => null,
}));
vi.mock("@/components/settings/coach-memory-section", () => ({
StoredCoachMemory: () => <div data-slot="stored-coach-memory" />,
}));
import { I18nProvider } from "@/lib/i18n/context";
import { AiSection } from "../ai-section";
import { SettingsShell } from "../settings-shell";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("the Coach entry in Settings", () => {
beforeEach(() => {
h.coachAvailable = true;
h.active = null;
});
it("is listed while the Coach can answer, and the memory lives there", () => {
expect(render(<SettingsShell active="ai">body</SettingsShell>)).toContain(
'href="/settings/coach"',
);
expect(render(<AiSection />)).not.toContain(
'data-slot="stored-coach-memory"',
);
});
it("goes when the Coach cannot answer, and Settings → AI keeps the memory", () => {
h.coachAvailable = false;
expect(
render(<SettingsShell active="ai">body</SettingsShell>),
).not.toContain('href="/settings/coach"');
expect(render(<AiSection />)).toContain('data-slot="stored-coach-memory"');
});
it("stays for a managed record whose Coach cannot answer yet", () => {
// A record has no Settings, AI: the guardian reaches the record's Coach
// data scope and switch through this entry only.
h.coachAvailable = false;
h.active = {
recordKind: "managed",
level: "manage",
manageableDomains: [],
};
expect(
render(<SettingsShell active="coach">body</SettingsShell>),
).toContain('href="/settings/coach"');
});
});
@@ -65,7 +65,8 @@ vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => mocks.queryClient,
}));
vi.mock("@/lib/api/api-fetch", () => ({
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/api/api-fetch")>()),
apiFetchRaw: mocks.apiFetchRaw,
apiGet: vi.fn(),
}));
@@ -1,12 +1,16 @@
import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/api/api-fetch", () => ({
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/api/api-fetch")>()),
apiFetchRaw: vi.fn(),
}));
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { ApiError, apiFetchRaw } from "@/lib/api/api-fetch";
import en from "@/../messages/en.json";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
channelSaveError,
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
@@ -27,7 +31,7 @@ describe("notification channel enable-only mutation", () => {
new Response(JSON.stringify({ data: { saved: true } }), { status: 200 }),
);
await persistChannelEnabled(endpoint, true, "save failed");
await persistChannelEnabled(endpoint, true);
expect(apiFetchRaw).toHaveBeenCalledWith(endpoint, {
method: "PUT",
@@ -87,7 +91,33 @@ describe("notification channel enable-only mutation", () => {
);
await expect(
persistChannelEnabled("/api/settings/ntfy", true, "save failed"),
persistChannelEnabled("/api/settings/ntfy", true),
).rejects.toThrow("Stored configuration is missing");
});
});
describe("the sentence a refused channel save leaves", () => {
const { t } = getServerTranslator("en");
it("names the private-origin policy like the test button does", () => {
const refusal = new ApiError("Private network target", 422, {
errorCode: "private_origin_not_approved",
});
expect(channelSaveError(refusal, t)).toBe(
en.settings.testConnection.errors.private_origin_not_approved,
);
});
it("reads any other refusal through the catalogue, never as the server's prose", () => {
const refusal = new ApiError("Stored configuration is missing", 422, {
errorCode: "http.invalid",
});
expect(channelSaveError(refusal, t)).toBe(en.apiErrors.http.invalid);
});
it("says could not save when the request never reached the server", () => {
expect(channelSaveError(new TypeError("Failed to fetch"), t)).toBe(
en.settings.savingError,
);
});
});
@@ -146,7 +146,11 @@ function dialog(): Dialog {
},
// The same call `useRecentProof` makes.
submit: (proof) => apiPost("/api/auth/reproof", proof),
messages: () => ({ signInAgain: "sign in again", failed: "failed" }),
messages: () => ({
signInAgain: "sign in again",
failed: "failed",
rateLimited: "rate limited",
}),
});
return d;
}
@@ -0,0 +1,39 @@
/**
* The avatar fallback on the settings card spells the name the rest of the
* app shows. It used to take its letters from the login, so an account named
* "Robin Example" with the login "rex" read "RE" in the top bar and the
* sidebar and "R" here.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@tanstack/react-query", () => ({
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "rex",
displayName: "Robin Example",
avatarUrl: null,
},
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { AvatarSection } from "../avatar-section";
describe("the settings avatar fallback", () => {
it("builds its initials from the display name, not the login", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<AvatarSection />
</I18nProvider>,
);
expect(html).toContain(">RE<");
expect(html).not.toContain(">R<");
});
});
@@ -11,8 +11,10 @@ import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { ApiError, apiFetchRaw, throwApiError } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { statusText, type StatusMessage } from "./account-section-utils";
import { accountName, nameInitials } from "@/lib/account-name";
// Accepted upload types + byte cap mirror the server contract in
// `src/app/api/user/avatar/route.ts` so the client rejects an obvious
@@ -21,14 +23,6 @@ import { statusText, type StatusMessage } from "./account-section-utils";
const ACCEPTED_AVATAR_TYPES = ["image/jpeg", "image/png", "image/webp"];
const AVATAR_MAX_UPLOAD_BYTES = 2 * 1024 * 1024;
function getAvatarInitials(name: string): string {
return name
.split(/[\s._-]+/)
.slice(0, 2)
.map((s) => s[0]?.toUpperCase() ?? "")
.join("");
}
export function AvatarSection() {
const { t } = useTranslations();
const { user, refetch } = useAuth();
@@ -44,41 +38,38 @@ export function AvatarSection() {
method: "POST",
body,
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 415)
throw new Error(t("settings.avatar.invalidType"));
if (res.status === 413) throw new Error(t("settings.avatar.tooLarge"));
throw new Error(json.error || t("settings.avatar.error"));
}
if (!res.ok) await throwApiError(res);
},
onSuccess: async () => {
setMsgType("success");
setMsg({ key: "settings.avatar.uploaded" });
await refetch();
},
onError: (err: Error) => {
onError: (err) => {
setMsgType("error");
setMsg({ text: err.message });
setMsg(
err instanceof ApiError && err.status === 415
? { key: "settings.avatar.invalidType" }
: err instanceof ApiError && err.status === 413
? { key: "settings.avatar.tooLarge" }
: { text: localizedApiError(err, t, "settings.avatar.error") },
);
},
});
const remove = useMutation({
mutationFn: async () => {
const res = await apiFetchRaw("/api/user/avatar", { method: "DELETE" });
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json.error || t("settings.avatar.error"));
}
if (!res.ok) await throwApiError(res);
},
onSuccess: async () => {
setMsgType("success");
setMsg({ key: "settings.avatar.removed" });
await refetch();
},
onError: (err: Error) => {
onError: (err) => {
setMsgType("error");
setMsg({ text: err.message });
setMsg({ text: localizedApiError(err, t, "settings.avatar.error") });
},
});
@@ -123,9 +114,9 @@ export function AvatarSection() {
</p>
<div className="flex items-center gap-4">
<Avatar className="size-16">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
{avatarUrl && <AvatarImage src={avatarUrl} alt={accountName(user)} />}
<AvatarFallback className="text-base">
{getAvatarInitials(user.username)}
{nameInitials(accountName(user))}
</AvatarFallback>
</Avatar>
<div className="flex flex-wrap gap-2">
@@ -52,7 +52,7 @@ import {
} from "./account-section-utils";
import { AvatarSection } from "./avatar-section";
import { SetupCard } from "./setup-card";
import { dateOnlyKey } from "@/lib/tz/date-only";
import { dateOfBirthKey } from "@/lib/tz/date-only";
import { translateCodeOrNull } from "@/lib/i18n/translate-code";
export { resolveInitialTimezone } from "./account-section-utils";
@@ -149,7 +149,7 @@ export function AccountSection() {
displayName: user.displayName ?? "",
height: heightAdapter.toDraft(user.heightCm),
dateOfBirth: user.dateOfBirth
? dateOnlyKey(new Date(user.dateOfBirth))
? dateOfBirthKey(new Date(user.dateOfBirth), user.timezone)
: "",
gender: user.gender ?? "",
fullName: user.fullName ?? "",
+6 -6
View File
@@ -25,10 +25,12 @@ import { StoredCoachMemory } from "@/components/settings/coach-memory-section";
import { AiSetupHint } from "@/components/insights/ai-setup-hint";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useNavModules } from "@/hooks/use-nav-modules";
import { isSurfaceVisible } from "@/lib/modules/surface";
export function AiSection() {
const { isAuthenticated, user } = useAuth();
const { isAuthenticated } = useAuth();
const navModules = useNavModules();
// v1.16.6 — the auth query can resolve before this boundary
// hydrates; every `disabled={!isAuthenticated}` binding in the child
// cards would then disagree with the SSR HTML (React #418). Gate the
@@ -46,11 +48,9 @@ export function AiSection() {
// nothing below reads as broken), and the one calm setup hint while no
// provider is configured, or the consent question while the operator's
// provider waits on it. Below it: what the Coach stored, while Settings →
// Coach is not reachable, so it can always be read and deleted.
const coachSettingsReachable = isSurfaceVisible(
"settings:coach",
user?.modules,
);
// Coach is not listed (the settings nav reads the same map), so it can
// always be read and deleted.
const coachSettingsReachable = isSurfaceVisible("settings:coach", navModules);
return (
<div className="space-y-6">
<OperatorOffNotice />
@@ -15,6 +15,7 @@ import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { apiPatch } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
@@ -86,7 +87,7 @@ export function AnthropicProviderForm({
},
onError: (e) => {
setOk(false);
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
setMsg(localizedApiError(e, t, "settings.ai.errorGeneric"));
},
onSettled: () => {
submitInFlightRef.current = false;
@@ -12,7 +12,8 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
@@ -77,18 +78,18 @@ export function CodexProviderForm({
setMsg(null);
setDevicePolling(true);
try {
const res = await apiFetchRaw("/api/auth/codex/device-start", {
method: "POST",
});
const json = await res.json();
if (!res.ok) throw new Error(json.error || t("settings.savingError"));
const data = await apiPost<{
userCode: string;
verificationUrl: string;
intervalSeconds: number;
}>("/api/auth/codex/device-start");
setDeviceCode({
userCode: json.data.userCode,
verificationUrl: json.data.verificationUrl,
intervalSeconds: json.data.intervalSeconds,
userCode: data.userCode,
verificationUrl: data.verificationUrl,
intervalSeconds: data.intervalSeconds,
});
} catch (err) {
setMsg(err instanceof Error ? err.message : t("settings.savingError"));
setMsg(localizedApiError(err, t, "settings.codexConnectionFailed"));
setMsgType("error");
setDevicePolling(false);
}
@@ -101,15 +102,11 @@ export function CodexProviderForm({
async function tick() {
try {
const res = await apiFetchRaw("/api/auth/codex/device-poll", {
method: "POST",
});
const json = await res.json();
const data = await apiPost<{ status?: string } | null>(
"/api/auth/codex/device-poll",
);
if (cancelled) return;
if (!res.ok) {
throw new Error(json.error || t("settings.savingError"));
}
if (json.data?.status === "connected") {
if (data?.status === "connected") {
setDeviceCode(null);
setDevicePolling(false);
setMsg(t("settings.codexConnected"));
@@ -120,7 +117,7 @@ export function CodexProviderForm({
if (!cancelled) setTimeout(tick, intervalMs);
} catch (err) {
if (cancelled) return;
setMsg(err instanceof Error ? err.message : t("settings.savingError"));
setMsg(localizedApiError(err, t, "settings.codexConnectionFailed"));
setMsgType("error");
setDeviceCode(null);
setDevicePolling(false);
@@ -144,18 +141,12 @@ export function CodexProviderForm({
setDisconnecting(true);
setMsg(null);
try {
const res = await apiFetchRaw("/api/auth/codex/disconnect", {
method: "DELETE",
});
if (!res.ok) {
const json = await res.json();
throw new Error(json.error);
}
await apiDelete("/api/auth/codex/disconnect");
setMsg(t("settings.codexDisconnected"));
setMsgType("success");
void invalidateKeys(queryClient, aiInputDependentKeys);
} catch (err) {
setMsg(err instanceof Error ? err.message : t("settings.savingError"));
setMsg(localizedApiError(err, t, "settings.savingError"));
setMsgType("error");
} finally {
setDisconnecting(false);
@@ -17,6 +17,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { apiPatch } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
@@ -78,7 +79,7 @@ export function CompatProviderForm({
},
onError: (e) => {
setOk(false);
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
setMsg(localizedApiError(e, t, "settings.ai.errorGeneric"));
},
onSettled: () => {
submitInFlightRef.current = false;
@@ -26,6 +26,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { NativeSelect } from "@/components/ui/native-select";
import { Switch } from "@/components/ui/switch";
import { apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
@@ -97,11 +98,7 @@ export function FallbackChainCard({
},
onError: (e) => {
setOk(false);
setMsg(
e instanceof Error
? e.message
: t("settings.ai.providerChain.saveFailed"),
);
setMsg(localizedApiError(e, t, "settings.ai.providerChain.saveFailed"));
},
});
@@ -15,6 +15,7 @@ import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { apiPatch } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
@@ -92,7 +93,7 @@ export function LocalProviderForm({
},
onError: (e) => {
setOk(false);
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
setMsg(localizedApiError(e, t, "settings.ai.errorGeneric"));
},
onSettled: () => {
submitInFlightRef.current = false;
@@ -18,6 +18,7 @@ import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { apiPatch } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
@@ -94,7 +95,7 @@ export function OpenAIProviderForm({
},
onError: (e) => {
setOk(false);
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
setMsg(localizedApiError(e, t, "settings.ai.errorGeneric"));
},
onSettled: () => {
submitInFlightRef.current = false;
@@ -24,6 +24,7 @@ import { Label } from "@/components/ui/label";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { PROVIDER_DEFAULT_TIMEOUT_MS } from "@/lib/ai/effective-timeout";
import { apiPatch } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useTranslations } from "@/lib/i18n/context";
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
@@ -68,17 +69,7 @@ export function ResponseTimeoutCard({
}
const saveMutation = useMutation({
mutationFn: async () => {
const trimmed = value.trim();
const responseTimeoutSeconds = trimmed === "" ? null : Number(trimmed);
if (
responseTimeoutSeconds !== null &&
(!Number.isInteger(responseTimeoutSeconds) ||
responseTimeoutSeconds < 10 ||
responseTimeoutSeconds > 600)
) {
throw new Error(t("settings.ai.responseTimeoutInvalid"));
}
mutationFn: async (responseTimeoutSeconds: number | null) => {
await apiPatch("/api/user/ai-provider", { responseTimeoutSeconds });
},
onSuccess: () => {
@@ -88,7 +79,7 @@ export function ResponseTimeoutCard({
},
onError: (e) => {
setOk(false);
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
setMsg(localizedApiError(e, t, "settings.ai.errorGeneric"));
},
onSettled: () => {
submitInFlightRef.current = false;
@@ -98,8 +89,20 @@ export function ResponseTimeoutCard({
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (submitInFlightRef.current || saveMutation.isPending) return;
const trimmed = value.trim();
const responseTimeoutSeconds = trimmed === "" ? null : Number(trimmed);
if (
responseTimeoutSeconds !== null &&
(!Number.isInteger(responseTimeoutSeconds) ||
responseTimeoutSeconds < 10 ||
responseTimeoutSeconds > 600)
) {
setOk(false);
setMsg(t("settings.ai.responseTimeoutInvalid"));
return;
}
submitInFlightRef.current = true;
saveMutation.mutate();
saveMutation.mutate(responseTimeoutSeconds);
}
return (
@@ -17,6 +17,7 @@ import { apiFetchRaw, apiPut } from "@/lib/api/api-fetch";
import { formatDateTime } from "@/lib/format";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { useTranslations } from "@/lib/i18n/context";
import { translateCodeOrNull } from "@/lib/i18n/translate-code";
import {
localiseTestReason,
@@ -83,7 +84,6 @@ export function RuntimeActionsRow({
reason?: string;
httpStatus?: number | null;
} | null;
error?: string | null;
};
try {
json = await res.json();
@@ -102,7 +102,12 @@ export function RuntimeActionsRow({
typeof code === "string" && code.startsWith("assistant.disabled.")
? t("settings.ai.operatorOff.testRefused")
: t("settings.ai.testFailedShort", {
message: json.error ?? `HTTP ${res.status}`,
message:
translateCodeOrNull(
t,
"apiErrors",
typeof code === "string" ? code : null,
) ?? `HTTP ${res.status}`,
}),
);
return;
@@ -131,12 +136,10 @@ export function RuntimeActionsRow({
model: json.data?.model ?? "",
}),
);
} catch (e) {
} catch {
setTestOk(false);
setTestMsg(
t("settings.ai.testFailedShort", {
message: e instanceof Error ? e.message : "fetch error",
}),
t("settings.ai.testFailedShort", { message: t("common.networkError") }),
);
} finally {
setTesting(false);
+2 -2
View File
@@ -127,8 +127,8 @@ export const LOCAL_MODEL_PRESETS = [
export const CUSTOM_MODEL_SENTINEL = "__custom__";
/**
* Map UI provider tag → the `aiProvider` enum understood by the
* legacy single-result `resolveProvider()` resolver. `codex` and
* Map UI provider tag → the stored `aiProvider` enum, the provider picked
* under Settings, AI. `codex` and
* `admin-openai` aren't user-level provider columns (they're handled
* by Codex OAuth + admin-key fallback), so they map to null and the
* legacy column is left blank.
@@ -36,6 +36,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { useTranslations } from "@/lib/i18n/context";
import { useAiCapability } from "@/hooks/use-ai-capability";
import { planMetricLabel } from "@/lib/ai/coach/plan-metric";
import {
useCoachReminders,
useCoachReminderMutations,
@@ -90,7 +91,7 @@ export function CoachRemindersSection({
>
<p className="text-sm break-words">{r.note}</p>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{r.metric && <span className="uppercase">{r.metric}</span>}
{r.metric && <span>{planMetricLabel(r.metric, t)}</span>}
{r.dueAt && (
<span>
{t("settings.ai.coachReminders.duePrefix", {
+8 -8
View File
@@ -14,10 +14,13 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { isEmailFormDirty } from "@/components/settings/channel-form-dirty";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useChannelEnabledToggle } from "@/components/settings/notification-channel-toggle";
import {
channelSaveError,
useChannelEnabledToggle,
} from "@/components/settings/notification-channel-toggle";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiGet, throwApiError } from "@/lib/api/api-fetch";
interface EmailSettings {
enabled: boolean;
@@ -64,10 +67,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
enabled: settings?.enabled ?? false,
}),
});
if (!res.ok) {
const json = await res.json();
throw new Error(json.error || t("common.error"));
}
if (!res.ok) await throwApiError(res);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() });
@@ -75,8 +75,8 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
toastWrittenOutcome("success", t("settings.saved"));
},
// A form shows its refusal inline, next to the field it is about.
onError: (err: Error) => {
setSaveError(err.message);
onError: (err) => {
setSaveError(channelSaveError(err, t));
},
});
@@ -32,7 +32,7 @@ import { Textarea } from "@/components/ui/textarea";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
MedicationIntakeImportError,
MedicationIntakeImportTimeout,
waitForMedicationIntakeImport,
} from "@/lib/medications/intake-import-poll";
import { invalidateMedicationReads } from "@/lib/query-keys";
@@ -96,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();
@@ -195,14 +206,10 @@ export function DoseHistoryImportCard() {
});
void invalidateMedicationReads(queryClient);
} catch (err) {
// A failed job carries a sanitised reason; anything else is a transport
// failure with nothing to add. Either way the run did not write, and the
// card says so rather than leaving the last verdict on screen.
setError(
err instanceof MedicationIntakeImportError && err.message.length > 0
? err.message
: t("settings.sections.export.import.doseHistory.failed"),
);
// A failed job's reason is the worker's English and adds nothing;
// 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);
@@ -15,33 +15,46 @@ import {
} from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { ApiError, apiFetchRaw, throwApiError } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { translateCodeOrNull } from "@/lib/i18n/translate-code";
interface EnabledSettings {
enabled: boolean;
}
/**
* A refused channel save in the reader's language. The private-origin policy
* reads like the test button's verdict; anything else goes through the API
* catalogue, and a request that never reached the server (offline, a dropped
* connection) reads as the plain "could not save". Never the server's prose.
*/
export function channelSaveError(
err: unknown,
t: (key: string) => string,
): string {
const code = err instanceof ApiError ? err.meta?.errorCode : undefined;
return (
translateCodeOrNull(
t,
"settings.testConnection.errors",
typeof code === "string" ? code : null,
) ?? localizedApiError(err, t, "settings.savingError")
);
}
export async function persistChannelEnabled(
endpoint: string,
enabled: boolean,
fallbackError: string,
): Promise<void> {
// A request that never reaches the server (offline, a dropped connection)
// is a refused change like any other.
const response = await apiFetchRaw(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
}).catch(() => null);
if (!response?.ok) {
const json = (await response?.json().catch(() => null)) as {
error?: string;
} | null;
throw new Error(json?.error || fallbackError);
}
});
if (!response.ok) await throwApiError(response);
}
export function optimisticallySetChannelEnabled<T extends EnabledSettings>(
@@ -75,8 +88,7 @@ export function useChannelEnabledToggle<T extends EnabledSettings>(
const { t } = useTranslations();
const queryClient = useQueryClient();
return useMutation<void, Error, boolean, T | undefined>({
mutationFn: (enabled) =>
persistChannelEnabled(endpoint, enabled, t("settings.savingError")),
mutationFn: (enabled) => persistChannelEnabled(endpoint, enabled),
onMutate: async (enabled) => {
await queryClient.cancelQueries({ queryKey });
return optimisticallySetChannelEnabled<T>(queryClient, queryKey, enabled);
@@ -86,7 +98,7 @@ export function useChannelEnabledToggle<T extends EnabledSettings>(
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(queryClient, queryKey, previous);
showErrorToast(err.message, { cause: err });
showErrorToast(channelSaveError(err, t), { cause: err });
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey });
+8 -21
View File
@@ -15,11 +15,13 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { isNtfyFormDirty } from "@/components/settings/channel-form-dirty";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useChannelEnabledToggle } from "@/components/settings/notification-channel-toggle";
import {
channelSaveError,
useChannelEnabledToggle,
} from "@/components/settings/notification-channel-toggle";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { translateCodeOrNull } from "@/lib/i18n/translate-code";
import { apiFetchRaw, apiGet, throwApiError } from "@/lib/api/api-fetch";
interface NtfySettings {
enabled: boolean;
@@ -73,22 +75,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
enabled: settings?.enabled ?? false,
}),
});
if (!res.ok) {
const json = (await res.json()) as {
error?: string;
meta?: { errorCode?: string };
};
// A coded refusal (the private-origin policy) translates like the
// test button's; anything else shows the server sentence.
throw new Error(
translateCodeOrNull(
t,
"settings.testConnection.errors",
json.meta?.errorCode,
) ??
(json.error || t("common.error")),
);
}
if (!res.ok) await throwApiError(res);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsNtfy() });
@@ -97,8 +84,8 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
setAuthToken("");
},
// A form shows its refusal inline, next to the fields it is about.
onError: (err: Error) => {
setSaveError(err.message);
onError: (err) => {
setSaveError(channelSaveError(err, t));
},
});
@@ -49,6 +49,7 @@ import { ReportVisitOffer } from "./report-visit-offer";
import { Switch } from "@/components/ui/switch";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import {
recentProofErrorMessage,
throwIfReproofRequired,
@@ -177,7 +178,7 @@ export function HealthRecordExportPanel() {
}
const message = recentProofErrorMessage(
err,
err instanceof Error ? err.message : String(err),
localizedApiError(err, t, "settings.healthRecord.errorGeneric"),
);
if (message) setError(message);
} finally {
@@ -62,7 +62,11 @@ describe("the re-proof dialog flow", () => {
const flow = createRecentProofFlow({
view: { setMethods, setPending: vi.fn(), setError: vi.fn() },
submit: vi.fn(),
messages: () => ({ signInAgain: "sign in again", failed: "failed" }),
messages: () => ({
signInAgain: "sign in again",
failed: "failed",
rateLimited: "rate limited",
}),
});
void flow
.run(async () => {
@@ -75,4 +79,23 @@ describe("the re-proof dialog flow", () => {
await vi.waitFor(() => expect(setMethods).toHaveBeenCalledWith(["oidc"]));
flow.cancel();
});
it("says a throttled proof in the caller's words, not the server's", async () => {
const setError = vi.fn();
const flow = createRecentProofFlow({
view: { setMethods: vi.fn(), setPending: vi.fn(), setError },
submit: vi.fn(async () => {
throw new ApiError("Too many attempts. Please wait 15 minutes.", 429);
}),
messages: () => ({
signInAgain: "sign in again",
failed: "failed",
rateLimited: "rate limited",
}),
});
await flow.prove({ method: "password", password: "pw" });
expect(setError).toHaveBeenLastCalledWith("rate limited");
});
});
@@ -12,8 +12,9 @@
* 1. a step-up refusal renders the card's sentence, not the server's prose,
* 2. the mfa_not_enrolled arm maps too — both codes mean "the gate stopped
* you", and handling only the first falls through to raw English,
* 3. an ordinary failure still shows its own message rather than being
* swallowed by the step-up sentence,
* 3. any other refusal gets the caller's sentence (its catalogue sentence
* through `localizedApiError`), not the step-up one and not the server's
* prose,
* 4. the sentence resolves in more than one locale,
* 5. the sentence names a recovery that always exists. A refusal that names
* its proofs (`meta.methods`) opens the re-proof dialog instead and never
@@ -65,14 +66,15 @@ describe("passkey removal — the step-up refusal a person sees", () => {
).toBe(STEP_UP_SENTENCE);
});
it("leaves an ordinary refusal saying its own thing", () => {
it("leaves any other refusal to the caller's sentence, never the server's", () => {
const lastMethod = new ApiError(
"Cannot delete — at least one authentication method must remain",
"Cannot delete. At least one authentication method must remain",
400,
{ errorCode: "auth.passkey.last_method" },
);
expect(describeStepUp(lastMethod, "fallback", STEP_UP_SENTENCE)).toContain(
"at least one authentication method",
expect(describeStepUp(lastMethod, "fallback", STEP_UP_SENTENCE)).toBe(
"fallback",
);
});
@@ -27,7 +27,7 @@
*
* Mutation check: drop either `onSettled` and the wiring case goes red; drop
* an `open=` binding and the controlled case goes red; return `err.message`
* from `describeError`'s step-up arm and the sentence case goes red.
* from `describeStepUp`'s step-up arm and the sentence case goes red.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
@@ -38,7 +38,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ApiError } from "@/lib/api/api-fetch";
import { I18nProvider } from "@/lib/i18n/context";
import en from "@/../messages/en.json";
import { describeError, TotpCard } from "../totp-card";
import { describeStepUp } from "../security-keys-card";
import { TotpCard } from "../totp-card";
const STEP_UP_SENTENCE = en.settings.security.stepUpRequired;
@@ -81,7 +82,7 @@ describe("<TotpCard> — a refused mutation", () => {
{ errorCode: "auth.stepup.required" },
);
const shown = describeError(refusal, "fallback", STEP_UP_SENTENCE);
const shown = describeStepUp(refusal, "fallback", STEP_UP_SENTENCE);
expect(shown).toBe(STEP_UP_SENTENCE);
expect(shown).not.toContain("second-factor verification required");
@@ -17,6 +17,7 @@ import { Segmented } from "@/components/ui/segmented";
import { Input } from "@/components/ui/input";
import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { describePasskeyError } from "@/lib/passkey-errors";
export type ReauthMethod =
@@ -171,7 +172,7 @@ export function ExistingFactorReauthDialog({
onProof({ method, challengeId: begun.challengeId, credential });
} catch (err) {
if (err instanceof ApiError) {
setAssertionError(err.message);
setAssertionError(localizedApiError(err, t, "settings.reproof.failed"));
} else {
const { key, params } = describePasskeyError(err);
setAssertionError(t(key, params));
@@ -43,6 +43,7 @@ import {
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { describePasskeyError } from "@/lib/passkey-errors";
import { describeStepUp } from "./security-keys-card";
import {
@@ -206,7 +207,7 @@ export function PasskeyListSection({
},
onError: (err) => {
if (err instanceof ApiError) {
setMsg(err.message || t("settings.passkeyRegistrationFailed"));
setMsg(localizedApiError(err, t, "settings.passkeyRegistrationFailed"));
} else {
const { key, params } = describePasskeyError(err);
setMsg(t(key, params));
@@ -223,7 +224,8 @@ export function PasskeyListSection({
setMsg(null);
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
},
onError: (err: Error) => setMsg(err.message),
onError: (err) =>
setMsg(localizedApiError(err, t, "settings.passkeyRenameFailed")),
});
// Removal is step-up gated: a second factor, or on a passkey-only account a
@@ -248,7 +250,11 @@ export function PasskeyListSection({
onError: (err: Error) => {
if (err instanceof ReproofCancelledError) return;
setMsg(
describeStepUp(err, err.message, t("settings.passkeyStepUpRequired")),
describeStepUp(
err,
localizedApiError(err, t, "settings.passkeyRemoveFailed"),
t("settings.passkeyStepUpRequired"),
),
);
},
});
@@ -83,7 +83,7 @@ export function createRecentProofFlow(deps: {
/** Posts the proof to `/api/auth/reproof`. */
submit: (proof: ExistingFactorProof) => Promise<unknown>;
/** Read at the moment they are needed, so a locale switch is honoured. */
messages: () => { signInAgain: string; failed: string };
messages: () => { signInAgain: string; failed: string; rateLimited: string };
}): RecentProofFlow {
let waiter: { resolve: () => void; reject: () => void } | null = null;
@@ -127,7 +127,7 @@ export function createRecentProofFlow(deps: {
} catch (err) {
deps.view.setError(
err instanceof ApiError && err.status === 429
? err.message
? deps.messages().rateLimited
: deps.messages().failed,
);
} finally {
@@ -23,6 +23,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { describePasskeyError } from "@/lib/passkey-errors";
import { formatDate } from "@/lib/format";
import {
@@ -50,6 +51,8 @@ export interface WebauthnKeyInfo {
* "Recent second-factor verification required", which is English-only and, for a
* passkey-only account, not even accurate about which credential to re-prove —
* so the caller supplies its own sentence and this only decides when to use it.
* Any other refusal reads as `fallback`, which callers resolve through
* `localizedApiError`; the server's prose is never shown.
*
* Matched on `meta.errorCode`, and on the whole `auth.stepup` prefix rather than
* one code: `auth.stepup.required` and `auth.stepup.mfa_not_enrolled` both mean
@@ -70,7 +73,6 @@ export function describeStepUp(
) {
return stepUpMsg;
}
return err.message || fallback;
}
return fallback;
}
@@ -141,7 +143,7 @@ export function SecurityKeysCard({
}
let message: string;
if (err instanceof ApiError) {
message = err.message || t("settings.security.keys.addFailed");
message = localizedApiError(err, t, "settings.security.keys.addFailed");
} else {
const { key, params } = describePasskeyError(err);
message = t(key, params);
@@ -165,9 +167,7 @@ export function SecurityKeysCard({
},
onError: (err) =>
setError(
err instanceof ApiError
? err.message
: t("settings.security.keys.renameFailed"),
localizedApiError(err, t, "settings.security.keys.renameFailed"),
),
});
@@ -191,7 +191,7 @@ export function SecurityKeysCard({
setError(
describeStepUp(
err,
t("settings.security.keys.removeFailed"),
localizedApiError(err, t, "settings.security.keys.removeFailed"),
t("settings.security.stepUpRequired"),
),
);
@@ -31,8 +31,10 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { RecoveryCodesPanel } from "./recovery-codes-panel";
import { describeStepUp } from "./security-keys-card";
import {
ReproofCancelledError,
recentProofErrorMessage,
@@ -59,31 +61,6 @@ interface ConfirmResult {
recoveryCodesRemaining: number;
}
/**
* Map a step-up 401 to a clear re-login message; otherwise the raw message.
*
* Exported for the unit suite: this is the sentence a refused mutation leaves
* on the card, and the card is where a person reads it.
*/
export function describeError(
err: unknown,
fallback: string,
stepUpMsg: string,
): string {
if (err instanceof ApiError) {
const code = err.meta?.errorCode;
if (
err.status === 401 &&
typeof code === "string" &&
code.startsWith("auth.stepup")
) {
return stepUpMsg;
}
return err.message || fallback;
}
return fallback;
}
export function TotpCard({
enabled,
recoveryCodesRemaining,
@@ -201,9 +178,9 @@ export function TotpCard({
setReauthOpen(true);
return;
}
const message = describeError(
const message = describeStepUp(
err,
t("settings.security.totp.setupFailed"),
localizedApiError(err, t, "settings.security.totp.setupFailed"),
t("settings.security.stepUpRequired"),
);
if (proof) setReauthError(message);
@@ -223,9 +200,9 @@ export function TotpCard({
},
onError: (err) =>
setError(
describeError(
describeStepUp(
err,
t("settings.security.totp.invalidCode"),
localizedApiError(err, t, "settings.security.totp.invalidCode"),
t("settings.security.stepUpRequired"),
),
),
@@ -248,9 +225,9 @@ export function TotpCard({
onError: (err) => {
if (err instanceof ReproofCancelledError) return;
setError(
describeError(
describeStepUp(
err,
t("settings.security.totp.disableFailed"),
localizedApiError(err, t, "settings.security.totp.disableFailed"),
t("settings.security.stepUpRequired"),
),
);
@@ -274,9 +251,9 @@ export function TotpCard({
onError: (err) => {
if (err instanceof ReproofCancelledError) return;
setError(
describeError(
describeStepUp(
err,
t("settings.security.recovery.regenFailed"),
localizedApiError(err, t, "settings.security.recovery.regenFailed"),
t("settings.security.stepUpRequired"),
),
);
@@ -105,6 +105,7 @@ export function useRecentProof(
messages: () => ({
signInAgain: t("settings.reproof.signInAgain"),
failed: t("settings.reproof.failed"),
rateLimited: t("apiErrors.http.rate_limited"),
}),
}),
[t],
+18 -3
View File
@@ -47,6 +47,7 @@ import { cn } from "@/lib/utils";
import { PageHeader } from "@/components/ui/page-header";
import { SUB_SHELL_GRID_FLOOR } from "@/components/layout/shell-metrics";
import { useAuth } from "@/hooks/use-auth";
import { useNavModules } from "@/hooks/use-nav-modules";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { isSurfaceVisible } from "@/lib/modules/surface";
@@ -355,6 +356,20 @@ function deriveActiveSlug(
return isSettingsSectionSlug(candidate) ? candidate : "account";
}
/**
* The module map the section list reads, the sidebar's. A managed record has
* no Settings, AI to list its Coach settings (its data scope, its switch), so
* there the Coach entry follows the module switch alone, not whether the
* record's Coach can answer yet.
*/
function useSectionModules() {
const { user } = useAuth();
const navModules = useNavModules();
return user?.accountAccess?.active?.recordKind === "managed"
? user.modules
: navModules;
}
export function SettingsShell({
active,
heading,
@@ -366,8 +381,8 @@ export function SettingsShell({
const activeSlug = deriveActiveSlug(pathname, active);
// v1.18.0 — per-submodule entries are listed only when their module
// is enabled. Read from the resolved `useAuth().user.modules` map (the
// same map the Module hub, nav, and Insights pills gate off). Fail OPEN
// is enabled, from `useNavModules()` as in the sidebar (Coach follows its
// AI capability; Settings → AI then lists the stored memory). Fail OPEN
// (`!== false`): a missing key, or a not-yet-resolved `/me` payload,
// reads as enabled so an entry never silently disappears. A section's
// owner comes from the surface map (`settings:<slug>`); sections with no
@@ -390,7 +405,7 @@ export function SettingsShell({
// effect (which the lint rule flags as a cascading render), and no risk of
// the two passes disagreeing.
const hydrated = useMounted();
const modules = user?.modules;
const modules = useSectionModules();
const activeRecord = user?.accountAccess?.active ?? null;
const allVisibleSections = SETTINGS_SECTIONS.filter(
(section) =>
@@ -22,7 +22,8 @@ import {
type ThresholdMetric,
type EffectiveRange,
} from "@/lib/analytics/effective-range";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiGet, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -81,15 +82,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
mutationFn: async (
payload: Partial<Record<ThresholdMetric, { min: number; max: number }>>,
) => {
const res = await apiFetchRaw("/api/user/thresholds", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.error ?? "save failed");
}
await apiPut("/api/user/thresholds", payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.userThresholds() });
@@ -102,10 +95,9 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
toast.success(t("thresholds.saveSuccess"));
},
onError: (err) =>
showErrorToast(
err instanceof Error ? err.message : t("thresholds.saveError"),
{ cause: err },
),
showErrorToast(localizedApiError(err, t, "thresholds.saveError"), {
cause: err,
}),
});
const resetMutation = useMutation({
+8 -21
View File
@@ -16,12 +16,14 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { isWebhookFormDirty } from "@/components/settings/channel-form-dirty";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useChannelEnabledToggle } from "@/components/settings/notification-channel-toggle";
import {
channelSaveError,
useChannelEnabledToggle,
} from "@/components/settings/notification-channel-toggle";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { apiFetchRaw, apiGet, throwApiError } from "@/lib/api/api-fetch";
import type { WebhookPayloadFormat } from "@/lib/notifications/types";
import { translateCodeOrNull } from "@/lib/i18n/translate-code";
interface WebhookSettings {
enabled: boolean;
@@ -82,22 +84,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
enabled: settings?.enabled ?? false,
}),
});
if (!res.ok) {
const json = (await res.json()) as {
error?: string;
meta?: { errorCode?: string };
};
// A coded refusal (the private-origin policy) translates like the
// test button's; anything else shows the server sentence.
throw new Error(
translateCodeOrNull(
t,
"settings.testConnection.errors",
json.meta?.errorCode,
) ??
(json.error || t("common.error")),
);
}
if (!res.ok) await throwApiError(res);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() });
@@ -106,8 +93,8 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
setHeaderValue("");
},
// A form shows its refusal inline, next to the fields it is about.
onError: (err: Error) => {
setSaveError(err.message);
onError: (err) => {
setSaveError(channelSaveError(err, t));
},
});