From 3ebef19ac3a6b7ef1d562eb7a70e6061e84b7a63 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Wed, 21 Oct 2026 19:42:53 +0200
Subject: [PATCH] fix(settings): one save model and one confirm per card
The Telegram, ntfy, webhook and email cards saved their enable switch on
tap and the rest of the form on Save; the switch is now part of the form
and the one Save sends it. The display selects confirm a change with a
toast. Destructive confirmations in Settings go through ConfirmButton or
ConfirmDialog with the destructive style. Two structural guards hold both
rules: a card may not mix instant and explicit saves, and no settings file
reaches for a raw AlertDialog without a written reason.
---
e2e/notification-dispatch-journey.spec.ts | 20 +-
.../destructive-control-guard.test.ts | 12 +-
.../success-affordance-guard.test.ts | 4 -
.../__tests__/channel-form-dirty.test.ts | 77 +++++-
.../__tests__/confirm-style-guard.test.ts | 77 ++++++
.../notification-channel-cards.test.tsx | 79 ++----
.../notification-channel-toggle.test.ts | 93 -------
.../__tests__/save-model-guard.test.ts | 256 ++++++++++++++++++
.../__tests__/token-revoke-labels.test.ts | 10 +-
src/components/settings/advanced-section.tsx | 170 ++++--------
.../settings/ai/fallback-chain-card.tsx | 47 +---
src/components/settings/api-section.tsx | 102 ++-----
src/components/settings/channel-form-dirty.ts | 46 +++-
.../settings/coach-memory-section.tsx | 73 +----
.../settings/dashboard-layout-section.tsx | 1 +
.../settings/date-format-select.tsx | 57 +---
src/components/settings/email-card.tsx | 83 ++----
.../settings/glucose-unit-select.tsx | 61 +----
.../settings/notification-channel-toggle.ts | 44 ---
src/components/settings/ntfy-card.tsx | 84 ++----
.../security-section/passkey-list-section.tsx | 56 ++--
.../security-section/security-keys-card.tsx | 69 ++---
.../settings/security-section/totp-card.tsx | 1 +
src/components/settings/sharing-section.tsx | 59 ++--
src/components/settings/sources-section.tsx | 1 +
src/components/settings/telegram-card.tsx | 105 ++-----
.../settings/thresholds-editor-section.tsx | 3 +
.../settings/time-format-select.tsx | 57 +---
.../settings/unit-preference-select.tsx | 59 +---
src/components/settings/webhook-card.tsx | 89 ++----
.../ui/__tests__/confirm-button.test.tsx | 32 +++
src/components/ui/confirm-button.tsx | 38 ++-
32 files changed, 830 insertions(+), 1135 deletions(-)
create mode 100644 src/components/settings/__tests__/confirm-style-guard.test.ts
delete mode 100644 src/components/settings/__tests__/notification-channel-toggle.test.ts
create mode 100644 src/components/settings/__tests__/save-model-guard.test.ts
delete mode 100644 src/components/settings/notification-channel-toggle.ts
diff --git a/e2e/notification-dispatch-journey.spec.ts b/e2e/notification-dispatch-journey.spec.ts
index ea8b68a90..b2b0e8061 100644
--- a/e2e/notification-dispatch-journey.spec.ts
+++ b/e2e/notification-dispatch-journey.spec.ts
@@ -270,21 +270,6 @@ async function saveCard(
return (await settled).status();
}
-/** Flip a channel card's enable switch and wait for the route to answer. */
-async function toggleCard(
- page: Page,
- switchId: string,
- endpoint: string,
-): Promise {
- const settled = page.waitForResponse(
- (res) =>
- new URL(res.url()).pathname === endpoint &&
- res.request().method() === "PUT",
- );
- await page.locator(switchId).click();
- return (await settled).status();
-}
-
/**
* Configure both channels the way a person would: email on with a recipient,
* ntfy saved and left off. Everything here happens on the settings surface.
@@ -292,11 +277,10 @@ async function toggleCard(
async function configureChannels(page: Page): Promise {
await openChannels(page);
+ // The enable switch is a field of the form: it saves with the one Save.
await fillField(page, "#email-recipient", EMAIL_RECIPIENT);
+ await page.locator("#email-toggle").click();
expect(await saveCard(page, "#email", "/api/settings/email")).toBe(200);
- expect(await toggleCard(page, "#email-toggle", "/api/settings/email")).toBe(
- 200,
- );
await expect(page.locator("#email-toggle")).toHaveAttribute(
"data-state",
"checked",
diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts
index 6fe80d772..75ed78be4 100644
--- a/src/__tests__/destructive-control-guard.test.ts
+++ b/src/__tests__/destructive-control-guard.test.ts
@@ -110,7 +110,7 @@ const REGISTRY: DestructiveEntry[] = [
destroys:
"every health record on the account, or the whole account including passkeys and audit history",
recovery: "permanent",
- confirm: ["AlertDialog"],
+ confirm: ["ConfirmDialog"],
},
{
file: "components/settings/security-sessions-card.tsx",
@@ -129,13 +129,13 @@ const REGISTRY: DestructiveEntry[] = [
file: "components/settings/security-section/passkey-list-section.tsx",
destroys: "a passkey — hard-deleted, the credential cannot be re-derived",
recovery: "permanent",
- confirm: ["AlertDialog"],
+ confirm: ["ConfirmButton"],
},
{
file: "components/settings/security-section/security-keys-card.tsx",
destroys: "a WebAuthn second factor",
recovery: "permanent",
- confirm: ["AlertDialog"],
+ confirm: ["ConfirmButton"],
},
{
file: "components/settings/ai/remove-saved-key-button.tsx",
@@ -156,7 +156,7 @@ const REGISTRY: DestructiveEntry[] = [
file: "components/settings/api-section.tsx",
destroys: "an API token — revoked, and the secret is never shown again",
recovery: "permanent",
- confirm: ["AlertDialog"],
+ confirm: ["ConfirmButton"],
},
{
file: "components/settings/mcp-section.tsx",
@@ -168,7 +168,7 @@ const REGISTRY: DestructiveEntry[] = [
file: "components/settings/sharing-section.tsx",
destroys: "a clinician share link — the URL stays dead once revoked",
recovery: "permanent",
- confirm: ["AlertDialog"],
+ confirm: ["ConfirmButton"],
},
{
file: "components/settings/account-section/avatar-section.tsx",
@@ -214,7 +214,7 @@ const REGISTRY: DestructiveEntry[] = [
file: "components/settings/coach-memory-section.tsx",
destroys: "every remembered Coach fact at once",
recovery: "tombstoned-no-restore",
- confirm: ["AlertDialog"],
+ confirm: ["ConfirmButton"],
},
{
file: "components/insights/coach-panel/use-coach-facts.ts",
diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts
index a41563ed7..2b78f8702 100644
--- a/src/__tests__/success-affordance-guard.test.ts
+++ b/src/__tests__/success-affordance-guard.test.ts
@@ -348,7 +348,6 @@ const PINNED_AFFORDANCES: Record<
"src/components/settings/dashboard-layout-section.tsx": {
"toast.success": 2,
},
- "src/components/settings/email-card.tsx": { "text-success": 1 },
"src/components/settings/environment-section.tsx": { "toast.success": 4 },
"src/components/settings/insights-pill-order-section.tsx": {
"toast.success": 1,
@@ -389,7 +388,6 @@ const PINNED_AFFORDANCES: Record<
CheckCircle2: 2,
"text-success": 1,
},
- "src/components/settings/ntfy-card.tsx": { "text-success": 1 },
// The one that used to sit inside `account-section/index.tsx` — the card
// moved to Settings → Security, the line moved with it.
"src/components/settings/password-card.tsx": { "text-success": 1 },
@@ -397,7 +395,6 @@ const PINNED_AFFORDANCES: Record<
"src/components/settings/share-link-create-form.tsx": { "text-success": 3 },
"src/components/settings/sharing-section.tsx": { "text-success": 1 },
"src/components/settings/sources-section.tsx": { "toast.success": 2 },
- "src/components/settings/telegram-card.tsx": { "text-success": 1 },
"src/components/settings/test-connection-button.tsx": {
CheckCircle2: 2,
"text-success": 1,
@@ -407,7 +404,6 @@ const PINNED_AFFORDANCES: Record<
},
"src/components/settings/trusted-devices-card.tsx": { "text-success": 1 },
"src/components/settings/web-push-card.tsx": { "text-success": 1 },
- "src/components/settings/webhook-card.tsx": { "text-success": 1 },
"src/components/targets/target-edit-sheet.tsx": { "toast.success": 2 },
"src/components/ui/password-strength.tsx": { "text-success": 2 },
// The success tone of the grade chip (a high confidence, a score that went
diff --git a/src/components/settings/__tests__/channel-form-dirty.test.ts b/src/components/settings/__tests__/channel-form-dirty.test.ts
index 63e57592c..cb20527dc 100644
--- a/src/components/settings/__tests__/channel-form-dirty.test.ts
+++ b/src/components/settings/__tests__/channel-form-dirty.test.ts
@@ -21,6 +21,7 @@ describe("channel form dirty state", () => {
headerName: "X-Gotify-Key",
headerValue: "",
format: "gotify" as const,
+ enabled: false,
};
it("webhook: a form that matches the saved config is clean", () => {
@@ -56,7 +57,12 @@ describe("channel form dirty state", () => {
it("ntfy: server, topic and a typed auth token count", () => {
const saved = { serverUrl: "https://ntfy.sh", topic: "t" };
- const form = { serverUrl: "https://ntfy.sh", topic: "t", authToken: "" };
+ const form = {
+ serverUrl: "https://ntfy.sh",
+ topic: "t",
+ authToken: "",
+ enabled: false,
+ };
expect(isNtfyFormDirty(form, saved)).toBe(false);
expect(isNtfyFormDirty({ ...form, topic: "u" }, saved)).toBe(true);
expect(isNtfyFormDirty({ ...form, serverUrl: "https://x" }, saved)).toBe(
@@ -67,39 +73,86 @@ describe("channel form dirty state", () => {
it("email: the recipient counts", () => {
expect(
- isEmailFormDirty({ recipient: "a@x.io" }, { recipient: "a@x.io" }),
+ isEmailFormDirty(
+ { recipient: "a@x.io", enabled: false },
+ { recipient: "a@x.io" },
+ ),
).toBe(false);
expect(
- isEmailFormDirty({ recipient: "b@x.io" }, { recipient: "a@x.io" }),
+ isEmailFormDirty(
+ { recipient: "b@x.io", enabled: false },
+ { recipient: "a@x.io" },
+ ),
).toBe(true);
});
it("telegram: a typed bot token or a changed chat id counts; whitespace alone does not", () => {
expect(
- isTelegramFormDirty({ botToken: "", chatId: "1" }, { chatId: "1" }),
- ).toBe(false);
- expect(
- isTelegramFormDirty({ botToken: " ", chatId: "" }, { chatId: null }),
+ isTelegramFormDirty(
+ { botToken: "", chatId: "1", enabled: false },
+ { chatId: "1" },
+ ),
).toBe(false);
expect(
isTelegramFormDirty(
- { botToken: "123:abc", chatId: "1" },
+ { botToken: " ", chatId: "", enabled: false },
+ { chatId: null },
+ ),
+ ).toBe(false);
+ expect(
+ isTelegramFormDirty(
+ { botToken: "123:abc", chatId: "1", enabled: false },
{ chatId: "1" },
),
).toBe(true);
expect(
- isTelegramFormDirty({ botToken: "", chatId: "2" }, { chatId: "1" }),
+ isTelegramFormDirty(
+ { botToken: "", chatId: "2", enabled: false },
+ { chatId: "1" },
+ ),
).toBe(true);
});
+ it("a flipped enable switch is a change on every channel, since it saves with the form", () => {
+ expect(
+ isWebhookFormDirty({ ...webhookForm, enabled: true }, webhookSaved),
+ ).toBe(true);
+ expect(
+ isNtfyFormDirty(
+ { serverUrl: "s", topic: "t", authToken: "", enabled: true },
+ { serverUrl: "s", topic: "t", enabled: false },
+ ),
+ ).toBe(true);
+ expect(
+ isEmailFormDirty(
+ { recipient: "a@x.io", enabled: false },
+ { recipient: "a@x.io", enabled: true },
+ ),
+ ).toBe(true);
+ expect(
+ isTelegramFormDirty(
+ { botToken: "", chatId: "1", enabled: true },
+ { chatId: "1", enabled: true },
+ ),
+ ).toBe(false);
+ });
+
it("nothing loaded yet is never a change", () => {
expect(isWebhookFormDirty(webhookForm, undefined)).toBe(false);
expect(
- isNtfyFormDirty({ serverUrl: "", topic: "", authToken: "x" }, undefined),
+ isNtfyFormDirty(
+ { serverUrl: "", topic: "", authToken: "x", enabled: true },
+ undefined,
+ ),
).toBe(false);
- expect(isEmailFormDirty({ recipient: "x" }, undefined)).toBe(false);
- expect(isTelegramFormDirty({ botToken: "x", chatId: "" }, undefined)).toBe(
+ expect(isEmailFormDirty({ recipient: "x", enabled: true }, undefined)).toBe(
false,
);
+ expect(
+ isTelegramFormDirty(
+ { botToken: "x", chatId: "", enabled: true },
+ undefined,
+ ),
+ ).toBe(false);
});
});
diff --git a/src/components/settings/__tests__/confirm-style-guard.test.ts b/src/components/settings/__tests__/confirm-style-guard.test.ts
new file mode 100644
index 000000000..a83d4f0c3
--- /dev/null
+++ b/src/components/settings/__tests__/confirm-style-guard.test.ts
@@ -0,0 +1,77 @@
+/**
+ * One confirm style across Settings.
+ *
+ * A destructive act (delete, revoke, remove, reset, sign out) confirms through
+ * `ConfirmButton` / `ConfirmDialog` (`src/components/ui/confirm-button.tsx`),
+ * which owns the dialog's anatomy and wears the destructive style when the
+ * caller says the act is destructive. A hand-built `AlertDialog` is how the
+ * same question ended up styled three different ways on neighbouring cards.
+ *
+ * So no file under `src/components/settings` imports the raw `AlertDialog`
+ * unless it is listed below with the reason the primitive does not fit.
+ */
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { join, relative } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+const ROOT = join(process.cwd(), "src", "components", "settings");
+
+const ALLOWED: Readonly> = {
+ "security-section/existing-factor-reauth-dialog.tsx":
+ "a re-authentication form, not a destructive confirm",
+ "security-section/passkey-list-section.tsx":
+ "the add-passkey re-authentication form; the delete confirm uses ConfirmButton",
+ "security-section/totp-card.tsx":
+ "the disable confirm asks for a current code and stays open until the server answers; both actions wear the destructive style",
+ "mcp-section.tsx":
+ "the connection and token revoke confirms; the MCP card is reshaped separately",
+};
+
+/** Owned by the integration cards, which follow their own card kit. */
+const ALLOWED_PREFIXES: readonly string[] = ["integrations/"];
+
+function walk(dir: string): string[] {
+ return readdirSync(dir).flatMap((name) => {
+ const path = join(dir, name);
+ if (statSync(path).isDirectory()) {
+ return name === "__tests__" ? [] : walk(path);
+ }
+ return /\.tsx?$/.test(name) ? [path] : [];
+ });
+}
+
+const IMPORTS_ALERT_DIALOG = /from\s+["']@\/components\/ui\/alert-dialog["']/;
+
+describe("settings confirm style", () => {
+ const files = walk(ROOT);
+
+ it("scans the settings tree", () => {
+ expect(files.length).toBeGreaterThan(50);
+ });
+
+ it("imports the raw AlertDialog only where the primitive does not fit", () => {
+ const offenders = files
+ .map((path) => relative(ROOT, path))
+ .filter((rel) =>
+ IMPORTS_ALERT_DIALOG.test(readFileSync(join(ROOT, rel), "utf8")),
+ )
+ .filter(
+ (rel) =>
+ !(rel in ALLOWED) &&
+ !ALLOWED_PREFIXES.some((prefix) => rel.startsWith(prefix)),
+ );
+ expect(
+ offenders,
+ "Confirm a destructive act through ConfirmButton / ConfirmDialog, or list the file here with the reason",
+ ).toEqual([]);
+ });
+
+ it("lists no file that no longer needs the exemption", () => {
+ const stale = Object.keys(ALLOWED).filter(
+ (rel) =>
+ !IMPORTS_ALERT_DIALOG.test(readFileSync(join(ROOT, rel), "utf8")),
+ );
+ expect(stale).toEqual([]);
+ });
+});
diff --git a/src/components/settings/__tests__/notification-channel-cards.test.tsx b/src/components/settings/__tests__/notification-channel-cards.test.tsx
index 85861ac18..26a2d5b2a 100644
--- a/src/components/settings/__tests__/notification-channel-cards.test.tsx
+++ b/src/components/settings/__tests__/notification-channel-cards.test.tsx
@@ -121,60 +121,27 @@ describe("notification channel cards", () => {
);
it.each([
- ["webhook", , 1],
- ["ntfy", , 1],
- ["email", , 1],
- ["telegram", , 0],
+ ["webhook", ],
+ ["ntfy", ],
+ ["email", ],
+ ["telegram", ],
] as const)(
- "%s switch invokes the enable-only mutation rather than the full save",
- async (channel, card, toggleMutationIndex) => {
- render(card);
-
- await mocks.mutationOptions[toggleMutationIndex]!.mutationFn(true);
-
- expect(mocks.apiFetchRaw).toHaveBeenCalledTimes(1);
- expect(mocks.apiFetchRaw).toHaveBeenCalledWith(
- `/api/settings/${channel}`,
- {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ enabled: true }),
- },
- );
- },
- );
-
- it.each([
- [, 1, ["settings", "webhook"]],
- [, 1, ["settings", "ntfy"]],
- [, 1, ["settings", "email"]],
- [
- ,
- 0,
- ["telegram", "settings"],
- ],
- ] as const)(
- "rolls each visible switch back to its previous server state on error",
- async (card, toggleMutationIndex, queryKey) => {
- const previous = { enabled: false, persistedConfig: "unchanged" };
- mocks.queryClient.getQueryData.mockReturnValue(previous);
- render(card);
- const toggle = mocks.mutationOptions[toggleMutationIndex]!;
-
- const context = await toggle.onMutate!(true);
- const optimisticUpdater = mocks.queryClient.setQueryData.mock
- .calls[0]![1] as (current: typeof previous) => typeof previous;
- expect(optimisticUpdater(previous)).toEqual({
- ...previous,
- enabled: true,
- });
-
- toggle.onError!(new Error("save failed"), true, context);
-
- expect(mocks.queryClient.setQueryData).toHaveBeenLastCalledWith(
- queryKey,
- previous,
- );
+ "%s enable switch is a form field: it writes nothing on its own",
+ async (_channel, card) => {
+ const html = render(card);
+ // Rendering the switch writes nothing, and the card mounts no
+ // switch-only mutation: every write it can make is the full form.
+ expect(html).toContain('role="switch"');
+ expect(mocks.apiFetchRaw).not.toHaveBeenCalled();
+ for (const mutation of mocks.mutationOptions) {
+ await mutation.mutationFn(true);
+ }
+ for (const [, init] of mocks.apiFetchRaw.mock.calls as Array<
+ [string, { body: string }]
+ >) {
+ const body = JSON.parse(init.body) as Record;
+ expect(Object.keys(body).length).toBeGreaterThan(1);
+ }
},
);
@@ -185,9 +152,9 @@ describe("notification channel cards", () => {
expect(html).toMatch(/
)}
- setConfirmOpen(true)}
+ variant="destructive"
+ size="sm"
+ disabled={deleting}
+ className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
+ >
+ {deleting ? (
+
+ ) : (
+
+ )}
+ {t("settings.dangerZone")}
+
+ {
- // Hold the dialog open while the destructive mutation is in
- // flight so the in-dialog pending state stays visible and a
- // stray backdrop tap can't dismiss it mid-request.
+ // Hold the dialog open while the request runs so the pending
+ // state stays on screen and a stray dismissal cannot fire it twice.
if (deleting) return;
setConfirmOpen(open);
}}
- >
-
-
-
-
-
-
- {t("settings.dangerZoneConfirm")}
-
-
- {t("settings.dangerZoneConfirmDescription")}
-
-
-
-
- {t("common.cancel")}
-
- {
- e.preventDefault();
- void handleDeleteAllData();
- }}
- disabled={deleting}
- aria-busy={deleting || undefined}
- data-slot="settings-data-reset-confirm"
- >
- {deleting && (
-
- )}
- {t("settings.finalDelete")}
-
-
-
-
+ title={t("settings.dangerZoneConfirm")}
+ body={t("settings.dangerZoneConfirmDescription")}
+ confirmLabel={t("settings.finalDelete")}
+ onConfirm={() => void handleDeleteAllData()}
+ pending={deleting}
+ slot="settings-data-reset"
+ holdOpen
+ destructive
+ />
);
@@ -291,63 +257,39 @@ function AccountDeleteCard() {
)}
- setConfirmOpen(true)}
+ variant="destructive"
+ size="sm"
+ disabled={deleting}
+ className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
+ data-slot="settings-account-delete-trigger"
+ >
+ {deleting ? (
+
+ ) : (
+
+ )}
+ {t("settings.deleteAccountCta")}
+
+ {
- // Hold the dialog open while the irreversible delete runs so
- // the pending state stays on screen and a stray dismissal
- // can't fire a second request.
+ // Hold the dialog open while the request runs so the pending
+ // state stays on screen and a stray dismissal cannot fire it twice.
if (deleting) return;
setConfirmOpen(open);
}}
- >
-
-
-
-
-
-
- {t("settings.deleteAccountConfirmTitle")}
-
-
- {t("settings.deleteAccountConfirmDescription")}
-
-
-
-
- {t("common.cancel")}
-
- {
- e.preventDefault();
- void handleDeleteAccount();
- }}
- disabled={deleting}
- aria-busy={deleting || undefined}
- data-slot="settings-account-delete-confirm"
- >
- {deleting && (
-
- )}
- {t("settings.deleteAccountFinal")}
-
-
-
-
+ title={t("settings.deleteAccountConfirmTitle")}
+ body={t("settings.deleteAccountConfirmDescription")}
+ confirmLabel={t("settings.deleteAccountFinal")}
+ onConfirm={() => void handleDeleteAccount()}
+ pending={deleting}
+ slot="settings-account-delete"
+ holdOpen
+ destructive
+ />
);
diff --git a/src/components/settings/ai/fallback-chain-card.tsx b/src/components/settings/ai/fallback-chain-card.tsx
index c563d943a..1c6631ec0 100644
--- a/src/components/settings/ai/fallback-chain-card.tsx
+++ b/src/components/settings/ai/fallback-chain-card.tsx
@@ -18,18 +18,8 @@ import {
X,
} from "lucide-react";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
+import { ConfirmButton } from "@/components/ui/confirm-button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -246,30 +236,17 @@ export function FallbackChainCard({
)}
-
-
-
-
-
-
-
- {t("settings.ai.providerChain.resetConfirmTitle")}
-
-
- {t("settings.ai.providerChain.resetConfirmBody")}
-
-
-
- {t("common.cancel")}
-
- {t("settings.ai.providerChain.resetDefaults")}
-
-
-
-
+ void resetToDefaults()}
+ size="sm"
+ className="min-h-11 sm:min-h-9"
+ icon={}
+ destructive
+ />