7.7 KiB
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:216ensureBootstrapAdmin():if (countUsers() > 0) return;then requires both env vars, throwsUserStoreErrorotherwise; seeds viacreateUser(username, password, "admin")when present.- Call sites:
src/lib/server/auth.ts:58(verifyCredentials, wraps the throw inAuthConfigurationError),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 insrc/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.tsxis a client component posting to/api/auth/login, with captcha flow,useTranslations("login"), andnextreturn-path handling.
Components
1. ensureBootstrapAdmin() — stop throwing (userStore.ts:216)
Behavior change, one branch:
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→verifyUserPasswordreturns null → normal invalid-credentials login failure (no more 500).requireActor→roleForUsernamereturns 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}:
ensureBootstrapAdmin()(env may seed at any moment; env wins).- If
countUsers() > 0→409 {error}— this is also the race guard against two concurrent first-start submissions. - Validate with
isValidUsername/isValidPassword→ on failure400 {error, field: "username" | "password"}(fieldlets the client place the inline message under the offending input). 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 thenfalse. - setup: valid body → 201, admin exists in store, password verified via
verifyUserPassword; second POST → 409; bad username → 400 withfield: "username"; short/long password → 400 withfield: "password"; env seeded between probe and POST → 409. ensureBootstrapAdminregression: no env + empty store no longer throws (was the 500 source); env seeding path unchanged.
- setup-status: empty store →
- 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.