Files
rootnode-academy/frontend/src/platform/api.js
T
ced4568 1b6da0a966 feat(selfpaced): student-facing My Programs dashboard
- ProgramsTab.jsx: Active/Completed programs, real progress, deterministic Continue Learning
  (reuses the same launch/poll pattern MyLearningTab already established), entitlement-blocked
  states shown with a real reason, never a silent dead end.
- StudentDashboard.jsx: new My Programs tab. api.js: programs/program/launchProgram.
2026-08-25 12:08:44 -05:00

128 lines
6.5 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),
// ── Phase 5A: real student accounts ─────────────────────────────────────────────────────
// No CSRF token for login/activate/reset-request/reset — same reasoning as the pilot login
// above: nothing authenticated exists yet to forge into.
studentLogin: async (identifier, password) => {
const { student } = await fetch('/api/student/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier, password }),
}).then(json)
cacheDisplay(student)
return student
},
activate: async (token, password) => {
const { student } = await fetch('/api/student/activate', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password }),
}).then(json)
cacheDisplay(student)
return student
},
credentialTokenPreview: (purpose, token) => fetch(`/api/student/credential-token/${purpose}?token=${encodeURIComponent(token)}`).then(json),
requestPasswordReset: (identifier) => fetch('/api/student/request-password-reset', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier }),
}).then(json),
resetPassword: (token, password) => fetch('/api/student/reset-password', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password }),
}).then(json),
changePassword: (old_password, new_password) => mutate('/api/student/change-password', { old_password, new_password }),
profile: () => fetch('/api/student/profile').then(json),
// ── Phase 5A: remote classroom ───────────────────────────────────────────────────────────
liveClassroom: () => fetch('/api/classroom/live').then(json),
classroom: (id) => fetch(`/api/classroom/${id}`).then(json),
classroomAnnouncements: (id) => fetch(`/api/classroom/${id}/announcements`).then(json),
joinClassroom: (id) => mutate(`/api/classroom/${id}/join`),
leaveClassroom: (id) => mutate(`/api/classroom/${id}/leave`),
classroomHeartbeat: (id) => mutate(`/api/classroom/${id}/heartbeat`),
requestHelp: (id, category, message) => mutate(`/api/classroom/${id}/help-request`, { category, message }),
// ── Phase 5B: self-paced programs ────────────────────────────────────────────────────────
programs: () => fetch('/api/student/programs').then(json),
program: (enrollmentId) => fetch(`/api/student/programs/${enrollmentId}`).then(json),
launchProgram: (enrollmentId) => mutate(`/api/student/programs/${enrollmentId}/launch`),
}