docs: first-start admin setup design spec

This commit is contained in:
m4
2026-08-12 22:12:15 +08:00
parent f31bb7203f
commit 26a477464e
@@ -0,0 +1,179 @@
# First-Start Admin Setup — Design
Date: 2026-08-12
Status: Approved (brainstorming 2026-08-12)
## Goal
When the WebUI user store is empty and no `WEBUI_AUTH_USERNAME` /
`WEBUI_AUTH_PASSWORD` env seeding is configured, the deployment currently
dead-ends: `ensureBootstrapAdmin()` throws on every auth path, surfacing a
500-class configuration error. Replace that dead-end with a first-start
setup flow: the login page detects the empty store and renders an
admin-creation form; submitting creates the first admin, then returns to the
normal login form.
Decisions locked in during brainstorming:
- **Env seeding kept, unchanged priority.** If the env vars are present,
`ensureBootstrapAdmin()` seeds the first admin exactly as today and the
setup UI never appears.
- **No auto-login.** After creating the admin, the UI returns to the login
form (username prefilled) with a success notice; the operator logs in
manually.
- **Setup lives on the login page.** No new route/page; the login page
swaps its form based on a public status endpoint.
## Current state (verified)
- `src/lib/server/userStore.ts:216` `ensureBootstrapAdmin()`:
`if (countUsers() > 0) return;` then requires both env vars, throws
`UserStoreError` otherwise; seeds via `createUser(username, password,
"admin")` when present.
- Call sites: `src/lib/server/auth.ts:58` (`verifyCredentials`, wraps the
throw in `AuthConfigurationError`), `src/lib/server/actor.ts:39`
(`requireActor`), `src/app/api/auth/users/route.ts:29,47`,
`src/app/api/auth/me/route.ts:29`.
- `createUser(username, password, role)` (userStore.ts:154) validates the
username (`validateUsername`) and rejects duplicates; hashes the password
itself. No password-strength check inside — that lives in
`src/lib/userManagement.ts` (`isValidPassword`, 8–256 chars;
`isValidUsername`, `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`).
- Login page `src/app/[locale]/login/page.tsx` is a client component posting
to `/api/auth/login`, with captcha flow, `useTranslations("login")`, and
`next` return-path handling.
## Components
### 1. `ensureBootstrapAdmin()` — stop throwing (userStore.ts:216)
Behavior change, one branch:
```ts
export function ensureBootstrapAdmin(): void {
if (countUsers() > 0) return;
const username = process.env.WEBUI_AUTH_USERNAME?.trim();
const password = process.env.WEBUI_AUTH_PASSWORD;
if (!username || !password) return; // was: throw UserStoreError
createUser(username, password, "admin");
}
```
Downstream effect of an empty store, per call site (no code changes needed):
- `verifyCredentials` → `verifyUserPassword` returns null → normal
invalid-credentials login failure (no more 500).
- `requireActor` → `roleForUsername` returns null → 401 as today.
- users/me routes → the existing actor/auth guards reject before any
admin-only logic runs.
### 2. Public setup routes (Next.js route handlers)
Both unauthenticated — they exist precisely for the no-session state.
**`GET /api/auth/setup-status`** → `200 {needsSetup: boolean}`,
`needsSetup = countUsers() === 0`. Calls `ensureBootstrapAdmin()` first so
an env-seeded deployment flips to `false` on the first probe. No-store
caching header.
**`POST /api/auth/setup`** — body `{username: string, password: string}`:
1. `ensureBootstrapAdmin()` (env may seed at any moment; env wins).
2. If `countUsers() > 0` → `409 {error}` — this is also the race guard
against two concurrent first-start submissions.
3. Validate with `isValidUsername` / `isValidPassword` → on failure
`400 {error, field: "username" | "password"}` (`field` lets the client
place the inline message under the offending input).
4. `createUser(username, password, "admin")` → `201 {username, role:
"admin"}` (no password material in the response).
Error payload shape follows the existing auth-route convention
(`{error: message}` JSON plus optional structured fields, matching the
login route's responses).
### 3. Login page setup form (`src/app/[locale]/login/page.tsx`)
On mount, the page fetches `GET /api/auth/setup-status`:
- `needsSetup: false` (or request failure) → existing login form, zero
behavior change.
- `needsSetup: true` → render the setup form in place of the login form:
title, username, password, confirm-password fields, submit button. No
captcha (the flow is one-shot and store-guarded server-side).
Client-side validation before submit: username pattern, password length,
password === confirm — failures show inline messages and send no request.
Submit flow:
- `POST /api/auth/setup` → 201: show a success notice ("admin created,
please log in"), switch back to the login form with the username field
prefilled from the submitted value. No auto-login (locked decision).
- 409: another admin appeared meanwhile → refetch setup-status, fall back
to the login form, show the "already initialized" message.
- 400: inline error under the offending field.
While the status probe is in flight the login form renders as today (the
probe is fast; the setup state is the rare case and may pop in).
### 4. i18n
New `setup` namespace in both zh/en catalogs (added in lockstep, plus the
`messages.test.ts` module list): title, subtitle (why this screen exists),
field labels, password rules hint, submit button, success notice,
`alreadyCompleted` message, `invalidUsername` / `invalidPassword` /
`passwordMismatch` errors, `failed{status}` generic failure.
### 5. Testing
- **vitest route tests** (`src/app/api/auth/setup-status/route.test.ts`,
`src/app/api/auth/setup/route.test.ts`), following the existing
auth-route test pattern (temp sqlite user store, env var control):
- setup-status: empty store → `{needsSetup: true}`; with a user →
`false`; env vars set + empty store → seeds then `false`.
- setup: valid body → 201, admin exists in store, password verified via
`verifyUserPassword`; second POST → 409; bad username → 400 with
`field: "username"`; short/long password → 400 with
`field: "password"`; env seeded between probe and POST → 409.
- `ensureBootstrapAdmin` regression: no env + empty store no longer
throws (was the 500 source); env seeding path unchanged.
- **Manual:** wipe the users table, open the login page, see the setup
form, create the admin, log in normally; verify zh/en copy.
## Data flow
```
Probe: login page mount → GET /api/auth/setup-status (public)
→ ensureBootstrapAdmin (env seed if configured)
→ {needsSetup: countUsers() === 0}
Setup: form submit → POST /api/auth/setup (public, store-empty guarded)
→ ensureBootstrapAdmin → countUsers()>0 ? 409
→ validate → createUser(admin) → 201
→ UI switches to login form (username prefilled), no session set
```
## Error handling
- Status probe failure (network/500): render the login form (safe default;
an empty store then simply fails login as invalid credentials).
- 409 race (two operators, or env seeded in between): UI refetches status
and shows `alreadyCompleted`.
- All 400s are field-scoped inline errors; no toast needed.
## Security posture
- The unauthenticated surface is exactly two routes; the write route is
useless the moment any user exists (409), so the exposure window is
"between deploy and first setup" — the same window env seeding already
occupies, now with a UI instead of a 500.
- No session/JWT material is issued by the setup route; it only creates a
user row. Authentication still goes through `/api/auth/login`.
- Password rules identical to the admin-managed user creation path; the
store hashes with the existing `hashPassword`.
## Out of scope
- Setup wizard beyond the first admin (model config, channels, etc.).
- Recovery flows for "admin exists but password lost".
- Changing the env-seeding mechanism itself.