"use client"; /** * `` — admin view of the weekly `DataBackup` snapshots. * * Lists every snapshot row (one per user × backup-type) with size and age, * plus a "Run backup now" CTA that enqueues the pg-boss `data-backup` job. * The encrypted payload itself is never shipped to the browser; only the * size in bytes is surfaced. * * Folds in v1.4.6 deferred T2.6. */ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { BookOpen, Database, Download, History, Loader2, PlayCircle, Upload, } from "lucide-react"; import { useRef, useState, type ChangeEvent } from "react"; import { toast } from "sonner"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { formatDateTime } from "@/lib/format"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import type { BackupRow, BackupsList } from "@/app/api/admin/backups/route"; import { getApiErrorMessage } from "./_shared"; /** * Typed-confirmation dialog for restore. The destructive Restore button * is gated behind: * 1. Opening the dialog (one click). * 2. Reading the warning copy (Title + Description spell out exactly * what's about to happen + which user it affects). * 3. Typing the literal string `RESTORE` into the prompt input — * anything else keeps the confirm button disabled. * * Three independent steps before the request fires == "triple confirm". * Mirrors the wipe dialog's pattern but adds the typed gate because the * blast radius is bigger (re-creates rows, not just deletes). */ function RestoreRowDialog({ row, pending, onConfirm, }: { row: BackupRow; pending: boolean; onConfirm: () => void; }) { const { t } = useTranslations(); const [open, setOpen] = useState(false); const [typed, setTyped] = useState(""); const matched = typed.trim() === "RESTORE"; return ( { setOpen(next); if (!next) setTyped(""); }} > {t("admin.section.backups.restoreTitle")} {t("admin.section.backups.restoreDescription", { username: row.username, when: formatDateTime(row.createdAt), })}
setTyped(e.target.value)} autoComplete="off" spellCheck={false} placeholder="RESTORE" />
{t("common.cancel")} { if (!matched) return; setOpen(false); setTyped(""); onConfirm(); }} > {pending ? t("admin.section.backups.restoreInProgress") : t("admin.section.backups.restoreConfirm")}
); } function formatBytes(bytes: number, fmt: ReturnType) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) { return `${fmt.integer(Math.round(bytes / 1024))} KB`; } return `${fmt.number(bytes / 1024 / 1024, 2)} MB`; } export function BackupsSection() { const { t } = useTranslations(); const fmt = useFormatters(); const queryClient = useQueryClient(); const { data, isLoading, isError } = useQuery({ queryKey: ["admin", "backups"], queryFn: async () => { const res = await fetch("/api/admin/backups"); if (!res.ok) throw new Error("Failed"); return (await res.json()).data as BackupsList; }, }); const runBackup = useMutation({ mutationFn: async () => { const res = await fetch("/api/admin/backups/run", { method: "POST" }); if (!res.ok) { throw new Error(await getApiErrorMessage(res)); } return (await res.json()).data as { jobId: string | null }; }, onSuccess: () => { toast.success(t("admin.section.backups.runEnqueued")); // The job runs async; refetch after a short delay so the new row // shows up without leaving the user wondering whether the click // did anything. setTimeout(() => { queryClient.invalidateQueries({ queryKey: ["admin", "backups"] }); }, 2000); }, onError: (err) => { toast.error( err instanceof Error && err.message ? err.message : t("admin.section.backups.runFailed"), ); }, }); const rows: BackupRow[] = data?.rows ?? []; // Per-row download in-flight state — keyed by backup id so two // parallel clicks on different rows don't share a single spinner. const [downloadingId, setDownloadingId] = useState(null); // Upload state — single-file flow, drives the file input + button. const fileInputRef = useRef(null); const upload = useMutation({ mutationFn: async (file: File) => { const fd = new FormData(); fd.append("file", file); const res = await fetch("/api/admin/backups/upload", { method: "POST", body: fd, }); if (!res.ok) { throw new Error(await getApiErrorMessage(res)); } return (await res.json()).data as { id: string; valid: true; summary: { measurements: number; medications: number; intakeEvents: number; moodEntries: number; }; }; }, onSuccess: (data) => { const total = data.summary.measurements + data.summary.medications + data.summary.intakeEvents + data.summary.moodEntries; toast.success( t("admin.section.backups.uploadSuccess", { count: String(total) }), ); queryClient.invalidateQueries({ queryKey: ["admin", "backups"] }); }, onError: (err) => { toast.error( err instanceof Error && err.message ? err.message : t("admin.section.backups.uploadFailed"), ); }, onSettled: () => { // Reset the file input so the same file can be re-selected after a // rejected upload (browsers keep the previous selection otherwise). if (fileInputRef.current) fileInputRef.current.value = ""; }, }); function handleFileChange(e: ChangeEvent) { const file = e.target.files?.[0]; if (file) upload.mutate(file); } // Restore: typed-confirmation dialog. The mutation is keyed by row id // and used inline by `` below — keeping the // mutation here lets the parent invalidate the list query on success. const restore = useMutation({ mutationFn: async (row: BackupRow) => { // Idempotency-Key prevents a double-click from re-running the // destructive transaction. Include the row id so two different // backups can both be restored independently in the same minute. const idempotencyKey = `restore-${row.id}-${crypto.randomUUID()}`; const res = await fetch(`/api/admin/backups/${row.id}/restore`, { method: "POST", headers: { "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }, body: JSON.stringify({ confirm: "RESTORE" }), }); if (!res.ok) { throw new Error(await getApiErrorMessage(res)); } return (await res.json()).data as { restored: true }; }, onSuccess: () => { toast.success(t("admin.section.backups.restoreSuccess")); queryClient.invalidateQueries({ queryKey: ["admin", "backups"] }); // Restore touches every personal-data table; nuke the broader // cache so dashboards / lists rebuild against the new state. queryClient.invalidateQueries(); }, onError: (err) => { toast.error( err instanceof Error && err.message ? err.message : t("admin.section.backups.restoreFailed"), ); }, }); async function handleDownload(row: BackupRow) { setDownloadingId(row.id); try { const res = await fetch(`/api/admin/backups/${row.id}/download`); if (!res.ok) { throw new Error(await getApiErrorMessage(res)); } // Use the server-provided Content-Disposition filename if present, // otherwise fall back to a deterministic client-side name. The // server filename is more accurate (uses the actual createdAt). const cd = res.headers.get("content-disposition") ?? ""; const match = cd.match(/filename="?([^";]+)"?/i); const fallback = `healthlog-backup-${row.userId}-${row.createdAt.slice(0, 10)}.json`; const filename = match?.[1] ?? fallback; const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); toast.success(t("admin.section.backups.downloadStarted")); } catch (err) { toast.error( err instanceof Error && err.message ? err.message : t("admin.section.backups.downloadFailed"), ); } finally { setDownloadingId(null); } } return (
{t("admin.section.backups.title")}
{data && ( {rows.length} )}

{t("admin.section.backups.description")}{" "} {/* External docs link — Phase E will publish the matching page on the docs site. `noopener noreferrer` because this leaves the admin shell. */}

{/* Upload card — separate from the table so admins can ingest a backup file independently of any existing rows. The visible button proxies a hidden file input so the layout stays clean while keyboard / screen-reader users keep a labelled control. */}
{t("admin.section.backups.uploadTitle")}

{t("admin.section.backups.uploadDescription")}{" "} {t("admin.section.backups.uploadHelp")}

{isLoading ? (
{t("admin.section.backups.loading")}
) : isError ? (
{t("admin.section.backups.loadError")}
) : rows.length === 0 ? (
{t("admin.section.backups.empty")}
) : (
{rows.map((row, i) => ( ))}
{t("admin.section.backups.colUser")} {t("admin.section.backups.colType")} {t("admin.section.backups.colSize")} {t("admin.section.backups.colCreatedAt")} {t("admin.section.backups.colActions")}
{row.username} {row.type} {formatBytes(row.sizeBytes, fmt)} {formatDateTime(row.createdAt)}
restore.mutate(row)} />

{t("admin.section.backups.retentionLabel", { days: data!.retentionDays, })}

)}
); }