feat: first-run setup wizard, CLI seed script, and installation docs
- /setup Server Component + SetupClient form + createFirstAdmin Server Action: zero-users guard (TOCTOU-safe), argon2 hash, ADMIN user creation, redirects to /auth/signin after setup - scripts/setup-admin.mjs: CLI alternative for headless/container setups - docs/installation.md: 7-section install guide (clone → configure → run → verify) Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createFirstAdmin } from "./actions.js";
|
||||
|
||||
export function SetupClient() {
|
||||
const router = useRouter();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
if (password.length < 8) {
|
||||
setFormError("Password must be at least 8 characters.");
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setFormError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await createFirstAdmin({ name, email, password });
|
||||
|
||||
if ("success" in result) {
|
||||
setDone(true);
|
||||
router.push("/auth/signin?setup=done");
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.error === "alreadySetup") {
|
||||
setFormError("Setup already completed. Redirecting…");
|
||||
setTimeout(() => router.push("/auth/signin"), 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.error === "emailTaken") {
|
||||
setFormError("An account with this email already exists.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.error === "validation") {
|
||||
setFormError(result.message ?? "Validation error.");
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 p-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 shadow-lg p-8 text-center">
|
||||
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Admin account created
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">Redirecting to sign in…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 p-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 shadow-lg p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
First-run setup
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Create the initial administrator account for CapaKraken.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{formError && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
placeholder="Your full name"
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="admin@example.com"
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="At least 8 characters"
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
placeholder="Repeat your password"
|
||||
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="w-full rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Creating account…" : "Create admin account"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use server";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { SystemRole } from "@capakraken/db";
|
||||
|
||||
export type SetupResult =
|
||||
| { success: true }
|
||||
| { error: "alreadySetup" | "emailTaken" | "validation"; message?: string };
|
||||
|
||||
export async function createFirstAdmin(formData: {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}): Promise<SetupResult> {
|
||||
// Validate
|
||||
if (!formData.name.trim()) return { error: "validation", message: "Name is required." };
|
||||
if (!formData.email.includes("@")) return { error: "validation", message: "Valid email required." };
|
||||
if (formData.password.length < 8) return { error: "validation", message: "Password must be at least 8 characters." };
|
||||
|
||||
// TOCTOU guard — check again inside the action
|
||||
const count = await prisma.user.count();
|
||||
if (count > 0) return { error: "alreadySetup" };
|
||||
|
||||
const { hash } = await import("@node-rs/argon2");
|
||||
const passwordHash = await hash(formData.password);
|
||||
|
||||
try {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
email: formData.email.toLowerCase().trim(),
|
||||
name: formData.name.trim(),
|
||||
passwordHash,
|
||||
systemRole: SystemRole.ADMIN,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("Unique constraint") || message.includes("unique")) {
|
||||
return { error: "emailTaken" };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { SetupClient } from "./SetupClient.js";
|
||||
|
||||
export default async function SetupPage() {
|
||||
const count = await prisma.user.count();
|
||||
if (count > 0) {
|
||||
redirect("/auth/signin");
|
||||
}
|
||||
return <SetupClient />;
|
||||
}
|
||||
Reference in New Issue
Block a user