diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 87672e3..389ed5d 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,14 +1,23 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App.jsx' +import PlatformShell from './platform/PlatformShell.jsx' import './index.css' // Apply saved theme before first paint to avoid flash const savedTheme = localStorage.getItem('kubekosh-theme') || 'dark' 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//) — 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 +// () 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( - + {isLabSession ? : } ) diff --git a/frontend/src/platform/EntryScreen.jsx b/frontend/src/platform/EntryScreen.jsx new file mode 100644 index 0000000..f4f7074 --- /dev/null +++ b/frontend/src/platform/EntryScreen.jsx @@ -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 ( +
+
+
+ ⎈ RootNode Academy +
+

Real-world Kubernetes labs. Pick your name to continue.

+

Pilot identity — no password. Not production auth.

+ + {error &&
{error}
} + + + + + {cohort && ( + <> + + + + )} + + +
+
+ ) +} diff --git a/frontend/src/platform/PlatformShell.jsx b/frontend/src/platform/PlatformShell.jsx new file mode 100644 index 0000000..7a01733 --- /dev/null +++ b/frontend/src/platform/PlatformShell.jsx @@ -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// Lab experience (which stays the existing, unmodified +// — see main.jsx). +export default function PlatformShell() { + const [student, setStudent] = useState(getStoredStudent) + + if (!student) return + return setStudent(null)} /> +} diff --git a/frontend/src/platform/api.js b/frontend/src/platform/api.js new file mode 100644 index 0000000..70f69ae --- /dev/null +++ b/frontend/src/platform/api.js @@ -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), +}