diff --git a/frontend/src/platform/ClassroomTab.jsx b/frontend/src/platform/ClassroomTab.jsx new file mode 100644 index 0000000..cf125c2 --- /dev/null +++ b/frontend/src/platform/ClassroomTab.jsx @@ -0,0 +1,146 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { api } from './api' +import styles from './Platform.module.css' + +const HEARTBEAT_MS = 25000 // §15 — "browser heartbeat every 20-30 seconds" +const POLL_MS = 5000 + +const HELP_CATEGORIES = [ + { value: 'lab_problem', label: 'Lab problem' }, + { value: 'dont_understand', label: "Don't understand the task" }, + { value: 'validation_failing', label: 'Validation failing' }, + { value: 'other', label: 'Other' }, +] + +// Phase 5A §22 — the live classroom experience. No live classroom -> a quiet "nothing live +// right now" state. Live -> title, current activity (guidance only — a link into the student's +// own Lab, never an auto-redirect), announcements, Need Help, and the student's own Lab status +// (reusing the exact same /api/environment/* the My Learning tab uses — no second Lab-status +// system). Heartbeats fire only while this tab is mounted and a classroom is actually joined. +export default function ClassroomTab() { + const [live, setLive] = useState(null) + const [detail, setDetail] = useState(null) + const [envStatus, setEnvStatus] = useState(null) + const [joined, setJoined] = useState(false) + const [helpCategory, setHelpCategory] = useState('lab_problem') + const [helpMessage, setHelpMessage] = useState('') + const [helpSent, setHelpSent] = useState(false) + const [error, setError] = useState(null) + const heartbeatRef = useRef(null) + const pollRef = useRef(null) + + const loadLive = useCallback(() => { + api.liveClassroom().then((r) => setLive(r.classroom)).catch((e) => setError(e.message)) + }, []) + useEffect(() => { loadLive() }, [loadLive]) + + const loadDetail = useCallback((id) => { + api.classroom(id).then(setDetail).catch((e) => setError(e.message)) + api.environmentStatus().then(setEnvStatus).catch(() => {}) + }, []) + + useEffect(() => { + if (!live) return + loadDetail(live.classroom_session_id) + pollRef.current = setInterval(() => loadDetail(live.classroom_session_id), POLL_MS) + return () => clearInterval(pollRef.current) + }, [live, loadDetail]) + + useEffect(() => { + if (!joined || !live) return + heartbeatRef.current = setInterval(() => api.classroomHeartbeat(live.classroom_session_id).catch(() => {}), HEARTBEAT_MS) + api.classroomHeartbeat(live.classroom_session_id).catch(() => {}) // one immediately on join, not just after the first interval + return () => clearInterval(heartbeatRef.current) + }, [joined, live]) + + const join = async () => { + try { await api.joinClassroom(live.classroom_session_id); setJoined(true) } + catch (e) { setError(e.message) } + } + + const sendHelp = async (e) => { + e.preventDefault() + try { + await api.requestHelp(live.classroom_session_id, helpCategory, helpMessage) + setHelpSent(true); setHelpMessage('') + } catch (e) { setError(e.message) } + } + + const launchLab = async () => { + try { setEnvStatus(await api.launchEnvironment()) } catch (e) { setError(e.message) } + } + + if (!live) { + return ( +
+

Classroom

+

No live class right now. Check back at your scheduled class time.

+
+ ) + } + + return ( +
+
+

LIVE NOW

+

{live.title}

+ {live.description &&

{live.description}

} + {live.meeting_url && ( +

Join Video Class

+ )} + {!joined ? ( + + ) : ( +

You're in this classroom.

+ )} +
+ + {joined && detail && ( + <> + {detail.classroom.current_scenario_id && ( +
+

Current class activity

+

{detail.classroom.current_scenario_id}

+

Open it from the My Learning tab when you're ready — nothing here jumps you there automatically.

+
+ )} + +
+

Your Lab

+ {envStatus?.status === 'running' + ?

Open Lab

+ : } +
+ +
+

Announcements

+ {!detail.announcements.length ?

None yet.

: ( + + )} +
+ +
+

Need help?

+ {helpSent ?

Help requested — your instructor has been notified.

: ( +
+ + + + setHelpMessage(e.target.value)} /> + +
+ )} +
+ + )} + + {error &&
{error}
} +
+ ) +} diff --git a/frontend/src/platform/ForgotPasswordScreen.jsx b/frontend/src/platform/ForgotPasswordScreen.jsx new file mode 100644 index 0000000..59729cb --- /dev/null +++ b/frontend/src/platform/ForgotPasswordScreen.jsx @@ -0,0 +1,59 @@ +import { useState } from 'react' +import { api } from './api' +import styles from './Platform.module.css' + +// Phase 5A §6/§9 — self-service reset REQUEST. No email delivery exists yet +// (docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §5) — this always shows the same generic +// message regardless of whether the identifier exists (anti-enumeration, matches the server's +// own response shape exactly). In dev/test, the server includes dev_reset_token in its +// response so this screen can offer a direct link — a real deployment omits it, and completing +// the flow needs an instructor to run the admin-side reset instead (see +// docs/RUNBOOK_RESET_STUDENT_PASSWORD.md). +export default function ForgotPasswordScreen({ onCancel, onHaveToken }) { + const [identifier, setIdentifier] = useState('') + const [sent, setSent] = useState(null) + const [loading, setLoading] = useState(false) + + const submit = async (e) => { + e.preventDefault() + setLoading(true) + try { + const result = await api.requestPasswordReset(identifier) + setSent(result) + } finally { + setLoading(false) + } + } + + return ( +
+
+
+ ⎈ RootNode Academy +
+

Reset your password

+ + {!sent ? ( +
+ + setIdentifier(e.target.value)} required /> + +
+ ) : ( + <> +

{sent.message}

+ {sent.dev_reset_token && ( +

+ Dev/test only (never shown in production):{' '} + +

+ )} + + )} +

+ +

+
+
+ ) +} diff --git a/frontend/src/platform/LoginScreen.jsx b/frontend/src/platform/LoginScreen.jsx new file mode 100644 index 0000000..0643a1c --- /dev/null +++ b/frontend/src/platform/LoginScreen.jsx @@ -0,0 +1,63 @@ +import { useState } from 'react' +import { api } from './api' +import styles from './Platform.module.css' + +// Phase 5A §6 — the real, credentialed student login. This is now the default entry point +// (see PlatformShell.jsx); EntryScreen (pilot, no password) is still reachable for +// not-yet-converted accounts via the link at the bottom, not removed — see +// docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §7. +export default function LoginScreen({ onLogin, onShowPilotEntry, onShowForgotPassword }) { + const [identifier, setIdentifier] = useState('') + const [password, setPassword] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const handleLogin = async (e) => { + e.preventDefault() + if (!identifier || !password) return + setLoading(true) + setError(null) + try { + const student = await api.studentLogin(identifier, password) + onLogin(student) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + return ( +
+
+
+ ⎈ RootNode Academy +
+

Sign in to continue your learning.

+ + {error &&
{error}
} + +
+ + setIdentifier(e.target.value)} required /> + + + setPassword(e.target.value)} required /> + + +
+ +

+ +

+

+ +

+
+
+ ) +} diff --git a/frontend/src/platform/Platform.module.css b/frontend/src/platform/Platform.module.css index 32a8cf9..810c114 100644 --- a/frontend/src/platform/Platform.module.css +++ b/frontend/src/platform/Platform.module.css @@ -36,7 +36,7 @@ .label { display: block; font-size: 0.8rem; color: var(--text-2); margin: 0.9rem 0 0.3rem; } -.select { +.select, .input { width: 100%; padding: 0.55rem 0.6rem; background: var(--surface2); @@ -45,6 +45,7 @@ color: var(--text); font-family: var(--sans); font-size: 0.9rem; + box-sizing: border-box; } .primaryBtn { @@ -179,5 +180,12 @@ table.dataTable { width: 100%; border-collapse: collapse; font-size: 0.85rem; } .pillErr { background: var(--red-dim); color: var(--red); } .pillMuted { background: var(--surface3); color: var(--text-2); } +/* Phase 5A — classroom/profile screens */ +.livePill { + display: inline-block; background: var(--red-dim); color: var(--red); font-family: var(--mono); + font-size: 0.72rem; font-weight: 700; letter-spacing: 0.05em; padding: 0.2rem 0.6rem; border-radius: 999px; +} +.pager { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.75rem; } + .lockedBox { text-align: center; padding: 2rem 1rem; color: var(--text-2); } .checklistItem { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0; font-size: 0.88rem; } diff --git a/frontend/src/platform/PlatformShell.jsx b/frontend/src/platform/PlatformShell.jsx index e479f04..5b51131 100644 --- a/frontend/src/platform/PlatformShell.jsx +++ b/frontend/src/platform/PlatformShell.jsx @@ -1,17 +1,28 @@ import { useState, useEffect } from 'react' import { api, getCachedDisplay } from './api' +import LoginScreen from './LoginScreen' import EntryScreen from './EntryScreen' +import SetPasswordScreen from './SetPasswordScreen' +import ForgotPasswordScreen from './ForgotPasswordScreen' import StudentDashboard from './StudentDashboard' -// Top-level Platform Shell: entry screen if no valid session, dashboard otherwise. Session -// validity is decided by the server (GET /api/session/whoami against the rn_session cookie), -// not by anything read from localStorage/sessionStorage — those only cache the display name -// for a snappier first paint, per api.js. +// Top-level Platform Shell. Phase 5A: the credentialed LoginScreen is now the default entry +// point (was EntryScreen's pilot name-picker); an activation/reset link +// (?activate= or ?reset=) short-circuits straight to SetPasswordScreen before +// any session check, since neither flow requires an existing session. Session validity is +// still decided by the server (GET /api/session/whoami against the rn_session cookie), not by +// anything read from localStorage/sessionStorage. export default function PlatformShell() { const [student, setStudent] = useState(null) const [checking, setChecking] = useState(true) + const params = new URLSearchParams(window.location.search) + const [view, setView] = useState( + params.get('activate') ? 'activate' : params.get('reset') ? 'reset' : 'login' + ) + const [resetToken, setResetToken] = useState(params.get('reset') || null) useEffect(() => { + if (view === 'activate' || view === 'reset') { setChecking(false); return } const cached = getCachedDisplay() if (cached) setStudent(cached) // optimistic paint while we confirm with the server api.whoami() @@ -20,9 +31,29 @@ export default function PlatformShell() { }) .catch(() => setStudent(null)) .finally(() => setChecking(false)) - }, []) + }, [view]) if (checking) return null - if (!student) return - return setStudent(null)} /> + + if (view === 'activate') { + return { setStudent(s); setView('dashboard'); window.history.replaceState({}, '', '/') }} + onCancel={() => { setView('login'); window.history.replaceState({}, '', '/') }} /> + } + if (view === 'reset') { + return { setView('login'); window.history.replaceState({}, '', '/') }} + onCancel={() => { setView('login'); window.history.replaceState({}, '', '/') }} /> + } + if (view === 'forgot') { + return setView('login')} onHaveToken={(t) => { setResetToken(t); setView('reset') }} /> + } + if (view === 'pilot') { + return { setStudent(s); setView('dashboard') }} /> + } + if (!student) { + return { setStudent(s); setView('dashboard') }} + onShowPilotEntry={() => setView('pilot')} onShowForgotPassword={() => setView('forgot')} /> + } + return { setStudent(null); setView('login') }} /> } diff --git a/frontend/src/platform/ProfileTab.jsx b/frontend/src/platform/ProfileTab.jsx new file mode 100644 index 0000000..c9b55f1 --- /dev/null +++ b/frontend/src/platform/ProfileTab.jsx @@ -0,0 +1,91 @@ +import { useState, useEffect } from 'react' +import { api } from './api' +import styles from './Platform.module.css' + +// Phase 5A §10 — minimal account/profile page. Deliberately narrow: identity, cohort, progress +// summary, achievements, active session count, and change-password. No social/profile features. +export default function ProfileTab() { + const [profile, setProfile] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { api.profile().then(setProfile).catch((e) => setError(e.message)) }, []) + + if (error) return
{error}
+ if (!profile) return

Loading…

+ + return ( +
+
+

Account

+

{profile.student.display_name}

+

{profile.student.email || profile.student.login_identifier || 'No login identifier set'}

+

+ {profile.cohorts.length ? profile.cohorts.map((c) => c.name).join(', ') : 'Not enrolled in a cohort yet'} +

+

{profile.active_sessions} active session{profile.active_sessions === 1 ? '' : 's'}

+
+ +
+

Progress

+

{profile.progress_summary.completed} / {profile.progress_summary.total} scenarios completed

+
+
+
+
+ +
+

Achievements

+ {!profile.achievements.length ?

None yet — keep going!

: ( +
    + {profile.achievements.map((a, i) => ( +
  • {a.achievement_type.replace(/_/g, ' ')}{a.stage_id ? ` — ${a.stage_id}` : ''}
  • + ))} +
+ )} +
+ + +
+ ) +} + +function ChangePasswordCard({ hasCredentials }) { + const [oldPassword, setOldPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [busy, setBusy] = useState(false) + const [notice, setNotice] = useState(null) + const [error, setError] = useState(null) + + if (!hasCredentials) { + return ( +
+

Change password

+

This is still a pilot account with no password set. Ask your instructor to convert it to a real account first.

+
+ ) + } + + const submit = async (e) => { + e.preventDefault() + setBusy(true); setError(null); setNotice(null) + try { + await api.changePassword(oldPassword, newPassword) + setNotice('Password updated.'); setOldPassword(''); setNewPassword('') + } catch (err) { setError(err.message) } finally { setBusy(false) } + } + + return ( +
+

Change password

+ {error &&
{error}
} + {notice &&

{notice}

} +
+ + setOldPassword(e.target.value)} required /> + + setNewPassword(e.target.value)} required minLength={10} /> + +
+
+ ) +} diff --git a/frontend/src/platform/SetPasswordScreen.jsx b/frontend/src/platform/SetPasswordScreen.jsx new file mode 100644 index 0000000..544c542 --- /dev/null +++ b/frontend/src/platform/SetPasswordScreen.jsx @@ -0,0 +1,76 @@ +import { useState, useEffect } from 'react' +import { api } from './api' +import styles from './Platform.module.css' + +// Phase 5A §5/§6 — one screen for both flows the credential token mechanism serves: +// activation (first login, purpose='activation') and forgot-password (purpose='reset'). Same +// UI, different API call and success copy. +export default function SetPasswordScreen({ token, purpose, onDone, onCancel }) { + const [preview, setPreview] = useState(null) + const [previewError, setPreviewError] = useState(null) + const [password, setPassword] = useState('') + const [confirm, setConfirm] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + api.credentialTokenPreview(purpose, token).then(setPreview).catch((e) => setPreviewError(e.message)) + }, [token, purpose]) + + const submit = async (e) => { + e.preventDefault() + if (password.length < 10) { setError('Password must be at least 10 characters.'); return } + if (password !== confirm) { setError('Passwords do not match.'); return } + setLoading(true) + setError(null) + try { + if (purpose === 'activation') { + const student = await api.activate(token, password) + onDone(student) + } else { + await api.resetPassword(token, password) + onDone(null) // reset doesn't log the browser in — every existing session was revoked, sign in fresh + } + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + return ( +
+
+
+ ⎈ RootNode Academy +
+

{purpose === 'activation' ? 'Set up your account' : 'Choose a new password'}

+ + {previewError && ( +
+ This link is invalid or has expired. Ask your instructor for a new one. +
+ )} + {preview &&

Hi, {preview.display_name}.

} + {error &&
{error}
} + + {!previewError && ( +
+ + setPassword(e.target.value)} required minLength={10} /> + + setConfirm(e.target.value)} required minLength={10} /> + +
+ )} +

+ +

+
+
+ ) +} diff --git a/frontend/src/platform/StudentDashboard.jsx b/frontend/src/platform/StudentDashboard.jsx index 2796188..cb09f54 100644 --- a/frontend/src/platform/StudentDashboard.jsx +++ b/frontend/src/platform/StudentDashboard.jsx @@ -6,13 +6,17 @@ import PracticeTab from './PracticeTab' import AssessmentsTab from './AssessmentsTab' import CapstoneTab from './CapstoneTab' import AchievementsTab from './AchievementsTab' +import ClassroomTab from './ClassroomTab' +import ProfileTab from './ProfileTab' const TABS = [ { key: 'learning', label: 'My Learning' }, + { key: 'classroom', label: 'Classroom' }, { key: 'practice', label: 'Practice' }, { key: 'assessments', label: 'Assessments' }, { key: 'capstone', label: 'Capstone' }, { key: 'achievements', label: 'Achievements' }, + { key: 'profile', label: 'Profile' }, ] // Student home — tabbed as of Phase 3B (docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §10): My @@ -41,10 +45,12 @@ export default function StudentDashboard({ student, onLogout }) { {tab === 'learning' && } + {tab === 'classroom' && } {tab === 'practice' && } {tab === 'assessments' && } {tab === 'capstone' && } {tab === 'achievements' && } + {tab === 'profile' && }
) } diff --git a/frontend/src/platform/api.js b/frontend/src/platform/api.js index 87884f0..5f6bfb9 100644 --- a/frontend/src/platform/api.js +++ b/frontend/src/platform/api.js @@ -83,4 +83,40 @@ export const api = { 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 }), }