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..e1dbf19b 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,79 @@ 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,