Nyk commited on
Commit
943fe08
·
1 Parent(s): 58ba0af

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

Browse files

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

.env.example CHANGED
@@ -3,15 +3,16 @@
3
  # PORT=3000
4
 
5
  # === Authentication ===
6
- # Admin user seeded on first run (only if no users exist in DB)
7
- AUTH_USER=admin
8
- AUTH_PASS=change-me-on-first-login
9
- # If your password includes "#" and you do not want to quote AUTH_PASS, use base64:
10
- # AUTH_PASS_B64=Y2hhbmdlLW1lLW9uLWZpcnN0LWxvZ2lu
11
  # Example: echo -n 'my#password' | base64
12
 
13
  # API key for headless/external access (x-api-key header)
14
- API_KEY=generate-a-random-key
 
15
 
16
  # Primary gateway defaults (used by /api/gateways seeding if DB is empty)
17
  MC_DEFAULT_GATEWAY_NAME=primary
@@ -48,7 +49,8 @@ GOOGLE_CLIENT_ID=
48
  NEXT_PUBLIC_GOOGLE_CLIENT_ID=
49
 
50
  # Legacy cookie auth (backward compat, can be removed once all clients use session auth)
51
- AUTH_SECRET=random-secret-for-legacy-cookies
 
52
 
53
  # Coordinator identity (used for coordinator chat status replies and comms UI)
54
  MC_COORDINATOR_AGENT=coordinator
 
3
  # PORT=3000
4
 
5
  # === Authentication ===
6
+ # On first run, visit http://localhost:3000/setup to create your admin account.
7
+ # Alternatively, set AUTH_USER/AUTH_PASS to seed an admin from env (useful for CI/automation).
8
+ # AUTH_USER=admin
9
+ # AUTH_PASS=your-strong-password-here
10
+ # If your password includes "#", use base64: AUTH_PASS_B64=<base64-encoded-password>
11
  # Example: echo -n 'my#password' | base64
12
 
13
  # API key for headless/external access (x-api-key header)
14
+ # Auto-generated on first run if not set. Persisted to .data/.auto-generated.
15
+ # API_KEY=
16
 
17
  # Primary gateway defaults (used by /api/gateways seeding if DB is empty)
18
  MC_DEFAULT_GATEWAY_NAME=primary
 
49
  NEXT_PUBLIC_GOOGLE_CLIENT_ID=
50
 
51
  # Legacy cookie auth (backward compat, can be removed once all clients use session auth)
52
+ # Auto-generated on first run if not set. Persisted to .data/.auto-generated.
53
+ # AUTH_SECRET=
54
 
55
  # Coordinator identity (used for coordinator chat status replies and comms UI)
56
  MC_COORDINATOR_AGENT=coordinator
.gitignore CHANGED
@@ -42,6 +42,6 @@ playwright-report/
42
  /e2e-debug-*.png
43
  /e2e-channels-*.png
44
 
45
- # Claude Code context files
46
- CLAUDE.md
47
  **/CLAUDE.md
 
 
42
  /e2e-debug-*.png
43
  /e2e-channels-*.png
44
 
45
+ # Claude Code context files (root CLAUDE.md is committed for AI agent discovery)
 
46
  **/CLAUDE.md
47
+ !/CLAUDE.md
CLAUDE.md ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Mission Control
2
+
3
+ Open-source dashboard for AI agent orchestration. Manage agent fleets, track tasks, monitor costs, and orchestrate workflows.
4
+
5
+ **Stack**: Next.js 16, React 19, TypeScript 5, SQLite (better-sqlite3), Tailwind CSS 3, Zustand, pnpm
6
+
7
+ ## Prerequisites
8
+
9
+ - Node.js >= 22 (LTS recommended; 24.x also supported)
10
+ - pnpm (`corepack enable` to auto-install)
11
+
12
+ ## Setup
13
+
14
+ ```bash
15
+ pnpm install
16
+ pnpm build
17
+ ```
18
+
19
+ Secrets (AUTH_SECRET, API_KEY) auto-generate on first run if not set.
20
+ Visit `http://localhost:3000/setup` to create an admin account, or set `AUTH_USER`/`AUTH_PASS` in `.env` for headless/CI seeding.
21
+
22
+ ## Run
23
+
24
+ ```bash
25
+ pnpm dev # development (localhost:3000)
26
+ pnpm start # production
27
+ node .next/standalone/server.js # standalone mode (after build)
28
+ ```
29
+
30
+ ## Docker
31
+
32
+ ```bash
33
+ docker compose up # zero-config
34
+ bash install.sh --docker # full guided setup
35
+ ```
36
+
37
+ Production hardening: `docker compose -f docker-compose.yml -f docker-compose.hardened.yml up -d`
38
+
39
+ ## Tests
40
+
41
+ ```bash
42
+ pnpm test # unit tests (vitest)
43
+ pnpm test:e2e # end-to-end (playwright)
44
+ pnpm typecheck # tsc --noEmit
45
+ pnpm lint # eslint
46
+ pnpm test:all # lint + typecheck + test + build + e2e
47
+ ```
48
+
49
+ ## Key Directories
50
+
51
+ ```
52
+ src/app/ Next.js pages + API routes (App Router)
53
+ src/components/ UI panels and shared components
54
+ src/lib/ Core logic, database, utilities
55
+ .data/ SQLite database + runtime state (gitignored)
56
+ scripts/ Install, deploy, diagnostics scripts
57
+ docs/ Documentation and guides
58
+ ```
59
+
60
+ Path alias: `@/*` maps to `./src/*`
61
+
62
+ ## Data Directory
63
+
64
+ Set `MISSION_CONTROL_DATA_DIR` env var to change the data location (defaults to `.data/`).
65
+ Database path: `MISSION_CONTROL_DB_PATH` (defaults to `.data/mission-control.db`).
66
+
67
+ ## Conventions
68
+
69
+ - **Commits**: Conventional Commits (`feat:`, `fix:`, `docs:`, `test:`, `refactor:`, `chore:`)
70
+ - **No AI attribution**: Never add `Co-Authored-By` or similar trailers to commits
71
+ - **Package manager**: pnpm only (no npm/yarn)
72
+ - **Icons**: No icon libraries -- use raw text/emoji in components
73
+ - **Standalone output**: `next.config.js` sets `output: 'standalone'`
74
+
75
+ ## Common Pitfalls
76
+
77
+ - **Standalone mode**: Use `node .next/standalone/server.js`, not `pnpm start` (which requires full `node_modules`)
78
+ - **better-sqlite3**: Native addon -- needs rebuild when switching Node versions (`pnpm rebuild better-sqlite3`)
79
+ - **AUTH_PASS with `#`**: Quote it (`AUTH_PASS="my#pass"`) or use `AUTH_PASS_B64` (base64-encoded)
80
+ - **Gateway optional**: Set `NEXT_PUBLIC_GATEWAY_OPTIONAL=true` for standalone deployments without gateway connectivity
Dockerfile CHANGED
@@ -38,10 +38,12 @@ COPY --from=build /app/src/lib/schema.sql ./src/lib/schema.sql
38
  # Create data directory with correct ownership for SQLite
39
  RUN mkdir -p .data && chown nextjs:nodejs .data
40
  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
 
 
41
  USER nextjs
42
  ENV PORT=3000
43
  EXPOSE 3000
44
  ENV HOSTNAME=0.0.0.0
45
  HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
46
  CMD ["node", "/app/healthcheck.js"]
47
- CMD ["node", "server.js"]
 
38
  # Create data directory with correct ownership for SQLite
39
  RUN mkdir -p .data && chown nextjs:nodejs .data
40
  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
41
+ COPY docker-entrypoint.sh /app/docker-entrypoint.sh
42
+ RUN chmod +x /app/docker-entrypoint.sh
43
  USER nextjs
44
  ENV PORT=3000
45
  EXPOSE 3000
46
  ENV HOSTNAME=0.0.0.0
47
  HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
48
  CMD ["node", "/app/healthcheck.js"]
49
+ ENTRYPOINT ["/app/docker-entrypoint.sh"]
README.md CHANGED
@@ -44,7 +44,7 @@ cd mission-control
44
  bash install.sh --docker
45
  ```
46
 
47
- 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.
48
 
49
  ### One-Command Install (Local)
50
 
@@ -66,12 +66,12 @@ git clone https://github.com/builderz-labs/mission-control.git
66
  cd mission-control
67
  nvm use 22 # or: nvm use 24
68
  pnpm install
69
- cp .env.example .env # edit with your values
70
- pnpm dev # http://localhost:3000
71
  ```
72
 
73
- Initial login is seeded from `AUTH_USER` / `AUTH_PASS` on first run.
74
- If `AUTH_PASS` contains `#`, quote it (e.g. `AUTH_PASS="my#password"`) or use `AUTH_PASS_B64`.
 
75
 
76
  ## Gateway Optional Mode (Standalone Deployment)
77
 
@@ -99,6 +99,14 @@ Requires active gateway:
99
 
100
  For production VPS setups, you can also proxy gateway WebSockets over 443. See `docs/deployment.md`.
101
 
 
 
 
 
 
 
 
 
102
  ### Docker Hardening (Production)
103
 
104
  For production deployments, use the hardened compose overlay:
 
44
  bash install.sh --docker
45
  ```
46
 
47
+ 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.
48
 
49
  ### One-Command Install (Local)
50
 
 
66
  cd mission-control
67
  nvm use 22 # or: nvm use 24
68
  pnpm install
69
+ pnpm dev # http://localhost:3000/setup
 
70
  ```
71
 
72
+ 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/`.
73
+
74
+ For CI/automation, set `AUTH_USER` and `AUTH_PASS` env vars to seed the admin from environment instead.
75
 
76
  ## Gateway Optional Mode (Standalone Deployment)
77
 
 
99
 
100
  For production VPS setups, you can also proxy gateway WebSockets over 443. See `docs/deployment.md`.
101
 
102
+ ### Docker Zero-Config
103
+
104
+ ```bash
105
+ docker compose up
106
+ ```
107
+
108
+ 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.
109
+
110
  ### Docker Hardening (Production)
111
 
112
  For production deployments, use the hardened compose overlay:
docker-entrypoint.sh ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ set -e
3
+
4
+ # --- Source .env if present ---
5
+ if [ -f /app/.env ]; then
6
+ printf '[entrypoint] Loading .env\n'
7
+ set -a
8
+ . /app/.env
9
+ set +a
10
+ fi
11
+
12
+ # --- Helper: generate a random hex secret ---
13
+ generate_secret() {
14
+ if command -v openssl >/dev/null 2>&1; then
15
+ openssl rand -hex 32
16
+ else
17
+ head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n'
18
+ fi
19
+ }
20
+
21
+ SECRETS_FILE="/app/.data/.generated-secrets"
22
+
23
+ # Load previously generated secrets if they exist
24
+ if [ -f "$SECRETS_FILE" ]; then
25
+ printf '[entrypoint] Loading persisted secrets from .data\n'
26
+ set -a
27
+ . "$SECRETS_FILE"
28
+ set +a
29
+ fi
30
+
31
+ # --- AUTH_SECRET ---
32
+ if [ -z "$AUTH_SECRET" ] || [ "$AUTH_SECRET" = "random-secret-for-legacy-cookies" ]; then
33
+ AUTH_SECRET=$(generate_secret)
34
+ printf '[entrypoint] Generated new AUTH_SECRET\n'
35
+ printf 'AUTH_SECRET=%s\n' "$AUTH_SECRET" >> "$SECRETS_FILE"
36
+ export AUTH_SECRET
37
+ fi
38
+
39
+ # --- API_KEY ---
40
+ if [ -z "$API_KEY" ] || [ "$API_KEY" = "generate-a-random-key" ]; then
41
+ API_KEY=$(generate_secret)
42
+ printf '[entrypoint] Generated new API_KEY\n'
43
+ printf 'API_KEY=%s\n' "$API_KEY" >> "$SECRETS_FILE"
44
+ export API_KEY
45
+ fi
46
+
47
+ printf '[entrypoint] Starting server\n'
48
+ exec node server.js
src/app/api/auth/login/route.ts CHANGED
@@ -1,6 +1,6 @@
1
  import { NextResponse } from 'next/server'
2
  import { authenticateUser, createSession } from '@/lib/auth'
3
- import { logAuditEvent } from '@/lib/db'
4
  import { getMcSessionCookieName, getMcSessionCookieOptions, isRequestSecure } from '@/lib/session-cookie'
5
  import { loginLimiter } from '@/lib/rate-limit'
6
  import { logger } from '@/lib/logger'
@@ -22,6 +22,19 @@ export async function POST(request: Request) {
22
  const user = authenticateUser(username, password)
23
  if (!user) {
24
  logAuditEvent({ action: 'login_failed', actor: username, ip_address: ipAddress, user_agent: userAgent })
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
26
  }
27
 
 
1
  import { NextResponse } from 'next/server'
2
  import { authenticateUser, createSession } from '@/lib/auth'
3
+ import { logAuditEvent, needsFirstTimeSetup } from '@/lib/db'
4
  import { getMcSessionCookieName, getMcSessionCookieOptions, isRequestSecure } from '@/lib/session-cookie'
5
  import { loginLimiter } from '@/lib/rate-limit'
6
  import { logger } from '@/lib/logger'
 
22
  const user = authenticateUser(username, password)
23
  if (!user) {
24
  logAuditEvent({ action: 'login_failed', actor: username, ip_address: ipAddress, user_agent: userAgent })
25
+
26
+ // When no users exist at all, give actionable feedback instead of "Invalid credentials"
27
+ if (needsFirstTimeSetup()) {
28
+ return NextResponse.json(
29
+ {
30
+ error: 'No admin account has been created yet',
31
+ code: 'NO_USERS',
32
+ hint: 'Visit /setup to create your admin account',
33
+ },
34
+ { status: 401 }
35
+ )
36
+ }
37
+
38
  return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
39
  }
40
 
src/app/api/setup/route.ts ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from 'next/server'
2
+ import { needsFirstTimeSetup } from '@/lib/db'
3
+ import { createUser, createSession } from '@/lib/auth'
4
+ import { logAuditEvent } from '@/lib/db'
5
+ import { getMcSessionCookieName, getMcSessionCookieOptions, isRequestSecure } from '@/lib/session-cookie'
6
+ import { logger } from '@/lib/logger'
7
+
8
+ const INSECURE_PASSWORDS = new Set([
9
+ 'admin',
10
+ 'password',
11
+ 'change-me-on-first-login',
12
+ 'changeme',
13
+ 'testpass123',
14
+ ])
15
+
16
+ export async function GET() {
17
+ return NextResponse.json({ needsSetup: needsFirstTimeSetup() })
18
+ }
19
+
20
+ export async function POST(request: Request) {
21
+ try {
22
+ // Only allow setup when no users exist
23
+ if (!needsFirstTimeSetup()) {
24
+ return NextResponse.json(
25
+ { error: 'Setup has already been completed' },
26
+ { status: 403 }
27
+ )
28
+ }
29
+
30
+ const body = await request.json()
31
+ const { username, password, displayName } = body as {
32
+ username?: string
33
+ password?: string
34
+ displayName?: string
35
+ }
36
+
37
+ // Validate username
38
+ if (!username || typeof username !== 'string') {
39
+ return NextResponse.json({ error: 'Username is required' }, { status: 400 })
40
+ }
41
+ const trimmedUsername = username.trim().toLowerCase()
42
+ if (trimmedUsername.length < 2 || trimmedUsername.length > 64) {
43
+ return NextResponse.json({ error: 'Username must be 2-64 characters' }, { status: 400 })
44
+ }
45
+ if (!/^[a-z0-9_.-]+$/.test(trimmedUsername)) {
46
+ return NextResponse.json(
47
+ { error: 'Username can only contain lowercase letters, numbers, dots, hyphens, and underscores' },
48
+ { status: 400 }
49
+ )
50
+ }
51
+
52
+ // Validate password
53
+ if (!password || typeof password !== 'string') {
54
+ return NextResponse.json({ error: 'Password is required' }, { status: 400 })
55
+ }
56
+ if (password.length < 12) {
57
+ return NextResponse.json({ error: 'Password must be at least 12 characters' }, { status: 400 })
58
+ }
59
+ if (INSECURE_PASSWORDS.has(password)) {
60
+ return NextResponse.json({ error: 'That password is too common. Choose a stronger one.' }, { status: 400 })
61
+ }
62
+
63
+ // Double-check no users exist (race safety — createUser will also fail on duplicate username)
64
+ if (!needsFirstTimeSetup()) {
65
+ return NextResponse.json(
66
+ { error: 'Another admin was created while you were setting up' },
67
+ { status: 409 }
68
+ )
69
+ }
70
+
71
+ const resolvedDisplayName = displayName?.trim() ||
72
+ trimmedUsername.charAt(0).toUpperCase() + trimmedUsername.slice(1)
73
+
74
+ const user = createUser(trimmedUsername, password, resolvedDisplayName, 'admin')
75
+
76
+ const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
77
+ const userAgent = request.headers.get('user-agent') || undefined
78
+
79
+ logAuditEvent({
80
+ action: 'setup_admin_created',
81
+ actor: user.username,
82
+ actor_id: user.id,
83
+ ip_address: ipAddress,
84
+ user_agent: userAgent,
85
+ })
86
+
87
+ logger.info(`First-time setup: admin user "${user.username}" created`)
88
+
89
+ // Auto-login: create session and set cookie
90
+ const { token, expiresAt } = createSession(user.id, ipAddress, userAgent, user.workspace_id)
91
+
92
+ const response = NextResponse.json({
93
+ user: {
94
+ id: user.id,
95
+ username: user.username,
96
+ display_name: user.display_name,
97
+ role: user.role,
98
+ },
99
+ })
100
+
101
+ const isSecureRequest = isRequestSecure(request)
102
+ const cookieName = getMcSessionCookieName(isSecureRequest)
103
+ response.cookies.set(cookieName, token, {
104
+ ...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000), isSecureRequest }),
105
+ })
106
+
107
+ return response
108
+ } catch (error) {
109
+ logger.error({ err: error }, 'Setup error')
110
+ return NextResponse.json({ error: 'Failed to create admin account' }, { status: 500 })
111
+ }
112
+ }
src/app/login/page.tsx CHANGED
@@ -29,6 +29,7 @@ type LoginRequestBody =
29
  type LoginErrorPayload = {
30
  code?: string
31
  error?: string
 
32
  }
33
 
34
  function readLoginErrorPayload(value: unknown): LoginErrorPayload {
@@ -37,6 +38,7 @@ function readLoginErrorPayload(value: unknown): LoginErrorPayload {
37
  return {
38
  code: typeof record.code === 'string' ? record.code : undefined,
39
  error: typeof record.error === 'string' ? record.error : undefined,
 
40
  }
41
  }
42
 
@@ -62,6 +64,7 @@ export default function LoginPage() {
62
  const [password, setPassword] = useState('')
63
  const [error, setError] = useState('')
64
  const [pendingApproval, setPendingApproval] = useState(false)
 
65
  const [loading, setLoading] = useState(false)
66
  const [googleLoading, setGoogleLoading] = useState(false)
67
  const [googleReady, setGoogleReady] = useState(false)
@@ -69,6 +72,20 @@ export default function LoginPage() {
69
 
70
  const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ''
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  const completeLogin = useCallback(async (path: string, body: LoginRequestBody) => {
73
  const res = await fetch(path, {
74
  method: 'POST',
@@ -80,6 +97,14 @@ export default function LoginPage() {
80
  const data = readLoginErrorPayload(await res.json().catch(() => null))
81
  if (data.code === 'PENDING_APPROVAL') {
82
  setPendingApproval(true)
 
 
 
 
 
 
 
 
83
  setError('')
84
  setLoading(false)
85
  setGoogleLoading(false)
@@ -87,6 +112,7 @@ export default function LoginPage() {
87
  }
88
  setError(data.error || 'Login failed')
89
  setPendingApproval(false)
 
90
  setLoading(false)
91
  setGoogleLoading(false)
92
  return false
@@ -202,6 +228,29 @@ export default function LoginPage() {
202
  </div>
203
  )}
204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  {error && (
206
  <div role="alert" className="mb-4 p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-sm text-destructive">
207
  {error}
 
29
  type LoginErrorPayload = {
30
  code?: string
31
  error?: string
32
+ hint?: string
33
  }
34
 
35
  function readLoginErrorPayload(value: unknown): LoginErrorPayload {
 
38
  return {
39
  code: typeof record.code === 'string' ? record.code : undefined,
40
  error: typeof record.error === 'string' ? record.error : undefined,
41
+ hint: typeof record.hint === 'string' ? record.hint : undefined,
42
  }
43
  }
44
 
 
64
  const [password, setPassword] = useState('')
65
  const [error, setError] = useState('')
66
  const [pendingApproval, setPendingApproval] = useState(false)
67
+ const [needsSetup, setNeedsSetup] = useState(false)
68
  const [loading, setLoading] = useState(false)
69
  const [googleLoading, setGoogleLoading] = useState(false)
70
  const [googleReady, setGoogleReady] = useState(false)
 
72
 
73
  const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ''
74
 
75
+ // Check if first-time setup is needed on page load — auto-redirect to /setup
76
+ useEffect(() => {
77
+ fetch('/api/setup')
78
+ .then((res) => res.json())
79
+ .then((data) => {
80
+ if (data.needsSetup) {
81
+ window.location.href = '/setup'
82
+ }
83
+ })
84
+ .catch(() => {
85
+ // Ignore — setup check is best-effort
86
+ })
87
+ }, [])
88
+
89
  const completeLogin = useCallback(async (path: string, body: LoginRequestBody) => {
90
  const res = await fetch(path, {
91
  method: 'POST',
 
97
  const data = readLoginErrorPayload(await res.json().catch(() => null))
98
  if (data.code === 'PENDING_APPROVAL') {
99
  setPendingApproval(true)
100
+ setNeedsSetup(false)
101
+ setError('')
102
+ setLoading(false)
103
+ setGoogleLoading(false)
104
+ return false
105
+ }
106
+ if (data.code === 'NO_USERS') {
107
+ setNeedsSetup(true)
108
  setError('')
109
  setLoading(false)
110
  setGoogleLoading(false)
 
112
  }
113
  setError(data.error || 'Login failed')
114
  setPendingApproval(false)
115
+ setNeedsSetup(false)
116
  setLoading(false)
117
  setGoogleLoading(false)
118
  return false
 
228
  </div>
229
  )}
230
 
231
+ {needsSetup && (
232
+ <div className="mb-4 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20 text-center">
233
+ <div className="flex justify-center mb-2">
234
+ <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">
235
+ <circle cx="12" cy="12" r="10" />
236
+ <line x1="12" y1="8" x2="12" y2="12" />
237
+ <line x1="12" y1="16" x2="12.01" y2="16" />
238
+ </svg>
239
+ </div>
240
+ <div className="text-sm font-medium text-blue-200">No admin account created yet</div>
241
+ <p className="text-xs text-muted-foreground mt-1">
242
+ Set up your admin account to get started with Mission Control.
243
+ </p>
244
+ <Button
245
+ onClick={() => { window.location.href = '/setup' }}
246
+ size="sm"
247
+ className="mt-3"
248
+ >
249
+ Create Admin Account
250
+ </Button>
251
+ </div>
252
+ )}
253
+
254
  {error && (
255
  <div role="alert" className="mb-4 p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-sm text-destructive">
256
  {error}
src/app/setup/page.tsx ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useState, type FormEvent } from 'react'
4
+ import Image from 'next/image'
5
+ import { Button } from '@/components/ui/button'
6
+
7
+ type SetupStep = 'form' | 'creating'
8
+
9
+ interface ProgressStep {
10
+ label: string
11
+ status: 'pending' | 'active' | 'done' | 'error'
12
+ }
13
+
14
+ const INITIAL_PROGRESS: ProgressStep[] = [
15
+ { label: 'Validating credentials', status: 'pending' },
16
+ { label: 'Creating admin account', status: 'pending' },
17
+ { label: 'Configuring session', status: 'pending' },
18
+ { label: 'Launching dashboard', status: 'pending' },
19
+ ]
20
+
21
+ function ProgressIndicator({ steps }: { steps: ProgressStep[] }) {
22
+ return (
23
+ <div className="space-y-3">
24
+ {steps.map((step, i) => (
25
+ <div key={i} className="flex items-center gap-3">
26
+ <div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
27
+ {step.status === 'done' && (
28
+ <svg className="w-5 h-5 text-green-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
29
+ <path d="M20 6L9 17l-5-5" />
30
+ </svg>
31
+ )}
32
+ {step.status === 'active' && (
33
+ <div className="w-4 h-4 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
34
+ )}
35
+ {step.status === 'pending' && (
36
+ <div className="w-2 h-2 rounded-full bg-muted-foreground/30" />
37
+ )}
38
+ {step.status === 'error' && (
39
+ <svg className="w-5 h-5 text-destructive" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
40
+ <line x1="18" y1="6" x2="6" y2="18" />
41
+ <line x1="6" y1="6" x2="18" y2="18" />
42
+ </svg>
43
+ )}
44
+ </div>
45
+ <span className={`text-sm ${
46
+ step.status === 'active' ? 'text-foreground font-medium' :
47
+ step.status === 'done' ? 'text-green-400' :
48
+ step.status === 'error' ? 'text-destructive' :
49
+ 'text-muted-foreground'
50
+ }`}>
51
+ {step.label}
52
+ </span>
53
+ </div>
54
+ ))}
55
+ </div>
56
+ )
57
+ }
58
+
59
+ export default function SetupPage() {
60
+ const [username, setUsername] = useState('admin')
61
+ const [password, setPassword] = useState('')
62
+ const [confirmPassword, setConfirmPassword] = useState('')
63
+ const [displayName, setDisplayName] = useState('')
64
+ const [error, setError] = useState('')
65
+ const [step, setStep] = useState<SetupStep>('form')
66
+ const [progress, setProgress] = useState<ProgressStep[]>(INITIAL_PROGRESS)
67
+ const [checking, setChecking] = useState(true)
68
+ const [setupAvailable, setSetupAvailable] = useState(false)
69
+
70
+ useEffect(() => {
71
+ fetch('/api/setup')
72
+ .then((res) => res.json())
73
+ .then((data) => {
74
+ if (!data.needsSetup) {
75
+ window.location.href = '/login'
76
+ return
77
+ }
78
+ setSetupAvailable(true)
79
+ setChecking(false)
80
+ })
81
+ .catch(() => {
82
+ setError('Failed to check setup status')
83
+ setChecking(false)
84
+ })
85
+ }, [])
86
+
87
+ const updateProgress = useCallback((index: number, status: ProgressStep['status']) => {
88
+ setProgress((prev) => prev.map((s, i) => (i === index ? { ...s, status } : s)))
89
+ }, [])
90
+
91
+ const handleSubmit = useCallback(async (e: FormEvent) => {
92
+ e.preventDefault()
93
+ setError('')
94
+
95
+ if (password !== confirmPassword) {
96
+ setError('Passwords do not match')
97
+ return
98
+ }
99
+ if (password.length < 12) {
100
+ setError('Password must be at least 12 characters')
101
+ return
102
+ }
103
+
104
+ setStep('creating')
105
+ setProgress(INITIAL_PROGRESS)
106
+
107
+ // Step 1: Validating
108
+ updateProgress(0, 'active')
109
+ await new Promise((r) => setTimeout(r, 400))
110
+ updateProgress(0, 'done')
111
+
112
+ // Step 2: Creating account
113
+ updateProgress(1, 'active')
114
+ try {
115
+ const res = await fetch('/api/setup', {
116
+ method: 'POST',
117
+ headers: { 'Content-Type': 'application/json' },
118
+ body: JSON.stringify({
119
+ username,
120
+ password,
121
+ displayName: displayName || undefined,
122
+ }),
123
+ })
124
+
125
+ if (!res.ok) {
126
+ const data = await res.json().catch(() => null)
127
+ updateProgress(1, 'error')
128
+ setError(data?.error || 'Setup failed')
129
+ // Allow retry after a brief pause
130
+ await new Promise((r) => setTimeout(r, 1500))
131
+ setStep('form')
132
+ setProgress(INITIAL_PROGRESS)
133
+ return
134
+ }
135
+
136
+ updateProgress(1, 'done')
137
+
138
+ // Step 3: Configuring session
139
+ updateProgress(2, 'active')
140
+ await new Promise((r) => setTimeout(r, 500))
141
+ updateProgress(2, 'done')
142
+
143
+ // Step 4: Launching
144
+ updateProgress(3, 'active')
145
+ await new Promise((r) => setTimeout(r, 300))
146
+ updateProgress(3, 'done')
147
+
148
+ await new Promise((r) => setTimeout(r, 500))
149
+ window.location.href = '/'
150
+ } catch {
151
+ updateProgress(1, 'error')
152
+ setError('Network error')
153
+ await new Promise((r) => setTimeout(r, 1500))
154
+ setStep('form')
155
+ setProgress(INITIAL_PROGRESS)
156
+ }
157
+ }, [username, password, confirmPassword, displayName, updateProgress])
158
+
159
+ if (checking) {
160
+ return (
161
+ <div className="min-h-screen flex items-center justify-center bg-background p-4">
162
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
163
+ <div className="w-4 h-4 border-2 border-muted-foreground/30 border-t-muted-foreground rounded-full animate-spin" />
164
+ Checking setup status...
165
+ </div>
166
+ </div>
167
+ )
168
+ }
169
+
170
+ if (!setupAvailable) {
171
+ return null
172
+ }
173
+
174
+ return (
175
+ <div className="min-h-screen flex items-center justify-center bg-background p-4">
176
+ <div className="w-full max-w-sm">
177
+ <div className="flex flex-col items-center mb-8">
178
+ <div className="w-12 h-12 rounded-lg overflow-hidden bg-background border border-border/50 flex items-center justify-center mb-3">
179
+ <Image
180
+ src="/brand/mc-logo-128.png"
181
+ alt="Mission Control logo"
182
+ width={48}
183
+ height={48}
184
+ className="h-full w-full object-cover"
185
+ priority
186
+ />
187
+ </div>
188
+ <h1 className="text-xl font-semibold text-foreground">
189
+ {step === 'form' ? 'Welcome to Mission Control' : 'Setting up Mission Control'}
190
+ </h1>
191
+ <p className="text-sm text-muted-foreground mt-1">
192
+ {step === 'form'
193
+ ? 'Create your admin account to get started'
194
+ : 'Creating your admin account...'}
195
+ </p>
196
+ </div>
197
+
198
+ {step === 'creating' && (
199
+ <div className="mb-6 p-4 rounded-lg bg-secondary/50 border border-border">
200
+ <ProgressIndicator steps={progress} />
201
+ {error && (
202
+ <div role="alert" className="mt-3 p-2 rounded bg-destructive/10 border border-destructive/20 text-sm text-destructive">
203
+ {error}
204
+ </div>
205
+ )}
206
+ </div>
207
+ )}
208
+
209
+ {step === 'form' && (
210
+ <>
211
+ {error && (
212
+ <div role="alert" className="mb-4 p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-sm text-destructive">
213
+ {error}
214
+ </div>
215
+ )}
216
+
217
+ <form onSubmit={handleSubmit} className="space-y-4">
218
+ <div>
219
+ <label htmlFor="username" className="block text-sm font-medium text-foreground mb-1.5">
220
+ Username
221
+ </label>
222
+ <input
223
+ id="username"
224
+ type="text"
225
+ value={username}
226
+ onChange={(e) => setUsername(e.target.value)}
227
+ 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"
228
+ placeholder="admin"
229
+ autoComplete="username"
230
+ autoFocus
231
+ required
232
+ minLength={2}
233
+ maxLength={64}
234
+ pattern="[a-z0-9_.\-]+"
235
+ title="Lowercase letters, numbers, dots, hyphens, and underscores only"
236
+ />
237
+ </div>
238
+
239
+ <div>
240
+ <label htmlFor="displayName" className="block text-sm font-medium text-foreground mb-1.5">
241
+ Display Name <span className="text-muted-foreground font-normal">(optional)</span>
242
+ </label>
243
+ <input
244
+ id="displayName"
245
+ type="text"
246
+ value={displayName}
247
+ onChange={(e) => setDisplayName(e.target.value)}
248
+ 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"
249
+ placeholder="Admin"
250
+ maxLength={100}
251
+ />
252
+ </div>
253
+
254
+ <div>
255
+ <label htmlFor="password" className="block text-sm font-medium text-foreground mb-1.5">
256
+ Password
257
+ </label>
258
+ <input
259
+ id="password"
260
+ type="password"
261
+ value={password}
262
+ onChange={(e) => setPassword(e.target.value)}
263
+ 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"
264
+ placeholder="At least 12 characters"
265
+ autoComplete="new-password"
266
+ required
267
+ minLength={12}
268
+ />
269
+ {password.length > 0 && password.length < 12 && (
270
+ <p className="mt-1 text-xs text-amber-400">
271
+ {12 - password.length} more character{12 - password.length !== 1 ? 's' : ''} needed
272
+ </p>
273
+ )}
274
+ </div>
275
+
276
+ <div>
277
+ <label htmlFor="confirmPassword" className="block text-sm font-medium text-foreground mb-1.5">
278
+ Confirm Password
279
+ </label>
280
+ <input
281
+ id="confirmPassword"
282
+ type="password"
283
+ value={confirmPassword}
284
+ onChange={(e) => setConfirmPassword(e.target.value)}
285
+ 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"
286
+ placeholder="Repeat your password"
287
+ autoComplete="new-password"
288
+ required
289
+ minLength={12}
290
+ />
291
+ {confirmPassword.length > 0 && password !== confirmPassword && (
292
+ <p className="mt-1 text-xs text-destructive">Passwords do not match</p>
293
+ )}
294
+ </div>
295
+
296
+ <Button
297
+ type="submit"
298
+ size="lg"
299
+ className="w-full rounded-lg"
300
+ >
301
+ Create Admin Account
302
+ </Button>
303
+ </form>
304
+
305
+ <p className="text-center text-xs text-muted-foreground mt-6">
306
+ This page is only available during first-time setup.
307
+ </p>
308
+ </>
309
+ )}
310
+ </div>
311
+ </div>
312
+ )
313
+ }
src/lib/auto-credentials.ts ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { randomBytes } from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { config, ensureDirExists } from './config'
5
+ import { logger } from './logger'
6
+
7
+ function getGeneratedFilePath(): string {
8
+ return path.join(config.dataDir, '.auto-generated')
9
+ }
10
+
11
+ interface PersistedValues {
12
+ AUTH_SECRET?: string
13
+ API_KEY?: string
14
+ }
15
+
16
+ function readPersisted(): PersistedValues {
17
+ try {
18
+ if (!fs.existsSync(getGeneratedFilePath())) return {}
19
+ const raw = fs.readFileSync(getGeneratedFilePath(), 'utf8')
20
+ const values: PersistedValues = {}
21
+ for (const line of raw.split('\n')) {
22
+ const trimmed = line.trim()
23
+ if (!trimmed || trimmed.startsWith('#')) continue
24
+ const eqIdx = trimmed.indexOf('=')
25
+ if (eqIdx < 0) continue
26
+ const key = trimmed.slice(0, eqIdx).trim()
27
+ const value = trimmed.slice(eqIdx + 1).trim()
28
+ if (key === 'AUTH_SECRET' || key === 'API_KEY') {
29
+ values[key] = value
30
+ }
31
+ }
32
+ return values
33
+ } catch {
34
+ return {}
35
+ }
36
+ }
37
+
38
+ function writePersisted(values: PersistedValues): void {
39
+ try {
40
+ ensureDirExists(config.dataDir)
41
+ const lines = [
42
+ '# Auto-generated values. Overridden by env vars when set.',
43
+ ]
44
+ if (values.AUTH_SECRET) lines.push(`AUTH_SECRET=${values.AUTH_SECRET}`)
45
+ if (values.API_KEY) lines.push(`API_KEY=${values.API_KEY}`)
46
+ fs.writeFileSync(getGeneratedFilePath(), lines.join('\n') + '\n', { mode: 0o600 })
47
+ } catch (err) {
48
+ logger.warn({ err }, 'Failed to persist auto-generated values')
49
+ }
50
+ }
51
+
52
+ function generate(): string {
53
+ return randomBytes(32).toString('hex')
54
+ }
55
+
56
+ // Known placeholder values from .env.example that should be replaced
57
+ const PLACEHOLDER_AUTH_SECRETS = new Set([
58
+ 'random-secret-for-legacy-cookies',
59
+ ])
60
+ const PLACEHOLDER_API_KEYS = new Set([
61
+ 'generate-a-random-key',
62
+ ])
63
+
64
+ /**
65
+ * Ensure AUTH_SECRET and API_KEY are available.
66
+ * Priority: env var > persisted file > auto-generate + persist.
67
+ * Sets process.env so downstream code picks them up.
68
+ */
69
+ export function ensureAutoGeneratedCredentials(): void {
70
+ if (process.env.NEXT_PHASE === 'phase-production-build') return
71
+
72
+ const persisted = readPersisted()
73
+ let dirty = false
74
+
75
+ // AUTH_SECRET
76
+ const currentAuthSecret = (process.env.AUTH_SECRET || '').trim()
77
+ if (!currentAuthSecret || PLACEHOLDER_AUTH_SECRETS.has(currentAuthSecret)) {
78
+ if (persisted.AUTH_SECRET) {
79
+ process.env.AUTH_SECRET = persisted.AUTH_SECRET
80
+ } else {
81
+ const val = generate()
82
+ process.env.AUTH_SECRET = val
83
+ persisted.AUTH_SECRET = val
84
+ dirty = true
85
+ logger.info('Auto-generated AUTH_SECRET (persisted to .data/.auto-generated)')
86
+ }
87
+ }
88
+
89
+ // API_KEY
90
+ const currentApiKey = (process.env.API_KEY || '').trim()
91
+ if (!currentApiKey || PLACEHOLDER_API_KEYS.has(currentApiKey)) {
92
+ if (persisted.API_KEY) {
93
+ process.env.API_KEY = persisted.API_KEY
94
+ } else {
95
+ const val = generate()
96
+ process.env.API_KEY = val
97
+ persisted.API_KEY = val
98
+ dirty = true
99
+ logger.info('Auto-generated API_KEY (persisted to .data/.auto-generated)')
100
+ }
101
+ }
102
+
103
+ if (dirty) {
104
+ writePersisted(persisted)
105
+ }
106
+ }
src/lib/db.ts CHANGED
@@ -6,6 +6,7 @@ import { eventBus } from './event-bus';
6
  import { hashPassword } from './password';
7
  import { logger } from './logger';
8
  import { parseMentions as parseMentionTokens } from './mentions';
 
9
 
10
  // Database file location
11
  const DB_PATH = config.dbPath;
@@ -20,6 +21,7 @@ const isTestMode = process.env.MISSION_CONTROL_TEST_MODE === '1'
20
  */
21
  export function getDatabase(): Database.Database {
22
  if (!db) {
 
23
  ensureDirExists(dirname(DB_PATH));
24
  db = new Database(DB_PATH);
25
 
@@ -126,9 +128,10 @@ function seedAdminUserFromEnv(dbConn: Database.Database): void {
126
  const password = resolveSeedAuthPassword()
127
 
128
  if (!password) {
129
- logger.warn(
130
- 'AUTH_PASS is not set — skipping admin user seeding. ' +
131
- 'Set AUTH_PASS (quote values containing #) or AUTH_PASS_B64 in your environment.'
 
132
  )
133
  return
134
  }
@@ -309,6 +312,20 @@ export interface ProvisionEvent {
309
  created_at: number
310
  }
311
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  // Database helper functions
313
  export const db_helpers = {
314
  /**
 
6
  import { hashPassword } from './password';
7
  import { logger } from './logger';
8
  import { parseMentions as parseMentionTokens } from './mentions';
9
+ import { ensureAutoGeneratedCredentials } from './auto-credentials';
10
 
11
  // Database file location
12
  const DB_PATH = config.dbPath;
 
21
  */
22
  export function getDatabase(): Database.Database {
23
  if (!db) {
24
+ ensureAutoGeneratedCredentials();
25
  ensureDirExists(dirname(DB_PATH));
26
  db = new Database(DB_PATH);
27
 
 
128
  const password = resolveSeedAuthPassword()
129
 
130
  if (!password) {
131
+ // No AUTH_PASS set — admin will be created via /setup web wizard instead
132
+ logger.info(
133
+ 'AUTH_PASS is not set admin account will be created via /setup. ' +
134
+ 'Set AUTH_PASS or AUTH_PASS_B64 to seed an admin from env (useful for CI/automation).'
135
  )
136
  return
137
  }
 
312
  created_at: number
313
  }
314
 
315
+ /**
316
+ * Returns true when the database has zero users — i.e. first-time setup is needed.
317
+ * Safe to call during normal operation (fast single-row query).
318
+ */
319
+ export function needsFirstTimeSetup(): boolean {
320
+ try {
321
+ const database = getDatabase()
322
+ const row = database.prepare('SELECT COUNT(*) as count FROM users').get() as CountRow
323
+ return row.count === 0
324
+ } catch {
325
+ return false
326
+ }
327
+ }
328
+
329
  // Database helper functions
330
  export const db_helpers = {
331
  /**
src/proxy.ts CHANGED
@@ -171,9 +171,9 @@ export function proxy(request: NextRequest) {
171
  }
172
  }
173
 
174
- // Allow login page, auth API, docs, and container health probe without session
175
  const isPublicHealthProbe = pathname === '/api/status' && request.nextUrl.searchParams.get('action') === 'health'
176
- if (pathname === '/login' || pathname.startsWith('/api/auth/') || pathname === '/api/docs' || pathname === '/docs' || isPublicHealthProbe) {
177
  const { response, nonce } = nextResponseWithNonce(request)
178
  return addSecurityHeaders(response, request, nonce)
179
  }
 
171
  }
172
  }
173
 
174
+ // Allow login, setup, auth API, docs, and container health probe without session
175
  const isPublicHealthProbe = pathname === '/api/status' && request.nextUrl.searchParams.get('action') === 'health'
176
+ if (pathname === '/login' || pathname === '/setup' || pathname.startsWith('/api/auth/') || pathname === '/api/setup' || pathname === '/api/docs' || pathname === '/docs' || isPublicHealthProbe) {
177
  const { response, nonce } = nextResponseWithNonce(request)
178
  return addSecurityHeaders(response, request, nonce)
179
  }