mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
The two consoles used the same primitives and still read as two systems, because everything without a primitive had drifted: the header-to-body step existed in three competing techniques at once (`mt-4` on the first body child, `space-y-4` on the card, `mb-4` on the header), the body started either at the card edge or 28 px in, and both shells hand-rolled the page title instead of calling `PageHeader`. Move the decisions into the primitives so a call site cannot re-open them: - `SettingsCard` is gap-based like `Card` (`flex flex-col gap-4`). The header-to-body step is the container's, not a margin somebody remembers. A dense card still overrides the gap on the card itself. - The body starts at the card edge. `pl-7` is gone — off the spacing scale, and it mis-aligned action rows against their own forms wherever two neighbouring cards disagreed about it. - The card header's description slot loses its `space-y-1`. That was the licence call sites used to stack explainer paragraphs into a muted `text-xs` slot; the slot holds one sentence. - New `SettingsCardActions`: one action row, trailing edge, last thing in the card. `align="start"` spells the one exception, a card whose only actions are links out. - New `StatTile` replaces four hand-rolled label/value boxes in Admin. - `SectionHeading` moves from `insights/` to `ui/`. It is the heading above a group of cards, which is what Settings needs; Settings had been using a card header with no card, which reads as a card that failed to paint. Both shells now render their heading through `PageHeader`, which gained `headingAs="div"` for the second copy each shell paints (mobile above the nav strip, desktop inside the grid) so only one is a real `<h1>`. The page explainer is muted everywhere: the primitive said foreground, the standards and every hand-rolled copy said muted, so pages disagreed with each other. Medications, Vorsorge, the labs / custom-metric / illness detail pages and the clinician view drop their hand-rolled title blocks too, including the mid-word truncation on the medications subtitle. Tests pin the parts that can be checked exactly: the card's gap, the description slot's missing rhythm, the action row's two alignments, the `<h1>`-free shells, and a sweep asserting `pl-7` stays gone.
183 lines
6.5 KiB
TypeScript
183 lines
6.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { AlertTriangle, Loader2, Trash2 } 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 { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { SettingsCard } from "@/components/settings/settings-card";
|
|
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { apiDelete } from "@/lib/api/api-fetch";
|
|
|
|
/**
|
|
* The literal an admin must type to arm the wipe. Mirrors the Backups
|
|
* `RESTORE` gate — and matches the server's typed-token defence at
|
|
* `src/app/api/admin/data/route.ts` (which 422s any other `confirm`).
|
|
*/
|
|
const WIPE_CONFIRM_TOKEN = "DELETE ALL";
|
|
|
|
export function DangerZoneSection() {
|
|
const { t } = useTranslations();
|
|
const queryClient = useQueryClient();
|
|
const [wipeMsg, setWipeMsg] = useState<string | null>(null);
|
|
// Typed-confirmation gate. The destructive button stays disabled until
|
|
// the admin types `DELETE ALL` verbatim, matching the Backups Restore
|
|
// dialog (which forces typing `RESTORE`) so the FAR more destructive
|
|
// global wipe is not the weaker gate. Reset whenever the dialog closes.
|
|
const [open, setOpen] = useState(false);
|
|
const [typed, setTyped] = useState("");
|
|
const matched = typed.trim() === WIPE_CONFIRM_TOKEN;
|
|
|
|
const wipeAllData = useMutation({
|
|
mutationFn: async () => {
|
|
// Idempotency-Key prevents a double-submit from re-running the
|
|
// destructive transaction, matching the Backups restore path.
|
|
const idempotencyKey = `admin-data-wipe-${crypto.randomUUID()}`;
|
|
return apiDelete<{
|
|
measurements: number;
|
|
intakeEvents: number;
|
|
medications: number;
|
|
apiTokens: number;
|
|
withingsConnections: number;
|
|
authChallenges: number;
|
|
notificationChannels: number;
|
|
pushSubscriptions: number;
|
|
telegramScheduledDeletions: number;
|
|
}>(
|
|
"/api/admin/data",
|
|
{ confirm: WIPE_CONFIRM_TOKEN },
|
|
{ headers: { "Idempotency-Key": idempotencyKey } },
|
|
);
|
|
},
|
|
onSuccess: (data) => {
|
|
queryClient.invalidateQueries();
|
|
setWipeMsg(
|
|
t("admin.deletedResult", {
|
|
measurements: data.measurements,
|
|
medications: data.medications,
|
|
intakeEvents: data.intakeEvents,
|
|
apiTokens: data.apiTokens,
|
|
withingsConnections: data.withingsConnections,
|
|
authChallenges: data.authChallenges,
|
|
notificationChannels: data.notificationChannels,
|
|
pushSubscriptions: data.pushSubscriptions,
|
|
telegramScheduledDeletions: data.telegramScheduledDeletions,
|
|
}),
|
|
);
|
|
},
|
|
onError: (err: Error) => {
|
|
setWipeMsg(err.message);
|
|
},
|
|
});
|
|
|
|
return (
|
|
// v1.18.10 (W7) — the card SURFACE is neutral like every other admin /
|
|
// settings section (`SettingsCard`: bg-card + border-border): a red card
|
|
// edge reads as alarming, not premium. The danger signal lives ONLY in
|
|
// the destructive delete button + its typed-confirmation dialog, which
|
|
// are alarm enough. The header keeps the `AlertTriangle` icon so the
|
|
// section still flags its intent.
|
|
<SettingsCard>
|
|
<SettingsCardHeader
|
|
icon={AlertTriangle}
|
|
title={t("admin.deleteAllData")}
|
|
description={t("admin.deleteAllDescription")}
|
|
status={
|
|
<AlertDialog
|
|
open={open}
|
|
onOpenChange={(next) => {
|
|
setOpen(next);
|
|
if (!next) setTyped("");
|
|
}}
|
|
>
|
|
<AlertDialogTrigger asChild>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
disabled={wipeAllData.isPending}
|
|
className="min-h-11"
|
|
>
|
|
{wipeAllData.isPending ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
) : (
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
)}
|
|
{t("admin.deleteButton")}
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
{t("admin.deleteAllConfirm")}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t("admin.deleteAllConfirmDescription")}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="wipe-confirm-prompt">
|
|
{t("admin.deleteAllPromptLabel", {
|
|
token: WIPE_CONFIRM_TOKEN,
|
|
})}
|
|
</Label>
|
|
<Input
|
|
id="wipe-confirm-prompt"
|
|
value={typed}
|
|
onChange={(e) => setTyped(e.target.value)}
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
placeholder={WIPE_CONFIRM_TOKEN}
|
|
/>
|
|
</div>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
variant="destructive"
|
|
disabled={!matched || wipeAllData.isPending}
|
|
aria-busy={wipeAllData.isPending || undefined}
|
|
onClick={(e) => {
|
|
if (!matched) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
setOpen(false);
|
|
setTyped("");
|
|
wipeAllData.mutate();
|
|
}}
|
|
>
|
|
{wipeAllData.isPending && (
|
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
)}
|
|
{t("admin.finalDelete")}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
}
|
|
/>
|
|
{wipeMsg && (
|
|
<p
|
|
className={`mt-3 text-sm ${
|
|
wipeAllData.isError ? "text-destructive" : "text-success"
|
|
}`}
|
|
>
|
|
{wipeMsg}
|
|
</p>
|
|
)}
|
|
</SettingsCard>
|
|
);
|
|
}
|