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. -

- - - -
- - {/* 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}
-
- )} -
-
- ); +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."} +

+
+ + +
+
+ + setCurrent(e.target.value)} + autoComplete="current-password" + autoFocus + required + /> +
+
+ + setNext(e.target.value)} + autoComplete="new-password" + required + minLength={8} + /> +
+
+ + setConfirm(e.target.value)} + autoComplete="new-password" + required + minLength={8} + /> +
+ {error &&

{error}

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

+ + + +
+ + {/* 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/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: [