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:
parent
4f05b40a6a
commit
2267f53b55
19 changed files with 761 additions and 26 deletions
34
backend/alembic/versions/d4e5f6a7b8c9_invite_codes.py
Normal file
34
backend/alembic/versions/d4e5f6a7b8c9_invite_codes.py
Normal 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")
|
||||
|
|
@ -42,3 +42,4 @@ __all__ = [
|
|||
]
|
||||
|
||||
from app.models.feedback import QuestionFeedback # noqa: F401
|
||||
from app.models.invite import InviteCode # noqa: F401
|
||||
|
|
|
|||
28
backend/app/models/invite.py
Normal file
28
backend/app/models/invite.py
Normal 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)
|
||||
|
|
@ -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"])
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
67
backend/app/services/invites.py
Normal file
67
backend/app/services/invites.py
Normal 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",
|
||||
}
|
||||
50
backend/app/services/site_settings.py
Normal file
50
backend/app/services/site_settings.py
Normal 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}
|
||||
99
backend/tests/test_invites_and_flags.py
Normal file
99
backend/tests/test_invites_and_flags.py
Normal 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()
|
||||
16
docs/TODO.md
16
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
|
||||
|
|
|
|||
52
frontend/src/components/SitePolicy.css
Normal file
52
frontend/src/components/SitePolicy.css
Normal 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; }
|
||||
}
|
||||
144
frontend/src/components/SitePolicy.jsx
Normal file
144
frontend/src/components/SitePolicy.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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%; }
|
||||
}
|
||||
|
|
|
|||
65
frontend/src/pages/RegisterInvite.test.jsx
Normal file
65
frontend/src/pages/RegisterInvite.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in a new issue