diff --git a/apps/web/src/app/setup/SetupClient.tsx b/apps/web/src/app/setup/SetupClient.tsx new file mode 100644 index 0000000..d0161f3 --- /dev/null +++ b/apps/web/src/app/setup/SetupClient.tsx @@ -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(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 ( +
+
+

+ Admin account created +

+

Redirecting to sign in…

+
+
+ ); + } + + return ( +
+
+
+

+ First-run setup +

+

+ Create the initial administrator account for CapaKraken. +

+
+ +
+ {formError && ( +
+ {formError} +
+ )} + +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/app/setup/actions.ts b/apps/web/src/app/setup/actions.ts new file mode 100644 index 0000000..42c97e9 --- /dev/null +++ b/apps/web/src/app/setup/actions.ts @@ -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 { + // 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; + } +} diff --git a/apps/web/src/app/setup/page.tsx b/apps/web/src/app/setup/page.tsx new file mode 100644 index 0000000..7ac9fef --- /dev/null +++ b/apps/web/src/app/setup/page.tsx @@ -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 ; +} diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..52789ca --- /dev/null +++ b/docs/installation.md @@ -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= +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. diff --git a/scripts/setup-admin.mjs b/scripts/setup-admin.mjs new file mode 100644 index 0000000..3f84795 --- /dev/null +++ b/scripts/setup-admin.mjs @@ -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(); +}