Spaces:
Runtime error
Runtime error
Nyk commited on
Commit ·
943fe08
1
Parent(s): 58ba0af
feat: add first-time setup wizard and zero-config startup
Browse filesEliminate 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 +9 -7
- .gitignore +2 -2
- CLAUDE.md +80 -0
- Dockerfile +3 -1
- README.md +13 -5
- docker-entrypoint.sh +48 -0
- src/app/api/auth/login/route.ts +14 -1
- src/app/api/setup/route.ts +112 -0
- src/app/login/page.tsx +49 -0
- src/app/setup/page.tsx +313 -0
- src/lib/auto-credentials.ts +106 -0
- src/lib/db.ts +20 -3
- src/proxy.ts +2 -2
.env.example
CHANGED
|
@@ -3,15 +3,16 @@
|
|
| 3 |
# PORT=3000
|
| 4 |
|
| 5 |
# === Authentication ===
|
| 6 |
-
#
|
| 7 |
-
AUTH_USER
|
| 8 |
-
|
| 9 |
-
#
|
| 10 |
-
# AUTH_PASS_B64=
|
| 11 |
# Example: echo -n 'my#password' | base64
|
| 12 |
|
| 13 |
# API key for headless/external access (x-api-key header)
|
| 14 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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`
|
| 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 |
-
|
| 70 |
-
pnpm dev # http://localhost:3000
|
| 71 |
```
|
| 72 |
|
| 73 |
-
|
| 74 |
-
|
|
|
|
| 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 |
-
|
| 130 |
-
|
| 131 |
-
'
|
|
|
|
| 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
|
| 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 |
}
|