From c12e607be7f6edb15e4d48b7ae3b05320e074428 Mon Sep 17 00:00:00 2001 From: Bonna Moon Date: Mon, 15 Jun 2026 21:47:49 -0500 Subject: [PATCH] Add Backup & Restore page (zip download + drag-drop upload) --- package-lock.json | 7 ++ package.json | 1 + src/app/(dashboard)/settings/backup/page.tsx | 115 +++++++++++++++++++ src/app/api/admin/backup/route.ts | 55 +++++++++ src/app/api/admin/restore/route.ts | 73 ++++++++++++ src/components/layout/sidebar.tsx | 27 ++++- 6 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 src/app/(dashboard)/settings/backup/page.tsx create mode 100644 src/app/api/admin/backup/route.ts create mode 100644 src/app/api/admin/restore/route.ts diff --git a/package-lock.json b/package-lock.json index ced4138..fb2505c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", + "fflate": "^0.8.3", "lucide-react": "^0.441.0", "next": "14.2.29", "next-auth": "^4.24.11", @@ -4853,6 +4854,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", diff --git a/package.json b/package.json index aa14bd8..89b607f 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", + "fflate": "^0.8.3", "lucide-react": "^0.441.0", "next": "14.2.29", "next-auth": "^4.24.11", diff --git a/src/app/(dashboard)/settings/backup/page.tsx b/src/app/(dashboard)/settings/backup/page.tsx new file mode 100644 index 0000000..3eb3216 --- /dev/null +++ b/src/app/(dashboard)/settings/backup/page.tsx @@ -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(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 ( +
+
+

Backup & Restore

+

+ Download a full backup of all Moon Base data, or restore from a previous backup. +

+
+ + {/* Backup */} +
+

Download Backup

+

+ Downloads all plants, logs, tasks, and settings as a .zip file. Keep it somewhere safe. +

+ + + +
+ + {/* Restore */} +
+

Restore from Backup

+

+ Upload a .zip backup file to restore. This replaces everything — all current data will be overwritten. +

+ +
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); + }} + > + +

+ {restoring ? "Restoring…" : "Drop a backup .zip here, or click to choose"} +

+ { const f = e.target.files?.[0]; if (f) handleFile(f); }} + /> +
+ + {restoreStatus?.type === "success" && ( +
+ +
+ Restore complete. Data from backup exported on{" "} + {new Date(restoreStatus.exportedAt).toLocaleString()} is now live. + Reload the page to see it. +
+
+ )} + + {restoreStatus?.type === "error" && ( +
+ +
{restoreStatus.message}
+
+ )} +
+
+ ); +} diff --git a/src/app/api/admin/backup/route.ts b/src/app/api/admin/backup/route.ts new file mode 100644 index 0000000..8f8a9db --- /dev/null +++ b/src/app/api/admin/backup/route.ts @@ -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 }); + } +} diff --git a/src/app/api/admin/restore/route.ts b/src/app/api/admin/restore/route.ts new file mode 100644 index 0000000..f1483f7 --- /dev/null +++ b/src/app/api/admin/restore/route.ts @@ -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 }); + } +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 6269183..e2dff56 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -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() { })} +
+ {settingsItems.map(({ label, href, icon: Icon }) => { + const active = path.startsWith(href); + return ( + + + {label} + + ); + })} +
+
Moon homestead · Eau Claire, WI