Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
896c230142 | ||
|
|
bfa16d9162 | ||
|
|
7557fdc753 | ||
|
|
5c7e49c399 | ||
|
|
ee8ccb2ab6 | ||
|
|
d34ddae458 | ||
|
|
2f09b1a498 |
@@ -150,12 +150,19 @@ its core:
|
||||
the browser (`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`, session model in
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §3, hint gating in
|
||||
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` §3).
|
||||
- The student flow (roster login → curriculum dashboard → Launch Lab → practice/assessments/
|
||||
capstone → achievements) lives in `frontend/src/platform/`; the instructor/admin portal
|
||||
(per-instructor login, cohorts, students, instructor accounts, audit log, classroom
|
||||
"Prepare Labs," environment controls, learning analytics, assessment/capstone management)
|
||||
lives in `frontend/src/admin/` at the `/admin` route — both additions alongside the existing
|
||||
Lab UI, not a rewrite of it.
|
||||
- The student flow (real login (email or instructor-assigned login ID) or legacy pilot roster
|
||||
entry → curriculum dashboard → Launch Lab → practice/assessments/capstone → achievements →
|
||||
live classroom → profile) lives in `frontend/src/platform/`; the instructor/admin portal
|
||||
(per-instructor login, cohorts, students with credential/CSV-import management, a remote
|
||||
classroom control center, instructor accounts, audit log, environment controls, learning
|
||||
analytics, assessment/capstone management) lives in `frontend/src/admin/` at the `/admin`
|
||||
route — both additions alongside the existing Lab UI, not a rewrite of it.
|
||||
- Students authenticate with real credentials as of Phase 5A (email+password or
|
||||
login-identifier+password, first-login/reset via a single-use token an instructor relays) —
|
||||
see `docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`. Instructors run live, remote-capable
|
||||
classes from `/admin` (presence, Lab/learning status, help-request queue, announcements,
|
||||
guidance-only current-activity) — RootNode owns identity/coordination, an external service
|
||||
(Meet/Zoom/Teams/etc.) owns the actual video/voice.
|
||||
|
||||
**Never publish the raw privileged container** — front it with isolation (that's what the
|
||||
Gateway is). The one-container `docker run --privileged -p 7554:80 ...` command above stays
|
||||
@@ -192,7 +199,8 @@ Key properties of the platform (not this image):
|
||||
|
||||
Production topology and what's built vs. not yet: `docs/LAB_ARCHITECTURE.md`,
|
||||
`docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`,
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`, `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`.
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`, `docs/PHASE3B_LEARNING_AND_ASSESSMENT.md`,
|
||||
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`.
|
||||
|
||||
The production infrastructure package (Proxmox/Ansible/Terraform/firewall/monitoring/backup/CI)
|
||||
that runs all of this on real hardware lives in `infra/` — see
|
||||
|
||||
+56
-32
@@ -90,35 +90,34 @@ To add/reorder a stage: edit `rootnode-path.json` directly, referencing existing
|
||||
|
||||
## 5. Student identity — what's built, what isn't
|
||||
|
||||
**Built (backend only, as of this doc):**
|
||||
- `students` table: `student_id` (`student-NNN`, sequential), `display_name`, `cohort`,
|
||||
`created_at`, `last_active_at`.
|
||||
- Login model: **instructor roster, pick-your-name, no password.** `POST /api/roster` adds
|
||||
a student (instructor-only action, currently unauthenticated at the API level — see
|
||||
"not built" below). `GET /api/roster?cohort=X` lists names for a dropdown.
|
||||
`POST /api/login` just validates the id exists and returns it; the frontend is expected to
|
||||
hold onto `student_id` and send it back as an `X-Student-Id` header on every request.
|
||||
- `progress` table now keyed on `(student_id, scenario_id)` instead of `scenario_id` alone —
|
||||
each student's completion/attempts/hints are isolated. Verified by smoke test: two students
|
||||
answering the same scenario don't affect each other's state, and pre-migration data was
|
||||
preserved under `student_id = 'unassigned'`.
|
||||
- Exam `sessions` now carry `student_id` (set at session start), so exam history and the
|
||||
timer-reset-on-submit logic are correctly scoped per student instead of leaking across
|
||||
whoever's browser hits the API next.
|
||||
**Current state (Phase 5A — see `docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` for the
|
||||
full design):**
|
||||
- `students.student_id` (`student-NNN`, sequential) is still the FK every other table hangs off
|
||||
of and never changes across an account's lifecycle, including conversion.
|
||||
- Real credentials: `email` and `login_identifier` (independently optional/unique — email for
|
||||
older/self-paced students, an instructor-assigned login ID for younger ones), `password_hash`
|
||||
reusing `instructors`' exact `scrypt` machinery (`src/auth/password.js`). A student with
|
||||
neither field set is a plain **pilot** account, unchanged since Phase 2A: `POST /api/login
|
||||
{ student_id }`, no password, instructor-roster-only.
|
||||
- First login/reset is one shared, single-use, hashed, time-limited token mechanism
|
||||
(`students-auth.js`) — instructors never see or set a student's actual password.
|
||||
- Sessions are the same Postgres-backed `sessions` table instructors use (opaque, HttpOnly,
|
||||
hashed, revocable, CSRF-protected); login is rate-limited the same Postgres-backed way as
|
||||
instructor login (Phase 4.1), now also applied to students.
|
||||
- Per-student `progress`/`scenario_attempts`/exam `sessions` isolation (below) and real
|
||||
concurrent multi-student Lab isolation (Phase 1's Provisioner/k3s-per-student model) were both
|
||||
already in place well before this — Phase 5A only changed how a student proves who they are,
|
||||
not how their data or Labs are isolated.
|
||||
|
||||
**Not built yet — do not assume these exist:**
|
||||
- **No frontend UI.** No login screen, no name picker, no roster-add form. The API works;
|
||||
nothing calls it yet. This is the next slice.
|
||||
- **No auth on `/api/roster` POST.** Anyone who can reach the API can add a "student."
|
||||
Fine for a dev instance, not fine once this is reachable by actual students — needs an
|
||||
instructor-only gate before that happens (ties into the instructor dashboard, roadmap
|
||||
item 3).
|
||||
- **No concurrency / isolation.** Exam sessions are still global-single-active
|
||||
(`WHERE status='active' LIMIT 1` — see `server.js`), and there is one shared k3s cluster
|
||||
for the whole instance. Per-student identity ≠ per-student environment. **Multiple students
|
||||
cannot yet use this platform at the same time without stepping on each other.** That's
|
||||
roadmap item 4 (isolation) and it gates real classroom/minors use, same as the plan called
|
||||
out — this SOP is not claiming that gate is cleared.
|
||||
**Not built — deliberate non-goals, not gaps (Phase 5A §37):**
|
||||
- No public self-registration — accounts are still instructor-created/converted/imported only.
|
||||
- No email delivery infrastructure — password reset works via an instructor-issued token today;
|
||||
self-service reset returns a real token only in non-production, by design.
|
||||
- No Parent/Guardian portal — the schema doesn't block adding one later, but none exists.
|
||||
|
||||
**Historical note, kept for context:** earlier drafts of this section described a pre-Phase-2A
|
||||
state (`X-Student-Id` header, unauthenticated roster POST, one shared cluster) that no longer
|
||||
reflects any deployed version of this codebase; that content has been superseded by the above.
|
||||
|
||||
## 6. Schema migration pattern
|
||||
|
||||
@@ -146,9 +145,11 @@ Order agreed 2026-08-17/18. Status as of this doc:
|
||||
(§12), student-facing tabs (My Learning/Practice/Assessments/Capstone/Achievements) at
|
||||
`frontend/src/platform/`.
|
||||
3. Student accounts / persistent progress — **done**, Postgres-authoritative as of Phase 2B
|
||||
(§9), sessions persistent as of Phase 3A (§10). Roster name-picking is still classroom/pilot
|
||||
identification, not strong student auth — documented, not silently glossed over
|
||||
(docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §7).
|
||||
(§9), sessions persistent as of Phase 3A (§10). Real student credentials (email or
|
||||
login-identifier + password, first-login/reset token flow) closed the remaining "pick your
|
||||
name" gap as of Phase 5A — see §5 above and
|
||||
docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md. Pilot (no-login) accounts still work
|
||||
unchanged for anyone not yet converted.
|
||||
4. Instructor dashboard — **done** (§9/§10). Full admin portal at `/admin`: dashboard, cohort
|
||||
and student management, classroom Prepare/Destroy Labs, per-instructor accounts with
|
||||
owner/admin/instructor roles, audit log.
|
||||
@@ -332,4 +333,27 @@ Full design: `docs/PHASE4_1_PREPRODUCTION_HARDENING.md`, plus
|
||||
- **Known limitation carried forward:** real remote/self-paced delivery needs actual student
|
||||
authentication (today's `student_id`-only login relies on instructors controlling who gets a
|
||||
valid id) — see `docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md` for the full accounting of what
|
||||
already works vs. what's genuinely Phase 5 scope.
|
||||
already works vs. what's genuinely Phase 5 scope. **Closed in Phase 5A, below.**
|
||||
|
||||
## 14. Student Identity & Remote Classroom (Phase 5A)
|
||||
|
||||
Full design: `docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`, plus
|
||||
`docs/RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md` and `docs/RUNBOOK_REMOTE_CLASSROOM.md`.
|
||||
|
||||
- **What it is:** two deliverables built on the Phase 4.1 milestone. (A) Real student accounts —
|
||||
email-or-login-identifier + password, reusing `instructors`' exact credential machinery, a
|
||||
single first-login/reset token mechanism, deactivation enforced per-request — replacing pilot
|
||||
"pick your name" identification without touching a single existing pilot student's progress.
|
||||
(B) A remote classroom control center — scheduled/live/ended sessions, derived (not stored)
|
||||
presence via heartbeat, a live instructor dashboard combining real Provisioner Lab state and
|
||||
real academic records, a help-request queue, guidance-only announcements/current-activity, and
|
||||
a generic external meeting-URL field. Explicitly **not** video/voice/chat/shell-takeover —
|
||||
RootNode owns identity and coordination, an external service owns the call.
|
||||
- **Tested, not just designed:** `platform/test/e2e-phase5a.js` (21 checks: the two-student RBAC
|
||||
matrix and full password-security checklist, including a real Platform API restart proving
|
||||
rate-limit state survives it) and `infra/scripts/drills/test-phase5a-rehearsal.js` (15 checks:
|
||||
5 pilot students with seeded real history, a live classroom, help requests, **a real
|
||||
mid-rehearsal API restart** proving classroom/session/presence recovery, then converting two
|
||||
students and confirming zero orphaned data) — both against a live `docker-compose.dev.yml`
|
||||
stack, not mocks. The full pre-existing regression suite was re-run given how much shared code
|
||||
this phase touched (`server.js`, `admin-routes.js`, `sessions.js`, `rbac.js`).
|
||||
|
||||
@@ -498,3 +498,12 @@ mechanism for reaching a Lab container (it assumes Gateway and Lab share a Docke
|
||||
breaks once they're genuinely on separate VMs); Phase 4.1
|
||||
(`docs/PHASE4_1_PREPRODUCTION_HARDENING.md`) built and simulation-tested the fix, a Lab Host
|
||||
Router (`platform/lab-router/`).
|
||||
|
||||
## Phase 5A: reused, not modified
|
||||
|
||||
Phase 5A (`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`) added real student accounts and a
|
||||
remote classroom control center on top of everything this document describes, without changing
|
||||
any of it — the Provisioner, the `environments` state machine, the Lab Host Router, disposable
|
||||
Lab lifecycle, and the Platform/Lab-Host VM boundary are all untouched. The classroom dashboard
|
||||
reads Lab status the exact same way every other admin view does (`provisioner.listForStudent`,
|
||||
the same state enum) rather than inventing a second one.
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# Phase 5A — Student Identity & Remote Classroom
|
||||
|
||||
**Status: PHASE 5A COMPLETE. STUDENT IDENTITY COMPLETE. REMOTE CLASSROOM FOUNDATION COMPLETE.**
|
||||
|
||||
Phase 4.1 closed the last pre-production infrastructure gap without real hardware. Phase 5A's
|
||||
job — still without real hardware — was the last real *product* gap flagged along the way: pilot
|
||||
students identify themselves by picking a name from a list, and there was no way for an
|
||||
instructor to run a live, remote-capable class through the platform itself. This phase replaces
|
||||
pilot identification with real accounts and adds a remote classroom control center. It does
|
||||
**not** add video, voice, chat, or any A/V — those stay Meet/Zoom/Teams's job; RootNode owns
|
||||
identity, presence, curriculum guidance, Lab status, help requests, and instructor control.
|
||||
|
||||
## 1. Student identity model
|
||||
|
||||
`students` (migration `017_student_credentials.sql`) gained `email`, `login_identifier`,
|
||||
`password_hash`, `last_login_at`, and a single credential-token triple
|
||||
(`credential_token_hash`/`_purpose`/`_expires_at`) — all nullable, all additive. `email` and
|
||||
`login_identifier` are independently unique (partial unique indexes, `WHERE ... IS NOT NULL`)
|
||||
and both optional: an older/self-paced student gets an email, a younger instructor-managed
|
||||
student gets an instructor-assigned login ID, neither is required, and nothing here blocks a
|
||||
future `guardian_id` FK for a Parent Portal — deliberately not built this phase (§37).
|
||||
|
||||
A student with **neither** field set is unchanged from before Phase 5A: a plain pilot row,
|
||||
identified with `POST /api/login { student_id }`. Setting either field (via account creation or
|
||||
`/convert`) is what makes an account "real." `password_hash` reuses `platform/api/src/auth/
|
||||
password.js` (scrypt, `salt:hash`) verbatim — the same function instructors use, no second
|
||||
hashing scheme.
|
||||
|
||||
## 2. Credential lifecycle — one token mechanism, not two
|
||||
|
||||
Activation ("first login") and password reset are the same problem: a time-limited, single-use
|
||||
secret that lets someone set a new password. `students-auth.js`'s `issueCredentialToken`/
|
||||
`consumeCredentialToken` implement it once (SHA-256-hashed at rest, plaintext returned exactly
|
||||
once in the API response, never logged), with `purpose` (`activation` | `reset`) distinguishing
|
||||
the two callers. Activation tokens live 7 days, reset tokens 1 hour. `consumeCredentialToken` is
|
||||
atomic: token verification, password hashing, `password_hash` write, and token-clearing happen
|
||||
in one statement — there is no window where a token is "spent" but the password isn't set.
|
||||
|
||||
Instructors never see or choose a student's password (§5's "instructor creates account →
|
||||
temporary credential" flow): `POST /api/admin/students`, `/convert`, and `/reset-password` all
|
||||
return a token (activation or reset), which the instructor relays out of band (printed, read
|
||||
aloud, handed over). The admin UI surfaces the resulting `?activate=<token>`/`?reset=<token>`
|
||||
link once and never again — there is no "view credentials" screen anywhere.
|
||||
|
||||
## 3. Password reset without email delivery
|
||||
|
||||
No email-sending infrastructure exists yet, and this phase deliberately doesn't fake one.
|
||||
`POST /api/student/request-password-reset` always returns an identical generic response
|
||||
regardless of whether the identifier exists (anti-enumeration) and never echoes the token in
|
||||
production. In non-production (`isProduction` false, i.e. `COOKIE_SECURE !== '1'`) it also
|
||||
returns `dev_reset_token` so local/CI flows can complete the loop without a real inbox — a
|
||||
documented dev/test path, not a production feature. The realistic production path today is
|
||||
instructor-triggered: `POST /api/admin/students/:id/reset-password`. Real email delivery is a
|
||||
named, not-yet-built follow-up, not something silently promised.
|
||||
|
||||
## 4. Session and login security — reused, not duplicated
|
||||
|
||||
Students and instructors share the exact same `sessions` table and mechanism
|
||||
(`sessions.js`: opaque tokens, HttpOnly, SameSite, hashed at rest, revocable), differentiated
|
||||
only by `session_type`/`student_id` vs `instructor_id`. `studentSession` middleware now
|
||||
re-verifies `status = 'active'` on **every** request (previously only checked session
|
||||
existence) — deactivating a student takes effect immediately, matching `instructorSession`'s
|
||||
existing behavior, not just at next login (verified in `e2e-phase5a.js`).
|
||||
|
||||
Rate limiting reuses Phase 4.1's Postgres-backed `tooManyAttempts` (`security.js`) with a
|
||||
`student:` key prefix — 8 attempts / 5 minutes, survives a Platform API restart because it's a
|
||||
table, not an in-memory Map (verified with a real restart in both `e2e-phase5a.js` and the
|
||||
rehearsal drill). CSRF reuses the existing double-submit cookie pattern (`rn_csrf_s`).
|
||||
|
||||
**A real bug this closed:** the legacy `POST /api/login` (bare `student_id`, no password) only
|
||||
checked `password_hash` to decide whether an account had "moved on" from pilot mode. An account
|
||||
mid-conversion — instructor has issued an activation token, student hasn't set a password yet —
|
||||
has `credential_token_hash` set but `password_hash` still `NULL`, and was reachable through the
|
||||
old insecure path for that whole window. Found via testing, fixed by checking
|
||||
`student.password_hash || student.credential_token_hash` instead (`server.js`).
|
||||
|
||||
## 5. Pilot migration
|
||||
|
||||
No destructive migration. `017_student_credentials.sql` only adds nullable columns — every
|
||||
existing pilot row, its progress, attempts, achievements, and cohort membership are completely
|
||||
untouched at migration time. `POST /api/admin/students/:id/convert` assigns email/login_
|
||||
identifier and issues an activation token without touching `student_id` (the FK every other
|
||||
table hangs off of) or anything downstream of it. Verified directly, not just asserted: the
|
||||
Phase 5A rehearsal drill (`infra/scripts/drills/test-phase5a-rehearsal.js`) seeds real pre-
|
||||
existing progress/attempt/achievement rows for a pilot student, converts them, activates them,
|
||||
logs in the new way, and confirms `/api/progress` shows the exact same completed scenario —
|
||||
nothing orphaned, nothing re-created.
|
||||
|
||||
## 6. Remote classroom — a fourth session concept
|
||||
|
||||
Distinct from cohort membership, assessment sessions, and Lab environments: a scheduled,
|
||||
bounded teaching event. `018_classroom_sessions.sql` adds `classroom_sessions` (lifecycle
|
||||
`scheduled → live → ended`, or `cancelled`), `classroom_participants`, `classroom_events`
|
||||
(append-only audit), `classroom_announcements`, and `help_requests`. A partial unique index
|
||||
(`WHERE status = 'live'`) enforces at most one live classroom per cohort at the database level —
|
||||
the app layer catches the resulting `23505` and returns a clean `ALREADY_LIVE` (409), not a
|
||||
raw constraint error (verified in `e2e-phase5a.js`).
|
||||
|
||||
## 7. Presence — derived, not stored
|
||||
|
||||
`classroom.derivePresence(lastSeenAt, leftAt)` is a pure function computing `online` /
|
||||
`recently_active` / `offline` from a timestamp at read time — there's no status column that can
|
||||
go stale or need a background job to sweep. Heartbeats (`POST /api/classroom/:id/heartbeat`,
|
||||
client fires every 20-30s while the Classroom tab is open and joined) are throttled server-side
|
||||
(`WHERE last_seen_at IS NULL OR last_seen_at < now() - interval`), never depend on Lab terminal
|
||||
activity, and stop the moment the tab is closed or the student leaves — no invasive tracking.
|
||||
|
||||
## 8. The classroom dashboard — real data, not a second state system
|
||||
|
||||
`GET /api/admin/classroom-sessions/:id` is the one place that reaches beyond `classroom.js`: it
|
||||
joins participant/presence data with the Provisioner's actual `environments` state (same enum
|
||||
every other Lab-status view uses — `none`/`requested`/`provisioning`/`running`/`resetting`/
|
||||
`error`/`destroying`/`destroyed`) and a lightweight academic snapshot from the existing
|
||||
`progress`/`scenario_attempts` tables (current stage, most recent scenario, validation-fail
|
||||
count, hints used). Never inspects terminal command history. Nothing here is a second Lab-state
|
||||
or academic-state system — it's the existing ones, read the same way every other admin view
|
||||
already reads them.
|
||||
|
||||
## 9. Help requests — a queue, not a chat
|
||||
|
||||
`POST /api/classroom/:id/help-request { category, message }` → `open` → (instructor)
|
||||
`acknowledge` → `resolve`. That's the entire surface — explicitly no chat, no video, no remote
|
||||
shell takeover (§37). The instructor dashboard's help queue polls the same dashboard endpoint
|
||||
everything else does.
|
||||
|
||||
## 10. Guidance, never control
|
||||
|
||||
`setCurrentActivity` (current scenario) and announcements are exactly that — a link the student
|
||||
can choose to open, a message they can read. Nothing server-side redirects a student's browser
|
||||
or marks work complete on their behalf. The external meeting URL (`classroom_sessions.
|
||||
meeting_url`) is a generic field the student sees as a link — RootNode never proxies or embeds
|
||||
Meet/Zoom/Teams/anything else, so it isn't coupled to a single vendor.
|
||||
|
||||
## 11. CSV roster import — whole-file, transactional
|
||||
|
||||
`student-csv.js` hand-parses (no CSV dependency — the file format here is small and simple
|
||||
enough that pulling in a library would just be one more thing to audit) and validates the
|
||||
**entire** file before any database write. `POST /api/admin/students/import` then does every
|
||||
insert inside one Postgres transaction — one bad row rolls back the whole import rather than
|
||||
leaving a half-imported roster, which is the more dangerous failure mode of the two. Never
|
||||
accepts a `password`/`password_hash`/`credential_token_hash` column — activation tokens are
|
||||
always generated server-side, never taken from input.
|
||||
|
||||
## 12. Observability
|
||||
|
||||
`GET /metrics` is a hand-rolled Prometheus text-exposition endpoint (no `prom-client`
|
||||
dependency — a handful of counters/gauges don't need one) with real in-process counters for
|
||||
login success/failure and on-demand gauges for live-classroom/help-request/Lab-state counts.
|
||||
No student or instructor ID ever appears as a label value — every label is a bounded enum
|
||||
(status, presence, result), per the explicit no-PII/no-high-cardinality-labels instruction. The
|
||||
Prometheus scrape job for the Platform API (`infra/monitoring/prometheus/prometheus.yml.j2`),
|
||||
previously commented out, is now active.
|
||||
|
||||
## 13. Frontend
|
||||
|
||||
**Student side** (`frontend/src/platform/`): `LoginScreen` (credentialed, now the default entry
|
||||
point) with links to the 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`; a `Classroom`
|
||||
tab (live-class banner, join, guidance-only current-activity link, own Lab status/launch reusing
|
||||
the My Learning tab's exact API, announcements, Need Help form); a `Profile` tab (identity,
|
||||
cohort, progress, achievements, active-session count, change-password).
|
||||
|
||||
**Admin side** (`frontend/src/admin/`): `StudentsView` gained credential status in the roster
|
||||
table, a create-student form (email/login-ID/cohort), convert/reset-password/revoke-sessions
|
||||
controls per student, and a bulk CSV import form. A new `ClassroomView` is the control center:
|
||||
cohort picker → schedule/list classes → a live dashboard (participants with presence/Lab-status/
|
||||
current-work, help queue with acknowledge/resolve, current-activity setter, announcement
|
||||
poster, start/end/cancel, Prepare Labs).
|
||||
|
||||
## 14. Testing — real, not asserted
|
||||
|
||||
- `platform/test/e2e-phase5a.js` (21 checks): activation single-use, password security (§32 —
|
||||
correct/wrong/unknown-identifier/deactivated/immediate-deactivation/rate-limit/rate-limit-
|
||||
survives-restart/change-password/session-revocation/forgot-password anti-enumeration/admin
|
||||
reset/CSRF), and the two-student RBAC matrix (§31 — student can't reach `/api/admin/*`,
|
||||
assigned/unassigned/viewer instructor access), plus a classroom lifecycle smoke pass.
|
||||
- `infra/scripts/drills/test-phase5a-rehearsal.js` (15 checks): 5 pilot students, real seeded
|
||||
progress/attempt/achievement history, a live classroom, help requests, announcements, **a real
|
||||
Platform API restart mid-rehearsal** proving classroom/session/presence recovery, then
|
||||
converting two students mid-rehearsal and confirming every bit of their prior history survived
|
||||
under the new login.
|
||||
- Both run against a live `docker-compose.dev.yml` stack over real HTTP, not mocks.
|
||||
- The full pre-existing regression suite (Phase 1/2A/2B/3A/3B/4.1) was re-run unmodified given
|
||||
how much shared code (`server.js`, `admin-routes.js`, `sessions.js`, `rbac.js`) this phase
|
||||
touched — see the Phase 5A completion report for results.
|
||||
|
||||
## 15. What was deliberately not built
|
||||
|
||||
Per §37: video/voice conferencing, student-to-student chat, remote instructor shell takeover,
|
||||
screen recording or proctoring, public registration or course catalog, payments/Stripe, a Parent
|
||||
Portal, school/SIS integrations, real email delivery infrastructure, certificates, an AI tutor,
|
||||
a mobile app, a multi-Lab-host scheduler, and production deployment itself. Each is a plausible
|
||||
"while we're in here" addition and each was left alone on purpose — the schema and API surface
|
||||
built this phase don't block any of them later.
|
||||
@@ -54,5 +54,18 @@ can prove in advance.
|
||||
full" message, not a 500 — confirms the real host's admission control matches the local test
|
||||
- [ ] `docs/RUNBOOK_LAB_HOST_RESOURCE_PRESSURE.md`'s steps still make sense once capacity numbers are real
|
||||
|
||||
## Student identity & remote classroom (Phase 5A — see docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md)
|
||||
- [ ] `COOKIE_SECURE=1` in the real production environment — confirms `/api/student/
|
||||
request-password-reset` never returns `dev_reset_token` outside of dev/CI
|
||||
- [ ] A real activation/reset link (`?activate=`/`?reset=`) round-trips against the production
|
||||
domain, not just `localhost` — query-param parsing, no router library in play
|
||||
- [ ] Student login rate limiting still 429s after a restart against the real production
|
||||
Postgres, same as the local `e2e-phase5a.js` check
|
||||
- [ ] `GET /metrics` is reachable by the real Prometheus instance (`infra/monitoring/
|
||||
prometheus/prometheus.yml.j2`'s `rootnode-platform-api` job) and no student/instructor ID
|
||||
appears in any label value
|
||||
- [ ] `docs/RUNBOOK_STUDENT_ACCOUNT_LIFECYCLE.md` and `docs/RUNBOOK_REMOTE_CLASSROOM.md`'s steps
|
||||
still make sense walked against the real admin portal
|
||||
|
||||
Only once every box above is checked against the real server does the deployment graduate from
|
||||
"infrastructure package built" to "actually in production."
|
||||
|
||||
@@ -51,15 +51,13 @@ this flow specifically — see the Phase 4.1 report for the full run results.
|
||||
product/business decision (course commerce, is it free, who's allowed to enroll), not an
|
||||
infrastructure gap.
|
||||
|
||||
2. **No real student authentication.** A student session is created from a bare `student_id`
|
||||
(`POST /api/login`, no password) — deliberately, since today every `student_id` is issued and
|
||||
controlled by an instructor via the roster, not chosen or known by the public. A genuinely
|
||||
remote/self-paced product where students access their account without an instructor in the
|
||||
loop needs real credentials (email+password, or a magic link) — reusing the same
|
||||
`password_hash`/`scrypt` machinery `instructors` already has (`src/auth/password.js`) would
|
||||
be the natural extension, not a new auth system. **This is the one item on this list that
|
||||
would actually block self-paced delivery today** — everything else already works or is a
|
||||
product decision, not an infrastructure one.
|
||||
2. ~~**No real student authentication.**~~ **Closed in Phase 5A.** Students now have real
|
||||
credentials (email+password or login-identifier+password, `password_hash`/`scrypt` reusing
|
||||
`instructors`' exact `src/auth/password.js`), a first-login/reset token flow, and Postgres-
|
||||
backed rate-limited/CSRF-protected sessions on the same `sessions` table instructors use — see
|
||||
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md`. The old bare-`student_id` `POST
|
||||
/api/login` still works for not-yet-converted pilot rows only; it's rejected (409) the moment
|
||||
an account has real credentials or a pending activation token.
|
||||
|
||||
3. **`curriculum_assignments.student_id` is schema-ready but unwired.** The table has supported
|
||||
a direct per-student curriculum assignment (independent of any cohort) since Phase 2B
|
||||
@@ -80,6 +78,6 @@ this flow specifically — see the Phase 4.1 report for the full run results.
|
||||
|
||||
No signup flow, no student password/email auth implementation, no per-student curriculum
|
||||
assignment route, no payments, no public course catalog, no held/premium scenario content. All
|
||||
explicit non-goals per this phase's authorization. This document exists so Phase 5 planning
|
||||
starts from an accurate picture of the gap (one real item: student authentication) instead of a
|
||||
guess.
|
||||
explicit non-goals per this phase's authorization. Student authentication (item 2, above) was
|
||||
closed in Phase 5A; the rest remain open, unmodeled by design (YAGNI) until a real product
|
||||
reason exists.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Runbook: Running a Remote Classroom
|
||||
|
||||
Covers scheduling, running, and recovering a live class through `/admin` → Classroom. See
|
||||
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` for the underlying design. RootNode does
|
||||
**not** provide video/voice — the meeting URL field just links out to Meet/Zoom/Teams/whatever
|
||||
you already use for that.
|
||||
|
||||
**Before class: schedule it**
|
||||
|
||||
`/admin` → Classroom → pick the cohort → Schedule a class. Title is required; scheduled start,
|
||||
description, and a meeting URL are optional. Only one class can be `live` per cohort at a time —
|
||||
scheduling a second one is fine, starting it while another is live is rejected.
|
||||
|
||||
**Right before class: Prepare Labs**
|
||||
|
||||
Open the classroom → Prepare Labs. This provisions/warms every enrolled student's Lab ahead of
|
||||
time (same underlying flow as the cohort-level Prepare Labs button) so nobody is waiting on a
|
||||
cold start once class begins. If a Lab host is at capacity, that student shows as
|
||||
`capacity_waiting` in the results rather than failing silently — check back or ease off other
|
||||
concurrent load.
|
||||
|
||||
**Start class**
|
||||
|
||||
Open the classroom → Start class. Status moves to `live`; students now see a "LIVE NOW" banner
|
||||
on their Classroom tab and can join.
|
||||
|
||||
**During class**
|
||||
|
||||
- **Participants table**: presence (`online`/`recently_active`/`offline`, derived from each
|
||||
student's last heartbeat — not a hard real-time signal, allow ~30s of lag), Lab status, and a
|
||||
one-line current-work snapshot (stage, scenario, validation failures, hints used).
|
||||
- **Set current activity**: enter a scenario ID and Set. This only shows students a link — it
|
||||
never redirects their browser or marks anything complete. Use it to say "everyone open this
|
||||
next," not to force navigation.
|
||||
- **Post an announcement**: short messages, shown newest-first on the student side.
|
||||
- **Help queue**: students flag `lab_problem` / `dont_understand` / `validation_failing` /
|
||||
`other` with an optional note. Acknowledge when you've seen it, Resolve when it's handled.
|
||||
There is no chat here — if it needs back-and-forth, use your actual video call.
|
||||
|
||||
**End of class**
|
||||
|
||||
Open the classroom → End class. Status moves to `ended`; this does not touch Labs or student
|
||||
progress — destroy Labs separately from the cohort view if you're done with them for the day.
|
||||
|
||||
**If you need to cancel a scheduled (not yet started) class:** Cancel, from the classroom
|
||||
detail view. A `live` class can only be ended, not cancelled.
|
||||
|
||||
**If the Platform API restarts mid-class** (deploy, crash, host reboot): nothing to do. Sessions,
|
||||
the live classroom row, and participant presence all live in Postgres, not in the API process's
|
||||
memory — students' browsers keep their existing session and heartbeat right through it, and the
|
||||
dashboard shows the same state a few seconds after the API comes back. This is exercised for
|
||||
real in `infra/scripts/drills/test-phase5a-rehearsal.js`, which restarts the API mid-rehearsal
|
||||
and asserts on exactly this.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Runbook: Student Account Lifecycle
|
||||
|
||||
Covers creating, converting, resetting, and revoking student accounts. See
|
||||
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` for the underlying design.
|
||||
|
||||
**Create a new credentialed student** (`/admin` → Students → Add student):
|
||||
|
||||
Fill in display name plus **either** email (older/self-paced) or login ID (younger/instructor-
|
||||
managed) — leave both blank for a plain pilot account instead. Submitting shows an activation
|
||||
link **once**: `https://<domain>/?activate=<token>`. Copy it immediately (print it, read it
|
||||
aloud, hand it over) — it is never shown again and never stored in plaintext anywhere. The
|
||||
student opens the link and sets their own password; the instructor never sees it.
|
||||
|
||||
**Convert an existing pilot student to a real account** (Students → \[student\] → Convert):
|
||||
|
||||
Same as creation, but for a student who already exists with progress/attempts/achievements —
|
||||
none of that is touched. Give an email or login ID, get a one-time activation link the same way.
|
||||
|
||||
**Bulk import via CSV** (Students → Bulk import):
|
||||
|
||||
Columns: `display_name, email, login_identifier, cohort_id` (only `display_name` is required per
|
||||
row). The whole file is validated before anything is written — one bad row rejects the entire
|
||||
file with a list of row errors, rather than importing some rows and skipping others. Never
|
||||
include a password column; it will always be rejected if present. Activation links for every
|
||||
created row are shown once after a successful import.
|
||||
|
||||
**Reset a student's password** (Students → \[student\] → Reset password):
|
||||
|
||||
Only for already-credentialed accounts. Issues a fresh reset link (`?reset=<token>`, valid 1
|
||||
hour) and immediately revokes every existing session for that student — the instructor still
|
||||
never sees or sets the actual password.
|
||||
|
||||
**Revoke a student's sessions** (Students → \[student\] → Revoke all sessions):
|
||||
|
||||
Immediately invalidates every active login for that student without touching their password.
|
||||
Use this if a device is lost/shared or a session looks compromised.
|
||||
|
||||
**Deactivate a student** (Students → \[student\] → PATCH status: inactive, via the existing
|
||||
students list — deactivation takes effect on their very next request, not just their next
|
||||
login, because `studentSession` middleware re-checks `status='active'` every time.
|
||||
|
||||
**If a student says their activation/reset link "doesn't work":** tokens are single-use and
|
||||
time-limited (activation 7 days, reset 1 hour). Just issue a new one via Convert or Reset
|
||||
password — there's no way to "resend" the same token, and that's intentional.
|
||||
@@ -8,6 +8,7 @@ import AuditView from './AuditView'
|
||||
import AccountView from './AccountView'
|
||||
import LearningView from './LearningView'
|
||||
import AssessmentsView from './AssessmentsView'
|
||||
import ClassroomView from './ClassroomView'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
|
||||
@@ -20,6 +21,7 @@ const NAV = [
|
||||
{ key: 'dashboard', label: 'Dashboard' },
|
||||
{ key: 'cohorts', label: 'Cohorts' },
|
||||
{ key: 'students', label: 'Students' },
|
||||
{ key: 'classroom', label: 'Classroom' },
|
||||
{ key: 'learning', label: 'Learning' },
|
||||
{ key: 'assessments', label: 'Assessments' },
|
||||
{ key: 'instructors', label: 'Instructors', roles: ['owner'] },
|
||||
@@ -59,6 +61,7 @@ export default function AdminPortal() {
|
||||
{view === 'dashboard' && <AdminDashboard />}
|
||||
{view === 'cohorts' && <CohortsView role={instructor.role} />}
|
||||
{view === 'students' && <StudentsView role={instructor.role} />}
|
||||
{view === 'classroom' && <ClassroomView />}
|
||||
{view === 'learning' && <LearningView />}
|
||||
{view === 'assessments' && <AssessmentsView role={instructor.role} />}
|
||||
{view === 'instructors' && instructor.role === 'owner' && <InstructorsView currentInstructorId={instructor.instructor_id} />}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
const DASHBOARD_POLL_MS = 5000
|
||||
|
||||
// Phase 5A §14/§21 — remote classroom control center. Cohort picker -> that cohort's
|
||||
// classroom sessions -> a live dashboard once one is open. No video/chat/shell-takeover here
|
||||
// by design (§37) — this is identity/presence/Lab-status/help-queue/announcements only.
|
||||
export default function ClassroomView() {
|
||||
const [cohorts, setCohorts] = useState(null)
|
||||
const [cohortId, setCohortId] = useState('')
|
||||
const [classrooms, setClassrooms] = useState(null)
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => { adminApi.cohorts().then((rows) => { setCohorts(rows); if (rows.length) setCohortId(rows[0].cohort_id) }).catch((e) => setError(e.message)) }, [])
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (!cohortId) return
|
||||
adminApi.classrooms(cohortId).then(setClassrooms).catch((e) => setError(e.message))
|
||||
}, [cohortId])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
if (selected) return <ClassroomDashboard classroomId={selected} onBack={() => { setSelected(null); reload() }} />
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.card}>
|
||||
<h2>Classroom</h2>
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Cohort</label>
|
||||
<select className={styles.select} value={cohortId} onChange={(e) => setCohortId(e.target.value)}>
|
||||
{cohorts?.map((c) => <option key={c.cohort_id} value={c.cohort_id}>{c.name}</option>)}
|
||||
</select></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cohortId && (
|
||||
<div className={styles.card}>
|
||||
<h3>Schedule a class</h3>
|
||||
<CreateClassroomForm cohortId={cohortId} onCreated={reload} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Classes</h3>
|
||||
{!classrooms ? <p className={styles.muted}>Loading…</p> : classrooms.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Title</th><th>Status</th><th>Scheduled</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{classrooms.map((c) => (
|
||||
<tr key={c.classroom_session_id}>
|
||||
<td>{c.title}</td>
|
||||
<td><span className={`${styles.pill} ${c.status === 'live' ? styles.pillOk : c.status === 'cancelled' ? styles.pillErr : styles.pillMuted}`}>{c.status}</span></td>
|
||||
<td>{c.scheduled_start ? new Date(c.scheduled_start).toLocaleString() : '—'}</td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setSelected(c.classroom_session_id)}>Open</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateClassroomForm({ cohortId, onCreated }) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [scheduledStart, setScheduledStart] = useState('')
|
||||
const [meetingUrl, setMeetingUrl] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await adminApi.createClassroom(cohortId, {
|
||||
title: title.trim(), description: description.trim() || undefined,
|
||||
scheduled_start: scheduledStart || undefined, meeting_url: meetingUrl.trim() || undefined,
|
||||
})
|
||||
setTitle(''); setDescription(''); setScheduledStart(''); setMeetingUrl('')
|
||||
onCreated()
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Title</label>
|
||||
<input className={styles.input} value={title} onChange={(e) => setTitle(e.target.value)} required /></div>
|
||||
<div><label className={styles.label}>Scheduled start</label>
|
||||
<input className={styles.input} type="datetime-local" value={scheduledStart} onChange={(e) => setScheduledStart(e.target.value)} /></div>
|
||||
<div><label className={styles.label}>Meeting URL (Meet/Zoom/Teams — optional)</label>
|
||||
<input className={styles.input} value={meetingUrl} onChange={(e) => setMeetingUrl(e.target.value)} /></div>
|
||||
</div>
|
||||
<label className={styles.label}>Description (optional)</label>
|
||||
<input className={styles.input} value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
<button className={styles.btn} style={{ marginTop: '0.75rem' }} disabled={busy || !title.trim()}>{busy ? 'Scheduling…' : 'Schedule'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function ClassroomDashboard({ classroomId, onBack }) {
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
const [activityScenario, setActivityScenario] = useState('')
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.classroomDashboard(classroomId).then(setData).catch((e) => setError(e.message))
|
||||
}, [classroomId])
|
||||
useEffect(() => {
|
||||
reload()
|
||||
const t = setInterval(reload, DASHBOARD_POLL_MS) // presence/help-queue/Lab-status all move live — poll rather than a second push channel
|
||||
return () => clearInterval(t)
|
||||
}, [reload])
|
||||
|
||||
const withBusy = (fn) => async () => {
|
||||
setBusy(true); setError(null); setNotice(null)
|
||||
try { await fn(); reload() } catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (!data) return <>{error ? <div className={styles.errorBox}>{error}</div> : <p className={styles.muted}>Loading…</p>}</>
|
||||
const { classroom, students, announcements, help_requests: helpRequests } = data
|
||||
const openHelp = helpRequests.filter((h) => h.status !== 'resolved')
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={styles.linkBtn} onClick={onBack}>← Back to classes</button>
|
||||
<div className={styles.card} style={{ marginTop: '1rem' }}>
|
||||
<h2>{classroom.title} <span className={`${styles.pill} ${classroom.status === 'live' ? styles.pillOk : styles.pillMuted}`}>{classroom.status}</span></h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{notice && <p className={styles.muted}>{notice}</p>}
|
||||
<div className={styles.formRow}>
|
||||
{classroom.status === 'scheduled' && <button className={styles.btn} disabled={busy} onClick={withBusy(() => adminApi.startClassroom(classroomId))}>Start class</button>}
|
||||
{classroom.status === 'live' && <button className={styles.btnSecondary} disabled={busy} onClick={withBusy(() => adminApi.endClassroom(classroomId))}>End class</button>}
|
||||
{classroom.status === 'scheduled' && <button className={styles.btnDanger} disabled={busy} onClick={withBusy(() => adminApi.cancelClassroom(classroomId))}>Cancel</button>}
|
||||
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(async () => {
|
||||
const { results } = await adminApi.prepareClassroomLabs(classroomId)
|
||||
setNotice(`Labs prepared: ${results.length} student(s).`)
|
||||
})}>Prepare Labs</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Participants ({students.length})</h3>
|
||||
{students.length === 0 ? <p className={styles.muted}>No one enrolled in this cohort yet.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Student</th><th>Presence</th><th>Lab</th><th>Current work</th><th>Hints</th></tr></thead>
|
||||
<tbody>
|
||||
{students.map((s) => (
|
||||
<tr key={s.student_id}>
|
||||
<td>{s.display_name}</td>
|
||||
<td><span className={`${styles.pill} ${s.presence === 'online' ? styles.pillOk : s.presence === 'recently_active' ? styles.pillWarn : styles.pillMuted}`}>{s.presence}</span></td>
|
||||
<td><span className={`${styles.pill} ${s.lab_status === 'running' ? styles.pillOk : s.lab_status === 'error' ? styles.pillErr : styles.pillMuted}`}>{s.lab_status}</span></td>
|
||||
<td className={styles.muted}>{s.current_scenario || '—'} {s.attempt_status ? `(${s.attempt_status}, ${s.validation_failures} fails)` : ''}</td>
|
||||
<td className={styles.muted}>{s.hints_used}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Help queue {openHelp.length > 0 && <span className={`${styles.pill} ${styles.pillWarn}`}>{openHelp.length} open</span>}</h3>
|
||||
{helpRequests.length === 0 ? <p className={styles.muted}>No help requests.</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Student</th><th>Category</th><th>Message</th><th>Status</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{helpRequests.map((h) => (
|
||||
<tr key={h.help_request_id}>
|
||||
<td>{h.display_name || h.student_id}</td>
|
||||
<td className={styles.muted}>{h.category}</td>
|
||||
<td className={styles.muted}>{h.message || '—'}</td>
|
||||
<td><span className={`${styles.pill} ${h.status === 'resolved' ? styles.pillOk : h.status === 'acknowledged' ? styles.pillWarn : styles.pillErr}`}>{h.status}</span></td>
|
||||
<td>
|
||||
{h.status === 'open' && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.acknowledgeHelpRequest(h.help_request_id))}>Acknowledge</button>}
|
||||
{h.status !== 'resolved' && <button className={styles.linkBtn} onClick={withBusy(() => adminApi.resolveHelpRequest(h.help_request_id))}>Resolve</button>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Current activity</h3>
|
||||
<p className={styles.muted}>Guidance only — students see this as a link, nothing auto-redirects them (§20).</p>
|
||||
<div className={styles.formRow}>
|
||||
<div><input className={styles.input} placeholder="scenario id" value={activityScenario} onChange={(e) => setActivityScenario(e.target.value)} /></div>
|
||||
<div><button className={styles.btn} disabled={busy || !activityScenario.trim()} onClick={withBusy(async () => { await adminApi.setClassroomActivity(classroomId, activityScenario.trim()); setActivityScenario('') })}>Set</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Announcements</h3>
|
||||
{announcements.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
<ul>{announcements.map((a) => <li key={a.announcement_id} className={styles.muted}>{a.message}</li>)}</ul>
|
||||
)}
|
||||
<div className={styles.formRow} style={{ marginTop: '0.5rem' }}>
|
||||
<div><input className={styles.input} placeholder="Post an announcement" value={announcement} onChange={(e) => setAnnouncement(e.target.value)} /></div>
|
||||
<div><button className={styles.btn} disabled={busy || !announcement.trim()} onClick={withBusy(async () => { await adminApi.postClassroomAnnouncement(classroomId, announcement.trim()); setAnnouncement('') })}>Post</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -101,7 +101,7 @@ function CohortDetail({ cohortId, canManage, onBack }) {
|
||||
if (!newStudentName.trim()) return
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await adminApi.createStudent(newStudentName.trim(), cohortId)
|
||||
await adminApi.createStudent({ display_name: newStudentName.trim(), cohort_id: cohortId })
|
||||
setNewStudentName('')
|
||||
reload()
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
|
||||
@@ -3,30 +3,53 @@ import { adminApi } from './api'
|
||||
import { StatusPill } from './CohortsView'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
export default function StudentsView() {
|
||||
// Phase 5A §4-7: student roster now shows credential status alongside the pilot fields that
|
||||
// were already here. canManage gates create/convert/reset/revoke/import the same way
|
||||
// CohortsView gates cohort management — the server (requireAdminOrOwner) is the real boundary,
|
||||
// this just avoids showing controls a plain instructor would get a 403 from.
|
||||
export default function StudentsView({ role }) {
|
||||
const [students, setStudents] = useState(null)
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const canManage = role !== 'instructor'
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.students().then(setStudents).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
if (selected) return <StudentDetail studentId={selected} onBack={() => { setSelected(null); reload() }} />
|
||||
if (selected) return <StudentDetail studentId={selected} canManage={canManage} onBack={() => { setSelected(null); reload() }} />
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{canManage && (
|
||||
<div className={styles.card}>
|
||||
<h2>Add student</h2>
|
||||
<CreateStudentForm onCreated={reload} />
|
||||
</div>
|
||||
)}
|
||||
{canManage && (
|
||||
<div className={styles.card}>
|
||||
<h2>Bulk import (CSV)</h2>
|
||||
<ImportForm onImported={reload} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.card}>
|
||||
<h2>Students ({students?.length ?? '…'})</h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{students && (
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>ID</th><th>Status</th><th>Last active</th><th /></tr></thead>
|
||||
<thead><tr><th>Name</th><th>ID</th><th>Login</th><th>Status</th><th>Last active</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{students.map((s) => (
|
||||
<tr key={s.student_id}>
|
||||
<td>{s.display_name}</td>
|
||||
<td className={styles.muted}>{s.student_id}</td>
|
||||
<td>
|
||||
{s.has_credentials
|
||||
? <span className={`${styles.pill} ${styles.pillOk}`}>{s.email || s.login_identifier}</span>
|
||||
: <span className={`${styles.pill} ${styles.pillMuted}`}>pilot (no login)</span>}
|
||||
</td>
|
||||
<td><span className={`${styles.pill} ${s.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{s.status}</span></td>
|
||||
<td>{s.last_active_at ? new Date(s.last_active_at).toLocaleString() : 'never'}</td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setSelected(s.student_id)}>Open</button></td>
|
||||
@@ -36,24 +59,127 @@ export default function StudentsView() {
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function StudentDetail({ studentId, onBack }) {
|
||||
function CreateStudentForm({ onCreated }) {
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [loginIdentifier, setLoginIdentifier] = useState('')
|
||||
const [cohortId, setCohortId] = useState('')
|
||||
const [cohorts, setCohorts] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [created, setCreated] = useState(null) // { student_id, activation_token } — token shown exactly once
|
||||
|
||||
useEffect(() => { adminApi.cohorts().then(setCohorts).catch(() => {}) }, [])
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null); setCreated(null)
|
||||
try {
|
||||
const result = await adminApi.createStudent({
|
||||
display_name: displayName.trim(), cohort_id: cohortId || undefined,
|
||||
email: email.trim() || undefined, login_identifier: loginIdentifier.trim() || undefined,
|
||||
})
|
||||
setCreated(result)
|
||||
setDisplayName(''); setEmail(''); setLoginIdentifier('')
|
||||
onCreated()
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{created?.activation_token && (
|
||||
<div className={styles.errorBox} style={{ background: 'var(--green-dim)', color: 'var(--green)', borderColor: 'var(--green)' }}>
|
||||
Account created ({created.student_id}). Activation link (share it out of band, shown once):<br />
|
||||
<code>{`${window.location.origin}/?activate=${created.activation_token}`}</code>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Display name</label>
|
||||
<input className={styles.input} value={displayName} onChange={(e) => setDisplayName(e.target.value)} required /></div>
|
||||
<div><label className={styles.label}>Email (older/self-paced)</label>
|
||||
<input className={styles.input} type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
|
||||
<div><label className={styles.label}>Login ID (younger/managed)</label>
|
||||
<input className={styles.input} value={loginIdentifier} onChange={(e) => setLoginIdentifier(e.target.value)} /></div>
|
||||
<div><label className={styles.label}>Cohort (optional)</label>
|
||||
<select className={styles.select} value={cohortId} onChange={(e) => setCohortId(e.target.value)}>
|
||||
<option value="">— none —</option>
|
||||
{cohorts?.map((c) => <option key={c.cohort_id} value={c.cohort_id}>{c.name}</option>)}
|
||||
</select></div>
|
||||
</div>
|
||||
<p className={styles.muted}>Leave email and login ID both blank to add a pilot (no-login) student.</p>
|
||||
<button className={styles.btn} disabled={busy || !displayName.trim()}>{busy ? 'Adding…' : 'Add student'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function ImportForm({ onImported }) {
|
||||
const [csv, setCsv] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [rowErrors, setRowErrors] = useState(null)
|
||||
const [created, setCreated] = useState(null)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null); setRowErrors(null); setCreated(null)
|
||||
try {
|
||||
const { created: rows } = await adminApi.importStudents(csv)
|
||||
setCreated(rows); setCsv('')
|
||||
onImported()
|
||||
} catch (e) {
|
||||
if (e.row_errors) setRowErrors(e.row_errors) // not thrown this way by the json() helper today; message covers it below
|
||||
setError(e.message)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
<p className={styles.muted}>Columns: <code>display_name, email, login_identifier, cohort_id</code> (email/login_identifier/cohort_id optional per row). Never include a password column — the whole file is validated before anything is imported.</p>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{rowErrors && (
|
||||
<div className={styles.errorBox}>{rowErrors.map((re, i) => <div key={i}>{re}</div>)}</div>
|
||||
)}
|
||||
{created && (
|
||||
<div className={styles.errorBox} style={{ background: 'var(--green-dim)', color: 'var(--green)', borderColor: 'var(--green)' }}>
|
||||
Imported {created.length} student(s). Activation links (shown once — copy before navigating away):
|
||||
{created.filter((c) => c.activation_token).map((c) => (
|
||||
<div key={c.student_id}><code>{c.display_name}: {window.location.origin}/?activate={c.activation_token}</code></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<textarea className={styles.input} style={{ width: '100%', fontFamily: 'var(--mono)', minHeight: '6rem' }}
|
||||
placeholder={'display_name,email,login_identifier,cohort_id\nJane Doe,[email protected],,'}
|
||||
value={csv} onChange={(e) => setCsv(e.target.value)} required />
|
||||
<button className={styles.btn} style={{ marginTop: '0.5rem' }} disabled={busy || !csv.trim()}>{busy ? 'Importing…' : 'Import'}</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function StudentDetail({ studentId, canManage, onBack }) {
|
||||
const [student, setStudent] = useState(null)
|
||||
const [progress, setProgress] = useState(null)
|
||||
const [attempts, setAttempts] = useState(null)
|
||||
const [env, setEnv] = useState(null)
|
||||
const [events, setEvents] = useState(null)
|
||||
const [sessions, setSessions] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.students().then((rows) => setStudent(rows.find((s) => s.student_id === studentId) || null)).catch(() => {})
|
||||
adminApi.studentProgress(studentId).then(setProgress).catch(() => {})
|
||||
// Phase 4.1 §16 — studentAttempts is now paginated; this panel only ever shows the most
|
||||
// recent page (labeled "Recent attempts," not a full history browser — see
|
||||
// /api/admin/students/:id/attempts's own pagination for the underlying capability).
|
||||
adminApi.studentAttempts(studentId).then((r) => setAttempts(r.items)).catch(() => {})
|
||||
adminApi.studentEnvironment(studentId).then((rows) => setEnv(rows?.find((e) => e.status !== 'destroyed') || null)).catch(() => {})
|
||||
adminApi.studentSessions(studentId).then(setSessions).catch(() => {})
|
||||
}, [studentId])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
@@ -72,6 +198,27 @@ function StudentDetail({ studentId, onBack }) {
|
||||
<button className={styles.linkBtn} onClick={onBack}>← Back to students</button>
|
||||
<h2 style={{ margin: '1rem 0' }}>{studentId}</h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{notice && <p className={styles.muted}>{notice}</p>}
|
||||
|
||||
{canManage && (
|
||||
<div className={styles.card}>
|
||||
<h3>Credentials</h3>
|
||||
{student?.has_credentials ? (
|
||||
<>
|
||||
<p className={styles.muted}>Logs in with {student.email || student.login_identifier}. {sessions ? `${sessions.length} active session(s).` : ''}</p>
|
||||
<div className={styles.formRow}>
|
||||
<button className={styles.btnSecondary} disabled={busy} onClick={withBusy(async () => {
|
||||
const { reset_token } = await adminApi.resetStudentPassword(studentId)
|
||||
setNotice(`Password reset. Link (shown once): ${window.location.origin}/?reset=${reset_token}`)
|
||||
})}>Reset password</button>
|
||||
<button className={styles.btnDanger} disabled={busy || !sessions?.length} onClick={withBusy(() => adminApi.revokeStudentSessions(studentId))}>Revoke all sessions</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ConvertForm studentId={studentId} onDone={(msg) => { setNotice(msg); reload() }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Environment</h3>
|
||||
@@ -134,3 +281,35 @@ function StudentDetail({ studentId, onBack }) {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ConvertForm({ studentId, onDone }) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [loginIdentifier, setLoginIdentifier] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
const { activation_token } = await adminApi.convertStudent(studentId, {
|
||||
email: email.trim() || undefined, login_identifier: loginIdentifier.trim() || undefined,
|
||||
})
|
||||
onDone(`Converted. Activation link (shown once): ${window.location.origin}/?activate=${activation_token}`)
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
<p className={styles.muted}>Still a pilot account (no login). Give it an email or login ID to convert it to a real, password-protected account.</p>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Email</label>
|
||||
<input className={styles.input} type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
|
||||
<div><label className={styles.label}>Login ID</label>
|
||||
<input className={styles.input} value={loginIdentifier} onChange={(e) => setLoginIdentifier(e.target.value)} /></div>
|
||||
<div><button className={styles.btn} disabled={busy || (!email.trim() && !loginIdentifier.trim())}>{busy ? 'Converting…' : 'Convert'}</button></div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ function getCookie(name) {
|
||||
async function json(res) {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.message || body.error || `Request failed (${res.status})`)
|
||||
const err = new Error(body.message || body.error || `Request failed (${res.status})`)
|
||||
if (body.row_errors) err.row_errors = body.row_errors // Phase 5A §29 — CSV import row-level errors
|
||||
throw err
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
@@ -49,8 +51,14 @@ export const adminApi = {
|
||||
dashboard: () => get('/api/admin/dashboard'),
|
||||
|
||||
students: () => get('/api/admin/students'),
|
||||
createStudent: (display_name, cohort_id) => post('/api/admin/students', { display_name, cohort_id }),
|
||||
createStudent: (fields) => post('/api/admin/students', fields),
|
||||
updateStudent: (id, fields) => patch(`/api/admin/students/${id}`, fields),
|
||||
// ── Phase 5A §4-7/§29: credential lifecycle + CSV import ────────────────────────────────
|
||||
convertStudent: (id, fields) => post(`/api/admin/students/${id}/convert`, fields),
|
||||
resetStudentPassword: (id) => post(`/api/admin/students/${id}/reset-password`),
|
||||
revokeStudentSessions: (id) => post(`/api/admin/students/${id}/revoke-sessions`),
|
||||
studentSessions: (id) => get(`/api/admin/students/${id}/sessions`),
|
||||
importStudents: (csv) => post('/api/admin/students/import', { csv }),
|
||||
studentProgress: (id) => get(`/api/admin/students/${id}/progress`),
|
||||
studentAttempts: (id, page = 1) => get(`/api/admin/students/${id}/attempts?page=${page}`), // Phase 4.1 §16 — paginated, see pagination.js
|
||||
studentEnvironment: (id) => get(`/api/admin/students/${id}/environment`),
|
||||
@@ -95,4 +103,17 @@ export const adminApi = {
|
||||
assignAssessment: (id, cohort_id) => post(`/api/admin/assessments/${id}/assign`, { cohort_id }),
|
||||
assessmentSessions: (id, page = 1) => get(`/api/admin/assessments/${id}/sessions?page=${page}`), // Phase 4.1 §16 — paginated, see pagination.js
|
||||
cancelAssessmentSession: (sessionId) => post(`/api/admin/assessment-sessions/${sessionId}/cancel`),
|
||||
|
||||
// ── Phase 5A §14/§21: remote classroom control center ───────────────────────────────────
|
||||
classrooms: (cohortId) => get(`/api/admin/cohorts/${cohortId}/classroom-sessions`),
|
||||
createClassroom: (cohortId, fields) => post(`/api/admin/cohorts/${cohortId}/classroom-sessions`, fields),
|
||||
classroomDashboard: (id) => get(`/api/admin/classroom-sessions/${id}`),
|
||||
startClassroom: (id) => post(`/api/admin/classroom-sessions/${id}/start`),
|
||||
endClassroom: (id) => post(`/api/admin/classroom-sessions/${id}/end`),
|
||||
cancelClassroom: (id) => post(`/api/admin/classroom-sessions/${id}/cancel`),
|
||||
prepareClassroomLabs: (id) => post(`/api/admin/classroom-sessions/${id}/prepare-labs`),
|
||||
setClassroomActivity: (id, scenario_id) => post(`/api/admin/classroom-sessions/${id}/activity`, { scenario_id }),
|
||||
postClassroomAnnouncement: (id, message) => post(`/api/admin/classroom-sessions/${id}/announcements`, { message }),
|
||||
acknowledgeHelpRequest: (id) => post(`/api/admin/help-requests/${id}/acknowledge`),
|
||||
resolveHelpRequest: (id, note) => post(`/api/admin/help-requests/${id}/resolve`, { note }),
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.card}>
|
||||
<h2>Classroom</h2>
|
||||
<p className={styles.muted}>No live class right now. Check back at your scheduled class time.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.card}>
|
||||
<p className={styles.livePill}>LIVE NOW</p>
|
||||
<h2 style={{ margin: '0.4rem 0' }}>{live.title}</h2>
|
||||
{live.description && <p className={styles.muted}>{live.description}</p>}
|
||||
{live.meeting_url && (
|
||||
<p><a className={styles.primaryBtn} href={live.meeting_url} target="_blank" rel="noreferrer">Join Video Class</a></p>
|
||||
)}
|
||||
{!joined ? (
|
||||
<button className={styles.primaryBtn} onClick={join}>Join Classroom</button>
|
||||
) : (
|
||||
<p className={styles.muted}>You're in this classroom.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{joined && detail && (
|
||||
<>
|
||||
{detail.classroom.current_scenario_id && (
|
||||
<div className={styles.card}>
|
||||
<h3>Current class activity</h3>
|
||||
<p>{detail.classroom.current_scenario_id}</p>
|
||||
<p className={styles.muted}>Open it from the My Learning tab when you're ready — nothing here jumps you there automatically.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Your Lab</h3>
|
||||
{envStatus?.status === 'running'
|
||||
? <p><a className={styles.primaryBtn} href={envStatus.launch_path}>Open Lab</a></p>
|
||||
: <button className={styles.primaryBtn} onClick={launchLab} disabled={envStatus?.status === 'provisioning'}>
|
||||
{envStatus?.status === 'provisioning' ? 'Starting…' : 'Launch Lab'}
|
||||
</button>}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Announcements</h3>
|
||||
{!detail.announcements.length ? <p className={styles.muted}>None yet.</p> : (
|
||||
<ul>
|
||||
{detail.announcements.map((a) => <li key={a.announcement_id}>{a.message}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Need help?</h3>
|
||||
{helpSent ? <p className={styles.muted}>Help requested — your instructor has been notified.</p> : (
|
||||
<form onSubmit={sendHelp}>
|
||||
<label className={styles.label}>What's going on?</label>
|
||||
<select className={styles.select} value={helpCategory} onChange={(e) => setHelpCategory(e.target.value)}>
|
||||
{HELP_CATEGORIES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||
</select>
|
||||
<label className={styles.label}>Details (optional)</label>
|
||||
<input className={styles.input} value={helpMessage} onChange={(e) => setHelpMessage(e.target.value)} />
|
||||
<button className={styles.primaryBtn} type="submit">Need Help</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={styles.entryWrap}>
|
||||
<div className={styles.entryCard}>
|
||||
<div className={styles.brand}>
|
||||
<span className={styles.brandIcon}>⎈</span> RootNode Academy
|
||||
</div>
|
||||
<p className={styles.tagline}>Sign in to continue your learning.</p>
|
||||
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
|
||||
<form onSubmit={handleLogin}>
|
||||
<label className={styles.label}>Email or login</label>
|
||||
<input className={styles.input} type="text" autoComplete="username" value={identifier}
|
||||
onChange={(e) => setIdentifier(e.target.value)} required />
|
||||
|
||||
<label className={styles.label}>Password</label>
|
||||
<input className={styles.input} type="password" autoComplete="current-password" value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} required />
|
||||
|
||||
<button className={styles.primaryBtn} type="submit" disabled={loading}>
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p style={{ marginTop: '1rem', textAlign: 'center' }}>
|
||||
<button className={styles.linkBtn} onClick={onShowForgotPassword}>Forgot password?</button>
|
||||
</p>
|
||||
<p style={{ marginTop: '0.5rem', textAlign: 'center' }}>
|
||||
<button className={styles.linkBtn} onClick={onShowPilotEntry}>Pick your name instead (pilot cohorts)</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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=<token> or ?reset=<token>) 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 <EntryScreen onLogin={setStudent} />
|
||||
return <StudentDashboard student={student} onLogout={() => setStudent(null)} />
|
||||
|
||||
if (view === 'activate') {
|
||||
return <SetPasswordScreen token={params.get('activate')} purpose="activation"
|
||||
onDone={(s) => { setStudent(s); setView('dashboard'); window.history.replaceState({}, '', '/') }}
|
||||
onCancel={() => { setView('login'); window.history.replaceState({}, '', '/') }} />
|
||||
}
|
||||
if (view === 'reset') {
|
||||
return <SetPasswordScreen token={resetToken} purpose="reset"
|
||||
onDone={() => { setView('login'); window.history.replaceState({}, '', '/') }}
|
||||
onCancel={() => { setView('login'); window.history.replaceState({}, '', '/') }} />
|
||||
}
|
||||
if (view === 'forgot') {
|
||||
return <ForgotPasswordScreen onCancel={() => setView('login')} onHaveToken={(t) => { setResetToken(t); setView('reset') }} />
|
||||
}
|
||||
if (view === 'pilot') {
|
||||
return <EntryScreen onLogin={(s) => { setStudent(s); setView('dashboard') }} />
|
||||
}
|
||||
if (!student) {
|
||||
return <LoginScreen onLogin={(s) => { setStudent(s); setView('dashboard') }}
|
||||
onShowPilotEntry={() => setView('pilot')} onShowForgotPassword={() => setView('forgot')} />
|
||||
}
|
||||
return <StudentDashboard student={student} onLogout={() => { setStudent(null); setView('login') }} />
|
||||
}
|
||||
|
||||
@@ -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 <div className={styles.errorBox}>{error}</div>
|
||||
if (!profile) return <p className={styles.muted}>Loading…</p>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.card}>
|
||||
<h2>Account</h2>
|
||||
<p><strong>{profile.student.display_name}</strong></p>
|
||||
<p className={styles.muted}>{profile.student.email || profile.student.login_identifier || 'No login identifier set'}</p>
|
||||
<p className={styles.muted}>
|
||||
{profile.cohorts.length ? profile.cohorts.map((c) => c.name).join(', ') : 'Not enrolled in a cohort yet'}
|
||||
</p>
|
||||
<p className={styles.muted}>{profile.active_sessions} active session{profile.active_sessions === 1 ? '' : 's'}</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h2>Progress</h2>
|
||||
<p>{profile.progress_summary.completed} / {profile.progress_summary.total} scenarios completed</p>
|
||||
<div className={styles.progressBarTrack}>
|
||||
<div className={styles.progressBarFill} style={{ width: `${profile.progress_summary.total ? Math.round(100 * profile.progress_summary.completed / profile.progress_summary.total) : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h2>Achievements</h2>
|
||||
{!profile.achievements.length ? <p className={styles.muted}>None yet — keep going!</p> : (
|
||||
<ul>
|
||||
{profile.achievements.map((a, i) => (
|
||||
<li key={i}>{a.achievement_type.replace(/_/g, ' ')}{a.stage_id ? ` — ${a.stage_id}` : ''}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChangePasswordCard hasCredentials={profile.student.has_credentials} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.card}>
|
||||
<h2>Change password</h2>
|
||||
<p className={styles.muted}>This is still a pilot account with no password set. Ask your instructor to convert it to a real account first.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.card}>
|
||||
<h2>Change password</h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{notice && <p className={styles.muted}>{notice}</p>}
|
||||
<form onSubmit={submit}>
|
||||
<label className={styles.label}>Current password</label>
|
||||
<input className={styles.input} type="password" autoComplete="current-password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} required />
|
||||
<label className={styles.label}>New password</label>
|
||||
<input className={styles.input} type="password" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required minLength={10} />
|
||||
<button className={styles.primaryBtn} type="submit" disabled={busy}>{busy ? 'Saving…' : 'Update password'}</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }) {
|
||||
</nav>
|
||||
|
||||
{tab === 'learning' && <MyLearningTab />}
|
||||
{tab === 'classroom' && <ClassroomTab />}
|
||||
{tab === 'practice' && <PracticeTab />}
|
||||
{tab === 'assessments' && <AssessmentsTab />}
|
||||
{tab === 'capstone' && <CapstoneTab />}
|
||||
{tab === 'achievements' && <AchievementsTab />}
|
||||
{tab === 'profile' && <ProfileTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
|
||||
@@ -83,11 +83,10 @@ scrape_configs:
|
||||
- target_label: __address__
|
||||
replacement: localhost:9115
|
||||
|
||||
# RootNode application metrics — see docs/OBSERVABILITY.md §"exposing /metrics" for the
|
||||
# documented, NOT-YET-BUILT follow-up (Platform API/Gateway/Provisioner don't currently
|
||||
# expose a Prometheus /metrics endpoint; this job is ready for when that lands, disabled
|
||||
# until then rather than scraping a 404 forever).
|
||||
# - job_name: rootnode-platform-api
|
||||
# static_configs:
|
||||
# - targets: ["api:4300"]
|
||||
# metrics_path: /metrics
|
||||
# RootNode application metrics — Phase 5A §34 (platform/api/src/metrics.js): classroom/help-
|
||||
# request/Lab-state gauges and student login counters. No student/instructor id or other
|
||||
# high-cardinality value is ever a label here — see that file's header comment.
|
||||
- job_name: rootnode-platform-api
|
||||
static_configs:
|
||||
- targets: ["api:4300"]
|
||||
metrics_path: /metrics
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Orchestrates the Phase 5A §30/§33 dress rehearsal: brings up the real dev stack, bootstraps
|
||||
# an owner, runs test-phase5a-rehearsal.js, tears down.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../../../platform"
|
||||
|
||||
export PROVISIONER_SHARED_SECRET="${PROVISIONER_SHARED_SECRET:-rehearsal-shared-secret-not-for-production}"
|
||||
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
cleanup() {
|
||||
echo "==> Tearing down"
|
||||
docker ps -aq --filter "name=rootnode-lab-" | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||||
docker network ls --filter "name=rootnode-lab-net-" --format "{{.Name}}" | xargs -r -n1 docker network rm >/dev/null 2>&1 || true
|
||||
docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "==> Waiting for the stack"
|
||||
for _ in $(seq 1 60); do
|
||||
curl -sf http://localhost:4300/healthz >/dev/null 2>&1 && curl -sf http://localhost:4200/healthz >/dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "==> Bootstrapping the rehearsal owner"
|
||||
( cd api && PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
|
||||
node bootstrap-owner.js [email protected] "P5A Rehearsal Owner" 'RehearsalPass!2026' )
|
||||
|
||||
echo "==> Running the rehearsal"
|
||||
OWNER_EMAIL=[email protected] OWNER_PASSWORD='RehearsalPass!2026' \
|
||||
node ../infra/scripts/drills/test-phase5a-rehearsal.js
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 5A §30/§33 — the student identity + remote classroom dress rehearsal. One continuous
|
||||
// narrative: an instructor rosters 5 PILOT students (no email/login_identifier — simulating the
|
||||
// real pre-Phase-5A population), each logs in the OLD bare-student_id way, picks up real
|
||||
// progress/attempt/achievement history, joins a live classroom, sends a help request, and the
|
||||
// Platform API is restarted MID-REHEARSAL to prove classroom/session/presence survive it. Two
|
||||
// of the five are then converted to real credentialed accounts and their entire prior history
|
||||
// (progress/attempts/achievements/cohort membership) is verified intact afterward — the actual
|
||||
// migration-safety check §33 asks for, run against a live stack rather than asserted in the
|
||||
// abstract. No framework, matching project convention (see test-fake-cohort-rehearsal.js).
|
||||
//
|
||||
// Run via run-phase5a-rehearsal.sh, which brings up platform/docker-compose.dev.yml first.
|
||||
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const { makeSession } = require('../../../platform/test/session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
||||
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
||||
if (!OWNER_EMAIL || !OWNER_PASSWORD) { console.error('Set OWNER_EMAIL and OWNER_PASSWORD'); process.exit(1); }
|
||||
const RUN_ID = Date.now();
|
||||
const COHORT_ID = `p5a-rehearsal-${RUN_ID}`;
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function step(name, fn) {
|
||||
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||
catch (e) { console.error(` FAIL — ${name}\n ${e.stack || e.message}`); failed++; }
|
||||
}
|
||||
|
||||
// Seeds one realistic progress/attempt/achievement record for a pilot student directly in
|
||||
// Postgres — standing in for months of real pre-Phase-5A usage without re-driving the full
|
||||
// Lab-exec/validation pipeline, which is already covered end-to-end by e2e-phase2a/2b.js.
|
||||
function seedHistory(studentId) {
|
||||
const sql = `
|
||||
INSERT INTO progress (student_id, scenario_id, status, attempts, completed_at, started_at)
|
||||
VALUES ('${studentId}', 'intro-shell-basics', 'completed', 1, now() - interval '3 days', now() - interval '3 days');
|
||||
INSERT INTO scenario_attempts (student_id, scenario_id, started_at, completed_at, result, validation_count)
|
||||
VALUES ('${studentId}', 'intro-shell-basics', now() - interval '3 days', now() - interval '3 days', 'passed', 4);
|
||||
INSERT INTO achievements (student_id, achievement_type, curriculum_path_id, stage_id)
|
||||
VALUES ('${studentId}', 'stage_completed', 'rootnode-path', 'foundations');
|
||||
`;
|
||||
execSync(`docker compose -f docker-compose.dev.yml exec -T postgres psql -U postgres -d rootnode_platform -c "${sql.replace(/"/g, '\\"')}"`,
|
||||
{ cwd: `${__dirname}/../../../platform`, stdio: 'pipe' });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 5A student identity / remote classroom dress rehearsal\n');
|
||||
|
||||
const instructor = makeSession(GW);
|
||||
await instructor.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
|
||||
const names = ['Reh-Ada', 'Reh-Bo', 'Reh-Cy', 'Reh-Di', 'Reh-Ez'];
|
||||
const students = {}; // name -> student_id
|
||||
const sessions = {}; // name -> pilot login session
|
||||
|
||||
await step('instructor: create a cohort and roster 5 PILOT students (no email/login_identifier)', async () => {
|
||||
await instructor.request('POST', '/api/admin/cohorts', { cohort_id: COHORT_ID, name: 'P5A Rehearsal Cohort' });
|
||||
for (const name of names) {
|
||||
const { body } = await instructor.request('POST', '/api/admin/students', { display_name: name, cohort_id: COHORT_ID });
|
||||
assert.equal(body.activation_token, null, `${name} must be a plain pilot row — no email/login_identifier was given`);
|
||||
students[name] = body.student_id;
|
||||
}
|
||||
});
|
||||
|
||||
await step('every pilot student logs in the OLD bare-student_id way, exactly as before Phase 5A', async () => {
|
||||
for (const name of names) {
|
||||
sessions[name] = makeSession(GW);
|
||||
const { status } = await sessions[name].request('POST', '/api/login', { student_id: students[name] });
|
||||
assert.equal(status, 200);
|
||||
}
|
||||
});
|
||||
|
||||
await step('seed realistic pre-existing progress/attempt/achievement history for two students', () => {
|
||||
seedHistory(students['Reh-Ada']);
|
||||
seedHistory(students['Reh-Bo']);
|
||||
});
|
||||
|
||||
let classroomId;
|
||||
await step('instructor: create and start a live classroom for the cohort', async () => {
|
||||
const { body } = await instructor.request('POST', `/api/admin/cohorts/${COHORT_ID}/classroom-sessions`, { title: 'Rehearsal Live Class' });
|
||||
classroomId = body.classroom_session_id;
|
||||
const { status } = await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/start`);
|
||||
assert.equal(status, 200);
|
||||
});
|
||||
|
||||
await step('all 5 students join the classroom and heartbeat', async () => {
|
||||
for (const name of names) {
|
||||
const { status } = await sessions[name].request('POST', `/api/classroom/${classroomId}/join`);
|
||||
assert.equal(status, 200);
|
||||
await sessions[name].request('POST', `/api/classroom/${classroomId}/heartbeat`);
|
||||
}
|
||||
});
|
||||
|
||||
await step('Reh-Cy sends a help request; instructor acknowledges it', async () => {
|
||||
const { status } = await sessions['Reh-Cy'].request('POST', `/api/classroom/${classroomId}/help-request`, { category: 'lab_problem', message: 'stuck on validation' });
|
||||
assert.equal(status, 201);
|
||||
const { body: dash } = await instructor.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
|
||||
const req = dash.help_requests.find((h) => h.status === 'open');
|
||||
assert.ok(req);
|
||||
await instructor.request('POST', `/api/admin/help-requests/${req.help_request_id}/acknowledge`);
|
||||
});
|
||||
|
||||
await step('instructor posts an announcement and sets current activity', async () => {
|
||||
await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/announcements`, { message: 'Welcome — start with the intro exercise.' });
|
||||
await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/activity`, { scenario_id: 'intro-shell-basics' });
|
||||
});
|
||||
|
||||
// ── The restart: the whole point of §30's "prove recovery" requirement. ─────────────────
|
||||
await step('Platform API restarts mid-rehearsal', () => {
|
||||
execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: `${__dirname}/../../../platform`, stdio: 'pipe' });
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 4000));
|
||||
|
||||
await step('after restart: the classroom is still live and every session still works, with no re-login', async () => {
|
||||
for (const name of names) {
|
||||
const { status } = await sessions[name].request('GET', `/api/classroom/live`);
|
||||
assert.equal(status, 200);
|
||||
}
|
||||
const { body: dash } = await instructor.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
|
||||
assert.equal(dash.classroom.status, 'live');
|
||||
assert.equal(dash.students.length, 5);
|
||||
assert.equal(dash.announcements.length, 1);
|
||||
});
|
||||
|
||||
await step('after restart: heartbeats and help requests still work normally', async () => {
|
||||
const { status } = await sessions['Reh-Di'].request('POST', `/api/classroom/${classroomId}/heartbeat`);
|
||||
assert.equal(status, 200);
|
||||
});
|
||||
|
||||
// ── §33: convert two students mid-rehearsal, verify nothing was orphaned. ───────────────
|
||||
let adaActivation, boActivation;
|
||||
await step('instructor converts Reh-Ada and Reh-Bo to real credentialed accounts', async () => {
|
||||
const a = await instructor.request('POST', `/api/admin/students/${students['Reh-Ada']}/convert`, { login_identifier: `p5a-reh-ada-${RUN_ID}` });
|
||||
const b = await instructor.request('POST', `/api/admin/students/${students['Reh-Bo']}/convert`, { login_identifier: `p5a-reh-bo-${RUN_ID}` });
|
||||
assert.equal(a.status, 200); assert.equal(b.status, 200);
|
||||
adaActivation = a.body.activation_token; boActivation = b.body.activation_token;
|
||||
});
|
||||
|
||||
await step('old bare-id pilot login is now refused for the converted two, still works for the other three', async () => {
|
||||
const adaRetry = await makeSession(GW).request('POST', '/api/login', { student_id: students['Reh-Ada'] });
|
||||
assert.equal(adaRetry.status, 409);
|
||||
const cyStillWorks = await makeSession(GW).request('POST', '/api/login', { student_id: students['Reh-Cy'] });
|
||||
assert.equal(cyStillWorks.status, 200);
|
||||
});
|
||||
|
||||
let adaNewSession, boNewSession;
|
||||
await step('Reh-Ada and Reh-Bo activate their new accounts and log in the NEW way', async () => {
|
||||
adaNewSession = makeSession(GW);
|
||||
boNewSession = makeSession(GW);
|
||||
const a = await adaNewSession.request('POST', '/api/student/activate', { token: adaActivation, password: 'RehAdaPass123!' });
|
||||
const b = await boNewSession.request('POST', '/api/student/activate', { token: boActivation, password: 'RehBoPass123!' });
|
||||
assert.equal(a.status, 200); assert.equal(b.status, 200);
|
||||
});
|
||||
|
||||
await step('every bit of prior history survived the conversion — nothing orphaned', async () => {
|
||||
const adaProgress = await adaNewSession.request('GET', '/api/progress');
|
||||
assert.equal(adaProgress.status, 200);
|
||||
assert.ok(adaProgress.body.details['intro-shell-basics'], 'the seeded pre-conversion progress must still be visible under the new login');
|
||||
assert.equal(adaProgress.body.details['intro-shell-basics'].status, 'completed');
|
||||
|
||||
const { body: dashAfter } = await instructor.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
|
||||
const adaRow = dashAfter.students.find((s) => s.student_id === students['Reh-Ada']);
|
||||
assert.ok(adaRow, 'converted student must still be a classroom participant — same student_id throughout, nothing re-created');
|
||||
|
||||
const stillEnrolled = await instructor.request('GET', `/api/admin/cohorts/${COHORT_ID}/students`);
|
||||
assert.ok(stillEnrolled.body.some((s) => s.student_id === students['Reh-Ada']), 'cohort membership must survive conversion');
|
||||
});
|
||||
|
||||
await step('cleanup: end the classroom', async () => {
|
||||
await instructor.request('POST', `/api/admin/classroom-sessions/${classroomId}/end`);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
+7
-5
@@ -10,13 +10,15 @@ entrypoint/Dockerfile unmodified; frontend bundle gained Phase 3B learning UX, s
|
||||
- `api/` — the lightweight Platform API: students, cohorts, enrollments, curriculum, detailed
|
||||
scenario attempt telemetry, progressive hints, curriculum completion rules, the exam +
|
||||
capstone assessment engine, achievements, per-instructor accounts, persistent student/
|
||||
instructor sessions, RBAC, audit logging, and the instructor/admin control plane. No k3s, no
|
||||
`--privileged` — this replaced Phase 2A's "identity" instance, which was a full Lab image
|
||||
booting an unused k3s cluster just to serve these APIs. Design:
|
||||
instructor sessions, RBAC, audit logging, real student credentials + remote classrooms
|
||||
(Phase 5A), and the instructor/admin control plane. No k3s, no `--privileged` — this replaced
|
||||
Phase 2A's "identity" instance, which was a full Lab image booting an unused k3s cluster just
|
||||
to serve these APIs. Design:
|
||||
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` (split + Postgres migration),
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (sessions, accounts, RBAC, audit),
|
||||
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` (learning/assessment engine). Operational
|
||||
reference: `api/README.md`.
|
||||
`docs/PHASE3B_LEARNING_AND_ASSESSMENT.md` (learning/assessment engine),
|
||||
`docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md` (student credentials, remote classroom).
|
||||
Operational reference: `api/README.md`.
|
||||
- `gateway/` — the only student-facing path to a Lab (HTTP + WebSocket proxy, environment-aware
|
||||
routing derived from a server-controlled session — not a client-suppliable header/param — and,
|
||||
as of Phase 3B, server-side gating of progressive hint reveal). Design:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Real student accounts — Phase 5A §2/§3/§7, docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md.
|
||||
--
|
||||
-- Additive only (check-before-alter, docs/ENGINEERING_SOP.md §6) — every existing pilot student
|
||||
-- row stays a valid academic record with all columns NULL/default. A pilot student
|
||||
-- (password_hash IS NULL) can still use the original bare student_id login
|
||||
-- (POST /api/login, unchanged) until an instructor converts them; a converted/newly-created
|
||||
-- student (password_hash IS NOT NULL) must use the new credentialed login
|
||||
-- (POST /api/student/login) instead. See src/students-auth.js.
|
||||
--
|
||||
-- email vs login_identifier (Phase 5A §3): RootNode serves minors who may not have an email
|
||||
-- address. Both are nullable, independently unique — an older/self-paced student gets `email`,
|
||||
-- a younger instructor-managed student gets `login_identifier` (any safe non-email string the
|
||||
-- instructor assigns, e.g. a classroom username). Exactly one of the two is required once an
|
||||
-- account is credentialed (enforced in application code, not a CHECK, so the exact rule can
|
||||
-- evolve without a migration). Neither column names or implies a parent/guardian relationship —
|
||||
-- that's explicitly future work (docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md), not built here;
|
||||
-- the schema deliberately doesn't need to change to add it later (a guardian would reference a
|
||||
-- student_id, not the other way around).
|
||||
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS email TEXT;
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS login_identifier TEXT;
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS password_hash TEXT;
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
|
||||
|
||||
-- The activation/reset token is ONE mechanism serving two purposes (first-login activation and
|
||||
-- forgot-password) — both are "a time-limited secure token that lets you set a new password,"
|
||||
-- functionally identical, so one column set instead of two parallel ones. Only the hash is
|
||||
-- ever stored (crypto.createHash('sha256'), same pattern as sessions.js's token_hash) — the
|
||||
-- plaintext token exists only in the instructor's API response / student's activation link,
|
||||
-- never persisted, never logged.
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS credential_token_hash TEXT;
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS credential_token_purpose TEXT;
|
||||
ALTER TABLE students ADD COLUMN IF NOT EXISTS credential_token_expires_at TIMESTAMPTZ;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE students ADD CONSTRAINT students_credential_token_purpose_check
|
||||
CHECK (credential_token_purpose IS NULL OR credential_token_purpose IN ('activation', 'reset'));
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_students_email ON students(email) WHERE email IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_students_login_identifier ON students(login_identifier) WHERE login_identifier IS NOT NULL;
|
||||
@@ -0,0 +1,99 @@
|
||||
-- Live classroom sessions — Phase 5A §11/§12. A classroom session is a distinct concept from a
|
||||
-- cohort (a standing roster), an assessment session (one student's exam attempt), a Lab
|
||||
-- environment (disposable infrastructure), or a login session (one browser's auth) — it
|
||||
-- represents ONE live teaching event, e.g. "Week 3 — Kubernetes Workloads, Aug 29 6-8pm."
|
||||
--
|
||||
-- Deliberately five tables, not one JSONB blob: classroom_sessions (the event itself),
|
||||
-- classroom_participants (who's in it + presence), classroom_events (append-only audit trail),
|
||||
-- classroom_announcements and help_requests get their own tables rather than being folded into
|
||||
-- classroom_events, since both need efficient direct queries ("show me the open help queue",
|
||||
-- "show me this classroom's announcements in order") that a generic event log would make
|
||||
-- awkward. classroom_events stays the audit trail (docs/PHASE5A... §25) — it does NOT duplicate
|
||||
-- announcement/help-request content, just records that the action happened.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS classroom_sessions (
|
||||
classroom_session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
cohort_id TEXT NOT NULL REFERENCES cohorts(cohort_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'scheduled'
|
||||
CHECK (status IN ('scheduled', 'live', 'ended', 'cancelled')),
|
||||
scheduled_start TIMESTAMPTZ,
|
||||
scheduled_end TIMESTAMPTZ,
|
||||
started_at TIMESTAMPTZ,
|
||||
ended_at TIMESTAMPTZ,
|
||||
created_by UUID REFERENCES instructors(instructor_id),
|
||||
-- "Current activity" (§20) — a scenario id, not a stage id, matching how students actually
|
||||
-- work (one scenario at a time). NULL means the instructor hasn't set one yet. This is
|
||||
-- guidance only — never used to redirect a browser or auto-mark work complete (enforced in
|
||||
-- application code: nothing reads this to gate access or grade anything).
|
||||
current_scenario_id TEXT,
|
||||
-- Optional external video-conferencing link (§23) — a plain URL, RootNode never validates
|
||||
-- which provider it points at and never proxies/embeds it.
|
||||
meeting_url TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_classroom_sessions_cohort ON classroom_sessions(cohort_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_classroom_sessions_status ON classroom_sessions(status);
|
||||
|
||||
-- At most one LIVE classroom per cohort at a time — an instructor starting a second live
|
||||
-- session for the same cohort while one is already live is almost certainly a mistake, not a
|
||||
-- real use case (see classroom.js's startClassroom for the app-level check this backs).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_classroom_sessions_one_live_per_cohort
|
||||
ON classroom_sessions(cohort_id) WHERE status = 'live';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS classroom_participants (
|
||||
classroom_session_id UUID NOT NULL REFERENCES classroom_sessions(classroom_session_id),
|
||||
student_id TEXT NOT NULL REFERENCES students(student_id),
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
-- Presence (§15) — updated by a throttled heartbeat while the student has this classroom
|
||||
-- open, never by Lab terminal activity. online/recently-active/offline is DERIVED from this
|
||||
-- at read time (see admin-classroom-routes.js), not stored as a separate status column, so
|
||||
-- there's nothing to keep in sync or go stale on its own.
|
||||
last_seen_at TIMESTAMPTZ,
|
||||
left_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (classroom_session_id, student_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_classroom_participants_student ON classroom_participants(student_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS classroom_events (
|
||||
event_id BIGSERIAL PRIMARY KEY,
|
||||
classroom_session_id UUID NOT NULL REFERENCES classroom_sessions(classroom_session_id),
|
||||
actor_type TEXT NOT NULL CHECK (actor_type IN ('instructor', 'student', 'system')),
|
||||
actor_id TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_classroom_events_session ON classroom_events(classroom_session_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS classroom_announcements (
|
||||
announcement_id BIGSERIAL PRIMARY KEY,
|
||||
classroom_session_id UUID NOT NULL REFERENCES classroom_sessions(classroom_session_id),
|
||||
instructor_id UUID REFERENCES instructors(instructor_id),
|
||||
message TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_classroom_announcements_session ON classroom_announcements(classroom_session_id, created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS help_requests (
|
||||
help_request_id BIGSERIAL PRIMARY KEY,
|
||||
classroom_session_id UUID NOT NULL REFERENCES classroom_sessions(classroom_session_id),
|
||||
student_id TEXT NOT NULL REFERENCES students(student_id),
|
||||
category TEXT, -- free text ('lab_problem' | 'dont_understand' | 'validation_failing' | 'other' from the frontend, not a DB-enforced enum — see §18's example list)
|
||||
message TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'acknowledged', 'resolved')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
acknowledged_at TIMESTAMPTZ,
|
||||
acknowledged_by UUID REFERENCES instructors(instructor_id),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
instructor_note TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_help_requests_session_status ON help_requests(classroom_session_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_help_requests_student ON help_requests(student_id);
|
||||
@@ -0,0 +1,175 @@
|
||||
// Instructor-facing classroom API — Phase 5A §14/§21. RBAC via rbac.js's
|
||||
// requireClassroomAccess/requireClassroomWriteAccess/requireHelpRequestWriteAccess (resolve
|
||||
// through to the classroom's cohort, same owner/admin/instructor/viewer rules as everything
|
||||
// else cohort-scoped — see docs/PHASE4_1_PREPRODUCTION_HARDENING.md §19 for role_in_cohort).
|
||||
//
|
||||
// The dashboard aggregation (GET /api/admin/classroom-sessions/:id) is the one place in this
|
||||
// file that reaches beyond classroom.js — it pulls in Provisioner Lab state (§16) and Phase 3B
|
||||
// academic records (§17) alongside classroom.js's own participant/presence/help-queue data,
|
||||
// because that combined view is the entire point of a live classroom dashboard. Neither the
|
||||
// Provisioner nor the academic-records shape is duplicated here — both are read exactly the
|
||||
// way admin-routes.js/admin-learning-routes.js already read them.
|
||||
|
||||
const classroom = require('./classroom');
|
||||
const { prepareLabsForCohort } = require('./prepare-labs');
|
||||
|
||||
module.exports = function registerAdminClassroomRoutes(app, deps) {
|
||||
const { instructorSession, rbac, provisioner, curriculum, getPool, loadProgress, recordAudit } = deps;
|
||||
const { requireAnyInstructor, requireCohortAccess, requireCohortWriteAccess, requireClassroomAccess, requireClassroomWriteAccess, requireHelpRequestWriteAccess } = rbac;
|
||||
|
||||
function wrap(fn) {
|
||||
return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };
|
||||
}
|
||||
const admin = {
|
||||
get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)),
|
||||
post: (p, ...mw) => app.post(p, instructorSession, ...mw.map(wrap)),
|
||||
};
|
||||
|
||||
// Maps classroom.js's INVALID_STATE/NOT_FOUND/ALREADY_LIVE error codes to HTTP status —
|
||||
// every lifecycle route below funnels through this instead of repeating the same try/catch.
|
||||
function classroomErrorStatus(code) {
|
||||
if (code === 'NOT_FOUND') return 404;
|
||||
if (code === 'ALREADY_LIVE' || code === 'INVALID_STATE') return 409;
|
||||
return 500;
|
||||
}
|
||||
|
||||
admin.post('/api/admin/cohorts/:id/classroom-sessions', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => {
|
||||
const { title, description, scheduled_start, scheduled_end, meeting_url } = req.body || {};
|
||||
if (!title) return res.status(400).json({ error: 'title is required' });
|
||||
const created = await classroom.createClassroom(getPool(), {
|
||||
cohortId: req.params.id, title, description, scheduledStart: scheduled_start, scheduledEnd: scheduled_end,
|
||||
meetingUrl: meeting_url, createdBy: req.instructor.instructor_id,
|
||||
});
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_created', resourceType: 'classroom_session', resourceId: created.classroom_session_id, result: 'success', sourceIp: req.ip });
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
admin.get('/api/admin/cohorts/:id/classroom-sessions', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
|
||||
res.json(await classroom.listClassroomsForCohort(getPool(), req.params.id));
|
||||
});
|
||||
|
||||
// The dashboard itself — §14's "ROOTNODE LIVE CLASSROOM" view. Built from real available
|
||||
// data, not a fixed mock: participant presence (classroom.js), each participant's Lab status
|
||||
// (Provisioner, reusing the SAME environment-state values everywhere else in the app uses —
|
||||
// no second state system, §16), and a lightweight per-student learning snapshot (§17) —
|
||||
// current stage, most recent scenario worked, its status/validation-failure count, hints
|
||||
// used. Never inspects terminal command history.
|
||||
admin.get('/api/admin/classroom-sessions/:id', requireAnyInstructor, requireClassroomAccess('id'), async (req, res) => {
|
||||
const pool = getPool();
|
||||
const classroomRow = await classroom.getClassroom(pool, req.params.id);
|
||||
const [participants, announcements, helpRequests] = await Promise.all([
|
||||
classroom.listParticipants(pool, req.params.id),
|
||||
classroom.listAnnouncements(pool, req.params.id),
|
||||
classroom.listHelpRequests(pool, req.params.id),
|
||||
]);
|
||||
|
||||
const path = curriculum.loadCurriculumPath();
|
||||
const students = await Promise.all(participants.map(async (p) => {
|
||||
const [envRes, progress, lastAttempt] = await Promise.all([
|
||||
provisioner.listForStudent(p.student_id),
|
||||
loadProgress(pool, p.student_id),
|
||||
pool.query(
|
||||
`SELECT scenario_id, status, validation_fail_count, hint_count FROM scenario_attempts
|
||||
WHERE student_id = $1 ORDER BY started_at DESC NULLS LAST LIMIT 1`,
|
||||
[p.student_id]
|
||||
),
|
||||
]);
|
||||
const env = envRes.body?.find((e) => e.status !== 'destroyed');
|
||||
const completed = Object.values(progress).filter((pr) => pr.status === 'completed').length;
|
||||
const currentStage = path ? path.stages.find((s) => (s.scenario_ids || []).some((sid) => progress[sid]?.status !== 'completed'))?.id || null : null;
|
||||
return {
|
||||
...p,
|
||||
lab_status: env ? env.status : 'none', // 'none'|'requested'|'provisioning'|'running'|'resetting'|'error'|'destroying'|'destroyed' — the real Provisioner enum, unchanged
|
||||
current_stage: currentStage,
|
||||
current_scenario: lastAttempt.rows[0]?.scenario_id || null,
|
||||
attempt_status: lastAttempt.rows[0]?.status || null,
|
||||
validation_failures: lastAttempt.rows[0]?.validation_fail_count || 0,
|
||||
hints_used: lastAttempt.rows[0]?.hint_count || 0,
|
||||
scenarios_completed: completed,
|
||||
};
|
||||
}));
|
||||
|
||||
res.json({ classroom: classroomRow, students, announcements, help_requests: helpRequests });
|
||||
});
|
||||
|
||||
admin.post('/api/admin/classroom-sessions/:id/start', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
|
||||
try {
|
||||
const updated = await classroom.startClassroom(getPool(), req.params.id, req.instructor.instructor_id);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_started', resourceType: 'classroom_session', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||
res.json(updated);
|
||||
} catch (e) {
|
||||
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
admin.post('/api/admin/classroom-sessions/:id/end', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
|
||||
try {
|
||||
const updated = await classroom.endClassroom(getPool(), req.params.id, req.instructor.instructor_id);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_ended', resourceType: 'classroom_session', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||
res.json(updated);
|
||||
} catch (e) {
|
||||
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
admin.post('/api/admin/classroom-sessions/:id/cancel', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
|
||||
try {
|
||||
const updated = await classroom.cancelClassroom(getPool(), req.params.id, req.instructor.instructor_id);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_cancelled', resourceType: 'classroom_session', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||
res.json(updated);
|
||||
} catch (e) {
|
||||
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// §21 — the same underlying prepareLabsForCohort() the cohort-level route uses, just entered
|
||||
// from the classroom dashboard and scoped to req.classroomCohortId (set by
|
||||
// requireClassroomWriteAccess). Respects admission control exactly the same way (Phase 4.1
|
||||
// §26) — a full host surfaces as lab_status: 'capacity_waiting' per student, not a crash.
|
||||
admin.post('/api/admin/classroom-sessions/:id/prepare-labs', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
|
||||
const results = await prepareLabsForCohort(getPool(), provisioner, req.classroomCohortId);
|
||||
await classroom.emitEvent(getPool(), req.params.id, 'instructor', req.instructor.instructor_id, 'labs_prepared', { student_count: results.length });
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'classroom_labs_prepared', resourceType: 'classroom_session', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: results.length } });
|
||||
res.json({ results });
|
||||
});
|
||||
|
||||
admin.post('/api/admin/classroom-sessions/:id/activity', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
|
||||
const { scenario_id } = req.body || {};
|
||||
try {
|
||||
const updated = await classroom.setCurrentActivity(getPool(), req.params.id, scenario_id, req.instructor.instructor_id);
|
||||
res.json(updated);
|
||||
} catch (e) {
|
||||
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
admin.post('/api/admin/classroom-sessions/:id/announcements', requireAnyInstructor, requireClassroomWriteAccess('id'), async (req, res) => {
|
||||
const { message } = req.body || {};
|
||||
if (!message || !message.trim()) return res.status(400).json({ error: 'message is required' });
|
||||
const created = await classroom.postAnnouncement(getPool(), req.params.id, req.instructor.instructor_id, message.trim());
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
admin.get('/api/admin/classroom-sessions/:id/help-requests', requireAnyInstructor, requireClassroomAccess('id'), async (req, res) => {
|
||||
res.json(await classroom.listHelpRequests(getPool(), req.params.id, req.query.status));
|
||||
});
|
||||
|
||||
admin.post('/api/admin/help-requests/:id/acknowledge', requireAnyInstructor, requireHelpRequestWriteAccess('id'), async (req, res) => {
|
||||
try {
|
||||
const updated = await classroom.acknowledgeHelpRequest(getPool(), req.params.id, req.instructor.instructor_id);
|
||||
res.json(updated);
|
||||
} catch (e) {
|
||||
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
admin.post('/api/admin/help-requests/:id/resolve', requireAnyInstructor, requireHelpRequestWriteAccess('id'), async (req, res) => {
|
||||
const { note } = req.body || {};
|
||||
try {
|
||||
const updated = await classroom.resolveHelpRequest(getPool(), req.params.id, req.instructor.instructor_id, note);
|
||||
res.json(updated);
|
||||
} catch (e) {
|
||||
res.status(classroomErrorStatus(e.code)).json({ error: e.code || 'error', message: e.message });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -16,6 +16,9 @@
|
||||
const { listInstructors, createInstructor, assertNotLastOwner } = require('./instructors');
|
||||
const attemptsLib = require('./attempts'); // Phase 4.1 §13 — abandonment on instructor-driven reset/destroy
|
||||
const { parsePagination, paginatedResponse } = require('./pagination'); // Phase 4.1 §16
|
||||
const { issueCredentialToken } = require('./students-auth'); // Phase 5A §4/§5/§7
|
||||
const { prepareLabsForCohort } = require('./prepare-labs'); // Phase 5A §21 — shared with admin-classroom-routes.js
|
||||
const { parseStudentCsv } = require('./student-csv'); // Phase 5A §29
|
||||
|
||||
module.exports = function registerAdminRoutes(app, deps) {
|
||||
const { instructorSession, rbac, provisioner, curriculum, getPool, loadProgress, recordAudit } = deps;
|
||||
@@ -95,33 +98,168 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
});
|
||||
|
||||
// ── Students ─────────────────────────────────────────────────────────────────────────
|
||||
// Phase 5A: explicit column list, not SELECT * — students now carries password_hash and
|
||||
// credential_token_hash (migration 017), which must never reach an API response
|
||||
// (docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §2). (password_hash IS NOT NULL) is
|
||||
// exposed as a boolean, matching students-auth.js's publicStudent() shape, so the admin UI
|
||||
// can tell "pilot, needs conversion" from "already credentialed" without ever seeing the hash.
|
||||
admin.get('/api/admin/students', requireAnyInstructor, async (req, res) => {
|
||||
const pool = getPool();
|
||||
const { rows } = req.instructor.role === 'instructor'
|
||||
? await pool.query(
|
||||
`SELECT DISTINCT s.* FROM students s
|
||||
`SELECT DISTINCT s.student_id, s.display_name, s.status, s.email, s.login_identifier,
|
||||
(s.password_hash IS NOT NULL) AS has_credentials, s.created_at, s.updated_at, s.last_active_at, s.last_login_at
|
||||
FROM students s
|
||||
JOIN cohort_enrollments e ON e.student_id = s.student_id
|
||||
JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id
|
||||
WHERE ci.instructor_id = $1 AND e.enrollment_status = 'enrolled' ORDER BY s.display_name`, [req.instructor.instructor_id])
|
||||
: await pool.query(`SELECT * FROM students ORDER BY display_name`);
|
||||
: await pool.query(
|
||||
`SELECT student_id, display_name, status, email, login_identifier,
|
||||
(password_hash IS NOT NULL) AS has_credentials, created_at, updated_at, last_active_at, last_login_at
|
||||
FROM students ORDER BY display_name`);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// Phase 5A §2/§3: email/login_identifier are both optional and independent — an older
|
||||
// self-paced student gets email, a younger instructor-managed one gets login_identifier
|
||||
// (any instructor-assigned safe string, not necessarily an email). Providing EITHER issues
|
||||
// an activation token immediately (§5's "instructor creates account -> temporary credential"
|
||||
// flow) and returns it ONCE in this response — the instructor relays it to the student
|
||||
// out-of-band (print it, read it to them, hand it over); it is never stored in plaintext or
|
||||
// logged anywhere (students-auth.js's issueCredentialToken only ever persists the hash).
|
||||
// Providing neither creates a plain pilot row, unchanged from before Phase 5A.
|
||||
admin.post('/api/admin/students', requireAdminOrOwner, async (req, res) => {
|
||||
const { display_name, cohort_id } = req.body || {};
|
||||
const { display_name, cohort_id, email, login_identifier } = req.body || {};
|
||||
if (!display_name) return res.status(400).json({ error: 'display_name is required' });
|
||||
const pool = getPool();
|
||||
const { rows: countRows } = await pool.query('SELECT count(*)::int AS n FROM students');
|
||||
const studentId = `student-${String(countRows[0].n + 1).padStart(3, '0')}`;
|
||||
await pool.query(`INSERT INTO students (student_id, display_name) VALUES ($1, $2)`, [studentId, display_name.trim()]);
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4)`,
|
||||
[studentId, display_name.trim(), email ? String(email).toLowerCase().trim() : null, login_identifier ? String(login_identifier).trim() : null]
|
||||
);
|
||||
} catch (e) {
|
||||
if (e.code === '23505') return res.status(409).json({ error: 'email_or_identifier_in_use' });
|
||||
throw e;
|
||||
}
|
||||
if (cohort_id) {
|
||||
await pool.query(
|
||||
`INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[cohort_id, studentId]
|
||||
);
|
||||
}
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_add', resourceType: 'student', resourceId: studentId, result: 'success', sourceIp: req.ip, metadata: { cohort_id } });
|
||||
res.status(201).json({ student_id: studentId, display_name: display_name.trim() });
|
||||
let activationToken = null;
|
||||
if (email || login_identifier) {
|
||||
const issued = await issueCredentialToken(pool, studentId, 'activation');
|
||||
activationToken = issued.token;
|
||||
}
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_add', resourceType: 'student', resourceId: studentId, result: 'success', sourceIp: req.ip, metadata: { cohort_id, credentialed: !!activationToken } });
|
||||
res.status(201).json({ student_id: studentId, display_name: display_name.trim(), activation_token: activationToken });
|
||||
});
|
||||
|
||||
// §7 — converts an existing PILOT student (password_hash IS NULL) into a credentialed
|
||||
// account: assigns email/login_identifier and issues an activation token, same mechanism as
|
||||
// creation. Nothing about the student's existing progress/attempts/achievements/cohort
|
||||
// membership is touched — student_id, the FK every other table hangs off of, never changes.
|
||||
admin.post('/api/admin/students/:id/convert', requireAdminOrOwner, async (req, res) => {
|
||||
const { email, login_identifier } = req.body || {};
|
||||
if (!email && !login_identifier) return res.status(400).json({ error: 'email or login_identifier is required' });
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(`SELECT student_id, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
if (rows[0].password_hash) return res.status(409).json({ error: 'already_converted', message: 'This student already has real credentials.' });
|
||||
try {
|
||||
await pool.query(
|
||||
`UPDATE students SET email = COALESCE($1, email), login_identifier = COALESCE($2, login_identifier), updated_at = now() WHERE student_id = $3`,
|
||||
[email ? String(email).toLowerCase().trim() : null, login_identifier ? String(login_identifier).trim() : null, req.params.id]
|
||||
);
|
||||
} catch (e) {
|
||||
if (e.code === '23505') return res.status(409).json({ error: 'email_or_identifier_in_use' });
|
||||
throw e;
|
||||
}
|
||||
const { token } = await issueCredentialToken(pool, req.params.id, 'activation');
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_converted', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||
res.json({ student_id: req.params.id, activation_token: token });
|
||||
});
|
||||
|
||||
// §4 — for an ALREADY-credentialed account (unlike convert, above). Issues a fresh 'reset'
|
||||
// token and revokes every existing session, same "instructor never needs to know the
|
||||
// student's password" principle as activation.
|
||||
admin.post('/api/admin/students/:id/reset-password', requireAdminOrOwner, async (req, res) => {
|
||||
const pool = getPool();
|
||||
const { rows } = await pool.query(`SELECT student_id, password_hash FROM students WHERE student_id = $1`, [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
if (!rows[0].password_hash) return res.status(409).json({ error: 'not_yet_converted', message: 'Convert this student to a real account first.' });
|
||||
const { token } = await issueCredentialToken(pool, req.params.id, 'reset');
|
||||
await require('./sessions').revokeAllStudentSessions(pool, req.params.id);
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_password_reset', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||
res.json({ student_id: req.params.id, reset_token: token });
|
||||
});
|
||||
|
||||
admin.post('/api/admin/students/:id/revoke-sessions', requireAdminOrOwner, async (req, res) => {
|
||||
await require('./sessions').revokeAllStudentSessions(getPool(), req.params.id);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_sessions_revoked', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
admin.get('/api/admin/students/:id/sessions', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
|
||||
const sessionsList = await require('./sessions').listStudentSessions(getPool(), req.params.id);
|
||||
res.json(sessionsList);
|
||||
});
|
||||
|
||||
// §29 — CSV bulk roster import. Deliberately transactional: the WHOLE file is parsed and
|
||||
// validated first; if any row is invalid, NOTHING is imported and every row-level error is
|
||||
// returned, rather than silently importing the valid rows and skipping the rest (the safer
|
||||
// default per docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §29 — a partial import that
|
||||
// "mostly worked" is a worse failure mode than a rejected file with a clear error list).
|
||||
// Expected columns: display_name, login_identifier (or email), cohort_id (optional). Never
|
||||
// accepts a password/password_hash column — activation tokens are always generated server-side.
|
||||
admin.post('/api/admin/students/import', requireAdminOrOwner, async (req, res) => {
|
||||
const { csv } = req.body || {};
|
||||
if (!csv || typeof csv !== 'string') return res.status(400).json({ error: 'csv (text) is required' });
|
||||
const { rows: parsedRows, errors: parseErrors } = parseStudentCsv(csv);
|
||||
if (parseErrors.length) return res.status(400).json({ error: 'invalid_csv', row_errors: parseErrors });
|
||||
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
const created = [];
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows: countRows } = await client.query('SELECT count(*)::int AS n FROM students');
|
||||
let nextN = countRows[0].n + 1;
|
||||
for (const row of parsedRows) {
|
||||
const studentId = `student-${String(nextN++).padStart(3, '0')}`;
|
||||
await client.query(
|
||||
`INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4)`,
|
||||
[studentId, row.display_name, row.email || null, row.login_identifier || null]
|
||||
);
|
||||
if (row.cohort_id) {
|
||||
const { rowCount } = await client.query(`SELECT 1 FROM cohorts WHERE cohort_id = $1`, [row.cohort_id]);
|
||||
if (!rowCount) throw Object.assign(new Error(`cohort_id '${row.cohort_id}' does not exist (row for ${row.display_name})`), { code: 'BAD_COHORT' });
|
||||
await client.query(`INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2)`, [row.cohort_id, studentId]);
|
||||
}
|
||||
let activationToken = null;
|
||||
if (row.email || row.login_identifier) {
|
||||
// issueCredentialToken only ever calls .query() on what it's given — a transaction
|
||||
// client works identically to a pool here, so the whole import (including every
|
||||
// token) commits or rolls back together with everything else.
|
||||
const issued = await issueCredentialToken(client, studentId, 'activation');
|
||||
activationToken = issued.token;
|
||||
}
|
||||
created.push({ student_id: studentId, display_name: row.display_name, activation_token: activationToken });
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
if (e.code === '23505') return res.status(409).json({ error: 'duplicate_email_or_identifier_in_file_or_existing_roster' });
|
||||
if (e.code === 'BAD_COHORT') return res.status(400).json({ error: 'invalid_cohort', message: e.message });
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_csv_import', result: 'success', sourceIp: req.ip, metadata: { count: created.length } });
|
||||
res.status(201).json({ created });
|
||||
});
|
||||
|
||||
admin.patch('/api/admin/students/:id', requireAdminOrOwner, async (req, res) => {
|
||||
@@ -306,23 +444,9 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
|
||||
// ── Classroom prep: Prepare Labs / Destroy Cohort Labs ──────────────────────────────────
|
||||
admin.post('/api/admin/cohorts/:id/prepare-labs', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => {
|
||||
const { rows: students } = await getPool().query(
|
||||
`SELECT s.student_id, s.display_name FROM cohort_enrollments e JOIN students s ON s.student_id = e.student_id
|
||||
WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled' AND s.status = 'active'`,
|
||||
[req.params.id]
|
||||
);
|
||||
const results = await Promise.allSettled(students.map(async (s) => {
|
||||
const existing = await provisioner.listForStudent(s.student_id);
|
||||
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
|
||||
if (active) return { student_id: s.student_id, display_name: s.display_name, status: active.status };
|
||||
const { status, body } = await provisioner.provision(s.student_id);
|
||||
if (status !== 201) return { student_id: s.student_id, display_name: s.display_name, status: 'error', error: body?.error };
|
||||
return { student_id: s.student_id, display_name: s.display_name, status: body.status };
|
||||
}));
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'prepare_labs', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: students.length } });
|
||||
res.json({
|
||||
results: results.map((r, i) => r.status === 'fulfilled' ? r.value : { student_id: students[i].student_id, display_name: students[i].display_name, status: 'error', error: r.reason?.message }),
|
||||
});
|
||||
const results = await prepareLabsForCohort(getPool(), provisioner, req.params.id);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'prepare_labs', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: results.length } });
|
||||
res.json({ results });
|
||||
});
|
||||
|
||||
admin.post('/api/admin/cohorts/:id/destroy-labs', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Student-facing classroom API — Phase 5A §22/§24. Every route here requires studentSession
|
||||
// (session-derived student_id, never trusted from the request body/params — same rule as
|
||||
// everywhere else a student acts) AND that the student is actually enrolled in the classroom's
|
||||
// cohort (cohort_enrollments) — a student can never join/view/act on a classroom for a cohort
|
||||
// they don't belong to, regardless of whether they know or can guess its id.
|
||||
|
||||
const classroom = require('./classroom');
|
||||
|
||||
module.exports = function registerClassroomRoutes(app, deps) {
|
||||
const { studentSession, getPool, curriculum } = deps;
|
||||
|
||||
function wrap(fn) {
|
||||
return (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); };
|
||||
}
|
||||
|
||||
// Resolves the classroom AND checks cohort enrollment in one place — every route below uses
|
||||
// this instead of repeating the join. Sets req.classroom on success.
|
||||
async function requireEnrolledClassroom(req, res, next) {
|
||||
const classroomRow = await classroom.getClassroom(getPool(), req.params.id);
|
||||
if (!classroomRow) return res.status(404).json({ error: 'not found' });
|
||||
const { rowCount } = await getPool().query(
|
||||
`SELECT 1 FROM cohort_enrollments WHERE cohort_id = $1 AND student_id = $2 AND enrollment_status = 'enrolled'`,
|
||||
[classroomRow.cohort_id, req.studentId]
|
||||
);
|
||||
if (!rowCount) return res.status(403).json({ error: 'forbidden' });
|
||||
req.classroom = classroomRow;
|
||||
next();
|
||||
}
|
||||
|
||||
// §22 — "LIVE NOW" banner on the student dashboard. Null if nothing is live for any cohort
|
||||
// this student belongs to (deliberately not "their one cohort" — §27 self-paced compatible).
|
||||
app.get('/api/classroom/live', studentSession, wrap(async (req, res) => {
|
||||
const live = await classroom.getLiveClassroomForStudent(getPool(), req.studentId);
|
||||
res.json({ classroom: live });
|
||||
}));
|
||||
|
||||
app.get('/api/classroom/:id', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
|
||||
const [announcements, own] = await Promise.all([
|
||||
classroom.listAnnouncements(getPool(), req.params.id),
|
||||
getPool().query(`SELECT * FROM help_requests WHERE classroom_session_id = $1 AND student_id = $2 ORDER BY created_at DESC LIMIT 5`, [req.params.id, req.studentId]),
|
||||
]);
|
||||
res.json({ classroom: req.classroom, announcements, my_help_requests: own.rows });
|
||||
}));
|
||||
|
||||
app.get('/api/classroom/:id/announcements', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
|
||||
res.json(await classroom.listAnnouncements(getPool(), req.params.id));
|
||||
}));
|
||||
|
||||
app.post('/api/classroom/:id/join', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
|
||||
if (req.classroom.status !== 'live') return res.status(409).json({ error: 'not_live', message: 'This classroom is not currently live.' });
|
||||
const participant = await classroom.joinClassroom(getPool(), req.params.id, req.studentId);
|
||||
res.json(participant);
|
||||
}));
|
||||
|
||||
app.post('/api/classroom/:id/leave', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
|
||||
await classroom.leaveClassroom(getPool(), req.params.id, req.studentId);
|
||||
res.json({ ok: true });
|
||||
}));
|
||||
|
||||
// §15 — throttled server-side (classroom.js's heartbeat only writes if the last recorded
|
||||
// beat is old enough), so the frontend can just call this every 20-30s without either side
|
||||
// needing to track timing beyond "did the interval fire."
|
||||
app.post('/api/classroom/:id/heartbeat', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
|
||||
await classroom.heartbeat(getPool(), req.params.id, req.studentId);
|
||||
res.json({ ok: true });
|
||||
}));
|
||||
|
||||
// §18 — no chat, no video, no remote shell takeover: exactly one action (raise a flag with
|
||||
// an optional category/message), visible only to instructors managing this cohort.
|
||||
app.post('/api/classroom/:id/help-request', studentSession, wrap(requireEnrolledClassroom), wrap(async (req, res) => {
|
||||
const { category, message } = req.body || {};
|
||||
const created = await classroom.createHelpRequest(getPool(), req.params.id, req.studentId, category, message);
|
||||
res.status(201).json(created);
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,242 @@
|
||||
// Classroom session lifecycle — Phase 5A §11-§20. A classroom session is a live teaching event,
|
||||
// distinct from a cohort (standing roster), an assessment session (one exam attempt), a Lab
|
||||
// environment (disposable infra), or a login session. This module owns the pure
|
||||
// classroom_sessions/classroom_participants/classroom_events/classroom_announcements/
|
||||
// help_requests CRUD and lifecycle rules; route files (admin-classroom-routes.js,
|
||||
// classroom-routes.js) own RBAC/session/HTTP shape and the dashboard aggregation that pulls in
|
||||
// Provisioner Lab state and Phase 3B academic records (this module doesn't know about either).
|
||||
|
||||
const PRESENCE_ONLINE_MS = 45 * 1000; // ~1.5x the 20-30s heartbeat interval the frontend uses
|
||||
const PRESENCE_RECENTLY_ACTIVE_MS = 5 * 60 * 1000;
|
||||
const HEARTBEAT_WRITE_THROTTLE_MS = 15 * 1000; // §15 "avoid excessive database writes" — coalesce heartbeats closer together than this into a no-op
|
||||
|
||||
async function emitEvent(pool, classroomSessionId, actorType, actorId, eventType, metadata) {
|
||||
await pool.query(
|
||||
`INSERT INTO classroom_events (classroom_session_id, actor_type, actor_id, event_type, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[classroomSessionId, actorType, actorId ? String(actorId) : null, eventType, metadata ? JSON.stringify(metadata) : null]
|
||||
);
|
||||
}
|
||||
|
||||
async function createClassroom(pool, { cohortId, title, description, scheduledStart, scheduledEnd, meetingUrl, createdBy }) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO classroom_sessions (cohort_id, title, description, scheduled_start, scheduled_end, meeting_url, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
[cohortId, title, description || null, scheduledStart || null, scheduledEnd || null, meetingUrl || null, createdBy]
|
||||
);
|
||||
const classroom = rows[0];
|
||||
await emitEvent(pool, classroom.classroom_session_id, 'instructor', createdBy, 'created', { title });
|
||||
return classroom;
|
||||
}
|
||||
|
||||
async function getClassroom(pool, classroomSessionId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM classroom_sessions WHERE classroom_session_id = $1`, [classroomSessionId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function listClassroomsForCohort(pool, cohortId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM classroom_sessions WHERE cohort_id = $1 ORDER BY COALESCE(scheduled_start, created_at) DESC`,
|
||||
[cohortId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// §13 lifecycle: scheduled -> live -> ended, or -> cancelled from scheduled/live. The DB's
|
||||
// partial unique index (idx_classroom_sessions_one_live_per_cohort) is the actual enforcement
|
||||
// for "at most one live classroom per cohort" — this pre-check just gives a clean error
|
||||
// instead of a raw constraint-violation message.
|
||||
async function startClassroom(pool, classroomSessionId, instructorId) {
|
||||
const classroom = await getClassroom(pool, classroomSessionId);
|
||||
if (!classroom) throw Object.assign(new Error('classroom not found'), { code: 'NOT_FOUND' });
|
||||
if (classroom.status !== 'scheduled') throw Object.assign(new Error(`cannot start a classroom in status '${classroom.status}'`), { code: 'INVALID_STATE' });
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE classroom_sessions SET status = 'live', started_at = now(), updated_at = now() WHERE classroom_session_id = $1 RETURNING *`,
|
||||
[classroomSessionId]
|
||||
);
|
||||
await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'started', {});
|
||||
return rows[0];
|
||||
} catch (e) {
|
||||
if (e.code === '23505') throw Object.assign(new Error('this cohort already has a live classroom'), { code: 'ALREADY_LIVE' });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Ending a classroom is purely a status change — never touches Labs, progress, attempts, or
|
||||
// any academic data (§13/§26). Labs are only ever destroyed by an explicit, separate action
|
||||
// (the existing cohort Destroy Labs / Prepare Labs machinery), never implicitly by this.
|
||||
async function endClassroom(pool, classroomSessionId, instructorId) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE classroom_sessions SET status = 'ended', ended_at = now(), updated_at = now()
|
||||
WHERE classroom_session_id = $1 AND status = 'live' RETURNING *`,
|
||||
[classroomSessionId]
|
||||
);
|
||||
if (!rows.length) throw Object.assign(new Error('classroom not found or not live'), { code: 'INVALID_STATE' });
|
||||
await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'ended', {});
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async function cancelClassroom(pool, classroomSessionId, instructorId) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE classroom_sessions SET status = 'cancelled', updated_at = now()
|
||||
WHERE classroom_session_id = $1 AND status IN ('scheduled', 'live') RETURNING *`,
|
||||
[classroomSessionId]
|
||||
);
|
||||
if (!rows.length) throw Object.assign(new Error('classroom not found or already ended/cancelled'), { code: 'INVALID_STATE' });
|
||||
await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'cancelled', {});
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// §20 — guidance only. Never redirects a browser, never marks anything complete; students just
|
||||
// see this value and choose to open that scenario themselves (see classroom-routes.js).
|
||||
async function setCurrentActivity(pool, classroomSessionId, scenarioId, instructorId) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE classroom_sessions SET current_scenario_id = $1, updated_at = now() WHERE classroom_session_id = $2 RETURNING *`,
|
||||
[scenarioId || null, classroomSessionId]
|
||||
);
|
||||
if (!rows.length) throw Object.assign(new Error('classroom not found'), { code: 'NOT_FOUND' });
|
||||
await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'activity_changed', { scenario_id: scenarioId });
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async function postAnnouncement(pool, classroomSessionId, instructorId, message) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO classroom_announcements (classroom_session_id, instructor_id, message) VALUES ($1, $2, $3) RETURNING *`,
|
||||
[classroomSessionId, instructorId, message]
|
||||
);
|
||||
await emitEvent(pool, classroomSessionId, 'instructor', instructorId, 'announcement_posted', { announcement_id: rows[0].announcement_id });
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async function listAnnouncements(pool, classroomSessionId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT announcement_id, message, created_at FROM classroom_announcements
|
||||
WHERE classroom_session_id = $1 ORDER BY created_at DESC LIMIT 50`,
|
||||
[classroomSessionId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// §11/§22 — join is idempotent: re-joining an already-joined classroom just clears left_at and
|
||||
// bumps last_seen_at, it doesn't create a second row or duplicate joined_at (PRIMARY KEY is
|
||||
// (classroom_session_id, student_id) — there can only ever be one participation row per
|
||||
// student per classroom, matching how a student can leave and rejoin the same live session).
|
||||
async function joinClassroom(pool, classroomSessionId, studentId) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO classroom_participants (classroom_session_id, student_id, last_seen_at)
|
||||
VALUES ($1, $2, now())
|
||||
ON CONFLICT (classroom_session_id, student_id)
|
||||
DO UPDATE SET last_seen_at = now(), left_at = NULL
|
||||
RETURNING *`,
|
||||
[classroomSessionId, studentId]
|
||||
);
|
||||
await emitEvent(pool, classroomSessionId, 'student', studentId, 'joined', {});
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async function leaveClassroom(pool, classroomSessionId, studentId) {
|
||||
await pool.query(
|
||||
`UPDATE classroom_participants SET left_at = now() WHERE classroom_session_id = $1 AND student_id = $2`,
|
||||
[classroomSessionId, studentId]
|
||||
);
|
||||
await emitEvent(pool, classroomSessionId, 'student', studentId, 'left', {});
|
||||
}
|
||||
|
||||
// §15 presence — throttled: a heartbeat within HEARTBEAT_WRITE_THROTTLE_MS of the last recorded
|
||||
// one is a silent no-op (still returns success to the client, just skips the write) rather than
|
||||
// hammering Postgres every 20s per online student. Deliberately NOT a full audit-logged event
|
||||
// (§25 "do not turn presence heartbeats into audit spam") — no classroom_events row per beat.
|
||||
async function heartbeat(pool, classroomSessionId, studentId) {
|
||||
await pool.query(
|
||||
`UPDATE classroom_participants SET last_seen_at = now()
|
||||
WHERE classroom_session_id = $1 AND student_id = $2
|
||||
AND (last_seen_at IS NULL OR last_seen_at < now() - ($3 || ' milliseconds')::interval)`,
|
||||
[classroomSessionId, studentId, HEARTBEAT_WRITE_THROTTLE_MS]
|
||||
);
|
||||
}
|
||||
|
||||
function derivePresence(lastSeenAt, leftAt) {
|
||||
if (leftAt) return 'offline';
|
||||
if (!lastSeenAt) return 'offline';
|
||||
const age = Date.now() - new Date(lastSeenAt).getTime();
|
||||
if (age <= PRESENCE_ONLINE_MS) return 'online';
|
||||
if (age <= PRESENCE_RECENTLY_ACTIVE_MS) return 'recently_active';
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
async function listParticipants(pool, classroomSessionId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT cp.student_id, s.display_name, cp.joined_at, cp.last_seen_at, cp.left_at
|
||||
FROM classroom_participants cp JOIN students s ON s.student_id = cp.student_id
|
||||
WHERE cp.classroom_session_id = $1 ORDER BY s.display_name`,
|
||||
[classroomSessionId]
|
||||
);
|
||||
return rows.map((r) => ({ ...r, presence: derivePresence(r.last_seen_at, r.left_at) }));
|
||||
}
|
||||
|
||||
// §27 — a student may be enrolled in more than one cohort eventually (self-paced compatible);
|
||||
// this returns the FIRST live classroom across any cohort they're enrolled in, not "their one
|
||||
// cohort's classroom" — matches "My Courses, not My Cohort" (§27) as the eventual shape.
|
||||
async function getLiveClassroomForStudent(pool, studentId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT cs.* FROM classroom_sessions cs
|
||||
JOIN cohort_enrollments e ON e.cohort_id = cs.cohort_id
|
||||
WHERE e.student_id = $1 AND e.enrollment_status = 'enrolled' AND cs.status = 'live'
|
||||
ORDER BY cs.started_at DESC LIMIT 1`,
|
||||
[studentId]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// §18 help requests
|
||||
async function createHelpRequest(pool, classroomSessionId, studentId, category, message) {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO help_requests (classroom_session_id, student_id, category, message) VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[classroomSessionId, studentId, category || null, message || null]
|
||||
);
|
||||
await emitEvent(pool, classroomSessionId, 'student', studentId, 'help_requested', { help_request_id: rows[0].help_request_id, category });
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async function listHelpRequests(pool, classroomSessionId, statusFilter) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT hr.*, s.display_name FROM help_requests hr JOIN students s ON s.student_id = hr.student_id
|
||||
WHERE hr.classroom_session_id = $1 AND ($2::text IS NULL OR hr.status = $2)
|
||||
ORDER BY hr.created_at ASC`,
|
||||
[classroomSessionId, statusFilter || null]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function acknowledgeHelpRequest(pool, helpRequestId, instructorId) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE help_requests SET status = 'acknowledged', acknowledged_at = now(), acknowledged_by = $1
|
||||
WHERE help_request_id = $2 AND status = 'open' RETURNING *`,
|
||||
[instructorId, helpRequestId]
|
||||
);
|
||||
if (!rows.length) throw Object.assign(new Error('help request not found or not open'), { code: 'INVALID_STATE' });
|
||||
await emitEvent(pool, rows[0].classroom_session_id, 'instructor', instructorId, 'help_acknowledged', { help_request_id: helpRequestId });
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async function resolveHelpRequest(pool, helpRequestId, instructorId, note) {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE help_requests SET status = 'resolved', resolved_at = now(), instructor_note = COALESCE($1, instructor_note)
|
||||
WHERE help_request_id = $2 AND status IN ('open', 'acknowledged') RETURNING *`,
|
||||
[note || null, helpRequestId]
|
||||
);
|
||||
if (!rows.length) throw Object.assign(new Error('help request not found or already resolved'), { code: 'INVALID_STATE' });
|
||||
await emitEvent(pool, rows[0].classroom_session_id, 'instructor', instructorId, 'help_resolved', { help_request_id: helpRequestId });
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createClassroom, getClassroom, listClassroomsForCohort,
|
||||
startClassroom, endClassroom, cancelClassroom, setCurrentActivity,
|
||||
postAnnouncement, listAnnouncements,
|
||||
joinClassroom, leaveClassroom, heartbeat, derivePresence, listParticipants,
|
||||
getLiveClassroomForStudent,
|
||||
createHelpRequest, listHelpRequests, acknowledgeHelpRequest, resolveHelpRequest,
|
||||
emitEvent,
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
// Prometheus text-exposition metrics — Phase 5A §34. Hand-rolled, not prom-client: the metric
|
||||
// set here is small (a handful of counters/gauges), and the exposition format is trivial text
|
||||
// (`# HELP`, `# TYPE`, `name value`) — not worth a new dependency for this (ponytail:
|
||||
// stdlib/hand-rolled first). Deliberately NO student/instructor id (or any other
|
||||
// high-cardinality value) ever appears as a label — every metric here is an aggregate count,
|
||||
// matching the explicit "avoid high-cardinality student IDs... do not expose PII" requirement.
|
||||
//
|
||||
// Counters (login success/failure) are real in-process Prometheus counters — incremented at
|
||||
// the exact point the event happens (student-auth-routes.js), not derived from a windowed SQL
|
||||
// COUNT(*), which would behave like a gauge with a rolling window rather than a real counter.
|
||||
// They reset on process restart, which is the normal, expected way Prometheus counters behave
|
||||
// (rate()/increase() handle a counter reset transparently) — no persistence needed.
|
||||
|
||||
let studentLoginSuccessTotal = 0;
|
||||
let studentLoginFailureTotal = 0;
|
||||
|
||||
function recordStudentLoginSuccess() { studentLoginSuccessTotal++; }
|
||||
function recordStudentLoginFailure() { studentLoginFailureTotal++; }
|
||||
|
||||
// Gauges are computed fresh at scrape time from Postgres/the Provisioner — cheap enough at a
|
||||
// normal 15-30s Prometheus scrape interval, and always exactly correct (no separate value to
|
||||
// keep in sync with reality).
|
||||
async function collectGauges(pool, provisioner) {
|
||||
const [{ rows: liveClassrooms }, { rows: helpOpen }, envRes] = await Promise.all([
|
||||
pool.query(`SELECT classroom_session_id FROM classroom_sessions WHERE status = 'live'`),
|
||||
pool.query(`SELECT count(*)::int AS n FROM help_requests WHERE status = 'open'`),
|
||||
// Reuses the exact same environments-by-status query the admin dashboard already runs
|
||||
// (admin-routes.js's /api/admin/dashboard) — one source of truth for Lab state counts.
|
||||
pool.query(`SELECT status, count(*)::int AS n FROM environments GROUP BY status`).catch(() => ({ rows: [] })),
|
||||
]);
|
||||
|
||||
let onlineParticipants = 0;
|
||||
if (liveClassrooms.length) {
|
||||
const classroom = require('./classroom');
|
||||
for (const c of liveClassrooms) {
|
||||
const participants = await classroom.listParticipants(pool, c.classroom_session_id);
|
||||
onlineParticipants += participants.filter((p) => p.presence === 'online').length;
|
||||
}
|
||||
}
|
||||
|
||||
const envCounts = Object.fromEntries(envRes.rows.map((r) => [r.status, r.n]));
|
||||
return {
|
||||
classroom_sessions_live: liveClassrooms.length,
|
||||
classroom_participants_online: onlineParticipants,
|
||||
help_requests_open: helpOpen[0].n,
|
||||
labs_running: envCounts.running || 0,
|
||||
labs_provisioning: (envCounts.requested || 0) + (envCounts.provisioning || 0) + (envCounts.resetting || 0),
|
||||
labs_error: envCounts.error || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function line(name, help, type, value) {
|
||||
return `# HELP ${name} ${help}\n# TYPE ${name} ${type}\n${name} ${value}\n`;
|
||||
}
|
||||
|
||||
async function renderMetrics(pool, provisioner) {
|
||||
const gauges = await collectGauges(pool, provisioner);
|
||||
return [
|
||||
line('rootnode_classroom_sessions_live', 'Number of classroom sessions currently live.', 'gauge', gauges.classroom_sessions_live),
|
||||
line('rootnode_classroom_participants_online', 'Number of classroom participants currently online across all live sessions.', 'gauge', gauges.classroom_participants_online),
|
||||
line('rootnode_help_requests_open', 'Number of open (not yet acknowledged/resolved) help requests.', 'gauge', gauges.help_requests_open),
|
||||
line('rootnode_labs_running', 'Number of student Lab environments currently running.', 'gauge', gauges.labs_running),
|
||||
line('rootnode_labs_provisioning', 'Number of student Lab environments currently being created/reset.', 'gauge', gauges.labs_provisioning),
|
||||
line('rootnode_labs_error', 'Number of student Lab environments in an error state.', 'gauge', gauges.labs_error),
|
||||
line('rootnode_student_login_success_total', 'Total successful student logins since process start.', 'counter', studentLoginSuccessTotal),
|
||||
line('rootnode_student_login_failure_total', 'Total failed student login attempts since process start.', 'counter', studentLoginFailureTotal),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
module.exports = { recordStudentLoginSuccess, recordStudentLoginFailure, renderMetrics };
|
||||
@@ -0,0 +1,27 @@
|
||||
// Shared "Prepare Labs for a cohort's enrolled students" logic — used by both the existing
|
||||
// cohort-level admin route and the Phase 5A classroom-scoped one (a classroom's Prepare Labs is
|
||||
// exactly this, just entered from the classroom dashboard instead of the cohort page — see
|
||||
// docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §21). Extracted so there's one place this
|
||||
// logic lives, not two copies that could drift.
|
||||
|
||||
async function prepareLabsForCohort(pool, provisioner, cohortId) {
|
||||
const { rows: students } = await pool.query(
|
||||
`SELECT s.student_id, s.display_name FROM cohort_enrollments e JOIN students s ON s.student_id = e.student_id
|
||||
WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled' AND s.status = 'active'`,
|
||||
[cohortId]
|
||||
);
|
||||
const results = await Promise.allSettled(students.map(async (s) => {
|
||||
const existing = await provisioner.listForStudent(s.student_id);
|
||||
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
|
||||
if (active) return { student_id: s.student_id, display_name: s.display_name, status: active.status };
|
||||
const { status, body } = await provisioner.provision(s.student_id);
|
||||
if (status === 503 && body?.code === 'CAPACITY_EXCEEDED') {
|
||||
return { student_id: s.student_id, display_name: s.display_name, status: 'capacity_waiting' };
|
||||
}
|
||||
if (status !== 201) return { student_id: s.student_id, display_name: s.display_name, status: 'error', error: body?.error };
|
||||
return { student_id: s.student_id, display_name: s.display_name, status: body.status };
|
||||
}));
|
||||
return results.map((r, i) => r.status === 'fulfilled' ? r.value : { student_id: students[i].student_id, display_name: students[i].display_name, status: 'error', error: r.reason?.message });
|
||||
}
|
||||
|
||||
module.exports = { prepareLabsForCohort };
|
||||
+66
-16
@@ -30,15 +30,12 @@ module.exports = function createRbac(pool) {
|
||||
const requireAdminOrOwner = requireRole('owner', 'admin');
|
||||
const requireAnyInstructor = requireRole('owner', 'admin', 'instructor');
|
||||
|
||||
// Cohort-scoped access: owner/admin bypass (global teaching visibility by role alone). A
|
||||
// plain 'instructor' must have a cohort_instructors row for this cohort_id, or gets 403 —
|
||||
// same as any other authorization failure, no distinction made between "not your cohort" and
|
||||
// "cohort doesn't exist" in the response.
|
||||
function requireCohortAccess(cohortIdParam = 'id') {
|
||||
return async (req, res, next) => {
|
||||
// Core check, factored out so both the cohort_id-param routes below AND the
|
||||
// classroom_session_id-keyed routes (Phase 5A §24, further down) can share one rule instead
|
||||
// of two copies of the same query drifting apart over time.
|
||||
async function checkCohortAccess(req, res, cohortId) {
|
||||
const instructor = req.instructor;
|
||||
if (['owner', 'admin'].includes(instructor.role)) return next();
|
||||
const cohortId = req.params[cohortIdParam];
|
||||
if (['owner', 'admin'].includes(instructor.role)) return true;
|
||||
const { rowCount } = await pool.query(
|
||||
`SELECT 1 FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`,
|
||||
[cohortId, instructor.instructor_id]
|
||||
@@ -48,9 +45,19 @@ module.exports = function createRbac(pool) {
|
||||
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
|
||||
resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip,
|
||||
});
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
res.status(403).json({ error: 'forbidden' });
|
||||
return false;
|
||||
}
|
||||
next();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cohort-scoped access: owner/admin bypass (global teaching visibility by role alone). A
|
||||
// plain 'instructor' must have a cohort_instructors row for this cohort_id, or gets 403 —
|
||||
// same as any other authorization failure, no distinction made between "not your cohort" and
|
||||
// "cohort doesn't exist" in the response.
|
||||
function requireCohortAccess(cohortIdParam = 'id') {
|
||||
return async (req, res, next) => {
|
||||
if (await checkCohortAccess(req, res, req.params[cohortIdParam])) next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,11 +91,9 @@ module.exports = function createRbac(pool) {
|
||||
// vs 'viewer' (read-only — sees dashboards/progress/analytics via requireCohortAccess above,
|
||||
// but cannot Prepare/Destroy Labs or provision a student's environment). owner/admin still
|
||||
// bypass entirely, same as every other cohort-scoped check.
|
||||
function requireCohortWriteAccess(cohortIdParam = 'id') {
|
||||
return async (req, res, next) => {
|
||||
async function checkCohortWriteAccess(req, res, cohortId) {
|
||||
const instructor = req.instructor;
|
||||
if (['owner', 'admin'].includes(instructor.role)) return next();
|
||||
const cohortId = req.params[cohortIdParam];
|
||||
if (['owner', 'admin'].includes(instructor.role)) return true;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT role_in_cohort FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`,
|
||||
[cohortId, instructor.instructor_id]
|
||||
@@ -99,9 +104,14 @@ module.exports = function createRbac(pool) {
|
||||
resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip,
|
||||
metadata: rows.length ? { reason: 'viewer_role' } : undefined,
|
||||
});
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
res.status(403).json({ error: 'forbidden' });
|
||||
return false;
|
||||
}
|
||||
next();
|
||||
return true;
|
||||
}
|
||||
function requireCohortWriteAccess(cohortIdParam = 'id') {
|
||||
return async (req, res, next) => {
|
||||
if (await checkCohortWriteAccess(req, res, req.params[cohortIdParam])) next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,9 +140,49 @@ module.exports = function createRbac(pool) {
|
||||
};
|
||||
}
|
||||
|
||||
// Phase 5A §24 — classroom routes are keyed by classroom_session_id, not cohort_id directly,
|
||||
// so this resolves the classroom's cohort first (404 if it doesn't exist — a route param
|
||||
// isn't proof the resource exists) and then applies the exact same rule
|
||||
// requireCohortAccess/requireCohortWriteAccess do. Stashes the resolved cohort_id on the
|
||||
// request (req.classroomCohortId) so handlers that need it don't re-query.
|
||||
function requireClassroomAccess(classroomIdParam = 'id') {
|
||||
return async (req, res, next) => {
|
||||
const { rows } = await pool.query(`SELECT cohort_id FROM classroom_sessions WHERE classroom_session_id = $1`, [req.params[classroomIdParam]]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
req.classroomCohortId = rows[0].cohort_id;
|
||||
if (await checkCohortAccess(req, res, rows[0].cohort_id)) next();
|
||||
};
|
||||
}
|
||||
function requireClassroomWriteAccess(classroomIdParam = 'id') {
|
||||
return async (req, res, next) => {
|
||||
const { rows } = await pool.query(`SELECT cohort_id FROM classroom_sessions WHERE classroom_session_id = $1`, [req.params[classroomIdParam]]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
req.classroomCohortId = rows[0].cohort_id;
|
||||
if (await checkCohortWriteAccess(req, res, rows[0].cohort_id)) next();
|
||||
};
|
||||
}
|
||||
|
||||
// Same idea, one join further — help_requests routes are keyed by help_request_id, resolved
|
||||
// through classroom_sessions to the cohort. Used by the acknowledge/resolve routes.
|
||||
function requireHelpRequestWriteAccess(helpRequestIdParam = 'id') {
|
||||
return async (req, res, next) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT cs.cohort_id, hr.classroom_session_id FROM help_requests hr
|
||||
JOIN classroom_sessions cs ON cs.classroom_session_id = hr.classroom_session_id
|
||||
WHERE hr.help_request_id = $1`,
|
||||
[req.params[helpRequestIdParam]]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
req.classroomCohortId = rows[0].cohort_id;
|
||||
req.classroomSessionId = rows[0].classroom_session_id;
|
||||
if (await checkCohortWriteAccess(req, res, rows[0].cohort_id)) next();
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
requireRole, requireOwner, requireAdminOrOwner, requireAnyInstructor,
|
||||
requireCohortAccess, requireStudentCohortAccess,
|
||||
requireCohortWriteAccess, requireStudentCohortWriteAccess,
|
||||
requireClassroomAccess, requireClassroomWriteAccess, requireHelpRequestWriteAccess,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -43,10 +43,20 @@ app.use(express.static(FRONTEND_DIST));
|
||||
// student_id is derived ONLY from an opaque, server-issued session token (now Postgres-backed,
|
||||
// not the in-memory Map from Phase 2B) — never from a header or query parameter a client
|
||||
// controls. CSRF is checked here too, for every state-changing student request.
|
||||
// Phase 5A: now re-checks the student is still 'active' on every request, matching
|
||||
// instructorSession's existing pattern below — a deactivation takes effect on the very next
|
||||
// request instead of waiting out the session's natural expiry. Previously this middleware only
|
||||
// checked the session itself existed, which was fine when every student was pilot/trusted-roster
|
||||
// but is a real gap now that real credentialed accounts (and a "deactivate" admin control) exist.
|
||||
async function studentSession(req, res, next) {
|
||||
const token = parseCookies(req)[STUDENT_COOKIE];
|
||||
const studentId = await sessions.resolveStudentSession(getPool(), token);
|
||||
if (!studentId) return res.status(401).json({ error: 'no_session', message: 'Please log in.' });
|
||||
const { rows } = await getPool().query(`SELECT status FROM students WHERE student_id = $1`, [studentId]);
|
||||
if (!rows.length || rows[0].status !== 'active') {
|
||||
await sessions.destroyStudentSession(getPool(), token); // deactivated since the session was issued
|
||||
return res.status(401).json({ error: 'no_session', message: 'Please log in.' });
|
||||
}
|
||||
if (!checkCsrf(req, STUDENT_CSRF_COOKIE)) return res.status(403).json({ error: 'csrf' });
|
||||
req.studentId = studentId;
|
||||
req.sessionToken = token;
|
||||
@@ -88,6 +98,19 @@ app.get('/healthz', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /metrics — Phase 5A §34. Unauthenticated like /healthz (Prometheus scrapers don't carry
|
||||
// credentials here, matching every other exporter in infra/monitoring/) but restricted at the
|
||||
// firewall to the Platform VM's own Prometheus (infra/firewall/rn-platform-01.rules already
|
||||
// only allows loopback/rn-platform-internal for this port — this endpoint adds no new public
|
||||
// exposure). No student/instructor PII, no high-cardinality labels — see metrics.js.
|
||||
app.get('/metrics', async (req, res) => {
|
||||
try {
|
||||
res.type('text/plain').send(await require('./metrics').renderMetrics(getPool(), provisioner));
|
||||
} catch (e) {
|
||||
res.status(503).type('text/plain').send('# metrics collection failed\n');
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/version — Phase 4.1 §25. Unauthenticated like /healthz, and deliberately just as
|
||||
// minimal: an app version, the deployed Git SHA (or 'dev' locally — see
|
||||
// infra/docker/platform/docker-compose.production.yml's GIT_SHA env var, set to the same
|
||||
@@ -140,11 +163,26 @@ app.get('/api/roster', async (req, res) => {
|
||||
// can't be forged/switched by editing client state, and is checked on every Lab request. See
|
||||
// docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §5 for the future path (one-time codes,
|
||||
// institutional SSO) that upgrades identification without touching this session system.
|
||||
// Phase 5A §7: pilot-only path now — once an instructor converts a student to a real,
|
||||
// credentialed account (password_hash set), this route refuses them and points at the real
|
||||
// login, so a converted account can never be accessed by just knowing its bare student_id.
|
||||
// Unconverted pilot rows (password_hash IS NULL) are untouched by Phase 5A and keep working
|
||||
// exactly as before — no pilot data or access is lost.
|
||||
app.post('/api/login', async (req, res) => {
|
||||
const { student_id } = req.body || {};
|
||||
const { rows } = await getPool().query(`SELECT student_id, display_name, status FROM students WHERE student_id = $1`, [student_id]);
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT student_id, display_name, status, password_hash, credential_token_hash FROM students WHERE student_id = $1`,
|
||||
[student_id]
|
||||
);
|
||||
const student = rows[0];
|
||||
if (!student || student.status !== 'active') return res.status(404).json({ error: 'Student not found' });
|
||||
// Blocks on credential_token_hash too, not just password_hash — a student mid-conversion
|
||||
// (instructor has issued an activation token but the student hasn't set a password yet) must
|
||||
// not be reachable via the old insecure bare-id path either, or conversion doesn't actually
|
||||
// close it. Found by testing (infra/scripts/drills/test-phase5a-rehearsal.js).
|
||||
if (student.password_hash || student.credential_token_hash) {
|
||||
return res.status(409).json({ error: 'account_converted', message: 'This account now requires a real login — use POST /api/student/login.' });
|
||||
}
|
||||
await touchStudent(getPool(), student.student_id);
|
||||
const token = await sessions.createStudentSession(getPool(), student.student_id);
|
||||
const csrf = makeCsrfToken();
|
||||
@@ -329,6 +367,15 @@ app.get('/api/environment/by-token/:token', async (req, res) => {
|
||||
|
||||
require('./instructor-auth-routes')(app, { pool: getPool(), sessions, instructorSession: wrap(instructorSession) });
|
||||
|
||||
// Phase 5A — the new credentialed student login path. COOKIE_SECURE=1 is the existing signal
|
||||
// for "this is a real deployed environment" (see cookies.js) — reused here rather than adding
|
||||
// a second env var, to decide whether /api/student/request-password-reset may echo the reset
|
||||
// token back for local/test use (never in a real deployment).
|
||||
require('./student-auth-routes')(app, {
|
||||
pool: getPool(), sessions, studentSession, curriculum, loadProgress,
|
||||
isProduction: process.env.COOKIE_SECURE === '1',
|
||||
});
|
||||
|
||||
require('./learning-routes')(app, { studentSession, getPool, curriculum, loadProgress, wrap });
|
||||
|
||||
const rbac = createRbac(getPool());
|
||||
@@ -340,6 +387,16 @@ require('./admin-learning-routes')(app, {
|
||||
instructorSession: wrap(instructorSession), rbac, curriculum, getPool, loadProgress, recordAudit,
|
||||
});
|
||||
|
||||
// Phase 5A §11-§20 — live classroom sessions. classroom-routes.js is the student-facing half
|
||||
// (join/heartbeat/announcements/help-request); admin-classroom-routes.js is the instructor
|
||||
// dashboard/lifecycle half. Both sit alongside, not inside, the existing learning-routes.js/
|
||||
// admin-learning-routes.js — a classroom session is a distinct concept from curriculum
|
||||
// progress, even though its dashboard reads curriculum/progress data.
|
||||
require('./classroom-routes')(app, { studentSession, getPool, curriculum });
|
||||
require('./admin-classroom-routes')(app, {
|
||||
instructorSession: wrap(instructorSession), rbac, provisioner, curriculum, getPool, loadProgress, recordAudit,
|
||||
});
|
||||
|
||||
// Fallback to the platform-shell SPA for any unmatched non-API route
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(FRONTEND_DIST, 'index.html'));
|
||||
|
||||
@@ -71,6 +71,27 @@ async function resolveStudentSession(pool, token) {
|
||||
}
|
||||
const destroyStudentSession = revokeSession;
|
||||
|
||||
// Phase 5A §6/§10 — mirrors revokeAllInstructorSessions/listInstructorSessions exactly. Used
|
||||
// by admin-triggered "revoke student sessions", and automatically after a student password
|
||||
// change/reset (except the session that just made the change, if any).
|
||||
async function revokeAllStudentSessions(pool, studentId, exceptToken) {
|
||||
const exceptHash = exceptToken ? hashToken(exceptToken) : null;
|
||||
await pool.query(
|
||||
`UPDATE sessions SET revoked_at = now()
|
||||
WHERE student_id = $1 AND revoked_at IS NULL AND ($2::text IS NULL OR token_hash != $2)`,
|
||||
[studentId, exceptHash]
|
||||
);
|
||||
}
|
||||
|
||||
async function listStudentSessions(pool, studentId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT session_id, created_at, last_seen_at, expires_at, revoked_at
|
||||
FROM sessions WHERE student_id = $1 ORDER BY created_at DESC LIMIT 20`,
|
||||
[studentId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ── Instructor sessions ──────────────────────────────────────────────────────────────────
|
||||
function createInstructorSession(pool, instructorId) {
|
||||
return createSession(pool, { type: 'instructor', instructorId, ttlMs: INSTRUCTOR_TTL_MS, idleTtlMs: INSTRUCTOR_IDLE_TTL_MS });
|
||||
@@ -131,6 +152,7 @@ function startSessionSweeper(pool) {
|
||||
|
||||
module.exports = {
|
||||
createStudentSession, resolveStudentSession, destroyStudentSession,
|
||||
revokeAllStudentSessions, listStudentSessions,
|
||||
createInstructorSession, resolveInstructorSession, destroyInstructorSession,
|
||||
revokeAllInstructorSessions, listInstructorSessions, startSessionSweeper,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// POST /api/student/login, /activate, /change-password, /request-password-reset,
|
||||
// /reset-password, GET /api/student/profile — Phase 5A §2/§5/§6/§9/§10. Logout reuses the
|
||||
// existing POST /api/logout (session-mechanism-agnostic already, no change needed there).
|
||||
//
|
||||
// A pilot student (password_hash IS NULL) keeps using the original POST /api/login
|
||||
// (bare student_id, unchanged) until an instructor converts them — see
|
||||
// docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §7. This file is exclusively the NEW
|
||||
// credentialed path.
|
||||
|
||||
const {
|
||||
verifyPassword, hashPassword, publicStudent, getStudentByIdentifier, getActiveStudent,
|
||||
issueCredentialToken, findStudentByCredentialToken, consumeCredentialToken,
|
||||
} = require('./students-auth');
|
||||
const { recordAudit } = require('./audit');
|
||||
const { makeCsrfToken, tooManyAttempts, parseCookies, checkCsrf } = require('./security');
|
||||
const { buildCookie, clearCookie } = require('./cookies');
|
||||
const { recordStudentLoginSuccess, recordStudentLoginFailure } = require('./metrics'); // Phase 5A §34
|
||||
|
||||
const STUDENT_COOKIE = 'rn_session';
|
||||
const STUDENT_CSRF_COOKIE = 'rn_csrf_s';
|
||||
const LOGIN_MAX_ATTEMPTS = 8;
|
||||
const LOGIN_WINDOW_MS = 5 * 60 * 1000;
|
||||
const SESSION_MAX_AGE_SEC = 8 * 3600;
|
||||
|
||||
module.exports = function registerStudentAuthRoutes(app, { pool, sessions, studentSession, curriculum, loadProgress, isProduction }) {
|
||||
app.post('/api/student/login', async (req, res) => {
|
||||
const { identifier, password } = req.body || {};
|
||||
const rateKey = `student:${req.ip}:${String(identifier || '').toLowerCase()}`;
|
||||
if (await tooManyAttempts(pool, rateKey, LOGIN_MAX_ATTEMPTS, LOGIN_WINDOW_MS)) {
|
||||
return res.status(429).json({ error: 'too_many_attempts', message: 'Too many login attempts. Try again in a few minutes.' });
|
||||
}
|
||||
if (!identifier || !password) return res.status(400).json({ error: 'identifier and password are required' });
|
||||
|
||||
const student = await getStudentByIdentifier(pool, identifier);
|
||||
// Same generic response whether the identifier doesn't exist, has no credentials yet
|
||||
// (pilot/unconverted), or the password is wrong — no account enumeration either way.
|
||||
const passwordOk = student && student.password_hash && verifyPassword(password, student.password_hash);
|
||||
if (!passwordOk) {
|
||||
recordStudentLoginFailure();
|
||||
await recordAudit(pool, {
|
||||
actorType: 'student', actorId: student?.student_id, action: 'login', result: 'failure', sourceIp: req.ip,
|
||||
});
|
||||
return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid login or password.' });
|
||||
}
|
||||
if (student.status !== 'active') {
|
||||
recordStudentLoginFailure();
|
||||
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'login', result: 'failure', sourceIp: req.ip, metadata: { reason: 'inactive' } });
|
||||
return res.status(403).json({ error: 'account_inactive', message: 'This account has been deactivated.' });
|
||||
}
|
||||
|
||||
const token = await sessions.createStudentSession(pool, student.student_id);
|
||||
const csrf = makeCsrfToken();
|
||||
await pool.query(`UPDATE students SET last_login_at = now() WHERE student_id = $1`, [student.student_id]);
|
||||
recordStudentLoginSuccess();
|
||||
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'login', result: 'success', sourceIp: req.ip });
|
||||
|
||||
res.append('Set-Cookie', buildCookie(STUDENT_COOKIE, token, { maxAgeSec: SESSION_MAX_AGE_SEC }));
|
||||
res.append('Set-Cookie', buildCookie(STUDENT_CSRF_COOKIE, csrf, { maxAgeSec: SESSION_MAX_AGE_SEC, httpOnly: false }));
|
||||
res.json({ student: publicStudent({ ...student, last_login_at: new Date() }) });
|
||||
});
|
||||
|
||||
// Consumes an activation token issued by GET/POST /api/admin/students (creation) or
|
||||
// /api/admin/students/:id/convert — sets the student's first password and logs them straight
|
||||
// in, same as any other login (§5's "temporary credential becomes invalid" is the token being
|
||||
// single-use — consumeCredentialToken clears it atomically with setting the password).
|
||||
app.post('/api/student/activate', async (req, res) => {
|
||||
const { token, password } = req.body || {};
|
||||
if (!token || !password || password.length < 10) {
|
||||
return res.status(400).json({ error: 'invalid_request', message: 'A valid token and a password of at least 10 characters are required.' });
|
||||
}
|
||||
const student = await consumeCredentialToken(pool, token, 'activation', password);
|
||||
if (!student) return res.status(400).json({ error: 'invalid_or_expired_token', message: 'This activation link is invalid or has expired. Ask your instructor for a new one.' });
|
||||
|
||||
const sessionToken = await sessions.createStudentSession(pool, student.student_id);
|
||||
const csrf = makeCsrfToken();
|
||||
await pool.query(`UPDATE students SET last_login_at = now() WHERE student_id = $1`, [student.student_id]);
|
||||
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'account_activated', result: 'success', sourceIp: req.ip });
|
||||
|
||||
res.append('Set-Cookie', buildCookie(STUDENT_COOKIE, sessionToken, { maxAgeSec: SESSION_MAX_AGE_SEC }));
|
||||
res.append('Set-Cookie', buildCookie(STUDENT_CSRF_COOKIE, csrf, { maxAgeSec: SESSION_MAX_AGE_SEC, httpOnly: false }));
|
||||
res.json({ student: publicStudent(student) });
|
||||
});
|
||||
|
||||
// Lets a not-yet-logged-in student preview whose activation/reset link this is before they
|
||||
// submit a new password (e.g. "Activating account for Alice") — never reveals anything if
|
||||
// the token is invalid/expired/wrong-purpose, same shape either way.
|
||||
app.get('/api/student/credential-token/:purpose', async (req, res) => {
|
||||
const { purpose } = req.params;
|
||||
if (!['activation', 'reset'].includes(purpose)) return res.status(400).json({ error: 'invalid_purpose' });
|
||||
const student = await findStudentByCredentialToken(pool, req.query.token, purpose);
|
||||
if (!student) return res.status(404).json({ error: 'invalid_or_expired_token' });
|
||||
res.json({ display_name: student.display_name });
|
||||
});
|
||||
|
||||
app.post('/api/student/change-password', studentSession, async (req, res) => {
|
||||
const { old_password, new_password } = req.body || {};
|
||||
if (!old_password || !new_password || new_password.length < 10) {
|
||||
return res.status(400).json({ error: 'invalid_request', message: 'new_password must be at least 10 characters' });
|
||||
}
|
||||
const student = await getActiveStudent(pool, req.studentId);
|
||||
if (!student || !student.password_hash || !verifyPassword(old_password, student.password_hash)) {
|
||||
await recordAudit(pool, { actorType: 'student', actorId: req.studentId, action: 'password_change', result: 'failure', sourceIp: req.ip });
|
||||
return res.status(401).json({ error: 'invalid_credentials', message: 'Current password is incorrect.' });
|
||||
}
|
||||
const newHash = hashPassword(new_password);
|
||||
await pool.query(`UPDATE students SET password_hash = $1, updated_at = now() WHERE student_id = $2`, [newHash, student.student_id]);
|
||||
await sessions.revokeAllStudentSessions(pool, student.student_id, req.sessionToken);
|
||||
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_change', result: 'success', sourceIp: req.ip });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// §9/§6 — self-service "forgot password." No email delivery infrastructure exists yet
|
||||
// (explicitly not required this phase), so this deliberately does NOT invent fake email
|
||||
// sending and does NOT hand the reset token back to whoever asked (that would let anyone
|
||||
// "reset" any account just by guessing an identifier — a real vulnerability, not a shortcut).
|
||||
// Always returns the same generic response regardless of whether the identifier exists
|
||||
// (anti-enumeration). In non-production, the real token is included so local/test flows can
|
||||
// complete the loop without pretending email exists; in production it is never returned by
|
||||
// this endpoint — completing a real self-service reset needs email delivery, a documented,
|
||||
// not-yet-built follow-up (see docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §5). The
|
||||
// realistic path today is instructor-triggered reset (POST /api/admin/students/:id/reset-password).
|
||||
app.post('/api/student/request-password-reset', async (req, res) => {
|
||||
const { identifier } = req.body || {};
|
||||
const generic = { ok: true, message: 'If that account exists, a password reset has been prepared.' };
|
||||
if (!identifier) return res.json(generic);
|
||||
const student = await getStudentByIdentifier(pool, identifier);
|
||||
if (!student || student.status !== 'active') return res.json(generic);
|
||||
const { token } = await issueCredentialToken(pool, student.student_id, 'reset');
|
||||
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_reset_requested', result: 'success', sourceIp: req.ip });
|
||||
if (!isProduction) return res.json({ ...generic, dev_reset_token: token });
|
||||
res.json(generic);
|
||||
});
|
||||
|
||||
app.post('/api/student/reset-password', async (req, res) => {
|
||||
const { token, password } = req.body || {};
|
||||
if (!token || !password || password.length < 10) {
|
||||
return res.status(400).json({ error: 'invalid_request', message: 'A valid token and a password of at least 10 characters are required.' });
|
||||
}
|
||||
const student = await consumeCredentialToken(pool, token, 'reset', password);
|
||||
if (!student) return res.status(400).json({ error: 'invalid_or_expired_token', message: 'This reset link is invalid or has expired.' });
|
||||
await sessions.revokeAllStudentSessions(pool, student.student_id, null); // a reset revokes EVERY existing session — the whole point is "someone other than me might have access"
|
||||
await recordAudit(pool, { actorType: 'student', actorId: student.student_id, action: 'password_reset_completed', result: 'success', sourceIp: req.ip });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// §10 — minimal profile page data: identity, cohort(s), progress summary, achievements,
|
||||
// active session count. Reuses the same loadProgress/curriculum modules the existing
|
||||
// /api/progress and /api/curriculum/path routes use — no parallel progress-reading logic.
|
||||
app.get('/api/student/profile', studentSession, async (req, res) => {
|
||||
const student = await getActiveStudent(pool, req.studentId);
|
||||
if (!student) return res.status(401).json({ error: 'no_session' });
|
||||
const [{ rows: cohorts }, progress, { rows: achievements }, sessionsList] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT c.cohort_id, c.name FROM cohort_enrollments e JOIN cohorts c ON c.cohort_id = e.cohort_id
|
||||
WHERE e.student_id = $1 AND e.enrollment_status = 'enrolled'`,
|
||||
[student.student_id]
|
||||
),
|
||||
loadProgress(pool, student.student_id),
|
||||
pool.query(
|
||||
`SELECT achievement_type, stage_id, achieved_at FROM achievements WHERE student_id = $1 ORDER BY achieved_at DESC`,
|
||||
[student.student_id]
|
||||
),
|
||||
sessions.listStudentSessions(pool, student.student_id),
|
||||
]);
|
||||
const scenarios = curriculum.loadScenarios();
|
||||
const completed = Object.values(progress).filter((p) => p.status === 'completed').length;
|
||||
res.json({
|
||||
student: publicStudent(student),
|
||||
cohorts,
|
||||
progress_summary: { completed, total: scenarios.length },
|
||||
achievements,
|
||||
active_sessions: sessionsList.filter((s) => !s.revoked_at && new Date(s.expires_at) > new Date()).length,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.STUDENT_COOKIE = STUDENT_COOKIE;
|
||||
module.exports.STUDENT_CSRF_COOKIE = STUDENT_CSRF_COOKIE;
|
||||
@@ -0,0 +1,70 @@
|
||||
// CSV roster import parsing — Phase 5A §29. Deliberately hand-rolled, not a CSV library: the
|
||||
// expected input is simple (no quoted fields containing commas expected in practice for
|
||||
// names/identifiers), and pulling in a dependency for a three-column parser isn't justified
|
||||
// (ponytail: stdlib/hand-rolled first). Validates the WHOLE file and returns every row-level
|
||||
// error at once — the caller decides whether to import (transactional, all-or-nothing).
|
||||
|
||||
const REQUIRED_HEADERS = ['display_name'];
|
||||
const KNOWN_HEADERS = ['display_name', 'email', 'login_identifier', 'cohort_id'];
|
||||
const FORBIDDEN_HEADERS = ['password', 'password_hash', 'credential_token_hash']; // never accepted from a file — see admin-routes.js's import route comment
|
||||
|
||||
function splitCsvLine(line) {
|
||||
// No quoted-comma support needed for this column set (names/identifiers/cohort slugs don't
|
||||
// contain commas in practice) — a plain split covers the real input shape.
|
||||
return line.split(',').map((cell) => cell.trim());
|
||||
}
|
||||
|
||||
function parseStudentCsv(csv) {
|
||||
const lines = csv.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
|
||||
const errors = [];
|
||||
if (!lines.length) return { rows: [], errors: [{ row: 0, message: 'CSV is empty' }] };
|
||||
|
||||
const headers = splitCsvLine(lines[0]).map((h) => h.toLowerCase());
|
||||
for (const required of REQUIRED_HEADERS) {
|
||||
if (!headers.includes(required)) errors.push({ row: 0, message: `missing required column: ${required}` });
|
||||
}
|
||||
for (const forbidden of FORBIDDEN_HEADERS) {
|
||||
if (headers.includes(forbidden)) errors.push({ row: 0, message: `column '${forbidden}' is never accepted from a file — credentials are always generated server-side` });
|
||||
}
|
||||
const unknown = headers.filter((h) => !KNOWN_HEADERS.includes(h));
|
||||
if (unknown.length) errors.push({ row: 0, message: `unknown column(s): ${unknown.join(', ')}` });
|
||||
if (errors.length) return { rows: [], errors };
|
||||
|
||||
const seenEmails = new Set();
|
||||
const seenIdentifiers = new Set();
|
||||
const rows = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const rowNum = i + 1; // 1-indexed with header as row 1, matching what a spreadsheet user sees
|
||||
const cells = splitCsvLine(lines[i]);
|
||||
if (cells.length !== headers.length) {
|
||||
errors.push({ row: rowNum, message: `expected ${headers.length} columns, got ${cells.length}` });
|
||||
continue;
|
||||
}
|
||||
const raw = Object.fromEntries(headers.map((h, idx) => [h, cells[idx]]));
|
||||
if (!raw.display_name) {
|
||||
errors.push({ row: rowNum, message: 'display_name is required' });
|
||||
continue;
|
||||
}
|
||||
const email = raw.email ? raw.email.toLowerCase() : null;
|
||||
const loginIdentifier = raw.login_identifier || null;
|
||||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
errors.push({ row: rowNum, message: `invalid email: ${raw.email}` });
|
||||
continue;
|
||||
}
|
||||
if (email && seenEmails.has(email)) {
|
||||
errors.push({ row: rowNum, message: `duplicate email within this file: ${email}` });
|
||||
continue;
|
||||
}
|
||||
if (loginIdentifier && seenIdentifiers.has(loginIdentifier)) {
|
||||
errors.push({ row: rowNum, message: `duplicate login_identifier within this file: ${loginIdentifier}` });
|
||||
continue;
|
||||
}
|
||||
if (email) seenEmails.add(email);
|
||||
if (loginIdentifier) seenIdentifiers.add(loginIdentifier);
|
||||
rows.push({ display_name: raw.display_name, email, login_identifier: loginIdentifier, cohort_id: raw.cohort_id || null });
|
||||
}
|
||||
if (errors.length) return { rows: [], errors };
|
||||
return { rows, errors: [] };
|
||||
}
|
||||
|
||||
module.exports = { parseStudentCsv };
|
||||
@@ -0,0 +1,90 @@
|
||||
// Student credential/account lifecycle — Phase 5A §2/§4/§5/§7. Parallel to instructors.js
|
||||
// (same scrypt hashing from ./auth/password.js, same "row minus password_hash/token_hash"
|
||||
// public-view pattern), not a duplicate auth system: student and instructor accounts share the
|
||||
// exact same session store (sessions.js), CSRF pattern, and rate limiter — only the identity
|
||||
// table and the specific login identifier (email/login_identifier vs. instructor email) differ.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { hashPassword, verifyPassword } = require('./auth/password');
|
||||
|
||||
const CREDENTIAL_TOKEN_BYTES = 32;
|
||||
const ACTIVATION_TOKEN_TTL_MS = 7 * 24 * 3600 * 1000; // a week — an instructor may hand this to a student who isn't logging in same-day
|
||||
const RESET_TOKEN_TTL_MS = 60 * 60 * 1000; // an hour — shorter, since a reset implies "I have access right now"
|
||||
|
||||
function hashToken(token) {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
// Never returns password_hash or credential_token_hash — the one and only place a student row
|
||||
// is shaped for an API response. Every route below returns THIS, never the raw row.
|
||||
function publicStudent(row) {
|
||||
if (!row) return null;
|
||||
const { password_hash, credential_token_hash, ...rest } = row;
|
||||
return { ...rest, has_credentials: !!password_hash };
|
||||
}
|
||||
|
||||
async function getActiveStudent(pool, studentId) {
|
||||
const { rows } = await pool.query(`SELECT * FROM students WHERE student_id = $1 AND status = 'active'`, [studentId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// Case-insensitive on email (matches instructors.js's convention); login_identifier is matched
|
||||
// exactly as stored — it's instructor-assigned, not self-chosen, so there's no "did they mean
|
||||
// to capitalize it differently" ambiguity to normalize away.
|
||||
async function getStudentByIdentifier(pool, identifier) {
|
||||
const id = String(identifier || '').trim();
|
||||
if (!id) return null;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM students WHERE lower(email) = lower($1) OR login_identifier = $1`,
|
||||
[id]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// Generates a credential token (activation or reset), returns the PLAINTEXT once — callers
|
||||
// must hand it to the instructor/student and never persist or log it themselves. Only the hash
|
||||
// goes to the database. Overwrites any previous outstanding token for this student (a new
|
||||
// activation/reset link invalidates an older unused one — only the latest is ever valid).
|
||||
async function issueCredentialToken(pool, studentId, purpose) {
|
||||
const token = crypto.randomBytes(CREDENTIAL_TOKEN_BYTES).toString('base64url');
|
||||
const ttlMs = purpose === 'reset' ? RESET_TOKEN_TTL_MS : ACTIVATION_TOKEN_TTL_MS;
|
||||
const expiresAt = new Date(Date.now() + ttlMs);
|
||||
await pool.query(
|
||||
`UPDATE students SET credential_token_hash = $1, credential_token_purpose = $2, credential_token_expires_at = $3
|
||||
WHERE student_id = $4`,
|
||||
[hashToken(token), purpose, expiresAt, studentId]
|
||||
);
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
// Looks up the student a token belongs to, WITHOUT consuming it — callers that need to show
|
||||
// "who is this token for" before the student submits a new password (e.g. to greet them by
|
||||
// name) use this; consumeCredentialToken is the one that actually spends it.
|
||||
async function findStudentByCredentialToken(pool, token, purpose) {
|
||||
if (!token) return null;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM students WHERE credential_token_hash = $1 AND credential_token_purpose = $2
|
||||
AND credential_token_expires_at > now()`,
|
||||
[hashToken(token), purpose]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// Sets a new password and clears the token in one statement — a token can only ever be used
|
||||
// once, whether it succeeds or the caller retries with a stale value afterward.
|
||||
async function consumeCredentialToken(pool, token, purpose, newPassword) {
|
||||
const passwordHash = hashPassword(newPassword);
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE students SET password_hash = $1, credential_token_hash = NULL, credential_token_purpose = NULL,
|
||||
credential_token_expires_at = NULL, updated_at = now()
|
||||
WHERE credential_token_hash = $2 AND credential_token_purpose = $3 AND credential_token_expires_at > now()
|
||||
RETURNING *`,
|
||||
[passwordHash, hashToken(token), purpose]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hashPassword, verifyPassword, publicStudent, getActiveStudent, getStudentByIdentifier,
|
||||
issueCredentialToken, findStudentByCredentialToken, consumeCredentialToken,
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
// Fast, isolated unit test for student-csv.js — no Postgres, no HTTP. No framework, matching
|
||||
// project convention (see platform/lab-router/test/run.js).
|
||||
|
||||
const assert = require('assert');
|
||||
const { parseStudentCsv } = require('../src/student-csv');
|
||||
|
||||
function main() {
|
||||
{
|
||||
const r = parseStudentCsv('display_name,email,login_identifier,cohort_id\nAlice,[email protected],,root-fall\nBob,,bob01,root-fall');
|
||||
assert.equal(r.errors.length, 0);
|
||||
assert.equal(r.rows.length, 2);
|
||||
assert.equal(r.rows[0].email, '[email protected]');
|
||||
assert.equal(r.rows[1].login_identifier, 'bob01');
|
||||
console.log('ok — valid file parses');
|
||||
}
|
||||
{
|
||||
const r = parseStudentCsv('email\[email protected]');
|
||||
assert.ok(r.errors.length > 0);
|
||||
console.log('ok — missing display_name column rejected');
|
||||
}
|
||||
{
|
||||
const r = parseStudentCsv('display_name,password\nAlice,hunter2');
|
||||
assert.ok(r.errors.some((e) => e.message.includes('password')));
|
||||
console.log('ok — password column never accepted from a file');
|
||||
}
|
||||
{
|
||||
const r = parseStudentCsv('display_name,email\nAlice,[email protected],extra');
|
||||
assert.ok(r.errors.length > 0);
|
||||
assert.equal(r.rows.length, 0);
|
||||
console.log('ok — a malformed row rejects the whole file (transactional, no partial import)');
|
||||
}
|
||||
{
|
||||
const r = parseStudentCsv('display_name,email\nAlice,[email protected]\nBob,[email protected]');
|
||||
assert.ok(r.errors.some((e) => e.message.includes('duplicate email')));
|
||||
console.log('ok — duplicate email within one file rejected');
|
||||
}
|
||||
{
|
||||
const r = parseStudentCsv('display_name,email\nAlice,not-an-email');
|
||||
assert.ok(r.errors.some((e) => e.message.includes('invalid email')));
|
||||
console.log('ok — invalid email format rejected');
|
||||
}
|
||||
console.log('\nAll student-csv unit tests passed.');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 5A — student identity & remote classroom. Permanent regression test replacing the
|
||||
// throwaway smoke5a/smoke5b scripts used during development. Covers §31 (two-student + RBAC
|
||||
// matrix) and §32 (password security), plus a real classroom lifecycle smoke pass. Same
|
||||
// conventions as e2e-phase3a.js: assert-based, no framework, runs against a live
|
||||
// docker-compose.dev.yml stack, shells out for the restart-persistence test.
|
||||
//
|
||||
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
|
||||
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
|
||||
// OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase5a.js
|
||||
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const { makeSession } = require('./session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
||||
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
||||
const SKIP_RESTART_TEST = process.env.SKIP_RESTART_TEST === '1';
|
||||
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
|
||||
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
|
||||
process.exit(1);
|
||||
}
|
||||
const RUN_ID = Date.now();
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function test(name, fn) {
|
||||
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||
catch (e) { console.error(` FAIL — ${name}\n ${e.message}`); failed++; }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 5A student identity / remote classroom acceptance test\n');
|
||||
|
||||
const owner = makeSession(GW);
|
||||
await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
assert.ok(owner.hasCookie('rn_instructor'), 'owner login must succeed before anything else can run');
|
||||
|
||||
// ── Fixtures: two cohorts, an assigned instructor, an unassigned instructor, a viewer,
|
||||
// two students (Alice/Bob) in cohort A. ────────────────────────────────────────────────
|
||||
const cohortA = `p5a-cohort-a-${RUN_ID}`;
|
||||
const cohortB = `p5a-cohort-b-${RUN_ID}`;
|
||||
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortA, name: 'P5A Cohort A' });
|
||||
await owner.request('POST', '/api/admin/cohorts', { cohort_id: cohortB, name: 'P5A Cohort B' });
|
||||
|
||||
const { body: assignedInstr } = await owner.request('POST', '/api/admin/instructors', {
|
||||
email: `p5a-assigned-${RUN_ID}@rootnode.test`, display_name: 'Assigned Instructor', role: 'instructor', password: 'AssignedPass123!',
|
||||
});
|
||||
const { body: unassignedInstr } = await owner.request('POST', '/api/admin/instructors', {
|
||||
email: `p5a-unassigned-${RUN_ID}@rootnode.test`, display_name: 'Unassigned Instructor', role: 'instructor', password: 'UnassignedPass123!',
|
||||
});
|
||||
await owner.request('POST', `/api/admin/cohorts/${cohortA}/instructors`, { instructor_id: assignedInstr.instructor_id, role_in_cohort: 'teacher' });
|
||||
const { body: viewerInstr } = await owner.request('POST', '/api/admin/instructors', {
|
||||
email: `p5a-viewer-${RUN_ID}@rootnode.test`, display_name: 'Viewer Instructor', role: 'instructor', password: 'ViewerPass123!',
|
||||
});
|
||||
await owner.request('POST', `/api/admin/cohorts/${cohortA}/instructors`, { instructor_id: viewerInstr.instructor_id, role_in_cohort: 'viewer' });
|
||||
|
||||
const { body: aliceCreate } = await owner.request('POST', '/api/admin/students', {
|
||||
display_name: 'Alice', cohort_id: cohortA, login_identifier: `p5a-alice-${RUN_ID}`,
|
||||
});
|
||||
const { body: bobCreate } = await owner.request('POST', '/api/admin/students', {
|
||||
display_name: 'Bob', cohort_id: cohortA, login_identifier: `p5a-bob-${RUN_ID}`,
|
||||
});
|
||||
const alicePassword = 'AlicePassword123!';
|
||||
const bobPassword = 'BobPassword123!';
|
||||
const alice = makeSession(GW);
|
||||
const bob = makeSession(GW);
|
||||
await test('activation: token sets password and logs the student in', async () => {
|
||||
const { status, body } = await alice.request('POST', '/api/student/activate', { token: aliceCreate.activation_token, password: alicePassword });
|
||||
assert.equal(status, 200);
|
||||
assert.equal(body.student.display_name, 'Alice');
|
||||
assert.ok(alice.hasCookie('rn_session'));
|
||||
});
|
||||
await test('activation: the same token cannot be used twice (single-use)', async () => {
|
||||
const { status } = await makeSession(GW).request('POST', '/api/student/activate', { token: aliceCreate.activation_token, password: 'SomeOtherPass123!' });
|
||||
assert.equal(status, 400);
|
||||
});
|
||||
await bob.request('POST', '/api/student/activate', { token: bobCreate.activation_token, password: bobPassword });
|
||||
|
||||
const { body: classroomCreate } = await owner.request('POST', `/api/admin/cohorts/${cohortA}/classroom-sessions`, { title: 'P5A Live Class' });
|
||||
const classroomId = classroomCreate.classroom_session_id;
|
||||
|
||||
// ── §32 Password security ───────────────────────────────────────────────────────────────
|
||||
console.log('\nPassword security (§32)');
|
||||
|
||||
await test('correct identifier + password logs in', async () => {
|
||||
const s = makeSession(GW);
|
||||
const { status } = await s.request('POST', '/api/student/login', { identifier: `p5a-alice-${RUN_ID}`, password: alicePassword });
|
||||
assert.equal(status, 200);
|
||||
});
|
||||
|
||||
await test('wrong password is rejected generically', async () => {
|
||||
const s = makeSession(GW);
|
||||
const { status, body } = await s.request('POST', '/api/student/login', { identifier: `p5a-alice-${RUN_ID}`, password: 'wrong-password-entirely' });
|
||||
assert.equal(status, 401);
|
||||
assert.equal(body.error, 'invalid_credentials');
|
||||
});
|
||||
|
||||
await test('unknown identifier fails with the SAME response as a wrong password (no enumeration)', async () => {
|
||||
const s1 = makeSession(GW), s2 = makeSession(GW);
|
||||
const unknown = await s1.request('POST', '/api/student/login', { identifier: `nobody-${RUN_ID}`, password: 'whatever12345' });
|
||||
const wrongPw = await s2.request('POST', '/api/student/login', { identifier: `p5a-alice-${RUN_ID}`, password: 'wrong-password-entirely' });
|
||||
assert.equal(unknown.status, wrongPw.status);
|
||||
assert.deepEqual(unknown.body, wrongPw.body);
|
||||
});
|
||||
|
||||
await test('deactivated account is blocked at login', async () => {
|
||||
await owner.request('PATCH', `/api/admin/students/${bobCreate.student_id}`, { status: 'inactive' });
|
||||
const s = makeSession(GW);
|
||||
const { status } = await s.request('POST', '/api/student/login', { identifier: `p5a-bob-${RUN_ID}`, password: bobPassword });
|
||||
assert.equal(status, 403);
|
||||
await owner.request('PATCH', `/api/admin/students/${bobCreate.student_id}`, { status: 'active' }); // restore for later tests
|
||||
});
|
||||
|
||||
await test('deactivation takes effect immediately on an EXISTING session, not just at next login', async () => {
|
||||
const s = makeSession(GW);
|
||||
await s.request('POST', '/api/student/login', { identifier: `p5a-bob-${RUN_ID}`, password: bobPassword });
|
||||
const before = await s.request('GET', '/api/student/profile');
|
||||
assert.equal(before.status, 200);
|
||||
await owner.request('PATCH', `/api/admin/students/${bobCreate.student_id}`, { status: 'inactive' });
|
||||
const after = await s.request('GET', '/api/student/profile');
|
||||
assert.notEqual(after.status, 200); // studentSession middleware re-checks status='active' every request
|
||||
await owner.request('PATCH', `/api/admin/students/${bobCreate.student_id}`, { status: 'active' });
|
||||
});
|
||||
|
||||
await test('rate limiting: repeated failures against the same identifier eventually 429s', async () => {
|
||||
const key = `p5a-ratelimit-${RUN_ID}`;
|
||||
let sawTooMany = false;
|
||||
for (let i = 0; i < 12 && !sawTooMany; i++) {
|
||||
const s = makeSession(GW);
|
||||
const { status } = await s.request('POST', '/api/student/login', { identifier: key, password: 'wrong-password' });
|
||||
if (status === 429) sawTooMany = true;
|
||||
}
|
||||
assert.ok(sawTooMany, 'expected a 429 within 12 attempts (LOGIN_MAX_ATTEMPTS=8)');
|
||||
});
|
||||
|
||||
if (!SKIP_RESTART_TEST) {
|
||||
await test('rate limit state survives a Platform API restart (Postgres-backed, not in-memory)', async () => {
|
||||
const key = `p5a-ratelimit-restart-${RUN_ID}`;
|
||||
for (let i = 0; i < 9; i++) {
|
||||
await makeSession(GW).request('POST', '/api/student/login', { identifier: key, password: 'wrong-password' });
|
||||
}
|
||||
execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: `${__dirname}/..`, stdio: 'pipe' });
|
||||
await new Promise((r) => setTimeout(r, 4000));
|
||||
const { status } = await makeSession(GW).request('POST', '/api/student/login', { identifier: key, password: 'wrong-password' });
|
||||
assert.equal(status, 429, 'the attempt count must have survived the restart');
|
||||
});
|
||||
} else {
|
||||
console.log(' skip — restart persistence (SKIP_RESTART_TEST=1)');
|
||||
}
|
||||
|
||||
await test('change-password: wrong current password is rejected', async () => {
|
||||
const { status } = await alice.request('POST', '/api/student/change-password', { old_password: 'not-the-real-password', new_password: 'NewAlicePass123!' });
|
||||
assert.equal(status, 401);
|
||||
});
|
||||
|
||||
await test('change-password: correct current password succeeds and revokes OTHER sessions', async () => {
|
||||
const otherSession = makeSession(GW);
|
||||
await otherSession.request('POST', '/api/student/login', { identifier: `p5a-alice-${RUN_ID}`, password: alicePassword });
|
||||
const { status } = await alice.request('POST', '/api/student/change-password', { old_password: alicePassword, new_password: 'NewAlicePass123!' });
|
||||
assert.equal(status, 200);
|
||||
const { status: otherStatus } = await otherSession.request('GET', '/api/student/profile');
|
||||
assert.notEqual(otherStatus, 200); // the OTHER session (not the one that made the change) is revoked
|
||||
const { status: selfStatus } = await alice.request('GET', '/api/student/profile');
|
||||
assert.equal(selfStatus, 200); // the session that made the change is untouched
|
||||
});
|
||||
|
||||
await test('forgot-password: identical generic response for a real vs unknown identifier (anti-enumeration)', async () => {
|
||||
const real = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `p5a-alice-${RUN_ID}` });
|
||||
const fake = await makeSession(GW).request('POST', '/api/student/request-password-reset', { identifier: `nobody-${RUN_ID}` });
|
||||
assert.equal(real.body.message, fake.body.message);
|
||||
});
|
||||
|
||||
await test('admin-issued reset token: sets a new password and revokes ALL sessions', async () => {
|
||||
const { body } = await owner.request('POST', `/api/admin/students/${aliceCreate.student_id}/reset-password`);
|
||||
assert.ok(body.reset_token);
|
||||
const preCheck = await alice.request('GET', '/api/student/profile');
|
||||
assert.notEqual(preCheck.status, 200); // reset-password revokes existing sessions immediately
|
||||
const fresh = makeSession(GW);
|
||||
const { status } = await fresh.request('POST', '/api/student/reset-password', { token: body.reset_token, password: 'ResetAlicePass123!' });
|
||||
assert.equal(status, 200);
|
||||
const login = await makeSession(GW).request('POST', '/api/student/login', { identifier: `p5a-alice-${RUN_ID}`, password: 'ResetAlicePass123!' });
|
||||
assert.equal(login.status, 200);
|
||||
});
|
||||
|
||||
await test('CSRF: a mutating request without the header is rejected even with a valid session cookie', async () => {
|
||||
const s = makeSession(GW);
|
||||
await s.request('POST', '/api/student/login', { identifier: `p5a-bob-${RUN_ID}`, password: bobPassword });
|
||||
const res = await fetch(`${GW}/api/student/change-password`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `rn_session=${s.getCookie('rn_session')}` },
|
||||
body: JSON.stringify({ old_password: bobPassword, new_password: 'ShouldNotWork123!' }),
|
||||
});
|
||||
assert.notEqual(res.status, 200);
|
||||
});
|
||||
|
||||
// ── §31 Two-student + RBAC matrix ───────────────────────────────────────────────────────
|
||||
console.log('\nTwo-student and RBAC matrix (§31)');
|
||||
|
||||
const bobSession = makeSession(GW);
|
||||
await bobSession.request('POST', '/api/student/login', { identifier: `p5a-bob-${RUN_ID}`, password: bobPassword });
|
||||
|
||||
await test('a student session cannot reach ANY /api/admin/* route', async () => {
|
||||
const { status } = await bobSession.request('GET', '/api/admin/students');
|
||||
assert.ok([401, 403].includes(status));
|
||||
});
|
||||
|
||||
await test('a student profile request only ever returns that student\'s own identity', async () => {
|
||||
const { body } = await bobSession.request('GET', '/api/student/profile');
|
||||
assert.equal(body.student.display_name, 'Bob');
|
||||
});
|
||||
|
||||
await test('assigned instructor CAN view and manage cohort A (students, classroom)', async () => {
|
||||
const assigned = makeSession(GW);
|
||||
await assigned.request('POST', '/api/auth/instructor/login', { email: `p5a-assigned-${RUN_ID}@rootnode.test`, password: 'AssignedPass123!' });
|
||||
const view = await assigned.request('GET', `/api/admin/cohorts/${cohortA}/students`);
|
||||
assert.equal(view.status, 200);
|
||||
const write = await assigned.request('POST', `/api/admin/cohorts/${cohortA}/classroom-sessions`, { title: 'Assigned-created class' });
|
||||
assert.equal(write.status, 201);
|
||||
});
|
||||
|
||||
await test('instructor NOT assigned to cohort A is DENIED (403) on its resources', async () => {
|
||||
const unassigned = makeSession(GW);
|
||||
await unassigned.request('POST', '/api/auth/instructor/login', { email: `p5a-unassigned-${RUN_ID}@rootnode.test`, password: 'UnassignedPass123!' });
|
||||
const view = await unassigned.request('GET', `/api/admin/cohorts/${cohortA}/students`);
|
||||
assert.equal(view.status, 403);
|
||||
const dashboard = await unassigned.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
|
||||
assert.equal(dashboard.status, 403);
|
||||
});
|
||||
|
||||
await test('viewer role_in_cohort CAN view but is DENIED mutation on cohort A', async () => {
|
||||
const viewer = makeSession(GW);
|
||||
await viewer.request('POST', '/api/auth/instructor/login', { email: `p5a-viewer-${RUN_ID}@rootnode.test`, password: 'ViewerPass123!' });
|
||||
const view = await viewer.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
|
||||
assert.equal(view.status, 200);
|
||||
const write = await viewer.request('POST', `/api/admin/classroom-sessions/${classroomId}/announcements`, { message: 'should be denied' });
|
||||
assert.equal(write.status, 403);
|
||||
});
|
||||
|
||||
// ── Classroom lifecycle smoke pass ──────────────────────────────────────────────────────
|
||||
console.log('\nClassroom lifecycle');
|
||||
|
||||
await test('classroom: start -> join -> heartbeat -> dashboard reflects presence -> end', async () => {
|
||||
const { status: startStatus } = await owner.request('POST', `/api/admin/classroom-sessions/${classroomId}/start`);
|
||||
assert.equal(startStatus, 200);
|
||||
await bobSession.request('POST', `/api/classroom/${classroomId}/join`);
|
||||
await bobSession.request('POST', `/api/classroom/${classroomId}/heartbeat`);
|
||||
const { body: dash } = await owner.request('GET', `/api/admin/classroom-sessions/${classroomId}`);
|
||||
const bobRow = dash.students.find((s) => s.student_id === bobCreate.student_id);
|
||||
assert.ok(bobRow, 'Bob must appear as a participant after joining');
|
||||
assert.equal(bobRow.presence, 'online');
|
||||
const { status: endStatus } = await owner.request('POST', `/api/admin/classroom-sessions/${classroomId}/end`);
|
||||
assert.equal(endStatus, 200);
|
||||
});
|
||||
|
||||
await test('classroom: only one LIVE classroom per cohort at a time', async () => {
|
||||
const c2 = await owner.request('POST', `/api/admin/cohorts/${cohortA}/classroom-sessions`, { title: 'Second class' });
|
||||
await owner.request('POST', `/api/admin/classroom-sessions/${c2.body.classroom_session_id}/start`);
|
||||
const c3 = await owner.request('POST', `/api/admin/cohorts/${cohortA}/classroom-sessions`, { title: 'Third class' });
|
||||
const { status } = await owner.request('POST', `/api/admin/classroom-sessions/${c3.body.classroom_session_id}/start`);
|
||||
assert.equal(status, 409);
|
||||
await owner.request('POST', `/api/admin/classroom-sessions/${c2.body.classroom_session_id}/end`);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
Reference in New Issue
Block a user