Public Access
feat: implement setup wizard for initial configuration and user registration
This commit is contained in:
1 parent
f7b93f8612
commit
ad777ef1c3
9 files changed
+571
-3
No files matched your search
@@ -2,3 +2,5 @@ backend/brainwave.db-shm
|
|||||||
backend/brainwave.db-wal
|
backend/brainwave.db-wal
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
backend/db/brainwave.db-shm
|
||||||
|
backend/db/brainwave.db-wal
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Database from 'better-sqlite3'
|
import Database from 'better-sqlite3'
|
||||||
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import { runMigrations } from './migrations.js'
|
import { runMigrations } from './migrations.js'
|
||||||
@@ -10,6 +11,7 @@ let db: Database.Database | null = null
|
|||||||
|
|
||||||
export function getDb(): Database.Database {
|
export function getDb(): Database.Database {
|
||||||
if (!db) {
|
if (!db) {
|
||||||
|
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
|
||||||
db = new Database(DB_PATH)
|
db = new Database(DB_PATH)
|
||||||
db.pragma('journal_mode = WAL')
|
db.pragma('journal_mode = WAL')
|
||||||
db.pragma('foreign_keys = ON')
|
db.pragma('foreign_keys = ON')
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import authRoutes from './routes/auth.js'
|
|||||||
import examRoutes from './routes/exams.js'
|
import examRoutes from './routes/exams.js'
|
||||||
import resultRoutes from './routes/results.js'
|
import resultRoutes from './routes/results.js'
|
||||||
import adminRoutes from './routes/admin.js'
|
import adminRoutes from './routes/admin.js'
|
||||||
|
import setupRoutes from './routes/setup.js'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
@@ -24,6 +25,7 @@ getDb()
|
|||||||
console.log('✓ Database initialized')
|
console.log('✓ Database initialized')
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
|
app.use('/api/setup', setupRoutes)
|
||||||
app.use('/api/auth', authRoutes)
|
app.use('/api/auth', authRoutes)
|
||||||
app.use('/api/exams', examRoutes)
|
app.use('/api/exams', examRoutes)
|
||||||
app.use('/api/results', resultRoutes)
|
app.use('/api/results', resultRoutes)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getDb } from '../db/database.js'
|
|||||||
import { signToken, generateVerificationToken } from '../utils/tokens.js'
|
import { signToken, generateVerificationToken } from '../utils/tokens.js'
|
||||||
import { sendVerificationEmail, sendPasswordResetEmail, isEmailConfigured } from '../utils/email.js'
|
import { sendVerificationEmail, sendPasswordResetEmail, isEmailConfigured } from '../utils/email.js'
|
||||||
import { requireAuth } from '../middleware/auth.js'
|
import { requireAuth } from '../middleware/auth.js'
|
||||||
|
import { needsSetup } from './setup.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
const BCRYPT_ROUNDS = 12
|
const BCRYPT_ROUNDS = 12
|
||||||
@@ -13,6 +14,11 @@ const RESET_EXPIRY_MS = 60 * 60 * 1000 // 1 hour
|
|||||||
// ─── Register ────────────────────────────────────────────
|
// ─── Register ────────────────────────────────────────────
|
||||||
router.post('/register', async (req, res) => {
|
router.post('/register', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
if (needsSetup()) {
|
||||||
|
res.status(403).json({ error: 'Initial setup is required before registration' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const { email, password, displayName } = req.body
|
const { email, password, displayName } = req.body
|
||||||
|
|
||||||
if (!email || !password || !displayName) {
|
if (!email || !password || !displayName) {
|
||||||
|
|||||||
@@ -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
@@ -28,6 +28,7 @@ import AuthModal from "./components/AuthModal";
|
|||||||
import EmailVerify from "./components/EmailVerify";
|
import EmailVerify from "./components/EmailVerify";
|
||||||
import ResetPassword from "./components/ResetPassword";
|
import ResetPassword from "./components/ResetPassword";
|
||||||
import AdminDashboard from "./components/AdminDashboard";
|
import AdminDashboard from "./components/AdminDashboard";
|
||||||
|
import SetupWizard from "./components/SetupWizard";
|
||||||
|
|
||||||
const DEFAULT_TWEAKS: Tweaks = {
|
const DEFAULT_TWEAKS: Tweaks = {
|
||||||
theme: "light",
|
theme: "light",
|
||||||
@@ -41,7 +42,17 @@ const withNotes = (state: ExamState): ExamState => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function AppInner() {
|
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 navigate = useNavigate();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const [exam, setExam] = useState<Exam | null>(null);
|
const [exam, setExam] = useState<Exam | null>(null);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ export interface User {
|
|||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
user: User | null
|
user: User | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
needsSetup: boolean
|
||||||
|
setUser: (user: User | null) => void
|
||||||
login: (email: string, password: string) => Promise<void>
|
login: (email: string, password: string) => Promise<void>
|
||||||
register: (email: string, password: string, displayName: string) => Promise<string>
|
register: (email: string, password: string, displayName: string) => Promise<string>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
@@ -23,14 +25,25 @@ const AuthContext = createContext<AuthContextValue | null>(null)
|
|||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [needsSetup, setNeedsSetup] = useState(false)
|
||||||
|
|
||||||
const refreshUser = useCallback(async () => {
|
const refreshUser = useCallback(async () => {
|
||||||
|
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()) {
|
if (!isAuthenticated()) {
|
||||||
setUser(null)
|
setUser(null)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
const data = await apiFetch<{ id: number; email: string; displayName: string; role: 'user' | 'admin' }>('/api/auth/me')
|
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' })
|
setUser({ id: data.id, email: data.email, displayName: data.displayName, role: data.role ?? 'user' })
|
||||||
} catch {
|
} catch {
|
||||||
@@ -68,7 +81,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, register, logout, refreshUser }}>
|
<AuthContext.Provider value={{ user, loading, needsSetup, setUser, login, register, logout, refreshUser }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2294,3 +2294,100 @@ select {
|
|||||||
max-width: 100%;
|
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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user