diff --git a/.gitignore b/.gitignore index 60ab541..8ae66ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ backend/brainwave.db-shm backend/brainwave.db-wal .claude/settings.local.json -.superpowers/ \ No newline at end of file +.superpowers/ +backend/db/brainwave.db-shm +backend/db/brainwave.db-wal diff --git a/backend/src/db/database.ts b/backend/src/db/database.ts index e8e66b8..fcbb3d9 100644 --- a/backend/src/db/database.ts +++ b/backend/src/db/database.ts @@ -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') diff --git a/backend/src/index.ts b/backend/src/index.ts index 32585bf..26bd050 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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) diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index c243fb1..1cd0ab4 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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) { diff --git a/backend/src/routes/setup.ts b/backend/src/routes/setup.ts new file mode 100644 index 0000000..d048898 --- /dev/null +++ b/backend/src/routes/setup.ts @@ -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 = { + 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 ' + 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 } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 289698d..86e7b11 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( + { + void refreshUser() + }} + /> + ) + } const navigate = useNavigate(); const { pathname } = useLocation(); const [exam, setExam] = useState(null); diff --git a/frontend/src/components/SetupWizard.tsx b/frontend/src/components/SetupWizard.tsx new file mode 100644 index 0000000..b1bc28e --- /dev/null +++ b/frontend/src/components/SetupWizard.tsx @@ -0,0 +1,313 @@ +import { useState } from 'react' +import { apiFetch, setToken } from '../utils/api' + +interface Props { + onComplete: () => void +} + +export default function SetupWizard({ onComplete }: Readonly) { + 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(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 ', + smtpSecure, + appUrl, + }), + }) + setToken(data.token) + onComplete() + } catch (err) { + setError( + err instanceof Error ? err.message : 'Setup failed. Please try again.', + ) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+
+

Welcome to BrainWave

+

+ Let's get your instance set up. This only takes a minute. +

+ +
+
= 1 ? 'active' : ''}`}> + 1 + Admin Account +
+
+
= 2 ? 'active' : ''}`}> + 2 + Email Settings +
+
+ + {error &&
{error}
} + + {step === 1 && ( + <> +
+ + setEmail(e.target.value)} + placeholder="admin@example.com" + required + autoComplete="email" + /> +
+
+ + setDisplayName(e.target.value)} + placeholder="Your name" + required + autoComplete="name" + /> +
+
+ + setPassword(e.target.value)} + placeholder="Min. 8 characters" + required + minLength={8} + autoComplete="new-password" + /> +
+
+ + setConfirmPassword(e.target.value)} + placeholder="••••••••" + required + minLength={8} + autoComplete="new-password" + /> +
+ + + )} + + {step === 2 && ( +
void handleSubmit(e)}> +

+ Configure your outgoing email server so BrainWave can send + verification and password-reset emails. +

+

+ You can leave these blank and configure them later in the admin + dashboard. +

+ +
+ + setSmtpHost(e.target.value)} + placeholder="smtp.example.com" + /> +
+
+ + setSmtpPort(e.target.value)} + placeholder="587" + /> +
+
+ + setSmtpUser(e.target.value)} + placeholder="user@example.com" + /> +
+
+ + setSmtpPass(e.target.value)} + placeholder="••••••••" + /> +
+
+ + setSmtpFrom(e.target.value)} + placeholder="BrainWave " + /> +
+
+ + +
+
+ + setAppUrl(e.target.value)} + placeholder="https://brainwave.example.com" + required + /> +
+ +
+ + +
+
+ )} +
+
+ ) +} diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index a9d9ff6..6430ee7 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -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 register: (email: string, password: string, displayName: string) => Promise logout: () => void @@ -23,14 +25,25 @@ const AuthContext = createContext(null) export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(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 ( - + {children} ) diff --git a/frontend/src/index.css b/frontend/src/index.css index 4df143d..3b01617 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; +}