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 />;
|
||||
}
|
||||
@@ -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 30–60 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.
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user