Add Backup & Restore page (zip download + drag-drop upload)

This commit is contained in:
Bonna Moon
2026-06-15 21:47:49 -05:00
parent 9ec4457552
commit c12e607be7
6 changed files with 277 additions and 1 deletions

View File

@@ -0,0 +1,115 @@
"use client";
import { useState, useRef } from "react";
import { Download, Upload, AlertTriangle, CheckCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function BackupPage() {
const [restoreStatus, setRestoreStatus] = useState<
{ type: "success"; exportedAt: string } | { type: "error"; message: string } | null
>(null);
const [restoring, setRestoring] = useState(false);
const [dragOver, setDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
async function handleFile(file: File) {
if (!file.name.endsWith(".zip")) {
setRestoreStatus({ type: "error", message: "Please upload a .zip backup file." });
return;
}
if (!confirm("This will REPLACE all current data with the backup. Are you sure?")) return;
setRestoring(true);
setRestoreStatus(null);
try {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/admin/restore", { method: "POST", body: form });
const json = await res.json();
if (!res.ok) throw new Error(json.error ?? "Restore failed");
setRestoreStatus({ type: "success", exportedAt: json.exportedAt });
} catch (err) {
setRestoreStatus({ type: "error", message: String(err) });
} finally {
setRestoring(false);
}
}
return (
<div className="max-w-xl space-y-8">
<div>
<h1 className="text-2xl font-bold">Backup &amp; Restore</h1>
<p className="mt-1 text-sm text-muted-foreground">
Download a full backup of all Moon Base data, or restore from a previous backup.
</p>
</div>
{/* Backup */}
<section className="rounded-lg border p-6 space-y-3">
<h2 className="font-semibold text-lg">Download Backup</h2>
<p className="text-sm text-muted-foreground">
Downloads all plants, logs, tasks, and settings as a <code>.zip</code> file. Keep it somewhere safe.
</p>
<a href="/api/admin/backup" download>
<Button className="gap-2">
<Download className="h-4 w-4" />
Download backup
</Button>
</a>
</section>
{/* Restore */}
<section className="rounded-lg border p-6 space-y-4">
<h2 className="font-semibold text-lg">Restore from Backup</h2>
<p className="text-sm text-muted-foreground">
Upload a <code>.zip</code> backup file to restore. <strong>This replaces everything</strong> all current data will be overwritten.
</p>
<div
className={`flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-10 transition-colors cursor-pointer ${
dragOver ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"
}`}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files[0];
if (f) handleFile(f);
}}
>
<Upload className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{restoring ? "Restoring…" : "Drop a backup .zip here, or click to choose"}
</p>
<input
ref={inputRef}
type="file"
accept=".zip"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
/>
</div>
{restoreStatus?.type === "success" && (
<div className="flex items-start gap-2 rounded-md bg-green-50 border border-green-200 p-3 text-sm text-green-800">
<CheckCircle className="h-4 w-4 mt-0.5 shrink-0" />
<div>
Restore complete. Data from backup exported on{" "}
<strong>{new Date(restoreStatus.exportedAt).toLocaleString()}</strong> is now live.
Reload the page to see it.
</div>
</div>
)}
{restoreStatus?.type === "error" && (
<div className="flex items-start gap-2 rounded-md bg-red-50 border border-red-200 p-3 text-sm text-red-800">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<div>{restoreStatus.message}</div>
</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { zipSync, strToU8 } from "fflate";
import { db } from "@/lib/db";
import { requireAuth, isAdmin } from "@/lib/auth";
export async function GET() {
try {
const session = await requireAuth();
if (!isAdmin(session.user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const [users, plantGroups, plants, plantLogs, tasks, taskCompletions, auditEvents] =
await Promise.all([
db.user.findMany(),
db.plantGroup.findMany(),
db.plant.findMany(),
db.plantLog.findMany(),
db.task.findMany(),
db.taskCompletion.findMany(),
db.auditEvent.findMany({ orderBy: { createdAt: "asc" } }),
]);
const manifest = {
version: 1,
exportedAt: new Date().toISOString(),
tables: ["users", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "auditEvents"],
};
const zip = zipSync({
"manifest.json": strToU8(JSON.stringify(manifest, null, 2)),
"users.json": strToU8(JSON.stringify(users, null, 2)),
"plantGroups.json": strToU8(JSON.stringify(plantGroups, null, 2)),
"plants.json": strToU8(JSON.stringify(plants, null, 2)),
"plantLogs.json": strToU8(JSON.stringify(plantLogs, null, 2)),
"tasks.json": strToU8(JSON.stringify(tasks, null, 2)),
"taskCompletions.json": strToU8(JSON.stringify(taskCompletions, null, 2)),
"auditEvents.json": strToU8(JSON.stringify(auditEvents, null, 2)),
});
const date = new Date().toISOString().slice(0, 10);
return new NextResponse(zip, {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="moonbase-backup-${date}.zip"`,
},
});
} catch (err) {
if (err instanceof Error && err.message === "Unauthorized") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
console.error(err);
return NextResponse.json({ error: "Backup failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
import { unzipSync, strFromU8 } from "fflate";
import { db } from "@/lib/db";
import { requireAuth, isAdmin } from "@/lib/auth";
export async function POST(req: Request) {
try {
const session = await requireAuth();
if (!isAdmin(session.user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const form = await req.formData();
const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
const buf = await file.arrayBuffer();
const files = unzipSync(new Uint8Array(buf));
const parse = (name: string) => {
if (!files[name]) throw new Error(`Missing ${name} in zip`);
return JSON.parse(strFromU8(files[name]));
};
const manifest = parse("manifest.json");
if (manifest.version !== 1) {
return NextResponse.json({ error: "Unsupported backup version" }, { status: 400 });
}
const users = parse("users.json");
const plantGroups = parse("plantGroups.json");
const plants = parse("plants.json");
const plantLogs = parse("plantLogs.json");
const tasks = parse("tasks.json");
const taskCompletions = parse("taskCompletions.json");
const auditEvents = parse("auditEvents.json");
// Restore inside a transaction — wipe then reload in FK order
await db.$transaction(async (tx) => {
await tx.taskCompletion.deleteMany();
await tx.plantLog.deleteMany();
await tx.task.deleteMany();
await tx.plant.deleteMany();
await tx.plantGroup.deleteMany();
await tx.auditEvent.deleteMany();
await tx.user.deleteMany();
if (users.length) await tx.user.createMany({ data: users });
if (plantGroups.length) await tx.plantGroup.createMany({ data: plantGroups });
if (plants.length) await tx.plant.createMany({ data: plants });
if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs });
if (tasks.length) await tx.task.createMany({ data: tasks });
if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions });
if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents });
});
await db.auditEvent.create({
data: {
action: "admin.restore",
actorId: session.user.id,
payload: { exportedAt: manifest.exportedAt, restoredAt: new Date().toISOString() },
},
});
return NextResponse.json({ ok: true, exportedAt: manifest.exportedAt });
} catch (err) {
if (err instanceof Error && err.message === "Unauthorized") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
console.error(err);
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}

View File

@@ -2,7 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Leaf, Home, Wrench, LayoutDashboard, Bell } from "lucide-react";
import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive } from "lucide-react";
import { cn } from "@/lib/utils";
const navItems = [
@@ -14,6 +14,10 @@ const navItems = [
// { label: "Equipment", href: "/equipment", icon: Wrench },
];
const settingsItems = [
{ label: "Backup & Restore", href: "/settings/backup", icon: HardDrive },
];
export function Sidebar() {
const path = usePathname();
@@ -47,6 +51,27 @@ export function Sidebar() {
})}
</nav>
<div className="px-2 py-2 border-t border-[hsl(var(--canopy-border))] space-y-0.5">
{settingsItems.map(({ label, href, icon: Icon }) => {
const active = path.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors",
active
? "bg-[hsl(var(--canopy-accent))] text-[hsl(var(--canopy-accent-foreground))]"
: "hover:bg-[hsl(var(--canopy-accent))] hover:text-[hsl(var(--canopy-accent-foreground))] text-[hsl(var(--canopy-foreground))]/60"
)}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
);
})}
</div>
<div className="px-4 py-3 border-t border-[hsl(var(--canopy-border))] text-[10px] text-[hsl(var(--canopy-foreground))]/40 uppercase tracking-widest">
Moon homestead · Eau Claire, WI
</div>