feat: initialize database and authentication routes in backend

This commit is contained in:
LaurensBartelds committed 2026-04-21 22:51:22 +02:00
1 parent be69db04a5
commit eb013d4978
22 files changed
+1808 -8

No files matched your search

+200 -7
View File
@@ -2,20 +2,71 @@ import { useState, useEffect, useCallback } from 'react'
import type { AppScreen, Exam, ExamState, HistoryEntry, Tweaks } from './types'
import { gradeQuestion } from './utils/grading'
import { loadState, saveState, clearState, loadHistory, appendHistory, clearHistory } from './utils/storage'
import { apiFetch, isAuthenticated } from './utils/api'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import Landing from './components/Landing'
import ExamRunner from './components/ExamRunner'
import Results from './components/Results'
import TweaksPanel from './components/TweaksPanel'
import AuthModal from './components/AuthModal'
import EmailVerify from './components/EmailVerify'
import ResetPassword from './components/ResetPassword'
const DEFAULT_TWEAKS: Tweaks = { theme: 'light', fontSize: 'medium', feedbackMode: 'auto' }
export default function App() {
function AppInner() {
const { user, loading: authLoading, logout } = useAuth()
const [screen, setScreen] = useState<AppScreen>('landing')
const [exam, setExam] = useState<Exam | null>(null)
const [examState, setExamState] = useState<ExamState | null>(null)
const [history, setHistory] = useState<HistoryEntry[]>(() => loadHistory())
const [tweaks, setTweaks] = useState<Tweaks>(DEFAULT_TWEAKS)
const [tweaksOpen, setTweaksOpen] = useState(false)
const [authModalOpen, setAuthModalOpen] = useState(false)
const [userMenuOpen, setUserMenuOpen] = useState(false)
// Check URL for verify/reset-password routes on mount
useEffect(() => {
const path = window.location.pathname
const search = window.location.search
if (path === '/verify' || search.includes('token=') && path === '/verify') {
setScreen('verify')
} else if (path === '/reset-password') {
setScreen('reset-password')
}
}, [])
// Load history from API when authenticated
useEffect(() => {
if (!authLoading && user && isAuthenticated()) {
apiFetch<{ results: Array<{
id: number; title: string; score_points: number; total_points: number;
correct_count: number; graded_count: number; passing_score: number;
elapsed_ms: number; completed_at: number; exam_id: number | null;
}> }>('/api/results')
.then((data) => {
const apiHistory: HistoryEntry[] = data.results.map((r) => ({
id: String(r.id),
title: r.title,
completedAt: r.completed_at * 1000,
elapsedMs: r.elapsed_ms,
gradedCount: r.graded_count,
correctCount: r.correct_count,
totalPoints: r.total_points,
scorePoints: r.score_points,
passingScore: r.passing_score,
exam: { title: r.title, questions: [] } as unknown as Exam,
state: { currentIdx: 0, answers: {}, flags: {}, startedAt: 0, feedbackMode: 'end' as const } as ExamState,
_remoteId: r.id,
}))
setHistory(apiHistory)
})
.catch(() => {
// Fall back to localStorage
setHistory(loadHistory())
})
}
}, [user, authLoading])
useEffect(() => {
document.documentElement.dataset['theme'] = tweaks.theme
@@ -60,24 +111,122 @@ export default function App() {
exam,
state: { ...examState, completedAt },
}
appendHistory(entry)
setHistory(loadHistory())
// Save to API if authenticated
if (user && isAuthenticated()) {
void apiFetch('/api/results', {
method: 'POST',
body: JSON.stringify({
title: entry.title,
scorePoints: entry.scorePoints,
totalPoints: entry.totalPoints,
correctCount: entry.correctCount,
gradedCount: entry.gradedCount,
passingScore: entry.passingScore,
elapsedMs: entry.elapsedMs,
state: entry.state,
exam: entry.exam,
}),
}).catch(() => {
// Fallback: save locally
appendHistory(entry)
})
} else {
appendHistory(entry)
}
// Always update local state for immediate display
setHistory((prev) => [entry, ...prev])
clearState()
setExamState((s) => s ? { ...s, completedAt } : s)
setScreen('results')
}, [exam, examState])
}, [exam, examState, user])
const exit = () => { setScreen('landing'); setExam(null); setExamState(null) }
const restart = () => { if (exam) loadExam(exam) }
const reviewAttempt = (entry: HistoryEntry) => { setExam(entry.exam); setExamState(entry.state); setScreen('review') }
const clearAllHistory = () => { if (confirm('Clear all past attempts?')) { clearHistory(); setHistory([]) } }
const reviewAttempt = (entry: HistoryEntry) => {
// If this is a remote result, fetch full data
const remoteId = (entry as HistoryEntry & { _remoteId?: number })._remoteId
if (remoteId && user && isAuthenticated()) {
void apiFetch<{
exam: Exam; state: ExamState; title: string; scorePoints: number; totalPoints: number;
correctCount: number; gradedCount: number; passingScore: number; elapsedMs: number; completedAt: number;
}>(`/api/results/${remoteId}`)
.then((data) => {
setExam(data.exam)
setExamState(data.state)
setScreen('review')
})
.catch(() => {
// Fallback to local data
setExam(entry.exam); setExamState(entry.state); setScreen('review')
})
} else {
setExam(entry.exam); setExamState(entry.state); setScreen('review')
}
}
const clearAllHistory = () => {
if (confirm('Clear all past attempts?')) {
clearHistory()
setHistory([])
}
}
const handleVerifyDone = () => {
window.history.replaceState({}, '', '/')
setScreen('landing')
setAuthModalOpen(true)
}
const handleResetDone = () => {
window.history.replaceState({}, '', '/')
setScreen('landing')
setAuthModalOpen(true)
}
if (screen === 'verify') {
return (
<div className="shell">
<header className="topbar">
<div className="brand">
<span className="brand-mark" />
<span className="brand-name">BrainWave</span>
</div>
</header>
<EmailVerify onDone={handleVerifyDone} />
</div>
)
}
if (screen === 'reset-password') {
return (
<div className="shell">
<header className="topbar">
<div className="brand">
<span className="brand-mark" />
<span className="brand-name">BrainWave</span>
</div>
</header>
<ResetPassword onDone={handleResetDone} />
</div>
)
}
return (
<div className="shell">
<header className="topbar">
<div className="brand">
<span className="brand-mark" />
<span>BrainWave</span>
<button
className="brand-name"
onClick={exit}
aria-label="Home"
style={{ background: 'none', border: 'none', padding: 0, margin: 0, cursor: 'pointer', font: 'inherit', color: 'inherit' }}
>
BrainWave
</button>
<span className="faint mono" style={{ fontSize: 11, marginLeft: 4 }}>/ practice</span>
</div>
<div className="topbar-meta">
@@ -88,6 +237,36 @@ export default function App() {
</>
)}
<button className="btn btn-ghost btn-sm" onClick={() => setTweaksOpen((o) => !o)} style={{ marginLeft: 8 }} aria-label="Tweaks">⚙</button>
{/* Auth buttons */}
{!authLoading && !user && (
<button className="btn btn-primary btn-sm" onClick={() => setAuthModalOpen(true)}>
Sign In
</button>
)}
{!authLoading && user && (
<div className="user-menu-wrap">
<button
className="user-avatar"
onClick={() => setUserMenuOpen((o) => !o)}
aria-label="User menu"
>
{user.displayName.charAt(0).toUpperCase()}
</button>
{userMenuOpen && (
<div className="user-menu">
<div className="user-menu-header">
<div className="user-menu-name">{user.displayName}</div>
<div className="user-menu-email">{user.email}</div>
</div>
<div className="user-menu-divider" />
<button className="user-menu-item" onClick={() => { setUserMenuOpen(false); logout() }}>
Sign Out
</button>
</div>
)}
</div>
)}
</div>
</header>
@@ -101,6 +280,20 @@ export default function App() {
<Results exam={exam} state={examState} onRestart={restart} onExit={exit} reviewOnly={screen === 'review'} />
)}
{tweaksOpen && <TweaksPanel tweaks={tweaks} update={updateTweak} onClose={() => setTweaksOpen(false)} />}
{authModalOpen && (
<AuthModal
onClose={() => setAuthModalOpen(false)}
onForgotPassword={() => setScreen('reset-password')}
/>
)}
</div>
)
}
export default function App() {
return (
<AuthProvider>
<AppInner />
</AuthProvider>
)
}
+201
View File
@@ -0,0 +1,201 @@
import { useState } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { ApiError } from '../utils/api'
interface Props {
onClose: () => void
onForgotPassword: () => void
}
type Tab = 'login' | 'register'
export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>) {
const { login, register } = useAuth()
const [tab, setTab] = useState<Tab>('login')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [displayName, setDisplayName] = useState('')
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const switchTab = (t: Tab) => {
setTab(t)
setError(null)
setSuccess(null)
}
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setSubmitting(true)
try {
await login(email, password)
onClose()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Login failed')
} finally {
setSubmitting(false)
}
}
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
setSubmitting(true)
try {
const message = await register(email, password, displayName)
setSuccess(message)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Registration failed')
} finally {
setSubmitting(false)
}
}
return (
<div className="auth-overlay" onClick={onClose}>
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
<button className="auth-close" onClick={onClose} aria-label="Close">×</button>
<div className="auth-header">
<div className="brand-mark" />
<h2 className="auth-title">
{tab === 'login' ? 'Welcome back' : 'Create account'}
</h2>
<p className="auth-subtitle">
{tab === 'login'
? 'Sign in to access your saved exams and results'
: 'Create an account to save your progress'}
</p>
</div>
<div className="auth-tabs">
<button
className={`auth-tab ${tab === 'login' ? 'active' : ''}`}
onClick={() => switchTab('login')}
>
Sign In
</button>
<button
className={`auth-tab ${tab === 'register' ? 'active' : ''}`}
onClick={() => switchTab('register')}
>
Register
</button>
</div>
{error && <div className="auth-error">{error}</div>}
{success && <div className="auth-success">{success}</div>}
{!success && tab === 'login' && (
<form onSubmit={(e) => void handleLogin(e)} className="auth-form">
<div className="auth-field">
<label className="auth-label" htmlFor="login-email">Email</label>
<input
id="login-email"
className="input"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
autoComplete="email"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="login-password">Password</label>
<input
id="login-password"
className="input"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
autoComplete="current-password"
/>
</div>
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
{submitting ? 'Signing in…' : 'Sign In'}
</button>
<button
type="button"
className="auth-forgot"
onClick={() => { onClose(); onForgotPassword() }}
>
Forgot your password?
</button>
</form>
)}
{!success && tab === 'register' && (
<form onSubmit={(e) => void handleRegister(e)} className="auth-form">
<div className="auth-field">
<label className="auth-label" htmlFor="reg-name">Display Name</label>
<input
id="reg-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="reg-email">Email</label>
<input
id="reg-email"
className="input"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
autoComplete="email"
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="reg-password">Password</label>
<input
id="reg-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="reg-confirm">Confirm Password</label>
<input
id="reg-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" type="submit" disabled={submitting}>
{submitting ? 'Creating account…' : 'Create Account'}
</button>
</form>
)}
</div>
</div>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { useState, useEffect } from 'react'
import { apiFetch, ApiError } from '../utils/api'
interface Props {
onDone: () => void
}
export default function EmailVerify({ onDone }: Readonly<Props>) {
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const [message, setMessage] = useState('')
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const token = params.get('token')
if (!token) {
setStatus('error')
setMessage('No verification token found.')
return
}
apiFetch<{ message: string }>(`/api/auth/verify-email?token=${encodeURIComponent(token)}`)
.then((data) => {
setStatus('success')
setMessage(data.message)
// Clean up URL
window.history.replaceState({}, '', '/')
})
.catch((err) => {
setStatus('error')
setMessage(err instanceof ApiError ? err.message : 'Verification failed')
})
}, [])
return (
<div className="verify-page">
<div className="verify-card">
{status === 'loading' && (
<>
<div className="verify-icon">⏳</div>
<h2 className="verify-title">Verifying your email…</h2>
<p className="verify-message">Please wait while we confirm your email address.</p>
</>
)}
{status === 'success' && (
<>
<div className="verify-icon success">✓</div>
<h2 className="verify-title">Email Verified</h2>
<p className="verify-message">{message}</p>
<button className="btn btn-primary" onClick={onDone} style={{ marginTop: 24 }}>
Sign In →
</button>
</>
)}
{status === 'error' && (
<>
<div className="verify-icon error">✗</div>
<h2 className="verify-title">Verification Failed</h2>
<p className="verify-message">{message}</p>
<button className="btn" onClick={onDone} style={{ marginTop: 24 }}>
Back to Home
</button>
</>
)}
</div>
</div>
)
}
+164
View File
@@ -0,0 +1,164 @@
import { useState, useEffect } from 'react'
import { apiFetch, ApiError } from '../utils/api'
interface Props {
onDone: () => void
}
type Step = 'request' | 'reset' | 'success'
export default function ResetPassword({ onDone }: Readonly<Props>) {
const [step, setStep] = useState<Step>('request')
const [email, setEmail] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [token, setToken] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const t = params.get('token')
if (t) {
setToken(t)
setStep('reset')
window.history.replaceState({}, '', '/reset-password')
}
}, [])
const handleRequestReset = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setSubmitting(true)
try {
const data = await apiFetch<{ message: string }>('/api/auth/forgot-password', {
method: 'POST',
body: JSON.stringify({ email }),
})
setMessage(data.message)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Request failed')
} finally {
setSubmitting(false)
}
}
const handleReset = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (newPassword !== confirmPassword) {
setError('Passwords do not match')
return
}
setSubmitting(true)
try {
await apiFetch('/api/auth/reset-password', {
method: 'POST',
body: JSON.stringify({ token, newPassword }),
})
setStep('success')
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Password reset failed')
} finally {
setSubmitting(false)
}
}
return (
<div className="verify-page">
<div className="verify-card" style={{ maxWidth: 420 }}>
{step === 'request' && !message && (
<>
<div className="verify-icon">🔑</div>
<h2 className="verify-title">Reset Password</h2>
<p className="verify-message">Enter your email address and we'll send you a link to reset your password.</p>
{error && <div className="auth-error" style={{ marginBottom: 16 }}>{error}</div>}
<form onSubmit={(e) => void handleRequestReset(e)} className="auth-form" style={{ marginTop: 16 }}>
<div className="auth-field">
<label className="auth-label" htmlFor="reset-email">Email</label>
<input
id="reset-email"
className="input"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
/>
</div>
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
{submitting ? 'Sending…' : 'Send Reset Link'}
</button>
<button type="button" className="auth-forgot" onClick={onDone}>
← Back to sign in
</button>
</form>
</>
)}
{step === 'request' && message && (
<>
<div className="verify-icon success">📧</div>
<h2 className="verify-title">Check Your Email</h2>
<p className="verify-message">{message}</p>
<button className="btn" onClick={onDone} style={{ marginTop: 24 }}>
Back to Home
</button>
</>
)}
{step === 'reset' && (
<>
<div className="verify-icon">🔐</div>
<h2 className="verify-title">Set New Password</h2>
<p className="verify-message">Enter your new password below.</p>
{error && <div className="auth-error" style={{ marginBottom: 16 }}>{error}</div>}
<form onSubmit={(e) => void handleReset(e)} className="auth-form" style={{ marginTop: 16 }}>
<div className="auth-field">
<label className="auth-label" htmlFor="new-password">New Password</label>
<input
id="new-password"
className="input"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Min. 8 characters"
required
minLength={8}
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="confirm-new-password">Confirm Password</label>
<input
id="confirm-new-password"
className="input"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
minLength={8}
/>
</div>
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
{submitting ? 'Resetting…' : 'Reset Password'}
</button>
</form>
</>
)}
{step === 'success' && (
<>
<div className="verify-icon success">✓</div>
<h2 className="verify-title">Password Updated</h2>
<p className="verify-message">Your password has been reset successfully. You can now sign in with your new password.</p>
<button className="btn btn-primary" onClick={onDone} style={{ marginTop: 24 }}>
Sign In →
</button>
</>
)}
</div>
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
import type { ReactNode } from 'react'
import { apiFetch, setToken, clearToken, isAuthenticated } from '../utils/api'
export interface User {
id: number
email: string
displayName: string
}
interface AuthContextValue {
user: User | null
loading: boolean
login: (email: string, password: string) => Promise<void>
register: (email: string, password: string, displayName: string) => Promise<string>
logout: () => void
refreshUser: () => Promise<void>
}
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 refreshUser = useCallback(async () => {
if (!isAuthenticated()) {
setUser(null)
setLoading(false)
return
}
try {
const data = await apiFetch<{ id: number; email: string; displayName: string }>('/api/auth/me')
setUser({ id: data.id, email: data.email, displayName: data.displayName })
} catch {
clearToken()
setUser(null)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void refreshUser()
}, [refreshUser])
const login = useCallback(async (email: string, password: string) => {
const data = await apiFetch<{ token: string; user: User }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
setToken(data.token)
setUser(data.user)
}, [])
const register = useCallback(async (email: string, password: string, displayName: string): Promise<string> => {
const data = await apiFetch<{ message: string }>('/api/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password, displayName }),
})
return data.message
}, [])
const logout = useCallback(() => {
clearToken()
setUser(null)
}, [])
return (
<AuthContext.Provider value={{ user, loading, login, register, logout, refreshUser }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within an AuthProvider')
return ctx
}
+256
View File
@@ -1024,4 +1024,260 @@ input, textarea, select { font-family: inherit; font-size: inherit; color: inher
.landing-grid { grid-template-columns: 1fr; }
.score-grid { grid-template-columns: 1fr; }
.score-cell { border-right: none; border-bottom: 1px solid var(--line); }
.auth-modal { width: 95vw; min-width: 0; }
}
/* ---------- Auth Modal ---------- */
.auth-overlay {
position: fixed;
inset: 0;
z-index: 200;
background: oklch(0 0 0 / 0.45);
backdrop-filter: blur(6px);
display: grid;
place-items: center;
animation: auth-fade-in 200ms ease;
}
@keyframes auth-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.auth-modal {
position: relative;
width: 400px;
max-height: 90vh;
overflow-y: auto;
background: var(--paper);
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
box-shadow: 0 8px 40px oklch(0 0 0 / 0.12), 0 2px 8px oklch(0 0 0 / 0.06);
padding: 36px 32px 32px;
animation: auth-slide-up 250ms ease;
}
@keyframes auth-slide-up {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.auth-close {
position: absolute;
top: 12px; right: 14px;
background: none; border: none;
font-size: 22px; color: var(--ink-3);
cursor: pointer;
width: 32px; height: 32px;
display: grid; place-items: center;
border-radius: var(--r-sm);
transition: background 120ms ease, color 120ms ease;
}
.auth-close:hover { background: var(--paper-2); color: var(--ink); }
.auth-header {
text-align: center;
margin-bottom: 24px;
}
.auth-header .brand-mark {
margin: 0 auto 16px;
}
.auth-title {
font-family: var(--font-serif);
font-size: 28px;
font-weight: 400;
letter-spacing: -0.02em;
line-height: 1.1;
margin: 0 0 8px;
}
.auth-subtitle {
font-size: 14px;
color: var(--ink-3);
margin: 0;
line-height: 1.5;
}
.auth-tabs {
display: flex;
gap: 4px;
background: var(--paper-2);
padding: 3px;
border-radius: var(--r-sm);
border: 1px solid var(--line);
margin-bottom: 24px;
}
.auth-tab {
flex: 1;
padding: 8px 14px;
border: none;
background: transparent;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
color: var(--ink-3);
text-align: center;
transition: all 150ms ease;
}
.auth-tab.active {
background: var(--paper);
color: var(--ink);
box-shadow: var(--shadow-sm);
}
.auth-tab:hover:not(.active) { color: var(--ink-2); }
.auth-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.auth-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.auth-label {
font-family: var(--font-mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--ink-3);
font-weight: 500;
}
.auth-submit {
width: 100%;
justify-content: center;
padding: 11px 14px;
font-size: 14px;
margin-top: 4px;
}
.auth-forgot {
background: none;
border: none;
font-size: 13px;
color: var(--accent);
cursor: pointer;
text-align: center;
padding: 4px;
transition: color 120ms ease;
}
.auth-forgot:hover { color: var(--accent-ink); text-decoration: underline; }
.auth-error {
padding: 10px 14px;
border: 1px solid var(--wrong);
border-radius: var(--r-sm);
background: var(--wrong-weak);
color: var(--wrong);
font-size: 13px;
line-height: 1.5;
}
.auth-success {
padding: 10px 14px;
border: 1px solid var(--correct);
border-radius: var(--r-sm);
background: var(--correct-weak);
color: var(--correct);
font-size: 13px;
line-height: 1.5;
}
/* ---------- User Menu ---------- */
.user-menu-wrap {
position: relative;
}
.user-avatar {
width: 32px; height: 32px;
border-radius: 50%;
background: var(--accent);
color: white;
border: none;
font-size: 14px;
font-weight: 600;
font-family: var(--font-sans);
cursor: pointer;
display: grid;
place-items: center;
transition: opacity 150ms ease, box-shadow 150ms ease;
}
.user-avatar:hover {
opacity: 0.85;
box-shadow: 0 0 0 3px var(--accent-weak);
}
.user-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 220px;
background: var(--paper);
border: 1px solid var(--line-2);
border-radius: var(--r-md);
box-shadow: var(--shadow-md);
z-index: 50;
overflow: hidden;
animation: auth-slide-up 150ms ease;
}
.user-menu-header {
padding: 14px 16px;
}
.user-menu-name {
font-weight: 500;
font-size: 14px;
margin-bottom: 2px;
}
.user-menu-email {
font-size: 12px;
color: var(--ink-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-menu-divider {
height: 1px;
background: var(--line);
}
.user-menu-item {
width: 100%;
padding: 10px 16px;
border: none;
background: none;
text-align: left;
font-size: 13.5px;
color: var(--ink-2);
cursor: pointer;
transition: background 100ms ease;
}
.user-menu-item:hover {
background: var(--paper-2);
color: var(--ink);
}
/* ---------- Verify / Reset Pages ---------- */
.verify-page {
display: grid;
place-items: center;
min-height: calc(100vh - 57px);
padding: 32px;
}
.verify-card {
text-align: center;
max-width: 480px;
padding: 48px 40px;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-md);
}
.verify-icon {
font-size: 48px;
margin-bottom: 20px;
line-height: 1;
}
.verify-icon.success { color: var(--correct); }
.verify-icon.error { color: var(--wrong); }
.verify-title {
font-family: var(--font-serif);
font-size: 28px;
font-weight: 400;
letter-spacing: -0.02em;
margin: 0 0 12px;
}
.verify-message {
font-size: 14.5px;
color: var(--ink-2);
line-height: 1.6;
margin: 0;
}
+1 -1
View File
@@ -112,7 +112,7 @@ export interface GradeResult {
points: number
}
export type AppScreen = 'landing' | 'exam' | 'results' | 'review'
export type AppScreen = 'landing' | 'exam' | 'results' | 'review' | 'verify' | 'reset-password'
export type ThemeMode = 'light' | 'dark'
export type FontSize = 'small' | 'medium' | 'large'
export type FeedbackTweak = 'auto' | 'immediate' | 'end'
+57
View File
@@ -0,0 +1,57 @@
const API_BASE = '' // proxied via Vite in dev
function getToken(): string | null {
try {
return localStorage.getItem('brainwave.auth.token')
} catch {
return null
}
}
export function setToken(token: string): void {
localStorage.setItem('brainwave.auth.token', token)
}
export function clearToken(): void {
localStorage.removeItem('brainwave.auth.token')
}
export function isAuthenticated(): boolean {
return !!getToken()
}
export async function apiFetch<T = unknown>(
path: string,
options: RequestInit = {},
): Promise<T> {
const token = getToken()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> ?? {}),
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
})
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }))
throw new ApiError(res.status, (body as { error?: string }).error ?? 'Request failed')
}
return res.json() as Promise<T>
}
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message)
this.name = 'ApiError'
}
}