Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 19 additions & 2 deletions src/components/Editor/EssayEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ export default function EssayEditor({
const highlightInputRef = useRef<HTMLInputElement>(null);
const [showInvisibles, setShowInvisibles] = useState(false);
const [pageMode, setPageMode] = useState(defaultPageMode);
const lastTableInsertRef = useRef(0);

const editor = useEditor({
extensions: [
Expand Down Expand Up @@ -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();
};

Expand Down Expand Up @@ -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.
<div
style={{
background: 'var(--bg-elevated)',
background: '#f1f5f9',
padding: '32px 24px',
minHeight: 500,
}}
Expand All @@ -740,7 +752,7 @@ export default function EssayEditor({
maxWidth: 794,
minHeight: 1123,
margin: '0 auto',
background: 'var(--bg-raised)',
background: '#fff',
boxShadow: '0 4px 24px rgba(0,0,0,0.14)',
borderRadius: 2,
padding: '96px 96px 96px',
Expand Down Expand Up @@ -830,6 +842,11 @@ export default function EssayEditor({
.essay-editor-content hr { border: none; border-top: 1.5px solid #e2e8f0; margin: 1.2em 0; }
.essay-editor-content a { color: #6366f1; text-decoration: underline; }
.essay-editor-content table { border-collapse: collapse; width: 100%; margin: 0.8em 0; }
/* A cell with a large font size (or a long unbroken word) can force the table
wider than its column. Without this, that overflow spills out and is silently
clipped by this component's own rounded-corner container (overflow: hidden on
the outer wrapper) — the content becomes invisible with no way to scroll to it. */
.essay-editor-content .tableWrapper { overflow-x: auto; }
.essay-editor-content th, .essay-editor-content td { border: 1px solid #e2e8f0; padding: 6px 10px; text-align: left; min-width: 60px; }
.essay-editor-content th { background: #f8fafc; font-weight: 700; }
.essay-editor-content .selectedCell { background: #e0e7ff; }
Expand Down
37 changes: 31 additions & 6 deletions src/components/Essay/EssayAssignmentModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { X, Copy, Download, Check, FileText, Database, AlertCircle, Radio, Save } from 'lucide-react';
import { useTranslation } from 'react-i18next';
Expand Down Expand Up @@ -80,7 +80,10 @@ export default function EssayAssignmentModal({
? initialValues.expiresAt.slice(0, 16)
: ''
);
const [embedDb, setEmbedDb] = useState(dbStatus.isConnected); // on by default when connected
// On by default when connected — but only when this modal instance can actually save
// the row (onSaveAssignment). Without that, embedding credentials just hands out a
// "DB mode" link for a row that never gets persisted (see handleAssignToStudents).
const [embedDb, setEmbedDb] = useState(dbStatus.isConnected && !!onSaveAssignment);
const [copied, setCopied] = useState(false);
const [saved, setSaved] = useState(false);
const [saveError, setSaveError] = useState('');
Expand All @@ -106,8 +109,9 @@ export default function EssayAssignmentModal({
createdAt: new Date().toISOString(),
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : undefined,
};
// Embed Supabase credentials so the student page can submit directly to the DB
if (embedDb && dbStatus.isConnected && config) {
// Embed Supabase credentials so the student page can submit directly to the DB —
// only when there's a way to actually persist this row (onSaveAssignment).
if (embedDb && dbStatus.isConnected && config && onSaveAssignment) {
base.supabaseUrl = config.supabaseUrl;
base.supabaseAnonKey = config.supabaseAnonKey;
}
Expand All @@ -127,6 +131,7 @@ export default function EssayAssignmentModal({
embedDb,
dbStatus.isConnected,
config,
onSaveAssignment,
]
);

Expand Down Expand Up @@ -158,6 +163,18 @@ export default function EssayAssignmentModal({
}
}, [onSaveAssignment, buildAssignment, studentId, t]);

// Auto-save as soon as a DB-mode link becomes shareable. The link/SEB config/slip
// sheet are all generated eagerly from `essayUrl` above (and the raw link is also
// shown in a copyable input), so gating the save behind a specific "share" button
// click left a window where a teacher could hand out a link before the row existed
// — the student would then hit "Assignment not found" on submit.
useEffect(() => {
if (embedDb && onSaveAssignment && !saved && !saving) {
void handleSaveToDb();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [embedDb]);

const handleDownloadSEB = useCallback(() => {
downloadSebConfig(essayUrl, `essay-${studentName}`);
}, [essayUrl, studentName]);
Expand All @@ -170,6 +187,12 @@ export default function EssayAssignmentModal({

const handleAssignToStudents = useCallback(() => {
if (!onAssignToStudents) return;
// essay_assignments rows are 1:1 with a single teacherKey server-side, but this
// fan-out reuses one teacherKey across every student in the class — a "DB mode"
// link here would point every student at a row that can only ever belong to one
// of them. buildAssignment() never embeds credentials without onSaveAssignment
// (which EssayBuilderPage doesn't pass), so these links always use the
// always-working offline/local-code flow instead.
const assignment = buildAssignment(studentId);
onAssignToStudents(assignment, classStudents);
onClose();
Expand Down Expand Up @@ -376,8 +399,10 @@ export default function EssayAssignmentModal({
/>
</div>

{/* 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 && (
<div
style={{
background: 'color-mix(in srgb, var(--accent) 8%, transparent)',
Expand Down
181 changes: 181 additions & 0 deletions src/components/Students/StudentPasswordSlipSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import React, { useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { X, Printer } from 'lucide-react';
import QRCode from 'qrcode';
import { useTranslation } from 'react-i18next';

export interface PasswordSlip {
id: string;
name: string;
email: string;
password?: string;
error?: string;
}

interface Props {
slips: PasswordSlip[];
onClose: () => void;
}

function SlipItem({ slip }: { slip: PasswordSlip }) {
const { t } = useTranslation();
const canvasRef = useRef<HTMLCanvasElement>(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 (
<div className="slip-item">
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: '1rem', color: '#1e293b', marginBottom: 4 }}>{slip.name}</div>
<div style={{ fontSize: '0.78rem', color: '#475569', marginBottom: 6, wordBreak: 'break-all' }}>
{slip.email}
</div>
{slip.password ? (
<div
style={{
fontFamily: 'monospace',
fontSize: '1.05rem',
fontWeight: 700,
letterSpacing: '0.05em',
color: '#0f172a',
background: '#f1f5f9',
border: '1px solid #cbd5e1',
borderRadius: 4,
padding: '4px 8px',
display: 'inline-block',
}}
>
{slip.password}
</div>
) : (
<div style={{ fontSize: '0.78rem', color: '#dc2626' }}>
{t('studentsPage.password_slip_item_error')}
</div>
)}
</div>
{slip.password && <canvas ref={canvasRef} style={{ flexShrink: 0 }} />}
</div>
);
}

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 */}
<style>{`
@media print {
.slip-sheet-overlay { position: static !important; background: none !important; padding: 0 !important; }
.slip-sheet-controls { display: none !important; }
.slip-sheet-container { box-shadow: none !important; border-radius: 0 !important; max-height: none !important; overflow: visible !important; }
.slip-sheet-grid { grid-template-columns: repeat(${columns}, 1fr) !important; }
.slip-item { break-inside: avoid; }
}
.slip-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px dashed #cbd5e1;
border-radius: 6px;
background: #fff;
}
`}</style>

<div
className="slip-sheet-overlay"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
zIndex: 1100,
padding: 24,
overflowY: 'auto',
}}
>
<div
className="slip-sheet-container"
style={{
background: '#f8fafc',
borderRadius: 14,
width: '100%',
maxWidth: 860,
boxShadow: '0 20px 60px rgba(0,0,0,0.25)',
maxHeight: '90vh',
overflow: 'auto',
}}
>
{/* Controls bar */}
<div
className="slip-sheet-controls"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '14px 20px',
borderBottom: '1px solid #e2e8f0',
background: '#fff',
position: 'sticky',
top: 0,
zIndex: 10,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontWeight: 700, fontSize: '0.95rem' }}>
{t('studentsPage.password_slip_title', { count: slips.length })}
</span>
<div style={{ display: 'flex', gap: 4, marginLeft: 12 }}>
<button
onClick={() => setColumns(2)}
className={`btn btn-sm ${columns === 2 ? 'btn-primary' : 'btn-secondary'}`}
>
2 {t('studentsPage.password_slip_columns')}
</button>
<button
onClick={() => setColumns(4)}
className={`btn btn-sm ${columns === 4 ? 'btn-primary' : 'btn-secondary'}`}
>
4 {t('studentsPage.password_slip_columns')}
</button>
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary btn-sm" onClick={() => window.print()}>
<Printer size={14} /> {t('common.print')}
</button>
<button
className="btn btn-ghost btn-icon btn-sm"
aria-label={t('common.close')}
onClick={onClose}
>
<X size={16} />
</button>
</div>
</div>

{/* Slip grid */}
<div
className="slip-sheet-grid"
style={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 8, padding: 20 }}
>
{slips.map((slip) => (
<SlipItem key={slip.id} slip={slip} />
))}
</div>
</div>
</div>
</>
);

return ReactDOM.createPortal(content, document.body);
}
Loading
Loading