feat: add first-time setup wizard and zero-config startup

Eliminate friction for new users by adding a web-based setup wizard,
auto-generating infrastructure secrets, and providing actionable
feedback when no admin account exists.

- Add /setup page with visual progress steps for admin account creation
- Add /api/setup route (GET: check status, POST: create admin + auto-login)
- Auto-generate AUTH_SECRET and API_KEY when not set (persisted to .data/)
- Add docker-entrypoint.sh for zero-config Docker startup
- Login page auto-redirects to /setup when no users exist
- Login API returns NO_USERS error code with setup guidance
- Remove insecure defaults from .env.example
- Update README Quick Start for zero-config Docker and web setup
- Add CLAUDE.md for AI agent discoverability
This commit is contained in:
Nyk 2026-03-13 14:09:44 +07:00
parent 143f68e9c4
commit 2802f53d53
13 changed files with 771 additions and 21 deletions

View File

@ -3,15 +3,16 @@
# PORT=3000
# === Authentication ===
# Admin user seeded on first run (only if no users exist in DB)
AUTH_USER=admin
AUTH_PASS=change-me-on-first-login
# If your password includes "#" and you do not want to quote AUTH_PASS, use base64:
# AUTH_PASS_B64=Y2hhbmdlLW1lLW9uLWZpcnN0LWxvZ2lu
# On first run, visit http://localhost:3000/setup to create your admin account.
# Alternatively, set AUTH_USER/AUTH_PASS to seed an admin from env (useful for CI/automation).
# AUTH_USER=admin
# AUTH_PASS=your-strong-password-here
# If your password includes "#", use base64: AUTH_PASS_B64=<base64-encoded-password>
# Example: echo -n 'my#password' | base64
# API key for headless/external access (x-api-key header)
API_KEY=generate-a-random-key
# Auto-generated on first run if not set. Persisted to .data/.auto-generated.
# API_KEY=
# Primary gateway defaults (used by /api/gateways seeding if DB is empty)
MC_DEFAULT_GATEWAY_NAME=primary
@ -48,7 +49,8 @@ GOOGLE_CLIENT_ID=
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
# Legacy cookie auth (backward compat, can be removed once all clients use session auth)
AUTH_SECRET=random-secret-for-legacy-cookies
# Auto-generated on first run if not set. Persisted to .data/.auto-generated.
# AUTH_SECRET=
# Coordinator identity (used for coordinator chat status replies and comms UI)
MC_COORDINATOR_AGENT=coordinator

4
.gitignore vendored
View File

@ -42,6 +42,6 @@ playwright-report/
/e2e-debug-*.png
/e2e-channels-*.png
# Claude Code context files
CLAUDE.md
# Claude Code context files (root CLAUDE.md is committed for AI agent discovery)
**/CLAUDE.md
!/CLAUDE.md

80
CLAUDE.md Normal file
View File

@ -0,0 +1,80 @@
# Mission Control
Open-source dashboard for AI agent orchestration. Manage agent fleets, track tasks, monitor costs, and orchestrate workflows.
**Stack**: Next.js 16, React 19, TypeScript 5, SQLite (better-sqlite3), Tailwind CSS 3, Zustand, pnpm
## Prerequisites
- Node.js >= 22 (LTS recommended; 24.x also supported)
- pnpm (`corepack enable` to auto-install)
## Setup
```bash
pnpm install
pnpm build
```
Secrets (AUTH_SECRET, API_KEY) auto-generate on first run if not set.
Visit `http://localhost:3000/setup` to create an admin account, or set `AUTH_USER`/`AUTH_PASS` in `.env` for headless/CI seeding.
## Run
```bash
pnpm dev # development (localhost:3000)
pnpm start # production
node .next/standalone/server.js # standalone mode (after build)
```
## Docker
```bash
docker compose up # zero-config
bash install.sh --docker # full guided setup
```
Production hardening: `docker compose -f docker-compose.yml -f docker-compose.hardened.yml up -d`
## Tests
```bash
pnpm test # unit tests (vitest)
pnpm test:e2e # end-to-end (playwright)
pnpm typecheck # tsc --noEmit
pnpm lint # eslint
pnpm test:all # lint + typecheck + test + build + e2e
```
## Key Directories
```
src/app/ Next.js pages + API routes (App Router)
src/components/ UI panels and shared components
src/lib/ Core logic, database, utilities
.data/ SQLite database + runtime state (gitignored)
scripts/ Install, deploy, diagnostics scripts
docs/ Documentation and guides
```
Path alias: `@/*` maps to `./src/*`
## Data Directory
Set `MISSION_CONTROL_DATA_DIR` env var to change the data location (defaults to `.data/`).
Database path: `MISSION_CONTROL_DB_PATH` (defaults to `.data/mission-control.db`).
## Conventions
- **Commits**: Conventional Commits (`feat:`, `fix:`, `docs:`, `test:`, `refactor:`, `chore:`)
- **No AI attribution**: Never add `Co-Authored-By` or similar trailers to commits
- **Package manager**: pnpm only (no npm/yarn)
- **Icons**: No icon libraries -- use raw text/emoji in components
- **Standalone output**: `next.config.js` sets `output: 'standalone'`
## Common Pitfalls
- **Standalone mode**: Use `node .next/standalone/server.js`, not `pnpm start` (which requires full `node_modules`)
- **better-sqlite3**: Native addon -- needs rebuild when switching Node versions (`pnpm rebuild better-sqlite3`)
- **AUTH_PASS with `#`**: Quote it (`AUTH_PASS="my#pass"`) or use `AUTH_PASS_B64` (base64-encoded)
- **Gateway optional**: Set `NEXT_PUBLIC_GATEWAY_OPTIONAL=true` for standalone deployments without gateway connectivity

View File

@ -38,10 +38,12 @@ COPY --from=build /app/src/lib/schema.sql ./src/lib/schema.sql
# Create data directory with correct ownership for SQLite
RUN mkdir -p .data && chown nextjs:nodejs .data
RUN echo 'const http=require("http");const r=http.get("http://localhost:"+(process.env.PORT||3000)+"/api/status?action=health",s=>{process.exit(s.statusCode===200?0:1)});r.on("error",()=>process.exit(1));r.setTimeout(4000,()=>{r.destroy();process.exit(1)})' > /app/healthcheck.js
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod +x /app/docker-entrypoint.sh
USER nextjs
ENV PORT=3000
EXPOSE 3000
ENV HOSTNAME=0.0.0.0
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["node", "/app/healthcheck.js"]
CMD ["node", "server.js"]
ENTRYPOINT ["/app/docker-entrypoint.sh"]

View File

@ -44,7 +44,7 @@ cd mission-control
bash install.sh --docker
```
The installer auto-generates secure credentials, starts the container, and runs an OpenClaw fleet health check. Open `http://localhost:3000` and log in with the printed credentials.
The installer auto-generates secure credentials, starts the container, and runs an OpenClaw fleet health check. Open `http://localhost:3000` to create your admin account.
### One-Command Install (Local)
@ -66,12 +66,12 @@ git clone https://github.com/builderz-labs/mission-control.git
cd mission-control
nvm use 22 # or: nvm use 24
pnpm install
cp .env.example .env # edit with your values
pnpm dev # http://localhost:3000
pnpm dev # http://localhost:3000/setup
```
Initial login is seeded from `AUTH_USER` / `AUTH_PASS` on first run.
If `AUTH_PASS` contains `#`, quote it (e.g. `AUTH_PASS="my#password"`) or use `AUTH_PASS_B64`.
On first run, visit `http://localhost:3000/setup` to create your admin account. Secrets (`AUTH_SECRET`, `API_KEY`) are auto-generated and persisted to `.data/`.
For CI/automation, set `AUTH_USER` and `AUTH_PASS` env vars to seed the admin from environment instead.
## Gateway Optional Mode (Standalone Deployment)
@ -99,6 +99,14 @@ Requires active gateway:
For production VPS setups, you can also proxy gateway WebSockets over 443. See `docs/deployment.md`.
### Docker Zero-Config
```bash
docker compose up
```
No `.env` file needed. The container auto-generates `AUTH_SECRET` and `API_KEY` on first boot and persists them across restarts. Visit `http://localhost:3000` to create your admin account.
### Docker Hardening (Production)
For production deployments, use the hardened compose overlay:

48
docker-entrypoint.sh Executable file
View File

@ -0,0 +1,48 @@
#!/bin/sh
set -e
# --- Source .env if present ---
if [ -f /app/.env ]; then
printf '[entrypoint] Loading .env\n'
set -a
. /app/.env
set +a
fi
# --- Helper: generate a random hex secret ---
generate_secret() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex 32
else
head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n'
fi
}
SECRETS_FILE="/app/.data/.generated-secrets"
# Load previously generated secrets if they exist
if [ -f "$SECRETS_FILE" ]; then
printf '[entrypoint] Loading persisted secrets from .data\n'
set -a
. "$SECRETS_FILE"
set +a
fi
# --- AUTH_SECRET ---
if [ -z "$AUTH_SECRET" ] || [ "$AUTH_SECRET" = "random-secret-for-legacy-cookies" ]; then
AUTH_SECRET=$(generate_secret)
printf '[entrypoint] Generated new AUTH_SECRET\n'
printf 'AUTH_SECRET=%s\n' "$AUTH_SECRET" >> "$SECRETS_FILE"
export AUTH_SECRET
fi
# --- API_KEY ---
if [ -z "$API_KEY" ] || [ "$API_KEY" = "generate-a-random-key" ]; then
API_KEY=$(generate_secret)
printf '[entrypoint] Generated new API_KEY\n'
printf 'API_KEY=%s\n' "$API_KEY" >> "$SECRETS_FILE"
export API_KEY
fi
printf '[entrypoint] Starting server\n'
exec node server.js

View File

@ -1,6 +1,6 @@
import { NextResponse } from 'next/server'
import { authenticateUser, createSession } from '@/lib/auth'
import { logAuditEvent } from '@/lib/db'
import { logAuditEvent, needsFirstTimeSetup } from '@/lib/db'
import { getMcSessionCookieName, getMcSessionCookieOptions, isRequestSecure } from '@/lib/session-cookie'
import { loginLimiter } from '@/lib/rate-limit'
import { logger } from '@/lib/logger'
@ -22,6 +22,19 @@ export async function POST(request: Request) {
const user = authenticateUser(username, password)
if (!user) {
logAuditEvent({ action: 'login_failed', actor: username, ip_address: ipAddress, user_agent: userAgent })
// When no users exist at all, give actionable feedback instead of "Invalid credentials"
if (needsFirstTimeSetup()) {
return NextResponse.json(
{
error: 'No admin account has been created yet',
code: 'NO_USERS',
hint: 'Visit /setup to create your admin account',
},
{ status: 401 }
)
}
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
}

112
src/app/api/setup/route.ts Normal file
View File

@ -0,0 +1,112 @@
import { NextResponse } from 'next/server'
import { needsFirstTimeSetup } from '@/lib/db'
import { createUser, createSession } from '@/lib/auth'
import { logAuditEvent } from '@/lib/db'
import { getMcSessionCookieName, getMcSessionCookieOptions, isRequestSecure } from '@/lib/session-cookie'
import { logger } from '@/lib/logger'
const INSECURE_PASSWORDS = new Set([
'admin',
'password',
'change-me-on-first-login',
'changeme',
'testpass123',
])
export async function GET() {
return NextResponse.json({ needsSetup: needsFirstTimeSetup() })
}
export async function POST(request: Request) {
try {
// Only allow setup when no users exist
if (!needsFirstTimeSetup()) {
return NextResponse.json(
{ error: 'Setup has already been completed' },
{ status: 403 }
)
}
const body = await request.json()
const { username, password, displayName } = body as {
username?: string
password?: string
displayName?: string
}
// Validate username
if (!username || typeof username !== 'string') {
return NextResponse.json({ error: 'Username is required' }, { status: 400 })
}
const trimmedUsername = username.trim().toLowerCase()
if (trimmedUsername.length < 2 || trimmedUsername.length > 64) {
return NextResponse.json({ error: 'Username must be 2-64 characters' }, { status: 400 })
}
if (!/^[a-z0-9_.-]+$/.test(trimmedUsername)) {
return NextResponse.json(
{ error: 'Username can only contain lowercase letters, numbers, dots, hyphens, and underscores' },
{ status: 400 }
)
}
// Validate password
if (!password || typeof password !== 'string') {
return NextResponse.json({ error: 'Password is required' }, { status: 400 })
}
if (password.length < 12) {
return NextResponse.json({ error: 'Password must be at least 12 characters' }, { status: 400 })
}
if (INSECURE_PASSWORDS.has(password)) {
return NextResponse.json({ error: 'That password is too common. Choose a stronger one.' }, { status: 400 })
}
// Double-check no users exist (race safety — createUser will also fail on duplicate username)
if (!needsFirstTimeSetup()) {
return NextResponse.json(
{ error: 'Another admin was created while you were setting up' },
{ status: 409 }
)
}
const resolvedDisplayName = displayName?.trim() ||
trimmedUsername.charAt(0).toUpperCase() + trimmedUsername.slice(1)
const user = createUser(trimmedUsername, password, resolvedDisplayName, 'admin')
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
const userAgent = request.headers.get('user-agent') || undefined
logAuditEvent({
action: 'setup_admin_created',
actor: user.username,
actor_id: user.id,
ip_address: ipAddress,
user_agent: userAgent,
})
logger.info(`First-time setup: admin user "${user.username}" created`)
// Auto-login: create session and set cookie
const { token, expiresAt } = createSession(user.id, ipAddress, userAgent, user.workspace_id)
const response = NextResponse.json({
user: {
id: user.id,
username: user.username,
display_name: user.display_name,
role: user.role,
},
})
const isSecureRequest = isRequestSecure(request)
const cookieName = getMcSessionCookieName(isSecureRequest)
response.cookies.set(cookieName, token, {
...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000), isSecureRequest }),
})
return response
} catch (error) {
logger.error({ err: error }, 'Setup error')
return NextResponse.json({ error: 'Failed to create admin account' }, { status: 500 })
}
}

View File

@ -29,6 +29,7 @@ type LoginRequestBody =
type LoginErrorPayload = {
code?: string
error?: string
hint?: string
}
function readLoginErrorPayload(value: unknown): LoginErrorPayload {
@ -37,6 +38,7 @@ function readLoginErrorPayload(value: unknown): LoginErrorPayload {
return {
code: typeof record.code === 'string' ? record.code : undefined,
error: typeof record.error === 'string' ? record.error : undefined,
hint: typeof record.hint === 'string' ? record.hint : undefined,
}
}
@ -62,6 +64,7 @@ export default function LoginPage() {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [pendingApproval, setPendingApproval] = useState(false)
const [needsSetup, setNeedsSetup] = useState(false)
const [loading, setLoading] = useState(false)
const [googleLoading, setGoogleLoading] = useState(false)
const [googleReady, setGoogleReady] = useState(false)
@ -69,6 +72,20 @@ export default function LoginPage() {
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ''
// Check if first-time setup is needed on page load — auto-redirect to /setup
useEffect(() => {
fetch('/api/setup')
.then((res) => res.json())
.then((data) => {
if (data.needsSetup) {
window.location.href = '/setup'
}
})
.catch(() => {
// Ignore — setup check is best-effort
})
}, [])
const completeLogin = useCallback(async (path: string, body: LoginRequestBody) => {
const res = await fetch(path, {
method: 'POST',
@ -80,6 +97,14 @@ export default function LoginPage() {
const data = readLoginErrorPayload(await res.json().catch(() => null))
if (data.code === 'PENDING_APPROVAL') {
setPendingApproval(true)
setNeedsSetup(false)
setError('')
setLoading(false)
setGoogleLoading(false)
return false
}
if (data.code === 'NO_USERS') {
setNeedsSetup(true)
setError('')
setLoading(false)
setGoogleLoading(false)
@ -87,6 +112,7 @@ export default function LoginPage() {
}
setError(data.error || 'Login failed')
setPendingApproval(false)
setNeedsSetup(false)
setLoading(false)
setGoogleLoading(false)
return false
@ -202,6 +228,29 @@ export default function LoginPage() {
</div>
)}
{needsSetup && (
<div className="mb-4 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20 text-center">
<div className="flex justify-center mb-2">
<svg className="w-8 h-8 text-blue-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<div className="text-sm font-medium text-blue-200">No admin account created yet</div>
<p className="text-xs text-muted-foreground mt-1">
Set up your admin account to get started with Mission Control.
</p>
<Button
onClick={() => { window.location.href = '/setup' }}
size="sm"
className="mt-3"
>
Create Admin Account
</Button>
</div>
)}
{error && (
<div role="alert" className="mb-4 p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-sm text-destructive">
{error}

313
src/app/setup/page.tsx Normal file
View File

@ -0,0 +1,313 @@
'use client'
import { useCallback, useEffect, useState, type FormEvent } from 'react'
import Image from 'next/image'
import { Button } from '@/components/ui/button'
type SetupStep = 'form' | 'creating'
interface ProgressStep {
label: string
status: 'pending' | 'active' | 'done' | 'error'
}
const INITIAL_PROGRESS: ProgressStep[] = [
{ label: 'Validating credentials', status: 'pending' },
{ label: 'Creating admin account', status: 'pending' },
{ label: 'Configuring session', status: 'pending' },
{ label: 'Launching dashboard', status: 'pending' },
]
function ProgressIndicator({ steps }: { steps: ProgressStep[] }) {
return (
<div className="space-y-3">
{steps.map((step, i) => (
<div key={i} className="flex items-center gap-3">
<div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
{step.status === 'done' && (
<svg className="w-5 h-5 text-green-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6L9 17l-5-5" />
</svg>
)}
{step.status === 'active' && (
<div className="w-4 h-4 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
)}
{step.status === 'pending' && (
<div className="w-2 h-2 rounded-full bg-muted-foreground/30" />
)}
{step.status === 'error' && (
<svg className="w-5 h-5 text-destructive" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
)}
</div>
<span className={`text-sm ${
step.status === 'active' ? 'text-foreground font-medium' :
step.status === 'done' ? 'text-green-400' :
step.status === 'error' ? 'text-destructive' :
'text-muted-foreground'
}`}>
{step.label}
</span>
</div>
))}
</div>
)
}
export default function SetupPage() {
const [username, setUsername] = useState('admin')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [displayName, setDisplayName] = useState('')
const [error, setError] = useState('')
const [step, setStep] = useState<SetupStep>('form')
const [progress, setProgress] = useState<ProgressStep[]>(INITIAL_PROGRESS)
const [checking, setChecking] = useState(true)
const [setupAvailable, setSetupAvailable] = useState(false)
useEffect(() => {
fetch('/api/setup')
.then((res) => res.json())
.then((data) => {
if (!data.needsSetup) {
window.location.href = '/login'
return
}
setSetupAvailable(true)
setChecking(false)
})
.catch(() => {
setError('Failed to check setup status')
setChecking(false)
})
}, [])
const updateProgress = useCallback((index: number, status: ProgressStep['status']) => {
setProgress((prev) => prev.map((s, i) => (i === index ? { ...s, status } : s)))
}, [])
const handleSubmit = useCallback(async (e: FormEvent) => {
e.preventDefault()
setError('')
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
if (password.length < 12) {
setError('Password must be at least 12 characters')
return
}
setStep('creating')
setProgress(INITIAL_PROGRESS)
// Step 1: Validating
updateProgress(0, 'active')
await new Promise((r) => setTimeout(r, 400))
updateProgress(0, 'done')
// Step 2: Creating account
updateProgress(1, 'active')
try {
const res = await fetch('/api/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
password,
displayName: displayName || undefined,
}),
})
if (!res.ok) {
const data = await res.json().catch(() => null)
updateProgress(1, 'error')
setError(data?.error || 'Setup failed')
// Allow retry after a brief pause
await new Promise((r) => setTimeout(r, 1500))
setStep('form')
setProgress(INITIAL_PROGRESS)
return
}
updateProgress(1, 'done')
// Step 3: Configuring session
updateProgress(2, 'active')
await new Promise((r) => setTimeout(r, 500))
updateProgress(2, 'done')
// Step 4: Launching
updateProgress(3, 'active')
await new Promise((r) => setTimeout(r, 300))
updateProgress(3, 'done')
await new Promise((r) => setTimeout(r, 500))
window.location.href = '/'
} catch {
updateProgress(1, 'error')
setError('Network error')
await new Promise((r) => setTimeout(r, 1500))
setStep('form')
setProgress(INITIAL_PROGRESS)
}
}, [username, password, confirmPassword, displayName, updateProgress])
if (checking) {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="w-4 h-4 border-2 border-muted-foreground/30 border-t-muted-foreground rounded-full animate-spin" />
Checking setup status...
</div>
</div>
)
}
if (!setupAvailable) {
return null
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8">
<div className="w-12 h-12 rounded-lg overflow-hidden bg-background border border-border/50 flex items-center justify-center mb-3">
<Image
src="/brand/mc-logo-128.png"
alt="Mission Control logo"
width={48}
height={48}
className="h-full w-full object-cover"
priority
/>
</div>
<h1 className="text-xl font-semibold text-foreground">
{step === 'form' ? 'Welcome to Mission Control' : 'Setting up Mission Control'}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{step === 'form'
? 'Create your admin account to get started'
: 'Creating your admin account...'}
</p>
</div>
{step === 'creating' && (
<div className="mb-6 p-4 rounded-lg bg-secondary/50 border border-border">
<ProgressIndicator steps={progress} />
{error && (
<div role="alert" className="mt-3 p-2 rounded bg-destructive/10 border border-destructive/20 text-sm text-destructive">
{error}
</div>
)}
</div>
)}
{step === 'form' && (
<>
{error && (
<div role="alert" className="mb-4 p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-sm text-destructive">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1.5">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full h-10 px-3 rounded-lg bg-secondary border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-smooth"
placeholder="admin"
autoComplete="username"
autoFocus
required
minLength={2}
maxLength={64}
pattern="[a-z0-9_.\-]+"
title="Lowercase letters, numbers, dots, hyphens, and underscores only"
/>
</div>
<div>
<label htmlFor="displayName" className="block text-sm font-medium text-foreground mb-1.5">
Display Name <span className="text-muted-foreground font-normal">(optional)</span>
</label>
<input
id="displayName"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full h-10 px-3 rounded-lg bg-secondary border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-smooth"
placeholder="Admin"
maxLength={100}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1.5">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full h-10 px-3 rounded-lg bg-secondary border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-smooth"
placeholder="At least 12 characters"
autoComplete="new-password"
required
minLength={12}
/>
{password.length > 0 && password.length < 12 && (
<p className="mt-1 text-xs text-amber-400">
{12 - password.length} more character{12 - password.length !== 1 ? 's' : ''} needed
</p>
)}
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-foreground mb-1.5">
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full h-10 px-3 rounded-lg bg-secondary border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-smooth"
placeholder="Repeat your password"
autoComplete="new-password"
required
minLength={12}
/>
{confirmPassword.length > 0 && password !== confirmPassword && (
<p className="mt-1 text-xs text-destructive">Passwords do not match</p>
)}
</div>
<Button
type="submit"
size="lg"
className="w-full rounded-lg"
>
Create Admin Account
</Button>
</form>
<p className="text-center text-xs text-muted-foreground mt-6">
This page is only available during first-time setup.
</p>
</>
)}
</div>
</div>
)
}

106
src/lib/auto-credentials.ts Normal file
View File

@ -0,0 +1,106 @@
import { randomBytes } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { config, ensureDirExists } from './config'
import { logger } from './logger'
function getGeneratedFilePath(): string {
return path.join(config.dataDir, '.auto-generated')
}
interface PersistedValues {
AUTH_SECRET?: string
API_KEY?: string
}
function readPersisted(): PersistedValues {
try {
if (!fs.existsSync(getGeneratedFilePath())) return {}
const raw = fs.readFileSync(getGeneratedFilePath(), 'utf8')
const values: PersistedValues = {}
for (const line of raw.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eqIdx = trimmed.indexOf('=')
if (eqIdx < 0) continue
const key = trimmed.slice(0, eqIdx).trim()
const value = trimmed.slice(eqIdx + 1).trim()
if (key === 'AUTH_SECRET' || key === 'API_KEY') {
values[key] = value
}
}
return values
} catch {
return {}
}
}
function writePersisted(values: PersistedValues): void {
try {
ensureDirExists(config.dataDir)
const lines = [
'# Auto-generated values. Overridden by env vars when set.',
]
if (values.AUTH_SECRET) lines.push(`AUTH_SECRET=${values.AUTH_SECRET}`)
if (values.API_KEY) lines.push(`API_KEY=${values.API_KEY}`)
fs.writeFileSync(getGeneratedFilePath(), lines.join('\n') + '\n', { mode: 0o600 })
} catch (err) {
logger.warn({ err }, 'Failed to persist auto-generated values')
}
}
function generate(): string {
return randomBytes(32).toString('hex')
}
// Known placeholder values from .env.example that should be replaced
const PLACEHOLDER_AUTH_SECRETS = new Set([
'random-secret-for-legacy-cookies',
])
const PLACEHOLDER_API_KEYS = new Set([
'generate-a-random-key',
])
/**
* Ensure AUTH_SECRET and API_KEY are available.
* Priority: env var > persisted file > auto-generate + persist.
* Sets process.env so downstream code picks them up.
*/
export function ensureAutoGeneratedCredentials(): void {
if (process.env.NEXT_PHASE === 'phase-production-build') return
const persisted = readPersisted()
let dirty = false
// AUTH_SECRET
const currentAuthSecret = (process.env.AUTH_SECRET || '').trim()
if (!currentAuthSecret || PLACEHOLDER_AUTH_SECRETS.has(currentAuthSecret)) {
if (persisted.AUTH_SECRET) {
process.env.AUTH_SECRET = persisted.AUTH_SECRET
} else {
const val = generate()
process.env.AUTH_SECRET = val
persisted.AUTH_SECRET = val
dirty = true
logger.info('Auto-generated AUTH_SECRET (persisted to .data/.auto-generated)')
}
}
// API_KEY
const currentApiKey = (process.env.API_KEY || '').trim()
if (!currentApiKey || PLACEHOLDER_API_KEYS.has(currentApiKey)) {
if (persisted.API_KEY) {
process.env.API_KEY = persisted.API_KEY
} else {
const val = generate()
process.env.API_KEY = val
persisted.API_KEY = val
dirty = true
logger.info('Auto-generated API_KEY (persisted to .data/.auto-generated)')
}
}
if (dirty) {
writePersisted(persisted)
}
}

View File

@ -6,6 +6,7 @@ import { eventBus } from './event-bus';
import { hashPassword } from './password';
import { logger } from './logger';
import { parseMentions as parseMentionTokens } from './mentions';
import { ensureAutoGeneratedCredentials } from './auto-credentials';
// Database file location
const DB_PATH = config.dbPath;
@ -20,6 +21,7 @@ const isTestMode = process.env.MISSION_CONTROL_TEST_MODE === '1'
*/
export function getDatabase(): Database.Database {
if (!db) {
ensureAutoGeneratedCredentials();
ensureDirExists(dirname(DB_PATH));
db = new Database(DB_PATH);
@ -126,9 +128,10 @@ function seedAdminUserFromEnv(dbConn: Database.Database): void {
const password = resolveSeedAuthPassword()
if (!password) {
logger.warn(
'AUTH_PASS is not set — skipping admin user seeding. ' +
'Set AUTH_PASS (quote values containing #) or AUTH_PASS_B64 in your environment.'
// No AUTH_PASS set — admin will be created via /setup web wizard instead
logger.info(
'AUTH_PASS is not set — admin account will be created via /setup. ' +
'Set AUTH_PASS or AUTH_PASS_B64 to seed an admin from env (useful for CI/automation).'
)
return
}
@ -309,6 +312,20 @@ export interface ProvisionEvent {
created_at: number
}
/**
* Returns true when the database has zero users i.e. first-time setup is needed.
* Safe to call during normal operation (fast single-row query).
*/
export function needsFirstTimeSetup(): boolean {
try {
const database = getDatabase()
const row = database.prepare('SELECT COUNT(*) as count FROM users').get() as CountRow
return row.count === 0
} catch {
return false
}
}
// Database helper functions
export const db_helpers = {
/**

View File

@ -171,9 +171,9 @@ export function proxy(request: NextRequest) {
}
}
// Allow login page, auth API, docs, and container health probe without session
// Allow login, setup, auth API, docs, and container health probe without session
const isPublicHealthProbe = pathname === '/api/status' && request.nextUrl.searchParams.get('action') === 'health'
if (pathname === '/login' || pathname.startsWith('/api/auth/') || pathname === '/api/docs' || pathname === '/docs' || isPublicHealthProbe) {
if (pathname === '/login' || pathname === '/setup' || pathname.startsWith('/api/auth/') || pathname === '/api/setup' || pathname === '/api/docs' || pathname === '/docs' || isPublicHealthProbe) {
const { response, nonce } = nextResponseWithNonce(request)
return addSecurityHeaders(response, request, nonce)
}