fix: soft-reset UX + stale-cookie handling + leaderboard 'is_you' by id
Three coupled fixes from the first manual test pass:
1. Stale signed cookie no longer 500s. `rooms.me()` now raises KeyError
when the participant row is gone (the previous code deref'd None and
threw TypeError, caught by quiz.js's catch-all as 'link expired').
`/api/session/{sid}/me` translates KeyError into 401 + delete_cookie,
so the client falls back to the join form cleanly.
Returning a JSONResponse directly because `raise HTTPException`
discards Response.delete_cookie mutations (FastAPI middleware
composes a fresh response on exception).
2. Reset is now a soft restart from the student's perspective. Before
closing each student WS in `RoomManager.reset`, the server now sends
a `{"type": "session_reset"}` message. The student SPA tears down
local state and re-runs boot(); /me returns 401 (now that the
participant is gone) and the join form renders without the user
having to manually reload. The WS close handler suppresses its
"Disconnected" screen during a reset to avoid a flash.
3. "You" highlight on the student leaderboard is now matched by id, not
by name. `RoomManager.leaderboard()` accepts an optional
`you_student_id` and stamps `is_you: true` on the matching entry only.
No other students' ids leak over the wire (we still don't include
`student_id` in the public top5 payload). quiz.js's renderBoard
prefers `r.is_you` when any row is marked, falling back to name match
for backward compatibility.
41/41 tests pass. Two new tests cover (a) the 401 + cookie-clear path
after reset and (b) `is_you` marking only the requesting student.
This commit is contained in:
@@ -130,6 +130,10 @@ function connect() {
|
||||
try { handleMessage(JSON.parse(event.data)); } catch (e) { console.warn("bad ws msg", e); }
|
||||
});
|
||||
ws.addEventListener("close", () => {
|
||||
// session_reset already drove a re-boot; suppress the generic
|
||||
// "disconnected" screen so it doesn't briefly flash on top of the
|
||||
// "Re-joining…" interstitial.
|
||||
if (store.resetting) return;
|
||||
stopCountdown();
|
||||
setView(`
|
||||
<div class="card narrow center">
|
||||
@@ -149,10 +153,32 @@ function handleMessage(message) {
|
||||
case "question_closed": return renderReveal(message);
|
||||
case "between_questions": return renderBetween(message);
|
||||
case "session_ended": return renderFinished(message);
|
||||
case "session_reset": return handleSessionReset();
|
||||
case "error": return renderError(message);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionReset() {
|
||||
// Instructor cleared everyone out. Tear local state down and re-boot;
|
||||
// /api/session/<sid>/me will return 401 (with cookie cleared by the
|
||||
// server) and we'll land cleanly on the join form.
|
||||
store.resetting = true;
|
||||
stopCountdown();
|
||||
store.me = null;
|
||||
store.currentQuestion = null;
|
||||
store.submitted = null;
|
||||
store.pickedAnswer = null;
|
||||
if (store.ws) { try { store.ws.close(); } catch {} store.ws = null; }
|
||||
setView(`
|
||||
<div class="card narrow center">
|
||||
<h1>Session reset</h1>
|
||||
<p class="muted">Your instructor reset the session. Re-joining…</p>
|
||||
<div class="spinner" aria-hidden="true"></div>
|
||||
</div>
|
||||
`);
|
||||
setTimeout(() => { store.resetting = false; boot(); }, 600);
|
||||
}
|
||||
|
||||
function renderState(message) {
|
||||
store.currentQuestion = null;
|
||||
store.submitted = null;
|
||||
@@ -314,15 +340,18 @@ function renderFinished(message) {
|
||||
|
||||
function renderBoard(rows = []) {
|
||||
if (!rows || !rows.length) return `<p class="muted small">No scores yet.</p>`;
|
||||
// The server's student-facing top5 doesn't include student_id, so match
|
||||
// on display name. Same-name collisions are rare in a single classroom
|
||||
// session; if it ever happens both rows highlight, which still reads
|
||||
// as "yours might be one of these" to the student.
|
||||
// The server marks the requesting student's row with `is_you: true` so
|
||||
// we can highlight by id without other students' ids ever crossing the
|
||||
// wire. Falls back to name match only if the server didn't mark anything
|
||||
// (older payloads pre-migration).
|
||||
const anyMarked = rows.some((r) => r.is_you);
|
||||
const myName = store.me?.name;
|
||||
return `
|
||||
<ol class="leaderboard">
|
||||
${rows.map((r) => {
|
||||
const isYou = myName && r.name && r.name === myName;
|
||||
const isYou = anyMarked
|
||||
? !!r.is_you
|
||||
: (myName && r.name && r.name === myName);
|
||||
return `
|
||||
<li class="${isYou ? "is-you" : ""}">
|
||||
<span class="rank">${r.rank}</span>
|
||||
|
||||
Reference in New Issue
Block a user