feat: no invite codes and no email sign-in codes — that is the provider's job
Some checks failed
Tests / backend (push) Failing after 8s
Tests / frontend (push) Successful in 33s
Tests / e2e (push) Failing after 33s

Both were this app doing an identity provider's work. Sign-in and
sign-up happen at sso.pedshub.com now: it takes the address, sends the
code, checks it, and knows about second factors — none of which belongs
here, and two of which were never done here at all.

Gone: services/invites.py, services/login_codes.py,
routers/login_code.py, the two models, the three admin invite routes,
the invite_only flag and its switch, the invite field on both sign-up
forms, and the code half of the sign-in page — which was the primary way
in and is now a button that says "Sign in with PedsHub SSO". The
password form stays for a site with no provider configured.

Migration r7b8c9d0e1f2 drops invite_codes (three spent rows) and
login_codes (empty). The dump beside it has both.

585 tests, and the contract snapshot is 320 routes — five fewer, all
five named in the diff so the removal is reviewable rather than
discovered later by a client.

Also: "Make a deck" in the signed-in menu and on the landing page, going
to the scribe's My Resources at app.pedshub.com/#resources. Same
sign-in on both sides; the arrow says it leaves.

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-13 13:56:48 +02:00
parent b6cfcaa1e9
commit cf2f42975e
23 changed files with 107 additions and 1437 deletions

View file

@ -0,0 +1,31 @@
"""Sign-up codes and email sign-in codes, gone.
Both were the app doing an identity provider's job. Sign-in is through
sso.pedshub.com now: it takes the email, sends the code, checks it, and knows
about second factors none of which this app should be reimplementing, and
two of which it never did.
invite_codes held three rows, all of them spent or stale; login_codes held
none. The dump taken beside this change has both if anybody ever wants to know
who was invited.
Revision ID: r7b8c9d0e1f2
Revises: q6a7b8c9d0e1
"""
from alembic import op
revision = "r7b8c9d0e1f2"
down_revision = "q6a7b8c9d0e1"
branch_labels = None
depends_on = None
def upgrade():
op.execute("DROP TABLE IF EXISTS login_codes")
op.execute("DROP TABLE IF EXISTS invite_codes")
def downgrade():
# The tables can be recreated from the models in git history; their
# contents cannot, and inventing an invite is worse than not having one.
pass

View file

@ -17,7 +17,6 @@ from app.routers import access
from app.routers import feedback
from app.routers import folders
from app.routers import study_tools, uploads, articles, share, collections, study_plans, media, search, ai_mode, drafts, public
from app.routers import login_code
from app.utils.auth import get_password_hash
@ -610,7 +609,6 @@ app.add_middleware(VersionAlias)
app.include_router(uploads.router)
app.include_router(auth.router, prefix=f"{VERSIONED_ROOT}/auth", tags=["auth"])
app.include_router(login_code.router, prefix=f"{VERSIONED_ROOT}/auth", tags=["auth"])
# Counts the landing page states about itself. No auth: a stranger reads it.
app.include_router(public.router, prefix=f"{VERSIONED_ROOT}/public", tags=["public"])
app.include_router(articles.router, prefix=f"{VERSIONED_ROOT}/articles", tags=["articles"])

View file

@ -46,6 +46,4 @@ __all__ = [
from app.models.feedback import ArticleFeedback, QuestionFeedback # noqa: F401
from app.models.folder import QuestionFolder, QuestionFolderQuestion # noqa: F401
from app.models.user_note import ArticleSectionNote, QuestionNote # noqa: F401
from app.models.invite import InviteCode # noqa: F401
from app.models.draft_question import DraftBatch, DraftQuestion # noqa: F401
from app.models.login_code import LoginCode # noqa: F401

View file

@ -1,28 +0,0 @@
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,37 +0,0 @@
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from app.database import Base
class LoginCode(Base):
"""One issued sign-in code.
Only the fingerprint of the code is kept. A row here is a session waiting
to happen anybody who could read the table could sign in as its owner
so it holds something to compare against rather than something to type in.
The failed guesses are counted in this row and not in Redis. The rate
limiter fails open when the cache is down, which is the right call for
anything it normally protects; it is the wrong call for the only thing
standing between a patient stranger and six characters.
"""
__tablename__ = "login_codes"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
#: SHA-256 of the normalised code, hex. Not bcrypt: the guess budget is
#: what makes a short code safe, not the cost of testing one, and this is
#: compared on a path a person is waiting on. Not indexed and not unique
#: either — a code is only ever looked up against the one account it was
#: issued for, and six characters do collide.
code_hash = Column(String(64), nullable=False)
attempts = Column(Integer, nullable=False, default=0)
expires_at = Column(DateTime, nullable=False)
#: Set when the code is spent, when its guesses run out, and when a later
#: request retires it. All three mean the same thing to every reader here:
#: no longer usable.
consumed_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)

View file

@ -13,8 +13,7 @@ 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 ai_service, invites, site_settings, tts_voices
from app.services import ai_service, site_settings, tts_voices
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
@ -560,50 +559,6 @@ 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 an unused code, or clear away a spent one.
An unused code is withdrawn it stays listed, so it is clear that it was
issued and then stopped. A spent or already-withdrawn code has nothing left
to stop, and a list that only grows is a list nobody reads; removing it
loses who it let in, but that person has an account, which is the record
that matters.
"""
row = db.get(InviteCode, invite_id)
if not row:
raise HTTPException(404, "Invite not found")
if row.used_by is not None or row.revoked_at is not None:
db.delete(row)
db.commit()
return
row.revoked_at = datetime.utcnow()
db.commit()
# --- System Settings ---
@router.get("/settings")

View file

@ -5,7 +5,7 @@ from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request
from sqlalchemy.orm import Session
from app.services import captcha, invites, refresh_tokens, site_settings
from app.services import captcha, refresh_tokens, site_settings
from app.database import get_db
from app.models.user import User
from app.models.email_verification import EmailVerification
@ -116,19 +116,18 @@ def signup_policy(db: Session = Depends(get_db)):
# 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, "registration_open": True}
return {"first_user": True, "registration_open": True}
# Whether anyone may join at all, so the sign-in page can stop offering a
# door that is locked. It was offering one: registration can be turned off
# site-wide, and the only way to find out was to fill the form in and be
# refused.
# Single sign-on closes the password door, and this is the one question
# the sign-up form already asks. Saying so here means the form can decline
# to draw itself rather than collect a name, an email, a password twice and
# an invite code, and then be refused by the POST.
# to draw itself rather than collect a name, an email and a password
# twice, and then be refused by the POST.
from app.config import settings as cfg
sso_only = _get_sso_settings()["sso_only"]
return {"invite_required": site_settings.get_flag("invite_only"),
"first_user": False,
return {"first_user": False,
"sso_only": sso_only,
# So the page can name the provider rather than say "single
# sign-on" at somebody who only knows it as PedsHub SSO.
@ -161,14 +160,6 @@ 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")
@ -195,8 +186,6 @@ 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

@ -1,168 +0,0 @@
"""Ask for a sign-in code, and type one in.
Kept out of the auth router because these two routes answer to a rule the rest
of that file does not: whatever the state of the address given, the reply is
the same reply, and takes the same time to arrive. That is easy to break by
adding an early return next to routes that quite reasonably have several.
"""
import asyncio
import logging
import time
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.email_verification import EmailVerification
from app.models.user import User
from app.schemas.auth import LoginCodeRequest, LoginCodeVerify, Token
from app.services import email_service, login_codes, site_settings
from app.utils.auth import check_rate_limit, create_access_token
logger = logging.getLogger(__name__)
router = APIRouter()
#: The only thing the request endpoint ever says. Not "we sent you a code" and
#: not "no such account": either would turn the form into a way to ask the site
#: whether a given person is a member of it.
SENT = "If that address has an account, a sign-in code is on its way."
#: And the only thing the verify endpoint says when it will not let somebody
#: in. Wrong code, expired code, code out of guesses, address with no account —
#: one answer, because the alternative is a way to sort guesses into warm and
#: cold.
REFUSED = "That code is not right, or it has expired. Request a new one."
#: Wording and status are identical whichever way it went — but the work is
#: not, and a stopwatch reads the difference. Every reply from both routes is
#: held to this floor, comfortably above what either costs. Sending the mail is
#: a background task and runs after the response, so it never shows up in the
#: timing at all.
MIN_SECONDS = 0.4
#: Three per quarter hour, per address. This is the limit that matters when
#: issuing: the address is who the mail lands on, and the one thing an attacker
#: cannot vary while still reaching the person they mean to reach.
PER_ADDRESS_CALLS, PER_ADDRESS_WINDOW = 3, 15 * 60
#: Twenty per hour, per source address. Deliberately the looser of the two: a
#: hospital or university puts a whole department behind one address, so a
#: tight limit here locks out real people and barely inconveniences anyone
#: renting a hundred addresses. It protects the site's sending reputation from
#: one machine working through a word list; the per-address limit is what
#: protects any individual mailbox.
PER_IP_CALLS, PER_IP_WINDOW = 20, 60 * 60
#: Ten guesses a quarter hour against one address, on top of the five any one
#: code will answer. The code's own budget is the harder bound and survives the
#: cache being down; this one bounds what somebody gets by burning through
#: codes — three issued in a window, five guesses each, would otherwise be
#: fifteen. It is keyed by address rather than by network because a code only
#: means anything against the address it was issued for: limiting the network
#: would lock out a shared one while an attacker simply moved to the next
#: address.
PER_ADDRESS_TRIES, PER_ADDRESS_TRIES_WINDOW = 10, 15 * 60
def _sso_only() -> bool:
return site_settings.get_flag("sso_only")
async def _hold(started: float):
elapsed = time.perf_counter() - started
if elapsed < MIN_SECONDS:
await asyncio.sleep(MIN_SECONDS - elapsed)
@router.post("/login-code")
async def request_login_code(
data: LoginCodeRequest,
background_tasks: BackgroundTasks,
request: Request = None,
db: Session = Depends(get_db),
):
"""Send a sign-in code to an address, if that address has an account."""
started = time.perf_counter()
if _sso_only():
raise HTTPException(status_code=403, detail="Email sign-in is disabled. Please use SSO.")
address = data.email.lower().strip()
# Both limits are checked before the account is looked up, so that being
# throttled cannot itself become the tell about whether an account exists.
# The address is keyed by its fingerprint: Redis is a cache with a rather
# more relaxed life than the database, and it has no business holding a
# readable list of who has an account here.
check_rate_limit(
key=f"login_code:addr:{login_codes.fingerprint(address)}",
max_calls=PER_ADDRESS_CALLS,
window_seconds=PER_ADDRESS_WINDOW,
detail="Too many sign-in codes requested for that address. Please wait a few minutes.",
)
client_ip = (request.client.host if request and request.client else "unknown")
check_rate_limit(
key=f"login_code:ip:{client_ip}",
max_calls=PER_IP_CALLS,
window_seconds=PER_IP_WINDOW,
detail="Too many sign-in codes requested from this network. Please wait a while.",
)
user = db.query(User).filter(User.email == address).first()
if user is not None:
code = login_codes.issue(db, user)
background_tasks.add_task(email_service.send_login_code_email,
user.email, user.name, login_codes.for_display(code))
await _hold(started)
return {"message": SENT}
@router.post("/login-code/verify", response_model=Token)
async def verify_login_code(data: LoginCodeVerify, db: Session = Depends(get_db)):
"""Spend a code and hand back a session.
The code is typed into the page that asked for it, so nothing has to travel
between devices, no credential ends up in a URL, and there is no history
entry to clear afterwards.
"""
started = time.perf_counter()
if _sso_only():
raise HTTPException(status_code=403, detail="Email sign-in is disabled. Please use SSO.")
address = data.email.lower().strip()
# Before the lookup, and counted for an address with no account exactly as
# for one with — otherwise the 429 says which is which.
check_rate_limit(
key=f"login_code:try:{login_codes.fingerprint(address)}",
max_calls=PER_ADDRESS_TRIES,
window_seconds=PER_ADDRESS_TRIES_WINDOW,
detail="Too many attempts for that address. Please wait a few minutes and request a new code.",
)
user = login_codes.verify(db, address, data.code)
if user is None:
await _hold(started)
raise HTTPException(status_code=400, detail=REFUSED)
# Checked after the code is spent, and deliberately: a refusal must not
# leave a usable credential behind.
#
# Reading a code out of the mailbox proves control of it, which is exactly
# what verification checks — and it is still not allowed to stand in for
# it. Somebody can register an address that is not theirs, choose a
# password and never verify; if the real owner's sign-in code marked that
# account verified, they would be signing in to an account the squatter
# already has the password to. The verification mail is in the same inbox.
verification = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first()
if verification and verification.verified_at is None:
await _hold(started)
raise HTTPException(
status_code=403,
detail="Email not verified. Please check your inbox and verify your email before signing in.",
)
logger.info("Sign-in code spent by user %s", user.id)
await _hold(started)
return Token(access_token=create_access_token(data={"sub": user.email}))

View file

@ -9,8 +9,6 @@ class UserCreate(BaseModel):
name: str
#: Solved hCaptcha challenge. Absent when the site has no secret configured.
captcha_token: str | None = None
#: Required only while the site is invite-only.
invite_code: str | None = None
class UserResponse(BaseModel):
@ -101,12 +99,3 @@ class SsoExchangeRequest(BaseModel):
code: str = Field(min_length=8, max_length=200)
class LoginCodeRequest(BaseModel):
email: EmailStr
class LoginCodeVerify(BaseModel):
email: EmailStr
#: Typed by a person, so it arrives in whatever shape they typed it and is
#: normalised before it is compared.
code: str

View file

@ -1,67 +0,0 @@
"""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

@ -1,159 +0,0 @@
"""Signing in with a code sent to your own mailbox.
Six characters a person reads off an email and types into the page they asked
from. Everything here follows from the code being short: it can be guessed, so
what makes it safe is not its length on its own but the arithmetic between its
length, how many guesses it will ever answer, and how long it lives.
"""
import hashlib
import hmac
import re
import secrets
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from app.models.login_code import LoginCode
from app.models.user import User
#: The house alphabet, and the reason it is the house alphabet: no O/0 and no
#: I/1, because these are read off a screen and typed back. Borrowed rather
#: than copied — there should be one answer to "which characters may a person
#: be asked to retype", not one per feature.
from app.services.invites import ALPHABET
#: Six characters of a 32-letter alphabet: 2^30 codes, where the six digits
#: everyone is used to would be 2^20. The length is only half the argument
#: though, and the smaller half. A code answers at most MAX_ATTEMPTS guesses
#: before it is retired, only one is ever live per person, and the address may
#: only be tried so often — so an attacker gets tens of guesses an hour against
#: a billion, while sending the victim a mail for every code they burn through.
#: Eight characters would buy a thousandfold against an attack the guess budget
#: has already ended, and cost every user two more characters to type.
LENGTH = 6
#: Five. Enough that a mistyped character, a stale code from a first attempt
#: and one more fumble do not lock somebody out of their own account; few
#: enough that the whole budget is nothing against 2^30. Counted in the row
#: rather than the cache — see the model.
MAX_ATTEMPTS = 5
#: Fifteen minutes, unchanged from when this was a link, and for a reason that
#: survived the change: a first mail between a sender and a recipient who have
#: never corresponded is routinely greylisted five to ten minutes, and a code
#: that has expired before it arrives is not a sign-in method. Shortening it
#: would not slow an attacker down — only one code is ever live per person and
#: it answers five guesses however long it sits there — so the clock is a
#: deliverability question, and the one thing it does buy is a shorter window
#: for a mailbox somebody else is reading. Fifteen is where those meet.
CODE_TTL = timedelta(minutes=15)
#: Rows are dead weight once well past expiry and nothing reads them. Kept a
#: day rather than deleted on the spot, so "it did not work this morning" can
#: still be answered from the table.
KEEP_SPENT_FOR = timedelta(days=1)
def normalise(code: str) -> str:
"""What the person meant, from what they typed.
Case, spaces and dashes are not part of the code the mail shows it in two
groups of three, and somebody will type the space. Anything that is not a
letter or a digit goes; the rest is upper-cased. There is no look-alike
folding to do, which is the point of that alphabet: O and 0 are not two
characters one might mistake for each other here, they are both simply not
characters a code can contain.
"""
return re.sub(r"[^A-Z0-9]", "", (code or "").upper())
def fingerprint(value: str) -> str:
return hashlib.sha256(value.strip().encode("utf-8")).hexdigest()
def generate_code() -> str:
return "".join(secrets.choice(ALPHABET) for _ in range(LENGTH))
def for_display(code: str) -> str:
"""Grouped for reading aloud in one's head. Typed back in any shape."""
return f"{code[:3]} {code[3:]}" if len(code) == 6 else code
def issue(db: Session, user: User) -> str:
"""Retire this user's outstanding codes, mint a new one, return it.
Superseding rather than accumulating, and it matters more here than it did
for a link: with several codes live at once, a guess is tested against all
of them, and a six-character code gets easier to hit with every one left
lying around. Exactly one is live per person at any moment.
"""
now = datetime.utcnow()
db.query(LoginCode).filter(
LoginCode.user_id == user.id,
LoginCode.consumed_at.is_(None),
).update({"consumed_at": now}, synchronize_session=False)
db.query(LoginCode).filter(
LoginCode.user_id == user.id,
LoginCode.expires_at < now - KEEP_SPENT_FOR,
).delete(synchronize_session=False)
code = generate_code()
db.add(LoginCode(user_id=user.id, code_hash=fingerprint(code), expires_at=now + CODE_TTL))
db.commit()
return code
def verify(db: Session, email: str, code: str) -> User | None:
"""Spend a code and return whose it is, or None if it is no good.
Scoped to the address, and that is not merely convenient. A code looked up
on its own would be tested against every code live on the site at once, so
each guess would be as many guesses as there are people signing in the
short code's one real weakness, closed by knowing whose code it is meant to
be before comparing.
One answer for every failure: wrong code, expired code, code whose guesses
have run out, address with no account. Telling them apart would say which
of those was true, and none of them leaves the person anything to do but
ask for a new code.
"""
user = db.query(User).filter(User.email == email.lower().strip()).first()
if user is None:
return None
row = db.query(LoginCode).filter(
LoginCode.user_id == user.id,
LoginCode.consumed_at.is_(None),
).order_by(LoginCode.id.desc()).first()
if row is None or row.expires_at <= datetime.utcnow():
return None
if not hmac.compare_digest(row.code_hash, fingerprint(normalise(code))):
# A wrong guess costs one of five, and the fifth ends the code rather
# than leaving it to be guessed at a slower rate: whoever is typing has
# either lost the mail or is not its owner, and both are one click from
# a new code. The count is a SQL expression so that two guesses racing
# each other cost two.
exhausted = row.attempts + 1 >= MAX_ATTEMPTS
spend = {"attempts": LoginCode.attempts + 1}
if exhausted:
spend["consumed_at"] = datetime.utcnow()
db.query(LoginCode).filter(
LoginCode.id == row.id,
LoginCode.consumed_at.is_(None),
).update(spend, synchronize_session=False)
db.commit()
return None
# The claim is the UPDATE, not the read above: two requests carrying the
# same code both pass the comparison, and only the one whose UPDATE matched
# a row still unspent gets to sign in.
claimed = db.query(LoginCode).filter(
LoginCode.id == row.id,
LoginCode.consumed_at.is_(None),
).update({"consumed_at": datetime.utcnow()}, synchronize_session=False)
db.commit()
if not claimed:
return None
return user

View file

@ -19,8 +19,6 @@ FLAGS: dict[str, bool] = {
"registration_enabled": True,
#: 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,
#: Whether the only way in is the identity provider. Read here as well as
#: in the SSO routes because it governs every email-based way of signing
#: in, link included, and each of those had been reading the key itself.

View file

@ -12,16 +12,6 @@
"422"
]
},
"DELETE /api/v1/admin/invites/{invite_id}": {
"body": false,
"params": [
"path:invite_id"
],
"responses": [
"204",
"422"
]
},
"DELETE /api/v1/admin/models/{model_id}": {
"body": false,
"params": [
@ -550,13 +540,6 @@
"200"
]
},
"GET /api/v1/admin/invites": {
"body": false,
"params": [],
"responses": [
"200"
]
},
"GET /api/v1/admin/models": {
"body": false,
"params": [],
@ -2003,14 +1986,6 @@
"200"
]
},
"POST /api/v1/admin/invites": {
"body": true,
"params": [],
"responses": [
"201",
"422"
]
},
"POST /api/v1/admin/litellm/models": {
"body": true,
"params": [],
@ -2228,22 +2203,6 @@
"422"
]
},
"POST /api/v1/auth/login-code": {
"body": true,
"params": [],
"responses": [
"200",
"422"
]
},
"POST /api/v1/auth/login-code/verify": {
"body": true,
"params": [],
"responses": [
"200",
"422"
]
},
"POST /api/v1/auth/logout": {
"body": true,
"params": [],

View file

@ -1,125 +0,0 @@
"""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()
class TutorFlagTests(unittest.TestCase):
"""Whether the tutor may be opened during a session.
The rule that is not a setting: the tutor is handed the correct answer and
told it may explain it, so an exam-mode tutor is an answer key. That is
refused whatever the flag says. The flag only decides study mode.
"""
def test_the_tutor_is_allowed_by_default(self):
with patch.dict(sys.modules, {"redis": fake_redis({})}):
self.assertTrue(site_settings.get_flag("tutor_in_quiz"))
def test_turning_it_off_reads_back(self):
store = {}
with patch.dict(sys.modules, {"redis": fake_redis(store)}):
site_settings.set_flag("tutor_in_quiz", False)
self.assertFalse(site_settings.get_flag("tutor_in_quiz"))
def test_redis_being_down_leaves_the_tutor_on(self):
# The safe direction here is the permissive one: losing Redis should
# not silently remove a study aid. Nothing is revealed that study mode
# does not already show.
with patch.dict(sys.modules, {"redis": fake_redis({}, broken=True)}):
self.assertTrue(site_settings.get_flag("tutor_in_quiz"))

View file

@ -1,290 +0,0 @@
"""Signing in with a code sent by email.
The properties worth pinning are the ones that are invisible when they break: a
stranger's address gets the same answer as a member's, in the same time; a code
works once and not after that; a wrong guess costs one of five and the fifth
ends the code; and the limiter counts requests for an address that has no
account exactly as it counts the others, or the 429 becomes the answer the
identical message was there to withhold.
Disposable SQLite, a Mock for Redis, and nothing leaves the process.
"""
import hashlib
import sys
import time
import unittest
from datetime import datetime, timedelta
from types import ModuleType
from unittest.mock import AsyncMock, patch
import test_quiz_builder as fixtures
from app.models.email_verification import EmailVerification
from app.models.login_code import LoginCode
from app.routers import login_code
from app.services import email_service, invites, login_codes
class MemoryRedis:
def __init__(self): self.values = {}
def get(self, key): return self.values.get(key)
def set(self, key, value): self.values[key] = value
def incr(self, key):
self.values[key] = int(self.values.get(key, 0)) + 1
return self.values[key]
def expire(self, *args): return True
class LoginCodeTests(unittest.TestCase):
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.bank.owner.email = "owner@example.com"
# The peer too: the address goes through EmailStr on the way in, which
# refuses the reserved .test domain the fixtures otherwise use.
self.bank.peer.email = "peer@example.com"
self.bank.db.commit()
self.client = self.bank.client
self.client.app.include_router(login_code.router, prefix="/auth")
self.redis = MemoryRedis()
module = ModuleType("redis")
module.from_url = lambda *args, **kwargs: self.redis
modules = patch.dict(sys.modules, {"redis": module})
modules.start()
self.addCleanup(modules.stop)
# Every reply is paced to a floor; the pacing itself has its own test,
# and the rest of the suite has no reason to wait for it.
floor = patch.object(login_code, "MIN_SECONDS", 0.01)
floor.start()
self.addCleanup(floor.stop)
sent = patch.object(email_service, "send_login_code_email", new_callable=AsyncMock)
self.sent = sent.start()
self.addCleanup(sent.stop)
def tearDown(self):
self.bank.tearDown()
def ask(self, email="owner@example.com"):
return self.client.post("/auth/login-code", json={"email": email})
def code_for(self, email="owner@example.com"):
"""The code as the mail shows it — spaces and all."""
self.sent.reset_mock()
self.assertEqual(self.ask(email).status_code, 200)
self.sent.assert_awaited_once()
return self.sent.await_args.args[2]
def enter(self, code, email="owner@example.com"):
return self.client.post("/auth/login-code/verify", json={"email": email, "code": code})
def wrong(self, email="owner@example.com"):
return self.enter("ZZZZZZ", email)
# ── the answer gives nothing away ──────────────────────────────────
def test_a_stranger_gets_the_member_s_answer_word_for_word(self):
known = self.ask()
unknown = self.ask("nobody@example.com")
self.assertEqual(known.status_code, unknown.status_code)
self.assertEqual(known.json(), unknown.json())
self.assertEqual(known.json(), {"message": login_code.SENT})
# And the only difference behind it is one that never reaches the caller.
self.assertEqual(self.bank.db.query(LoginCode).count(), 1)
def test_both_answers_are_held_to_the_same_floor(self):
# The real constant, because the point of the test is the value of it.
with patch.object(login_code, "MIN_SECONDS", 0.3):
times = []
for address in ("owner@example.com", "nobody@example.com"):
started = time.perf_counter()
self.ask(address)
times.append(time.perf_counter() - started)
for elapsed in times:
self.assertGreaterEqual(elapsed, 0.3)
self.assertLess(abs(times[0] - times[1]), 0.15)
def test_a_wrong_code_reads_the_same_as_an_address_with_no_account(self):
self.code_for()
mistyped = self.wrong()
stranger = self.enter("ZZZZZZ", "nobody@example.com")
self.assertEqual(mistyped.status_code, stranger.status_code)
self.assertEqual(mistyped.json(), stranger.json())
self.assertEqual(mistyped.json()["detail"], login_code.REFUSED)
def test_the_limiter_counts_a_stranger_s_address_too(self):
# Otherwise the 429 arrives only for addresses that exist, and the
# identical message above has been undone by the thing protecting it.
for _ in range(login_code.PER_ADDRESS_CALLS):
self.assertEqual(self.ask("nobody@example.com").status_code, 200)
self.assertEqual(self.ask("nobody@example.com").status_code, 429)
self.assertEqual(self.ask().status_code, 200)
def test_the_address_limit_bites_on_the_fourth_request(self):
for _ in range(login_code.PER_ADDRESS_CALLS):
self.assertEqual(self.ask().status_code, 200)
refused = self.ask()
self.assertEqual(refused.status_code, 429)
self.assertIn("Too many sign-in codes", refused.json()["detail"])
# Nothing was issued on the refused attempt.
self.assertEqual(self.bank.db.query(LoginCode).count(), login_code.PER_ADDRESS_CALLS)
def test_the_network_limit_is_the_looser_of_the_two(self):
self.assertGreater(login_code.PER_IP_CALLS, login_code.PER_ADDRESS_CALLS)
# A different address each time, so the tighter limit cannot be what
# stops it.
for n in range(login_code.PER_IP_CALLS):
self.assertEqual(self.ask(f"person{n}@example.com").status_code, 200)
refused = self.ask(f"person{login_code.PER_IP_CALLS}@example.com")
self.assertEqual(refused.status_code, 429)
self.assertIn("from this network", refused.json()["detail"])
# ── the code itself ────────────────────────────────────────────────
def test_a_code_signs_you_in_once_and_never_again(self):
code = self.code_for()
first = self.enter(code)
self.assertEqual(first.status_code, 200, first.text)
self.assertTrue(first.json()["access_token"])
second = self.enter(code)
self.assertEqual(second.status_code, 400)
self.assertEqual(second.json()["detail"], login_code.REFUSED)
def test_six_characters_of_the_house_alphabet(self):
code = login_codes.normalise(self.code_for())
self.assertEqual(len(code), login_codes.LENGTH)
self.assertEqual(login_codes.LENGTH, 6)
self.assertTrue(set(code) <= set(invites.ALPHABET))
# The characters that get misread are not in it to be misread.
self.assertFalse(set("O0I1") & set(invites.ALPHABET))
def test_case_spaces_and_dashes_do_not_decide_who_gets_in(self):
code = login_codes.normalise(self.code_for())
typed = f" {code[:3].lower()}-{code[3:]} "
self.assertEqual(self.enter(typed).status_code, 200)
def test_the_code_as_the_mail_shows_it_types_straight_back(self):
shown = self.code_for()
self.assertIn(" ", shown)
self.assertEqual(self.enter(shown).status_code, 200)
def test_an_expired_code_is_refused(self):
code = self.code_for()
row = self.bank.db.query(LoginCode).one()
row.expires_at = datetime.utcnow() - timedelta(seconds=1)
self.bank.db.commit()
self.assertEqual(self.enter(code).status_code, 400)
# Refused without being marked spent: it was never used, and the row
# says so.
self.bank.db.refresh(row)
self.assertIsNone(row.consumed_at)
def test_fifteen_minutes_and_not_an_hour(self):
self.code_for()
row = self.bank.db.query(LoginCode).one()
self.assertAlmostEqual((row.expires_at - row.created_at).total_seconds(), 15 * 60, delta=5)
def test_a_second_request_retires_the_first_code(self):
stale = self.code_for()
fresh = self.code_for()
self.assertEqual(self.enter(stale).status_code, 400)
self.assertEqual(self.enter(fresh).status_code, 200)
def test_the_table_holds_a_fingerprint_rather_than_the_code(self):
code = login_codes.normalise(self.code_for())
row = self.bank.db.query(LoginCode).one()
self.assertNotIn(code, str(row.code_hash))
self.assertEqual(row.code_hash, hashlib.sha256(code.encode()).hexdigest())
def test_a_code_is_no_good_against_somebody_else_s_address(self):
# A short code looked up on its own would be tried against every code
# live on the site at once. It is only ever tried against one account.
code = self.code_for()
self.assertEqual(self.enter(code, "peer@example.com").status_code, 400)
self.assertEqual(self.enter(code).status_code, 200)
# ── guessing costs something ───────────────────────────────────────
def test_a_wrong_code_counts_against_the_limit(self):
self.code_for()
for expected in range(1, 3):
self.assertEqual(self.wrong().status_code, 400)
self.assertEqual(self.bank.db.query(LoginCode).one().attempts, expected)
def test_a_code_out_of_guesses_is_retired_rather_than_left_guessable(self):
code = self.code_for()
for _ in range(login_codes.MAX_ATTEMPTS):
self.assertEqual(self.wrong().status_code, 400)
row = self.bank.db.query(LoginCode).one()
self.assertIsNotNone(row.consumed_at)
# And now not even the right code opens it — the way back is a new one.
self.assertEqual(self.enter(code).status_code, 400)
self.assertEqual(self.enter(self.code_for()).status_code, 200)
def test_the_attempts_limit_keeps_counting_after_the_code_is_dead(self):
# The code's own five run out first. The address limit is what stops
# somebody working through codes to buy five more guesses at a time.
self.code_for()
for _ in range(login_code.PER_ADDRESS_TRIES):
self.assertEqual(self.wrong().status_code, 400)
refused = self.wrong()
self.assertEqual(refused.status_code, 429)
self.assertIn("Too many attempts", refused.json()["detail"])
def test_guessing_a_stranger_s_address_is_counted_the_same_way(self):
for _ in range(login_code.PER_ADDRESS_TRIES):
self.assertEqual(self.wrong("nobody@example.com").status_code, 400)
self.assertEqual(self.wrong("nobody@example.com").status_code, 429)
# ── the rules the password form already keeps ──────────────────────
def test_an_unverified_account_is_turned_away_as_it_is_at_the_password_form(self):
self.bank.db.add(EmailVerification(user_id=self.bank.owner.id, token="verification-token",
expires_at=datetime.utcnow() + timedelta(hours=1), verified_at=None))
self.bank.db.commit()
code = self.code_for()
refused = self.enter(code)
self.assertEqual(refused.status_code, 403)
self.assertIn("Email not verified", refused.json()["detail"])
# Refused, and the code spent all the same: turning somebody away must
# not leave a working credential behind them.
self.assertIsNotNone(self.bank.db.query(LoginCode).one().consumed_at)
def test_sso_only_closes_this_door_as_well_as_the_password_one(self):
code = self.code_for()
self.redis.values["settings:sso_only"] = "true"
refused = self.ask()
self.assertEqual(refused.status_code, 403)
self.assertIn("Please use SSO", refused.json()["detail"])
self.assertEqual(self.enter(code).status_code, 403)
self.assertEqual(self.bank.db.query(LoginCode).count(), 1)
class ServiceTests(unittest.TestCase):
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.addCleanup(self.bank.tearDown)
def test_only_one_of_two_claims_on_one_code_succeeds(self):
code = login_codes.issue(self.bank.db, self.bank.owner)
self.assertIsNotNone(login_codes.verify(self.bank.db, self.bank.owner.email, code))
self.assertIsNone(login_codes.verify(self.bank.db, self.bank.owner.email, code))
def test_rows_long_past_their_expiry_are_swept_on_the_next_request(self):
login_codes.issue(self.bank.db, self.bank.owner)
stale = self.bank.db.query(LoginCode).one()
stale.expires_at = datetime.utcnow() - timedelta(days=3)
self.bank.db.commit()
login_codes.issue(self.bank.db, self.bank.owner)
self.assertEqual(self.bank.db.query(LoginCode).count(), 1)
def test_normalising_keeps_only_what_the_code_is_made_of(self):
self.assertEqual(login_codes.normalise(" ab3-c d9 "), "AB3CD9")
self.assertEqual(login_codes.normalise("abc\u2013def"), "ABCDEF")
self.assertEqual(login_codes.normalise(""), "")
if __name__ == "__main__":
unittest.main()

View file

@ -223,6 +223,10 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
{ to: '/study-plans', label: 'Study plans' },
{ to: '/articles', label: 'Reading' },
{ to: '/flashcards', label: 'Cards' },
// The scribe, where a deck is written from your own material. A different
// application behind the same sign-in, so it is a real link out rather
// than a route and it says so with the arrow.
{ to: 'https://app.pedshub.com/#resources', label: 'Make a deck', external: true },
] : []
return (
@ -310,10 +314,16 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
a mouse the last links were unreachable. */}
<ScrollStrip as="nav" className="nav-sections" label="Sections">
{navLinks.map(l => (
l.external ? (
<a key={l.to} href={l.to} target="_blank" rel="noopener noreferrer">
{l.label}
</a>
) : (
<Link key={l.to} to={l.to} className={location.pathname === l.to ? 'is-current' : undefined}
aria-current={location.pathname === l.to ? 'page' : undefined}>
{l.label}
</Link>
)
))}
</ScrollStrip>
</div>
@ -327,17 +337,24 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
padding: '8px 0 12px',
}}>
<div className="container" style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{navLinks.map(l => (
<Link key={l.to} to={l.to} style={{
{navLinks.map(l => {
const style = {
color: 'var(--navbar-fg)', textDecoration: 'none',
padding: '10px 4px', fontSize: '0.95rem',
fontWeight: location.pathname === l.to ? 600 : 400,
opacity: location.pathname === l.to ? 1 : 0.75,
borderBottom: '1px solid rgba(255,255,255,0.05)',
}}>
}
return l.external ? (
<a key={l.to} href={l.to} target="_blank" rel="noopener noreferrer" style={style}>
{l.label}
</a>
) : (
<Link key={l.to} to={l.to} style={style}>
{l.label}
</Link>
))}
)
})}
<button onClick={() => { setMenuOpen(false); logout() }} style={{
background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.15)',
color: 'var(--navbar-fg)', padding: '10px', borderRadius: 8,

View file

@ -16,7 +16,7 @@ const when = (value) => (value ? new Date(value).toLocaleDateString(undefined,
*/
export default function SitePolicy() {
const [flags, setFlags] = useState({
registration_enabled: true, sharing_enabled: true, invite_only: false,
registration_enabled: true, sharing_enabled: true,
tutor_in_quiz: true, sso_only: false,
clinical_library_enabled: false, pubmed_enabled: false,
})
@ -27,20 +27,16 @@ export default function SitePolicy() {
clinical_mcp_url: '', pubmed_api_key: '', pubmed_contact_email: '',
})
const [sso, setSso] = useState({ configured: false, name: '' })
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]) => {
Promise.all([api.get('/admin/settings')])
.then(([settings]) => {
setFlags({
registration_enabled: settings.data.registration_enabled !== false,
sharing_enabled: settings.data.sharing_enabled !== false,
invite_only: settings.data.invite_only === true,
tutor_in_quiz: settings.data.tutor_in_quiz !== false,
sso_only: settings.data.sso_only === true,
clinical_library_enabled: settings.data.clinical_library_enabled === true,
@ -55,7 +51,6 @@ export default function SitePolicy() {
configured: !!settings.data.sso_configured,
name: settings.data.sso_provider_name || 'your identity provider',
})
setCodes(invites.data || [])
})
.catch(() => setError('Could not load the site policy'))
.finally(() => setLoading(false))
@ -82,23 +77,8 @@ export default function SitePolicy() {
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}`),
row.status === 'open' ? 'Could not withdraw that code' : 'Could not remove 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>}
@ -113,7 +93,7 @@ export default function SitePolicy() {
<span>
<strong>Anyone may register</strong>
<small>
Turn this off and nobody new can join at all, invite code or not.
Turn this off and nobody new can join at all.
People who already have an account keep it.
</small>
</span>
@ -126,17 +106,6 @@ export default function SitePolicy() {
</p>
)}
<label className="sp-switch">
<input type="checkbox" checked={flags.invite_only} disabled={busy || !flags.registration_enabled}
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}
@ -234,55 +203,6 @@ export default function SitePolicy() {
</label>
)}
{flags.invite_only && flags.registration_enabled && (
<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>
</>
) : (
// A spent code has nothing left to stop, and a list that
// only grows is a list nobody reads.
<button type="button" className="btn btn-secondary btn-sm sp-revoke"
disabled={busy} aria-label={`Remove ${row.code}`}
onClick={() => revoke(row)}>Remove</button>
)}
</span>
</li>
))}
</ul>
)}
</section>
)}
</div>
)
}

View file

@ -247,12 +247,6 @@ function AuthModal({ mode, onClose, onSwitch }) {
const [registered, setRegistered] = useState(false)
// Typed twice. The same field exists on /register; this is the other door.
const [confirm, setConfirm] = useState('')
// Whether this site is invite only. The register page asks the same
// question; this form is the other door and used not to ask at all, so
// turning the gate on left everyone here failing with "an invite code is
// required" and nowhere to put one.
const [inviteRequired, setInviteRequired] = useState(false)
const [inviteCode, setInviteCode] = useState('')
const [signupOpen, setSignupOpen] = useState(true)
//: The public page's own sign-in box. It is a different component from
//: /login and knew nothing about the provider, so on a site where the only
@ -267,7 +261,6 @@ function AuthModal({ mode, onClose, onSwitch }) {
api.get('/auth/signup-policy')
.then(res => {
if (!live) return
setInviteRequired(!!res.data?.invite_required)
// Registration closed by the switch, or because the site signs in
// through a provider. Either way there is no password sign-up to
// offer, and a Register tab that leads to a refusal is worse than no
@ -315,7 +308,6 @@ function AuthModal({ mode, onClose, onSwitch }) {
const res = await api.post('/auth/register', {
email, password, name,
captcha_token: captchaToken || null,
invite_code: inviteCode.trim() || null,
})
if (res.data.requires_verification) {
setRegistered(true)
@ -447,23 +439,10 @@ function AuthModal({ mode, onClose, onSwitch }) {
<small className="lp-mismatch">These do not match yet.</small>
)}
</div>
{inviteRequired && (
<div className="form-group">
<label htmlFor="modal-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="modal-invite-code" value={inviteCode} required autoComplete="off"
placeholder="From whoever invited you" className="lp-invite"
onChange={e => setInviteCode(e.target.value.toUpperCase())} />
<small className="lp-hint">This site is invite only.</small>
</div>
)}
<Captcha onVerify={setCaptchaToken} />
<button className="btn btn-primary btn-block"
disabled={loading || !password || password !== confirm
|| (captchaSiteKey() && !captchaToken)
|| (inviteRequired && !inviteCode.trim())}>
|| (captchaSiteKey() && !captchaToken)}>
{loading ? 'Creating account…' : 'Sign Up'}
</button>
</form>
@ -778,7 +757,9 @@ export default function LandingPage() {
<div className="lp-inner">
<SlideStudio calm={calm} />
<div className="lp-studio-cta">
<a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer"
{/* Straight to My Resources, which is where a deck is made the
scribe reads the hash and opens there after sign-in. */}
<a href="https://app.pedshub.com/#resources" target="_blank" rel="noopener noreferrer"
className="btn lp-cta-ghost">Make a deck </a>
</div>
</div>

View file

@ -23,10 +23,9 @@ afterEach(() => { document.getElementById('cap-widget-script')?.remove() })
function mount(Component) {
render(<MemoryRouter initialEntries={['/entry']}><AuthProvider><Routes><Route path="/entry" element={<Component />} /><Route path="/" element={<div>Signed in successfully</div>} /></Routes></AuthProvider></MemoryRouter>)
}
async function submitLogin({ revealPassword = false } = {}) {
// The standalone page leads with a code by email; the password is behind a
// second option, and the landing modal still asks for it outright.
if (revealPassword) await userEvent.click(screen.getByRole('button', { name: 'Sign in with a password instead' }))
async function submitLogin() {
// Both pages ask for the password outright now. Sign-in codes were the
// standalone page's first offer; they are the identity provider's job.
const form = screen.getByRole('form', { name: 'Sign in' })
expect(within(form).getByRole('button', { name: 'Sign In', exact: true })).toBeEnabled()
await userEvent.type(within(form).getByLabelText('Email'), 'owner@example.test')
@ -38,7 +37,7 @@ async function submitLogin({ revealPassword = false } = {}) {
it('logs in on the standalone page without rendering, loading or submitting a captcha', async () => {
mount(LoginPage)
await submitLogin({ revealPassword: true })
await submitLogin()
// Signing in is not a form a stranger can spam into existence there is an
// account behind it and a rate limit in front of it.
expect(document.querySelector('cap-widget')).toBeNull()

View file

@ -1,128 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, expect, it, vi } from 'vitest'
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
import LoginPage from './LoginPage'
import { AuthProvider } from '../context/AuthContext'
import api from '../api/client'
const SENT = 'If that address has an account, a sign-in code is on its way.'
const REFUSED = 'That code is not right, or it has expired. Request a new one.'
function answers({ sso = { sso_enabled: false }, policy = { registration_open: true } } = {}) {
api.get.mockImplementation(url => Promise.resolve({
data: url === '/auth/sso/config' ? sso
: url === '/auth/signup-policy' ? policy
: { id: 1, name: 'Test', role: 'user' },
}))
}
beforeEach(() => {
vi.resetAllMocks()
localStorage.clear()
answers()
api.post.mockResolvedValue({ data: { message: SENT } })
})
function mount() {
render(<MemoryRouter initialEntries={['/login']}><AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<div>Signed in successfully</div>} />
</Routes>
</AuthProvider></MemoryRouter>)
}
async function askForCode(address = 'owner@example.test') {
await userEvent.type(screen.getByLabelText('Email'), address)
await userEvent.click(screen.getByRole('button', { name: 'Continue with email' }))
expect(api.post).toHaveBeenCalledWith('/auth/login-code', { email: address })
return screen.findByLabelText('Sign-in code')
}
it('sends a code and takes it on the same page', async () => {
mount()
expect(screen.queryByLabelText('Password')).not.toBeInTheDocument()
await askForCode()
expect(screen.getByText(SENT)).toBeInTheDocument()
// The address field is gone, so there is nothing left to fill in but the
// code and nowhere for the sign-in to travel to.
expect(screen.queryByLabelText('Email')).not.toBeInTheDocument()
api.post.mockResolvedValue({ data: { access_token: 'synthetic-code-session' } })
await userEvent.type(screen.getByLabelText('Sign-in code'), 'abc def')
await userEvent.click(screen.getByRole('button', { name: 'Sign In', exact: true }))
expect(await screen.findByText('Signed in successfully')).toBeInTheDocument()
// Typed in whatever shape it was read in; the server does the normalising,
// and is sent the address so a code is only ever tried against one account.
expect(api.post).toHaveBeenCalledWith('/auth/login-code/verify',
{ email: 'owner@example.test', code: 'ABC DEF' })
expect(localStorage.getItem('token')).toBe('synthetic-code-session')
})
it('repeats what the server said rather than claiming the mail was sent', async () => {
// The message is the one thing that must read the same for an address with
// an account and one without, so the page is not allowed a copy of its own.
api.post.mockResolvedValue({ data: { message: 'A differently worded but equally uncommitted answer.' } })
mount()
await askForCode('nobody@example.test')
expect(screen.getByText('A differently worded but equally uncommitted answer.')).toBeInTheDocument()
expect(screen.queryByText(/we sent|no account|not registered/i)).not.toBeInTheDocument()
})
it('keeps the code panel up when a code is refused', async () => {
mount()
await askForCode()
api.post.mockRejectedValue({ response: { status: 400, data: { detail: REFUSED } } })
await userEvent.type(screen.getByLabelText('Sign-in code'), 'ZZZZZZ')
await userEvent.click(screen.getByRole('button', { name: 'Sign In', exact: true }))
expect(await screen.findByText(REFUSED)).toBeInTheDocument()
// Still here to try again, and still on the address it was sent to.
expect(screen.getByLabelText('Sign-in code')).toBeInTheDocument()
})
it('shows the reason when the site refuses to send another', async () => {
mount()
await askForCode()
api.post.mockRejectedValue({ response: { status: 429, data: { detail: 'Too many sign-in codes requested for that address. Please wait a few minutes.' } } })
await userEvent.click(screen.getByRole('button', { name: 'Send a new code' }))
expect(await screen.findByText(/Too many sign-in codes/)).toBeInTheDocument()
})
it('lets somebody go back and use a different address', async () => {
mount()
await askForCode()
await userEvent.click(screen.getByRole('button', { name: 'Use a different address' }))
expect(await screen.findByLabelText('Email')).toHaveValue('owner@example.test')
expect(screen.queryByLabelText('Sign-in code')).not.toBeInTheDocument()
})
it('keeps a password one click away for whoever has one', async () => {
api.post.mockResolvedValue({ data: { access_token: 'synthetic-login-token' } })
mount()
await userEvent.click(screen.getByRole('button', { name: 'Sign in with a password instead' }))
await userEvent.type(screen.getByLabelText('Email'), 'owner@example.test')
await userEvent.type(screen.getByLabelText('Password'), 'synthetic-password')
await userEvent.click(screen.getByRole('button', { name: 'Sign In', exact: true }))
expect(await screen.findByText('Signed in successfully')).toBeInTheDocument()
expect(api.post).toHaveBeenCalledWith('/auth/login', { email: 'owner@example.test', password: 'synthetic-password' })
expect(api.post).not.toHaveBeenCalledWith('/auth/login-code', expect.anything())
})
it('leaves single sign-on the first thing on the page', async () => {
answers({ sso: { sso_enabled: true, sso_only: false, provider_name: 'Test SSO' } })
mount()
const sso = await screen.findByRole('link', { name: 'Sign in with Test SSO' })
const byEmail = screen.getByRole('button', { name: 'Continue with email' })
expect(sso.compareDocumentPosition(byEmail) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it('offers registration only while the site is open to it', async () => {
answers({ policy: { registration_open: false } })
mount()
expect(await screen.findByRole('button', { name: 'Continue with email' })).toBeInTheDocument()
await waitFor(() => expect(screen.queryByRole('link', { name: 'Sign up' })).not.toBeInTheDocument())
})

View file

@ -3,22 +3,21 @@ import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
// Said by the server, and repeated here only if it says nothing. The wording
// is load-bearing it must not admit whether the address has an account so
// it lives in one place and this is not that place.
const SENT = 'If that address has an account, a sign-in code is on its way.'
/**
* Signing in.
*
* The provider first, and on a site that signs in only through one, the
* provider and nothing else. Email sign-in codes used to be the primary way in
* and lived here; codes are the identity provider's job now it sends them,
* checks them, and knows about second factors so the page that used to
* reimplement all of that offers a button instead.
*
* The password form survives for a site with no provider configured, or one
* that keeps both doors open.
*/
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [code, setCode] = useState('')
// A password is the second way in, not the first: most people do not have
// one worth remembering, and the ones who do are one click from it.
const [withPassword, setWithPassword] = useState(false)
// The code is typed on this page rather than followed from the mail, so the
// whole sign-in happens where it started nothing to hand between devices,
// and no credential in anybody's address bar or history.
const [sent, setSent] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [unverified, setUnverified] = useState(false)
@ -28,7 +27,7 @@ export default function LoginPage() {
// Null until asked, and treated as open while unknown: a slow answer must
// not hide a way in that exists.
const [policy, setPolicy] = useState(null)
const { login, loginWithToken } = useAuth()
const { login } = useAuth()
const navigate = useNavigate()
const searchParams = new URLSearchParams(window.location.search)
const ssoError = searchParams.get('error')
@ -38,74 +37,38 @@ export default function LoginPage() {
api.get('/auth/signup-policy').then(r => setPolicy(r.data)).catch(() => {})
}, [])
const failed = (err, fallback) => {
const detail = err.response?.data?.detail
if (typeof detail === 'string') setError(detail)
else if (Array.isArray(detail)) setError(detail.some(e => e.loc?.includes('email')) ? 'Invalid email address.' : 'Please check your input and try again.')
else setError(fallback)
}
const requestCode = async () => {
const res = await api.post('/auth/login-code', { email })
setCode('')
setSent(res.data?.message || SENT)
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setUnverified(false)
setLoading(true)
try {
if (withPassword) {
await login(email, password)
navigate('/')
} else if (sent) {
const res = await api.post('/auth/login-code/verify', { email, code })
await loginWithToken(res.data.access_token)
navigate('/')
} else {
await requestCode()
}
await login(email, password)
navigate('/')
} catch (err) {
if (withPassword && err.response?.status === 403) {
if (err.response?.status === 403) {
setUnverified(true)
} else {
failed(err, withPassword ? 'Login failed' : 'Something went wrong. Please try again.')
const detail = err.response?.data?.detail
setError(typeof detail === 'string' ? detail : 'Login failed')
}
} finally {
setLoading(false)
}
}
const resend = async () => {
setError('')
setResending(true)
try { await requestCode() }
catch (err) { failed(err, 'Could not send another code. Please try again.') }
finally { setResending(false) }
}
const resendVerification = async () => {
setResending(true)
try {
await api.post('/auth/resend-verification', { email })
setResendSent(true)
} catch {
setResendSent(true) // always show success to avoid enumeration
// Always shown as sent, so this cannot be used to learn who has an account.
} finally {
setResendSent(true)
setResending(false)
}
}
const startOver = () => { setSent(''); setCode(''); setError('') }
const usePassword = () => { startOver(); setWithPassword(true) }
const linkButton = {
background: 'none', border: 'none', color: 'var(--primary)',
cursor: 'pointer', padding: 0, fontSize: '0.85rem',
}
return (
<div className="auth-page">
<div className="auth-card">
@ -133,7 +96,7 @@ export default function LoginPage() {
{ssoConfig?.sso_enabled && (
<>
<a href="/api/auth/sso/login" className="btn btn-secondary" style={{ width: '100%', display: 'block', textAlign: 'center', textDecoration: 'none', marginBottom: 16 }}>
<a href="/api/auth/sso/login" className="btn btn-primary" style={{ width: '100%', display: 'block', textAlign: 'center', textDecoration: 'none', marginBottom: 16 }}>
Sign in with {ssoConfig.provider_name}
</a>
{!ssoConfig.sso_only && <div style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.82rem', margin: '12px 0' }}>or sign in with email</div>}
@ -143,60 +106,24 @@ export default function LoginPage() {
{!ssoConfig?.sso_only && (
<>
<form aria-label="Sign in" onSubmit={handleSubmit}>
{sent && !withPassword ? (
<>
<p style={{ color: '#64748b', fontSize: '0.9rem', margin: '0 0 4px' }}>{sent}</p>
<p style={{ color: '#64748b', fontSize: '0.82rem', margin: '0 0 16px' }}>
It expires in 15 minutes. Check your spam folder if it isn't there.
</p>
<div className="form-group">
<label htmlFor="login-code">Sign-in code</label>
{/* Upper-cased as it is typed, as the invite field is. What
the server compares is normalised anyway: case, spaces
and dashes are not part of the code. */}
<input id="login-code" value={code} required autoFocus autoComplete="one-time-code"
placeholder="ABC DEF" maxLength={12}
style={{ textTransform: 'uppercase', letterSpacing: '0.18em', fontSize: '1.1rem' }}
onChange={e => setCode(e.target.value.toUpperCase())} />
</div>
</>
) : (
<div className="form-group">
<label htmlFor="login-email">Email</label>
<input id="login-email" type="email" autoComplete="username" value={email} onChange={e => setEmail(e.target.value)} required />
</div>
)}
{withPassword && (
<div className="form-group">
<label htmlFor="login-password">Password</label>
<input id="login-password" type="password" autoComplete="current-password" autoFocus value={password} onChange={e => setPassword(e.target.value)} required />
</div>
)}
<div className="form-group">
<label htmlFor="login-email">Email</label>
<input id="login-email" type="email" autoComplete="username" value={email}
onChange={e => setEmail(e.target.value)} required />
</div>
<div className="form-group">
<label htmlFor="login-password">Password</label>
<input id="login-password" type="password" autoComplete="current-password"
value={password} onChange={e => setPassword(e.target.value)} required />
</div>
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
{withPassword ? (loading ? 'Signing in...' : 'Sign In')
: sent ? (loading ? 'Signing in...' : 'Sign In')
: (loading ? 'Sending...' : 'Continue with email')}
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
{withPassword ? (
<div className="auth-link" style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
<button onClick={() => setWithPassword(false)} style={linkButton}>Email me a code instead</button>
<Link to="/forgot-password" style={{ color: '#64748b', fontSize: '0.85rem' }}>Forgot password?</Link>
</div>
) : sent ? (
<div className="auth-link" style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
<button onClick={resend} disabled={resending} style={linkButton}>
{resending ? 'Sending…' : 'Send a new code'}
</button>
<button onClick={startOver} style={linkButton}>Use a different address</button>
</div>
) : (
<div className="auth-link">
{/* No mail to wait for if you already have a password. */}
<button onClick={usePassword} style={linkButton}>Sign in with a password instead</button>
</div>
)}
<div className="auth-link">
<Link to="/forgot-password" style={{ color: '#64748b', fontSize: '0.85rem' }}>Forgot password?</Link>
</div>
{/* Only where there is a door. Registration can be turned off
site-wide, and this offered a locked one the only way to find

View file

@ -1,68 +0,0 @@
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')
// Typed twice, as a real person would.
await userEvent.type(screen.getByLabelText('Confirm 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())
await fill()
expect(screen.getByRole('button', { name: /Sign Up/ })).toBeEnabled()
})
})

View file

@ -15,13 +15,9 @@ export default function RegisterPage() {
const [loading, setLoading] = useState(false)
const [done, setDone] = useState(false)
const [captchaToken, setCaptchaToken] = 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('')
//: Null until the policy is known. A site on single sign-on has no password
//: sign-up, and drawing the form and refusing the POST is asking somebody
//: for a name, an email, a password twice and an invite code before telling
//: for a name, an email and a password twice before telling
//: them the door does not exist.
const [policy, setPolicy] = useState(null)
const { loginWithToken } = useAuth()
@ -31,7 +27,6 @@ export default function RegisterPage() {
api.get('/auth/signup-policy')
.then(res => {
if (!live) return
setInviteRequired(!!res.data?.invite_required)
setPolicy(res.data || {})
})
.catch(() => { if (live) setPolicy({}) })
@ -47,7 +42,6 @@ export default function RegisterPage() {
const res = await api.post('/auth/register', {
email, password, name,
captcha_token: captchaToken || null,
invite_code: inviteCode.trim() || null,
})
if (res.data.requires_verification) {
setDone(true)
@ -140,25 +134,10 @@ export default function RegisterPage() {
</small>
)}
</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>
)}
<Captcha onVerify={setCaptchaToken} />
<button className="btn btn-primary" style={{ width: '100%' }}
disabled={loading || !password || password !== confirm
|| (captchaSiteKey() && !captchaToken) || (inviteRequired && !inviteCode.trim())}>
|| (captchaSiteKey() && !captchaToken)}>
{loading ? 'Creating account...' : 'Sign Up'}
</button>
</form>