Add Backup & Restore page (zip download + drag-drop upload)
This commit is contained in:
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user