Add Backup & Restore page (zip download + drag-drop upload)
This commit is contained in:
7
package-lock.json
generated
7
package-lock.json
generated
@@ -24,6 +24,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"lucide-react": "^0.441.0",
|
"lucide-react": "^0.441.0",
|
||||||
"next": "14.2.29",
|
"next": "14.2.29",
|
||||||
"next-auth": "^4.24.11",
|
"next-auth": "^4.24.11",
|
||||||
@@ -4853,6 +4854,12 @@
|
|||||||
"reusify": "^1.0.4"
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"lucide-react": "^0.441.0",
|
"lucide-react": "^0.441.0",
|
||||||
"next": "14.2.29",
|
"next": "14.2.29",
|
||||||
"next-auth": "^4.24.11",
|
"next-auth": "^4.24.11",
|
||||||
|
|||||||
115
src/app/(dashboard)/settings/backup/page.tsx
Normal file
115
src/app/(dashboard)/settings/backup/page.tsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/app/api/admin/backup/route.ts
Normal file
55
src/app/api/admin/backup/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/app/api/admin/restore/route.ts
Normal file
73
src/app/api/admin/restore/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -14,6 +14,10 @@ const navItems = [
|
|||||||
// { label: "Equipment", href: "/equipment", icon: Wrench },
|
// { label: "Equipment", href: "/equipment", icon: Wrench },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const settingsItems = [
|
||||||
|
{ label: "Backup & Restore", href: "/settings/backup", icon: HardDrive },
|
||||||
|
];
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const path = usePathname();
|
const path = usePathname();
|
||||||
|
|
||||||
@@ -47,6 +51,27 @@ export function Sidebar() {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</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">
|
<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
|
Moon homestead · Eau Claire, WI
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user