mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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:
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 ?? "",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user