feat: implement setup wizard for initial configuration and user registration

This commit is contained in:
LaurensBartelds committed 2026-04-25 16:48:23 +02:00
1 parent f7b93f8612
commit ad777ef1c3
9 files changed
+576 -8

No files matched your search

+3 -1
View File
@@ -1,4 +1,6 @@
backend/brainwave.db-shm
backend/brainwave.db-wal
.claude/settings.local.json
.superpowers/
.superpowers/
backend/db/brainwave.db-shm
backend/db/brainwave.db-wal
+2
View File
@@ -1,4 +1,5 @@
import Database from 'better-sqlite3'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { runMigrations } from './migrations.js'
@@ -10,6 +11,7 @@ let db: Database.Database | null = null
export function getDb(): Database.Database {
if (!db) {
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
+2
View File
@@ -5,6 +5,7 @@ import authRoutes from './routes/auth.js'
import examRoutes from './routes/exams.js'
import resultRoutes from './routes/results.js'
import adminRoutes from './routes/admin.js'
import setupRoutes from './routes/setup.js'
import path from 'path'
import fs from 'fs'
import { fileURLToPath } from 'url'
@@ -24,6 +25,7 @@ getDb()
console.log('✓ Database initialized')
// Routes
app.use('/api/setup', setupRoutes)
app.use('/api/auth', authRoutes)
app.use('/api/exams', examRoutes)
app.use('/api/results', resultRoutes)
+6
View File
@@ -4,6 +4,7 @@ import { getDb } from '../db/database.js'
import { signToken, generateVerificationToken } from '../utils/tokens.js'
import { sendVerificationEmail, sendPasswordResetEmail, isEmailConfigured } from '../utils/email.js'
import { requireAuth } from '../middleware/auth.js'
import { needsSetup } from './setup.js'
const router = Router()
const BCRYPT_ROUNDS = 12
@@ -13,6 +14,11 @@ const RESET_EXPIRY_MS = 60 * 60 * 1000 // 1 hour
// ─── Register ────────────────────────────────────────────
router.post('/register', async (req, res) => {
try {
if (needsSetup()) {
res.status(403).json({ error: 'Initial setup is required before registration' })
return
}
const { email, password, displayName } = req.body
if (!email || !password || !displayName) {
+122
View File
@@ -0,0 +1,122 @@
import { Router } from 'express'
import bcrypt from 'bcrypt'
import { getDb } from '../db/database.js'
import { signToken } from '../utils/tokens.js'
const router = Router()
const BCRYPT_ROUNDS = 12
function needsSetup(): boolean {
const db = getDb()
const row = db.prepare('SELECT COUNT(*) AS cnt FROM users').get() as { cnt: number }
return row.cnt === 0
}
// ─── Check Setup Status ──────────────────────────────────
router.get('/status', (_req, res) => {
try {
res.json({ needsSetup: needsSetup() })
} catch (err) {
console.error('Setup status error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Perform Initial Setup ───────────────────────────────
router.post('/', async (req, res) => {
try {
if (!needsSetup()) {
res.status(403).json({ error: 'Setup has already been completed' })
return
}
const {
email,
password,
displayName,
smtpHost,
smtpPort,
smtpUser,
smtpPass,
smtpFrom,
smtpSecure,
appUrl,
} = req.body
// Validate admin credentials
if (!email || !password || !displayName) {
res.status(400).json({ error: 'Email, password, and display name are required' })
return
}
if (typeof email !== 'string' || !email.includes('@') || email.length > 255) {
res.status(400).json({ error: 'Invalid email address' })
return
}
if (typeof password !== 'string' || password.length < 8) {
res.status(400).json({ error: 'Password must be at least 8 characters' })
return
}
if (typeof displayName !== 'string' || displayName.trim().length < 1 || displayName.length > 100) {
res.status(400).json({ error: 'Display name is required (max 100 characters)' })
return
}
const db = getDb()
// Create admin user (auto-verified since no SMTP yet)
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS)
const result = db.prepare(`
INSERT INTO users (email, password_hash, display_name, role, email_verified)
VALUES (?, ?, ?, 'admin', 1)
`).run(email.toLowerCase().trim(), passwordHash, displayName.trim())
const userId = result.lastInsertRowid as number
// Save settings
const settings: Record<string, string> = {
app_url: typeof appUrl === 'string' && appUrl.trim() ? appUrl.trim() : 'http://localhost:5173',
}
if (typeof smtpHost === 'string' && smtpHost.trim()) {
settings.smtp_host = smtpHost.trim()
settings.smtp_port = String(Number(smtpPort) || 587)
settings.smtp_user = typeof smtpUser === 'string' ? smtpUser : ''
settings.smtp_pass = typeof smtpPass === 'string' ? smtpPass : ''
settings.smtp_from = typeof smtpFrom === 'string' && smtpFrom.trim() ? smtpFrom.trim() : 'BrainWave <noreply@brainwave.local>'
settings.smtp_secure = typeof smtpSecure === 'string' ? smtpSecure : 'auto'
}
const upsert = db.prepare(`
INSERT INTO site_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = unixepoch()
`)
for (const [key, value] of Object.entries(settings)) {
upsert.run(key, value)
}
// Generate JWT and log admin in immediately
const token = signToken({
userId,
email: email.toLowerCase().trim(),
displayName: displayName.trim(),
role: 'admin',
})
res.status(201).json({
message: 'Setup completed successfully',
token,
user: {
id: userId,
email: email.toLowerCase().trim(),
displayName: displayName.trim(),
role: 'admin',
},
})
} catch (err) {
console.error('Setup error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
export default router
export { needsSetup }
+12 -1
View File
@@ -28,6 +28,7 @@ import AuthModal from "./components/AuthModal";
import EmailVerify from "./components/EmailVerify";
import ResetPassword from "./components/ResetPassword";
import AdminDashboard from "./components/AdminDashboard";
import SetupWizard from "./components/SetupWizard";
const DEFAULT_TWEAKS: Tweaks = {
theme: "light",
@@ -41,7 +42,17 @@ const withNotes = (state: ExamState): ExamState => ({
});
function AppInner() {
const { user, loading: authLoading, logout } = useAuth();
const { user, loading: authLoading, needsSetup, refreshUser, logout } = useAuth();
if (needsSetup) {
return (
<SetupWizard
onComplete={() => {
void refreshUser()
}}
/>
)
}
const navigate = useNavigate();
const { pathname } = useLocation();
const [exam, setExam] = useState<Exam | null>(null);
+313
View File
@@ -0,0 +1,313 @@
import { useState } from 'react'
import { apiFetch, setToken } from '../utils/api'
interface Props {
onComplete: () => void
}
export default function SetupWizard({ onComplete }: Readonly<Props>) {
const [step, setStep] = useState(1)
const [email, setEmail] = useState('')
const [displayName, setDisplayName] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [smtpHost, setSmtpHost] = useState('')
const [smtpPort, setSmtpPort] = useState('587')
const [smtpUser, setSmtpUser] = useState('')
const [smtpPass, setSmtpPass] = useState('')
const [smtpFrom, setSmtpFrom] = useState('')
const [smtpSecure, setSmtpSecure] = useState('auto')
const [appUrl, setAppUrl] = useState(window.location.origin)
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const validateStep1 = () => {
if (!email.trim() || !email.includes('@')) {
setError('Please enter a valid email address')
return false
}
if (!displayName.trim()) {
setError('Display name is required')
return false
}
if (password.length < 8) {
setError('Password must be at least 8 characters')
return false
}
if (password !== confirmPassword) {
setError('Passwords do not match')
return false
}
return true
}
const handleNext = () => {
setError(null)
if (validateStep1()) setStep(2)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setSubmitting(true)
try {
const data = await apiFetch<{
message: string
token: string
user: {
id: number
email: string
displayName: string
role: 'user' | 'admin'
}
}>('/api/setup', {
method: 'POST',
body: JSON.stringify({
email,
password,
displayName: displayName.trim(),
smtpHost,
smtpPort,
smtpUser,
smtpPass,
smtpFrom: smtpFrom || 'BrainWave <noreply@brainwave.local>',
smtpSecure,
appUrl,
}),
})
setToken(data.token)
onComplete()
} catch (err) {
setError(
err instanceof Error ? err.message : 'Setup failed. Please try again.',
)
} finally {
setSubmitting(false)
}
}
return (
<div className="setup-page">
<div className="setup-card">
<div className="brand-mark" style={{ margin: '0 auto 16px' }} />
<h1 className="setup-title">Welcome to BrainWave</h1>
<p className="setup-subtitle">
Let's get your instance set up. This only takes a minute.
</p>
<div className="setup-steps">
<div className={`setup-step ${step >= 1 ? 'active' : ''}`}>
<span className="setup-step-num">1</span>
<span className="setup-step-label">Admin Account</span>
</div>
<div className="setup-step-line" />
<div className={`setup-step ${step >= 2 ? 'active' : ''}`}>
<span className="setup-step-num">2</span>
<span className="setup-step-label">Email Settings</span>
</div>
</div>
{error && <div className="auth-error">{error}</div>}
{step === 1 && (
<>
<div className="auth-field">
<label className="auth-label" htmlFor="setup-email">
Email
</label>
<input
id="setup-email"
className="input"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@example.com"
required
autoComplete="email"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="setup-name">
Display Name
</label>
<input
id="setup-name"
className="input"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your name"
required
autoComplete="name"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="setup-password">
Password
</label>
<input
id="setup-password"
className="input"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
required
minLength={8}
autoComplete="new-password"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="setup-confirm">
Confirm Password
</label>
<input
id="setup-confirm"
className="input"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
minLength={8}
autoComplete="new-password"
/>
</div>
<button
className="btn btn-primary auth-submit"
onClick={handleNext}
style={{ marginTop: 8 }}
>
Continue →
</button>
</>
)}
{step === 2 && (
<form onSubmit={(e) => void handleSubmit(e)}>
<p className="setup-hint">
Configure your outgoing email server so BrainWave can send
verification and password-reset emails.
</p>
<p className="setup-hint">
You can leave these blank and configure them later in the admin
dashboard.
</p>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-host">
SMTP Host
</label>
<input
id="smtp-host"
className="input"
type="text"
value={smtpHost}
onChange={(e) => setSmtpHost(e.target.value)}
placeholder="smtp.example.com"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-port">
SMTP Port
</label>
<input
id="smtp-port"
className="input"
type="number"
value={smtpPort}
onChange={(e) => setSmtpPort(e.target.value)}
placeholder="587"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-user">
SMTP Username
</label>
<input
id="smtp-user"
className="input"
type="text"
value={smtpUser}
onChange={(e) => setSmtpUser(e.target.value)}
placeholder="user@example.com"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-pass">
SMTP Password
</label>
<input
id="smtp-pass"
className="input"
type="password"
value={smtpPass}
onChange={(e) => setSmtpPass(e.target.value)}
placeholder="••••••••"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-from">
From Address
</label>
<input
id="smtp-from"
className="input"
type="text"
value={smtpFrom}
onChange={(e) => setSmtpFrom(e.target.value)}
placeholder="BrainWave <noreply@brainwave.local>"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="smtp-secure">
Security
</label>
<select
id="smtp-secure"
className="input"
value={smtpSecure}
onChange={(e) => setSmtpSecure(e.target.value)}
>
<option value="auto">Auto (use SSL on port 465)</option>
<option value="true">SSL/TLS</option>
<option value="false">None / STARTTLS</option>
</select>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="app-url">
App URL
</label>
<input
id="app-url"
className="input"
type="url"
value={appUrl}
onChange={(e) => setAppUrl(e.target.value)}
placeholder="https://brainwave.example.com"
required
/>
</div>
<div className="setup-actions">
<button
type="button"
className="btn btn-ghost"
onClick={() => setStep(1)}
>
← Back
</button>
<button
className="btn btn-primary auth-submit"
type="submit"
disabled={submitting}
>
{submitting ? 'Setting up…' : 'Complete Setup'}
</button>
</div>
</form>
)}
</div>
</div>
)
}
+19 -6
View File
@@ -12,6 +12,8 @@ export interface User {
interface AuthContextValue {
user: User | null
loading: boolean
needsSetup: boolean
setUser: (user: User | null) => void
login: (email: string, password: string) => Promise<void>
register: (email: string, password: string, displayName: string) => Promise<string>
logout: () => void
@@ -23,14 +25,25 @@ const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [needsSetup, setNeedsSetup] = useState(false)
const refreshUser = useCallback(async () => {
if (!isAuthenticated()) {
setUser(null)
setLoading(false)
return
}
try {
// Always check setup status first
const setupStatus = await apiFetch<{ needsSetup: boolean }>('/api/setup/status')
if (setupStatus.needsSetup) {
setNeedsSetup(true)
setUser(null)
setLoading(false)
return
}
setNeedsSetup(false)
if (!isAuthenticated()) {
setUser(null)
setLoading(false)
return
}
const data = await apiFetch<{ id: number; email: string; displayName: string; role: 'user' | 'admin' }>('/api/auth/me')
setUser({ id: data.id, email: data.email, displayName: data.displayName, role: data.role ?? 'user' })
} catch {
@@ -68,7 +81,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, [])
return (
<AuthContext.Provider value={{ user, loading, login, register, logout, refreshUser }}>
<AuthContext.Provider value={{ user, loading, needsSetup, setUser, login, register, logout, refreshUser }}>
{children}
</AuthContext.Provider>
)
+97
View File
@@ -2294,3 +2294,100 @@ select {
max-width: 100%;
}
}
/* ============================================================
Setup Wizard
============================================================ */
.setup-page {
display: grid;
place-items: center;
min-height: 100vh;
padding: 24px;
background: var(--paper);
}
.setup-card {
width: 100%;
max-width: 480px;
background: var(--paper-2);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 32px 28px;
box-shadow: var(--shadow-md);
}
.setup-title {
font-family: var(--font-serif);
font-size: 26px;
font-weight: 600;
margin: 0 0 6px;
text-align: center;
color: var(--ink);
}
.setup-subtitle {
font-size: 14px;
color: var(--ink-3);
text-align: center;
margin: 0 0 24px;
}
.setup-steps {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 24px;
}
.setup-step {
display: flex;
align-items: center;
gap: 8px;
color: var(--ink-4);
font-size: 13px;
font-weight: 500;
}
.setup-step.active {
color: var(--accent-ink);
}
.setup-step-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--line);
color: var(--ink-3);
font-size: 12px;
font-weight: 600;
}
.setup-step.active .setup-step-num {
background: var(--accent);
color: #fff;
}
.setup-step-line {
width: 32px;
height: 1px;
background: var(--line-2);
}
.setup-hint {
font-size: 13px;
color: var(--ink-3);
margin: 0 0 12px;
line-height: 1.5;
}
.setup-actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}