Help requested — your instructor has been notified.
: (
+
+ )}
+
+ >
+ )}
+
+ {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 ? (
+
+ ) : (
+ <>
+
{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}
}
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
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