docs: first-start admin setup design spec
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user