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:
2026-04-02 20:45:15 +02:00
parent 41eb722369
commit d4641e27aa
5 changed files with 452 additions and 0 deletions
+159
View File
@@ -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>
);
}
+44
View File
@@ -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;
}
}
+11
View File
@@ -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 />;
}
+156
View File
@@ -0,0 +1,156 @@
# Installation Guide
This guide covers everything needed to get CapaKraken running from a fresh clone.
---
## 1. Prerequisites
Before you begin, ensure the following are installed on your machine:
- **Docker Desktop** (macOS / Windows) or **Docker Engine + Compose plugin** (Linux)
- Minimum version: Docker Engine 24, Compose 2.20
- **Git** — to clone the repository
- **openssl** — to generate a secure secret (usually pre-installed on macOS and Linux)
You do **not** need Node.js or pnpm installed locally; the application runs entirely inside Docker.
---
## 2. Clone & Configure
```bash
git clone https://github.com/your-org/capakraken.git
cd capakraken
cp .env.example .env
```
Open `.env` and fill in the required values:
| Variable | Description | Example |
|---|---|---|
| `NEXTAUTH_SECRET` | Random secret for session signing | see command below |
| `NEXTAUTH_URL` | Full URL the app is served from | `http://localhost:3100` |
| `DATABASE_URL` | PostgreSQL connection string | already set in `.env.example` |
Generate a secure `NEXTAUTH_SECRET`:
```bash
openssl rand -base64 32
```
Paste the output into `.env`:
```dotenv
NEXTAUTH_SECRET=<paste output here>
NEXTAUTH_URL=http://localhost:3100
```
---
## 3. Start
```bash
docker compose --profile full up -d
```
Wait for the app to become healthy (usually 3060 seconds):
```bash
curl -s http://localhost:3100/api/health | grep '"status"'
# Expected: "status": "ok"
```
You can also watch the logs:
```bash
docker compose logs -f app
```
---
## 4. First-Run: Web Wizard (recommended)
Open your browser and navigate to:
```
http://localhost:3100/setup
```
Fill in your administrator name, email, and password, then click **Create admin account**.
The wizard automatically redirects to the sign-in page once the account is created. If the setup page redirects you to sign-in immediately, an administrator account already exists.
---
## 5. First-Run: CLI (alternative)
If you prefer the command line, run the setup script inside the running container:
```bash
docker exec capakraken-app-1 \
node scripts/setup-admin.mjs \
--email admin@example.com \
--name "Admin" \
--password changeme123
```
Replace `capakraken-app-1` with your actual container name if different (check with `docker ps`).
Expected output on success:
```
Admin user created: admin@example.com
```
If an administrator already exists:
```
Admin user already exists. Skipping.
```
The script exits with code 0 in both cases.
**Running on the host** (advanced): If you have Node.js and pnpm installed locally and `DATABASE_URL` is reachable from your host, you can also run:
```bash
pnpm --filter @capakraken/db exec prisma generate # ensure Prisma client is built
node scripts/setup-admin.mjs --email admin@example.com --name "Admin" --password changeme123
```
---
## 6. Verify
Confirm the API is healthy:
```bash
curl -s http://localhost:3100/api/health
# {"status":"ok","db":"ok"}
```
Sign in at:
```
http://localhost:3100/auth/signin
```
Use the email and password you created in step 4 or 5.
---
## 7. Production Notes
For production deployments:
- Use `docker-compose.prod.yml` together with the base `docker-compose.yml`:
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
- Set `NEXTAUTH_URL` to your real HTTPS domain:
```dotenv
NEXTAUTH_URL=https://capakraken.yourdomain.com
```
- See `tooling/deploy/README.md` for reverse-proxy configuration and TLS setup.
- Never commit `.env` to version control — it contains secrets.
- Rotate `NEXTAUTH_SECRET` by updating the value and restarting the app; existing sessions will be invalidated.
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env node
// scripts/setup-admin.mjs
// Usage: node scripts/setup-admin.mjs --email admin@example.com --name "Admin" --password secret123
import { loadWorkspaceEnv } from "./load-env.mjs";
// Load .env if DATABASE_URL is not already set
if (!process.env.DATABASE_URL) {
const loaded = loadWorkspaceEnv();
if (loaded.length === 0 && !process.env.DATABASE_URL) {
console.error("ERROR: DATABASE_URL is not set. Create a .env file or export DATABASE_URL before running this script.");
process.exit(1);
}
}
// Parse CLI args
const args = process.argv.slice(2);
function getArg(flag) {
const index = args.indexOf(flag);
if (index === -1 || index + 1 >= args.length) return null;
return args[index + 1];
}
const email = getArg("--email");
const name = getArg("--name");
const password = getArg("--password");
const missing = [];
if (!email) missing.push("--email");
if (!name) missing.push("--name");
if (!password) missing.push("--password");
if (missing.length > 0) {
console.error(`ERROR: Missing required arguments: ${missing.join(", ")}`);
console.error("Usage: node scripts/setup-admin.mjs --email admin@example.com --name \"Admin\" --password secret123");
process.exit(1);
}
if (!email.includes("@")) {
console.error("ERROR: Invalid email address.");
process.exit(1);
}
if (password.length < 8) {
console.error("ERROR: Password must be at least 8 characters.");
process.exit(1);
}
const { PrismaClient } = await import("@prisma/client");
const { hash } = await import("@node-rs/argon2");
const prisma = new PrismaClient();
try {
const count = await prisma.user.count();
if (count > 0) {
console.log("Admin user already exists. Skipping.");
process.exit(0);
}
const passwordHash = await hash(password);
await prisma.user.create({
data: {
email: email.toLowerCase().trim(),
name: name.trim(),
passwordHash,
systemRole: "ADMIN",
isActive: true,
},
});
console.log(`Admin user created: ${email.toLowerCase().trim()}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`ERROR: ${message}`);
process.exit(1);
} finally {
await prisma.$disconnect();
}