Public Access
feat: implement retake exam functionality and update translations for exam status #2
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
Generated
+10
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { AppError } from '../middleware/error.js'
|
|||||||
import * as userRepo from '../repositories/userRepo.js'
|
import * as userRepo from '../repositories/userRepo.js'
|
||||||
import * as resultRepo from '../repositories/resultRepo.js'
|
import * as resultRepo from '../repositories/resultRepo.js'
|
||||||
import * as settingsRepo from '../repositories/settingsRepo.js'
|
import * as settingsRepo from '../repositories/settingsRepo.js'
|
||||||
import { updateUserRoleSchema, updateSettingSchema, listUsersQuerySchema, userIdParamSchema } from '../validators/adminSchemas.js'
|
import { updateUserRoleSchema, updateSettingSchema, updateSettingsBatchSchema, listUsersQuerySchema, userIdParamSchema } from '../validators/adminSchemas.js'
|
||||||
import type { Request } from 'express'
|
import type { Request } from 'express'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
@@ -124,17 +124,18 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
'/settings',
|
'/settings',
|
||||||
asyncHandler(async (_req, res) => {
|
asyncHandler(async (_req, res) => {
|
||||||
res.json(settingsRepo.getAllSettings())
|
res.json({ settings: settingsRepo.getAllSettings() })
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// PUT /api/admin/settings
|
// PUT /api/admin/settings
|
||||||
router.put(
|
router.put(
|
||||||
'/settings',
|
'/settings',
|
||||||
validateBody(updateSettingSchema),
|
validateBody(updateSettingsBatchSchema),
|
||||||
asyncHandler(async (req: Request, res) => {
|
asyncHandler(async (req: Request, res) => {
|
||||||
const { key, value } = req.body
|
const { settings } = req.body
|
||||||
settingsRepo.setSetting(key, value)
|
const entries = Object.entries(settings) as Array<[string, string]>
|
||||||
|
settingsRepo.setSettingsBatch(entries)
|
||||||
res.json({ success: true })
|
res.json({ success: true })
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -13,7 +14,7 @@ const router = Router()
|
|||||||
const SALT_ROUNDS = 10
|
const SALT_ROUNDS = 10
|
||||||
|
|
||||||
function getSiteTitle(): string | null {
|
function getSiteTitle(): string | null {
|
||||||
return settingsRepo.getSetting('site_title') ?? null
|
return settingsRepo.getSetting('site_name') ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/setup/status
|
// GET /api/setup/status
|
||||||
@@ -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_name')
|
||||||
|
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,
|
||||||
@@ -67,7 +74,7 @@ router.post(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const settings: Array<[string, string]> = [
|
const settings: Array<[string, string]> = [
|
||||||
['site_title', siteTitle?.trim() || 'BrainWave'],
|
['site_name', siteTitle?.trim() || 'BrainWave'],
|
||||||
['site_language', language || 'en'],
|
['site_language', language || 'en'],
|
||||||
['maintenance_mode', 'false'],
|
['maintenance_mode', 'false'],
|
||||||
]
|
]
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { updateUserRoleSchema, updateSettingSchema, listUsersQuerySchema, userIdParamSchema } from './adminSchemas'
|
import { updateUserRoleSchema, updateSettingSchema, updateSettingsBatchSchema, listUsersQuerySchema, userIdParamSchema } from './adminSchemas'
|
||||||
|
|
||||||
describe('updateUserRoleSchema', () => {
|
describe('updateUserRoleSchema', () => {
|
||||||
it('accepts valid role change', () => {
|
it('accepts valid role change', () => {
|
||||||
@@ -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_name', value: 'BrainWave' })
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -30,7 +30,30 @@ describe('updateSettingSchema', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('rejects empty value', () => {
|
it('rejects empty value', () => {
|
||||||
const result = updateSettingSchema.safeParse({ key: 'app_name', value: '' })
|
const result = updateSettingSchema.safeParse({ key: 'site_name', value: '' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateSettingsBatchSchema', () => {
|
||||||
|
it('accepts valid batch', () => {
|
||||||
|
const result = updateSettingsBatchSchema.safeParse({
|
||||||
|
settings: { site_name: 'BrainWave', maintenance_mode: 'false' },
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid key in batch', () => {
|
||||||
|
const result = updateSettingsBatchSchema.safeParse({
|
||||||
|
settings: { site_name: 'BrainWave', unknown_key: 'value' },
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty value in batch', () => {
|
||||||
|
const result = updateSettingsBatchSchema.safeParse({
|
||||||
|
settings: { site_name: '' },
|
||||||
|
})
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,11 +5,36 @@ export const updateUserRoleSchema = z.object({
|
|||||||
role: z.enum(['user', 'admin']),
|
role: z.enum(['user', 'admin']),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const ALLOWED_SETTING_KEYS = [
|
||||||
|
'site_name',
|
||||||
|
'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),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const updateSettingsBatchSchema = z.object({
|
||||||
|
settings: z.record(z.string().min(1)).refine(
|
||||||
|
(obj) => Object.keys(obj).every((k) => ALLOWED_SETTING_KEYS.includes(k as typeof ALLOWED_SETTING_KEYS[number])),
|
||||||
|
{ message: 'One or more setting keys are not allowed' }
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
export const listUsersQuerySchema = z.object({
|
export const listUsersQuerySchema = z.object({
|
||||||
page: z.string().regex(/^\d+$/).optional().transform((v: string | undefined) => (v ? Number(v) : 1)),
|
page: z.string().regex(/^\d+$/).optional().transform((v: string | undefined) => (v ? Number(v) : 1)),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { useState, useEffect, useCallback } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import * as resultApi from '../api/resultApi'
|
import * as resultApi from '../api/resultApi'
|
||||||
|
import * as examApi from '../api/examApi'
|
||||||
import { getExamUrlId } from '../utils/slug'
|
import { getExamUrlId } from '../utils/slug'
|
||||||
|
import type { Exam } from '../types'
|
||||||
|
|
||||||
interface ResultItem {
|
interface ResultItem {
|
||||||
id: number
|
id: number
|
||||||
@@ -17,7 +19,11 @@ interface ResultItem {
|
|||||||
exam_id: number | null
|
exam_id: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MyResults() {
|
interface Props {
|
||||||
|
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MyResults({ onLoadExam }: Props) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [results, setResults] = useState<ResultItem[]>([])
|
const [results, setResults] = useState<ResultItem[]>([])
|
||||||
@@ -25,6 +31,7 @@ export default function MyResults() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
|
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
|
||||||
|
const [retakingId, setRetakingId] = useState<number | null>(null)
|
||||||
|
|
||||||
const loadResults = useCallback(async () => {
|
const loadResults = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -54,6 +61,18 @@ export default function MyResults() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const retakeExam = async (item: ResultItem) => {
|
||||||
|
if (!item.exam_id) return
|
||||||
|
setRetakingId(item.id)
|
||||||
|
try {
|
||||||
|
const exam = await examApi.getExam(item.exam_id)
|
||||||
|
onLoadExam(exam, false, item.exam_id)
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message)
|
||||||
|
setRetakingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const reviewResult = async (item: ResultItem) => {
|
const reviewResult = async (item: ResultItem) => {
|
||||||
try {
|
try {
|
||||||
const data = await resultApi.getResult(item.id)
|
const data = await resultApi.getResult(item.id)
|
||||||
@@ -129,6 +148,14 @@ export default function MyResults() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="workspace-card-actions" style={{ gap: 8 }}>
|
<div className="workspace-card-actions" style={{ gap: 8 }}>
|
||||||
<div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div>
|
<div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() => void retakeExam(r)}
|
||||||
|
disabled={retakingId === r.id || !r.exam_id}
|
||||||
|
title={r.exam_id ? undefined : t('results.cantRetake')}
|
||||||
|
>
|
||||||
|
{retakingId === r.id ? t('common.loading') : t('results.retakeExam')}
|
||||||
|
</button>
|
||||||
<button className="btn btn-sm" onClick={() => void reviewResult(r)}>
|
<button className="btn btn-sm" onClick={() => void reviewResult(r)}>
|
||||||
{t('common.edit')}
|
{t('common.edit')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export default function Workspace({ onLoadExam }: Props) {
|
|||||||
|
|
||||||
<main className="workspace-main">
|
<main className="workspace-main">
|
||||||
{activeTab === 'exams' && <MyExams onLoadExam={onLoadExam} />}
|
{activeTab === 'exams' && <MyExams onLoadExam={onLoadExam} />}
|
||||||
{activeTab === 'results' && <MyResults />}
|
{activeTab === 'results' && <MyResults onLoadExam={onLoadExam} />}
|
||||||
{activeTab === 'settings' && <Settings />}
|
{activeTab === 'settings' && <Settings />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -124,6 +124,7 @@
|
|||||||
"explanation": "Explanation",
|
"explanation": "Explanation",
|
||||||
"rubric": "Rubric (self-grade)",
|
"rubric": "Rubric (self-grade)",
|
||||||
"retakeExam": "Retake exam",
|
"retakeExam": "Retake exam",
|
||||||
|
"cantRetake": "Exam no longer saved in workspace",
|
||||||
"backToHome": "Back to home"
|
"backToHome": "Back to home"
|
||||||
},
|
},
|
||||||
"tweaks": {
|
"tweaks": {
|
||||||
|
|||||||
@@ -124,6 +124,7 @@
|
|||||||
"explanation": "Uitleg",
|
"explanation": "Uitleg",
|
||||||
"rubric": "Rubric (zelfbeoordeling)",
|
"rubric": "Rubric (zelfbeoordeling)",
|
||||||
"retakeExam": "Examen opnieuw maken",
|
"retakeExam": "Examen opnieuw maken",
|
||||||
|
"cantRetake": "Examen niet meer opgeslagen in werkruimte",
|
||||||
"backToHome": "Terug naar home"
|
"backToHome": "Terug naar home"
|
||||||
},
|
},
|
||||||
"tweaks": {
|
"tweaks": {
|
||||||
|
|||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "brainwave",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -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.013800000000003,"failed":false}],[":backend/dist/validators/resultSchemas.test.js",{"duration":5.808800000000019,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":7.428100000000029,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":5.740099999999984,"failed":false}],[":backend/dist/validators/authSchemas.test.js",{"duration":7.838999999999999,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":6.458100000000002,"failed":false}],[":backend/dist/validators/examSchemas.test.js",{"duration":6.4299000000000035,"failed":false}],[":backend/dist/config.test.js",{"duration":3.8840999999999894,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":8.53079999999997,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":9.639899999999955,"failed":false}],[":backend/dist/validators/adminSchemas.test.js",{"duration":7.222399999999993,"failed":false}],[":backend/src/config.test.ts",{"duration":4.612200000000001,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.7660000000000196,"failed":false}],[":backend/dist/utils/tokens.test.js",{"duration":135.6874,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":5.303899999999999,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":133.43280000000001,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":4.7246000000000095,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":5.102000000000004,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.396100000000018,"failed":false}],[":backend/dist/validators/setupSchemas.test.js",{"duration":5.6451999999999884,"failed":false}],[":backend/dist/middleware/error.test.js",{"duration":2.9522999999999797,"failed":false}],[":backend/dist/utils/asyncHandler.test.js",{"duration":5.4879999999999995,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":3.3067000000000064,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":3.663899999999984,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":2.3597999999999786,"failed":false}]]}
|
||||||
Generated
+16
-1
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "brainwave",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1"
|
||||||
|
}
|
||||||
Reference in new issue
Block a user