feat: the player is a box; sharing and sign-up are the administrator's

The quiz player is a box the height of the window. The question used to
scroll the whole page, which took the session rail and the navigation off
screen exactly when you wanted them; now each column scrolls on its own
and the bar — Exit session, Previous, Next, Review — stays put.

Two site-wide switches, together under Settings → Site policy because
both are the administrator's and both apply to everyone:

  * Sharing can be turned off. That stops new links being made; one
    already handed to somebody keeps working, since revoking it would
    break something a learner has already given away.
  * Sign-up can be made invite-only, with single-use codes carrying a
    note of who each is for and, afterwards, who it let in. A spent code
    is kept rather than deleted — that record is the point of invite-only.
    The alphabet has no O/0 or I/1/l, because these get read aloud.

The registration form asks for a code only when the site needs one, via
an unauthenticated policy endpoint — it has to know before there is an
account to ask with. It never says whether a given code is valid before
the account exists, which would make it somewhere to guess them. The
first account is always allowed, or a new install would lock itself out
before an administrator existed to issue a code.

Flags fall back to their defaults when Redis is down, in the safe
direction each way: sharing keeps working, sign-up does not silently
open.

Found on the way: the registration form's three labels named nothing —
no `for`, no wrapping — so a screen reader announced unlabelled boxes.

Backend 261/261, frontend 328/328.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-11 19:17:35 +02:00
parent 4f05b40a6a
commit 2267f53b55
19 changed files with 761 additions and 26 deletions

View file

@ -0,0 +1,34 @@
"""Invite codes, for a site that is not open to register
Revision ID: d4e5f6a7b8c9
Revises: c3d4e5f6a7b8
"""
import sqlalchemy as sa
from alembic import op
revision = "d4e5f6a7b8c9"
down_revision = "c3d4e5f6a7b8"
branch_labels = None
depends_on = None
def upgrade() -> None:
if "invite_codes" in sa.inspect(op.get_bind()).get_table_names():
return
op.create_table(
"invite_codes",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("code", sa.String(length=32), nullable=False, unique=True),
sa.Column("note", sa.String(length=200), nullable=True),
sa.Column("created_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
sa.Column("used_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("used_at", sa.DateTime(), nullable=True),
sa.Column("revoked_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_invite_codes_code", "invite_codes", ["code"], unique=True)
def downgrade() -> None:
if "invite_codes" in sa.inspect(op.get_bind()).get_table_names():
op.drop_table("invite_codes")

View file

@ -42,3 +42,4 @@ __all__ = [
]
from app.models.feedback import QuestionFeedback # noqa: F401
from app.models.invite import InviteCode # noqa: F401

View file

@ -0,0 +1,28 @@
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from app.database import Base
class InviteCode(Base):
"""A code an administrator issues so one person can register.
Single use by default: the point of invite-only is knowing who came in, and
a code that works forever is a password shared by everyone who has seen it.
A code is never deleted once used who it let in is the record worth
keeping.
"""
__tablename__ = "invite_codes"
id = Column(Integer, primary_key=True, index=True)
code = Column(String(32), unique=True, nullable=False, index=True)
note = Column(String(200), nullable=True) # who it was meant for
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
#: Set when someone registers with it. Present means spent.
used_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
used_at = Column(DateTime, nullable=True)
#: An administrator can withdraw a code that has not been used.
revoked_at = Column(DateTime, nullable=True)

View file

@ -1,5 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from datetime import datetime
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
import httpx
@ -8,6 +10,8 @@ from app.config import settings
from app.database import get_db
from app.models.user import User
from app.models.ai_model_config import AIModelConfig
from app.models.invite import InviteCode
from app.services import invites, site_settings
from app.schemas.auth import UserResponse, UserUpdateRole, UserCreate
from app.schemas.admin import AIModelConfigCreate, AIModelConfigResponse, AIModelConfigUpdate
from app.utils.auth import require_admin, get_current_user, get_password_hash
@ -465,6 +469,45 @@ def search_tts_voices(
raise HTTPException(status_code=400, detail=f"Unknown provider '{provider}'. Valid: litellm")
# --- Invite codes ---
class InviteIn(BaseModel):
note: str | None = Field(default=None, max_length=200)
@router.get("/invites")
def list_invites(db: Session = Depends(get_db), admin: User = Depends(require_admin)):
"""Every code, newest first, with who it let in."""
rows = db.query(InviteCode).order_by(InviteCode.created_at.desc()).limit(200).all()
users = {u.id: u for u in db.query(User).filter(
User.id.in_({r.used_by for r in rows if r.used_by}))} if rows else {}
return [invites.as_json(row, users) for row in rows]
@router.post("/invites", status_code=201)
def create_invite(data: InviteIn, db: Session = Depends(get_db),
admin: User = Depends(require_admin)):
row = invites.create(db, created_by=admin.id, note=data.note)
return invites.as_json(row, {})
@router.delete("/invites/{invite_id}", status_code=204)
def revoke_invite(invite_id: int, db: Session = Depends(get_db),
admin: User = Depends(require_admin)):
"""Withdraw a code that has not been used.
A spent code is kept: who it let in is the record worth having, and
deleting it would lose that.
"""
row = db.get(InviteCode, invite_id)
if not row:
raise HTTPException(404, "Invite not found")
if row.used_by is not None:
raise HTTPException(400, "That code has already been used")
row.revoked_at = datetime.utcnow()
db.commit()
# --- System Settings ---
@router.get("/settings")
@ -482,6 +525,7 @@ def get_settings(admin: User = Depends(require_admin)):
"sso_only": sso_only == "true",
"sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
"sso_provider_name": settings.OIDC_PROVIDER_NAME,
**site_settings.all_flags(),
}
except Exception:
return {
@ -490,6 +534,7 @@ def get_settings(admin: User = Depends(require_admin)):
"sso_only": False,
"sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
"sso_provider_name": settings.OIDC_PROVIDER_NAME,
**site_settings.FLAGS,
}
@ -507,6 +552,10 @@ def update_settings(
value = "true" if settings_data["registration_enabled"] else "false"
r.set("settings:registration_enabled", value)
for flag in site_settings.FLAGS:
if flag in settings_data:
site_settings.set_flag(flag, bool(settings_data[flag]))
if "embedding_model" in settings_data:
r.set("settings:embedding_model", settings_data["embedding_model"])

View file

@ -4,6 +4,7 @@ from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request
from sqlalchemy.orm import Session
from app.services import invites, site_settings
from app.database import get_db
from app.models.user import User
from app.models.email_verification import EmailVerification
@ -79,6 +80,22 @@ async def _verify_turnstile(token: str) -> bool:
return True
@router.get("/signup-policy")
def signup_policy(db: Session = Depends(get_db)):
"""What a would-be member needs, before they are anybody.
Unauthenticated on purpose: the registration form has to know whether to
ask for a code, and it is asking before it has an account to ask with. It
says only whether one is needed never whether a given code is valid,
which would turn this into somewhere to guess them.
"""
# The very first account is always allowed, or a new install could lock
# itself out before an administrator exists to issue a code.
if db.query(User).count() == 0:
return {"invite_required": False, "first_user": True}
return {"invite_required": site_settings.get_flag("invite_only"), "first_user": False}
@router.post("/register")
async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
# Verify Turnstile if configured
@ -105,6 +122,14 @@ async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db:
except Exception as e:
import logging; logging.getLogger(__name__).warning(f"Redis registration check failed (failing open): {e}")
# Invite-only: a code is checked before anything is created, and spent only
# once the account exists, so a failure part-way through does not burn it.
invite = None
if not is_first_user and site_settings.get_flag("invite_only"):
invite = invites.usable(db, user_data.invite_code)
if invite is None:
raise HTTPException(403, "This site is invite-only. A valid invite code is required.")
if len(user_data.password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
@ -131,6 +156,8 @@ async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db:
)
db.add(verification)
db.commit()
if invite is not None:
invites.spend(db, invite, user)
db.refresh(user)
if is_first_user:

View file

@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import cast, String, or_, and_, func
from sqlalchemy.orm import Session
from app.services import site_settings
from app.services.attempt_expiry import settle_if_expired
from app.services.question_figures import figures_for_questions
from app.services.study_plan_context import plan_context_for_quizzes
@ -746,6 +747,10 @@ def share_quiz(quiz_id: int, shared: bool = Query(...), db: Session = Depends(ge
def create_share_link(quiz_id: int, db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
"""Mark a general quiz shareable and return its public token."""
# An institution can switch sharing off for everyone; a link already issued
# keeps working, but no new one is made.
if not site_settings.get_flag("sharing_enabled"):
raise HTTPException(403, "Sharing is turned off for this site")
quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first()
if not quiz:
raise HTTPException(404, "Quiz not found")

View file

@ -8,6 +8,8 @@ class UserCreate(BaseModel):
password: str
name: str
turnstile_token: str | None = None
#: Required only while the site is invite-only.
invite_code: str | None = None
class UserResponse(BaseModel):

View file

@ -0,0 +1,67 @@
"""Invite codes: issuing them, checking one, and spending it.
Kept out of the routers because two of them need it registration spends a
code, administration issues them and the rule for "usable" is the kind of
thing that must have exactly one definition.
"""
import secrets
from datetime import datetime
from sqlalchemy.orm import Session
from app.models.invite import InviteCode
from app.models.user import User
#: Unambiguous when read aloud or copied: no O/0, no I/1/l.
ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
LENGTH = 10
def generate_code() -> str:
return "".join(secrets.choice(ALPHABET) for _ in range(LENGTH))
def create(db: Session, created_by: int | None, note: str | None = None) -> InviteCode:
# Retried rather than trusted: the column is unique, and a collision is
# cheaper to avoid than to explain.
for _ in range(5):
code = generate_code()
if not db.query(InviteCode.id).filter(InviteCode.code == code).first():
row = InviteCode(code=code, note=(note or None), created_by=created_by)
db.add(row)
db.commit()
db.refresh(row)
return row
raise RuntimeError("Could not allocate an unused invite code")
def usable(db: Session, code: str | None) -> InviteCode | None:
"""The code, if it exists and has neither been spent nor withdrawn."""
cleaned = (code or "").strip().upper()
if not cleaned:
return None
row = db.query(InviteCode).filter(InviteCode.code == cleaned).first()
if row is None or row.used_by is not None or row.revoked_at is not None:
return None
return row
def spend(db: Session, invite: InviteCode, user: User) -> None:
invite.used_by = user.id
invite.used_at = datetime.utcnow()
db.commit()
def as_json(row: InviteCode, users: dict[int, User]) -> dict:
used_by = users.get(row.used_by)
return {
"id": row.id,
"code": row.code,
"note": row.note,
"created_at": row.created_at,
"used_at": row.used_at,
"used_by_name": getattr(used_by, "name", None),
"used_by_email": getattr(used_by, "email", None),
"revoked_at": row.revoked_at,
"status": "used" if row.used_by else "revoked" if row.revoked_at else "open",
}

View file

@ -0,0 +1,50 @@
"""Site-wide switches an administrator sets once.
They live in Redis because they are configuration rather than records but a
site must keep working when Redis does not, so every read falls back to the
default rather than raising. The defaults are deliberately the permissive ones
for features that already existed and the restrictive one for the gate that
protects sign-up: losing Redis should not silently open registration.
"""
import logging
logger = logging.getLogger(__name__)
#: name -> default. Anything not listed here cannot be set.
FLAGS: dict[str, bool] = {
#: Whether a learner may create a public share link for a session.
"sharing_enabled": True,
#: Whether registering requires an invite code issued by an administrator.
"invite_only": False,
}
def _client():
import redis as redis_lib
from app.config import settings
return redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
def get_flag(name: str) -> bool:
if name not in FLAGS:
raise KeyError(name)
default = FLAGS[name]
try:
value = _client().get(f"settings:{name}")
except Exception:
logger.warning("Redis unavailable reading %s; using the default", name, exc_info=True)
return default
if value is None:
return default
return value == "true"
def set_flag(name: str, value: bool) -> None:
if name not in FLAGS:
raise KeyError(name)
_client().set(f"settings:{name}", "true" if value else "false")
def all_flags() -> dict[str, bool]:
return {name: get_flag(name) for name in FLAGS}

View file

@ -0,0 +1,99 @@
"""Invite-only sign-up, and the switches an administrator sets once.
Disposable SQLite; Redis is a Mock. The rules worth pinning: a code works once,
a spent code is kept rather than deleted, the flag falls back to its default
when Redis is down and, for the gate that protects sign-up, the default is
the safe direction.
"""
import sys
import unittest
from types import ModuleType
from unittest.mock import Mock, patch
import test_quiz_builder as fixtures
from app.models.invite import InviteCode
from app.models.user import User
from app.services import invites, site_settings
def fake_redis(store, broken=False):
client = Mock()
if broken:
client.get.side_effect = ConnectionError("redis down")
client.set.side_effect = ConnectionError("redis down")
else:
client.get.side_effect = lambda k: store.get(k)
client.set.side_effect = lambda k, v: store.__setitem__(k, v)
module = Mock()
module.from_url.return_value = client
return module
class FlagTests(unittest.TestCase):
def test_unset_flags_take_their_default(self):
with patch.dict(sys.modules, {"redis": fake_redis({})}):
self.assertTrue(site_settings.get_flag("sharing_enabled"))
self.assertFalse(site_settings.get_flag("invite_only"))
def test_a_flag_reads_back_what_was_set(self):
store = {}
with patch.dict(sys.modules, {"redis": fake_redis(store)}):
site_settings.set_flag("invite_only", True)
self.assertEqual(store["settings:invite_only"], "true")
self.assertTrue(site_settings.get_flag("invite_only"))
def test_losing_redis_falls_back_rather_than_failing(self):
with patch.dict(sys.modules, {"redis": fake_redis({}, broken=True)}):
# Sharing keeps working; sign-up does not silently open.
self.assertTrue(site_settings.get_flag("sharing_enabled"))
self.assertFalse(site_settings.get_flag("invite_only"))
def test_an_unknown_flag_is_refused_rather_than_invented(self):
with self.assertRaises(KeyError):
site_settings.get_flag("nonsense")
with self.assertRaises(KeyError):
site_settings.set_flag("nonsense", True)
class InviteTests(unittest.TestCase):
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.db = self.bank.db
def tearDown(self):
self.bank.tearDown()
def test_a_code_is_unambiguous_to_read_aloud(self):
code = invites.generate_code()
self.assertEqual(len(code), invites.LENGTH)
# No O/0 or I/1/l to mistype.
self.assertFalse(set(code) & set("O0I1l"))
def test_a_code_works_once(self):
row = invites.create(self.db, created_by=None, note="For a new tutor")
self.assertIsNotNone(invites.usable(self.db, row.code))
# Case and surrounding space are forgiven; a typed code is typed.
self.assertIsNotNone(invites.usable(self.db, f" {row.code.lower()} "))
invites.spend(self.db, row, self.bank.peer)
self.assertIsNone(invites.usable(self.db, row.code))
# Kept, not deleted: who it let in is the record worth having.
self.assertEqual(self.db.query(InviteCode).count(), 1)
self.assertEqual(self.db.get(InviteCode, row.id).used_by, self.bank.peer.id)
def test_nothing_and_nonsense_are_not_codes(self):
self.assertIsNone(invites.usable(self.db, None))
self.assertIsNone(invites.usable(self.db, ""))
self.assertIsNone(invites.usable(self.db, "NOTACODE12"))
def test_a_withdrawn_code_stops_working(self):
from datetime import datetime
row = invites.create(self.db, created_by=None)
row.revoked_at = datetime.utcnow()
self.db.commit()
self.assertIsNone(invites.usable(self.db, row.code))
if __name__ == "__main__":
unittest.main()

View file

@ -233,22 +233,24 @@ Captured so nothing is lost while the article writing runs.
## Asked for on 2026-09-11 (evening), not yet done
- [ ] **Boxed quiz player** — a viewport-height shell: fixed rail, fixed bottom
bar (Exit / Previous / Next), question scrolling in its own column, so the
page itself does not scroll. Structural change to QuizPage.
- [x] **Boxed quiz player** — done 2026-09-11. A box the height of the window:
the rail and the bottom bar stay put, the question scrolls in its own
column, and the footer is out of the way while a session is being sat.
- [ ] **Share dialog** — a proper one: session title, first question as a
preview, the link with Copy, and a few share targets. Currently a copy
button in the more-menu.
- [ ] **Admin can turn sharing off site-wide** — one setting that disables
share links everywhere, for an institution that does not want them.
- [x] **Sharing off site-wide** — done 2026-09-11. Settings → Site policy.
Stops new links; one already handed to somebody keeps working.
- [ ] **Remove per-question share/unshare**`Question.is_shared` and
`PATCH /questions/{id}/share`. Access is the admin-scoped grant tree now.
NOT a small delete: `bank_question_predicate` and
`shareable_question_predicate` are built on is_shared and decide who sees
which questions and what the recommendation denominators are. Needs its
own change with the visibility rules rewritten deliberately.
- [ ] **Invite-only sign-up** — an admin switch plus generated invite codes,
with the registration form requiring one while it is on.
- [x] **Invite-only sign-up** — done 2026-09-11. A switch plus single-use
codes an administrator issues, with a note of who each is for and who it
let in. The form asks for one only when the site needs it, and never says
whether a code is valid before the account is made.
- [ ] **Settings, properly** — the section list was a restructure, not the
revamp asked for. Wants: what belongs there decided first, then the
sign-up policy and invite codes, the site-wide sharing switch, and the

View file

@ -0,0 +1,52 @@
/* Who gets in, and what they may pass on. */
.sp-error { margin: 0 0 12px; padding: 9px 12px; font-size: 0.85rem; color: var(--wrong-fg); background: var(--wrong-bg); border: 1px solid var(--wrong-bd); border-radius: 8px; }
.sp-switch { display: flex; gap: 12px; align-items: flex-start; cursor: pointer; padding: 12px 0; }
.sp-switch + .sp-switch { border-top: 1px solid var(--border); }
.sp-switch input { width: 18px; height: 18px; margin-top: 2px; flex-shrink: 0; }
.sp-switch strong { display: block; font-size: 0.92rem; margin-bottom: 3px; }
.sp-switch small { display: block; font-size: 0.82rem; line-height: 1.6; color: var(--text-muted); }
.sp-codes { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border); }
.sp-codes h3 {
display: flex; align-items: baseline; gap: 8px; margin: 0 0 10px;
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.07em;
text-transform: uppercase; color: var(--text-subtle);
}
.sp-codes h3 small { font-size: 0.74rem; font-weight: 500; letter-spacing: 0; text-transform: none; color: var(--text-muted); }
.sp-issue { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
.sp-issue input {
flex: 1; min-width: 180px; padding: 8px 11px;
/* 16px on touch so iOS does not zoom the page in on focus. */
font-size: 16px; font-family: inherit;
border: 1px solid var(--border); border-radius: 8px; background: var(--input-bg); color: var(--text);
}
@media (min-width: 700px) { .sp-issue input { font-size: 0.88rem; } }
.sp-empty { margin: 0; font-size: 0.85rem; color: var(--text-muted); }
.sp-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.sp-code {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 10px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 10px;
}
.sp-code code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.95rem; font-weight: 700; letter-spacing: 0.08em;
padding: 3px 9px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
}
/* A spent code is kept who it let in is the record worth having but it
should not read as one you can still hand out. */
.sp-code.is-used, .sp-code.is-revoked { opacity: 0.62; }
.sp-code.is-used code, .sp-code.is-revoked code { text-decoration: line-through; }
.sp-code-note { flex: 1; min-width: 140px; font-size: 0.85rem; overflow-wrap: anywhere; }
.sp-code-note small { display: block; font-size: 0.78rem; color: var(--text-muted); }
.sp-code-note em { color: var(--text-subtle); }
.sp-code-actions { display: flex; gap: 6px; flex-shrink: 0; }
.sp-revoke { color: var(--wrong-fg); border-color: var(--wrong-bd); }
@media (max-width: 560px) {
.sp-code-actions { width: 100%; }
.sp-code-actions .btn { flex: 1; }
}

View file

@ -0,0 +1,144 @@
import { useCallback, useEffect, useState } from 'react'
import api from '../api/client'
import './SitePolicy.css'
const when = (value) => (value ? new Date(value).toLocaleDateString(undefined,
{ day: '2-digit', month: 'short', year: 'numeric' }) : '')
/**
* The two switches that decide who gets in and what they may pass on.
*
* Both are site-wide and both are the administrator's, so they sit together
* rather than one in the admin dashboard and one somewhere in a quiz.
*
* Turning sharing off does not revoke links already issued that would break
* something a learner has already handed to someone it stops new ones.
*/
export default function SitePolicy() {
const [flags, setFlags] = useState({ sharing_enabled: true, invite_only: false })
const [codes, setCodes] = useState([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [note, setNote] = useState('')
const [copied, setCopied] = useState(null)
const load = useCallback(() => {
Promise.all([api.get('/admin/settings'), api.get('/admin/invites')])
.then(([settings, invites]) => {
setFlags({
sharing_enabled: settings.data.sharing_enabled !== false,
invite_only: settings.data.invite_only === true,
})
setCodes(invites.data || [])
})
.catch(() => setError('Could not load the site policy'))
.finally(() => setLoading(false))
}, [])
useEffect(() => { load() }, [load])
const run = async (fn, failure) => {
setBusy(true); setError('')
try { await fn(); load() }
catch (err) {
const detail = err?.response?.data?.detail
setError(typeof detail === 'string' ? detail : failure)
} finally { setBusy(false) }
}
const toggle = (name, value) => {
setFlags(prev => ({ ...prev, [name]: value }))
return run(() => api.put('/admin/settings', { [name]: value }), 'Could not save that')
}
const issue = () => run(
() => api.post('/admin/invites', { note: note.trim() || null }).then(res => { setNote(''); return res }),
'Could not create a code')
const revoke = (row) => run(() => api.delete(`/admin/invites/${row.id}`), 'Could not withdraw that code')
const copy = (code) => {
navigator.clipboard?.writeText(code)
setCopied(code)
setTimeout(() => setCopied(null), 2000)
}
if (loading) return <div className="loading"><div className="spinner" /></div>
const open = codes.filter(row => row.status === 'open')
return (
<div className="sp">
{error && <p className="sp-error" role="alert">{error}</p>}
<label className="sp-switch">
<input type="checkbox" checked={flags.invite_only} disabled={busy}
onChange={e => toggle('invite_only', e.target.checked)} />
<span>
<strong>Invite only</strong>
<small>
Registering requires a code issued here. Anyone who already has an
account keeps it.
</small>
</span>
</label>
<label className="sp-switch">
<input type="checkbox" checked={flags.sharing_enabled} disabled={busy}
onChange={e => toggle('sharing_enabled', e.target.checked)} />
<span>
<strong>Allow sharing sessions</strong>
<small>
Learners can create a public link to a session. Turning this off
stops new links; one already handed to somebody keeps working.
</small>
</span>
</label>
{flags.invite_only && (
<section className="sp-codes">
<h3>Invite codes <small>{open.length} unused</small></h3>
<div className="sp-issue">
<input value={note} maxLength={200} placeholder="Who is it for? (optional)"
aria-label="Who the invite is for" onChange={e => setNote(e.target.value)} />
<button type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={issue}>
Create a code
</button>
</div>
{codes.length === 0 ? (
<p className="sp-empty">No codes yet. Create one to let somebody in.</p>
) : (
<ul className="sp-list">
{codes.map(row => (
<li key={row.id} className={`sp-code is-${row.status}`}>
<code>{row.code}</code>
<span className="sp-code-note">
{row.note || <em>no note</em>}
{row.status === 'used' && (
<small>Used by {row.used_by_name || 'someone'} on {when(row.used_at)}</small>
)}
{row.status === 'revoked' && <small>Withdrawn {when(row.revoked_at)}</small>}
</span>
<span className="sp-code-actions">
{row.status === 'open' && (
<>
<button type="button" className="btn btn-secondary btn-sm"
onClick={() => copy(row.code)}>{copied === row.code ? '✓ Copied' : 'Copy'}</button>
<button type="button" className="btn btn-secondary btn-sm sp-revoke"
disabled={busy} aria-label={`Withdraw ${row.code}`}
onClick={() => revoke(row)}>Withdraw</button>
</>
)}
</span>
</li>
))}
</ul>
)}
</section>
)}
</div>
)
}

View file

@ -1148,7 +1148,7 @@ const timerStarted = timeLeft !== null
}
return (
<div className="quiz-bottom quiz-player">
<div className="quiz-bottom quiz-player is-boxed">
{/* The floating global-notes tab is gone. A note taken while sitting a
question is about that question, and there is a per-question note in
the toolbar below; a second, unrelated notepad floating over the same
@ -1622,15 +1622,6 @@ const timerStarted = timeLeft !== null
</div>
)}
{quizNavigation('bottom')}
{answeredCount > 0 && !isLast && (
<div style={{ textAlign: 'center', marginTop: 14 }}>
<button className="btn btn-secondary btn-sm" onClick={() => setShowReview(true)} disabled={submitting}>
Review ({answeredCount}/{totalCount} answered)
</button>
</div>
)}
</div>
{/* Desktop rail — numbers with an excerpt, as in a Qbank session */}
@ -1654,6 +1645,21 @@ const timerStarted = timeLeft !== null
</div>
</div>
{/* The session's own bar, outside the scrolling columns so it is always
on screen the player is a fixed-height shell and the question
scrolls inside it, rather than the whole page scrolling. */}
<div className="quiz-footbar">
<button type="button" className="btn btn-secondary btn-sm quiz-exit"
onClick={() => setLeaveTarget(returnTo || '/')}>Exit session</button>
{quizNavigation('bottom')}
{answeredCount > 0 && (
<button className="btn btn-secondary btn-sm quiz-review-link"
onClick={() => setShowReview(true)} disabled={submitting}>
Review ({answeredCount}/{totalCount})
</button>
)}
</div>
{/* AI tutor — only in study mode, lazy-loaded */}
{isStudy && current && (
<Suspense fallback={null}>

View file

@ -272,6 +272,21 @@ describe('quiz player', () => {
await waitFor(() => expect(screen.queryByText('Because it is first.')).not.toBeInTheDocument())
})
it('is a box the height of the window, not a page that scrolls away from its own controls', async () => {
await begin()
await findStem('Full first clinical question.')
const player = document.querySelector('.quiz-player')
expect(player).toHaveClass('is-boxed')
// The navigation is outside the scrolling columns, so it stays on screen
// while the question scrolls it used to sit under the question.
const bar = document.querySelector('.quiz-footbar')
expect(bar).toBeInTheDocument()
expect(bar.closest('.quiz-layout')).toBeNull()
expect(within(bar).getByRole('button', { name: 'Exit session' })).toBeInTheDocument()
expect(within(bar).getByRole('button', { name: /Next/ })).toBeInTheDocument()
})
it('keeps notes with the question, not in a second notepad floating over it', async () => {
await begin()
const bar = await screen.findByRole('toolbar', { name: 'Question actions' })

View file

@ -277,3 +277,46 @@
.quiz-more-feedback { border-top: 1px solid var(--border); margin-top: 6px; padding-top: 6px; }
.quiz-more-feedback .fb-form { padding: 4px 10px 8px; }
.quiz-more-menu .quiz-code-badge { padding: 8px 10px; }
/* The player as a fixed-height shell
The question used to scroll the whole page, which took the session rail and
the navigation off screen exactly when you wanted them. The player is now a
box the height of the window: the rail and the bar stay, and the question
scrolls inside its own column. */
.quiz-player.is-boxed {
/* The two header bars above it. They do not collapse here, because with no
page scroll there is no scrolling for them to react to. */
height: calc(100dvh - 98px);
display: flex; flex-direction: column;
padding-bottom: 0; overflow: hidden;
}
.quiz-player.is-boxed .quiz-layout {
flex: 1; min-height: 0; align-items: stretch;
}
/* Each column scrolls on its own. `min-height: 0` is what lets a grid child
shrink below its content and become scrollable at all. */
.quiz-player.is-boxed .quiz-layout > * { min-height: 0; overflow-y: auto; }
.quiz-player.is-boxed .quiz-sidebar { position: static; max-height: none; }
.quiz-footbar {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 10px 0 calc(10px + env(safe-area-inset-bottom));
border-top: 1px solid var(--border); background: #fff;
}
.quiz-footbar .quiz-nav-controls { flex: 1; justify-content: center; margin: 0; }
.quiz-exit { flex-shrink: 0; }
.quiz-review-link { flex-shrink: 0; }
/* Nothing else on the page while a session is being sat. */
body:has(.quiz-player.is-boxed) .site-footer { display: none; }
@media (max-width: 1150px) {
/* One column; the question scrolls and the bar stays. */
.quiz-player.is-boxed .quiz-layout > * { overflow-y: visible; }
.quiz-player.is-boxed .quiz-layout { overflow-y: auto; }
}
@media (max-width: 640px) {
.quiz-player.is-boxed { height: calc(100dvh - 90px); }
.quiz-footbar { gap: 8px; }
.quiz-footbar .quiz-nav-controls { order: -1; width: 100%; }
}

View file

@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import RegisterPage from './RegisterPage'
import api from '../api/client'
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ loginWithToken: vi.fn() }) }))
const mount = (inviteRequired) => {
api.get.mockResolvedValue({ data: { invite_required: inviteRequired, first_user: false } })
return render(<MemoryRouter><RegisterPage /></MemoryRouter>)
}
const fill = async () => {
await userEvent.type(screen.getByLabelText('Name'), 'Ada')
await userEvent.type(screen.getByLabelText('Email'), 'ada@example.test')
await userEvent.type(screen.getByLabelText('Password'), 'longenough1')
}
describe('registering when the site is invite only', () => {
beforeEach(() => { vi.clearAllMocks() })
it('asks for nothing extra on an open site', async () => {
mount(false)
await waitFor(() => expect(api.get).toHaveBeenCalledWith('/auth/signup-policy'))
expect(screen.queryByLabelText('Invite code')).not.toBeInTheDocument()
})
it('asks for a code, and will not submit without one', async () => {
mount(true)
const field = await screen.findByLabelText('Invite code')
await fill()
expect(screen.getByRole('button', { name: /Sign Up/ })).toBeDisabled()
await userEvent.type(field, 'abcd234xyz')
// Typed in whatever case, sent in the one the codes are issued in.
expect(field).toHaveValue('ABCD234XYZ')
expect(screen.getByRole('button', { name: /Sign Up/ })).toBeEnabled()
api.post.mockResolvedValue({ data: { requires_verification: true } })
await userEvent.click(screen.getByRole('button', { name: /Sign Up/ }))
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/auth/register',
expect.objectContaining({ invite_code: 'ABCD234XYZ' })))
})
it('shows the refusal rather than a blank failure', async () => {
mount(true)
await screen.findByLabelText('Invite code')
await fill()
await userEvent.type(screen.getByLabelText('Invite code'), 'WRONGCODE1')
api.post.mockRejectedValue({ response: { data: { detail: 'This site is invite-only. A valid invite code is required.' } } })
await userEvent.click(screen.getByRole('button', { name: /Sign Up/ }))
expect(await screen.findByText(/invite-only/)).toBeInTheDocument()
})
it('stays usable if the policy cannot be fetched', async () => {
api.get.mockRejectedValue(new Error('down'))
render(<MemoryRouter><RegisterPage /></MemoryRouter>)
// No code asked for, rather than a form nobody can complete.
await waitFor(() => expect(screen.queryByLabelText('Invite code')).not.toBeInTheDocument())
expect(screen.getByRole('button', { name: /Sign Up/ })).toBeEnabled()
})
})

View file

@ -37,14 +37,30 @@ export default function RegisterPage() {
const [loading, setLoading] = useState(false)
const [done, setDone] = useState(false)
const [turnstileToken, setTurnstileToken] = useState('')
// Whether this site is invite-only. Asked before there is an account to ask
// with, so the form knows whether to want a code.
const [inviteRequired, setInviteRequired] = useState(false)
const [inviteCode, setInviteCode] = useState('')
const { loginWithToken } = useAuth()
useEffect(() => {
let live = true
api.get('/auth/signup-policy')
.then(res => { if (live) setInviteRequired(!!res.data?.invite_required) })
.catch(() => {})
return () => { live = false }
}, [])
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const res = await api.post('/auth/register', { email, password, name, turnstile_token: turnstileToken || null })
const res = await api.post('/auth/register', {
email, password, name,
turnstile_token: turnstileToken || null,
invite_code: inviteCode.trim() || null,
})
if (res.data.requires_verification) {
setDone(true)
} else {
@ -92,19 +108,35 @@ export default function RegisterPage() {
{error && <div className="alert alert-error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Name</label>
<input type="text" value={name} onChange={e => setName(e.target.value)} required />
<label htmlFor="reg-name">Name</label>
<input id="reg-name" type="text" value={name} onChange={e => setName(e.target.value)} required />
</div>
<div className="form-group">
<label>Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
<label htmlFor="reg-email">Email</label>
<input id="reg-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required />
</div>
<div className="form-group">
<label>Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required minLength={8} />
<label htmlFor="reg-password">Password</label>
<input id="reg-password" type="password" value={password} onChange={e => setPassword(e.target.value)} required minLength={8} />
</div>
{inviteRequired && (
<div className="form-group">
<label htmlFor="invite-code">Invite code</label>
{/* Asked for only where it is needed. The form never says whether
a code is valid before the account is made that would be a
place to guess them. */}
<input id="invite-code" value={inviteCode} required autoComplete="off"
placeholder="From whoever invited you"
style={{ textTransform: 'uppercase', letterSpacing: '0.08em' }}
onChange={e => setInviteCode(e.target.value.toUpperCase())} />
<small style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>
This site is invite only.
</small>
</div>
)}
<TurnstileWidget onVerify={setTurnstileToken} />
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading || (TURNSTILE_SITE_KEY && !turnstileToken)}>
<button className="btn btn-primary" style={{ width: '100%' }}
disabled={loading || (TURNSTILE_SITE_KEY && !turnstileToken) || (inviteRequired && !inviteCode.trim())}>
{loading ? 'Creating account...' : 'Sign Up'}
</button>
</form>

View file

@ -4,6 +4,7 @@ import { useAuth } from '../context/AuthContext'
import { useTheme } from '../context/ThemeContext'
import api from '../api/client'
import ExamSwitcher from '../components/ExamSwitcher'
import SitePolicy from '../components/SitePolicy'
import './SettingsPage.css'
function Section({ title, description, children }) {
@ -376,6 +377,16 @@ function DataSection() {
* order, with no way to link to any of it. The section now lives in the URL,
* so "change your password" is a link and Back works.
*/
/** Who may register, and whether sessions can be shared. Administrators only. */
function SitePolicySection() {
return (
<Section title="Site policy"
description="Who can join, and what they can pass on. These apply to everyone.">
<SitePolicy />
</Section>
)
}
export default function SettingsPage() {
const { user } = useAuth()
const isAdmin = user?.role === 'admin'
@ -392,6 +403,9 @@ export default function SettingsPage() {
render: () => <><DocumentsSection /><NextcloudSection /></> },
{ key: 'admin', icon: '🛠️', label: 'Administration', render: () => <AdminSection /> },
] : []),
...(isAdmin ? [
{ key: 'policy', icon: '🔒', label: 'Site policy', render: () => <SitePolicySection /> },
] : []),
]
const requested = params.get('s')