StudentDashboard is now tabbed: My Learning (the original Phase 2A/2B flow, extracted unchanged into MyLearningTab and extended with an overall percent-complete bar), Practice, Assessments (available/active-with-live-countdown/history), Capstone (locked checklist -> unlocked -> start/resume -> completed), Achievements (a flat dated list, not a leaderboard). Inside the Lab UI: ProgressiveHints replaces the static hint array render when the Gateway has stripped hints (detected via scenario.hints being undefined but hint_count present - standalone dev mode never sees this, so it keeps the original behavior); a new top-level AssessmentBanner shows a live countdown and completion count whenever a Platform assessment is running, rendered alongside (not replacing) the pre-existing Lab-local exam-mode UI, which is untouched.
87 lines
3.7 KiB
JavaScript
87 lines
3.7 KiB
JavaScript
// Platform Shell API client. Identity is a server-controlled session cookie (rn_session,
|
|
// HttpOnly — this code can't read it, and doesn't need to; the browser sends it automatically
|
|
// on every same-origin request). Roster name-picking is classroom/pilot identification, not
|
|
// production authentication (see docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §5) — what's
|
|
// hardened here is the session that results from it: persistent (survives an API restart),
|
|
// unforgeable, and checked on every request.
|
|
//
|
|
// display_name is still cached in sessionStorage purely for UI convenience (showing "Hi,
|
|
// Alice" without an extra round trip) — it carries no authority; every actual API call is
|
|
// authorized by the cookie alone, not by anything read from here.
|
|
//
|
|
// rn_csrf_s is a deliberately non-HttpOnly cookie (double-submit CSRF pattern) — this code
|
|
// reads it and echoes it back as a header on every state-changing request, proving the request
|
|
// came from our own origin's JS (a cross-origin attacker can trigger the cookie but can't read
|
|
// its value or attach a matching custom header without CORS, which we don't grant elsewhere).
|
|
|
|
const DISPLAY_CACHE_KEY = 'rootnode_student_display'
|
|
const CSRF_COOKIE = 'rn_csrf_s'
|
|
|
|
export function getCachedDisplay() {
|
|
try { return JSON.parse(sessionStorage.getItem(DISPLAY_CACHE_KEY) || 'null') } catch { return null }
|
|
}
|
|
function cacheDisplay(student) {
|
|
sessionStorage.setItem(DISPLAY_CACHE_KEY, JSON.stringify(student))
|
|
}
|
|
function clearCachedDisplay() {
|
|
sessionStorage.removeItem(DISPLAY_CACHE_KEY)
|
|
}
|
|
|
|
function getCookie(name) {
|
|
return document.cookie.split('; ').find((c) => c.startsWith(`${name}=`))?.split('=')[1]
|
|
}
|
|
|
|
async function json(res) {
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}))
|
|
throw new Error(body.message || body.error || `Request failed (${res.status})`)
|
|
}
|
|
return res.json()
|
|
}
|
|
|
|
function mutate(path, body) {
|
|
return fetch(path, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCookie(CSRF_COOKIE) || '' },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
}).then(json)
|
|
}
|
|
|
|
export const api = {
|
|
cohorts: () => fetch('/api/roster/cohorts').then(json),
|
|
roster: (cohort) => fetch(`/api/roster?cohort=${encodeURIComponent(cohort)}`).then(json),
|
|
login: async (student_id) => {
|
|
// No CSRF token yet at login (nothing authenticated to forge into) — same reasoning as the
|
|
// instructor login route.
|
|
const student = await fetch('/api/login', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ student_id }),
|
|
}).then(json)
|
|
cacheDisplay(student)
|
|
return student
|
|
},
|
|
logout: async () => {
|
|
await mutate('/api/logout').catch(() => {})
|
|
clearCachedDisplay()
|
|
},
|
|
whoami: () => fetch('/api/session/whoami').then(json),
|
|
|
|
curriculumPath: () => fetch('/api/curriculum/path').then(json),
|
|
curriculumState: () => fetch('/api/curriculum/state').then(json),
|
|
|
|
environmentStatus: () => fetch('/api/environment/status').then(json),
|
|
launchEnvironment: () => mutate('/api/environment/launch'),
|
|
resetEnvironment: () => mutate('/api/environment/reset'),
|
|
|
|
recentAttempts: () => fetch('/api/attempts/recent').then(json),
|
|
|
|
assessments: () => fetch('/api/assessments').then(json),
|
|
activeAssessment: () => fetch('/api/assessments/active').then(json),
|
|
assessmentDetail: (sessionId) => fetch(`/api/assessments/${sessionId}`).then(json),
|
|
startAssessment: (definitionId) => mutate(`/api/assessments/${definitionId}/start`),
|
|
submitAssessment: (sessionId) => mutate(`/api/assessments/${sessionId}/submit`),
|
|
|
|
capstone: () => fetch('/api/capstone').then(json),
|
|
|
|
achievements: () => fetch('/api/achievements').then(json),
|
|
}
|