From 2267f53b55b8532a5f7320afaeae25275c787623 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 19:17:35 +0200 Subject: [PATCH] feat: the player is a box; sharing and sign-up are the administrator's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- .../versions/d4e5f6a7b8c9_invite_codes.py | 34 +++++ backend/app/models/__init__.py | 1 + backend/app/models/invite.py | 28 ++++ backend/app/routers/admin.py | 51 ++++++- backend/app/routers/auth.py | 27 ++++ backend/app/routers/quizzes.py | 5 + backend/app/schemas/auth.py | 2 + backend/app/services/invites.py | 67 ++++++++ backend/app/services/site_settings.py | 50 ++++++ backend/tests/test_invites_and_flags.py | 99 ++++++++++++ docs/TODO.md | 16 +- frontend/src/components/SitePolicy.css | 52 +++++++ frontend/src/components/SitePolicy.jsx | 144 ++++++++++++++++++ frontend/src/pages/QuizPage.jsx | 26 ++-- frontend/src/pages/QuizPage.test.jsx | 15 ++ frontend/src/pages/QuizPlayer.css | 43 ++++++ frontend/src/pages/RegisterInvite.test.jsx | 65 ++++++++ frontend/src/pages/RegisterPage.jsx | 48 +++++- frontend/src/pages/SettingsPage.jsx | 14 ++ 19 files changed, 761 insertions(+), 26 deletions(-) create mode 100644 backend/alembic/versions/d4e5f6a7b8c9_invite_codes.py create mode 100644 backend/app/models/invite.py create mode 100644 backend/app/services/invites.py create mode 100644 backend/app/services/site_settings.py create mode 100644 backend/tests/test_invites_and_flags.py create mode 100644 frontend/src/components/SitePolicy.css create mode 100644 frontend/src/components/SitePolicy.jsx create mode 100644 frontend/src/pages/RegisterInvite.test.jsx diff --git a/backend/alembic/versions/d4e5f6a7b8c9_invite_codes.py b/backend/alembic/versions/d4e5f6a7b8c9_invite_codes.py new file mode 100644 index 0000000..f71459c --- /dev/null +++ b/backend/alembic/versions/d4e5f6a7b8c9_invite_codes.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d65f8ca..c6dd29c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -42,3 +42,4 @@ __all__ = [ ] from app.models.feedback import QuestionFeedback # noqa: F401 +from app.models.invite import InviteCode # noqa: F401 diff --git a/backend/app/models/invite.py b/backend/app/models/invite.py new file mode 100644 index 0000000..f79c55c --- /dev/null +++ b/backend/app/models/invite.py @@ -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) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 2f0251b..1f5ee7b 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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"]) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index fcbf20e..c285b82 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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: diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py index 5b67d53..889471f 100644 --- a/backend/app/routers/quizzes.py +++ b/backend/app/routers/quizzes.py @@ -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") diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 9caa05c..eb78866 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -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): diff --git a/backend/app/services/invites.py b/backend/app/services/invites.py new file mode 100644 index 0000000..f68b64f --- /dev/null +++ b/backend/app/services/invites.py @@ -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", + } diff --git a/backend/app/services/site_settings.py b/backend/app/services/site_settings.py new file mode 100644 index 0000000..9a9d042 --- /dev/null +++ b/backend/app/services/site_settings.py @@ -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} diff --git a/backend/tests/test_invites_and_flags.py b/backend/tests/test_invites_and_flags.py new file mode 100644 index 0000000..debb3d8 --- /dev/null +++ b/backend/tests/test_invites_and_flags.py @@ -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() diff --git a/docs/TODO.md b/docs/TODO.md index 08fe42c..d664bd3 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 diff --git a/frontend/src/components/SitePolicy.css b/frontend/src/components/SitePolicy.css new file mode 100644 index 0000000..b765de5 --- /dev/null +++ b/frontend/src/components/SitePolicy.css @@ -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; } +} diff --git a/frontend/src/components/SitePolicy.jsx b/frontend/src/components/SitePolicy.jsx new file mode 100644 index 0000000..a890575 --- /dev/null +++ b/frontend/src/components/SitePolicy.jsx @@ -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
+ + const open = codes.filter(row => row.status === 'open') + + return ( +
+ {error &&

{error}

} + + + + + + {flags.invite_only && ( +
+

Invite codes {open.length} unused

+ +
+ setNote(e.target.value)} /> + +
+ + {codes.length === 0 ? ( +

No codes yet. Create one to let somebody in.

+ ) : ( +
    + {codes.map(row => ( +
  • + {row.code} + + {row.note || no note} + {row.status === 'used' && ( + Used by {row.used_by_name || 'someone'} on {when(row.used_at)} + )} + {row.status === 'revoked' && Withdrawn {when(row.revoked_at)}} + + + {row.status === 'open' && ( + <> + + + + )} + +
  • + ))} +
+ )} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 3aedd1e..63ac1fd 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -1148,7 +1148,7 @@ const timerStarted = timeLeft !== null } return ( -
+
{/* 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
)} - {quizNavigation('bottom')} - - {answeredCount > 0 && !isLast && ( -
- -
- )}
{/* Desktop rail — numbers with an excerpt, as in a Qbank session */} @@ -1654,6 +1645,21 @@ const timerStarted = timeLeft !== null
+ {/* 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. */} +
+ + {quizNavigation('bottom')} + {answeredCount > 0 && ( + + )} +
+ {/* AI tutor — only in study mode, lazy-loaded */} {isStudy && current && ( diff --git a/frontend/src/pages/QuizPage.test.jsx b/frontend/src/pages/QuizPage.test.jsx index f631515..5b9b2bb 100644 --- a/frontend/src/pages/QuizPage.test.jsx +++ b/frontend/src/pages/QuizPage.test.jsx @@ -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' }) diff --git a/frontend/src/pages/QuizPlayer.css b/frontend/src/pages/QuizPlayer.css index df1a363..24b196f 100644 --- a/frontend/src/pages/QuizPlayer.css +++ b/frontend/src/pages/QuizPlayer.css @@ -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%; } +} diff --git a/frontend/src/pages/RegisterInvite.test.jsx b/frontend/src/pages/RegisterInvite.test.jsx new file mode 100644 index 0000000..bb0614a --- /dev/null +++ b/frontend/src/pages/RegisterInvite.test.jsx @@ -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() +} + +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() + // 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() + }) +}) diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx index 8edc02f..6c2f837 100644 --- a/frontend/src/pages/RegisterPage.jsx +++ b/frontend/src/pages/RegisterPage.jsx @@ -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 &&
{error}
}
- - setName(e.target.value)} required /> + + setName(e.target.value)} required />
- - setEmail(e.target.value)} required /> + + setEmail(e.target.value)} required />
- - setPassword(e.target.value)} required minLength={8} /> + + setPassword(e.target.value)} required minLength={8} />
+ {inviteRequired && ( +
+ + {/* 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. */} + setInviteCode(e.target.value.toUpperCase())} /> + + This site is invite only. + +
+ )} - diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index c81e28d..a15f9f8 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -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 ( +
+ +
+ ) +} + export default function SettingsPage() { const { user } = useAuth() const isAdmin = user?.role === 'admin' @@ -392,6 +403,9 @@ export default function SettingsPage() { render: () => <> }, { key: 'admin', icon: '🛠️', label: 'Administration', render: () => }, ] : []), + ...(isAdmin ? [ + { key: 'policy', icon: '🔒', label: 'Site policy', render: () => }, + ] : []), ] const requested = params.get('s')