From b9124a58f3ce0c48c106f4a56f39a604a507c4c9 Mon Sep 17 00:00:00 2001 From: Wouter Meetsma Date: Wed, 1 Jul 2026 14:36:00 +0200 Subject: [PATCH 1/3] Fix broken essay hand-in/monitor, dark-mode/editor UI bugs, and add student password login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root causes and fixes: - Essay draft autosave crashed on QuotaExceededError, spamming logs — now caught silently. - "Assignment not found" on submit + broken live monitor: the "assign to whole class" flow reused one teacherKey across many students, but essay_assignments rows are 1:1 server-side, and nothing ever pushed those rows to Supabase. DB credentials are now only embedded when a save path actually exists, single-student assignments auto-save before their link is shareable, and the now-nonfunctional Monitor link is hidden for bulk-assigned groups. - Dark-mode dropdowns were black-on-black — missing `color-scheme` meant native option popups ignored our theme colors. - Essay editor's "A4 page view" was unreadable in dark mode (looked like typing didn't work) — its backdrop used theme vars while text stayed hardcoded dark; now always renders as a light page, consistent with the rest of that editor's chrome. - Large font size in a table cell forced the table wider than its column, silently clipped by the editor's own rounded-corner container — now scrolls within the table instead. - Table-insert throttled to survive an autoclicker without visible flicker. - Default theme changed to light. New feature: student password login (alternative to unreliable OTP email) - New set-student-password edge function lets a teacher set/reset a student's login password via the Supabase admin API, scoped to their own roster. - Students page: a key-icon action generates a password for one student, or a new "Generate password slips" action does it for a whole class at once — rendered as a printable slip sheet matching the existing essay slip-sheet's look and mechanics. - Landing page: new "Student login (password)" option alongside OAuth/OTP. - Docs, README, and i18n (all 5 locales) updated to match. Co-Authored-By: Claude Sonnet 5 --- README.md | 2 + src/components/Editor/EssayEditor.tsx | 21 +- src/components/Essay/EssayAssignmentModal.tsx | 37 +++- .../Students/StudentPasswordSlipSheet.tsx | 181 ++++++++++++++++++ src/components/auth/LoginButtons.tsx | 91 ++++++++- src/context/AppContext.tsx | 6 + src/index.css | 7 + src/locales/de.json | 10 +- src/locales/en.json | 10 +- src/locales/es.json | 10 +- src/locales/fr.json | 10 +- src/locales/nl.json | 10 +- src/pages/ActivityDashboardPage.tsx | 7 +- src/pages/DocsPage.tsx | 1 + src/pages/EssayBuilderPage.tsx | 7 +- src/pages/LandingPage.tsx | 7 + src/pages/StudentEssayPage.tsx | 15 +- src/pages/StudentsPage.tsx | 63 ++++++ src/services/database/StorageSync.ts | 5 + src/services/database/SupabaseAdapter.ts | 41 ++++ src/store/storage.test.ts | 2 +- src/store/storage.ts | 2 +- src/utils/studentPassword.ts | 9 + .../functions/set-student-password/index.ts | 102 ++++++++++ 24 files changed, 635 insertions(+), 21 deletions(-) create mode 100644 src/components/Students/StudentPasswordSlipSheet.tsx create mode 100644 src/utils/studentPassword.ts create mode 100644 supabase/functions/set-student-password/index.ts diff --git a/README.md b/README.md index a860b662..20db4c3e 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,8 @@ docker-compose up -d --force-recreate auth Teachers receive an 8-digit sign-in code by email. The bundled GoTrue config sends a code-only template (`public/email-templates/otp-code.html`, served by the `app` container at `/email-templates/otp-code.html`) with no clickable confirmation link — some email security scanners (e.g. Microsoft Safe Links) automatically open links in incoming mail, which would consume the one-time token before the teacher can enter the code, causing "Token has expired or is invalid" errors. +**Student login without email:** many schools' spam filters block or delay Supabase's default OTP sender, leaving students unable to sign in. As an alternative, a teacher can generate a password for any student with an email on file (Students page → key icon on that student's row) and share it with them directly — the student then signs in at the landing page with "Student login (password)" using their email and that password. This depends on the `set-student-password` edge function, which requires a functions runtime in front of an API gateway — **this repo's own docker-compose.yml above does not include one** (no `functions` service, and `docker/nginx.prod.conf` has no `/functions/v1/` route), so this feature (and the DB-backed essay submission flow, which relies on `submit-essay`/`get-essay-assignment` the same way) only works when Supabase is provided by the [official self-hosted Supabase Docker stack](https://supabase.com/docs/guides/self-hosting/docker) (which ships a `functions` container behind Kong) or Supabase Cloud. On the official self-hosted stack, deploy by copying the function's `index.ts` straight into that stack's `volumes/functions/set-student-password/index.ts` — there's no separate "deploy" step; the edge-runtime serves it as soon as the file is in place. + **Backup and restore:** ```bash diff --git a/src/components/Editor/EssayEditor.tsx b/src/components/Editor/EssayEditor.tsx index e8203b38..b66279b7 100644 --- a/src/components/Editor/EssayEditor.tsx +++ b/src/components/Editor/EssayEditor.tsx @@ -290,6 +290,7 @@ export default function EssayEditor({ const highlightInputRef = useRef(null); const [showInvisibles, setShowInvisibles] = useState(false); const [pageMode, setPageMode] = useState(defaultPageMode); + const lastTableInsertRef = useRef(0); const editor = useEditor({ extensions: [ @@ -339,6 +340,12 @@ export default function EssayEditor({ }; const handleInsertTable = () => { + // ponytail: guards against an autoclicker hammering this button — each table + // insertion is a full ProseMirror doc re-render, so dozens per second cause + // visible flicker. 300ms is well above human click cadence. + const now = Date.now(); + if (now - lastTableInsertRef.current < 300) return; + lastTableInsertRef.current = now; editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); }; @@ -726,9 +733,14 @@ export default function EssayEditor({ {/* ── Editor area ── */} {pageMode ? ( + // Deliberately hardcoded to a light "paper" look, matching the rest of this + // editor's chrome (toolbar, borders) — this component always renders like a + // white document regardless of app theme. Previously this used theme vars, + // which under the dark theme made the page background near-black while the + // text stayed the editor's hardcoded dark colour, i.e. unreadable. - {/* DB integration toggle */} - {dbStatus.isConnected && ( + {/* DB integration toggle — only meaningful when this modal instance can + persist the row (onSaveAssignment); otherwise the embedded credentials + would just be dead weight (see buildAssignment / handleAssignToStudents). */} + {dbStatus.isConnected && !!onSaveAssignment && (
void; +} + +function SlipItem({ slip }: { slip: PasswordSlip }) { + const { t } = useTranslation(); + const canvasRef = useRef(null); + + useEffect(() => { + if (!canvasRef.current || !slip.password) return; + QRCode.toCanvas(canvasRef.current, window.location.origin, { width: 80, margin: 0 }).catch((e) => { + console.error('[qr] canvas generation failed', e); + }); + }, [slip.password]); + + return ( +
+
+
{slip.name}
+
+ {slip.email} +
+ {slip.password ? ( +
+ {slip.password} +
+ ) : ( +
+ {t('studentsPage.password_slip_item_error')} +
+ )} +
+ {slip.password && } +
+ ); +} + +export default function StudentPasswordSlipSheet({ slips, onClose }: Props) { + const { t } = useTranslation(); + const [columns, setColumns] = useState<2 | 4>(2); + + const content = ( + <> + {/* Print styles — overlay is portalled to body so #root is hidden via index.css */} + + +
+
+ {/* Controls bar */} +
+
+ + {t('studentsPage.password_slip_title', { count: slips.length })} + +
+ + +
+
+
+ + +
+
+ + {/* Slip grid */} +
+ {slips.map((slip) => ( + + ))} +
+
+
+ + ); + + return ReactDOM.createPortal(content, document.body); +} diff --git a/src/components/auth/LoginButtons.tsx b/src/components/auth/LoginButtons.tsx index 72903179..3ad14d3c 100644 --- a/src/components/auth/LoginButtons.tsx +++ b/src/components/auth/LoginButtons.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { Mail, ChevronDown, ChevronUp, Loader2, Check } from 'lucide-react'; +import { Mail, KeyRound, ChevronDown, ChevronUp, Loader2, Check } from 'lucide-react'; import { storageSync } from '../../services/database'; interface LoginButtonsProps { @@ -20,6 +20,12 @@ export default function LoginButtons({ onEmailSuccess, supabaseReady, onNeedConf const [error, setError] = useState(''); const [done, setDone] = useState(false); + // Student login via teacher-issued password — the alternative when OTP email + // delivery is unreliable (school spam filters, low default send limits). + const [studentLoginOpen, setStudentLoginOpen] = useState(false); + const [studentEmail, setStudentEmail] = useState(''); + const [studentPassword, setStudentPassword] = useState(''); + // null = not yet fetched (show all); string[] = loaded from site_config const [enabledProviders, setEnabledProviders] = useState(null); @@ -86,6 +92,26 @@ export default function LoginButtons({ onEmailSuccess, supabaseReady, onNeedConf } } + async function handleStudentPasswordLogin() { + if (!studentEmail.trim() || !studentPassword) { + setError('Enter your email and password.'); + return; + } + if (!supabaseReady) { + onNeedConfig?.(); + return; + } + setError(''); + setBusy('student-password'); + const result = await storageSync.adapter.signInWithPassword(studentEmail.trim(), studentPassword); + setBusy(null); + if (result.error) setError(result.error); + else { + setDone(true); + onEmailSuccess?.(); + } + } + const btnBase: React.CSSProperties = { display: 'flex', alignItems: 'center', @@ -283,6 +309,69 @@ export default function LoginButtons({ onEmailSuccess, supabaseReady, onNeedConf
)} + {/* Student login (password) — bypasses OTP email entirely, for schools where + Supabase's default email delivery is blocked or delayed. The teacher issues + this password from the Students page. */} + + + {studentLoginOpen && ( +
+ { + setStudentEmail(e.target.value); + setError(''); + }} + placeholder="your@email.com" + style={{ padding: '9px 12px', borderRadius: 7, border: '1px solid #e2e8f0', fontSize: '0.9rem' }} + /> + { + setStudentPassword(e.target.value); + setError(''); + }} + onKeyDown={(e) => e.key === 'Enter' && handleStudentPasswordLogin()} + placeholder="Password" + style={{ padding: '9px 12px', borderRadius: 7, border: '1px solid #e2e8f0', fontSize: '0.9rem' }} + /> + +
+ )} + {error &&

{error}

} ); diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index 35981359..5229531c 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -740,6 +740,7 @@ interface AppContextValue extends StoreData { anonymizeStudent: (id: string) => void; // Essay assignments (teacher side) saveEssayAssignment: (a: EssayAssignment) => Promise; + setStudentPassword: (studentEmail: string, password: string) => Promise; deleteEssayAssignment: (teacherKey: string) => Promise; fetchEssaySubmissions: ( teacherKey: string @@ -1503,6 +1504,10 @@ export function AppProvider({ children }: { children: ReactNode }) { }, []); const saveEssayAssignment = useCallback((a: EssayAssignment) => storageSync.saveEssayAssignment(a), []); + const setStudentPassword = useCallback( + (studentEmail: string, password: string) => storageSync.setStudentPassword(studentEmail, password), + [] + ); const deleteEssayAssignment = useCallback( (teacherKey: string) => storageSync.deleteEssayAssignment(teacherKey), [] @@ -1718,6 +1723,7 @@ export function AppProvider({ children }: { children: ReactNode }) { anonymizeStudent, getCurrentDatabaseUserId, saveEssayAssignment, + setStudentPassword, deleteEssayAssignment, fetchEssaySubmissions, fetchEssaySubmissionsForStudent, diff --git a/src/index.css b/src/index.css index 6c4c22d5..ba947b90 100644 --- a/src/index.css +++ b/src/index.css @@ -48,9 +48,15 @@ /* transitions */ --transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1); + + /* Tells the browser to render native widgets (select popups, scrollbars) in dark + colors — without this,