Files
HealthLog/src/components/settings/confirm-button.tsx
T
Marc-André Bombeck 670e7097a2 fix(settings): destructive session and device controls ask before firing
Signing every other device out, revoking a single session, revoking a
trusted device and revoking them all each fired on a single tap. None is
undoable — the other devices simply have to sign in again, and a revoked
trusted device asks for its second factor next time — while every other
destructive surface in the app confirms first. On a shared screen a
mis-tap is a support call.

A sweep of the rest of the tree found two more hard deletes with neither
a confirmation nor an undo: a Coach reminder and a travel entry. Both now
confirm. Deleting a document deliberately does not: it soft-deletes and
offers an undo, which covers the mis-tap.

The four controls share one ConfirmButton rather than four copies of the
dialog, so the next destructive control gets the confirmation by using
it instead of by someone remembering. Copy is per control and states the
actual consequence — no claim of more finality than is true.
2026-07-19 12:46:04 +02:00

120 lines
3.4 KiB
TypeScript

"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>
);
}