feat(student): live student experience — real login, classroom, profile

- LoginScreen (credentialed, now the default entry point) with links to legacy pilot entry
  and forgot-password; SetPasswordScreen (shared activate/reset UI, driven by
  ?activate=/?reset= query params — no router library, matching this app's existing
  state-machine-per-shell convention); ForgotPasswordScreen (anti-enumeration, dev-only
  reset-token surface).
- ClassroomTab: LIVE NOW banner, join, heartbeat only while joined and the tab is mounted,
  guidance-only current-activity link, own Lab status/launch (reuses the My Learning tab's
  exact API), announcements, Need Help form. No chat, no video, no shell access.
- ProfileTab: identity, cohort, progress, achievements, active-session count,
  change-password (gated on has_credentials).
- PlatformShell rewritten as an explicit view state machine; StudentDashboard gained the new
  tabs; api.js/Platform.module.css extended to match.
This commit is contained in:
2026-08-25 01:19:44 -05:00
parent ee8ccb2ab6
commit 5c7e49c399
9 changed files with 524 additions and 8 deletions
@@ -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 (
<div className={styles.entryWrap}>
<div className={styles.entryCard}>
<div className={styles.brand}>
<span className={styles.brandIcon}>⎈</span> RootNode Academy
</div>
<p className={styles.tagline}>Reset your password</p>
{!sent ? (
<form onSubmit={submit}>
<label className={styles.label}>Email or login</label>
<input className={styles.input} type="text" value={identifier} onChange={(e) => setIdentifier(e.target.value)} required />
<button className={styles.primaryBtn} type="submit" disabled={loading}>{loading ? 'Sending…' : 'Send reset link'}</button>
</form>
) : (
<>
<p style={{ color: 'var(--text-2)', fontSize: '0.9rem' }}>{sent.message}</p>
{sent.dev_reset_token && (
<p style={{ fontSize: '0.8rem', color: 'var(--text-3)' }}>
Dev/test only (never shown in production):{' '}
<button className={styles.linkBtn} onClick={() => onHaveToken(sent.dev_reset_token)}>use this reset token</button>
</p>
)}
</>
)}
<p style={{ marginTop: '1rem', textAlign: 'center' }}>
<button className={styles.linkBtn} onClick={onCancel}>Back to sign in</button>
</p>
</div>
</div>
)
}