diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx
index f39ae0c..dc20652 100644
--- a/src/app/(dashboard)/layout.tsx
+++ b/src/app/(dashboard)/layout.tsx
@@ -7,6 +7,10 @@ import { Footer } from "@/components/layout/footer";
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await getSession();
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 (
diff --git a/src/app/(dashboard)/settings/backup/page.tsx b/src/app/(dashboard)/settings/backup/page.tsx
index 3eb3216..375a95f 100644
--- a/src/app/(dashboard)/settings/backup/page.tsx
+++ b/src/app/(dashboard)/settings/backup/page.tsx
@@ -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";
-import { Download, Upload, AlertTriangle, CheckCircle } from "lucide-react";
-import { Button } from "@/components/ui/button";
+export const metadata: Metadata = { title: "Backup & Restore" };
+export const dynamic = "force-dynamic";
-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.
-
- {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}
-
- )}
-
-
- );
+export default async function BackupPage() {
+ const session = await getSession();
+ if (!session) redirect("/login");
+ if (!isAdmin(session.user.role)) redirect("/dashboard");
+ return ;
}
diff --git a/src/app/api/account/change-password/route.ts b/src/app/api/account/change-password/route.ts
new file mode 100644
index 0000000..9bc546a
--- /dev/null
+++ b/src/app/api/account/change-password/route.ts
@@ -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 });
+}
diff --git a/src/app/api/admin/restore/route.ts b/src/app/api/admin/restore/route.ts
index 4275e0a..49565ed 100644
--- a/src/app/api/admin/restore/route.ts
+++ b/src/app/api/admin/restore/route.ts
@@ -1,43 +1,86 @@
import { NextResponse } from "next/server";
import { unzipSync, strFromU8 } from "fflate";
+import { z } from "zod";
+import { UserRole } from "@prisma/client";
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 });
- }
+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) {
+ let session;
+ try {
+ session = await requireAuth();
+ } catch {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ if (!isAdmin(session.user.role)) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ try {
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));
+ let files: Record;
+ try {
+ files = unzipSync(new Uint8Array(await file.arrayBuffer()));
+ } catch {
+ throw new BadBackup("not a valid zip");
+ }
- const parse = (name: string) => {
- if (!files[name]) throw new Error(`Missing ${name} in zip`);
- return JSON.parse(strFromU8(files[name]));
+ const parse = (name: string): unknown => {
+ if (!files[name]) throw new BadBackup(`missing ${name}`);
+ try {
+ 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");
- if (manifest.version !== 1) {
+ const manifest = parse("manifest.json") as { version?: number; exportedAt?: string };
+ if (manifest?.version !== 1) {
return NextResponse.json({ error: "Unsupported backup version" }, { status: 400 });
}
// Older backups won't have newer tables — tolerate their absence.
- const locations = files["locations.json"] ? parse("locations.json") : [];
- const plantTypes = files["plantTypes.json"] ? parse("plantTypes.json") : [];
- const users = parse("users.json");
- const apiTokens = files["apiTokens.json"] ? parse("apiTokens.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");
+ const locations = files["locations.json"] ? asArray("locations.json") : [];
+ const plantTypes = files["plantTypes.json"] ? asArray("plantTypes.json") : [];
+ const users = z.array(userSchema).parse(asArray("users.json"));
+ const apiTokens = files["apiTokens.json"] ? asArray("apiTokens.json") : [];
+ const plantGroups = asArray("plantGroups.json");
+ const plants = asArray("plants.json");
+ const plantLogs = asArray("plantLogs.json");
+ const tasks = asArray("tasks.json");
+ const taskCompletions = asArray("taskCompletions.json");
+ const auditEvents = asArray("auditEvents.json");
// Restore inside a transaction — wipe then reload in FK order.
await db.$transaction(async (tx) => {
@@ -53,17 +96,17 @@ export async function POST(req: Request) {
await tx.user.deleteMany();
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,
// so the FK is validated only after every row exists — order-independent.
- if (locations.length) await tx.location.createMany({ data: locations });
- if (plantTypes.length) await tx.plantType.createMany({ data: plantTypes });
- 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 });
+ if (locations.length) await tx.location.createMany({ data: locations as never });
+ if (plantTypes.length) await tx.plantType.createMany({ data: plantTypes as never });
+ if (plantGroups.length) await tx.plantGroup.createMany({ data: plantGroups as never });
+ if (plants.length) await tx.plant.createMany({ data: plants as never });
+ if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs as never });
+ if (tasks.length) await tx.task.createMany({ data: tasks as never });
+ if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions as never });
+ if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents as never });
});
await db.auditEvent.create({
@@ -76,10 +119,13 @@ export async function POST(req: Request) {
return NextResponse.json({ ok: true, exportedAt: manifest.exportedAt });
} catch (err) {
- if (err instanceof Error && err.message === "Unauthorized") {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ if (err instanceof BadBackup || err instanceof z.ZodError) {
+ return NextResponse.json(
+ { error: "The backup file is invalid or corrupted." },
+ { status: 400 }
+ );
}
- console.error(err);
- return NextResponse.json({ error: String(err) }, { status: 500 });
+ console.error("restore failed:", err);
+ return NextResponse.json({ error: "Restore failed." }, { status: 500 });
}
}
diff --git a/src/app/change-password/page.tsx b/src/app/change-password/page.tsx
new file mode 100644
index 0000000..a087d27
--- /dev/null
+++ b/src/app/change-password/page.tsx
@@ -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 ;
+}
diff --git a/src/components/account/change-password-form.tsx b/src/components/account/change-password-form.tsx
new file mode 100644
index 0000000..869f8e6
--- /dev/null
+++ b/src/components/account/change-password-form.tsx
@@ -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(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 (
+
+
+
+
+
+
+
+ Choose a password
+
+
+ {forced
+ ? "Before you continue, please set your own password."
+ : "Update your Moon Base password."}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/settings/backup-panel.tsx b/src/components/settings/backup-panel.tsx
new file mode 100644
index 0000000..d8140e7
--- /dev/null
+++ b/src/components/settings/backup-panel.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 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(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 (
+
+
+
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.
+
+ {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/lib/auth.ts b/src/lib/auth.ts
index 0376b21..87e2a0e 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -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 = {
+ secret: NEXTAUTH_SECRET,
session: { strategy: "jwt" },
pages: { signIn: "/login", error: "/login" },
providers: [