diff --git a/.DS_Store b/.DS_Store index da1470e..6d1fa21 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/scripts/reset-password.ts b/scripts/reset-password.ts new file mode 100644 index 0000000..cc56f33 --- /dev/null +++ b/scripts/reset-password.ts @@ -0,0 +1,14 @@ +import { PrismaClient } from "@prisma/client"; +import { hashSync } from "bcryptjs"; + +const db = new PrismaClient(); + +async function main() { + const hash = hashSync("moonbase!", 10); + const result = await db.user.updateMany({ + data: { passwordHash: hash, mustChangePassword: false }, + }); + console.log(`Reset password for ${result.count} user(s). Login with: moonbase!`); +} + +main().catch(console.error).finally(() => db.$disconnect()); diff --git a/src/app/api/auth/recover/route.ts b/src/app/api/auth/recover/route.ts new file mode 100644 index 0000000..17a9ae2 --- /dev/null +++ b/src/app/api/auth/recover/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { hashSync } from "bcryptjs"; +import { db } from "@/lib/db"; + +const schema = z.object({ + secret: z.string().min(1), + username: z.string().min(1), + password: z.string().min(6), +}); + +export async function POST(req: Request) { + const recoverySecret = process.env.RECOVERY_SECRET; + if (!recoverySecret) { + return NextResponse.json({ error: "Recovery not configured" }, { status: 503 }); + } + + try { + const body = await req.json(); + const data = schema.parse(body); + + if (data.secret !== recoverySecret) { + return NextResponse.json({ error: "Invalid recovery secret" }, { status: 403 }); + } + + const user = await db.user.findFirst({ + where: { OR: [{ username: data.username }, { email: data.username }], active: true }, + }); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + await db.user.update({ + where: { id: user.id }, + data: { passwordHash: hashSync(data.password, 10), mustChangePassword: false }, + }); + + await db.auditEvent.create({ + data: { action: "auth.recover", entityId: user.id, payload: { username: user.username } }, + }); + + return NextResponse.json({ ok: true, name: user.name }); + } catch (err) { + if (err instanceof z.ZodError) return NextResponse.json({ error: "Invalid input" }, { status: 400 }); + console.error(err); + return NextResponse.json({ error: "Server error" }, { status: 500 }); + } +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 06dc449..a79b67e 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -74,6 +74,11 @@ function LoginForm() { +
diff --git a/src/app/recover/page.tsx b/src/app/recover/page.tsx new file mode 100644 index 0000000..abb6396 --- /dev/null +++ b/src/app/recover/page.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +export default function RecoverPage() { + const router = useRouter(); + const [form, setForm] = useState({ secret: "", username: "", password: "", confirm: "" }); + const [status, setStatus] = useState<{ type: "error" | "success"; message: string } | null>(null); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (form.password !== form.confirm) { + setStatus({ type: "error", message: "Passwords don't match." }); + return; + } + setLoading(true); + setStatus(null); + try { + const res = await fetch("/api/auth/recover", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ secret: form.secret, username: form.username, password: form.password }), + }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error ?? "Failed"); + setStatus({ type: "success", message: `Password reset for ${json.name}. You can now log in.` }); + } catch (err) { + setStatus({ type: "error", message: String(err).replace("Error: ", "") }); + } finally { + setLoading(false); + } + } + + return ( +
+ Enter the recovery secret from your .env file to reset a password.
+