feat(student): wire roster identity and student session
New Platform Shell entry point (frontend/src/platform/) alongside the existing, completely unmodified Lab UI (App.jsx and everything under frontend/src/components/). main.jsx now splits on the current URL path — /lab/<token>/ renders the existing <App/> exactly as before; anything else renders the new <PlatformShell/>. Both live in the same built bundle (it's served both by the persistent identity instance and by every ephemeral Lab container), split purely at runtime. - EntryScreen.jsx — cohort dropdown -> name dropdown (existing GET /api/roster/cohorts, GET /api/roster?cohort=) -> POST /api/login. No password. Visibly labeled pilot identity, not production auth, per the governing requirement that this be stated explicitly, not just true in the architecture docs. - api.js — student_id lives in localStorage only, sent as X-Student-Id on every authenticated request. Every authenticated call throws if no student is stored rather than silently going out anonymously — this is what "prevent accidental anonymous progress writes" means in practice, not just a comment. - PlatformShell.jsx — entry screen if no stored session, dashboard otherwise. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
+10
-1
@@ -1,14 +1,23 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
|
import PlatformShell from './platform/PlatformShell.jsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
// Apply saved theme before first paint to avoid flash
|
// Apply saved theme before first paint to avoid flash
|
||||||
const savedTheme = localStorage.getItem('kubekosh-theme') || 'dark'
|
const savedTheme = localStorage.getItem('kubekosh-theme') || 'dark'
|
||||||
document.documentElement.setAttribute('data-theme', savedTheme)
|
document.documentElement.setAttribute('data-theme', savedTheme)
|
||||||
|
|
||||||
|
// This bundle is served both by the persistent identity/platform instance (root paths) and by
|
||||||
|
// every ephemeral per-student Lab container (reached via the Gateway at /lab/<token>/) — same
|
||||||
|
// build, same code, split purely by the URL path at runtime. See
|
||||||
|
// docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Student flow" for why: it lets the existing Lab UI
|
||||||
|
// (<App/>) stay completely unmodified while the new Platform Shell (entry/dashboard/launch)
|
||||||
|
// lives alongside it in the same bundle.
|
||||||
|
const isLabSession = window.location.pathname.startsWith('/lab/')
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
{isLabSession ? <App /> : <PlatformShell />}
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { api, storeStudent } from './api'
|
||||||
|
import styles from './Platform.module.css'
|
||||||
|
|
||||||
|
// A. Entry screen — RootNode Academy branding, choose your name from roster, no password.
|
||||||
|
// Explicitly pilot/dev identity, not production auth — see
|
||||||
|
// docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Student identity limitation."
|
||||||
|
export default function EntryScreen({ onLogin }) {
|
||||||
|
const [cohorts, setCohorts] = useState([])
|
||||||
|
const [cohort, setCohort] = useState('')
|
||||||
|
const [roster, setRoster] = useState([])
|
||||||
|
const [studentId, setStudentId] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.cohorts().then(setCohorts).catch((e) => setError(e.message))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cohort) { setRoster([]); return }
|
||||||
|
api.roster(cohort).then(setRoster).catch((e) => setError(e.message))
|
||||||
|
}, [cohort])
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!studentId) return
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const student = await api.login(studentId)
|
||||||
|
storeStudent(student)
|
||||||
|
onLogin(student)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.entryWrap}>
|
||||||
|
<div className={styles.entryCard}>
|
||||||
|
<div className={styles.brand}>
|
||||||
|
<span className={styles.brandIcon}>⎈</span> RootNode Academy
|
||||||
|
</div>
|
||||||
|
<p className={styles.tagline}>Real-world Kubernetes labs. Pick your name to continue.</p>
|
||||||
|
<p className={styles.pilotNote}>Pilot identity — no password. Not production auth.</p>
|
||||||
|
|
||||||
|
{error && <div className={styles.errorBox}>{error}</div>}
|
||||||
|
|
||||||
|
<label className={styles.label}>Cohort</label>
|
||||||
|
<select className={styles.select} value={cohort} onChange={(e) => { setCohort(e.target.value); setStudentId('') }}>
|
||||||
|
<option value="">Select a cohort…</option>
|
||||||
|
{cohorts.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{cohort && (
|
||||||
|
<>
|
||||||
|
<label className={styles.label}>Your name</label>
|
||||||
|
<select className={styles.select} value={studentId} onChange={(e) => setStudentId(e.target.value)}>
|
||||||
|
<option value="">Select your name…</option>
|
||||||
|
{roster.map((s) => <option key={s.student_id} value={s.student_id}>{s.display_name}</option>)}
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className={styles.primaryBtn} disabled={!studentId || loading} onClick={handleLogin}>
|
||||||
|
{loading ? 'Signing in…' : 'Continue'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { getStoredStudent } from './api'
|
||||||
|
import EntryScreen from './EntryScreen'
|
||||||
|
import StudentDashboard from './StudentDashboard'
|
||||||
|
|
||||||
|
// Top-level Platform Shell: entry screen if no student session, dashboard otherwise. This is
|
||||||
|
// everything OUTSIDE the /lab/<token>/ Lab experience (which stays the existing, unmodified
|
||||||
|
// <App/> — see main.jsx).
|
||||||
|
export default function PlatformShell() {
|
||||||
|
const [student, setStudent] = useState(getStoredStudent)
|
||||||
|
|
||||||
|
if (!student) return <EntryScreen onLogin={setStudent} />
|
||||||
|
return <StudentDashboard student={student} onLogout={() => setStudent(null)} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Platform Shell API client. Student identity is pilot-grade (roster pick-your-name, no
|
||||||
|
// password) — see docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Student identity limitation."
|
||||||
|
// student_id lives in localStorage only, never a cookie, never sent anywhere except as the
|
||||||
|
// X-Student-Id header on these specific requests.
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'rootnode_student'
|
||||||
|
|
||||||
|
export function getStoredStudent() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null') } catch { return null }
|
||||||
|
}
|
||||||
|
export function storeStudent(student) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(student))
|
||||||
|
}
|
||||||
|
export function clearStudent() {
|
||||||
|
localStorage.removeItem(STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders() {
|
||||||
|
const student = getStoredStudent()
|
||||||
|
// Prevents accidental anonymous progress writes (Phase 2A requirement): every call that
|
||||||
|
// needs identity throws instead of silently going out unauthenticated.
|
||||||
|
if (!student?.student_id) throw new Error('No student session')
|
||||||
|
return { 'X-Student-Id': student.student_id }
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
cohorts: () => fetch('/api/roster/cohorts').then(json),
|
||||||
|
roster: (cohort) => fetch(`/api/roster?cohort=${encodeURIComponent(cohort)}`).then(json),
|
||||||
|
login: (student_id) => fetch('/api/login', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ student_id }),
|
||||||
|
}).then(json),
|
||||||
|
|
||||||
|
curriculumPath: () => fetch('/api/curriculum/path', { headers: authHeaders() }).then(json),
|
||||||
|
|
||||||
|
environmentStatus: () => fetch('/api/environment/status', { headers: authHeaders() }).then(json),
|
||||||
|
launchEnvironment: () => fetch('/api/environment/launch', { method: 'POST', headers: authHeaders() }).then(json),
|
||||||
|
resetEnvironment: () => fetch('/api/environment/reset', { method: 'POST', headers: authHeaders() }).then(json),
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user