mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
fix(admin): harden the Danger-Zone wipe to match the Backups Restore gate
The global data wipe auto-supplied confirm:'DELETE ALL' from a single-click dialog, leaving the server's typed-token defence dead and making the most destructive action a weaker gate than the less-destructive restore. Mirror RestoreRowDialog: require typing 'DELETE ALL' into an Input that arms the destructive button, reset on close. Also attach an Idempotency-Key header to the wipe so a double-submit cannot re-run the transaction, add the documented post-requireAdmin() 'admin-data-delete:<userId>' rate-limit bucket alongside the anonymous IP throttle, and switch BackupsSection off the non-reactive formatDateTime onto the locale-reactive useFormatters() hook its siblings use.
This commit is contained in:
1 parent
e23f57448a
commit
88c4a8428f
9 files changed
+81
-7
No files matched your search
@@ -5177,6 +5177,7 @@
|
||||
"deleteAllConfirm": "Alle Daten wirklich löschen?",
|
||||
"deleteAllConfirmDescription": "Dieser Vorgang löscht alle Messwerte, Medikamente, Einnahmen, API-Tokens, Withings-Verbindungen, offene Authentifizierungs-Challenges, Benachrichtigungskanäle (Telegram, ntfy, Web Push), Push-Abonnements und in der Warteschlange befindliche Telegram-Nachrichtenlöschungen aller Benutzer unwiderruflich. Benutzerkonten, das Audit-Log und eingereichtes Feedback bleiben erhalten, damit der Löschvorgang nachvollziehbar bleibt und Bug-Reports weiter triagierbar sind.",
|
||||
"deleteButton": "Alle Daten löschen",
|
||||
"deleteAllPromptLabel": "Tippe {token} zum Fortfahren",
|
||||
"finalDelete": "Endgültig löschen",
|
||||
"deletedResult": "Gelöscht: {measurements} Messwerte, {medications} Medikamente, {intakeEvents} Einnahmen, {apiTokens} API-Tokens, {withingsConnections} Withings-Verbindungen, {authChallenges} Auth-Challenges, {notificationChannels} Benachrichtigungskanäle, {pushSubscriptions} Push-Abonnements, {telegramScheduledDeletions} eingeplante Telegram-Löschungen. Feedback bleibt erhalten.",
|
||||
"authRegister": "Registrierung",
|
||||
|
||||
@@ -5177,6 +5177,7 @@
|
||||
"deleteAllConfirm": "Really delete all data?",
|
||||
"deleteAllConfirmDescription": "This will permanently delete all measurements, medications, intake events, API tokens, Withings connections, open authentication challenges, notification channels (Telegram, ntfy, Web Push), push subscriptions, and queued Telegram message deletions for all users. User accounts, the audit log, and submitted feedback are preserved so the wipe itself remains traceable and bug reports stay triageable.",
|
||||
"deleteButton": "Delete all data",
|
||||
"deleteAllPromptLabel": "Type {token} to continue",
|
||||
"finalDelete": "Delete permanently",
|
||||
"deletedResult": "Deleted: {measurements} measurements, {medications} medications, {intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
|
||||
"authRegister": "Registration",
|
||||
|
||||
@@ -5177,6 +5177,7 @@
|
||||
"deleteAllConfirm": "¿Eliminar realmente todos los datos?",
|
||||
"deleteAllConfirmDescription": "Esto eliminará permanentemente todas las mediciones, medicamentos, eventos de toma, tokens de API, conexiones de Withings, retos de autenticación abiertos, canales de notificación (Telegram, ntfy, Web Push), suscripciones push y eliminaciones de mensajes de Telegram en cola de todos los usuarios. Las cuentas de usuario, el registro de auditoría y los comentarios enviados se conservan para que el borrado siga siendo rastreable y los informes de errores se puedan seguir clasificando.",
|
||||
"deleteButton": "Eliminar todos los datos",
|
||||
"deleteAllPromptLabel": "Escribe {token} para continuar",
|
||||
"finalDelete": "Eliminar permanentemente",
|
||||
"deletedResult": "Deleted: {measurements} measurements, {medications} Medicamentos,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
|
||||
"authRegister": "Registro",
|
||||
|
||||
@@ -5177,6 +5177,7 @@
|
||||
"deleteAllConfirm": "Vraiment supprimer toutes les données ?",
|
||||
"deleteAllConfirmDescription": "Cette action supprimera définitivement toutes les mesures, médicaments, événements de prise, jetons d'API, connexions Withings, défis d'authentification ouverts, canaux de notification (Telegram, ntfy, Web Push), abonnements push et suppressions de messages Telegram en file d'attente pour tous les utilisateurs. Les comptes utilisateurs, le journal d'audit et les retours soumis sont conservés afin que l'effacement reste traçable et que les rapports de bug restent triables.",
|
||||
"deleteButton": "Supprimer toutes les données",
|
||||
"deleteAllPromptLabel": "Tapez {token} pour continuer",
|
||||
"finalDelete": "Supprimer définitivement",
|
||||
"deletedResult": "Deleted: {measurements} measurements, {medications} Médicaments,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
|
||||
"authRegister": "Inscription",
|
||||
|
||||
@@ -5177,6 +5177,7 @@
|
||||
"deleteAllConfirm": "Eliminare davvero tutti i dati?",
|
||||
"deleteAllConfirmDescription": "Questa operazione eliminerà definitivamente tutte le misurazioni, i farmaci, gli eventi di assunzione, i token API, le connessioni Withings, le challenge di autenticazione aperte, i canali di notifica (Telegram, ntfy, Web Push), le sottoscrizioni push e le eliminazioni di messaggi Telegram in coda di tutti gli utenti. Gli account utente, il log di audit e i feedback inviati vengono conservati affinché la cancellazione resti tracciabile e le segnalazioni di bug restino gestibili.",
|
||||
"deleteButton": "Elimina tutti i dati",
|
||||
"deleteAllPromptLabel": "Digita {token} per continuare",
|
||||
"finalDelete": "Elimina definitivamente",
|
||||
"deletedResult": "Deleted: {measurements} measurements, {medications} Farmaci,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
|
||||
"authRegister": "Registrazione",
|
||||
|
||||
@@ -5177,6 +5177,7 @@
|
||||
"deleteAllConfirm": "Na pewno usunąć wszystkie dane?",
|
||||
"deleteAllConfirmDescription": "Spowoduje to trwałe usunięcie wszystkich pomiarów, leków, zdarzeń przyjęcia, tokenów API, połączeń Withings, otwartych wyzwań uwierzytelniania, kanałów powiadomień (Telegram, ntfy, Web Push), subskrypcji push oraz zakolejkowanych usunięć wiadomości Telegram wszystkich użytkowników. Konta użytkowników, dziennik audytu i przesłane opinie zostają zachowane, aby samo usunięcie pozostało możliwe do prześledzenia, a zgłoszenia błędów — do obsłużenia.",
|
||||
"deleteButton": "Usuń wszystkie dane",
|
||||
"deleteAllPromptLabel": "Wpisz {token}, aby kontynuować",
|
||||
"finalDelete": "Usuń trwale",
|
||||
"deletedResult": "Deleted: {measurements} measurements, {medications} Leki,{intakeEvents} intakes, {apiTokens} API tokens, {withingsConnections} Withings connections, {authChallenges} auth challenges, {notificationChannels} notification channels, {pushSubscriptions} push subscriptions, {telegramScheduledDeletions} queued Telegram deletions. Feedback rows preserved.",
|
||||
"authRegister": "Rejestracja",
|
||||
|
||||
@@ -26,6 +26,22 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.data.delete" } });
|
||||
|
||||
// v1.18.1 — the documented convention buckets authenticated admin
|
||||
// mutations on `userId`, not the (pre-auth, spoofable) client IP. The
|
||||
// IP bucket above is the anonymous first-line throttle; this is the
|
||||
// canonical per-admin bucket the rate-limit contract specifies.
|
||||
const userRl = await checkRateLimit(
|
||||
`admin-data-delete:${user.id}`,
|
||||
5,
|
||||
60 * 1000,
|
||||
);
|
||||
if (!userRl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ data: null, error: "Rate limit exceeded" },
|
||||
{ status: 429, headers: rateLimitHeaders(userRl) },
|
||||
);
|
||||
}
|
||||
|
||||
let confirm = "";
|
||||
try {
|
||||
const raw = await request.text();
|
||||
|
||||
@@ -39,7 +39,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -70,6 +69,7 @@ function RestoreRowDialog({
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [typed, setTyped] = useState("");
|
||||
const matched = typed.trim() === "RESTORE";
|
||||
@@ -108,7 +108,7 @@ function RestoreRowDialog({
|
||||
<AlertDialogDescription>
|
||||
{t("admin.section.backups.restoreDescription", {
|
||||
username: row.username,
|
||||
when: formatDateTime(row.createdAt),
|
||||
when: fmt.dateTime(row.createdAt),
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
@@ -501,7 +501,7 @@ export function BackupsSection() {
|
||||
{formatBytes(row.sizeBytes, fmt)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{formatDateTime(row.createdAt)}
|
||||
{fmt.dateTime(row.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right whitespace-nowrap">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
|
||||
@@ -15,17 +15,36 @@ import {
|
||||
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 { 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;
|
||||
@@ -36,7 +55,11 @@ export function DangerZoneSection() {
|
||||
notificationChannels: number;
|
||||
pushSubscriptions: number;
|
||||
telegramScheduledDeletions: number;
|
||||
}>("/api/admin/data", { confirm: "DELETE ALL" });
|
||||
}>(
|
||||
"/api/admin/data",
|
||||
{ confirm: WIPE_CONFIRM_TOKEN },
|
||||
{ headers: { "Idempotency-Key": idempotencyKey } },
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries();
|
||||
@@ -72,7 +95,13 @@ export function DangerZoneSection() {
|
||||
title={t("admin.deleteAllData")}
|
||||
description={t("admin.deleteAllDescription")}
|
||||
status={
|
||||
<AlertDialog>
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) setTyped("");
|
||||
}}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -97,13 +126,36 @@ export function DangerZoneSection() {
|
||||
{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={wipeAllData.isPending}
|
||||
disabled={!matched || wipeAllData.isPending}
|
||||
aria-busy={wipeAllData.isPending || undefined}
|
||||
onClick={() => wipeAllData.mutate()}
|
||||
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" />
|
||||
|
||||
Reference in new issue
Block a user