Security: force first-login password change, gate admin tools, validate restore
Fixes from the code review: - #1 Wire mustChangePassword: dashboard layout redirects to a new /change-password page + /api/account/change-password (verifies current pw, re-hashes, clears the flag, refreshes the session). Provisioned admins can no longer keep the default. - #2 Assert NEXTAUTH_SECRET in production (not during build); set it on authOptions. - #3 Backup page now server-gated to admins (split into server page + client panel), matching the Updates page. - #4 Restore validates input: strict zod schema for the users table (known fields, role enum) + array checks for the rest; returns generic errors instead of String(err). No version bump/changelog — left for you to fold into the next release (you're editing those live). Not pushed, not deployed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,10 @@ import { Footer } from "@/components/layout/footer";
|
|||||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
if (!session) redirect("/login");
|
if (!session) redirect("/login");
|
||||||
|
// Force a password change before anything else (provisioned accounts ship with
|
||||||
|
// a known default + mustChangePassword=true). The target page is outside this
|
||||||
|
// layout group, so there's no redirect loop.
|
||||||
|
if (session.user.mustChangePassword) redirect("/change-password");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
|
|||||||
@@ -1,115 +1,14 @@
|
|||||||
"use client";
|
import { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getSession, isAdmin } from "@/lib/auth";
|
||||||
|
import { BackupPanel } from "@/components/settings/backup-panel";
|
||||||
|
|
||||||
import { useState, useRef } from "react";
|
export const metadata: Metadata = { title: "Backup & Restore" };
|
||||||
import { Download, Upload, AlertTriangle, CheckCircle } from "lucide-react";
|
export const dynamic = "force-dynamic";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
export default function BackupPage() {
|
export default async function BackupPage() {
|
||||||
const [restoreStatus, setRestoreStatus] = useState<
|
const session = await getSession();
|
||||||
{ type: "success"; exportedAt: string } | { type: "error"; message: string } | null
|
if (!session) redirect("/login");
|
||||||
>(null);
|
if (!isAdmin(session.user.role)) redirect("/dashboard");
|
||||||
const [restoring, setRestoring] = useState(false);
|
return <BackupPanel />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
57
src/app/api/account/change-password/route.ts
Normal file
57
src/app/api/account/change-password/route.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { compare, hash } from "bcryptjs";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { requireAuth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
currentPassword: z.string().min(1, "Enter your current password."),
|
||||||
|
newPassword: z.string().min(8, "New password must be at least 8 characters."),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
session = await requireAuth();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(await req.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues[0]?.message ?? "Invalid input." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { currentPassword, newPassword } = parsed.data;
|
||||||
|
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: { passwordHash: true },
|
||||||
|
});
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
if (!(await compare(currentPassword, user.passwordHash))) {
|
||||||
|
return NextResponse.json({ error: "Current password is incorrect." }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (await compare(newPassword, user.passwordHash)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "New password must be different from your current one." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
data: { passwordHash: await hash(newPassword, 10), mustChangePassword: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.auditEvent.create({
|
||||||
|
data: { action: "user.change_password", actorId: session.user.id, entityId: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -1,43 +1,86 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { unzipSync, strFromU8 } from "fflate";
|
import { unzipSync, strFromU8 } from "fflate";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { UserRole } from "@prisma/client";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { requireAuth, isAdmin } from "@/lib/auth";
|
import { requireAuth, isAdmin } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// A malformed/crafted backup must not be able to inject arbitrary auth rows.
|
||||||
|
// users is the security boundary, so it's validated strictly (known fields only,
|
||||||
|
// role constrained to the enum). Other tables are app-owned data — we only check
|
||||||
|
// they're arrays; Prisma rejects any unknown columns on insert.
|
||||||
|
class BadBackup extends Error {}
|
||||||
|
|
||||||
|
const userSchema = z
|
||||||
|
.object({
|
||||||
|
id: z.string(),
|
||||||
|
email: z.string(),
|
||||||
|
username: z.string().nullable().optional(),
|
||||||
|
name: z.string(),
|
||||||
|
passwordHash: z.string(),
|
||||||
|
role: z.nativeEnum(UserRole).optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
mustChangePassword: z.boolean().optional(),
|
||||||
|
createdAt: z.string().optional(),
|
||||||
|
updatedAt: z.string().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
|
let session;
|
||||||
try {
|
try {
|
||||||
const session = await requireAuth();
|
session = await requireAuth();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
if (!isAdmin(session.user.role)) {
|
if (!isAdmin(session.user.role)) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const form = await req.formData();
|
const form = await req.formData();
|
||||||
const file = form.get("file") as File | null;
|
const file = form.get("file") as File | null;
|
||||||
if (!file) return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
|
if (!file) return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
|
||||||
|
|
||||||
const buf = await file.arrayBuffer();
|
let files: Record<string, Uint8Array>;
|
||||||
const files = unzipSync(new Uint8Array(buf));
|
try {
|
||||||
|
files = unzipSync(new Uint8Array(await file.arrayBuffer()));
|
||||||
|
} catch {
|
||||||
|
throw new BadBackup("not a valid zip");
|
||||||
|
}
|
||||||
|
|
||||||
const parse = (name: string) => {
|
const parse = (name: string): unknown => {
|
||||||
if (!files[name]) throw new Error(`Missing ${name} in zip`);
|
if (!files[name]) throw new BadBackup(`missing ${name}`);
|
||||||
|
try {
|
||||||
return JSON.parse(strFromU8(files[name]));
|
return JSON.parse(strFromU8(files[name]));
|
||||||
|
} catch {
|
||||||
|
throw new BadBackup(`invalid JSON in ${name}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const asArray = (name: string): unknown[] => {
|
||||||
|
const v = parse(name);
|
||||||
|
if (!Array.isArray(v)) throw new BadBackup(`${name} is not an array`);
|
||||||
|
return v;
|
||||||
};
|
};
|
||||||
|
|
||||||
const manifest = parse("manifest.json");
|
const manifest = parse("manifest.json") as { version?: number; exportedAt?: string };
|
||||||
if (manifest.version !== 1) {
|
if (manifest?.version !== 1) {
|
||||||
return NextResponse.json({ error: "Unsupported backup version" }, { status: 400 });
|
return NextResponse.json({ error: "Unsupported backup version" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Older backups won't have newer tables — tolerate their absence.
|
// Older backups won't have newer tables — tolerate their absence.
|
||||||
const locations = files["locations.json"] ? parse("locations.json") : [];
|
const locations = files["locations.json"] ? asArray("locations.json") : [];
|
||||||
const plantTypes = files["plantTypes.json"] ? parse("plantTypes.json") : [];
|
const plantTypes = files["plantTypes.json"] ? asArray("plantTypes.json") : [];
|
||||||
const users = parse("users.json");
|
const users = z.array(userSchema).parse(asArray("users.json"));
|
||||||
const apiTokens = files["apiTokens.json"] ? parse("apiTokens.json") : [];
|
const apiTokens = files["apiTokens.json"] ? asArray("apiTokens.json") : [];
|
||||||
const plantGroups = parse("plantGroups.json");
|
const plantGroups = asArray("plantGroups.json");
|
||||||
const plants = parse("plants.json");
|
const plants = asArray("plants.json");
|
||||||
const plantLogs = parse("plantLogs.json");
|
const plantLogs = asArray("plantLogs.json");
|
||||||
const tasks = parse("tasks.json");
|
const tasks = asArray("tasks.json");
|
||||||
const taskCompletions = parse("taskCompletions.json");
|
const taskCompletions = asArray("taskCompletions.json");
|
||||||
const auditEvents = parse("auditEvents.json");
|
const auditEvents = asArray("auditEvents.json");
|
||||||
|
|
||||||
// Restore inside a transaction — wipe then reload in FK order.
|
// Restore inside a transaction — wipe then reload in FK order.
|
||||||
await db.$transaction(async (tx) => {
|
await db.$transaction(async (tx) => {
|
||||||
@@ -53,17 +96,17 @@ export async function POST(req: Request) {
|
|||||||
await tx.user.deleteMany();
|
await tx.user.deleteMany();
|
||||||
|
|
||||||
if (users.length) await tx.user.createMany({ data: users });
|
if (users.length) await tx.user.createMany({ data: users });
|
||||||
if (apiTokens.length) await tx.apiToken.createMany({ data: apiTokens });
|
if (apiTokens.length) await tx.apiToken.createMany({ data: apiTokens as never });
|
||||||
// Location self-references parentId; a single createMany is one statement,
|
// Location self-references parentId; a single createMany is one statement,
|
||||||
// so the FK is validated only after every row exists — order-independent.
|
// so the FK is validated only after every row exists — order-independent.
|
||||||
if (locations.length) await tx.location.createMany({ data: locations });
|
if (locations.length) await tx.location.createMany({ data: locations as never });
|
||||||
if (plantTypes.length) await tx.plantType.createMany({ data: plantTypes });
|
if (plantTypes.length) await tx.plantType.createMany({ data: plantTypes as never });
|
||||||
if (plantGroups.length) await tx.plantGroup.createMany({ data: plantGroups });
|
if (plantGroups.length) await tx.plantGroup.createMany({ data: plantGroups as never });
|
||||||
if (plants.length) await tx.plant.createMany({ data: plants });
|
if (plants.length) await tx.plant.createMany({ data: plants as never });
|
||||||
if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs });
|
if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs as never });
|
||||||
if (tasks.length) await tx.task.createMany({ data: tasks });
|
if (tasks.length) await tx.task.createMany({ data: tasks as never });
|
||||||
if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions });
|
if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions as never });
|
||||||
if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents });
|
if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents as never });
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.auditEvent.create({
|
await db.auditEvent.create({
|
||||||
@@ -76,10 +119,13 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
return NextResponse.json({ ok: true, exportedAt: manifest.exportedAt });
|
return NextResponse.json({ ok: true, exportedAt: manifest.exportedAt });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error && err.message === "Unauthorized") {
|
if (err instanceof BadBackup || err instanceof z.ZodError) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json(
|
||||||
|
{ error: "The backup file is invalid or corrupted." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
console.error(err);
|
console.error("restore failed:", err);
|
||||||
return NextResponse.json({ error: String(err) }, { status: 500 });
|
return NextResponse.json({ error: "Restore failed." }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
src/app/change-password/page.tsx
Normal file
13
src/app/change-password/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
import { ChangePasswordForm } from "@/components/account/change-password-form";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Change password" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ChangePasswordPage() {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
return <ChangePasswordForm forced={session.user.mustChangePassword} />;
|
||||||
|
}
|
||||||
116
src/components/account/change-password-form.tsx
Normal file
116
src/components/account/change-password-form.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Leaf } from "lucide-react";
|
||||||
|
|
||||||
|
export function ChangePasswordForm({ forced }: { forced: boolean }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { update } = useSession();
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
|
const [next, setNext] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function onSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (next !== confirm) {
|
||||||
|
setError("The new passwords don't match.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/account/change-password", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ currentPassword: current, newPassword: next }),
|
||||||
|
});
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(json.error ?? "Couldn't change your password.");
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Refresh the session token so mustChangePassword clears, then continue.
|
||||||
|
await update();
|
||||||
|
router.push("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setError("Something went wrong. Please try again.");
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center px-4 bg-background">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="flex flex-col items-center mb-6 text-center">
|
||||||
|
<div className="h-14 w-14 rounded-full bg-[hsl(var(--canopy))] flex items-center justify-center mb-3">
|
||||||
|
<Leaf className="h-7 w-7 text-[hsl(var(--canopy-foreground))]" />
|
||||||
|
</div>
|
||||||
|
<h1 className="font-display text-3xl text-foreground tracking-tight">
|
||||||
|
Choose a password
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{forced
|
||||||
|
? "Before you continue, please set your own password."
|
||||||
|
: "Update your Moon Base password."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="current">Current password</Label>
|
||||||
|
<Input
|
||||||
|
id="current"
|
||||||
|
type="password"
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="next">New password</Label>
|
||||||
|
<Input
|
||||||
|
id="next"
|
||||||
|
type="password"
|
||||||
|
value={next}
|
||||||
|
onChange={(e) => setNext(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="confirm">Confirm new password</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm"
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-[hsl(var(--ember))]">{error}</p>}
|
||||||
|
<Button type="submit" className="w-full" disabled={busy}>
|
||||||
|
{busy ? "Saving…" : "Save password"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/components/settings/backup-panel.tsx
Normal file
115
src/components/settings/backup-panel.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 function BackupPanel() {
|
||||||
|
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: err instanceof Error ? err.message : "Restore failed" });
|
||||||
|
} 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,7 +29,22 @@ declare module "next-auth/jwt" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fail loudly in production if the JWT secret is missing or left at the
|
||||||
|
// placeholder — otherwise sessions are forgeable. Skipped during `next build`
|
||||||
|
// (no runtime env) and in development.
|
||||||
|
const NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET;
|
||||||
|
if (
|
||||||
|
process.env.NODE_ENV === "production" &&
|
||||||
|
process.env.NEXT_PHASE !== "phase-production-build" &&
|
||||||
|
(!NEXTAUTH_SECRET || NEXTAUTH_SECRET.length < 16 || NEXTAUTH_SECRET.includes("change-me"))
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"NEXTAUTH_SECRET is missing or insecure — generate one with `openssl rand -base64 32` and set it in the environment."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const authOptions: NextAuthOptions = {
|
export const authOptions: NextAuthOptions = {
|
||||||
|
secret: NEXTAUTH_SECRET,
|
||||||
session: { strategy: "jwt" },
|
session: { strategy: "jwt" },
|
||||||
pages: { signIn: "/login", error: "/login" },
|
pages: { signIn: "/login", error: "/login" },
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
Reference in New Issue
Block a user