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,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 (
<div className={styles.entryWrap}>
<div className={styles.entryCard}>
<div className={styles.brand}>
<span className={styles.brandIcon}>⎈</span> RootNode Academy
</div>
<p className={styles.tagline}>{purpose === 'activation' ? 'Set up your account' : 'Choose a new password'}</p>
{previewError && (
<div className={styles.errorBox}>
This link is invalid or has expired. Ask your instructor for a new one.
</div>
)}
{preview && <p style={{ color: 'var(--text-2)', fontSize: '0.9rem' }}>Hi, {preview.display_name}.</p>}
{error && <div className={styles.errorBox}>{error}</div>}
{!previewError && (
<form onSubmit={submit}>
<label className={styles.label}>New password</label>
<input className={styles.input} type="password" autoComplete="new-password" value={password}
onChange={(e) => setPassword(e.target.value)} required minLength={10} />
<label className={styles.label}>Confirm password</label>
<input className={styles.input} type="password" autoComplete="new-password" value={confirm}
onChange={(e) => setConfirm(e.target.value)} required minLength={10} />
<button className={styles.primaryBtn} type="submit" disabled={loading}>
{loading ? 'Saving…' : purpose === 'activation' ? 'Activate my account' : 'Set new password'}
</button>
</form>
)}
<p style={{ marginTop: '1rem', textAlign: 'center' }}>
<button className={styles.linkBtn} onClick={onCancel}>Back to sign in</button>
</p>
</div>
</div>
)
}