fix(ui): a mis-tap must not be able to destroy an allergy list

Settings → AI "About me" had one Clear control wired to
`save.mutate({ aboutMe: "", allergies: "" })`. One tap, no dialog, and
the allergy line went with the note — while the toast it raised said
"About-me note cleared", which is what the control looks like it does.
The allergy line is the first thing a clinician reads and the column is
overwritten in place, so there is nothing to restore afterwards.

Scope the control rather than only gating it: it now writes `aboutMe`
alone and omits `allergies`, which the PUT leaves untouched by contract,
so the payload cannot carry the allergy line away even if the dialog is
ever dropped. Clearing allergies stays possible in the allergies field
itself, which is a deliberate act on the thing being emptied. The
confirmation on top guards the note.

The same one-tap shape ran through thirteen other controls that
overwrite a column or drop a credential with nothing to restore from.
They route through the shared confirmation now: both Codex disconnects
(personal and the instance-wide shared account), the AI server key, the
profile photo, the threshold reset — all, per metric, and the override
switch, whose off position performed the same irreversible delete from a
control that reads as a display toggle — the source-priority order, the
dashboard layout, the Insights overview, a single target range, a
remembered Coach fact, a past Coach plan, a logged cycle day and a custom
cycle symptom.

`ConfirmButton` moves to `components/ui`, since it is no longer a
settings-local helper, and gains `ConfirmDialog`: the same dialog without
a trigger, for destructive controls that are not buttons. A switch has to
stay on when the dialog its off position raised is cancelled, and a
control that could not reach for the primitive reached for nothing —
which is a fair part of why those were the ones that shipped unguarded.

Copy lands in all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 17:26:56 +02:00
1 parent 0afd217f9f
commit 48ac8f7e2d
25 files changed
+587 -182

No files matched your search

@@ -122,11 +122,13 @@ describe("Settings — CoachMemorySection", () => {
it("renders a per-fact forget control and the bulk forget-all action", () => {
const html = render(buildFacts());
expect(html).toContain('data-testid="settings-coach-memory-forget"');
// The per-row control routes through `ConfirmButton`, which identifies
// itself with `data-slot`; the bulk one is still a local dialog.
expect(html).toContain('data-slot="settings-coach-memory-forget"');
expect(html).toContain('data-testid="settings-coach-memory-forget-all"');
// One forget button per fact (3 seeded).
const forgetButtons = html.match(
/data-testid="settings-coach-memory-forget"/g,
/data-slot="settings-coach-memory-forget"/g,
);
expect(forgetButtons).toHaveLength(3);
});
+30 -13
View File
@@ -26,6 +26,16 @@
*
* Plain text only — every value renders exclusively through inputs and
* React text children; no markdown anywhere.
*
* The "Clear" control writes `aboutMe: ""` and nothing else. It used to send
* `allergies: ""` alongside, so one untargeted tap wiped the allergy line as
* well — while the toast it raised said "About-me note cleared", which is what
* the control looks like it does and what a person reading it expects. The
* allergy line is the entry a clinician reads first, so the control is scoped
* rather than merely gated: the field is omitted from the payload, and the PUT
* leaves an omitted field untouched by contract. Clearing allergies is done in
* the allergies field itself, which is a deliberate act on the thing being
* emptied. The confirmation on top of that guards the note.
*/
import { useState } from "react";
import Link from "next/link";
@@ -40,6 +50,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
@@ -103,7 +114,9 @@ export function AboutMeSection({
};
const dirty =
value.aboutMe !== saved.aboutMe || value.allergies !== saved.allergies;
const hasAnyContent = saved.aboutMe.length > 0 || saved.allergies.length > 0;
// The clear control only ever touches the note, so it only appears when
// there is a note to clear.
const hasNote = saved.aboutMe.length > 0;
// Quiet completeness meter: one segment per answered field. Reads
// the SAVED state (not the draft) so it only moves on persistence.
@@ -118,14 +131,16 @@ export function AboutMeSection({
// v1.32.21 (R5a) — echo the base token this edit was based on so a
// concurrent write to the same self-context (another tab, or Coach adopt)
// 409s instead of silently overwriting the newer state.
mutationFn: async (input: { aboutMe: string; allergies: string }) => {
// `allergies` is optional on purpose: the clear control omits it so the
// stored allergy line survives, per the PUT's omitted-field contract.
mutationFn: async (input: { aboutMe: string; allergies?: string }) => {
return apiPut<AboutMeData>(
"/api/coach/about-me",
withBaseToken(input, query.data?.updatedAt ?? undefined),
);
},
onSuccess: (next) => {
const cleared = !next.aboutMe && !next.allergies;
const cleared = !next.aboutMe;
toast.success(
cleared
? t("settings.ai.aboutMe.clearedToast")
@@ -251,19 +266,21 @@ export function AboutMeSection({
})}
</p>
<div className="flex items-center gap-2">
{hasAnyContent && (
<Button
type="button"
{hasNote && (
<ConfirmButton
slot="settings-about-me-clear"
label={t("settings.ai.aboutMe.clear")}
icon={<Trash2 className="size-4" aria-hidden />}
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid="settings-about-me-clear"
disabled={!isAuthenticated || save.isPending}
onClick={() => save.mutate({ aboutMe: "", allergies: "" })}
>
<Trash2 className="size-4" aria-hidden />
{t("settings.ai.aboutMe.clear")}
</Button>
title={t("settings.ai.aboutMe.clearTitle")}
body={t("settings.ai.aboutMe.clearBody")}
confirmLabel={t("settings.ai.aboutMe.clearConfirm")}
disabled={!isAuthenticated}
pending={save.isPending}
onConfirm={() => save.mutate({ aboutMe: "" })}
/>
)}
<Button
type="button"
@@ -6,6 +6,7 @@ import { ImageUp, Loader2, Trash2 } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -146,23 +147,23 @@ export function AvatarSection() {
: t("settings.avatar.upload")}
</Button>
{avatarUrl && (
<Button
<ConfirmButton
slot="settings-avatar-remove"
variant="ghost"
className="text-destructive min-h-11 sm:min-h-9"
disabled={busy}
onClick={() => {
icon={<Trash2 className="h-4 w-4" />}
label={t("settings.avatar.remove")}
title={t("settings.avatar.removeTitle")}
body={t("settings.avatar.removeBody")}
confirmLabel={t("settings.avatar.removeConfirm")}
disabled={busy && !remove.isPending}
pending={remove.isPending}
onConfirm={() => {
setMsg(null);
setMsgType(null);
remove.mutate();
}}
>
{remove.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-4 w-4" />
)}
{t("settings.avatar.remove")}
</Button>
/>
)}
</div>
</div>
@@ -10,6 +10,7 @@ import { Loader2, Sparkles, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
@@ -205,20 +206,19 @@ export function CodexProviderForm({
{t("settings.ai.oauthNotConfigured")}
</p>
) : isConnected ? (
<Button
<ConfirmButton
slot="settings-codex-disconnect"
variant="outline"
size="sm"
className="text-destructive min-h-11 shrink-0 sm:min-h-9"
onClick={handleDisconnect}
disabled={disconnecting}
>
{disconnecting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-4 w-4" />
)}
{t("settings.ai.codex.disconnectButton")}
</Button>
icon={<Trash2 className="h-4 w-4" />}
label={t("settings.ai.codex.disconnectButton")}
title={t("settings.ai.codex.disconnectTitle")}
body={t("settings.ai.codex.disconnectBody")}
confirmLabel={t("settings.ai.codex.disconnectConfirm")}
pending={disconnecting}
onConfirm={handleDisconnect}
/>
) : deviceCode ? (
<div className="border-primary bg-primary/5 space-y-3 rounded-lg border-l-4 p-4">
<p className="text-sm font-medium">
@@ -34,6 +34,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { formatDateOrRelative } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -243,35 +244,28 @@ export function CoachMemorySection({
})}
</p>
</div>
<Button
type="button"
<ConfirmButton
slot="settings-coach-memory-forget"
variant="ghost"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid="settings-coach-memory-forget"
aria-label={t("settings.ai.coachMemory.forgetAria")}
ariaLabel={t("settings.ai.coachMemory.forgetAria")}
icon={<Trash2 className="size-4" aria-hidden />}
label={t("settings.ai.coachMemory.forget")}
title={t("settings.ai.coachMemory.forgetTitle")}
body={t("settings.ai.coachMemory.forgetBody")}
confirmLabel={t("settings.ai.coachMemory.forgetConfirm")}
disabled={!isAuthenticated}
// v1.11.2 — per-id pending: only the row being
// deleted disables/spins. `forgetOne.variables` holds
// the id passed to the in-flight `mutate()`, so a
// single shared mutation no longer greys out every
// other row's forget button during one delete.
disabled={
!isAuthenticated ||
(forgetOne.isPending && forgetOne.variables === fact.id)
pending={
forgetOne.isPending && forgetOne.variables === fact.id
}
onClick={() => forgetOne.mutate(fact.id)}
>
{forgetOne.isPending &&
forgetOne.variables === fact.id ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Trash2 className="size-4" aria-hidden />
)}
{t("settings.ai.coachMemory.forget")}
</Button>
onConfirm={() => forgetOne.mutate(fact.id)}
/>
</li>
))}
</ul>
@@ -25,7 +25,7 @@ import { EmptyState } from "@/components/ui/empty-state";
import { formatDateOrRelative } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ConfirmButton } from "@/components/settings/confirm-button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { useTranslations } from "@/lib/i18n/context";
import {
useCoachReminders,
-119
View File
@@ -1,119 +0,0 @@
"use client";
/**
* A destructive settings control that asks first.
*
* The security card's "sign out everywhere" and its per-row revoke, and the
* same pair on trusted devices, fired on a single tap with no confirmation —
* while every other destructive surface in the app confirms. Signing every
* other device out is not recoverable by an undo; the other devices simply
* have to log in again, and on a shared screen a mis-tap is a support call.
*
* Wrapping the pattern rather than inlining a fourth dialog is the point: the
* next destructive control gets the confirmation by using this, instead of by
* someone remembering to add one.
*
* The copy contract matches the rest of the app after the delete-confirmation
* sweep — say what happens, and claim no more finality than is true. Callers
* pass their own body text so each control can be specific about its own
* consequence.
*/
import type { ReactNode } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { useTranslations } from "@/lib/i18n/context";
export function ConfirmButton({
label,
title,
body,
confirmLabel,
onConfirm,
pending = false,
disabled = false,
size,
variant = "outline",
className,
icon,
ariaLabel,
slot,
}: {
/** The control's own label, before the dialog opens. */
label: string;
title: string;
body: string;
/** The dialog's action label. Names the act, not "OK". */
confirmLabel: string;
onConfirm: () => void;
pending?: boolean;
disabled?: boolean;
size?: "sm" | "icon";
variant?: "outline" | "ghost";
className?: string;
/** For an icon-only control, where `label` renders as an icon. */
ariaLabel?: string;
icon?: ReactNode;
/** `data-slot` for tests and the design guards. */
slot?: string;
}) {
const { t } = useTranslations();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant={variant}
size={size}
className={className}
aria-label={ariaLabel}
data-slot={slot}
disabled={disabled || pending}
>
{pending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
icon
)}
{label}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{body}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
data-slot={slot ? `${slot}-confirm` : undefined}
disabled={pending}
aria-busy={pending || undefined}
onClick={onConfirm}
>
{pending && (
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
)}
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -28,6 +28,7 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { Switch } from "@/components/ui/switch";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
@@ -607,16 +608,19 @@ export function DashboardLayoutSection({ id }: { id: string }) {
icon={LayoutDashboard}
title={t("dashboard.customizeTitle")}
status={
<Button
<ConfirmButton
slot="settings-dashboard-layout-reset"
variant="ghost"
size="sm"
onClick={() => resetMutation.mutate()}
disabled={resetMutation.isPending}
className="min-h-11 self-end sm:min-h-9 sm:self-auto"
>
<RotateCcw className="h-3.5 w-3.5" />
{t("dashboard.layoutReset")}
</Button>
icon={<RotateCcw className="h-3.5 w-3.5" />}
label={t("dashboard.layoutReset")}
title={t("dashboard.layoutResetTitle")}
body={t("dashboard.layoutResetBody")}
confirmLabel={t("dashboard.layoutResetConfirm")}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate()}
/>
}
/>
{/*
@@ -27,7 +27,7 @@ import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuth } from "@/hooks/use-auth";
import { ConfirmButton } from "@/components/settings/confirm-button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
@@ -14,7 +14,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { ConfirmButton } from "@/components/settings/confirm-button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
+12 -7
View File
@@ -41,6 +41,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -297,15 +298,19 @@ export function SourcesSection() {
title={t("settings.sections.sources.cardTitle")}
description={t("settings.sections.sources.help")}
status={
<Button
<ConfirmButton
slot="settings-sources-reset"
variant="ghost"
size="sm"
onClick={() => resetMutation.mutate()}
disabled={resetMutation.isPending || saveMutation.isPending}
>
<RotateCcw className="h-3.5 w-3.5" />
{t("settings.sections.sources.resetDefaults")}
</Button>
icon={<RotateCcw className="h-3.5 w-3.5" />}
label={t("settings.sections.sources.resetDefaults")}
title={t("settings.sections.sources.resetDefaultsTitle")}
body={t("settings.sections.sources.resetDefaultsBody")}
confirmLabel={t("settings.sections.sources.resetDefaultsConfirm")}
disabled={saveMutation.isPending}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate()}
/>
}
/>
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { SlidersHorizontal, RotateCcw, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ConfirmButton, ConfirmDialog } from "@/components/ui/confirm-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
@@ -130,15 +131,18 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
aria-hidden="true"
/>
{data && Object.keys(data.overrides).length > 0 && (
<Button
<ConfirmButton
slot="settings-thresholds-reset-all"
variant="ghost"
size="sm"
onClick={() => resetMutation.mutate(null)}
disabled={resetMutation.isPending}
>
<RotateCcw className="h-3.5 w-3.5" />
{t("thresholds.resetAllAction")}
</Button>
icon={<RotateCcw className="h-3.5 w-3.5" />}
label={t("thresholds.resetAllAction")}
title={t("thresholds.resetAllTitle")}
body={t("thresholds.resetAllBody")}
confirmLabel={t("thresholds.resetAllConfirm")}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate(null)}
/>
)}
</div>
@@ -241,6 +245,7 @@ function MetricRow({
const hasOverride = override !== null;
const [overrideMode, setOverrideMode] = useState(hasOverride);
const [confirmSwitchOff, setConfirmSwitchOff] = useState(false);
const [minStr, setMinStr] = useState(
seedString(override?.min ?? effective?.default?.greenMin, bounds.min),
);
@@ -310,15 +315,35 @@ function MetricRow({
<Label htmlFor={`override-${metric}`} className="text-xs">
{t("thresholds.overrideToggleLabel")}
</Label>
{/* Flipping this off DELETES the stored override — the same
irreversible write the Reset control performs, reached by a
control that reads as a display toggle. It gets the same
confirmation, and a dismissal leaves the switch on. */}
<Switch
id={`override-${metric}`}
checked={overrideMode}
onCheckedChange={(next) => {
if (!next && hasOverride) {
setConfirmSwitchOff(true);
return;
}
setOverrideMode(next);
if (!next && hasOverride) onReset();
}}
disabled={busy}
/>
<ConfirmDialog
slot={`settings-thresholds-override-off-${metric}`}
open={confirmSwitchOff}
onOpenChange={setConfirmSwitchOff}
title={t("thresholds.resetTitle")}
body={t("thresholds.resetBody")}
confirmLabel={t("thresholds.resetConfirm")}
pending={busy}
onConfirm={() => {
setOverrideMode(false);
onReset();
}}
/>
{hasOverride && (
<Badge variant="outline" className="text-xs">
{t("thresholds.sourceOverride")}
@@ -381,15 +406,18 @@ function MetricRow({
{t("common.save")}
</Button>
{hasOverride && (
<Button
<ConfirmButton
slot={`settings-thresholds-reset-${metric}`}
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={onReset}
disabled={busy}
>
{t("thresholds.resetAction")}
</Button>
label={t("thresholds.resetAction")}
title={t("thresholds.resetTitle")}
body={t("thresholds.resetBody")}
confirmLabel={t("thresholds.resetConfirm")}
pending={busy}
onConfirm={onReset}
/>
)}
</div>
</div>
@@ -13,7 +13,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, ShieldCheck } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { ConfirmButton } from "@/components/settings/confirm-button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";