diff --git a/backend/src/__tests__/answerKeyLeak.test.js b/backend/src/__tests__/answerKeyLeak.test.js new file mode 100644 index 00000000..0f08d36b --- /dev/null +++ b/backend/src/__tests__/answerKeyLeak.test.js @@ -0,0 +1,118 @@ +// Unit tests for the answer-key leak fix (stripAnswerKey, utils/sanitize.js) and the reveal +// gating used by GET /api/responses/room/:roomId/student/:studentId and GET /api/questions. +// Regression coverage for security-poc/leaderboard_bot.mjs finding #7: a student must never +// receive options[].isCorrect (or explanation) for a question they have not answered yet. + +const { stripAnswerKey } = require('../utils/sanitize.js') +const { secondsSinceLaunch, isWithinAnswerWindow } = require('../utils/answerWindow.js') + +describe('stripAnswerKey', () => { + const question = { + _id: 'q1', + question: 'What is 2+2?', + explanation: 'Basic arithmetic', + options: [ + { text: '3', isCorrect: false }, + { text: '4', isCorrect: true } + ] + } + + it('removes isCorrect from every option when reveal is false', () => { + const result = stripAnswerKey(question, false) + expect(result.options).toEqual([{ text: '3' }, { text: '4' }]) + result.options.forEach((opt) => expect(opt).not.toHaveProperty('isCorrect')) + }) + + it('removes explanation when reveal is false', () => { + const result = stripAnswerKey(question, false) + expect(result).not.toHaveProperty('explanation') + }) + + it('passes the document through unchanged when reveal is true', () => { + const result = stripAnswerKey(question, true) + expect(result).toBe(question) // same reference — no copy needed when revealing + expect(result.options[1].isCorrect).toBe(true) + }) + + it('does not mutate the original document', () => { + const original = JSON.parse(JSON.stringify(question)) + stripAnswerKey(question, false) + expect(question).toEqual(original) + }) + + it('handles a null/undefined question gracefully', () => { + expect(stripAnswerKey(null, false)).toBeNull() + expect(stripAnswerKey(undefined, true)).toBeUndefined() + }) + + it('leaves non-array options untouched', () => { + const weird = { ...question, options: null } + const result = stripAnswerKey(weird, false) + expect(result.options).toBeNull() + }) +}) + +describe('reveal gating — GET /questions (answered-only)', () => { + // Mirrors: `const reveal = isTeacher || !!studentResponse` in questions.js + function shouldReveal(isTeacher, studentResponse) { + return isTeacher || !!studentResponse + } + + it('always reveals to the teacher, answered or not', () => { + expect(shouldReveal(true, null)).toBe(true) + expect(shouldReveal(true, { selectedOption: 0 })).toBe(true) + }) + + it('hides the key from a student who has not answered yet', () => { + expect(shouldReveal(false, null)).toBe(false) + expect(shouldReveal(false, undefined)).toBe(false) + }) + + it('reveals to a student once they have answered', () => { + expect(shouldReveal(false, { selectedOption: 1, isCorrect: true })).toBe(true) + }) +}) + +describe('reveal gating — GET /responses/room/:roomId/student/:studentId (answered OR window closed)', () => { + // Mirrors responses.js: reveal if teacher, already answered, or the answer window has closed + // (so the "Missed — correct answer was X" review UI still works for genuinely past questions). + function shouldReveal(isTeacher, studentResponse, launchedAt, timeToAnswer, now) { + const windowClosed = !isWithinAnswerWindow(secondsSinceLaunch(launchedAt, now), timeToAnswer) + return isTeacher || !!studentResponse || windowClosed + } + + it('hides the key for a live, unanswered question — the actual exploit path', () => { + const now = Date.now() + const launchedAt = new Date(now - 5000) // launched 5s ago, tta=30s — still live + expect(shouldReveal(false, null, launchedAt, 30, now)).toBe(false) + }) + + it('reveals the key once the question is answered, even while still live', () => { + const now = Date.now() + const launchedAt = new Date(now - 5000) + expect(shouldReveal(false, { selectedOption: 0 }, launchedAt, 30, now)).toBe(true) + }) + + it('reveals the key for a genuinely missed question once its window has closed', () => { + const now = Date.now() + const launchedAt = new Date(now - 60_000) // launched 60s ago, tta=30s — long closed + expect(shouldReveal(false, null, launchedAt, 30, now)).toBe(true) + }) + + it('exercises the leaderboard_bot.mjs exploit path end to end: no isCorrect on a live question', () => { + const now = Date.now() + const launchedAt = new Date(now - 2000) // just launched + const revealed = shouldReveal(false, null, launchedAt, 30, now) + const liveQuestion = { + _id: 'live-q', + options: revealed + ? [{ text: 'A', isCorrect: false }, { text: 'B', isCorrect: true }] + : [{ text: 'A' }, { text: 'B' }] // stripped, as the fixed route sends for a live question + } + const idxs = liveQuestion.options + .map((opt, idx) => (opt.isCorrect ? idx : -1)) + .filter((idx) => idx !== -1) + expect(revealed).toBe(false) + expect(idxs).toHaveLength(0) // bot has nothing to submit + }) +}) diff --git a/backend/src/__tests__/answerWindow.test.js b/backend/src/__tests__/answerWindow.test.js new file mode 100644 index 00000000..47338781 --- /dev/null +++ b/backend/src/__tests__/answerWindow.test.js @@ -0,0 +1,125 @@ +// Unit tests for the server-side answer-window enforcement (utils/answerWindow.js) used by +// POST /api/responses. Regression coverage for two exploits documented in +// security-poc/leaderboard_bot.mjs: +// 1. Answering a question long after its timeToAnswer window has closed. +// 2. Forging a near-zero responseTime to farm max points regardless of actual reaction time. + +const { + ANSWER_GRACE_S, + RESPONSE_TIME_SLACK_S, + secondsSinceLaunch, + isWithinAnswerWindow, + resolveResponseTime +} = require('../utils/answerWindow.js') + +describe('secondsSinceLaunch', () => { + it('returns 0 when launchedAt is missing', () => { + expect(secondsSinceLaunch(null)).toBe(0) + expect(secondsSinceLaunch(undefined)).toBe(0) + }) + + it('computes elapsed seconds from a Date', () => { + const now = 1_000_000 + const launchedAt = new Date(now - 10_000) // 10s ago + expect(secondsSinceLaunch(launchedAt, now)).toBeCloseTo(10, 5) + }) + + it('computes elapsed seconds from an ISO string (as read back from Mongo)', () => { + const now = Date.now() + const launchedAt = new Date(now - 5000).toISOString() + expect(secondsSinceLaunch(launchedAt, now)).toBeCloseTo(5, 1) + }) +}) + +describe('isWithinAnswerWindow', () => { + const tta = 30 // typical timeToAnswer + + it('allows a submission within the window', () => { + expect(isWithinAnswerWindow(0, tta)).toBe(true) + expect(isWithinAnswerWindow(29, tta)).toBe(true) + }) + + it('allows a submission inside the grace buffer past the window', () => { + expect(isWithinAnswerWindow(tta + ANSWER_GRACE_S, tta)).toBe(true) + expect(isWithinAnswerWindow(tta + ANSWER_GRACE_S - 0.01, tta)).toBe(true) + }) + + it('rejects a submission past the window + grace', () => { + expect(isWithinAnswerWindow(tta + ANSWER_GRACE_S + 0.01, tta)).toBe(false) + }) + + it('rejects the leaderboard_bot.mjs scenario: joining an hour into a live session', () => { + const elapsedS = 60 * 60 // 1 hour after launch + expect(isWithinAnswerWindow(elapsedS, tta)).toBe(false) + }) +}) + +describe('resolveResponseTime', () => { + it('trusts a plausible client value close to real elapsed time', () => { + // Legit client: clicked at 12s in, jitter delay adds ~1s network time before it lands. + const elapsedS = 13 + expect(resolveResponseTime(12, elapsedS)).toBe(12) + }) + + it('floors a forged near-zero responseTime to the real elapsed time (minus the response slack)', () => { + // security-poc/leaderboard_bot.mjs default: --responseTime=1 + const elapsedS = 20 + const resolved = resolveResponseTime(1, elapsedS) + expect(resolved).toBe(Math.max(0, elapsedS - RESPONSE_TIME_SLACK_S)) + expect(resolved).toBeGreaterThan(1) + }) + + it('floors a missing/zero responseTime the same way', () => { + const elapsedS = 25 + expect(resolveResponseTime(undefined, elapsedS)).toBe(elapsedS - RESPONSE_TIME_SLACK_S) + expect(resolveResponseTime(0, elapsedS)).toBe(elapsedS - RESPONSE_TIME_SLACK_S) + }) + + it('never returns a negative responseTime under clock skew', () => { + expect(resolveResponseTime(0, -5)).toBe(0) + }) + + it('does not penalize a genuinely instant, honestly-reported answer', () => { + // Real click at t=0, request lands ~0.2s later — well inside the slack window. + expect(resolveResponseTime(0, 0.2)).toBe(0) + }) + + it('uses a slack strictly smaller than the deadline grace (they serve different purposes)', () => { + // The deadline grace tolerates a late *arrival*; the response-time slack bounds how much a + // claimed *reaction time* may undercut reality. Conflating them (using ANSWER_GRACE_S for + // both) lets every submission shave a flat multi-second discount off its score regardless of + // when it actually landed — this asserts the two stay independently tunable and separate. + expect(RESPONSE_TIME_SLACK_S).toBeLessThan(ANSWER_GRACE_S) + }) +}) + +describe('end-to-end scoring impact (mirrors the formula in routes/responses.js)', () => { + function scoreFor(clientResponseTime, launchedAt, tta, maxPoints, now = Date.now()) { + const elapsedS = secondsSinceLaunch(launchedAt, now) + if (!isWithinAnswerWindow(elapsedS, tta)) return { rejected: true } + const respTime = resolveResponseTime(clientResponseTime, elapsedS) + const timeDecayFactor = Math.max(0.1, Math.max(0, tta - respTime) / tta) + return { rejected: false, points: Math.round(maxPoints * timeDecayFactor) } + } + + it('rejects a bot answering a question launched an hour ago, regardless of forged responseTime', () => { + const launchedAt = new Date(Date.now() - 60 * 60 * 1000) + const result = scoreFor(1, launchedAt, 30, 100) + expect(result.rejected).toBe(true) + }) + + it('caps a forged near-zero responseTime far below what it used to earn', () => { + const now = Date.now() + const launchedAt = new Date(now - 25_000) // answered with 25s of real elapsed time + const forged = scoreFor(1, launchedAt, 30, 100, now) // bot claims responseTime=1 + const honest = scoreFor(25, launchedAt, 30, 100, now) // honest student reports the true 25s + + // Before this fix, a forged responseTime=1 against tta=30 scored round(100*(29/30)) = 97 — + // nearly the maximum, regardless of the 25s that actually elapsed. The floor (elapsedS - grace) + // caps that: it can still edge out an honest report by at most the grace window's worth of + // points, never back up near the old 97. + expect(forged.rejected).toBe(false) + expect(forged.points).toBeLessThanOrEqual(honest.points + 20) + expect(forged.points).toBeLessThan(50) + }) +}) diff --git a/backend/src/models/Question.js b/backend/src/models/Question.js index 8cd7312c..d7fe13e2 100644 --- a/backend/src/models/Question.js +++ b/backend/src/models/Question.js @@ -32,6 +32,14 @@ const questionSchema = new mongoose.Schema({ enum: ['pending', 'approved', 'rejected'], default: 'pending' }, + // Set the moment the question is created with status 'approved' — today that IS the launch + // moment (POST /api/questions creates the doc and broadcasts it to students in the same + // request). This is the clock POST /api/responses checks the answer window against. Null on + // documents written before this field existed; those fall back to createdAt (see responses.js). + launchedAt: { + type: Date, + default: null + }, timeToAnswer: { type: Number, default: 30 diff --git a/backend/src/routes/questions.js b/backend/src/routes/questions.js index b6bac88e..8adabe69 100644 --- a/backend/src/routes/questions.js +++ b/backend/src/routes/questions.js @@ -2,7 +2,7 @@ import express from 'express' import { authenticate, authorize } from '../middleware/auth.js' import { generateQuestions, AI_PROVIDERS } from '../services/questionService.js' import { getGenerationQueue } from '../services/generationQueue.js' -import { stripObject } from '../utils/sanitize.js' +import { stripObject, stripAnswerKey } from '../utils/sanitize.js' const router = express.Router() @@ -131,6 +131,13 @@ router.post('/', authorize('teacher'), async (req, res) => { // literally (e.g. ") on the student side. const sanitizedData = stripObject({ roomId, type, question, options, timeToAnswer, points, status, segmentIndex }) + // status defaults to 'approved' (see destructure above) and every caller in this app creates + // a question already-approved — that POST is the launch moment, so stamp it here. The answer + // window in POST /api/responses is measured from this timestamp. + if (sanitizedData.status === 'approved') { + sanitizedData.launchedAt = new Date() + } + const newQuestion = new Question(sanitizedData) await newQuestion.save() @@ -178,10 +185,21 @@ router.get('/', async (req, res) => { Question.find({ roomId }).sort({ createdAt: -1 }).skip(skip).limit(limitNum).lean(), Question.countDocuments({ roomId }) ]) - + + // Teachers always get the full doc. A student gets the answer key only for questions they + // have already answered — otherwise this listing hands them options[].isCorrect for a + // question that's still live (or hasn't been attempted), same leak as the /responses route. + let safeQuestions = questions + if (!isTeacher) { + const Response = (await import('../models/Response.js')).default + const answeredIds = await Response.distinct('questionId', { roomId, studentId: currentUser._id }) + const answered = new Set(answeredIds.map((id) => id.toString())) + safeQuestions = questions.map((q) => stripAnswerKey(q, answered.has(q._id.toString()))) + } + res.json({ success: true, - questions, + questions: safeQuestions, pagination: { page: pageNum, limit: limitNum, diff --git a/backend/src/routes/responses.js b/backend/src/routes/responses.js index 7148fe88..13e0c618 100644 --- a/backend/src/routes/responses.js +++ b/backend/src/routes/responses.js @@ -3,6 +3,8 @@ import { authenticate, authorize } from '../middleware/auth.js' import { isBatchEnabled, bufferResponse } from '../services/responseBuffer.js' import * as resultsSnapshot from '../services/resultsSnapshot.js' import { debug } from '../utils/debug.js' +import { stripAnswerKey } from '../utils/sanitize.js' +import { secondsSinceLaunch, isWithinAnswerWindow, resolveResponseTime } from '../utils/answerWindow.js' const router = express.Router() // Apply authentication to all routes @@ -115,6 +117,15 @@ router.post('/', authorize('student'), async (req, res) => { return res.status(404).json({ error: 'Question not found' }) } + // Answer window: reject anything submitted after the question's timeToAnswer (+ grace) has + // elapsed since it launched. launchedAt is set at creation for every question created today; + // fall back to createdAt for documents written before this field existed. + const tta = question.timeToAnswer || 30 + const elapsedS = secondsSinceLaunch(question.launchedAt || question.createdAt) + if (!isWithinAnswerWindow(elapsedS, tta)) { + return res.status(410).json({ error: 'This question is no longer accepting answers' }) + } + // Check if answer is correct based on question type let isCorrect = false @@ -142,8 +153,10 @@ router.post('/', authorize('student'), async (req, res) => { // Formula: earnedPoints = isCorrect ? maxPoints × max(0.1, (tta - responseTime) / tta) : 0 // Minimum 10% of max points for correct answers (even if time runs out) const maxPoints = question.points || 100 - const tta = question.timeToAnswer || 30 - const respTime = responseTime || 0 + // responseTime is client-reported and can be forged (see security-poc/leaderboard_bot.mjs) — + // floor it to what the server itself observed elapsed, so a claimed value can never read as + // *faster* than physically possible. + const respTime = resolveResponseTime(responseTime, elapsedS) let points = 0 if (isCorrect) { @@ -549,12 +562,26 @@ router.get('/room/:roomId/student/:studentId', async (req, res) => { if (studentResponse) { debug(`[responses] Matched question ${qIdStr} with response, selectedOption: ${studentResponse.selectedOption}`) } - + + // Answer-key protection: this endpoint is polled by every student's client on load and on + // every poll transition. Unconditionally sending options[].isCorrect here — even for a + // question the student hasn't answered — hands out the correct answer for free (this is + // exactly what security-poc/leaderboard_bot.mjs reads to auto-answer every question). Reveal + // once the student has answered (so the "your answer was right/wrong" badges work), OR once + // the question's own answer window has genuinely closed (so the "Missed — correct answer + // was X" review still works for questions no longer live) — never while it's still live and + // unanswered. Teachers always get it. + const answerWindowClosed = !isWithinAnswerWindow( + secondsSinceLaunch(q.launchedAt || q.createdAt), + q.timeToAnswer || 30 + ) + const safe = stripAnswerKey(q, isTeacher || !!studentResponse || answerWindowClosed) + return { _id: qIdStr, question: q.question, type: q.type, - options: q.options, + options: safe.options, segmentIndex: q.segmentIndex, maxPoints: q.points, timeToAnswer: q.timeToAnswer, diff --git a/backend/src/utils/answerWindow.js b/backend/src/utils/answerWindow.js new file mode 100644 index 00000000..e2fa5e49 --- /dev/null +++ b/backend/src/utils/answerWindow.js @@ -0,0 +1,61 @@ +/** + * Server-side answer-window enforcement for POST /api/responses. + * + * A question is only answerable for `timeToAnswer` seconds after it launches, plus a small grace + * buffer for ordinary network/clock slack at the boundary. Anything older is rejected outright — + * this is what closes the "join an hour into a live session and answer every expired question" + * hole (security-poc/leaderboard_bot.mjs). + * + * The client also self-reports `responseTime` (frozen at click time, before its own deliberate + * 0-2s send jitter, so a legitimately fast click never loses points to network delay) — but + * nothing stops a client from simply lying and claiming near-zero. `resolveResponseTime` floors + * that claim against what the server itself observed elapsed, so it can never read as more than a + * few seconds faster than physically possible — a fixed, small allowance, not a fraction of the + * question's full window. + */ + +// Deadline tolerance: how far past timeToAnswer a submission can still land and be accepted. +// Covers normal network latency + the app's own deliberate send-side jitter. +export const ANSWER_GRACE_S = 5 + +// responseTime floor discount: how much LESS than the server-observed elapsed time a client's +// self-reported responseTime is allowed to claim. Deliberately small and separate from +// ANSWER_GRACE_S above — that constant tolerates a late *arrival*, this one bounds how much a +// claimed *reaction time* may undercut reality. Matches exactly the app's own deliberate 0-2s +// send-side jitter (StudentRoomPage.jsx) — a legitimate click is never penalized beyond that; it +// must NOT scale with the deadline grace or every submission could shave a flat multi-second +// discount off its score regardless of when it actually landed. +export const RESPONSE_TIME_SLACK_S = 2 + +/** + * @param {Date|string|number} launchedAt - when the question went live (Question.launchedAt, or + * createdAt as a fallback for documents predating that field) + * @param {number} now - current time in ms (Date.now(); parameterized for tests) + * @returns {number} seconds elapsed since launch (can be negative under clock skew) + */ +export function secondsSinceLaunch(launchedAt, now = Date.now()) { + if (!launchedAt) return 0 + return (now - new Date(launchedAt).getTime()) / 1000 +} + +/** + * @param {number} elapsedS - result of secondsSinceLaunch + * @param {number} timeToAnswer - the question's answer window, in seconds + * @returns {boolean} true if a submission arriving now is within the allowed window + */ +export function isWithinAnswerWindow(elapsedS, timeToAnswer) { + return elapsedS <= timeToAnswer + ANSWER_GRACE_S +} + +/** + * Floors a client-reported responseTime to what the server itself observed (minus a small fixed + * slack), so a forged near-zero value can never score meaningfully higher than an honest report + * submitted at the same wall-clock moment. + * @param {number} clientResponseTime - value the client sent (may be forged/absent) + * @param {number} elapsedS - result of secondsSinceLaunch + * @returns {number} responseTime to use for scoring + */ +export function resolveResponseTime(clientResponseTime, elapsedS) { + const minPlausible = Math.max(0, elapsedS - RESPONSE_TIME_SLACK_S) + return Math.max(Number(clientResponseTime) || 0, minPlausible) +} diff --git a/backend/src/utils/sanitize.js b/backend/src/utils/sanitize.js index 1a3a3e39..cde73d99 100644 --- a/backend/src/utils/sanitize.js +++ b/backend/src/utils/sanitize.js @@ -111,4 +111,25 @@ export const stripObject = (obj) => { return obj } -export default { sanitize, sanitizeObject, stripHtml, stripObject } \ No newline at end of file +/** + * Strip the answer key (options[].isCorrect, explanation) from a question doc. + * A student must never receive these for a question they have not answered yet — + * doing so lets a client read the correct option straight off the wire instead of + * answering it (see security-poc/leaderboard_bot.mjs, finding #7). Call with + * reveal=true for a teacher, or a student who has already submitted a response. + * @param {object} q - a lean Question document (or question-shaped object) + * @param {boolean} reveal - false hides the key; true passes the doc through + * @returns {object} q unchanged, or a shallow copy with the key removed + */ +export const stripAnswerKey = (q, reveal) => { + if (!q || reveal) return q + const { explanation, ...rest } = q + return { + ...rest, + options: Array.isArray(q.options) + ? q.options.map(({ isCorrect, ...opt }) => opt) + : q.options + } +} + +export default { sanitize, sanitizeObject, stripHtml, stripObject, stripAnswerKey } \ No newline at end of file diff --git a/frontend/src/components/CreateQuestionOverlay.jsx b/frontend/src/components/CreateQuestionOverlay.jsx index 99179d41..286cb506 100644 --- a/frontend/src/components/CreateQuestionOverlay.jsx +++ b/frontend/src/components/CreateQuestionOverlay.jsx @@ -1,14 +1,34 @@ import React, { useState, useEffect, useRef } from 'react' -function CreateQuestionOverlay({ isOpen, onClose, onLaunch, defaultType = 'MCQ' }) { - const [questionType, setQuestionType] = useState(defaultType) - const [question, setQuestion] = useState('') - const [options, setOptions] = useState([ +// Default option set for a freshly opened / freshly reset question of the given type. +function defaultOptionsFor(type) { + if (type === 'TF') { + return [ + { text: 'True', isCorrect: true }, + { text: 'False', isCorrect: false } + ] + } + if (type === 'MSQ') { + // at least 2 correct by default + return [ + { text: '', isCorrect: true }, + { text: '', isCorrect: true }, + { text: '', isCorrect: false }, + { text: '', isCorrect: false } + ] + } + return [ { text: '', isCorrect: true }, { text: '', isCorrect: false }, { text: '', isCorrect: false }, { text: '', isCorrect: false } - ]) + ] +} + +function CreateQuestionOverlay({ isOpen, onClose, onLaunch, defaultType = 'MCQ' }) { + const [questionType, setQuestionType] = useState(defaultType) + const [question, setQuestion] = useState('') + const [options, setOptions] = useState(defaultOptionsFor(defaultType)) const [timeToAnswer, setTimeToAnswer] = useState(30) const [points, setPoints] = useState(100) @@ -16,32 +36,23 @@ function CreateQuestionOverlay({ isOpen, onClose, onLaunch, defaultType = 'MCQ' const [isLaunched, setIsLaunched] = useState(false) const [launchedTimeLeft, setLaunchedTimeLeft] = useState(0) const launchedTimerRef = useRef(null) + // Brief banner shown after a question's timer ends, so it's obvious the form is ready for the + // next one rather than just silently going blank. + const [showReadyBanner, setShowReadyBanner] = useState(false) + const readyBannerTimerRef = useRef(null) + + useEffect(() => { + return () => { + if (launchedTimerRef.current) clearInterval(launchedTimerRef.current) + if (readyBannerTimerRef.current) clearTimeout(readyBannerTimerRef.current) + } + }, []) if (!isOpen) return null const handleTypeChange = (newType) => { setQuestionType(newType) - if (newType === 'TF') { - setOptions([ - { text: 'True', isCorrect: true }, - { text: 'False', isCorrect: false } - ]) - } else if (newType === 'MCQ') { - setOptions([ - { text: '', isCorrect: true }, - { text: '', isCorrect: false }, - { text: '', isCorrect: false }, - { text: '', isCorrect: false } - ]) - } else { - // MSQ - at least 2 correct by default - setOptions([ - { text: '', isCorrect: true }, - { text: '', isCorrect: true }, - { text: '', isCorrect: false }, - { text: '', isCorrect: false } - ]) - } + setOptions(defaultOptionsFor(newType)) } const handleOptionChange = (index, text) => { @@ -117,9 +128,11 @@ function CreateQuestionOverlay({ isOpen, onClose, onLaunch, defaultType = 'MCQ' if (prev <= 1) { clearInterval(launchedTimerRef.current) launchedTimerRef.current = null - // Auto-close when timer hits 0 + // Reset the form for the next question, but stay open — a teacher creating questions + // one at a time otherwise has to close this overlay and reopen it from the room page + // for every single question. setTimeout(() => { - handleCloseAndReset() + resetFormForNextQuestion() }, 500) return 0 } @@ -128,21 +141,33 @@ function CreateQuestionOverlay({ isOpen, onClose, onLaunch, defaultType = 'MCQ' }, 1000) } + // Blank the question/options for the next one, keep timeToAnswer/points/type as-is (a teacher + // firing off several questions in a row usually wants the same settings), and stay open. + const resetFormForNextQuestion = () => { + setIsLaunched(false) + setLaunchedTimeLeft(0) + setQuestion('') + setOptions(defaultOptionsFor(questionType)) + + setShowReadyBanner(true) + if (readyBannerTimerRef.current) clearTimeout(readyBannerTimerRef.current) + readyBannerTimerRef.current = setTimeout(() => setShowReadyBanner(false), 4000) + } + const handleCloseAndReset = () => { if (launchedTimerRef.current) { clearInterval(launchedTimerRef.current) launchedTimerRef.current = null } + if (readyBannerTimerRef.current) { + clearTimeout(readyBannerTimerRef.current) + readyBannerTimerRef.current = null + } setIsLaunched(false) setLaunchedTimeLeft(0) - // Reset form + setShowReadyBanner(false) setQuestion('') - setOptions([ - { text: '', isCorrect: true }, - { text: '', isCorrect: false }, - { text: '', isCorrect: false }, - { text: '', isCorrect: false } - ]) + setOptions(defaultOptionsFor(questionType)) setTimeToAnswer(30) setPoints(100) onClose() @@ -194,6 +219,21 @@ function CreateQuestionOverlay({ isOpen, onClose, onLaunch, defaultType = 'MCQ'

✍️ Create Question

+ {showReadyBanner && ( +
+ + ✓ Question closed — ready for the next one + +
+ )} {isLaunched && (