Public Access
feat: initialize database and authentication routes in backend
This commit is contained in:
1 parent
be69db04a5
commit
eb013d4978
22 files changed
+1808
-8
No files matched your search
@@ -0,0 +1,3 @@
|
||||
backend/brainwave.db-shm
|
||||
backend/brainwave.db-wal
|
||||
backend/package-lock.json
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.db
|
||||
.env
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "brainwave-backend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^7.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cors": "^2.8.18",
|
||||
"@types/express": "^5.0.2",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "~6.0.2",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { runMigrations } from './migrations.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const DB_PATH = path.join(__dirname, '..', '..', 'brainwave.db')
|
||||
|
||||
let db: Database.Database | null = null
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (!db) {
|
||||
db = new Database(DB_PATH)
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('foreign_keys = ON')
|
||||
runMigrations(db)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
if (db) {
|
||||
db.close()
|
||||
db = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type Database from 'better-sqlite3'
|
||||
|
||||
export function runMigrations(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
email_verified INTEGER DEFAULT 0,
|
||||
verify_token TEXT,
|
||||
verify_expires INTEGER,
|
||||
reset_token TEXT,
|
||||
reset_expires INTEGER,
|
||||
created_at INTEGER DEFAULT (unixepoch()),
|
||||
updated_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saved_exams (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
subtitle TEXT,
|
||||
exam_json TEXT NOT NULL,
|
||||
created_at INTEGER DEFAULT (unixepoch()),
|
||||
updated_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS exam_results (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
exam_id INTEGER REFERENCES saved_exams(id) ON DELETE SET NULL,
|
||||
title TEXT NOT NULL,
|
||||
score_points REAL NOT NULL,
|
||||
total_points REAL NOT NULL,
|
||||
correct_count INTEGER NOT NULL,
|
||||
graded_count INTEGER NOT NULL,
|
||||
passing_score REAL DEFAULT 60,
|
||||
elapsed_ms INTEGER NOT NULL,
|
||||
state_json TEXT NOT NULL,
|
||||
exam_json TEXT NOT NULL,
|
||||
completed_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_saved_exams_user ON saved_exams(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_exam_results_user ON exam_results(user_id);
|
||||
`)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import { getDb, closeDb } from './db/database.js'
|
||||
import authRoutes from './routes/auth.js'
|
||||
import examRoutes from './routes/exams.js'
|
||||
import resultRoutes from './routes/results.js'
|
||||
|
||||
const PORT = Number(process.env['PORT'] ?? '3001')
|
||||
|
||||
const app = express()
|
||||
|
||||
app.use(cors({ origin: process.env['CORS_ORIGIN'] ?? 'http://localhost:5173' }))
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
|
||||
// Initialize database on startup
|
||||
getDb()
|
||||
console.log('✓ Database initialized')
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes)
|
||||
app.use('/api/exams', examRoutes)
|
||||
app.use('/api/results', resultRoutes)
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: Date.now() })
|
||||
})
|
||||
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`🧠 BrainWave API running on http://localhost:${PORT}`)
|
||||
})
|
||||
|
||||
// Graceful shutdown
|
||||
function shutdown() {
|
||||
console.log('\nShutting down...')
|
||||
server.close()
|
||||
closeDb()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown)
|
||||
process.on('SIGTERM', shutdown)
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import { verifyToken, type JwtPayload } from '../utils/tokens.js'
|
||||
|
||||
declare module 'express' {
|
||||
interface Request {
|
||||
user?: JwtPayload
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const header = req.headers.authorization
|
||||
if (!header?.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'Authentication required' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const token = header.slice(7)
|
||||
req.user = verifyToken(token)
|
||||
next()
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid or expired token' })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { Router } from 'express'
|
||||
import bcrypt from 'bcrypt'
|
||||
import { getDb } from '../db/database.js'
|
||||
import { signToken, generateVerificationToken } from '../utils/tokens.js'
|
||||
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
const BCRYPT_ROUNDS = 12
|
||||
const VERIFY_EXPIRY_MS = 24 * 60 * 60 * 1000 // 24 hours
|
||||
const RESET_EXPIRY_MS = 60 * 60 * 1000 // 1 hour
|
||||
|
||||
// ─── Register ────────────────────────────────────────────
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { email, password, displayName } = req.body
|
||||
|
||||
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()
|
||||
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email)
|
||||
if (existing) {
|
||||
res.status(409).json({ error: 'An account with this email already exists' })
|
||||
return
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS)
|
||||
const verifyToken = generateVerificationToken()
|
||||
const verifyExpires = Date.now() + VERIFY_EXPIRY_MS
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO users (email, password_hash, display_name, verify_token, verify_expires)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(email.toLowerCase().trim(), passwordHash, displayName.trim(), verifyToken, verifyExpires)
|
||||
|
||||
await sendVerificationEmail(email, verifyToken)
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Account created. Please check your email to verify your account.',
|
||||
userId: result.lastInsertRowid,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Register error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Login ───────────────────────────────────────────────
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body
|
||||
|
||||
if (!email || !password) {
|
||||
res.status(400).json({ error: 'Email and password are required' })
|
||||
return
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(
|
||||
(email as string).toLowerCase().trim()
|
||||
) as Record<string, unknown> | undefined
|
||||
|
||||
if (!user) {
|
||||
res.status(401).json({ error: 'Invalid email or password' })
|
||||
return
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password as string, user['password_hash'] as string)
|
||||
if (!valid) {
|
||||
res.status(401).json({ error: 'Invalid email or password' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!user['email_verified']) {
|
||||
res.status(403).json({ error: 'Please verify your email before logging in' })
|
||||
return
|
||||
}
|
||||
|
||||
const token = signToken({
|
||||
userId: user['id'] as number,
|
||||
email: user['email'] as string,
|
||||
displayName: user['display_name'] as string,
|
||||
})
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
id: user['id'],
|
||||
email: user['email'],
|
||||
displayName: user['display_name'],
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Login error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Verify Email ────────────────────────────────────────
|
||||
router.get('/verify-email', (req, res) => {
|
||||
try {
|
||||
const { token } = req.query
|
||||
|
||||
if (!token || typeof token !== 'string') {
|
||||
res.status(400).json({ error: 'Verification token is required' })
|
||||
return
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const user = db.prepare('SELECT * FROM users WHERE verify_token = ?').get(token) as Record<string, unknown> | undefined
|
||||
|
||||
if (!user) {
|
||||
res.status(400).json({ error: 'Invalid verification token' })
|
||||
return
|
||||
}
|
||||
|
||||
if ((user['verify_expires'] as number) < Date.now()) {
|
||||
res.status(400).json({ error: 'Verification token has expired. Please register again.' })
|
||||
return
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE users SET email_verified = 1, verify_token = NULL, verify_expires = NULL, updated_at = unixepoch()
|
||||
WHERE id = ?
|
||||
`).run(user['id'])
|
||||
|
||||
res.json({ message: 'Email verified successfully. You can now log in.' })
|
||||
} catch (err) {
|
||||
console.error('Verify error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Forgot Password ────────────────────────────────────
|
||||
router.post('/forgot-password', async (req, res) => {
|
||||
try {
|
||||
const { email } = req.body
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
if (!email || typeof email !== 'string') {
|
||||
res.json({ message: 'If an account exists with that email, a reset link has been sent.' })
|
||||
return
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const user = db.prepare('SELECT id, email_verified FROM users WHERE email = ?').get(
|
||||
email.toLowerCase().trim()
|
||||
) as Record<string, unknown> | undefined
|
||||
|
||||
if (user && user['email_verified']) {
|
||||
const resetToken = generateVerificationToken()
|
||||
const resetExpires = Date.now() + RESET_EXPIRY_MS
|
||||
|
||||
db.prepare(`
|
||||
UPDATE users SET reset_token = ?, reset_expires = ?, updated_at = unixepoch()
|
||||
WHERE id = ?
|
||||
`).run(resetToken, resetExpires, user['id'])
|
||||
|
||||
await sendPasswordResetEmail(email, resetToken)
|
||||
}
|
||||
|
||||
res.json({ message: 'If an account exists with that email, a reset link has been sent.' })
|
||||
} catch (err) {
|
||||
console.error('Forgot password error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Reset Password ─────────────────────────────────────
|
||||
router.post('/reset-password', async (req, res) => {
|
||||
try {
|
||||
const { token, newPassword } = req.body
|
||||
|
||||
if (!token || typeof token !== 'string') {
|
||||
res.status(400).json({ error: 'Reset token is required' })
|
||||
return
|
||||
}
|
||||
if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 8) {
|
||||
res.status(400).json({ error: 'New password must be at least 8 characters' })
|
||||
return
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const user = db.prepare('SELECT * FROM users WHERE reset_token = ?').get(token) as Record<string, unknown> | undefined
|
||||
|
||||
if (!user) {
|
||||
res.status(400).json({ error: 'Invalid reset token' })
|
||||
return
|
||||
}
|
||||
|
||||
if ((user['reset_expires'] as number) < Date.now()) {
|
||||
res.status(400).json({ error: 'Reset token has expired. Please request a new one.' })
|
||||
return
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS)
|
||||
|
||||
db.prepare(`
|
||||
UPDATE users SET password_hash = ?, reset_token = NULL, reset_expires = NULL, updated_at = unixepoch()
|
||||
WHERE id = ?
|
||||
`).run(passwordHash, user['id'])
|
||||
|
||||
res.json({ message: 'Password updated successfully. You can now log in with your new password.' })
|
||||
} catch (err) {
|
||||
console.error('Reset password error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Get Current User ────────────────────────────────────
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
const db = getDb()
|
||||
const user = db.prepare('SELECT id, email, display_name, created_at FROM users WHERE id = ?').get(
|
||||
req.user!.userId
|
||||
) as Record<string, unknown> | undefined
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' })
|
||||
return
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: user['id'],
|
||||
email: user['email'],
|
||||
displayName: user['display_name'],
|
||||
createdAt: user['created_at'],
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb } from '../db/database.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ─── List User's Saved Exams ─────────────────────────────
|
||||
router.get('/', requireAuth, (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const exams = db.prepare(`
|
||||
SELECT id, title, subtitle, created_at, updated_at,
|
||||
json_array_length(json_extract(exam_json, '$.questions')) AS question_count
|
||||
FROM saved_exams
|
||||
WHERE user_id = ?
|
||||
ORDER BY updated_at DESC
|
||||
`).all(req.user!.userId)
|
||||
|
||||
res.json(exams)
|
||||
} catch (err) {
|
||||
console.error('List exams error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Get Full Exam ───────────────────────────────────────
|
||||
router.get('/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const exam = db.prepare(`
|
||||
SELECT * FROM saved_exams WHERE id = ? AND user_id = ?
|
||||
`).get(Number(req.params['id']), req.user!.userId) as Record<string, unknown> | undefined
|
||||
|
||||
if (!exam) {
|
||||
res.status(404).json({ error: 'Exam not found' })
|
||||
return
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: exam['id'],
|
||||
title: exam['title'],
|
||||
subtitle: exam['subtitle'],
|
||||
exam: JSON.parse(exam['exam_json'] as string),
|
||||
createdAt: exam['created_at'],
|
||||
updatedAt: exam['updated_at'],
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Get exam error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Save Exam ───────────────────────────────────────────
|
||||
router.post('/', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { exam } = req.body
|
||||
|
||||
if (!exam || !exam.title || !exam.questions?.length) {
|
||||
res.status(400).json({ error: 'Valid exam data with title and questions is required' })
|
||||
return
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
INSERT INTO saved_exams (user_id, title, subtitle, exam_json)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(req.user!.userId, exam.title, exam.subtitle ?? null, JSON.stringify(exam))
|
||||
|
||||
res.status(201).json({
|
||||
id: result.lastInsertRowid,
|
||||
message: 'Exam saved successfully',
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Save exam error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Delete Exam ─────────────────────────────────────────
|
||||
router.delete('/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
DELETE FROM saved_exams WHERE id = ? AND user_id = ?
|
||||
`).run(Number(req.params['id']), req.user!.userId)
|
||||
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ error: 'Exam not found' })
|
||||
return
|
||||
}
|
||||
|
||||
res.json({ message: 'Exam deleted' })
|
||||
} catch (err) {
|
||||
console.error('Delete exam error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb } from '../db/database.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ─── List User's Results ─────────────────────────────────
|
||||
router.get('/', requireAuth, (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(Number(req.query['limit']) || 30, 100)
|
||||
const offset = Number(req.query['offset']) || 0
|
||||
|
||||
const db = getDb()
|
||||
const results = db.prepare(`
|
||||
SELECT id, title, score_points, total_points, correct_count, graded_count,
|
||||
passing_score, elapsed_ms, completed_at, exam_id
|
||||
FROM exam_results
|
||||
WHERE user_id = ?
|
||||
ORDER BY completed_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(req.user!.userId, limit, offset)
|
||||
|
||||
const countRow = db.prepare(
|
||||
'SELECT COUNT(*) AS total FROM exam_results WHERE user_id = ?'
|
||||
).get(req.user!.userId) as { total: number }
|
||||
|
||||
res.json({ results, total: countRow.total })
|
||||
} catch (err) {
|
||||
console.error('List results error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Get Single Result ───────────────────────────────────
|
||||
router.get('/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
SELECT * FROM exam_results WHERE id = ? AND user_id = ?
|
||||
`).get(Number(req.params['id']), req.user!.userId) as Record<string, unknown> | undefined
|
||||
|
||||
if (!result) {
|
||||
res.status(404).json({ error: 'Result not found' })
|
||||
return
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: result['id'],
|
||||
title: result['title'],
|
||||
scorePoints: result['score_points'],
|
||||
totalPoints: result['total_points'],
|
||||
correctCount: result['correct_count'],
|
||||
gradedCount: result['graded_count'],
|
||||
passingScore: result['passing_score'],
|
||||
elapsedMs: result['elapsed_ms'],
|
||||
completedAt: result['completed_at'],
|
||||
examId: result['exam_id'],
|
||||
state: JSON.parse(result['state_json'] as string),
|
||||
exam: JSON.parse(result['exam_json'] as string),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Get result error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Save Result ─────────────────────────────────────────
|
||||
router.post('/', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { title, scorePoints, totalPoints, correctCount, gradedCount, passingScore, elapsedMs, state, exam, examId } = req.body
|
||||
|
||||
if (
|
||||
!title || scorePoints == null || totalPoints == null ||
|
||||
correctCount == null || gradedCount == null || !state || !exam
|
||||
) {
|
||||
res.status(400).json({ error: 'Missing required result fields' })
|
||||
return
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
INSERT INTO exam_results (user_id, exam_id, title, score_points, total_points, correct_count, graded_count, passing_score, elapsed_ms, state_json, exam_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
req.user!.userId,
|
||||
examId ?? null,
|
||||
title,
|
||||
scorePoints,
|
||||
totalPoints,
|
||||
correctCount,
|
||||
gradedCount,
|
||||
passingScore ?? 60,
|
||||
elapsedMs ?? 0,
|
||||
JSON.stringify(state),
|
||||
JSON.stringify(exam),
|
||||
)
|
||||
|
||||
res.status(201).json({
|
||||
id: result.lastInsertRowid,
|
||||
message: 'Result saved successfully',
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Save result error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Delete Result ───────────────────────────────────────
|
||||
router.delete('/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
const db = getDb()
|
||||
const result = db.prepare(`
|
||||
DELETE FROM exam_results WHERE id = ? AND user_id = ?
|
||||
`).run(Number(req.params['id']), req.user!.userId)
|
||||
|
||||
if (result.changes === 0) {
|
||||
res.status(404).json({ error: 'Result not found' })
|
||||
return
|
||||
}
|
||||
|
||||
res.json({ message: 'Result deleted' })
|
||||
} catch (err) {
|
||||
console.error('Delete result error:', err)
|
||||
res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,81 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
const APP_URL = process.env['APP_URL'] ?? 'http://localhost:5173'
|
||||
|
||||
const SMTP_HOST = process.env['SMTP_HOST']
|
||||
const SMTP_PORT = Number(process.env['SMTP_PORT'] ?? '587')
|
||||
const SMTP_USER = process.env['SMTP_USER']
|
||||
const SMTP_PASS = process.env['SMTP_PASS']
|
||||
const SMTP_FROM = process.env['SMTP_FROM'] ?? 'BrainWave <noreply@brainwave.local>'
|
||||
|
||||
const isDev = !SMTP_HOST
|
||||
|
||||
let transporter: nodemailer.Transporter | null = null
|
||||
|
||||
function getTransporter(): nodemailer.Transporter | null {
|
||||
if (isDev) return null
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_PORT === 465,
|
||||
auth: { user: SMTP_USER, pass: SMTP_PASS },
|
||||
})
|
||||
}
|
||||
return transporter
|
||||
}
|
||||
|
||||
async function sendMail(to: string, subject: string, html: string): Promise<void> {
|
||||
const t = getTransporter()
|
||||
if (!t) {
|
||||
console.log('\n══════════════════════════════════════════════════')
|
||||
console.log(`📧 EMAIL (dev mode) → ${to}`)
|
||||
console.log(` Subject: ${subject}`)
|
||||
console.log(` Body:\n${html.replace(/<[^>]*>/g, '')}`)
|
||||
console.log('══════════════════════════════════════════════════\n')
|
||||
return
|
||||
}
|
||||
await t.sendMail({ from: SMTP_FROM, to, subject, html })
|
||||
}
|
||||
|
||||
export async function sendVerificationEmail(to: string, token: string): Promise<void> {
|
||||
const link = `${APP_URL}/verify?token=${token}`
|
||||
await sendMail(to, 'Verify your BrainWave account', `
|
||||
<div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
|
||||
<h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Welcome to BrainWave</h2>
|
||||
<p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;">
|
||||
Click the button below to verify your email address and activate your account.
|
||||
</p>
|
||||
<a href="${link}" style="display: inline-block; padding: 12px 28px; background: #1a1a2e; color: #fff; text-decoration: none; border-radius: 10px; font-size: 14px; font-weight: 500;">
|
||||
Verify Email
|
||||
</a>
|
||||
<p style="font-size: 13px; color: #888; margin-top: 32px; line-height: 1.5;">
|
||||
Or copy this link: <br/><code style="word-break: break-all;">${link}</code>
|
||||
</p>
|
||||
<p style="font-size: 12px; color: #aaa; margin-top: 24px;">
|
||||
This link expires in 24 hours. If you didn't create an account, you can ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
`)
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail(to: string, token: string): Promise<void> {
|
||||
const link = `${APP_URL}/reset-password?token=${token}`
|
||||
await sendMail(to, 'Reset your BrainWave password', `
|
||||
<div style="font-family: Inter, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 24px;">
|
||||
<h2 style="font-size: 24px; margin: 0 0 16px; color: #1a1a2e;">Password Reset</h2>
|
||||
<p style="font-size: 15px; color: #444; line-height: 1.6; margin: 0 0 24px;">
|
||||
We received a request to reset your password. Click the button below to set a new one.
|
||||
</p>
|
||||
<a href="${link}" style="display: inline-block; padding: 12px 28px; background: #1a1a2e; color: #fff; text-decoration: none; border-radius: 10px; font-size: 14px; font-weight: 500;">
|
||||
Reset Password
|
||||
</a>
|
||||
<p style="font-size: 13px; color: #888; margin-top: 32px; line-height: 1.5;">
|
||||
Or copy this link: <br/><code style="word-break: break-all;">${link}</code>
|
||||
</p>
|
||||
<p style="font-size: 12px; color: #aaa; margin-top: 24px;">
|
||||
This link expires in 1 hour. If you didn't request a password reset, you can ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
`)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import crypto from 'crypto'
|
||||
|
||||
const JWT_SECRET = process.env['JWT_SECRET'] ?? 'brainwave-dev-secret-change-in-production'
|
||||
|
||||
export interface JwtPayload {
|
||||
userId: number
|
||||
email: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export function signToken(payload: JwtPayload): string {
|
||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: '24h' })
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): JwtPayload {
|
||||
return jwt.verify(token, JWT_SECRET) as JwtPayload
|
||||
}
|
||||
|
||||
export function generateVerificationToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
+200
-7
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,17 @@ export default defineConfig({
|
||||
react(),
|
||||
babel({ presets: [reactCompilerPreset()] })
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user