mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
About forty refusal messages still printed the error string the server sent: English in every locale, and for a dropped connection the browser's own TypeError text. Mood tags, targets and thresholds, family history, medication side effects, phases, API tokens and dose imports, the Coach model picker, the AI provider, fallback chain, timeout and Codex cards, the notification channel cards, the avatar, passkeys, security keys, the authenticator card, the re-proof dialog and the admin pages now resolve the refusal's code through localizedApiError and fall back to the surface's own sentence. Two refusals carried something a person needs and had no code: a private AI base URL (the AI_PRIVATE_ORIGINS hint) and removing the last passkey. Both now send a code with a sentence of their own. A throttled re-proof says so in the reader's language. Raw fetch sites throw the same ApiError as the verb helpers through the exported throwApiError, which replaces the admin's getApiErrorMessage; the authenticator card's copy of describeStepUp is gone, and describeStepUp no longer passes a non-step-up refusal's prose through (its test pinned that). The admin password reset keeps the server's verdict on a 422: the server words the strength and breach verdict in the account's language.
190 lines
6.7 KiB
TypeScript
190 lines
6.7 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 { SettingsCardActions } from "@/components/settings/_card-actions";
|
|
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";
|
|
import { localizedApiError } from "@/lib/api/localized-error";
|
|
import { randomId } from "@/lib/random-id";
|
|
import {
|
|
recentProofErrorMessage,
|
|
useRecentProof,
|
|
} from "@/components/settings/security-section/use-recent-proof";
|
|
|
|
/**
|
|
* 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;
|
|
|
|
// Wiping every account asks for a fresh proof unless this session signed
|
|
// in or re-proved within five minutes.
|
|
const recentProof = useRecentProof();
|
|
|
|
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-${randomId()}`;
|
|
// The server returns one count per table that actually held rows. It
|
|
// used to return nine fixed names, which is how nobody noticed the
|
|
// wipe had stopped covering the schema: the result line read the same
|
|
// whether it had cleared nine tables or ninety.
|
|
return recentProof.run(() =>
|
|
apiDelete<{
|
|
cleared: boolean;
|
|
deletedRows: number;
|
|
models: Record<string, number>;
|
|
}>(
|
|
"/api/admin/data",
|
|
{ confirm: WIPE_CONFIRM_TOKEN },
|
|
{ headers: { "Idempotency-Key": idempotencyKey } },
|
|
),
|
|
);
|
|
},
|
|
onSuccess: (data) => {
|
|
queryClient.invalidateQueries();
|
|
setWipeMsg(
|
|
t("admin.deletedResult", {
|
|
rows: data.deletedRows,
|
|
tables: Object.keys(data.models).length,
|
|
}),
|
|
);
|
|
},
|
|
onError: (err) => {
|
|
setWipeMsg(
|
|
recentProofErrorMessage(err, localizedApiError(err, t, "common.error")),
|
|
);
|
|
},
|
|
});
|
|
|
|
return (
|
|
// v1.18.10 — 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>
|
|
{recentProof.dialog}
|
|
<SettingsCardHeader
|
|
icon={AlertTriangle}
|
|
title={t("admin.deleteAllData")}
|
|
description={t("admin.deleteAllDescription")}
|
|
/>
|
|
<p className="text-sm">{t("admin.deleteAllDetail")}</p>
|
|
|
|
{wipeMsg && (
|
|
<p
|
|
className={`mt-3 text-sm ${
|
|
wipeAllData.isError ? "text-destructive" : "text-success"
|
|
}`}
|
|
>
|
|
{wipeMsg}
|
|
</p>
|
|
)}
|
|
<SettingsCardActions>
|
|
<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>
|
|
</SettingsCardActions>
|
|
</SettingsCard>
|
|
);
|
|
}
|