Replaces the Phase 2A pilot ?sid= query param with an HttpOnly, SameSite=Lax session cookie. Gateway now derives student identity from the session (via Platform API's /api/session/whoami) instead of trusting a client-suppliable id, and checks ownership on every proxied HTTP and WebSocket Lab request, not just the initial navigation. Fixes a routing bug found during testing: the fallback route was using 'does this session own a running environment' to decide whether an unprefixed /api/* call belonged to the Lab UI or the dashboard — true in both places. Replaced with an explicit LAB_OWNED_PATHS allowlist of the exact unprefixed paths the existing Lab UI actually calls. e2e-phase2a.js rewritten to use a cookie jar instead of headers/query params, with explicit impersonation-rejection tests added. 10/10 passing.
73 lines
2.6 KiB
React
73 lines
2.6 KiB
React
import { useState, useEffect } from 'react'
|
|
import { api } 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)
|
|
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>
|
|
)
|
|
}
|