feat: web setup wizard + admin config/state dir split (#226)

This commit is contained in:
Linus Rath
2026-05-09 17:37:41 +02:00
parent c44a9ce6e0
commit 51745ea03d
36 changed files with 2612 additions and 164 deletions
+6 -6
View File
@@ -47,13 +47,13 @@ export default function AdminLoginPage() {
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8">
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-8 h-8 object-contain" />
) : (
{logoUrl ? (
<img src={logoUrl} alt="" className="h-12 object-contain mb-4" />
) : (
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mb-4">
<Shield className="w-6 h-6 text-primary" />
)}
</div>
</div>
)}
<h1 className="text-xl font-semibold text-foreground">Admin Dashboard</h1>
<p className="text-sm text-muted-foreground mt-1">Enter your admin password to continue</p>
</div>
+2 -2
View File
@@ -7,14 +7,14 @@ import { getRequiredConfig } from '@/lib/oauth/token-exchange';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { OAUTH_SCOPES } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
import { hasSessionSecret } from '@/lib/auth/session-secret';
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
export async function POST(request: NextRequest) {
try {
if (!process.env.SESSION_SECRET && !readFileEnv(process.env.SESSION_SECRET_FILE)) {
if (!hasSessionSecret()) {
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
}
+3 -3
View File
@@ -1,8 +1,8 @@
import { NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env';
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
import { hasSessionSecret } from '@/lib/auth/session-secret';
/**
* Runtime configuration endpoint
@@ -35,8 +35,8 @@ export async function GET() {
oauthOnly,
oauthClientId: configManager.get<string>('oauthClientId', ''),
oauthIssuerUrl: configManager.get<string>('oauthIssuerUrl', ''),
rememberMeEnabled: !!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE)),
rememberMeEnabled: hasSessionSecret(),
settingsSyncEnabled: configManager.get<boolean>('settingsSyncEnabled', false) && hasSessionSecret(),
stalwartFeaturesEnabled,
devMode: configManager.get<boolean>('devMode', false),
faviconUrl: configManager.get<string>('faviconUrl', '/branding/Bulwark_Favicon.svg'),
+5 -2
View File
@@ -6,7 +6,7 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
import { configManager } from '@/lib/admin/config-manager';
import { readFileEnv } from '@/lib/read-file-env';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
function classifyError(error: unknown): { message: string; status: number } {
@@ -50,7 +50,10 @@ function classifyError(error: unknown): { message: string; status: number } {
}
function isEnabled(): boolean {
return process.env.SETTINGS_SYNC_ENABLED === 'true' && (!!process.env.SESSION_SECRET || !!readFileEnv(process.env.SESSION_SECRET_FILE));
const flagOn =
process.env.SETTINGS_SYNC_ENABLED === 'true' ||
configManager.get<boolean>('settingsSyncEnabled', false);
return flagOn && hasSessionSecret();
}
/** Strip trailing slashes so differently-formatted URLs still match. */
+107
View File
@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from 'next/server';
import { writeFile } from 'node:fs/promises';
import { detectSetupState } from '@/lib/setup/state';
import { authenticateWizardRequest, SETUP_COOKIE } from '@/lib/setup/session';
import { configManager } from '@/lib/admin/config-manager';
import { setInitialAdminPassword } from '@/lib/admin/password';
import { clearSetupToken } from '@/lib/setup/token';
import { ensureConfigDir, getConfigPath } from '@/lib/admin/paths';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
export const dynamic = 'force-dynamic';
/**
* POST /api/setup/finish
*
* Final wizard step. Validates that required config is in place, hashes the
* admin password, marks setup complete, deletes the setup token (which
* invalidates the wizard cookie), and optionally drops a `.config-locked`
* marker so the operator remembers they intended to mount :ro.
*
* Body: { adminPassword: string, lockConfig?: boolean }
*/
export async function POST(request: NextRequest) {
if (detectSetupState() !== 'bootstrap') {
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
}
if (!(await authenticateWizardRequest())) {
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
}
let body: { adminPassword?: unknown; lockConfig?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const adminPassword =
typeof body?.adminPassword === 'string' ? body.adminPassword : '';
if (adminPassword.length < 8) {
return NextResponse.json(
{ error: 'Admin password must be at least 8 characters' },
{ status: 400 },
);
}
const lockConfig = body?.lockConfig === true;
// Validate required config is present.
await configManager.ensureLoaded();
const jmapUrl = configManager.get<string>('jmapServerUrl', '');
if (!jmapUrl || typeof jmapUrl !== 'string') {
return NextResponse.json(
{ error: 'JMAP server URL is required (run the Server step first)' },
{ status: 400 },
);
}
try {
// 1. Provision the admin account. Aborts cleanly if one already exists
// (defence in depth - should be impossible in bootstrap state).
const created = await setInitialAdminPassword(adminPassword);
if (!created) {
return NextResponse.json(
{ error: 'Admin account already exists; cannot finish setup again' },
{ status: 409 },
);
}
// 2. Persist setupComplete flag. After this, detectSetupState() flips
// to 'configured' and middleware starts 404'ing /setup paths.
await configManager.markSetupComplete();
// 3. Optional advisory lock marker.
if (lockConfig) {
await ensureConfigDir();
await writeFile(
getConfigPath('.config-locked'),
new Date().toISOString(),
'utf-8',
);
}
// 4. Destroy the setup token. Any other browser holding the cookie is
// now unauthenticated.
await clearSetupToken();
await auditLog(
'setup.finish',
{ lockConfig, jmapServerUrl: jmapUrl },
request.headers.get('x-forwarded-for') ?? 'unknown',
);
const response = NextResponse.json({ ok: true, lockConfig });
response.cookies.delete(SETUP_COOKIE);
return response;
} catch (error) {
logger.error('Wizard finish failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json(
{ error: 'Failed to finish setup', detail: error instanceof Error ? error.message : 'Unknown' },
{ status: 500 },
);
}
}
+52
View File
@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server';
import { detectSetupState } from '@/lib/setup/state';
import { authenticateWizardRequest } from '@/lib/setup/session';
import { configManager } from '@/lib/admin/config-manager';
import { isConfigReadOnly } from '@/lib/admin/paths';
import { SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types';
export const dynamic = 'force-dynamic';
/**
* GET /api/setup/status - public endpoint that returns the wizard state
* and (if authenticated) the partial config saved by previous steps. The
* wizard polls this on load so a refresh resumes with prior values.
*
* Sensitive values (OAuth client secret, session secret) are NEVER sent
* back to the client - only a `<key>HasValue` boolean. Re-entering them
* after refresh is the price of not exposing them.
*/
export async function GET() {
await configManager.ensureLoaded();
const state = detectSetupState();
const authenticated = state === 'bootstrap' ? await authenticateWizardRequest() : false;
let partialConfig: Record<string, unknown> | null = null;
if (state === 'bootstrap' && authenticated) {
// Only echo back values the operator has actually saved during the
// wizard (admin overrides). System defaults must not flow back here,
// because the wizard has its own opinionated defaults (e.g. settings
// sync on by default) that we'd otherwise stomp.
const sources = configManager.getAllWithSources();
const safe: Record<string, unknown> = {};
for (const [key, info] of Object.entries(sources)) {
if (info.source !== 'admin') continue;
if (SENSITIVE_CONFIG_KEYS.has(key)) {
safe[`${key}HasValue`] = typeof info.value === 'string' && info.value.length > 0;
} else {
safe[key] = info.value;
}
}
partialConfig = safe;
}
return NextResponse.json(
{
state,
authenticated,
readOnly: isConfigReadOnly(),
partialConfig,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
}
+118
View File
@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from 'next/server';
import { detectSetupState } from '@/lib/setup/state';
import { authenticateWizardRequest } from '@/lib/setup/session';
import { configManager } from '@/lib/admin/config-manager';
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
import { parseJmapServers } from '@/lib/admin/jmap-servers';
import { logger } from '@/lib/logger';
export const dynamic = 'force-dynamic';
/**
* Mapping of wizard-friendly step keys to the config keys they update. Each
* step's PATCH validates against this allowlist so a compromised wizard
* client can't slip in arbitrary config keys.
*/
const STEP_KEYS: Record<string, string[]> = {
server: [
'appName',
'jmapServerUrl',
'stalwartFeaturesEnabled',
'jmapServers',
'jmapServerAutoPickByDomain',
],
auth: [
'oauthEnabled',
'oauthOnly',
'oauthClientId',
'oauthClientSecret',
'oauthIssuerUrl',
],
security: ['sessionSecret', 'settingsSyncEnabled'],
logging: ['logFormat', 'logLevel'],
branding: [
'faviconUrl',
'appLogoLightUrl',
'appLogoDarkUrl',
'loginLogoLightUrl',
'loginLogoDarkUrl',
'loginCompanyName',
'loginImprintUrl',
'loginPrivacyPolicyUrl',
'loginWebsiteUrl',
],
};
/**
* POST /api/setup/step
* Body: { step: 'server' | 'auth' | ..., values: Record<string, unknown> }
*
* Persists partial config under the admin override (config.json). Each
* step's allowed keys are restricted by STEP_KEYS so the client can only
* touch what the corresponding screen owns.
*/
export async function POST(request: NextRequest) {
if (detectSetupState() !== 'bootstrap') {
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
}
if (!(await authenticateWizardRequest())) {
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
}
let body: { step?: unknown; values?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const step = typeof body?.step === 'string' ? body.step : '';
const values = body?.values;
const allowedKeys = STEP_KEYS[step];
if (!allowedKeys) {
return NextResponse.json({ error: `Unknown step: ${step}` }, { status: 400 });
}
if (!values || typeof values !== 'object' || Array.isArray(values)) {
return NextResponse.json({ error: 'values must be an object' }, { status: 400 });
}
const updates: Record<string, unknown> = {};
for (const [key, value] of Object.entries(values as Record<string, unknown>)) {
if (!allowedKeys.includes(key)) {
return NextResponse.json({ error: `Key not allowed in step ${step}: ${key}` }, { status: 400 });
}
if (!(key in CONFIG_ENV_MAP)) {
return NextResponse.json({ error: `Unknown config key: ${key}` }, { status: 400 });
}
if (key === 'jmapServers') {
// Sanitize: drop entries with bad ids, dup ids, or non-HTTP URLs
// before they're persisted. Mirrors the admin config PATCH route.
if (value != null && !Array.isArray(value)) {
return NextResponse.json({ error: 'jmapServers must be an array' }, { status: 400 });
}
const sanitized = parseJmapServers(value);
const incomingCount = Array.isArray(value) ? value.length : 0;
if (sanitized.length !== incomingCount) {
return NextResponse.json(
{ error: `One or more jmapServers entries were invalid (kept ${sanitized.length}/${incomingCount})` },
{ status: 400 },
);
}
updates[key] = sanitized;
continue;
}
updates[key] = value;
}
try {
await configManager.ensureLoaded();
await configManager.setAdminConfig(updates);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Wizard step save failed', {
step,
error: error instanceof Error ? error.message : 'Unknown error',
});
return NextResponse.json({ error: 'Failed to save step' }, { status: 500 });
}
}
+104
View File
@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from 'next/server';
import { detectSetupState } from '@/lib/setup/state';
import { authenticateWizardRequest } from '@/lib/setup/session';
export const dynamic = 'force-dynamic';
const JMAP_ENDPOINTS = ['/.well-known/jmap', '/jmap/session', '/jmap'];
const FETCH_TIMEOUT_MS = 5000;
/**
* POST /api/setup/test-jmap - server-side probe of a JMAP server. Mirrors
* the check_jmap_server() helper in setup.sh: we hit a few common session
* endpoints and look for capability strings to confirm the URL is actually
* a JMAP server (vs. a generic HTTP 200 page).
*
* Body: { url: string }
*/
export async function POST(request: NextRequest) {
if (detectSetupState() !== 'bootstrap') {
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
}
if (!(await authenticateWizardRequest())) {
return NextResponse.json({ error: 'Wizard session required' }, { status: 401 });
}
let body: { url?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const raw = typeof body?.url === 'string' ? body.url.trim() : '';
if (!raw) {
return NextResponse.json({ error: 'url required' }, { status: 400 });
}
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return NextResponse.json({ status: 'invalid_url', message: 'URL is not well-formed' });
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return NextResponse.json({ status: 'invalid_url', message: 'URL must use http or https' });
}
const base = raw.replace(/\/+$/, '');
for (const endpoint of JMAP_ENDPOINTS) {
const target = base + endpoint;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const res = await fetch(target, {
method: 'GET',
redirect: 'follow',
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) continue;
const text = await res.text();
if (looksLikeJmapSession(text)) {
return NextResponse.json({
status: 'jmap_detected',
endpoint,
httpStatus: res.status,
});
}
} catch {
// Try the next endpoint; we'll fall through to a final reachability
// check below if none match.
}
}
// No JMAP session found. Was the server even reachable?
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const res = await fetch(base, {
method: 'HEAD',
redirect: 'follow',
signal: controller.signal,
});
clearTimeout(timer);
return NextResponse.json({
status: 'reachable_no_jmap',
httpStatus: res.status,
message:
'Server responded but no JMAP session was found at standard paths. ' +
'This is OK if a reverse proxy routes JMAP separately.',
});
} catch (error) {
return NextResponse.json({
status: 'unreachable',
message: error instanceof Error ? error.message : 'Connection failed',
});
}
}
function looksLikeJmapSession(body: string): boolean {
return /"capabilities"|"apiUrl"|"downloadUrl"|"urn:ietf:params:jmap/i.test(body);
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { detectSetupState } from '@/lib/setup/state';
import { verifySetupToken } from '@/lib/setup/token';
import { buildSessionCookieAttributes } from '@/lib/setup/session';
export const dynamic = 'force-dynamic';
/**
* POST /api/setup/token - exchange the bootstrap token (printed to logs at
* startup) for a wizard session cookie. After this, subsequent step calls
* authenticate via the cookie instead of pasting the token every time.
*
* Body: { token: string }
*/
export async function POST(request: NextRequest) {
if (detectSetupState() !== 'bootstrap') {
return NextResponse.json({ error: 'Setup is not active' }, { status: 404 });
}
let body: { token?: unknown };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const submitted = typeof body?.token === 'string' ? body.token.trim() : '';
if (!submitted) {
return NextResponse.json({ error: 'Token required' }, { status: 400 });
}
const ok = await verifySetupToken(submitted);
if (!ok) {
// Don't differentiate between "wrong token" and "no token issued" - the
// operator either has it from the logs or they don't.
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 });
}
const response = NextResponse.json({ ok: true });
const attrs = buildSessionCookieAttributes();
response.cookies.set(attrs.name, submitted, {
httpOnly: attrs.httpOnly,
sameSite: attrs.sameSite,
secure: attrs.secure,
path: attrs.path,
maxAge: attrs.maxAge,
});
return response;
}
+5
View File
@@ -0,0 +1,5 @@
import type { ReactNode } from 'react';
export default function SetupLayout({ children }: { children: ReactNode }) {
return <div className="min-h-screen bg-background text-foreground">{children}</div>;
}
+1288
View File
File diff suppressed because it is too large Load Diff