feat: implement retake exam functionality and update translations for exam status #2

Merged
LaurensBartelds merged 3 commits from fixes into main 2026-04-27 13:08:23 +02:00
25 changed files with 254 additions and 50 deletions
Showing only changes of commit 1b5fd04040 - Show all commits

No files matched your search

+1 -3
View File
@@ -27,10 +27,8 @@ jobs:
echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV" echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV"
- name: Write Inventory - name: Write Inventory
# StrictHostKeyChecking=no is intentional for a known home-network host.
# To harden, add the host fingerprint as a secret and use a known_hosts file.
run: | run: |
printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s ansible_ssh_common_args="-o StrictHostKeyChecking=no -o ForwardAgent=yes"\n' \ printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s\n' \
"${{ secrets.ANSIBLE_HOST }}" \ "${{ secrets.ANSIBLE_HOST }}" \
"${{ secrets.ANSIBLE_USER }}" > /tmp/inventory.ini "${{ secrets.ANSIBLE_USER }}" > /tmp/inventory.ini
+10
View File
@@ -14,6 +14,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^5.1.0", "express": "^5.1.0",
"express-rate-limit": "^7.5.0", "express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"nodemailer": "^7.0.3", "nodemailer": "^7.0.3",
"zod": "^3.24.2" "zod": "^3.24.2"
@@ -1862,6 +1863,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+1
View File
@@ -16,6 +16,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^5.1.0", "express": "^5.1.0",
"express-rate-limit": "^7.5.0", "express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"nodemailer": "^7.0.3", "nodemailer": "^7.0.3",
"zod": "^3.24.2" "zod": "^3.24.2"
+1 -1
View File
@@ -28,7 +28,7 @@ export const config = {
jwtSecret: env('JWT_SECRET'), jwtSecret: env('JWT_SECRET'),
dbPath: env('DB_PATH', './db/brainwave.db'), dbPath: env('DB_PATH', './db/brainwave.db'),
corsOrigin: env('CORS_ORIGIN', 'http://localhost:5173'), corsOrigin: env('CORS_ORIGIN', 'http://localhost:5173'),
bodyLimit: env('BODY_LIMIT', '10mb'), bodyLimit: env('BODY_LIMIT', '100kb'),
email: { email: {
smtpHost: env('SMTP_HOST', ''), smtpHost: env('SMTP_HOST', ''),
+18 -1
View File
@@ -1,6 +1,8 @@
import 'dotenv/config' import 'dotenv/config'
import express from 'express' import express from 'express'
import cors from 'cors' import cors from 'cors'
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
import { getDb, closeDb } from './db/database.js' import { getDb, closeDb } from './db/database.js'
import authRoutes from './routes/auth.js' import authRoutes from './routes/auth.js'
import examRoutes from './routes/exams.js' import examRoutes from './routes/exams.js'
@@ -20,9 +22,25 @@ const PORT = config.port
const app = express() const app = express()
// Security headers
app.use(helmet())
// CORS
app.use(cors({ origin: config.corsOrigin })) app.use(cors({ origin: config.corsOrigin }))
// Body parsing with reasonable limit
app.use(express.json({ limit: config.bodyLimit })) app.use(express.json({ limit: config.bodyLimit }))
// Global API rate limiting
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 200,
message: { error: 'Too many requests, please try again later' },
standardHeaders: true,
legacyHeaders: false,
})
app.use('/api/', globalLimiter)
// Request logging // Request logging
app.use((req, res, next) => { app.use((req, res, next) => {
const start = Date.now() const start = Date.now()
@@ -63,7 +81,6 @@ app.get('/api/health', (_req, res) => {
res.status(overall === 'healthy' ? 200 : 503).json({ res.status(overall === 'healthy' ? 200 : 503).json({
status: overall, status: overall,
timestamp: Date.now(), timestamp: Date.now(),
version: process.env['npm_package_version'] ?? '0.0.1',
checks, checks,
}) })
}) })
+9 -1
View File
@@ -1,5 +1,6 @@
import type { Request, Response, NextFunction } from 'express' import type { Request, Response, NextFunction } from 'express'
import { verifyToken, type JwtPayload } from '../utils/tokens.js' import { verifyToken, hashToken, type JwtPayload } from '../utils/tokens.js'
import { isTokenBlacklisted } from '../utils/tokenBlacklist.js'
declare global { declare global {
namespace Express { namespace Express {
@@ -22,6 +23,13 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
try { try {
const token = header.slice(7) const token = header.slice(7)
req.user = verifyToken(token) req.user = verifyToken(token)
// Check if the token has been blacklisted (e.g., after logout)
if (isTokenBlacklisted(hashToken(token))) {
res.status(401).json({ error: 'Token has been revoked' })
return
}
next() next()
} catch { } catch {
res.status(401).json({ error: 'Invalid or expired token' }) res.status(401).json({ error: 'Invalid or expired token' })
+10 -4
View File
@@ -17,15 +17,21 @@ export function errorHandler(
_next: any _next: any
) { ) {
const statusCode = err instanceof AppError ? err.statusCode : 500; const statusCode = err instanceof AppError ? err.statusCode : 500;
const message = err.message || 'Internal Server Error'; const isOperational = err instanceof AppError && err.isOperational;
console.error(`[Error] ${statusCode} - ${message}`); // Log full details server-side
if (!(err instanceof AppError) || !err.isOperational) { console.error(`[Error] ${statusCode} - ${err.message}`);
if (!isOperational) {
console.error(err); console.error(err);
} }
// Never leak internal error details to the client for unexpected 500s
const clientMessage = statusCode === 500 && !isOperational
? 'Internal Server Error'
: (err.message || 'Internal Server Error');
res.status(statusCode).json({ res.status(statusCode).json({
status: 'error', status: 'error',
message, message: clientMessage,
}); });
} }
+4 -4
View File
@@ -63,14 +63,14 @@ export function getUserById(id: number): DbUser | undefined {
return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DbUser | undefined return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DbUser | undefined
} }
export function getUserByVerifyToken(token: string): DbUser | undefined { export function getUserByVerifyTokenHash(tokenHash: string): DbUser | undefined {
const db = getDb() const db = getDb()
return db.prepare('SELECT * FROM users WHERE verify_token = ?').get(token) as DbUser | undefined return db.prepare('SELECT * FROM users WHERE verify_token = ?').get(tokenHash) as DbUser | undefined
} }
export function getUserByResetToken(token: string): DbUser | undefined { export function getUserByResetTokenHash(tokenHash: string): DbUser | undefined {
const db = getDb() const db = getDb()
return db.prepare('SELECT * FROM users WHERE reset_token = ?').get(token) as DbUser | undefined return db.prepare('SELECT * FROM users WHERE reset_token = ?').get(tokenHash) as DbUser | undefined
} }
export function updateUser(id: number, data: UpdateUserData): boolean { export function updateUser(id: number, data: UpdateUserData): boolean {
+19
View File
@@ -5,6 +5,8 @@ import { requireAuth } from '../middleware/auth.js'
import { asyncHandler } from '../utils/asyncHandler.js' import { asyncHandler } from '../utils/asyncHandler.js'
import * as authService from '../services/authService.js' import * as authService from '../services/authService.js'
import * as userRepo from '../repositories/userRepo.js' import * as userRepo from '../repositories/userRepo.js'
import { hashToken } from '../utils/tokens.js'
import { blacklistToken } from '../utils/tokenBlacklist.js'
import { import {
registerSchema, registerSchema,
loginSchema, loginSchema,
@@ -113,6 +115,23 @@ router.post(
}) })
) )
// POST /api/auth/logout
router.post(
'/logout',
requireAuth,
asyncHandler(async (req: Request, res) => {
const header = req.headers.authorization
if (header?.startsWith('Bearer ')) {
const token = header.slice(7)
const tokenHash = hashToken(token)
// Blacklist the token so it cannot be reused after logout
// Expiry is 24h from now (matching JWT expiry)
blacklistToken(tokenHash, Date.now() + 24 * 60 * 60 * 1000)
}
res.json({ success: true })
})
)
// DELETE /api/auth/me // DELETE /api/auth/me
router.delete( router.delete(
'/me', '/me',
+3 -2
View File
@@ -5,6 +5,7 @@ import { requireAuth } from '../middleware/auth.js'
import { AppError } from '../middleware/error.js' import { AppError } from '../middleware/error.js'
import * as examRepo from '../repositories/examRepo.js' import * as examRepo from '../repositories/examRepo.js'
import { saveExamSchema, updateExamSchema, examIdParamSchema } from '../validators/examSchemas.js' import { saveExamSchema, updateExamSchema, examIdParamSchema } from '../validators/examSchemas.js'
import { sanitizeExam } from '../utils/sanitizeExam.js'
import type { Request } from 'express' import type { Request } from 'express'
const router = Router() const router = Router()
@@ -41,7 +42,7 @@ router.post(
'/', '/',
validateBody(saveExamSchema), validateBody(saveExamSchema),
asyncHandler(async (req: Request, res) => { asyncHandler(async (req: Request, res) => {
const exam = req.body.exam const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
const { id } = examRepo.createExam( const { id } = examRepo.createExam(
req.user!.userId, req.user!.userId,
exam.title, exam.title,
@@ -59,7 +60,7 @@ router.put(
validateBody(updateExamSchema), validateBody(updateExamSchema),
asyncHandler(async (req: Request, res) => { asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id) const id = Number(req.params.id)
const exam = req.body.exam const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
const updated = examRepo.updateExam( const updated = examRepo.updateExam(
id, id,
req.user!.userId, req.user!.userId,
+15 -3
View File
@@ -3,6 +3,7 @@ import bcrypt from 'bcrypt'
import { validateBody } from '../middleware/validate.js' import { validateBody } from '../middleware/validate.js'
import { asyncHandler } from '../utils/asyncHandler.js' import { asyncHandler } from '../utils/asyncHandler.js'
import { AppError } from '../middleware/error.js' import { AppError } from '../middleware/error.js'
import { getDb } from '../db/database.js'
import * as userRepo from '../repositories/userRepo.js' import * as userRepo from '../repositories/userRepo.js'
import * as settingsRepo from '../repositories/settingsRepo.js' import * as settingsRepo from '../repositories/settingsRepo.js'
import { setupSchema } from '../validators/setupSchemas.js' import { setupSchema } from '../validators/setupSchemas.js'
@@ -33,11 +34,17 @@ router.post(
'/', '/',
validateBody(setupSchema), validateBody(setupSchema),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
if (getSiteTitle()) { const db = getDb()
// Atomic transaction prevents race conditions between concurrent setup requests
const tx = db.transaction(() => {
const title = settingsRepo.getSetting('site_title')
if (title) {
throw new AppError(400, 'Setup already completed') throw new AppError(400, 'Setup already completed')
} }
if (userRepo.countUsers() > 0) { const userCount = userRepo.countUsers()
if (userCount > 0) {
throw new AppError(400, 'Users already exist') throw new AppError(400, 'Users already exist')
} }
@@ -56,7 +63,7 @@ router.post(
appUrl, appUrl,
} = req.body } = req.body
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS) const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
const { id } = userRepo.createUser({ const { id } = userRepo.createUser({
email, email,
@@ -82,6 +89,11 @@ router.post(
settingsRepo.setSettingsBatch(settings) settingsRepo.setSettingsBatch(settings)
return { id, email, displayName }
})
const { id, email, displayName } = tx()
const token = signToken({ const token = signToken({
userId: id, userId: id,
email, email,
+17 -9
View File
@@ -1,6 +1,6 @@
import bcrypt from 'bcrypt' import bcrypt from 'bcrypt'
import * as userRepo from '../repositories/userRepo.js' import * as userRepo from '../repositories/userRepo.js'
import { signToken, generateVerificationToken } from '../utils/tokens.js' import { signToken, generateVerificationToken, hashToken } from '../utils/tokens.js'
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js' import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js'
import { AppError } from '../middleware/error.js' import { AppError } from '../middleware/error.js'
import type { RegisterInput, LoginInput, UpdateProfileInput, ChangePasswordInput, VerifyEmailInput, ResetPasswordInput } from '../validators/authSchemas.js' import type { RegisterInput, LoginInput, UpdateProfileInput, ChangePasswordInput, VerifyEmailInput, ResetPasswordInput } from '../validators/authSchemas.js'
@@ -22,7 +22,8 @@ export async function register(input: RegisterInput, isFirstUser: boolean): Prom
const passwordHash = await bcrypt.hash(input.password, SALT_ROUNDS) const passwordHash = await bcrypt.hash(input.password, SALT_ROUNDS)
const role = isFirstUser ? 'admin' : 'user' const role = isFirstUser ? 'admin' : 'user'
const verifyToken = generateVerificationToken() const rawVerifyToken = generateVerificationToken()
const verifyTokenHash = hashToken(rawVerifyToken)
const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS
const { id } = userRepo.createUser({ const { id } = userRepo.createUser({
@@ -31,14 +32,14 @@ export async function register(input: RegisterInput, isFirstUser: boolean): Prom
displayName: input.displayName, displayName: input.displayName,
role, role,
emailVerified: 0, emailVerified: 0,
verifyToken, verifyToken: verifyTokenHash,
verifyExpires, verifyExpires,
}) })
const user = userRepo.getUserById(id)! const user = userRepo.getUserById(id)!
// Don't await — fire and forget so registration isn't blocked by email failures // Don't await — fire and forget so registration isn't blocked by email failures
sendVerificationEmail(user.email, verifyToken).catch(() => {}) sendVerificationEmail(user.email, rawVerifyToken).catch(() => {})
return { return {
token: signToken({ token: signToken({
@@ -67,6 +68,10 @@ export async function login(input: LoginInput): Promise<AuthResult> {
throw new AppError(401, 'Invalid email or password') throw new AppError(401, 'Invalid email or password')
} }
if (!user.email_verified) {
throw new AppError(403, 'Please verify your email before logging in')
}
return { return {
token: signToken({ token: signToken({
userId: user.id, userId: user.id,
@@ -118,7 +123,8 @@ export async function changePassword(userId: number, input: ChangePasswordInput)
} }
export async function verifyEmail(input: VerifyEmailInput) { export async function verifyEmail(input: VerifyEmailInput) {
const user = userRepo.getUserByVerifyToken(input.token) const tokenHash = hashToken(input.token)
const user = userRepo.getUserByVerifyTokenHash(tokenHash)
if (!user || !user.verify_expires || Date.now() > user.verify_expires) { if (!user || !user.verify_expires || Date.now() > user.verify_expires) {
throw new AppError(400, 'Invalid or expired verification token') throw new AppError(400, 'Invalid or expired verification token')
} }
@@ -141,17 +147,19 @@ export async function forgotPassword(email?: string) {
return return
} }
const resetToken = generateVerificationToken() const rawResetToken = generateVerificationToken()
const resetTokenHash = hashToken(rawResetToken)
const resetExpires = Date.now() + RESET_TOKEN_EXPIRY_MS const resetExpires = Date.now() + RESET_TOKEN_EXPIRY_MS
userRepo.updateUser(user.id, { resetToken, resetExpires }) userRepo.updateUser(user.id, { resetToken: resetTokenHash, resetExpires })
// Fire and forget // Fire and forget
sendPasswordResetEmail(user.email, resetToken).catch(() => {}) sendPasswordResetEmail(user.email, rawResetToken).catch(() => {})
} }
export async function resetPassword(input: ResetPasswordInput) { export async function resetPassword(input: ResetPasswordInput) {
const user = userRepo.getUserByResetToken(input.token) const tokenHash = hashToken(input.token)
const user = userRepo.getUserByResetTokenHash(tokenHash)
if (!user || !user.reset_expires || Date.now() > user.reset_expires) { if (!user || !user.reset_expires || Date.now() > user.reset_expires) {
throw new AppError(400, 'Invalid or expired reset token') throw new AppError(400, 'Invalid or expired reset token')
} }
+30
View File
@@ -0,0 +1,30 @@
/**
* Server-side sanitization for user-provided exam content.
* Removes script tags and event handlers from string fields.
*/
function sanitizeString(value: unknown): unknown {
if (typeof value !== 'string') return value
return value
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<script[^>]*\/>/gi, '')
.replace(/javascript:/gi, '')
.replace(/on\w+\s*=/gi, '')
}
function sanitizeObject(obj: unknown): unknown {
if (typeof obj === 'string') return sanitizeString(obj)
if (Array.isArray(obj)) return obj.map(sanitizeObject)
if (obj && typeof obj === 'object') {
const result: Record<string, unknown> = {}
for (const [key, val] of Object.entries(obj)) {
result[key] = sanitizeObject(val)
}
return result
}
return obj
}
export function sanitizeExam(exam: Record<string, unknown>): Record<string, unknown> {
return sanitizeObject(exam) as Record<string, unknown>
}
+46
View File
@@ -0,0 +1,46 @@
/**
* In-memory token blacklist with SQLite persistence.
* Tokens are blacklisted on logout and expire automatically based on JWT expiry.
*/
import { getDb } from '../db/database.js'
const memoryCache = new Set<string>()
let initialized = false
function initCache(): void {
if (initialized) return
const db = getDb()
db.exec(`
CREATE TABLE IF NOT EXISTS token_blacklist (
token_hash TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_token_blacklist_expires ON token_blacklist(expires_at);
`)
// Load recent blacklisted tokens into memory
const rows = db.prepare('SELECT token_hash FROM token_blacklist WHERE expires_at > ?')
.all(Date.now()) as { token_hash: string }[]
for (const row of rows) {
memoryCache.add(row.token_hash)
}
initialized = true
}
export function isTokenBlacklisted(tokenHash: string): boolean {
initCache()
return memoryCache.has(tokenHash)
}
export function blacklistToken(tokenHash: string, expiresAt: number): void {
initCache()
memoryCache.add(tokenHash)
const db = getDb()
db.prepare('INSERT OR IGNORE INTO token_blacklist (token_hash, expires_at) VALUES (?, ?)')
.run(tokenHash, expiresAt)
}
export function cleanupExpiredTokens(): void {
const db = getDb()
db.prepare('DELETE FROM token_blacklist WHERE expires_at <= ?').run(Date.now())
}
+4
View File
@@ -20,3 +20,7 @@ export function verifyToken(token: string): JwtPayload {
export function generateVerificationToken(): string { export function generateVerificationToken(): string {
return crypto.randomBytes(32).toString('hex') return crypto.randomBytes(32).toString('hex')
} }
export function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex')
}
+2 -2
View File
@@ -20,7 +20,7 @@ describe('updateUserRoleSchema', () => {
describe('updateSettingSchema', () => { describe('updateSettingSchema', () => {
it('accepts valid setting', () => { it('accepts valid setting', () => {
const result = updateSettingSchema.safeParse({ key: 'app_name', value: 'BrainWave' }) const result = updateSettingSchema.safeParse({ key: 'site_title', value: 'BrainWave' })
expect(result.success).toBe(true) expect(result.success).toBe(true)
}) })
@@ -30,7 +30,7 @@ describe('updateSettingSchema', () => {
}) })
it('rejects empty value', () => { it('rejects empty value', () => {
const result = updateSettingSchema.safeParse({ key: 'app_name', value: '' }) const result = updateSettingSchema.safeParse({ key: 'site_title', value: '' })
expect(result.success).toBe(false) expect(result.success).toBe(false)
}) })
}) })
+19 -1
View File
@@ -5,8 +5,26 @@ export const updateUserRoleSchema = z.object({
role: z.enum(['user', 'admin']), role: z.enum(['user', 'admin']),
}) })
const ALLOWED_SETTING_KEYS = [
'site_title',
'site_language',
'maintenance_mode',
'maintenance_message',
'allow_guest_mode',
'registration_enabled',
'default_theme',
'smtp_host',
'smtp_port',
'smtp_user',
'smtp_pass',
'smtp_from',
'smtp_secure',
'app_url',
'max_upload_mb',
] as const
export const updateSettingSchema = z.object({ export const updateSettingSchema = z.object({
key: z.string().min(1), key: z.enum(ALLOWED_SETTING_KEYS),
value: z.string().min(1), value: z.string().min(1),
}) })
+5 -5
View File
@@ -5,7 +5,7 @@ describe('registerSchema', () => {
it('accepts valid registration', () => { it('accepts valid registration', () => {
const result = registerSchema.safeParse({ const result = registerSchema.safeParse({
email: 'test@example.com', email: 'test@example.com',
password: 'password123', password: 'Password123!',
displayName: 'Test User', displayName: 'Test User',
}) })
expect(result.success).toBe(true) expect(result.success).toBe(true)
@@ -14,7 +14,7 @@ describe('registerSchema', () => {
it('rejects invalid email', () => { it('rejects invalid email', () => {
const result = registerSchema.safeParse({ const result = registerSchema.safeParse({
email: 'not-an-email', email: 'not-an-email',
password: 'password123', password: 'Password123!',
displayName: 'Test User', displayName: 'Test User',
}) })
expect(result.success).toBe(false) expect(result.success).toBe(false)
@@ -32,7 +32,7 @@ describe('registerSchema', () => {
it('rejects empty display name', () => { it('rejects empty display name', () => {
const result = registerSchema.safeParse({ const result = registerSchema.safeParse({
email: 'test@example.com', email: 'test@example.com',
password: 'password123', password: 'Password123!',
displayName: '', displayName: '',
}) })
expect(result.success).toBe(false) expect(result.success).toBe(false)
@@ -65,7 +65,7 @@ describe('verifyEmailSchema', () => {
describe('resetPasswordSchema', () => { describe('resetPasswordSchema', () => {
it('accepts valid reset', () => { it('accepts valid reset', () => {
const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'newpass123' }) const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'NewPass123!' })
expect(result.success).toBe(true) expect(result.success).toBe(true)
}) })
@@ -91,7 +91,7 @@ describe('changePasswordSchema', () => {
it('accepts valid change', () => { it('accepts valid change', () => {
const result = changePasswordSchema.safeParse({ const result = changePasswordSchema.safeParse({
currentPassword: 'oldpass', currentPassword: 'oldpass',
newPassword: 'newpass123', newPassword: 'NewPass123!',
}) })
expect(result.success).toBe(true) expect(result.success).toBe(true)
}) })
+11 -3
View File
@@ -1,8 +1,12 @@
import { z } from 'zod' import { z } from 'zod'
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
export const registerSchema = z.object({ export const registerSchema = z.object({
email: z.string().email().max(255), email: z.string().email().max(255),
password: z.string().min(8, 'Password must be at least 8 characters'), password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
displayName: z.string().min(1, 'Display name is required').max(100), displayName: z.string().min(1, 'Display name is required').max(100),
}) })
@@ -17,7 +21,9 @@ export const verifyEmailSchema = z.object({
export const resetPasswordSchema = z.object({ export const resetPasswordSchema = z.object({
token: z.string().min(1, 'Token is required'), token: z.string().min(1, 'Token is required'),
newPassword: z.string().min(8, 'Password must be at least 8 characters'), newPassword: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
}) })
export const updateProfileSchema = z.object({ export const updateProfileSchema = z.object({
@@ -26,7 +32,9 @@ export const updateProfileSchema = z.object({
export const changePasswordSchema = z.object({ export const changePasswordSchema = z.object({
currentPassword: z.string(), currentPassword: z.string(),
newPassword: z.string().min(8, 'Password must be at least 8 characters'), newPassword: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
}) })
export const forgotPasswordSchema = z.object({ export const forgotPasswordSchema = z.object({
+2 -2
View File
@@ -5,7 +5,7 @@ describe('setupSchema', () => {
it('accepts valid setup', () => { it('accepts valid setup', () => {
const result = setupSchema.safeParse({ const result = setupSchema.safeParse({
email: 'admin@example.com', email: 'admin@example.com',
password: 'adminpass123', password: 'AdminPass123!',
displayName: 'Admin', displayName: 'Admin',
siteTitle: 'BrainWave', siteTitle: 'BrainWave',
language: 'en', language: 'en',
@@ -16,7 +16,7 @@ describe('setupSchema', () => {
it('rejects invalid email', () => { it('rejects invalid email', () => {
const result = setupSchema.safeParse({ const result = setupSchema.safeParse({
email: 'bad', email: 'bad',
password: 'adminpass123', password: 'AdminPass123!',
displayName: 'Admin', displayName: 'Admin',
}) })
expect(result.success).toBe(false) expect(result.success).toBe(false)
+5 -1
View File
@@ -1,8 +1,12 @@
import { z } from 'zod' import { z } from 'zod'
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
export const setupSchema = z.object({ export const setupSchema = z.object({
email: z.string().email().max(255), email: z.string().email().max(255),
password: z.string().min(8, 'Password must be at least 8 characters'), password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
displayName: z.string().min(1, 'Display name is required').max(100), displayName: z.string().min(1, 'Display name is required').max(100),
siteTitle: z.string().min(1, 'Site title is required').max(255).optional(), siteTitle: z.string().min(1, 'Site title is required').max(255).optional(),
language: z.enum(['en', 'nl']).optional(), language: z.enum(['en', 'nl']).optional(),
-6
View File
@@ -1,6 +0,0 @@
{
"name": "brainwave",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
@@ -1 +1 @@
{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":7.161699999999996,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":9.518499999999989,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":7.022400000000005,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":4.217900000000014,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":4.687600000000003,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.788000000000011,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":7.534500000000008,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":2.5043000000000006,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":4.5700999999999965,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":4.011300000000006,"failed":false}],[":backend/src/config.test.ts",{"duration":4.423100000000005,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":5.812899999999985,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":73.72379999999998,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.070899999999995,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":4.5079000000000065,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":3.4222000000000037,"failed":false}]]} {"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":7.415200000000027,"failed":false}],[":backend/dist/validators/resultSchemas.test.js",{"duration":6.726200000000006,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":7.416300000000035,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":6.132000000000005,"failed":false}],[":backend/dist/validators/authSchemas.test.js",{"duration":7.569299999999998,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":7.313600000000008,"failed":false}],[":backend/dist/validators/examSchemas.test.js",{"duration":6.513400000000047,"failed":false}],[":backend/dist/config.test.js",{"duration":3.9307000000000016,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":7.670799999999986,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":10.730400000000031,"failed":false}],[":backend/dist/validators/adminSchemas.test.js",{"duration":7.168000000000006,"failed":false}],[":backend/src/config.test.ts",{"duration":4.0274,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.865000000000009,"failed":false}],[":backend/dist/utils/tokens.test.js",{"duration":130.3977,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":4.94319999999999,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":130.18120000000005,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":5.06280000000001,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":4.779500000000013,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.795000000000016,"failed":false}],[":backend/dist/validators/setupSchemas.test.js",{"duration":5.715100000000007,"failed":false}],[":backend/dist/middleware/error.test.js",{"duration":3.313999999999993,"failed":false}],[":backend/dist/utils/asyncHandler.test.js",{"duration":5.088700000000017,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":3.638200000000012,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":3.546299999999974,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":3.1704000000000008,"failed":false}]]}
+16 -1
View File
@@ -2,5 +2,20 @@
"name": "brainwave", "name": "brainwave",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": {} "packages": {
"": {
"dependencies": {
"helmet": "^8.1.0"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
}
}
} }
+5
View File
@@ -0,0 +1,5 @@
{
"name": "brainwave",
"private": true,
"version": "0.0.1"
}