From 9fe995792c8f918382a327fbd1d3d035c6e4e7fe Mon Sep 17 00:00:00 2001 From: Camillalalala Date: Mon, 11 May 2026 00:09:44 -0700 Subject: [PATCH] Feature: Implemented teacher authentication, dashboard, and analytics --- .gitignore | 1 + data/parse_uji.py | 19 + frontend/analytics-dashboard.html | 33 ++ frontend/classroom-setup.html | 31 ++ frontend/dashboard.html | 24 ++ frontend/index.html | 14 + frontend/session-console.html | 43 ++ frontend/src/analytics-dashboard.ts | 163 +++++++ frontend/src/auth-guard.ts | 11 + frontend/src/classroom-setup.ts | 125 ++++++ frontend/src/coach.ts | 1 + frontend/src/dashboard-entry.ts | 3 + frontend/src/dashboard.css | 153 +++++++ frontend/src/log.ts | 1 + frontend/src/main.ts | 10 +- frontend/src/object-box/main.ts | 15 +- frontend/src/session-console.ts | 126 ++++++ frontend/src/session-pad-lock.ts | 145 +++++++ frontend/src/style.css | 95 +++++ frontend/src/teacher-login.ts | 102 +++++ frontend/src/teacher-nav.ts | 32 ++ frontend/src/teacher.css | 399 ++++++++++++++++++ frontend/src/visualizer.ts | 9 +- frontend/src/vite-env.d.ts | 9 + frontend/teacher-login.html | 20 + frontend/vite.config.ts | 5 + package-lock.json | 18 + package.json | 8 +- workers/api/migrations/0002_teacher_class.sql | 31 ++ workers/api/src/index.ts | 77 +++- workers/api/src/lib/cookies.ts | 83 ++++ workers/api/src/lib/join_code.ts | 15 + workers/api/src/lib/jwt.ts | 165 ++++++++ workers/api/src/lib/secrets.ts | 7 + workers/api/src/lib/teacher_auth.ts | 63 +++ workers/api/src/router.ts | 6 + workers/api/src/routes/auth_google.ts | 89 ++++ workers/api/src/routes/auth_logout.ts | 14 + workers/api/src/routes/auth_me.ts | 14 + workers/api/src/routes/index.ts | 21 +- workers/api/src/routes/log_stroke.ts | 42 +- workers/api/src/routes/practice_status.ts | 18 + workers/api/src/routes/teacher_analytics.ts | 209 +++++++++ .../api/src/routes/teacher_class_report.ts | 143 +++++++ workers/api/src/routes/teacher_classes.ts | 76 ++++ workers/api/src/routes/teacher_sessions.ts | 162 +++++++ workers/api/wrangler.jsonc | 2 +- 47 files changed, 2830 insertions(+), 22 deletions(-) create mode 100644 frontend/analytics-dashboard.html create mode 100644 frontend/classroom-setup.html create mode 100644 frontend/dashboard.html create mode 100644 frontend/session-console.html create mode 100644 frontend/src/analytics-dashboard.ts create mode 100644 frontend/src/auth-guard.ts create mode 100644 frontend/src/classroom-setup.ts create mode 100644 frontend/src/dashboard-entry.ts create mode 100644 frontend/src/dashboard.css create mode 100644 frontend/src/session-console.ts create mode 100644 frontend/src/session-pad-lock.ts create mode 100644 frontend/src/teacher-login.ts create mode 100644 frontend/src/teacher-nav.ts create mode 100644 frontend/src/teacher.css create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/teacher-login.html create mode 100644 workers/api/migrations/0002_teacher_class.sql create mode 100644 workers/api/src/lib/cookies.ts create mode 100644 workers/api/src/lib/join_code.ts create mode 100644 workers/api/src/lib/jwt.ts create mode 100644 workers/api/src/lib/secrets.ts create mode 100644 workers/api/src/lib/teacher_auth.ts create mode 100644 workers/api/src/routes/auth_google.ts create mode 100644 workers/api/src/routes/auth_logout.ts create mode 100644 workers/api/src/routes/auth_me.ts create mode 100644 workers/api/src/routes/practice_status.ts create mode 100644 workers/api/src/routes/teacher_analytics.ts create mode 100644 workers/api/src/routes/teacher_class_report.ts create mode 100644 workers/api/src/routes/teacher_classes.ts create mode 100644 workers/api/src/routes/teacher_sessions.ts diff --git a/.gitignore b/.gitignore index 9906b05..5b174d5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ .dev.vars *.log .DS_Store +*.env # build outputs workers/api/dist/ diff --git a/data/parse_uji.py b/data/parse_uji.py index f8a9e94..edea11b 100644 --- a/data/parse_uji.py +++ b/data/parse_uji.py @@ -19,6 +19,7 @@ import json import re import string +import sys from dataclasses import dataclass, asdict from pathlib import Path @@ -113,7 +114,25 @@ def parse_all(path: Path = SOURCE) -> list[Sample]: if __name__ == "__main__": + if not SOURCE.is_file() or SOURCE.stat().st_size == 0: + print( + f"parse_uji: missing or empty {SOURCE.name} at {SOURCE}\n" + "Download the UJIpenchars2 text export and save it as ujiv2.txt in the repo root.\n" + "(ujiv2.txt is gitignored; it is not committed to this repository.)", + file=sys.stderr, + ) + sys.exit(1) + samples = parse_all() + if not samples: + print( + f"parse_uji: no samples parsed from {SOURCE}.\n" + "Check that the file is UTF-8 UJIpenchars2 format (WORD / NUMSTROKES / POINTS lines).\n" + "Only ASCII alphanumeric WORD labels are kept.", + file=sys.stderr, + ) + sys.exit(1) + writers = sorted({s.writer for s in samples}) by_label: dict[str, int] = {} for s in samples: diff --git a/frontend/analytics-dashboard.html b/frontend/analytics-dashboard.html new file mode 100644 index 0000000..23dd4f5 --- /dev/null +++ b/frontend/analytics-dashboard.html @@ -0,0 +1,33 @@ + + + + + + phoneme · analytics + + + +
+

Analytics

+

Stroke logs and session summaries for every student, across all classes.

+ + + + + +
+
+ + + diff --git a/frontend/classroom-setup.html b/frontend/classroom-setup.html new file mode 100644 index 0000000..bf894c8 --- /dev/null +++ b/frontend/classroom-setup.html @@ -0,0 +1,31 @@ + + + + + + phoneme · roster + + + +
+

Roster

+

Add a class, then enroll students. Sessions are started from the Class tab.

+ + + +
+

New class

+
+ + +
+
+ +
+
+ + + diff --git a/frontend/dashboard.html b/frontend/dashboard.html new file mode 100644 index 0000000..2bc9f31 --- /dev/null +++ b/frontend/dashboard.html @@ -0,0 +1,24 @@ + + + + + + phoneme · classroom hub + + + +
+

Classroom

+

Teachers sign in first, then manage sessions and analytics.

+ +
+ + + diff --git a/frontend/index.html b/frontend/index.html index b147f7c..f9d2464 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -16,12 +16,26 @@

phoneme

+ ← console → visualizer → object box + + diff --git a/frontend/session-console.html b/frontend/session-console.html new file mode 100644 index 0000000..3723695 --- /dev/null +++ b/frontend/session-console.html @@ -0,0 +1,43 @@ + + + + + + phoneme · class + + + +
+

Class

+

Start a practice session for a student on this device, then open the drawing pad.

+ +
+ +
+ + + + + + +
+

Roster

+
    +
    + +

    + → drawing pad · + → visualizer · + → object box +

    +
    + + + diff --git a/frontend/src/analytics-dashboard.ts b/frontend/src/analytics-dashboard.ts new file mode 100644 index 0000000..e094472 --- /dev/null +++ b/frontend/src/analytics-dashboard.ts @@ -0,0 +1,163 @@ +import { guardTeacherApp } from "./auth-guard"; +import { initTeacherNav } from "./teacher-nav"; + +await guardTeacherApp(); +initTeacherNav({ active: "analytics" }); + +const root = document.getElementById("report-root") as HTMLDivElement; +const msg = document.getElementById("rep-msg") as HTMLParagraphElement; +const noResults = document.getElementById("no-results") as HTMLParagraphElement; +const filters = document.getElementById("analytics-filters") as HTMLDivElement; +const searchEl = document.getElementById("search-input") as HTMLInputElement; +const classFilter = document.getElementById("class-filter") as HTMLSelectElement; + +interface ClassRow { id: number; name: string; } + +interface StrokeLogistics { + strokeCount: number; + goodStrokeCount: number; + orphanStrokeCount: number; + distinctWords: number; + distinctLetters: number; + sessionCount: number; + avgSessionDurationMs: number; + firstActivityAt: number | null; + lastActivityAt: number | null; +} + +interface StudentReport { + studentId: number; + displayName: string; + strokeLogistics: StrokeLogistics; + visualizerReport: { summary: string; metrics: Record }; +} + +function escHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function fmtDuration(ms: number): string { + if (ms <= 0) return "—"; + const totalSec = Math.round(ms / 1000); + const m = Math.floor(totalSec / 60); + const s = totalSec % 60; + return m > 0 ? `${m}m ${s}s` : `${s}s`; +} + +function fmtDate(ts: number | null): string { + if (!ts) return "—"; + return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +function stat(value: string | number, label: string): string { + return `
    ${value}${escHtml(label)}
    `; +} + +function renderStudent(s: StudentReport, classId: number): string { + const sl = s.strokeLogistics; + const accuracy = sl.strokeCount > 0 + ? Math.round((sl.goodStrokeCount / sl.strokeCount) * 100) + "%" + : "—"; + return ` +
    +

    ${escHtml(s.displayName)}

    +
    + ${stat(sl.strokeCount, "total strokes")} + ${stat(accuracy, "accuracy")} + ${stat(sl.distinctWords, "words")} + ${stat(sl.distinctLetters, "letters")} + ${stat(sl.sessionCount, "sessions")} + ${stat(fmtDuration(sl.avgSessionDurationMs), "avg session")} + ${stat(fmtDate(sl.firstActivityAt), "first activity")} + ${stat(fmtDate(sl.lastActivityAt), "last activity")} +
    +
    `; +} + +function applyFilter(): void { + const query = searchEl.value.trim().toLowerCase(); + const classId = classFilter.value; + + const cards = root.querySelectorAll(".student-card"); + const sections = root.querySelectorAll(".class-section"); + let anyVisible = false; + + for (const card of cards) { + const nameMatch = !query || (card.dataset.name ?? "").includes(query); + const classMatch = !classId || card.dataset.classId === classId; + const visible = nameMatch && classMatch; + card.hidden = !visible; + if (visible) anyVisible = true; + } + + // Hide class sections that have no visible students + for (const section of sections) { + const hasVisible = [...section.querySelectorAll(".student-card")] + .some((c) => !c.hidden); + section.hidden = !hasVisible; + } + + noResults.hidden = anyVisible; +} + +// ── Fetch data ─────────────────────────────────────────────────────────────── + +async function apiGet(path: string): Promise<{ ok: boolean; body: T }> { + const r = await fetch(path, { credentials: "include" }); + return { ok: r.ok, body: (await r.json()) as T }; +} + +const { ok: classOk, body: classBody } = await apiGet<{ classes?: ClassRow[] }>("/api/teacher/classes"); + +if (!classOk || !classBody.classes?.length) { + msg.textContent = classOk ? "No classes found. Create one in the Roster tab." : "Failed to load classes."; + msg.removeAttribute("hidden"); +} else { + const classes = classBody.classes; + + // Populate class filter dropdown + for (const c of classes) { + const opt = document.createElement("option"); + opt.value = String(c.id); + opt.textContent = c.name; + classFilter.appendChild(opt); + } + + // Fetch all class reports in parallel + const reports = await Promise.all( + classes.map((c) => + apiGet<{ students?: StudentReport[] }>(`/api/teacher/classes/${c.id}/report`), + ), + ); + + // Render + const parts: string[] = []; + let totalStudents = 0; + + for (let i = 0; i < classes.length; i++) { + const c = classes[i]!; + const rep = reports[i]!; + const students = rep.body.students ?? []; + totalStudents += students.length; + + parts.push(`
    `); + parts.push(`

    ${escHtml(c.name)} (${students.length} student${students.length === 1 ? "" : "s"})

    `); + + if (students.length === 0) { + parts.push(`

    No students in this class yet.

    `); + } else { + for (const s of students) { + parts.push(renderStudent(s, c.id)); + } + } + parts.push(`
    `); + } + + root.innerHTML = parts.join(""); + + if (totalStudents > 0) { + filters.removeAttribute("hidden"); + searchEl.addEventListener("input", applyFilter); + classFilter.addEventListener("change", applyFilter); + } +} diff --git a/frontend/src/auth-guard.ts b/frontend/src/auth-guard.ts new file mode 100644 index 0000000..8be4b33 --- /dev/null +++ b/frontend/src/auth-guard.ts @@ -0,0 +1,11 @@ +const LOGIN = "/teacher-login.html"; + +/** Require a signed-in teacher for every app page except the login screen. */ +export async function guardTeacherApp(): Promise { + const path = window.location.pathname; + if (path === LOGIN || path.endsWith("/teacher-login.html")) return; + const r = await fetch("/api/auth/me", { credentials: "include" }); + if (r.ok) return; + const next = encodeURIComponent(window.location.pathname + window.location.search); + window.location.replace(`${LOGIN}?next=${next}`); +} diff --git a/frontend/src/classroom-setup.ts b/frontend/src/classroom-setup.ts new file mode 100644 index 0000000..17adbd6 --- /dev/null +++ b/frontend/src/classroom-setup.ts @@ -0,0 +1,125 @@ +import { guardTeacherApp } from "./auth-guard"; +import { initTeacherNav } from "./teacher-nav"; + +await guardTeacherApp(); + +const msg = document.getElementById("setup-msg") as HTMLParagraphElement; +const classForm = document.getElementById("class-form") as HTMLFormElement; +const classNameEl = document.getElementById("class-name") as HTMLInputElement; +const classesArea = document.getElementById("classes-area") as HTMLElement; + +interface ClassRow { + id: number; + name: string; + joinCode: string; + studentCount: number; +} + +interface StudentRow { + id: number; + displayName: string; +} + +async function api(path: string, init?: RequestInit): Promise<{ ok: boolean; body: T }> { + const r = await fetch(path, { ...init, credentials: "include" }); + return { ok: r.ok, body: (await r.json()) as T }; +} + +function escHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +async function loadClasses(): Promise { + const { ok, body } = await api<{ classes?: ClassRow[] }>("/api/teacher/classes"); + classesArea.innerHTML = ""; + + if (!ok || !body.classes?.length) { + classesArea.innerHTML = "

    No classes yet. Create one above.

    "; + msg.setAttribute("hidden", ""); + return; + } + + msg.textContent = "Use the Class tab to start practice sessions for your students."; + msg.classList.remove("t-msg--err"); + msg.removeAttribute("hidden"); + + for (const c of body.classes) { + const card = document.createElement("div"); + card.className = "t-card"; + card.innerHTML = ` +

    ${escHtml(c.name)}

    +

    ${c.studentCount} student${c.studentCount === 1 ? "" : "s"}

    +
    + + +
    +
      + `; + classesArea.appendChild(card); + + const form = card.querySelector(".add-student-form") as HTMLFormElement; + form.addEventListener("submit", (e) => { + e.preventDefault(); + void addStudent(c.id, form); + }); + + const ul = card.querySelector(".student-ul") as HTMLUListElement; + await loadStudents(c.id, ul); + } +} + +async function loadStudents(classId: number, ul: HTMLUListElement): Promise { + const { ok, body } = await api<{ students?: StudentRow[] }>(`/api/teacher/classes/${classId}/students`); + ul.innerHTML = ""; + if (!ok || !body.students?.length) { + ul.innerHTML = "
    • No students yet.
    • "; + return; + } + for (const s of body.students) { + const li = document.createElement("li"); + li.className = "t-list-item"; + li.innerHTML = `${escHtml(s.displayName)}`; + ul.appendChild(li); + } +} + +async function addStudent(classId: number, form: HTMLFormElement): Promise { + const input = form.querySelector("input[name=displayName]") as HTMLInputElement; + const displayName = input.value.trim(); + if (!displayName) return; + const { ok, body } = await api<{ error?: string }>(`/api/teacher/classes/${classId}/students`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ displayName }), + }); + if (!ok) { + msg.textContent = (body as { error?: string }).error ?? "Failed to add student"; + msg.classList.add("t-msg--err"); + msg.removeAttribute("hidden"); + return; + } + input.value = ""; + const ul = form.parentElement!.querySelector(`.student-ul[data-class="${classId}"]`) as HTMLUListElement; + await loadStudents(classId, ul); +} + +classForm.addEventListener("submit", async (e) => { + e.preventDefault(); + const name = classNameEl.value.trim(); + if (!name) return; + const { ok } = await api("/api/teacher/classes", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name }), + }); + if (ok) { + classNameEl.value = ""; + await loadClasses(); + } +}); + +initTeacherNav({ active: "roster" }); +await loadClasses(); diff --git a/frontend/src/coach.ts b/frontend/src/coach.ts index 7a1d403..553a36e 100644 --- a/frontend/src/coach.ts +++ b/frontend/src/coach.ts @@ -419,6 +419,7 @@ async function fireCoach( try { resp = await fetch("/api/feedback", { method: "POST", + credentials: "include", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...analysis, strokeEventId }), signal: ctrl.signal, diff --git a/frontend/src/dashboard-entry.ts b/frontend/src/dashboard-entry.ts new file mode 100644 index 0000000..a102240 --- /dev/null +++ b/frontend/src/dashboard-entry.ts @@ -0,0 +1,3 @@ +import { guardTeacherApp } from "./auth-guard"; + +await guardTeacherApp(); diff --git a/frontend/src/dashboard.css b/frontend/src/dashboard.css new file mode 100644 index 0000000..6ba258b --- /dev/null +++ b/frontend/src/dashboard.css @@ -0,0 +1,153 @@ +.dash-body { + margin: 0; + min-height: 100vh; + background: #f4f5f7; + color: #1a1d24; + font-family: system-ui, sans-serif; +} + +.dash-panel { + max-width: 44rem; + margin: 0 auto; + padding: 2rem 1.25rem; +} + +.dash-muted { + color: #5c6570; + font-size: 0.95rem; +} + +.dash-form { + display: flex; + flex-direction: column; + gap: 1rem; + margin: 1.5rem 0; +} + +.dash-form label { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.9rem; + font-weight: 600; +} + +.dash-form input { + padding: 0.5rem 0.65rem; + border: 1px solid #c9ced6; + border-radius: 6px; + font-size: 1rem; +} + +.dash-form button { + align-self: flex-start; + padding: 0.55rem 1.1rem; + border-radius: 6px; + border: none; + background: #2b6cb0; + color: #fff; + font-weight: 600; + cursor: pointer; +} + +.dash-form button:hover { + background: #245a94; +} + +.dash-msg { + padding: 0.75rem 1rem; + border-radius: 6px; + background: #e8f2fc; + border: 1px solid #b6d4f7; +} + +.dash-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin-bottom: 1.25rem; +} + +.dash-toolbar button.secondary { + background: #e9ecef; + color: #1a1d24; +} + +.dash-toolbar button.secondary:hover { + background: #dde1e6; +} + +.dash-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); + gap: 1.25rem; +} + +@media (max-width: 800px) { + .dash-grid { + grid-template-columns: 1fr; + } +} + +.dash-card { + background: #fff; + border: 1px solid #dfe3e8; + border-radius: 10px; + padding: 1rem 1.1rem; +} + +.dash-card h2 { + margin: 0 0 0.75rem; + font-size: 1.05rem; +} + +.dash-list { + list-style: none; + margin: 0; + padding: 0; +} + +.dash-list li { + padding: 0.5rem 0.35rem; + border-bottom: 1px solid #eef0f3; + cursor: pointer; + border-radius: 4px; +} + +.dash-list li:hover { + background: #f8f9fb; +} + +.dash-list li.active { + background: #e8f2fc; + font-weight: 600; +} + +.dash-stat-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); + gap: 0.65rem; +} + +.dash-stat { + padding: 0.55rem 0.65rem; + background: #f8f9fb; + border-radius: 6px; + font-size: 0.82rem; +} + +.dash-stat strong { + display: block; + font-size: 1.15rem; + margin-bottom: 0.15rem; +} + +.dash-pill { + display: inline-block; + font-size: 0.75rem; + padding: 0.15rem 0.45rem; + border-radius: 999px; + background: #e7ecf3; + color: #3a4452; +} diff --git a/frontend/src/log.ts b/frontend/src/log.ts index aff11bc..263e42e 100644 --- a/frontend/src/log.ts +++ b/frontend/src/log.ts @@ -76,6 +76,7 @@ export async function logStroke(ctx: LogContext, analysis: WordAnalysis): Promis try { const r = await fetch("/api/log/stroke", { method: "POST", + credentials: "include", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), }); diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 609f533..80e5b38 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,3 +1,5 @@ +import { guardTeacherApp } from "./auth-guard"; +import { installPadSessionLock } from "./session-pad-lock"; import { scheduleCoach, clearCoach, analyzeWord } from "./coach"; import * as highlight from "./highlight"; import { getStudentName, logStroke, setStudentName } from "./log"; @@ -500,7 +502,7 @@ ro.observe(canvas); async function init() { resizeCanvas(); - const r = await fetch("/uji_strokes.json"); + const r = await fetch("/uji_strokes.json", { credentials: "include" }); if (!r.ok) { console.error(`failed to load /uji_strokes.json: ${r.status}`); return; @@ -542,4 +544,8 @@ highlight.onActiveStrokesChange(() => { // phoneme.highlight.setActiveStrokes([{letter: 0, stroke: 0}]) (window as unknown as { phoneme: { highlight: typeof highlight } }).phoneme = { highlight }; -init(); +void (async () => { + await guardTeacherApp(); + await init(); + await installPadSessionLock(); +})(); diff --git a/frontend/src/object-box/main.ts b/frontend/src/object-box/main.ts index bc975d4..e18f9cf 100644 --- a/frontend/src/object-box/main.ts +++ b/frontend/src/object-box/main.ts @@ -1,3 +1,4 @@ +import { guardTeacherApp } from "../auth-guard"; import { createScene } from "./scene"; import { createPaper } from "./paper"; import { LEVEL_FOX } from "./level"; @@ -69,12 +70,14 @@ function applyLevel(level: Level): void { if (level.greetingName) greetingName.textContent = level.greetingName; } -applyLevel(applyUrlOverrides(LEVEL_FOX)); +void (async () => { + await guardTeacherApp(); + applyLevel(applyUrlOverrides(LEVEL_FOX)); -// fade booting overlay once the first render has happened -requestAnimationFrame(() => { requestAnimationFrame(() => { - booting.classList.add("gone"); - setTimeout(() => booting.remove(), 800); + requestAnimationFrame(() => { + booting.classList.add("gone"); + setTimeout(() => booting.remove(), 800); + }); }); -}); +})(); diff --git a/frontend/src/session-console.ts b/frontend/src/session-console.ts new file mode 100644 index 0000000..48ecc16 --- /dev/null +++ b/frontend/src/session-console.ts @@ -0,0 +1,126 @@ +import { guardTeacherApp } from "./auth-guard"; +import { initTeacherNav } from "./teacher-nav"; + +await guardTeacherApp(); + +const classSel = document.getElementById("class-sel") as HTMLSelectElement; +const roster = document.getElementById("roster") as HTMLUListElement; +const msg = document.getElementById("sess-msg") as HTMLParagraphElement; +const pinReveal = document.getElementById("pin-reveal") as HTMLDivElement; +const pinDigits = document.getElementById("pin-reveal-digits") as HTMLParagraphElement; + +interface ClassRow { + id: number; + name: string; + joinCode: string; + studentCount: number; +} + +interface StudentRow { + id: number; + displayName: string; + strokeCount: number; + lastStrokeAt: number | null; +} + +function currentClassId(): number { + return Number(classSel.value); +} + +async function api(path: string, init?: RequestInit): Promise<{ ok: boolean; body: T }> { + const r = await fetch(path, { ...init, credentials: "include" }); + return { ok: r.ok, body: (await r.json()) as T }; +} + +function escHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +/** Fetch and display the teacher's stable PIN. */ +async function loadTeacherPin(): Promise { + const { ok, body } = await api<{ pin?: string }>("/api/teacher/pin"); + if (ok && (body as { pin?: string }).pin) { + pinDigits.textContent = (body as { pin: string }).pin; + pinReveal.removeAttribute("hidden"); + } +} + +async function loadClassSelector(): Promise { + const { ok, body } = await api<{ classes?: ClassRow[] }>("/api/teacher/classes"); + classSel.innerHTML = ""; + if (!ok || !body.classes?.length) { + window.location.replace("/classroom-setup.html"); + return; + } + const params = new URLSearchParams(window.location.search); + const want = Number(params.get("classId")); + for (const c of body.classes) { + const opt = document.createElement("option"); + opt.value = String(c.id); + opt.textContent = c.name; + classSel.appendChild(opt); + } + const ids = body.classes.map((c) => c.id); + classSel.value = String(ids.includes(want) ? want : body.classes[0]!.id); + + const analyticsLink = document.getElementById("t-analytics-link"); + if (analyticsLink) analyticsLink.setAttribute("href", `/analytics-dashboard.html?classId=${classSel.value}`); +} + +async function refreshRoster(): Promise { + const cid = currentClassId(); + const analyticsLink = document.getElementById("t-analytics-link"); + if (analyticsLink) analyticsLink.setAttribute("href", `/analytics-dashboard.html?classId=${cid}`); + + const { ok, body } = await api<{ students?: StudentRow[] }>(`/api/teacher/classes/${cid}/students`); + roster.innerHTML = ""; + if (!ok || !body.students?.length) { + roster.innerHTML = "
    • No students — add them in the Roster tab.
    • "; + return; + } + for (const s of body.students) { + const li = document.createElement("li"); + li.className = "t-list-item"; + li.innerHTML = ` + ${escHtml(s.displayName)} + ${s.strokeCount} strokes + `; + const btn = document.createElement("button"); + btn.className = "t-btn-sage"; + btn.style.padding = "5px 13px"; + btn.style.fontSize = "12px"; + btn.textContent = "Start session"; + btn.addEventListener("click", () => void startSession(s.id, s.displayName)); + li.appendChild(btn); + roster.appendChild(li); + } +} + +async function startSession(classStudentId: number, displayName: string): Promise { + msg.setAttribute("hidden", ""); + const { ok, body } = await api<{ error?: string }>("/api/teacher/session/start", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ classStudentId }), + }); + if (!ok) { + msg.textContent = (body as { error?: string }).error ?? "Failed to start session"; + msg.classList.add("t-msg--err"); + msg.classList.remove("t-msg--ok"); + msg.removeAttribute("hidden"); + return; + } + window.location.assign("/index.html"); +} + +classSel.addEventListener("change", async () => { + const u = new URL(window.location.href); + u.searchParams.set("classId", classSel.value); + history.replaceState(null, "", u.toString()); + await refreshRoster(); +}); + +initTeacherNav({ active: "class" }); + +await Promise.all([loadTeacherPin(), loadClassSelector()]); +await refreshRoster(); diff --git a/frontend/src/session-pad-lock.ts b/frontend/src/session-pad-lock.ts new file mode 100644 index 0000000..d579c6f --- /dev/null +++ b/frontend/src/session-pad-lock.ts @@ -0,0 +1,145 @@ +/** + * Pad session lock. + * + * When a phoneme_practice cookie is present: + * - The "← console" link is hidden; an "end session" button appears instead. + * - history.pushState traps prevent the browser back button from leaving. + * - beforeunload shows the browser's "Leave site?" dialog on tab-close / address-bar nav. + * - The "end session" button opens a PIN modal; on correct PIN the practice + * cookie is cleared and the browser is sent to the session console. + */ + +const SESSION_CONSOLE = "./session-console.html"; + +async function fetchPracticeActive(): Promise { + try { + const r = await fetch("/api/practice/status", { credentials: "include" }); + if (!r.ok) return false; + const j = (await r.json()) as { active?: boolean }; + return j.active === true; + } catch { + return false; + } +} + +export async function installPadSessionLock(): Promise { + const navConsole = document.getElementById("nav-console") as HTMLAnchorElement | null; + const navBtnEnd = document.getElementById("nav-btn-end") as HTMLButtonElement | null; + const pinModal = document.getElementById("pin-modal") as HTMLDivElement | null; + const pinInput = document.getElementById("pin-input") as HTMLInputElement | null; + const pinConfirm = document.getElementById("pin-confirm") as HTMLButtonElement | null; + const pinCancel = document.getElementById("pin-cancel") as HTMLButtonElement | null; + const pinMsg = document.getElementById("pin-msg") as HTMLParagraphElement | null; + + if (!navBtnEnd || !pinModal || !pinInput || !pinConfirm || !pinMsg) return; + + let popstateHandler: (() => void) | null = null; + let beforeunloadHandler: ((e: BeforeUnloadEvent) => void) | null = null; + + function showModal(): void { + pinModal!.classList.add("is-open"); + pinInput!.focus(); + } + + function hideModal(): void { + pinModal!.classList.remove("is-open"); + pinInput!.value = ""; + pinMsg!.textContent = ""; + pinMsg!.classList.remove("pin-msg--err"); + } + + function lock(): void { + document.body.classList.add("session-locked"); + navConsole?.setAttribute("hidden", ""); + navBtnEnd!.removeAttribute("hidden"); + + // Push a history entry so the first back-press fires popstate instead of navigating. + history.pushState(null, "", window.location.href); + + popstateHandler = () => { + history.pushState(null, "", window.location.href); + }; + window.addEventListener("popstate", popstateHandler); + + beforeunloadHandler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = ""; + }; + window.addEventListener("beforeunload", beforeunloadHandler); + } + + function unlock(): void { + document.body.classList.remove("session-locked"); + navConsole?.removeAttribute("hidden"); + navBtnEnd!.setAttribute("hidden", ""); + + if (popstateHandler) { + window.removeEventListener("popstate", popstateHandler); + popstateHandler = null; + } + if (beforeunloadHandler) { + window.removeEventListener("beforeunload", beforeunloadHandler); + beforeunloadHandler = null; + } + } + + navBtnEnd.addEventListener("click", () => showModal()); + pinCancel?.addEventListener("click", () => hideModal()); + + // Close modal on backdrop click + pinModal.addEventListener("click", (e) => { + if (e.target === pinModal) hideModal(); + }); + + pinConfirm.addEventListener("click", async () => { + const pin = pinInput.value.trim(); + if (!pin) return; + + pinConfirm.disabled = true; + pinMsg.classList.remove("pin-msg--err"); + pinMsg.textContent = ""; + + try { + const r = await fetch("/api/teacher/session/end", { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ pin }), + }); + const body = (await r.json()) as { error?: string }; + + if (!r.ok) { + pinMsg.textContent = body.error ?? `HTTP ${r.status}`; + pinMsg.classList.add("pin-msg--err"); + pinConfirm.disabled = false; + return; + } + + // Remove beforeunload so the programmatic navigation isn't blocked. + if (beforeunloadHandler) { + window.removeEventListener("beforeunload", beforeunloadHandler); + beforeunloadHandler = null; + } + window.location.assign(SESSION_CONSOLE); + } catch (e) { + pinMsg.textContent = (e as Error).message; + pinMsg.classList.add("pin-msg--err"); + pinConfirm.disabled = false; + } + }); + + // Also allow Enter key in pin input + pinInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") pinConfirm.click(); + }); + + document.addEventListener("visibilitychange", async () => { + if (document.visibilityState === "visible") { + const active = await fetchPracticeActive(); + active ? lock() : unlock(); + } + }); + + const active = await fetchPracticeActive(); + active ? lock() : unlock(); +} diff --git a/frontend/src/style.css b/frontend/src/style.css index 57f5348..ca7aa85 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -117,3 +117,98 @@ pre { max-height: 8em; overflow: auto; } + +/* "End session" button in pad header — visible only during an active session. */ +.pad-header .nav-btn-end { + background: #b8843f; + color: #fbf7f1; + border-color: #b8843f; + font-size: 13px; + padding: 4px 10px; +} +.pad-header .nav-btn-end:hover { background: #9e7030; border-color: #9e7030; } + +/* PIN modal — centered over the dark pad canvas. */ +.pin-modal { + display: none; + position: fixed; + inset: 0; + z-index: 200; + background: rgba(0, 0, 0, 0.82); + align-items: center; + justify-content: center; +} +.pin-modal.is-open { display: flex; } + +.pin-modal-card { + background: #f6efe3; + color: #2c2a26; + border-radius: 14px; + padding: 32px 28px 24px; + max-width: 320px; + width: 90%; + text-align: center; + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5); +} + +.pin-modal-title { + margin: 0 0 8px; + font-size: 17px; + font-weight: 600; + color: #2c2a26; +} + +.pin-modal-hint { + margin: 0 0 18px; + font-size: 13px; + color: #8a8276; + line-height: 1.45; +} + +#pin-input { + width: 100%; + text-align: center; + font-size: 26px; + letter-spacing: 0.25em; + padding: 10px 12px; + border: 2px solid #d5ccbe; + border-radius: 8px; + background: #f0e7d5; + color: #2c2a26; + font-family: ui-monospace, monospace; + outline: none; + margin-bottom: 10px; +} +#pin-input:focus { border-color: #b8843f; } + +.pin-msg { + min-height: 1.3em; + font-size: 12px; + color: #6b8554; + margin: 0 0 14px; +} +.pin-msg.pin-msg--err { color: #c97d6a; } + +.pin-modal-actions { + display: flex; + gap: 8px; + justify-content: center; +} + +.pin-modal-card button { + background: #b8843f; + color: #fbf7f1; + border: 1px solid #b8843f; + padding: 8px 20px; + border-radius: 8px; + font: inherit; + font-size: 14px; + cursor: pointer; +} +.pin-modal-card button:hover { background: #9e7030; border-color: #9e7030; } +.pin-modal-card button.secondary { + background: transparent; + color: #8a8276; + border-color: #d5ccbe; +} +.pin-modal-card button.secondary:hover { background: #f0e7d5; color: #2c2a26; } diff --git a/frontend/src/teacher-login.ts b/frontend/src/teacher-login.ts new file mode 100644 index 0000000..a1e22d6 --- /dev/null +++ b/frontend/src/teacher-login.ts @@ -0,0 +1,102 @@ +const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID?.trim(); + +const warnEl = document.getElementById("login-warn") as HTMLParagraphElement; +const gsiHost = document.getElementById("gsi-button") as HTMLDivElement; + +interface GsiCredentialResponse { + credential: string; +} + +interface GoogleAccounts { + id: { + initialize: (opts: { + client_id: string; + callback: (r: GsiCredentialResponse) => void; + }) => void; + renderButton: (el: HTMLElement, opts: Record) => void; + }; +} + +declare global { + interface Window { + google?: { accounts: GoogleAccounts }; + } +} + +function loadScript(src: string): Promise { + return new Promise((resolve, reject) => { + const s = document.createElement("script"); + s.src = src; + s.async = true; + s.onload = () => resolve(); + s.onerror = () => reject(new Error(`failed to load ${src}`)); + document.head.appendChild(s); + }); +} + +async function routeAfterSignIn(): Promise { + const r = await fetch("/api/teacher/classes", { credentials: "include" }); + if (!r.ok) { + window.location.replace("/session-console.html"); + return; + } + const body = (await r.json()) as { classes?: { id: number }[] }; + const list = body.classes ?? []; + const params = new URLSearchParams(window.location.search); + const next = params.get("next"); + + if (list.length === 0) { + window.location.replace("/classroom-setup.html"); + return; + } + if (next && next.startsWith("/") && !next.startsWith("//")) { + window.location.replace(next); + return; + } + window.location.replace(`/session-console.html?classId=${list[0]!.id}`); +} + +async function onCredential(res: GsiCredentialResponse): Promise { + const r = await fetch("/api/auth/google", { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ idToken: res.credential }), + }); + if (!r.ok) { + warnEl.textContent = await r.text(); + warnEl.hidden = false; + return; + } + await routeAfterSignIn(); +} + +async function boot(): Promise { + const me = await fetch("/api/auth/me", { credentials: "include" }); + if (me.ok) { + await routeAfterSignIn(); + return; + } + if (!clientId) { + warnEl.textContent = + "Set VITE_GOOGLE_CLIENT_ID in frontend/.env (Web client id, same as worker GOOGLE_CLIENT_ID)."; + warnEl.hidden = false; + return; + } + await loadScript("https://accounts.google.com/gsi/client"); + if (!window.google?.accounts?.id) return; + window.google.accounts.id.initialize({ + client_id: clientId, + callback: (r: GsiCredentialResponse) => { + void onCredential(r); + }, + }); + window.google.accounts.id.renderButton(gsiHost, { + type: "standard", + theme: "outline", + size: "large", + text: "signin_with", + }); +} + +void boot(); diff --git a/frontend/src/teacher-nav.ts b/frontend/src/teacher-nav.ts new file mode 100644 index 0000000..ea88510 --- /dev/null +++ b/frontend/src/teacher-nav.ts @@ -0,0 +1,32 @@ +/** Injects the shared teacher header/nav bar and wires the sign-out button. */ + +export type TeacherNavTab = "class" | "roster" | "analytics"; + +export interface TeacherNavOptions { + active: TeacherNavTab; + /** Override the Analytics href (e.g. include ?classId=). */ + analyticsHref?: string; +} + +export function initTeacherNav(opts: TeacherNavOptions): void { + const analyticsHref = opts.analyticsHref ?? "/analytics-dashboard.html"; + + const header = document.createElement("header"); + header.className = "t-header"; + header.innerHTML = ` + phoneme + + + `; + + document.body.insertBefore(header, document.body.firstChild); + + document.getElementById("t-signout-btn")?.addEventListener("click", async () => { + await fetch("/api/auth/logout", { method: "POST", credentials: "include" }).catch(() => null); + window.location.href = "/teacher-login.html"; + }); +} diff --git a/frontend/src/teacher.css b/frontend/src/teacher.css new file mode 100644 index 0000000..2686b19 --- /dev/null +++ b/frontend/src/teacher.css @@ -0,0 +1,399 @@ +@import url('https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&family=Inter:wght@400;500;600&display=swap'); + +/* ── Design tokens ───────────────────────────────────────────────────── */ +:root { + --t-bg: #e8dece; + --t-surface: #f6efe3; + --t-card: #f0e7d5; + --t-ink: #2c2a26; + --t-ink-soft: #8a8276; + --t-line: #d5ccbe; + --t-ochre: #b8843f; + --t-ochre-dk: #9e7030; + --t-sage: #6b8554; + --t-rose: #c97d6a; +} + +/* ── Reset / base ────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + height: 100%; +} + +body.t-body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 14px; + line-height: 1.5; + background: var(--t-bg); + color: var(--t-ink); + min-height: 100%; +} + +/* ── Teacher header / nav bar ────────────────────────────────────────── */ +.t-header { + display: flex; + align-items: center; + gap: 0; + padding: 0 24px; + background: #ddd0bc; + border-bottom: 1px solid #c9bfae; + height: 52px; + position: sticky; + top: 0; + z-index: 10; +} + +.t-brand { + font-family: 'Newsreader', Georgia, serif; + font-size: 18px; + font-style: italic; + color: var(--t-ochre-dk); + letter-spacing: 0.02em; + margin-right: 28px; + flex-shrink: 0; +} + +.t-nav { + display: flex; + gap: 2px; + flex: 1; +} + +.t-nav-tab { + display: inline-flex; + align-items: center; + padding: 0 16px; + height: 52px; + font-size: 13px; + font-weight: 500; + color: var(--t-ink-soft); + text-decoration: none; + border-bottom: 3px solid transparent; + transition: color 0.15s, border-color 0.15s; +} +.t-nav-tab:hover { color: var(--t-ink); } +.t-nav-tab.is-active { + color: var(--t-ochre-dk); + border-bottom-color: var(--t-ochre); +} + +.t-signout { + margin-left: auto; + background: transparent; + color: var(--t-ink-soft); + border: 1px solid var(--t-line); + border-radius: 6px; + padding: 5px 12px; + font-family: inherit; + font-size: 12px; + cursor: pointer; + transition: background 0.12s, color 0.12s; +} +.t-signout:hover { background: var(--t-card); color: var(--t-ink); } + +/* ── Main content wrapper ────────────────────────────────────────────── */ +.t-main { + max-width: 780px; + margin: 0 auto; + padding: 32px 24px 48px; +} + +.t-page-title { + font-family: 'Newsreader', Georgia, serif; + font-size: 26px; + font-weight: 400; + margin: 0 0 4px; + color: var(--t-ink); +} + +.t-page-sub { + font-size: 13px; + color: var(--t-ink-soft); + margin: 0 0 24px; +} + +/* ── Cards ───────────────────────────────────────────────────────────── */ +.t-card { + background: var(--t-surface); + border: 1px solid var(--t-line); + border-radius: 12px; + padding: 20px 22px; + margin-bottom: 16px; +} + +.t-card-title { + font-family: 'Newsreader', Georgia, serif; + font-size: 18px; + font-weight: 400; + margin: 0 0 12px; + color: var(--t-ink); +} + +/* ── Forms ───────────────────────────────────────────────────────────── */ +.t-form { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: flex-end; +} + +.t-label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + font-weight: 500; + color: var(--t-ink-soft); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.t-input { + background: var(--t-card); + color: var(--t-ink); + border: 1px solid var(--t-line); + padding: 7px 11px; + border-radius: 7px; + font-family: inherit; + font-size: 14px; + outline: none; + transition: border-color 0.15s; + min-width: 180px; +} +.t-input:focus { border-color: var(--t-ochre); } + +.t-select { + background: var(--t-card); + color: var(--t-ink); + border: 1px solid var(--t-line); + padding: 7px 11px; + border-radius: 7px; + font-family: inherit; + font-size: 14px; + outline: none; + cursor: pointer; +} +.t-select:focus { border-color: var(--t-ochre); } + +/* ── Buttons ─────────────────────────────────────────────────────────── */ +.t-btn { + background: var(--t-ochre); + color: #fbf7f1; + border: 1px solid var(--t-ochre); + padding: 8px 18px; + border-radius: 8px; + font-family: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 0.12s; + white-space: nowrap; +} +.t-btn:hover { background: var(--t-ochre-dk); border-color: var(--t-ochre-dk); } +.t-btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.t-btn-ghost { + background: transparent; + color: var(--t-ink-soft); + border: 1px solid var(--t-line); + padding: 7px 14px; + border-radius: 8px; + font-family: inherit; + font-size: 13px; + cursor: pointer; + transition: background 0.12s, color 0.12s; + white-space: nowrap; +} +.t-btn-ghost:hover { background: var(--t-card); color: var(--t-ink); } + +.t-btn-sage { + background: var(--t-sage); + color: #fbf7f1; + border: 1px solid var(--t-sage); + padding: 8px 18px; + border-radius: 8px; + font-family: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 0.12s; + white-space: nowrap; +} +.t-btn-sage:hover { background: #5a7244; border-color: #5a7244; } + +/* ── Lists ───────────────────────────────────────────────────────────── */ +.t-list { + list-style: none; + margin: 0; + padding: 0; +} + +.t-list-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 0; + border-bottom: 1px solid var(--t-line); + font-size: 14px; +} +.t-list-item:last-child { border-bottom: none; } + +.t-list-name { + flex: 1; + font-weight: 500; +} + +.t-list-meta { + font-size: 12px; + color: var(--t-ink-soft); +} + +/* ── Alerts / messages ───────────────────────────────────────────────── */ +.t-msg { + font-size: 13px; + padding: 10px 14px; + border-radius: 8px; + margin-bottom: 14px; + background: var(--t-card); + color: var(--t-ink); + border: 1px solid var(--t-line); +} +.t-msg[hidden] { display: none; } +.t-msg--err { background: #fce8e3; border-color: #e8c4bc; color: #8a3a2a; } +.t-msg--ok { background: #e8f0e3; border-color: #bdd4b0; color: #3a5a2a; } + +.t-muted { color: var(--t-ink-soft); font-size: 13px; } + +/* ── PIN reveal card (session-console) ───────────────────────────────── */ +.t-pin-reveal { + background: var(--t-card); + border: 2px solid var(--t-ochre); + border-radius: 12px; + padding: 18px 20px; + text-align: center; + margin-bottom: 16px; +} +.t-pin-reveal[hidden] { display: none; } + +.t-pin-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--t-ink-soft); + margin: 0 0 6px; +} + +.t-pin-digits { + font-family: 'Newsreader', Georgia, serif; + font-size: 44px; + letter-spacing: 0.25em; + color: var(--t-ochre-dk); + font-weight: 500; + line-height: 1; + margin: 0 0 6px; +} + +.t-pin-note { + font-size: 12px; + color: var(--t-ink-soft); + margin: 0; +} + +/* ── Toolbar / row of actions ────────────────────────────────────────── */ +.t-toolbar { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + margin-bottom: 20px; +} + +/* ── Analytics section header ────────────────────────────────────────── */ +.t-section-title { + font-family: 'Newsreader', Georgia, serif; + font-size: 20px; + font-weight: 400; + color: var(--t-ink); + margin: 28px 0 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--t-line); +} +.class-section:first-child .t-section-title { margin-top: 0; } + +/* ── Analytics stat grid ─────────────────────────────────────────────── */ +.t-stat-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 10px; + margin: 8px 0; +} + +.t-stat { + background: var(--t-card); + border: 1px solid var(--t-line); + border-radius: 8px; + padding: 10px 12px; + text-align: center; +} + +.t-stat strong { + display: block; + font-size: 20px; + font-family: 'Newsreader', Georgia, serif; + color: var(--t-ochre-dk); + margin-bottom: 2px; +} + +.t-stat span { + font-size: 11px; + color: var(--t-ink-soft); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* ── Login page ──────────────────────────────────────────────────────── */ +.t-login-wrap { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 24px; +} + +.t-login-card { + background: var(--t-surface); + border: 1px solid var(--t-line); + border-radius: 16px; + padding: 40px 36px 32px; + max-width: 380px; + width: 100%; + text-align: center; +} + +.t-login-logo { + font-family: 'Newsreader', Georgia, serif; + font-size: 32px; + font-style: italic; + color: var(--t-ochre-dk); + letter-spacing: 0.02em; + margin: 0 0 4px; +} + +.t-login-tagline { + font-size: 13px; + color: var(--t-ink-soft); + margin: 0 0 28px; +} + +/* ── Responsive ──────────────────────────────────────────────────────── */ +@media (max-width: 540px) { + .t-header { padding: 0 14px; } + .t-brand { margin-right: 12px; } + .t-nav-tab { padding: 0 10px; font-size: 12px; } + .t-main { padding: 20px 14px 40px; } + .t-pin-digits { font-size: 36px; } +} diff --git a/frontend/src/visualizer.ts b/frontend/src/visualizer.ts index 110f52f..be2d70f 100644 --- a/frontend/src/visualizer.ts +++ b/frontend/src/visualizer.ts @@ -1,4 +1,4 @@ -export {}; +import { guardTeacherApp } from "./auth-guard"; type Pt = [number, number]; @@ -106,7 +106,7 @@ function renderForWriter(writer: string) { } async function init() { - const r = await fetch("/uji_strokes.json"); + const r = await fetch("/uji_strokes.json", { credentials: "include" }); if (!r.ok) { grid.textContent = `failed to load /uji_strokes.json: ${r.status} — run \`python3 data/parse_uji.py\``; return; @@ -124,4 +124,7 @@ async function init() { renderForWriter(writerSel.value); } -init(); +void (async () => { + await guardTeacherApp(); + await init(); +})(); diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..b7225fb --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_GOOGLE_CLIENT_ID?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/teacher-login.html b/frontend/teacher-login.html new file mode 100644 index 0000000..e5ac21c --- /dev/null +++ b/frontend/teacher-login.html @@ -0,0 +1,20 @@ + + + + + + phoneme · sign in + + + + + + + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 1ca316c..96bc139 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -12,6 +12,11 @@ export default defineConfig({ rollupOptions: { input: { main: fileURLToPath(new URL("./index.html", import.meta.url)), + dashboard: fileURLToPath(new URL("./dashboard.html", import.meta.url)), + teacherLogin: fileURLToPath(new URL("./teacher-login.html", import.meta.url)), + classroomSetup: fileURLToPath(new URL("./classroom-setup.html", import.meta.url)), + sessionConsole: fileURLToPath(new URL("./session-console.html", import.meta.url)), + analyticsDashboard: fileURLToPath(new URL("./analytics-dashboard.html", import.meta.url)), visualizer: fileURLToPath(new URL("./visualizer.html", import.meta.url)), objectBox: fileURLToPath(new URL("./object-box.html", import.meta.url)), }, diff --git a/package-lock.json b/package-lock.json index 8151149..5a3b5d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20250510.0", + "@types/node": "^25.6.2", "@types/three": "^0.184.1", "concurrently": "^9.0.0", "typescript": "^5.6.0", @@ -1405,6 +1406,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz", + "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, "node_modules/@types/stats.js": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", @@ -2348,6 +2359,13 @@ "node": ">=14.0" } }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, "node_modules/unenv": { "version": "2.0.0-rc.14", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.14.tgz", diff --git a/package.json b/package.json index a1b8122..4a9aa9b 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,13 @@ "type": "module", "scripts": { "build": "vite build --config frontend/vite.config.ts", - "prebuild": "npm run data:filter", - "deploy": "npm run build && wrangler deploy --config workers/api/wrangler.jsonc", + "build:with-data": "npm run data:filter && vite build --config frontend/vite.config.ts", + "deploy": "npm run build:with-data && wrangler deploy --config workers/api/wrangler.jsonc", "predev:frontend": "npm run data:filter", "dev:frontend": "vite --config frontend/vite.config.ts", + "dashboard": "vite --config frontend/vite.config.ts --open /teacher-login.html", + "previsualizer": "npm run data:filter", + "visualizer": "vite --config frontend/vite.config.ts --open /index.html", "dev:api": "wrangler dev --config workers/api/wrangler.jsonc --port 8787", "dev": "concurrently -n web,api -c blue,green \"npm:dev:frontend\" \"npm:dev:api\"", "data:filter": "node scripts/filter_writers.mjs", @@ -19,6 +22,7 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20250510.0", + "@types/node": "^25.6.2", "@types/three": "^0.184.1", "concurrently": "^9.0.0", "typescript": "^5.6.0", diff --git a/workers/api/migrations/0002_teacher_class.sql b/workers/api/migrations/0002_teacher_class.sql new file mode 100644 index 0000000..a376a66 --- /dev/null +++ b/workers/api/migrations/0002_teacher_class.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS teachers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + google_sub TEXT NOT NULL UNIQUE, + email TEXT NOT NULL, + name TEXT, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS classes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + teacher_id INTEGER NOT NULL REFERENCES teachers(id), + name TEXT NOT NULL, + join_code TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_classes_teacher ON classes(teacher_id); + +CREATE TABLE IF NOT EXISTS class_students ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + class_id INTEGER NOT NULL REFERENCES classes(id), + display_name TEXT NOT NULL, + secret TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_class_students_class ON class_students(class_id); + +ALTER TABLE stroke_events ADD COLUMN class_student_id INTEGER REFERENCES class_students(id); + +CREATE INDEX IF NOT EXISTS idx_stroke_class_student ON stroke_events(class_student_id, timestamp); diff --git a/workers/api/src/index.ts b/workers/api/src/index.ts index ceb3172..1d7f8d6 100644 --- a/workers/api/src/index.ts +++ b/workers/api/src/index.ts @@ -1,20 +1,89 @@ -import { json, type Env } from "./router"; +import { json, type Env, type RouteCtx } from "./router"; import { routes } from "./routes"; +import { handleClassReport } from "./routes/teacher_class_report"; +import { handleAddClassStudent } from "./routes/teacher_sessions"; +import { handleClassStudents, handleStudentAnalytics } from "./routes/teacher_analytics"; +import { isTeacherAuthenticated } from "./lib/teacher_auth"; const table = new Map(routes.map((r) => [`${r.method} ${r.path}`, r.handler])); +const RE_CLASS_STUDENTS = /^\/api\/teacher\/classes\/(\d+)\/students$/; +const RE_STUDENT_ANALYTICS = /^\/api\/teacher\/students\/(\d+)\/analytics$/; +const RE_POST_CLASS_STUDENTS = /^\/api\/teacher\/classes\/(\d+)\/students$/; +const RE_CLASS_REPORT = /^\/api\/teacher\/classes\/(\d+)\/report$/; + +function assetMayBypassTeacherGate(pathname: string): boolean { + if (pathname === "/teacher-login.html") return true; + if (pathname.startsWith("/assets/")) return true; + const dot = pathname.lastIndexOf("."); + if (dot === -1) return false; + const ext = pathname.slice(dot).toLowerCase(); + return [ + ".js", + ".mjs", + ".css", + ".map", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".svg", + ".ico", + ".woff", + ".woff2", + ".ttf", + ".json", + ].includes(ext); +} + export default { async fetch(req: Request, env: Env): Promise { const url = new URL(req.url); if (url.pathname.startsWith("/api/")) { - const handler = table.get(`${req.method} ${url.pathname}`); - if (!handler) return json({ error: "not found" }, 404); + const key = `${req.method} ${url.pathname}`; + const staticHandler = table.get(key); + const ctx: RouteCtx = { req, env, url }; try { - return await handler({ req, env, url }); + if (staticHandler) return await staticHandler(ctx); + + let m = url.pathname.match(RE_CLASS_STUDENTS); + if (m && req.method === "GET") { + return await handleClassStudents(ctx, Number(m[1])); + } + m = url.pathname.match(RE_STUDENT_ANALYTICS); + if (m && req.method === "GET") { + return await handleStudentAnalytics(ctx, Number(m[1])); + } + m = url.pathname.match(RE_POST_CLASS_STUDENTS); + if (m && req.method === "POST") { + return await handleAddClassStudent(ctx, Number(m[1])); + } + m = url.pathname.match(RE_CLASS_REPORT); + if (m && req.method === "GET") { + return await handleClassReport(ctx, Number(m[1])); + } + + return json({ error: "not found" }, 404); } catch (err) { return json({ error: (err as Error).message }, 500); } } + + const pathname = url.pathname === "" ? "/" : url.pathname; + if (req.method === "GET" || req.method === "HEAD") { + if (!assetMayBypassTeacherGate(pathname)) { + const ok = await isTeacherAuthenticated(req, env); + if (!ok) { + const next = encodeURIComponent(pathname + url.search); + return Response.redirect( + new URL(`/teacher-login.html?next=${next}`, url.origin).href, + 302, + ); + } + } + } + return env.ASSETS.fetch(req); }, } satisfies ExportedHandler; diff --git a/workers/api/src/lib/cookies.ts b/workers/api/src/lib/cookies.ts new file mode 100644 index 0000000..e8b27e9 --- /dev/null +++ b/workers/api/src/lib/cookies.ts @@ -0,0 +1,83 @@ +const TEACHER_COOKIE = "phoneme_teacher"; +export const PRACTICE_COOKIE = "phoneme_practice"; + +export function parseCookies(header: string | null): Record { + const out: Record = {}; + if (!header) return out; + for (const part of header.split(";")) { + const idx = part.indexOf("="); + if (idx === -1) continue; + const k = part.slice(0, idx).trim(); + const v = part.slice(idx + 1).trim(); + if (k) out[k] = decodeURIComponent(v); + } + return out; +} + +export function getTeacherTokenFromCookie(req: Request): string | null { + const c = parseCookies(req.headers.get("Cookie")); + const v = c[TEACHER_COOKIE]; + return v && v.length > 0 ? v : null; +} + +export function setTeacherCookieHeader( + token: string, + secure: boolean, + maxAgeSec: number, +): string { + const flags = [ + `${TEACHER_COOKIE}=${encodeURIComponent(token)}`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + `Max-Age=${maxAgeSec}`, + ]; + if (secure) flags.push("Secure"); + return flags.join("; "); +} + +export function clearTeacherCookieHeader(secure: boolean): string { + const flags = [ + `${TEACHER_COOKIE}=`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + "Max-Age=0", + ]; + if (secure) flags.push("Secure"); + return flags.join("; "); +} + +export function getPracticeTokenFromCookie(req: Request): string | null { + const c = parseCookies(req.headers.get("Cookie")); + const v = c[PRACTICE_COOKIE]; + return v && v.length > 0 ? v : null; +} + +export function setPracticeCookieHeader( + token: string, + secure: boolean, + maxAgeSec: number, +): string { + const flags = [ + `${PRACTICE_COOKIE}=${encodeURIComponent(token)}`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + `Max-Age=${maxAgeSec}`, + ]; + if (secure) flags.push("Secure"); + return flags.join("; "); +} + +export function clearPracticeCookieHeader(secure: boolean): string { + const flags = [ + `${PRACTICE_COOKIE}=`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + "Max-Age=0", + ]; + if (secure) flags.push("Secure"); + return flags.join("; "); +} diff --git a/workers/api/src/lib/join_code.ts b/workers/api/src/lib/join_code.ts new file mode 100644 index 0000000..038f861 --- /dev/null +++ b/workers/api/src/lib/join_code.ts @@ -0,0 +1,15 @@ +const ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; + +export function randomJoinCode(length = 8): string { + const bytes = new Uint8Array(length); + crypto.getRandomValues(bytes); + let s = ""; + for (let i = 0; i < length; i++) s += ALPHABET[bytes[i]! % ALPHABET.length]; + return s; +} + +export function randomStudentSecret(): string { + const bytes = new Uint8Array(24); + crypto.getRandomValues(bytes); + return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); +} diff --git a/workers/api/src/lib/jwt.ts b/workers/api/src/lib/jwt.ts new file mode 100644 index 0000000..f1a3d92 --- /dev/null +++ b/workers/api/src/lib/jwt.ts @@ -0,0 +1,165 @@ +/** HS256 JWT for teacher sessions (Web Crypto). */ + +function b64urlEncode(bytes: Uint8Array): string { + let s = ""; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function b64urlDecode(s: string): Uint8Array | null { + const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4)); + const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + pad; + try { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } catch { + return null; + } +} + +async function hmacSha256B64url(data: string, secret: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(data), + ); + return b64urlEncode(new Uint8Array(sig)); +} + +export function timingSafeEq(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} + +export interface TeacherJwtPayload { + tid: number; + exp: number; + iat: number; +} + +export async function signTeacherJwt( + teacherId: number, + secret: string, + ttlSec: number, +): Promise { + const iat = Math.floor(Date.now() / 1000); + const exp = iat + ttlSec; + const header = b64urlEncode(new TextEncoder().encode(JSON.stringify({ alg: "HS256", typ: "JWT" }))); + const payload = b64urlEncode( + new TextEncoder().encode(JSON.stringify({ tid: teacherId, iat, exp })), + ); + const data = `${header}.${payload}`; + const sig = await hmacSha256B64url(data, secret); + return `${data}.${sig}`; +} + +export async function verifyTeacherJwt( + token: string, + secret: string, +): Promise { + const parts = token.split("."); + if (parts.length !== 3) return null; + const [h, p, s] = parts; + const data = `${h}.${p}`; + const expected = await hmacSha256B64url(data, secret); + if (!timingSafeEq(s, expected)) return null; + const raw = b64urlDecode(p); + if (!raw) return null; + let body: unknown; + try { + body = JSON.parse(new TextDecoder().decode(raw)); + } catch { + return null; + } + if (!body || typeof body !== "object") return null; + const o = body as Record; + if (typeof o.tid !== "number" || typeof o.exp !== "number") return null; + if (o.exp < Math.floor(Date.now() / 1000)) return null; + return { tid: o.tid, exp: o.exp, iat: typeof o.iat === "number" ? o.iat : 0 }; +} + +export interface PracticeJwtPayload { + typ: "practice"; + csid: number; + exp: number; + iat: number; +} + +export async function signPracticeJwt( + classStudentId: number, + secret: string, + ttlSec: number, +): Promise { + const iat = Math.floor(Date.now() / 1000); + const exp = iat + ttlSec; + const header = b64urlEncode(new TextEncoder().encode(JSON.stringify({ alg: "HS256", typ: "JWT" }))); + const payload = b64urlEncode( + new TextEncoder().encode( + JSON.stringify({ typ: "practice" as const, csid: classStudentId, iat, exp }), + ), + ); + const data = `${header}.${payload}`; + const sig = await hmacSha256B64url(data, secret); + return `${data}.${sig}`; +} + +export async function verifyPracticeJwt( + token: string, + secret: string, +): Promise { + const parts = token.split("."); + if (parts.length !== 3) return null; + const [h, p, s] = parts; + const data = `${h}.${p}`; + const expected = await hmacSha256B64url(data, secret); + if (!timingSafeEq(s, expected)) return null; + const raw = b64urlDecode(p); + if (!raw) return null; + let body: unknown; + try { + body = JSON.parse(new TextDecoder().decode(raw)); + } catch { + return null; + } + if (!body || typeof body !== "object") return null; + const o = body as Record; + if (o.typ !== "practice" || typeof o.csid !== "number" || typeof o.exp !== "number") return null; + if (o.exp < Math.floor(Date.now() / 1000)) return null; + return { + typ: "practice", + csid: o.csid, + exp: o.exp, + iat: typeof o.iat === "number" ? o.iat : 0, + }; +} + +/** + * Derives a stable 4-digit PIN (1000–9999) for a teacher from their DB id and + * the shared JWT secret. The same teacher always gets the same PIN; different + * teachers (or different secrets) get different PINs. No database required. + */ +export async function deriveTeacherPin(teacherId: number, secret: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = new Uint8Array( + await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`phoneme-pin:${teacherId}`)), + ); + const n = ((sig[0]! << 24) | (sig[1]! << 16) | (sig[2]! << 8) | sig[3]!) >>> 0; + return (1000 + (n % 9000)).toString(); +} diff --git a/workers/api/src/lib/secrets.ts b/workers/api/src/lib/secrets.ts new file mode 100644 index 0000000..bc8862e --- /dev/null +++ b/workers/api/src/lib/secrets.ts @@ -0,0 +1,7 @@ +import type { Env } from "../router"; + +export function jwtSigningSecret(env: Env): string | null { + const a = env.JWT_SECRET?.trim(); + const b = env.SESSION_SECRET?.trim(); + return (a && a.length > 0 ? a : b && b.length > 0 ? b : null) ?? null; +} diff --git a/workers/api/src/lib/teacher_auth.ts b/workers/api/src/lib/teacher_auth.ts new file mode 100644 index 0000000..21cc9da --- /dev/null +++ b/workers/api/src/lib/teacher_auth.ts @@ -0,0 +1,63 @@ +import type { Env } from "../router"; +import { getTeacherTokenFromCookie } from "./cookies"; +import { verifyTeacherJwt } from "./jwt"; +import { jwtSigningSecret } from "./secrets"; + +export interface AuthedTeacher { + id: number; + email: string; + name: string | null; +} + +export async function requireTeacher( + req: Request, + env: Env, +): Promise { + const secret = jwtSigningSecret(env); + if (!secret) { + return new Response(JSON.stringify({ error: "JWT_SECRET is not configured" }), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } + const raw = getTeacherTokenFromCookie(req); + if (!raw) { + return new Response(JSON.stringify({ error: "unauthorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + const payload = await verifyTeacherJwt(raw, secret); + if (!payload) { + return new Response(JSON.stringify({ error: "unauthorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + const row = await env.DB.prepare( + "SELECT id, email, name FROM teachers WHERE id = ?", + ) + .bind(payload.tid) + .first<{ id: number; email: string; name: string | null }>(); + if (!row) { + return new Response(JSON.stringify({ error: "unauthorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + } + return { id: row.id, email: row.email, name: row.name }; +} + +/** True if a valid teacher session cookie is present (for HTML asset gating). */ +export async function isTeacherAuthenticated(req: Request, env: Env): Promise { + const secret = jwtSigningSecret(env); + if (!secret) return false; + const raw = getTeacherTokenFromCookie(req); + if (!raw) return false; + const payload = await verifyTeacherJwt(raw, secret); + if (!payload) return false; + const row = await env.DB.prepare("SELECT 1 AS ok FROM teachers WHERE id = ?") + .bind(payload.tid) + .first<{ ok: number }>(); + return !!row; +} diff --git a/workers/api/src/router.ts b/workers/api/src/router.ts index c7959ee..219c7bc 100644 --- a/workers/api/src/router.ts +++ b/workers/api/src/router.ts @@ -2,6 +2,12 @@ export interface Env { ASSETS: Fetcher; ANTHROPIC_API_KEY: string; DB: D1Database; + /** HMAC secret for teacher session JWT (e.g. 32+ random bytes, hex). */ + JWT_SECRET?: string; + /** Alias for `JWT_SECRET` if you already use this name in `.dev.vars`. */ + SESSION_SECRET?: string; + /** Google OAuth Web client ID (same as GIS `client_id` on the dashboard). */ + GOOGLE_CLIENT_ID?: string; } export interface RouteCtx { diff --git a/workers/api/src/routes/auth_google.ts b/workers/api/src/routes/auth_google.ts new file mode 100644 index 0000000..71831e5 --- /dev/null +++ b/workers/api/src/routes/auth_google.ts @@ -0,0 +1,89 @@ +import { setTeacherCookieHeader } from "../lib/cookies"; +import { signTeacherJwt } from "../lib/jwt"; +import { jwtSigningSecret } from "../lib/secrets"; +import { json, type Route, type RouteCtx } from "../router"; + +interface GoogleTokenInfo { + aud?: string; + sub?: string; + email?: string; + name?: string; +} + +async function verifyGoogleIdToken( + idToken: string, + expectedAud: string, +): Promise<{ sub: string; email: string; name: string | null } | null> { + const url = `https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(idToken)}`; + const r = await fetch(url); + if (!r.ok) return null; + const j = (await r.json()) as GoogleTokenInfo; + if (j.aud !== expectedAud || typeof j.sub !== "string" || typeof j.email !== "string") { + return null; + } + return { sub: j.sub, email: j.email, name: typeof j.name === "string" ? j.name : null }; +} + +export const route: Route = { + method: "POST", + path: "/api/auth/google", + handler: async (ctx: RouteCtx) => { + const { req, env } = ctx; + const jwtSecret = jwtSigningSecret(env); + if (!jwtSecret) { + return json({ error: "JWT_SECRET (or SESSION_SECRET) is not configured" }, 500); + } + if (!env.GOOGLE_CLIENT_ID?.trim()) { + return json({ error: "GOOGLE_CLIENT_ID is not configured" }, 500); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return json({ error: "invalid JSON body" }, 400); + } + const idToken = + body && typeof body === "object" && typeof (body as { idToken?: unknown }).idToken === "string" + ? (body as { idToken: string }).idToken + : null; + if (!idToken) return json({ error: "idToken required" }, 400); + + const g = await verifyGoogleIdToken(idToken, env.GOOGLE_CLIENT_ID.trim()); + if (!g) return json({ error: "invalid id token" }, 401); + + const now = Date.now(); + const existing = await env.DB.prepare( + "SELECT id FROM teachers WHERE google_sub = ?", + ) + .bind(g.sub) + .first<{ id: number }>(); + + let teacherId: number; + if (existing) { + teacherId = existing.id; + await env.DB.prepare( + "UPDATE teachers SET email = ?, name = COALESCE(?, name) WHERE id = ?", + ) + .bind(g.email, g.name, teacherId) + .run(); + } else { + const ins = await env.DB.prepare( + "INSERT INTO teachers (google_sub, email, name, created_at) VALUES (?, ?, ?, ?) RETURNING id", + ) + .bind(g.sub, g.email, g.name, now) + .first<{ id: number }>(); + teacherId = ins?.id ?? 0; + if (!teacherId) return json({ error: "failed to create teacher" }, 500); + } + + const jwt = await signTeacherJwt(teacherId, jwtSecret, 60 * 60 * 24 * 14); + const secure = ctx.url.protocol === "https:"; + const headers = new Headers({ "content-type": "application/json" }); + headers.append( + "Set-Cookie", + setTeacherCookieHeader(jwt, secure, 60 * 60 * 24 * 14), + ); + return new Response(JSON.stringify({ ok: true, teacherId }), { status: 200, headers }); + }, +}; diff --git a/workers/api/src/routes/auth_logout.ts b/workers/api/src/routes/auth_logout.ts new file mode 100644 index 0000000..41702c9 --- /dev/null +++ b/workers/api/src/routes/auth_logout.ts @@ -0,0 +1,14 @@ +import { clearPracticeCookieHeader, clearTeacherCookieHeader } from "../lib/cookies"; +import { type Route, type RouteCtx } from "../router"; + +export const route: Route = { + method: "POST", + path: "/api/auth/logout", + handler: async (ctx: RouteCtx) => { + const secure = ctx.url.protocol === "https:"; + const headers = new Headers({ "content-type": "application/json" }); + headers.append("Set-Cookie", clearTeacherCookieHeader(secure)); + headers.append("Set-Cookie", clearPracticeCookieHeader(secure)); + return new Response(JSON.stringify({ ok: true }), { status: 200, headers }); + }, +}; diff --git a/workers/api/src/routes/auth_me.ts b/workers/api/src/routes/auth_me.ts new file mode 100644 index 0000000..16ba583 --- /dev/null +++ b/workers/api/src/routes/auth_me.ts @@ -0,0 +1,14 @@ +import { requireTeacher } from "../lib/teacher_auth"; +import { json, type Route, type RouteCtx } from "../router"; + +export const route: Route = { + method: "GET", + path: "/api/auth/me", + handler: async (ctx: RouteCtx) => { + const t = await requireTeacher(ctx.req, ctx.env); + if (t instanceof Response) return t; + return json({ + teacher: { id: t.id, email: t.email, name: t.name }, + }); + }, +}; diff --git a/workers/api/src/routes/index.ts b/workers/api/src/routes/index.ts index e8e34ef..c3a6fff 100644 --- a/workers/api/src/routes/index.ts +++ b/workers/api/src/routes/index.ts @@ -2,5 +2,24 @@ import type { Route } from "../router"; import { route as health } from "./health"; import { route as feedback } from "./feedback"; import { route as logStroke } from "./log_stroke"; +import { route as authGoogle } from "./auth_google"; +import { route as authMe } from "./auth_me"; +import { route as authLogout } from "./auth_logout"; +import { routeCreate as teacherClassCreate, routeList as teacherClassList } from "./teacher_classes"; +import { route as practiceStatus } from "./practice_status"; +import { routeGetTeacherPin, routeSessionEnd, routeSessionStart } from "./teacher_sessions"; -export const routes: Route[] = [health, feedback, logStroke]; +export const routes: Route[] = [ + health, + feedback, + logStroke, + practiceStatus, + authGoogle, + authMe, + authLogout, + teacherClassList, + teacherClassCreate, + routeGetTeacherPin, + routeSessionStart, + routeSessionEnd, +]; diff --git a/workers/api/src/routes/log_stroke.ts b/workers/api/src/routes/log_stroke.ts index 0285c90..c0b076e 100644 --- a/workers/api/src/routes/log_stroke.ts +++ b/workers/api/src/routes/log_stroke.ts @@ -1,4 +1,7 @@ -import { json, type Route } from "../router"; +import { getPracticeTokenFromCookie } from "../lib/cookies"; +import { verifyPracticeJwt } from "../lib/jwt"; +import { jwtSigningSecret } from "../lib/secrets"; +import { json, type Env, type Route } from "../router"; interface LogStrokeRequest { sessionId: string; @@ -15,6 +18,8 @@ interface LogStrokeRequest { landedOnLetterChar: string | null; good: boolean; failureModes: string[]; + classStudentId?: number | null; + classStudentSecret?: string | null; } function validate(body: unknown): LogStrokeRequest | null { @@ -30,6 +35,33 @@ function validate(body: unknown): LogStrokeRequest | null { return b as LogStrokeRequest; } +async function resolveClassStudentId( + req: Request, + env: Env, + p: LogStrokeRequest, +): Promise { + const secret = jwtSigningSecret(env); + const raw = getPracticeTokenFromCookie(req); + if (raw && secret) { + const pr = await verifyPracticeJwt(raw, secret); + if (pr) return pr.csid; + } + + const sid = p.classStudentId; + const sec = p.classStudentSecret; + if (sid == null && (sec == null || sec === "")) return null; + if (typeof sid !== "number" || typeof sec !== "string" || !sec.trim()) { + return json({ error: "classStudentId and classStudentSecret must be sent together" }, 400); + } + const row = await env.DB.prepare( + "SELECT id FROM class_students WHERE id = ? AND secret = ?", + ) + .bind(sid, sec.trim()) + .first<{ id: number }>(); + if (!row) return json({ error: "invalid class student credentials" }, 403); + return row.id; +} + export const route: Route = { method: "POST", path: "/api/log/stroke", @@ -43,12 +75,15 @@ export const route: Route = { const p = validate(body); if (!p) return json({ error: "invalid payload" }, 400); + const classStudentId = await resolveClassStudentId(req, env, p); + if (classStudentId instanceof Response) return classStudentId; + const result = await env.DB.prepare( `INSERT INTO stroke_events ( timestamp, session_id, student_name, writer, word, stroke_index, is_orphan, letter_index, letter_char, position_in_letter, - landed_on_letter_char, good, failure_modes - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, + landed_on_letter_char, good, failure_modes, class_student_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, ) .bind( Date.now(), @@ -64,6 +99,7 @@ export const route: Route = { p.landedOnLetterChar ?? null, p.good ? 1 : 0, JSON.stringify(p.failureModes), + classStudentId, ) .first<{ id: number }>(); diff --git a/workers/api/src/routes/practice_status.ts b/workers/api/src/routes/practice_status.ts new file mode 100644 index 0000000..f119572 --- /dev/null +++ b/workers/api/src/routes/practice_status.ts @@ -0,0 +1,18 @@ +import { getPracticeTokenFromCookie } from "../lib/cookies"; +import { verifyPracticeJwt } from "../lib/jwt"; +import { jwtSigningSecret } from "../lib/secrets"; +import { json, type Route, type RouteCtx } from "../router"; + +/** Whether this browser has an active signed practice session (HttpOnly cookie). */ +export const route: Route = { + method: "GET", + path: "/api/practice/status", + handler: async (ctx: RouteCtx) => { + const secret = jwtSigningSecret(ctx.env); + const raw = getPracticeTokenFromCookie(ctx.req); + if (!raw || !secret) return json({ active: false }); + const pr = await verifyPracticeJwt(raw, secret); + if (!pr) return json({ active: false }); + return json({ active: true, classStudentId: pr.csid }); + }, +}; diff --git a/workers/api/src/routes/teacher_analytics.ts b/workers/api/src/routes/teacher_analytics.ts new file mode 100644 index 0000000..86cf221 --- /dev/null +++ b/workers/api/src/routes/teacher_analytics.ts @@ -0,0 +1,209 @@ +import { requireTeacher } from "../lib/teacher_auth"; +import { json, type RouteCtx } from "../router"; + +async function assertTeacherOwnsClass( + env: RouteCtx["env"], + teacherId: number, + classId: number, +): Promise { + const row = await env.DB.prepare( + "SELECT 1 AS ok FROM classes WHERE id = ? AND teacher_id = ?", + ) + .bind(classId, teacherId) + .first<{ ok: number }>(); + return !!row; +} + +async function assertTeacherOwnsStudent( + env: RouteCtx["env"], + teacherId: number, + studentId: number, +): Promise<{ displayName: string; classId: number; className: string } | null> { + const row = await env.DB.prepare( + `SELECT cs.display_name AS display_name, cs.class_id AS class_id, c.name AS class_name + FROM class_students cs + JOIN classes c ON c.id = cs.class_id + WHERE cs.id = ? AND c.teacher_id = ?`, + ) + .bind(studentId, teacherId) + .first<{ display_name: string; class_id: number; class_name: string }>(); + if (!row) return null; + return { + displayName: row.display_name, + classId: row.class_id, + className: row.class_name, + }; +} + +/** GET /api/teacher/classes/:classId/students */ +export async function handleClassStudents( + ctx: RouteCtx, + classId: number, +): Promise { + const t = await requireTeacher(ctx.req, ctx.env); + if (t instanceof Response) return t; + if (!(await assertTeacherOwnsClass(ctx.env, t.id, classId))) { + return json({ error: "not found" }, 404); + } + + const { results } = await ctx.env.DB.prepare( + `SELECT cs.id, cs.display_name, cs.created_at, + COUNT(se.id) AS stroke_count, + MAX(se.timestamp) AS last_stroke_at + FROM class_students cs + LEFT JOIN stroke_events se ON se.class_student_id = cs.id + WHERE cs.class_id = ? + GROUP BY cs.id + ORDER BY (last_stroke_at IS NULL) ASC, last_stroke_at DESC, cs.display_name ASC`, + ) + .bind(classId) + .all<{ + id: number; + display_name: string; + created_at: number; + stroke_count: number; + last_stroke_at: number | null; + }>(); + + return json({ + students: (results ?? []).map((r) => ({ + id: r.id, + displayName: r.display_name, + createdAt: r.created_at, + strokeCount: r.stroke_count, + lastStrokeAt: r.last_stroke_at, + })), + }); +} + +/** GET /api/teacher/students/:studentId/analytics */ +export async function handleStudentAnalytics( + ctx: RouteCtx, + studentId: number, +): Promise { + const t = await requireTeacher(ctx.req, ctx.env); + if (t instanceof Response) return t; + const meta = await assertTeacherOwnsStudent(ctx.env, t.id, studentId); + if (!meta) return json({ error: "not found" }, 404); + + const strokes = await ctx.env.DB.prepare( + `SELECT id, timestamp, session_id, word, stroke_index, is_orphan, letter_char, + good, failure_modes, writer + FROM stroke_events WHERE class_student_id = ? ORDER BY timestamp ASC`, + ) + .bind(studentId) + .all<{ + id: number; + timestamp: number; + session_id: string; + word: string; + stroke_index: number; + is_orphan: number; + letter_char: string | null; + good: number; + failure_modes: string | null; + writer: string; + }>(); + + const rows = strokes.results ?? []; + const strokeCount = rows.length; + const goodStrokes = rows.filter((r) => r.good === 1).length; + const orphanStrokes = rows.filter((r) => r.is_orphan === 1).length; + + const words = new Set(); + const letters = new Set(); + const writers = new Set(); + const failureTagCounts = new Map(); + + for (const r of rows) { + words.add(r.word); + writers.add(r.writer); + if (r.letter_char) letters.add(r.letter_char); + if (r.failure_modes && r.failure_modes !== "[]") { + try { + const modes = JSON.parse(r.failure_modes) as unknown; + if (Array.isArray(modes)) { + for (const m of modes) { + if (typeof m === "string") { + failureTagCounts.set(m, (failureTagCounts.get(m) ?? 0) + 1); + } + } + } + } catch { + /* ignore */ + } + } + } + + const bySession = new Map(); + for (const r of rows) { + const cur = bySession.get(r.session_id) ?? { + min: r.timestamp, + max: r.timestamp, + strokes: 0, + }; + cur.min = Math.min(cur.min, r.timestamp); + cur.max = Math.max(cur.max, r.timestamp); + cur.strokes += 1; + bySession.set(r.session_id, cur); + } + + const sessionDurations: number[] = []; + for (const s of bySession.values()) { + if (s.strokes > 0) sessionDurations.push(Math.max(0, s.max - s.min)); + } + const avgSessionDurationMs = + sessionDurations.length > 0 + ? sessionDurations.reduce((a, b) => a + b, 0) / sessionDurations.length + : 0; + + const wordCounts = new Map(); + for (const r of rows) { + wordCounts.set(r.word, (wordCounts.get(r.word) ?? 0) + 1); + } + const topWords = [...wordCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 20) + .map(([word, count]) => ({ word, strokeCount: count })); + + const letterCounts = new Map(); + for (const r of rows) { + if (!r.letter_char) continue; + letterCounts.set(r.letter_char, (letterCounts.get(r.letter_char) ?? 0) + 1); + } + const topLetters = [...letterCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([letter, count]) => ({ letter, strokeCount: count })); + + const failureModes = [...failureTagCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([tag, count]) => ({ tag, count })); + + const firstStrokeAt = rows.length ? rows[0]!.timestamp : null; + const lastStrokeAt = rows.length ? rows[rows.length - 1]!.timestamp : null; + + return json({ + student: { + id: studentId, + displayName: meta.displayName, + classId: meta.classId, + className: meta.className, + }, + summary: { + strokeCount, + goodStrokeCount: goodStrokes, + badStrokeCount: strokeCount - goodStrokes, + orphanStrokeCount: orphanStrokes, + distinctWords: words.size, + distinctLetters: letters.size, + distinctWriters: writers.size, + sessionCount: bySession.size, + avgSessionDurationMs: Math.round(avgSessionDurationMs), + firstActivityAt: firstStrokeAt, + lastActivityAt: lastStrokeAt, + }, + topWords, + letters: topLetters, + failureModes, + }); +} diff --git a/workers/api/src/routes/teacher_class_report.ts b/workers/api/src/routes/teacher_class_report.ts new file mode 100644 index 0000000..1448a39 --- /dev/null +++ b/workers/api/src/routes/teacher_class_report.ts @@ -0,0 +1,143 @@ +import { requireTeacher } from "../lib/teacher_auth"; +import { json, type RouteCtx } from "../router"; + +async function assertTeacherOwnsClass( + env: RouteCtx["env"], + teacherId: number, + classId: number, +): Promise { + const row = await env.DB.prepare( + "SELECT 1 AS ok FROM classes WHERE id = ? AND teacher_id = ?", + ) + .bind(classId, teacherId) + .first<{ ok: number }>(); + return !!row; +} + +type StrokeRow = { + class_student_id: number | null; + timestamp: number; + session_id: string; + word: string; + stroke_index: number; + is_orphan: number; + letter_char: string | null; + good: number; + failure_modes: string | null; + writer: string; +}; + +function summarizeRows(rows: StrokeRow[]): { + strokeCount: number; + goodStrokeCount: number; + orphanStrokeCount: number; + distinctWords: number; + distinctLetters: number; + sessionCount: number; + avgSessionDurationMs: number; + firstActivityAt: number | null; + lastActivityAt: number | null; +} { + const strokeCount = rows.length; + const goodStrokeCount = rows.filter((r) => r.good === 1).length; + const orphanStrokeCount = rows.filter((r) => r.is_orphan === 1).length; + const words = new Set(); + const letters = new Set(); + for (const r of rows) { + words.add(r.word); + if (r.letter_char) letters.add(r.letter_char); + } + const bySession = new Map(); + for (const r of rows) { + const cur = bySession.get(r.session_id) ?? { min: r.timestamp, max: r.timestamp }; + cur.min = Math.min(cur.min, r.timestamp); + cur.max = Math.max(cur.max, r.timestamp); + bySession.set(r.session_id, cur); + } + const spans = [...bySession.values()].map((s) => Math.max(0, s.max - s.min)); + const avgSessionDurationMs = + spans.length > 0 ? spans.reduce((a, b) => a + b, 0) / spans.length : 0; + return { + strokeCount, + goodStrokeCount, + orphanStrokeCount, + distinctWords: words.size, + distinctLetters: letters.size, + sessionCount: bySession.size, + avgSessionDurationMs: Math.round(avgSessionDurationMs), + firstActivityAt: rows.length ? rows[0]!.timestamp : null, + lastActivityAt: rows.length ? rows[rows.length - 1]!.timestamp : null, + }; +} + +/** Stub “visualizer report” — replace with real generator later. */ +function blackBoxVisualizerReport(summary: ReturnType): { + source: string; + generatedAt: number; + summary: string; + metrics: Record; +} { + const avgStrokeMs = + summary.strokeCount > 0 + ? Math.round(summary.avgSessionDurationMs / Math.max(1, summary.strokeCount)) + : 0; + return { + source: "black-box-visualizer-report", + generatedAt: Date.now(), + summary: + "Placeholder visualizer report (generator not implemented). Metrics are derived from logged strokes only.", + metrics: { + averageStrokeTimeMs: avgStrokeMs, + sampleComplexityScore: 0.37, + attentionSpanEstimateSec: Math.round(summary.avgSessionDurationMs / 1000), + templateAlignmentIndex: summary.strokeCount > 0 ? summary.goodStrokeCount / summary.strokeCount : 0, + }, + }; +} + +/** GET /api/teacher/classes/:classId/report */ +export async function handleClassReport(ctx: RouteCtx, classId: number): Promise { + const t = await requireTeacher(ctx.req, ctx.env); + if (t instanceof Response) return t; + if (!(await assertTeacherOwnsClass(ctx.env, t.id, classId))) { + return json({ error: "not found" }, 404); + } + + const { results: students } = await ctx.env.DB.prepare( + "SELECT id, display_name FROM class_students WHERE class_id = ? ORDER BY display_name ASC", + ) + .bind(classId) + .all<{ id: number; display_name: string }>(); + + const { results: strokes } = await ctx.env.DB.prepare( + `SELECT se.class_student_id, se.timestamp, se.session_id, se.word, se.stroke_index, + se.is_orphan, se.letter_char, se.good, se.failure_modes, se.writer + FROM stroke_events se + JOIN class_students cs ON cs.id = se.class_student_id + WHERE cs.class_id = ? + ORDER BY se.timestamp ASC`, + ) + .bind(classId) + .all(); + + const byStudent = new Map(); + for (const s of strokes ?? []) { + if (s.class_student_id == null) continue; + const arr = byStudent.get(s.class_student_id) ?? []; + arr.push(s); + byStudent.set(s.class_student_id, arr); + } + + const out = (students ?? []).map((st) => { + const rows = byStudent.get(st.id) ?? []; + const summary = summarizeRows(rows); + return { + studentId: st.id, + displayName: st.display_name, + strokeLogistics: summary, + visualizerReport: blackBoxVisualizerReport(summary), + }; + }); + + return json({ classId, students: out }); +} diff --git a/workers/api/src/routes/teacher_classes.ts b/workers/api/src/routes/teacher_classes.ts new file mode 100644 index 0000000..fc4ac65 --- /dev/null +++ b/workers/api/src/routes/teacher_classes.ts @@ -0,0 +1,76 @@ +import { randomJoinCode } from "../lib/join_code"; +import { requireTeacher } from "../lib/teacher_auth"; +import { json, type Route, type RouteCtx } from "../router"; + +export const routeList: Route = { + method: "GET", + path: "/api/teacher/classes", + handler: async (ctx: RouteCtx) => { + const t = await requireTeacher(ctx.req, ctx.env); + if (t instanceof Response) return t; + const { results } = await ctx.env.DB.prepare( + `SELECT c.id, c.name, c.join_code, c.created_at, + (SELECT COUNT(*) FROM class_students s WHERE s.class_id = c.id) AS student_count + FROM classes c WHERE c.teacher_id = ? ORDER BY c.created_at DESC`, + ) + .bind(t.id) + .all<{ + id: number; + name: string; + join_code: string; + created_at: number; + student_count: number; + }>(); + return json({ + classes: (results ?? []).map((r) => ({ + id: r.id, + name: r.name, + joinCode: r.join_code, + createdAt: r.created_at, + studentCount: r.student_count, + })), + }); + }, +}; + +export const routeCreate: Route = { + method: "POST", + path: "/api/teacher/classes", + handler: async (ctx: RouteCtx) => { + const t = await requireTeacher(ctx.req, ctx.env); + if (t instanceof Response) return t; + let body: unknown; + try { + body = await ctx.req.json(); + } catch { + return json({ error: "invalid JSON body" }, 400); + } + const name = + body && typeof body === "object" && typeof (body as { name?: unknown }).name === "string" + ? (body as { name: string }).name.trim() + : ""; + if (!name) return json({ error: "name required" }, 400); + + const now = Date.now(); + for (let attempt = 0; attempt < 12; attempt++) { + const joinCode = randomJoinCode(8); + const taken = await ctx.env.DB.prepare("SELECT 1 FROM classes WHERE join_code = ?") + .bind(joinCode) + .first<{ "1": number }>(); + if (taken) continue; + const row = await ctx.env.DB.prepare( + `INSERT INTO classes (teacher_id, name, join_code, created_at) + VALUES (?, ?, ?, ?) RETURNING id, join_code`, + ) + .bind(t.id, name, joinCode, now) + .first<{ id: number; join_code: string }>(); + if (row) { + return json({ + ok: true, + class: { id: row.id, name, joinCode: row.join_code, createdAt: now }, + }); + } + } + return json({ error: "could not allocate join code" }, 500); + }, +}; diff --git a/workers/api/src/routes/teacher_sessions.ts b/workers/api/src/routes/teacher_sessions.ts new file mode 100644 index 0000000..1603216 --- /dev/null +++ b/workers/api/src/routes/teacher_sessions.ts @@ -0,0 +1,162 @@ +import { clearPracticeCookieHeader, setPracticeCookieHeader } from "../lib/cookies"; +import { deriveTeacherPin, signPracticeJwt, timingSafeEq } from "../lib/jwt"; +import { randomStudentSecret } from "../lib/join_code"; +import { jwtSigningSecret } from "../lib/secrets"; +import { requireTeacher } from "../lib/teacher_auth"; +import { json, type Route, type RouteCtx } from "../router"; + +const PRACTICE_TTL_SEC = 60 * 60 * 8; + +async function assertTeacherOwnsClass( + env: RouteCtx["env"], + teacherId: number, + classId: number, +): Promise { + const row = await env.DB.prepare( + "SELECT 1 AS ok FROM classes WHERE id = ? AND teacher_id = ?", + ) + .bind(classId, teacherId) + .first<{ ok: number }>(); + return !!row; +} + +async function assertTeacherOwnsStudent( + env: RouteCtx["env"], + teacherId: number, + studentId: number, +): Promise { + const row = await env.DB.prepare( + `SELECT 1 AS ok FROM class_students cs + JOIN classes c ON c.id = cs.class_id + WHERE cs.id = ? AND c.teacher_id = ?`, + ) + .bind(studentId, teacherId) + .first<{ ok: number }>(); + return !!row; +} + +/** POST /api/teacher/classes/:classId/students — teacher adds a roster student. */ +export async function handleAddClassStudent( + ctx: RouteCtx, + classId: number, +): Promise { + const t = await requireTeacher(ctx.req, ctx.env); + if (t instanceof Response) return t; + if (!(await assertTeacherOwnsClass(ctx.env, t.id, classId))) { + return json({ error: "not found" }, 404); + } + let body: unknown; + try { + body = await ctx.req.json(); + } catch { + return json({ error: "invalid JSON body" }, 400); + } + const displayName = + body && typeof body === "object" && typeof (body as { displayName?: unknown }).displayName === "string" + ? (body as { displayName: string }).displayName.trim() + : ""; + if (!displayName) return json({ error: "displayName required" }, 400); + + const secret = randomStudentSecret(); + const now = Date.now(); + const row = await ctx.env.DB.prepare( + `INSERT INTO class_students (class_id, display_name, secret, created_at) + VALUES (?, ?, ?, ?) RETURNING id`, + ) + .bind(classId, displayName, secret, now) + .first<{ id: number }>(); + if (!row?.id) return json({ error: "failed to add student" }, 500); + return json({ + ok: true, + student: { id: row.id, displayName, secret }, + }); +} + +/** GET /api/teacher/pin — returns the teacher's stable PIN. */ +export const routeGetTeacherPin: Route = { + method: "GET", + path: "/api/teacher/pin", + handler: async (ctx: RouteCtx) => { + const t = await requireTeacher(ctx.req, ctx.env); + if (t instanceof Response) return t; + const jwtSecret = jwtSigningSecret(ctx.env); + if (!jwtSecret) return json({ error: "JWT_SECRET not configured" }, 500); + const pin = await deriveTeacherPin(t.id, jwtSecret); + return json({ pin }); + }, +}; + +/** POST /api/teacher/session/start — creates a practice session cookie for a student. */ +export const routeSessionStart: Route = { + method: "POST", + path: "/api/teacher/session/start", + handler: async (ctx: RouteCtx) => { + const t = await requireTeacher(ctx.req, ctx.env); + if (t instanceof Response) return t; + const jwtSecret = jwtSigningSecret(ctx.env); + if (!jwtSecret) { + return json({ error: "JWT_SECRET (or SESSION_SECRET) is not configured" }, 500); + } + let body: unknown; + try { + body = await ctx.req.json(); + } catch { + return json({ error: "invalid JSON body" }, 400); + } + const classStudentId = + body && typeof body === "object" && typeof (body as { classStudentId?: unknown }).classStudentId === "number" + ? (body as { classStudentId: number }).classStudentId + : NaN; + if (!Number.isFinite(classStudentId)) { + return json({ error: "classStudentId required" }, 400); + } + if (!(await assertTeacherOwnsStudent(ctx.env, t.id, classStudentId))) { + return json({ error: "not found" }, 404); + } + + const pin = await deriveTeacherPin(t.id, jwtSecret); + const jwt = await signPracticeJwt(classStudentId, jwtSecret, PRACTICE_TTL_SEC); + const secure = ctx.url.protocol === "https:"; + const headers = new Headers({ "content-type": "application/json" }); + headers.append("Set-Cookie", setPracticeCookieHeader(jwt, secure, PRACTICE_TTL_SEC)); + return new Response( + JSON.stringify({ ok: true, classStudentId, pin }), + { status: 200, headers }, + ); + }, +}; + +/** POST /api/teacher/session/end — validates the teacher's PIN and clears the practice cookie. */ +export const routeSessionEnd: Route = { + method: "POST", + path: "/api/teacher/session/end", + handler: async (ctx: RouteCtx) => { + const t = await requireTeacher(ctx.req, ctx.env); + if (t instanceof Response) return t; + + const jwtSecret = jwtSigningSecret(ctx.env); + if (!jwtSecret) return json({ error: "JWT_SECRET not configured" }, 500); + + let body: unknown = null; + try { + body = await ctx.req.json(); + } catch { + return json({ error: "invalid JSON body" }, 400); + } + const pin = + body && typeof body === "object" && typeof (body as { pin?: unknown }).pin === "string" + ? (body as { pin: string }).pin.trim() + : ""; + if (!pin) return json({ error: "pin required" }, 400); + + const expected = await deriveTeacherPin(t.id, jwtSecret); + if (!timingSafeEq(pin, expected)) { + return json({ error: "incorrect PIN" }, 403); + } + + const secure = ctx.url.protocol === "https:"; + const headers = new Headers({ "content-type": "application/json" }); + headers.append("Set-Cookie", clearPracticeCookieHeader(secure)); + return new Response(JSON.stringify({ ok: true }), { status: 200, headers }); + }, +}; diff --git a/workers/api/wrangler.jsonc b/workers/api/wrangler.jsonc index b6bad63..072e268 100644 --- a/workers/api/wrangler.jsonc +++ b/workers/api/wrangler.jsonc @@ -16,7 +16,7 @@ "database_name": "phoneme", // Run `npx wrangler d1 create phoneme --config workers/api/wrangler.jsonc` // and paste the returned database_id below. - "database_id": "REPLACE_WITH_D1_ID", + "database_id": "c11a39ce-f15c-4826-ad2e-95e9d0fe0ecb", "migrations_dir": "migrations" } ]