diff --git a/backend/alembic/versions/f8091a2b3c4d_login_links.py b/backend/alembic/versions/f8091a2b3c4d_login_links.py new file mode 100644 index 0000000..2ee309d --- /dev/null +++ b/backend/alembic/versions/f8091a2b3c4d_login_links.py @@ -0,0 +1,35 @@ +"""Sign-in links sent by email. + +Only the fingerprint of each token is stored: a row here is a session waiting +to happen, and a table of usable ones is worth more to a reader of the database +than a table of verification tokens ever was. + +Revision ID: f8091a2b3c4d +Revises: e7f8091a2b3c +""" +from alembic import op + +revision = "f8091a2b3c4d" +down_revision = "e7f8091a2b3c" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute(""" + CREATE TABLE IF NOT EXISTS login_links ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL UNIQUE, + expires_at TIMESTAMP NOT NULL, + consumed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )""") + # Both lookups this table serves: one by token when a link is followed, + # one by owner when a newer request retires the older links. + op.execute("CREATE INDEX IF NOT EXISTS ix_login_links_token_hash ON login_links (token_hash)") + op.execute("CREATE INDEX IF NOT EXISTS ix_login_links_user_id ON login_links (user_id)") + + +def downgrade(): + op.execute("DROP TABLE IF EXISTS login_links") diff --git a/backend/app/main.py b/backend/app/main.py index 6e6da1e..c90f982 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,6 +14,7 @@ from app.routers import auth, documents, quizzes, attempts, admin, tts, nextclou from app.routers import access from app.routers import feedback from app.routers import study_tools, uploads, articles, share, collections, study_plans, media, search, ai_mode, drafts, public +from app.routers import login_link from app.utils.auth import get_password_hash @@ -609,6 +610,7 @@ app.add_middleware(RequestLoggingMiddleware) app.include_router(uploads.router) app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) +app.include_router(login_link.router, prefix="/api/auth", tags=["auth"]) # Counts the landing page states about itself. No auth: a stranger reads it. app.include_router(public.router, prefix="/api/public", tags=["public"]) app.include_router(articles.router, prefix="/api/articles", tags=["articles"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 025e514..a35cfcc 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -42,3 +42,4 @@ __all__ = [ from app.models.feedback import QuestionFeedback # noqa: F401 from app.models.invite import InviteCode # noqa: F401 from app.models.draft_question import DraftBatch, DraftQuestion # noqa: F401 +from app.models.login_link import LoginLink # noqa: F401 diff --git a/backend/app/models/login_link.py b/backend/app/models/login_link.py new file mode 100644 index 0000000..755afe3 --- /dev/null +++ b/backend/app/models/login_link.py @@ -0,0 +1,29 @@ +from datetime import datetime + +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String + +from app.database import Base + + +class LoginLink(Base): + """One issued sign-in link. + + Only the fingerprint of the token is kept. Unlike a verification or reset + token, this one is a session in waiting — anybody who can read the table + could sign in as its owner — so the row holds something that can be + compared against but not replayed. + """ + + __tablename__ = "login_links" + + 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 token, hex. Not bcrypt: the token is 256 bits of + #: randomness rather than something a person chose, so there is nothing for + #: a slow hash to buy — only the lookup would get slower. + token_hash = Column(String(64), unique=True, nullable=False, index=True) + expires_at = Column(DateTime, nullable=False) + #: Set the moment the link is spent, and also when a later request retires + #: it. Both mean the same thing to everyone who reads this: not usable. + consumed_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 705bdf4..554e6e5 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -404,13 +404,13 @@ def test_model( return {"message": f"✓ {model.model_id} read “{TEST_WORD}” off the test image"} try: - from app.services.ai_service import get_client - response = get_client(model.api_key).chat.completions.create( + from app.services.ai_service import chat + reply = chat( model=model.model_id, messages=[{"role": "user", "content": "Reply with only the word: OK"}], max_tokens=10, - ) - reply = response.choices[0].message.content.strip() + api_key=model.api_key, + ).strip() return {"message": f"✓ {model.model_id} → {reply!r}"} except Exception as e: raise HTTPException(status_code=502, detail=str(e)) diff --git a/backend/app/routers/ai_mode.py b/backend/app/routers/ai_mode.py index e6da9e5..6a92768 100644 --- a/backend/app/routers/ai_mode.py +++ b/backend/app/routers/ai_mode.py @@ -187,12 +187,11 @@ async def ask(conversation_id: int, data: AskIn, db: Session = Depends(get_db), *history, {"role": "user", "content": question}] try: - from app.services.ai_service import get_async_client + from app.services.ai_service import achat - client = get_async_client(api_key) - response = await client.chat.completions.create( - model=model_id, messages=messages, max_tokens=700, temperature=0.3) - raw = (response.choices[0].message.content or "").strip() + raw = (await achat( + model=model_id, messages=messages, max_tokens=700, temperature=0.3, + api_key=api_key) or "").strip() except Exception: log.error("AI Mode failed for user %s", current_user.id, exc_info=True) raise HTTPException(502, "AI Mode is temporarily unavailable. Try again in a moment.") diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index f170153..ae451fd 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -73,8 +73,14 @@ 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} - return {"invite_required": site_settings.get_flag("invite_only"), "first_user": False} + return {"invite_required": False, "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. + return {"invite_required": site_settings.get_flag("invite_only"), + "first_user": False, + "registration_open": site_settings.get_flag("registration_enabled")} @router.post("/register") diff --git a/backend/app/routers/courses.py b/backend/app/routers/courses.py index e49748a..71dc6a1 100644 --- a/backend/app/routers/courses.py +++ b/backend/app/routers/courses.py @@ -1470,7 +1470,7 @@ def ai_generate_standalone( """Generate content using AI without requiring a saved lesson.""" _course_owner_or_admin(course_id, current_user, db) - from app.services.ai_service import get_model_for_task, get_client + from app.services.ai_service import get_model_for_task, chat if data.model_id: model_id = data.model_id @@ -1495,15 +1495,16 @@ def ai_generate_standalone( user_msg = f"Existing content:\n{data.existing_content}\n\nInstructions: {data.prompt}" try: - response = get_client(api_key).chat.completions.create( + generated = chat( model=model_id, messages=[ {"role": "system", "content": system_msg}, {"role": "user", "content": user_msg}, ], max_tokens=4000, + api_key=api_key, ) - return {"generated_content": response.choices[0].message.content, "model_used": model_id} + return {"generated_content": generated, "model_used": model_id} except Exception as e: logger.error(f"AI generation failed: {e}") raise HTTPException(status_code=502, detail="AI generation failed") @@ -1529,7 +1530,7 @@ def ai_generate_content( if not lesson: raise HTTPException(status_code=404, detail="Lesson not found in this course") - from app.services.ai_service import get_model_for_task, get_client + from app.services.ai_service import get_model_for_task, chat if data.model_id: model_id = data.model_id @@ -1576,15 +1577,15 @@ def ai_generate_content( ) try: - response = get_client(api_key).chat.completions.create( + generated = chat( model=model_id, messages=[ {"role": "system", "content": system_msg}, {"role": "user", "content": user_msg}, ], max_tokens=4000, + api_key=api_key, ) - generated = response.choices[0].message.content except Exception as e: logger.error(f"AI generation failed: {e}") raise HTTPException(status_code=502, detail="AI generation failed") diff --git a/backend/app/routers/login_link.py b/backend/app/routers/login_link.py new file mode 100644 index 0000000..bd92f0a --- /dev/null +++ b/backend/app/routers/login_link.py @@ -0,0 +1,140 @@ +"""Ask for a sign-in link, and spend one. + +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 LoginLinkRequest, LoginLinkConsume, Token +from app.services import email_service, login_links, site_settings +from app.utils.auth import check_rate_limit, create_access_token + +logger = logging.getLogger(__name__) + +router = APIRouter() + +#: The only thing this endpoint ever says. Not "we sent you a link" 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 link is on its way." + +#: Wording and status are identical for an address that exists and one that +#: does not — but the work is not, and a stopwatch reads the difference. Every +#: reply is held to this floor, which is comfortably above the few milliseconds +#: that issuing a link 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: the +#: address is who the mail lands on, and the one thing an attacker cannot vary +#: while still hitting the person they mean to hit. Three is enough for +#: somebody who fumbles the first attempt and does not trust the second. +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 it barely inconveniences anyone +#: renting a hundred addresses. It is here to protect the site's own sending +#: reputation from one machine working through a word list, not to protect any +#: individual mailbox — the per-address limit does that. +PER_IP_CALLS, PER_IP_WINDOW = 20, 60 * 60 + + +def _sso_only() -> bool: + return site_settings.get_flag("sso_only") + + +@router.post("/login-link") +async def request_login_link( + data: LoginLinkRequest, + background_tasks: BackgroundTasks, + request: Request = None, + db: Session = Depends(get_db), +): + """Send a sign-in link 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_link:addr:{login_links.fingerprint(address)}", + max_calls=PER_ADDRESS_CALLS, + window_seconds=PER_ADDRESS_WINDOW, + detail="Too many sign-in links 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_link:ip:{client_ip}", + max_calls=PER_IP_CALLS, + window_seconds=PER_IP_WINDOW, + detail="Too many sign-in links requested from this network. Please wait a while.", + ) + + user = db.query(User).filter(User.email == address).first() + if user is not None: + token = login_links.issue(db, user) + background_tasks.add_task(email_service.send_login_link_email, user.email, user.name, token) + + elapsed = time.perf_counter() - started + if elapsed < MIN_SECONDS: + await asyncio.sleep(MIN_SECONDS - elapsed) + return {"message": SENT} + + +@router.post("/login-link/consume", response_model=Token) +def consume_login_link(data: LoginLinkConsume, db: Session = Depends(get_db)): + """Spend a link and hand back a session. + + A POST rather than the link itself pointing here. Mail gateways, security + scanners and preview panes fetch every URL in a message with GET, and a + single-use token spent by a scanner is a link that is already dead when its + owner clicks it. The mail points at a page in the app, which posts the + token from JavaScript that no scanner runs. + + The link is not tied to the browser that asked for it. Binding it — a nonce + in a cookie, checked here — would break the ordinary case, which is asking + on a laptop and reading mail on a phone, and it defends against almost + nothing: somebody who can read the mailbox can simply ask for a link of + their own. What is left is a link forwarded or leaked to a third party, and + that is what the fifteen minutes and the one use are for. + """ + user = login_links.consume(db, data.token) + if user is None: + raise HTTPException( + status_code=400, + detail="That sign-in link is no longer valid. If you asked more than once, " + "open the most recent email, or request a new link.", + ) + + # Following the link proves control of the mailbox, 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 link 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: + raise HTTPException( + status_code=403, + detail="Email not verified. Please check your inbox and verify your email before signing in.", + ) + + logger.info("Sign-in link spent by user %s", user.id) + return Token(access_token=create_access_token(data={"sub": user.email})) diff --git a/backend/app/routers/teach.py b/backend/app/routers/teach.py index 7be0f48..28c169d 100644 --- a/backend/app/routers/teach.py +++ b/backend/app/routers/teach.py @@ -348,15 +348,15 @@ async def chat( messages.append({"role": msg.role, "content": msg.content}) try: - from app.services.ai_service import DEFAULT_TIMEOUT, get_async_client - client = get_async_client(api_key, timeout=DEFAULT_TIMEOUT) - response = await client.chat.completions.create( + from app.services.ai_service import DEFAULT_TIMEOUT, achat + raw = (await achat( model=model_id, messages=messages, max_tokens=600, temperature=0.4, - ) - raw = response.choices[0].message.content.strip() + api_key=api_key, + timeout=DEFAULT_TIMEOUT, + )).strip() # Parse out follow-up suggestions (lines starting with "> ") lines = raw.splitlines() diff --git a/backend/app/routers/tts.py b/backend/app/routers/tts.py index 07daa12..8f53411 100644 --- a/backend/app/routers/tts.py +++ b/backend/app/routers/tts.py @@ -83,17 +83,17 @@ def text_to_speech( # never chosen one. A caller may still name a voice, which is how a preview # in Settings plays the one being considered rather than the one in force. wanted = request.voice or current_user.tts_voice - if wanted and wanted.startswith("local-"): - config = db.query(AIModelConfig).filter( - AIModelConfig.task == "tts", - AIModelConfig.is_active == True, - AIModelConfig.model_id == wanted, - ).first() - if not config: - model_id, api_key = _default_tts_model(db) - else: - model_id = config.model_id - api_key = config.api_key or None + # Any active voice, not only a locally served one. The prefix check that + # used to guard this meant a site adding a hosted voice would offer it in + # Settings, save the learner's choice, and then quietly read every question + # in the default voice instead. + config = db.query(AIModelConfig).filter( + AIModelConfig.task == "tts", + AIModelConfig.is_active == True, + AIModelConfig.model_id == wanted, + ).first() if wanted else None + if config: + model_id, api_key = config.model_id, config.api_key or None else: model_id, api_key = _default_tts_model(db) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 407839f..fd03681 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -52,3 +52,11 @@ class ForgotPasswordRequest(BaseModel): class ResetPasswordRequest(BaseModel): token: str new_password: str + + +class LoginLinkRequest(BaseModel): + email: EmailStr + + +class LoginLinkConsume(BaseModel): + token: str diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index 1f256d2..53bbb62 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -2,7 +2,7 @@ import json import logging import os -from openai import AsyncOpenAI, OpenAI +import httpx from app.config import settings @@ -15,51 +15,104 @@ logger = logging.getLogger(__name__) # sending. There is no routing left to defeat. -def _client_base_url() -> str | None: - """The proxy speaks OpenAI's HTTP API, and the SDK appends `chat/completions` - to the root it is given, so the root has to carry the `/v1` that the audio - calls below — and vector_service and embedding_service — append by hand. - LITELLM_API_BASE is configured without it.""" - base = (settings.LITELLM_API_BASE or "").rstrip("/") - return base.removesuffix("/v1") + "/v1" if base else None +class ProxyError(RuntimeError): + """A non-2xx answer from the completions proxy. + + Every call site catches `Exception` and turns it into a 502, so the type + matters less than what it carries. Two things are worth carrying: + + * `status_code`, because `vision_service._probe` sorts refusals by it — a + 4xx to a one-token call holding nothing but a white square is the model + saying it will not take images, and that verdict is cached, while a 5xx + or a timeout says nothing and is not. The SDK's error exposed the same + attribute name, so the probe needs no change. + * the start of the body, because the proxy explains refusals there and a + log line reading only "400" has never once been enough. + """ + + def __init__(self, status_code: int, body: str): + self.status_code = status_code + self.body = body + super().__init__(f"proxy returned {status_code}: {body}") -def _client_kwargs(api_key: str | None, timeout: float | None) -> dict: - kwargs = { - # The SDK refuses to construct a client with no key at all, so an - # unconfigured deployment would fail here rather than at the call — - # turning what every caller handles as a 502 into a 500. Let the - # endpoint reject the placeholder instead. - "api_key": api_key or settings.LITELLM_API_KEY or os.environ.get("OPENAI_API_KEY") or "missing", - # Left at its default the SDK repeats connection errors and 429/5xx - # twice on its own. extract_questions already has its own three - # attempts, which would become nine. - "max_retries": 0, - } - base = _client_base_url() - if base: - kwargs["base_url"] = base - if timeout is not None: - kwargs["timeout"] = timeout - return kwargs - - -#: The SDK reads for ten minutes by default and this client retries nothing, so -#: a stalled connection is a stalled request — three of them in extraction, -#: which does its own retrying. Two minutes is longer than any answer here has -#: ever legitimately taken. +#: Two minutes is longer than any answer a person waits on here has ever +#: legitimately taken, and nothing retries underneath (see `chat`), so a +#: stalled connection is a stalled request — three of them in extraction, which +#: does its own retrying. DEFAULT_TIMEOUT = 120.0 -def get_client(api_key: str | None = None, timeout: float | None = None) -> OpenAI: - """Blocking client for the completions proxy. Every call site goes through - here: the same three settings assembled by hand at each one is how one of - them ends up not sending the api_base.""" - return OpenAI(**_client_kwargs(api_key, timeout)) +#: What a call gets when it names no timeout. httpx's own default is five +#: seconds, which no completion survives, so the choice cannot be left to it. +#: Ten minutes is what the SDK gave these same call sites before it was +#: removed, and the longest of them drafts a 4000-token article, so it is kept +#: rather than lowered — shortening it is a visible change and belongs in its +#: own commit. Anything a person is sitting in front of should pass +#: DEFAULT_TIMEOUT instead. +FALLBACK_TIMEOUT = 600.0 -def get_async_client(api_key: str | None = None, timeout: float | None = None) -> AsyncOpenAI: - """Async counterpart to get_client, for the request-path chat endpoints.""" - return AsyncOpenAI(**_client_kwargs(api_key, timeout)) +def _endpoint() -> str: + """The proxy speaks OpenAI's HTTP API. `LITELLM_API_BASE` is configured as + the bare host, and every other hand-rolled call to it in this codebase — + embeddings, transcription, speech — appends the `/v1` itself, so this one + does too rather than inventing a third convention. With nothing configured + the address is OpenAI's own, which is where the SDK went by default and + where `text_to_speech` below still goes.""" + base = (settings.LITELLM_API_BASE or "https://api.openai.com").rstrip("/").removesuffix("/v1") + return f"{base}/v1/chat/completions" + + +def _headers(api_key: str | None) -> dict: + # An unconfigured deployment should fail at the call, the way every caller + # already handles, rather than earlier and louder somewhere else. + key = api_key or settings.LITELLM_API_KEY or os.environ.get("OPENAI_API_KEY") or "missing" + return {"Authorization": f"Bearer {key}", "Content-Type": "application/json"} + + +def _body(model: str, messages: list[dict], params: dict) -> dict: + return {"model": model, "messages": messages, **params} + + +def _content(response: httpx.Response) -> str | None: + """The assistant's message, or the failure that explains itself. + + Nothing in this codebase reads usage, cost, tool calls or logprobs off a + completion — nine call sites, all of them `choices[0].message.content` — + so the envelope is unwrapped here instead of nine times over. A caller that + one day needs more should get the parsed body, not a second return value. + """ + if response.status_code >= 400: + # Read the body before raising: it is where the proxy says *why*, and + # it is the difference between a useful log line and a number. + raise ProxyError(response.status_code, response.text[:500]) + return response.json()["choices"][0]["message"]["content"] + + +def chat(*, model: str, messages: list[dict], api_key: str | None = None, + timeout: float | None = None, **params) -> str | None: + """One blocking completion, returning the assistant's message text. + + Extra keyword arguments (`temperature`, `max_tokens`, …) go into the JSON + body untouched, so this is the OpenAI request with the boilerplate — URL, + key, timeout — filled in once instead of at each call site. + + No retries: `extract_questions` already makes three attempts of its own and + anything retrying underneath would quietly make that nine. + """ + with httpx.Client(timeout=timeout if timeout is not None else FALLBACK_TIMEOUT) as client: + return _content(client.post(_endpoint(), headers=_headers(api_key), + json=_body(model, messages, params))) + + +async def achat(*, model: str, messages: list[dict], api_key: str | None = None, + timeout: float | None = None, **params) -> str | None: + """Async counterpart to `chat`, for the request-path chat endpoints.""" + async with httpx.AsyncClient( + timeout=timeout if timeout is not None else FALLBACK_TIMEOUT) as client: + return _content(await client.post(_endpoint(), headers=_headers(api_key), + json=_body(model, messages, params))) + EXTRACTION_PROMPT = """You are extracting questions from a pediatric board review exam PDF. @@ -191,13 +244,13 @@ def extract_questions( for attempt in range(3): try: # Don't force JSON mode — let the model respond naturally and we parse it - response = get_client(use_key, timeout=DEFAULT_TIMEOUT).chat.completions.create( + response_text = chat( model=use_model, messages=[{"role": "user", "content": prompt}], temperature=0.1, # low temp for faithful extraction + api_key=use_key, + timeout=DEFAULT_TIMEOUT, ) - - response_text = response.choices[0].message.content logger.info(f"Model raw response (first 500 chars): {response_text[:500]!r}") # Try to parse JSON, handle markdown code blocks @@ -289,8 +342,7 @@ def _call_model(prompt: str, model_id: str | None, api_key: str | None, } if max_tokens: kwargs["max_tokens"] = max_tokens - response = get_client(use_key, timeout=timeout).chat.completions.create(**kwargs) - return response.choices[0].message.content + return chat(**kwargs, api_key=use_key, timeout=timeout) def _parse_json_response(text: str) -> dict: @@ -311,8 +363,6 @@ def transcribe_audio( api_key: str | None = None, ) -> str | None: """Transcribe uploaded audio through the proxy's OpenAI-compatible audio endpoint.""" - import httpx - if not audio: return None @@ -450,7 +500,7 @@ def generate_tts_audio( sherpa/: → Local speech gateway (e.g. sherpa/kokoro:am_adam) local-: → Local LiteLLM TTS model + voice (e.g. local-kokoro-tts:am_adam) """ - import httpx, base64 + import base64 use_model = model_id or "tts-1:alloy" diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 02447a5..19bf4e6 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -150,3 +150,23 @@ We received a request to reset your password. Click below to choose a new one. await _send(to_email, subject, _wrap(subject, md)) + + +async def send_login_link_email(to_email: str, name: str, token: str): + url = f"{settings.APP_URL}/signin-link?token={token}" + subject = "Your PedsHub sign-in link" + md = f"""# Sign in to PedsHub + +Hi **{name}**, + +Here is the sign-in link you asked for. It signs you in on whichever device you open it on. + +[button:Sign In]({url}) + +> Expires in **15 minutes** · Works once. If you asked more than once, only the newest link works. If you didn't ask for this, ignore this email — nobody can get in without it. + +--- + +[link:Or copy this link]({url}) +""" + await _send(to_email, subject, _wrap(subject, md)) diff --git a/backend/app/services/login_links.py b/backend/app/services/login_links.py new file mode 100644 index 0000000..47e20a9 --- /dev/null +++ b/backend/app/services/login_links.py @@ -0,0 +1,90 @@ +"""Signing in by following a link sent to your own mailbox. + +The link is a credential that sits in an inbox, so everything here is about +keeping the window in which it is worth stealing as narrow as it can be while +the flow still works: a short life, one use, and a newer request retiring +whatever came before it. +""" +import hashlib +import secrets +from datetime import datetime, timedelta + +from sqlalchemy.orm import Session + +from app.models.login_link import LoginLink +from app.models.user import User + +#: Fifteen minutes. Mail is normally delivered in seconds and whoever asked for +#: the link is at the keyboard now, so five would nearly always do — except +#: that greylisting defers a first delivery between a sender and a recipient +#: who have never spoken, commonly by five to ten minutes, and that is exactly +#: the case here for anybody signing in for the first time. Fifteen covers the +#: deferral and very little else. +#: +#: Deliberately not the hour the password reset link gets: spending a reset +#: link changes the password, which the owner notices the next time they sign +#: in. Spending this one leaves no trace at all in front of the owner, so it +#: does not get the same rope. +LINK_TTL = timedelta(minutes=15) + +#: Rows are dead weight once they are well past their expiry, and nothing reads +#: them afterwards. Kept a day rather than deleted on the spot so that a +#: support question about "the link did not work this morning" can still be +#: answered from the table. +KEEP_SPENT_FOR = timedelta(days=1) + + +def fingerprint(value: str) -> str: + return hashlib.sha256(value.strip().encode("utf-8")).hexdigest() + + +def issue(db: Session, user: User) -> str: + """Retire this user's outstanding links, mint a new one, return the token. + + Superseding rather than accumulating: with three live at once — which is + all the rate limit allows — "single use" stops meaning "one way in at a + time", and a mailbox quietly collects credentials. The cost is that somebody who asks twice and then opens the + first mail is refused — which is why the failure copy says to use the most + recent email rather than only that the link is dead. + """ + now = datetime.utcnow() + db.query(LoginLink).filter( + LoginLink.user_id == user.id, + LoginLink.consumed_at.is_(None), + ).update({"consumed_at": now}, synchronize_session=False) + db.query(LoginLink).filter( + LoginLink.user_id == user.id, + LoginLink.expires_at < now - KEEP_SPENT_FOR, + ).delete(synchronize_session=False) + + token = secrets.token_urlsafe(32) + db.add(LoginLink(user_id=user.id, token_hash=fingerprint(token), expires_at=now + LINK_TTL)) + db.commit() + return token + + +def consume(db: Session, token: str) -> User | None: + """Spend a link and return whose it is, or None if it is no good. + + One caller, one answer: an expired link, an already-spent link and a token + that was never issued are all None. Telling them apart would tell whoever + is guessing which of their guesses was once real, and none of the three + leaves the person holding it anything to do but ask for another. + """ + row = db.query(LoginLink).filter(LoginLink.token_hash == fingerprint(token)).first() + if row is None or row.expires_at <= datetime.utcnow(): + return None + + # The claim is the UPDATE, not the read above: two requests carrying the + # same token — a double click, or a mail client racing the person — both + # pass the read, and only the one whose UPDATE matched a row still unspent + # gets to sign in. + claimed = db.query(LoginLink).filter( + LoginLink.id == row.id, + LoginLink.consumed_at.is_(None), + ).update({"consumed_at": datetime.utcnow()}, synchronize_session=False) + db.commit() + if not claimed: + return None + + return db.query(User).filter(User.id == row.user_id).first() diff --git a/backend/app/services/site_settings.py b/backend/app/services/site_settings.py index 046e0a4..81b653e 100644 --- a/backend/app/services/site_settings.py +++ b/backend/app/services/site_settings.py @@ -12,10 +12,19 @@ logger = logging.getLogger(__name__) #: name -> default. Anything not listed here cannot be set. FLAGS: dict[str, bool] = { + #: Whether anybody new may join at all. Stored under the same key the + #: registration route and the admin settings page have always used; it was + #: simply not declared here, so the one helper that reads these flags + #: refused it and every caller wrote its own Redis lookup instead. + "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. + "sso_only": False, #: Whether the AI tutor may be opened while a session is being sat. It is #: only ever offered in study mode — the tutor is given the correct answer #: and told it may reveal it, so during an exam it would simply hand it diff --git a/backend/app/services/vision_service.py b/backend/app/services/vision_service.py index 4941d57..e9f651c 100644 --- a/backend/app/services/vision_service.py +++ b/backend/app/services/vision_service.py @@ -36,7 +36,7 @@ import time from dataclasses import dataclass, field from app.config import settings -from app.services.ai_service import get_client, get_configured_model +from app.services.ai_service import chat, get_configured_model logger = logging.getLogger(__name__) @@ -221,13 +221,15 @@ def _probe(model_id: str, api_key: str | None) -> bool: return _probes[model_id] try: - get_client(api_key, timeout=30).chat.completions.create( + chat( model=model_id, max_tokens=1, messages=[{"role": "user", "content": [ {"type": "image_url", "image_url": {"url": _probe_image()}}, {"type": "text", "text": "Reply with: ok"}, ]}], + api_key=api_key, + timeout=30, ) verdict = True except Exception as error: @@ -380,15 +382,16 @@ def _describe(images: list[Image], model_id: str, api_key: str | None, if context: prompt += f"\n\nWhere it appears:\n{context[:2000]}" - completion = get_client(api_key, timeout=120).chat.completions.create( + text = (chat( model=model_id, temperature=0, max_tokens=700, messages=[{"role": "user", "content": [ image_part(image), {"type": "text", "text": prompt}, ]}], - ) - text = (completion.choices[0].message.content or "").strip() + api_key=api_key, + timeout=120, + ) or "").strip() if not text: raise VisionUnavailable( f"{model_id} is configured as the tool model but returned no " diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py index cffbcda..3fad317 100644 --- a/backend/app/tasks/quiz_tasks.py +++ b/backend/app/tasks/quiz_tasks.py @@ -602,7 +602,7 @@ def generate_article_draft(self, job_id: str, user_id: int, topic: str, try: from app.models.article import Article from app.services import article_service - from app.services.ai_service import get_model_for_task, get_client + from app.services.ai_service import get_model_for_task, chat from app.config import settings existing = db.get(Article, article_id) if article_id else None @@ -626,10 +626,9 @@ def generate_article_draft(self, job_id: str, user_id: int, topic: str, instructions=f"Refine this existing draft: {existing.title}\n{instructions}" if existing else instructions or "", existing=existing_block, ) - response = get_client(ai_api_key).chat.completions.create( + raw = chat( model=ai_model_id, messages=[{"role": "user", "content": prompt}], - max_tokens=4000, temperature=0.4) - raw = response.choices[0].message.content.strip() + max_tokens=4000, temperature=0.4, api_key=ai_api_key).strip() if raw.startswith("```"): raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:] if raw.endswith("```"): diff --git a/backend/requirements.txt b/backend/requirements.txt index 7b9532b..3e1bb71 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,12 +12,6 @@ PyMuPDF==1.23.22 # Thumbnails. It arrives transitively today, which is not a reason to let a # feature depend on somebody else's dependency tree. Pillow==12.3.0 -# Every completion goes to one OpenAI-compatible endpoint, the proxy in -# LITELLM_API_BASE, with no streaming, tools, routing or fallbacks. litellm -# stood here for that and brought a large dependency tree to do it; the openai -# SDK it already depended on does the same work directly. Embeddings, model -# discovery and the audio endpoints were on plain httpx all along. -openai==3.13.0 chromadb==0.4.24 celery[redis]==5.3.6 redis==5.0.1 @@ -25,6 +19,10 @@ fastapi-mail==1.4.1 apscheduler==3.10.4 aiofiles==23.2.1 python-dotenv==1.0.1 +# The only HTTP client in the process. Completions, embeddings, model +# discovery and the audio endpoints all POST to the same OpenAI-compatible +# proxy; litellm and then the openai SDK each stood in front of the +# completions half of that, and neither was doing anything this was not. httpx==0.27.0 boto3==1.34.69 pgvector==0.3.6 diff --git a/backend/scripts/convert_jpeg2000_figures.py b/backend/scripts/convert_jpeg2000_figures.py new file mode 100644 index 0000000..ce79898 --- /dev/null +++ b/backend/scripts/convert_jpeg2000_figures.py @@ -0,0 +1,128 @@ +"""Rewrite JPEG 2000 figures as WebP. + +Twenty-one stem images extracted from the source PDFs are `.jpx`. No browser +but Safari draws JPEG 2000 — Chrome dropped it in 2015 — so those figures were +blank for almost everybody. Serving already converts them on the way out and +keeps the result, so nothing is broken by leaving them as they are; this +removes the conversion step entirely and makes what is stored the same thing +that is served. + +Written as a migration of the bytes rather than a lazy rewrite because there +are three places a path is recorded — `questions.image_path`, +`questions.explanation_image_path` and `media_assets.path` — and a conversion +that updated some of them would be worse than none. + +The original is left in storage. It is the only copy of what came out of the +PDF, it costs a few megabytes in total, and a conversion nobody can undo is not +one to run against a live bank on a Friday. + +Idempotent, and a dry run by default: + + docker compose exec backend python -m scripts.convert_jpeg2000_figures + docker compose exec backend python -m scripts.convert_jpeg2000_figures --apply +""" +import io +import sys + +from sqlalchemy import text as sa_text + +from app.database import SessionLocal +from app.services import storage_service, thumbnails + +#: What we are converting away from, by magic bytes rather than by name — the +#: name is what got this wrong in the first place. +SUFFIXES = (".jpx", ".jp2", ".jpf") + +QUALITY = 90 # Higher than a thumbnail: this replaces the figure, not a preview. + + +def converted_key(key: str) -> str: + for suffix in SUFFIXES: + if key.lower().endswith(suffix): + return key[: -len(suffix)] + ".webp" + return key + ".webp" + + +def to_webp(data: bytes) -> bytes | None: + from PIL import Image, ImageOps + + try: + image = ImageOps.exif_transpose(Image.open(io.BytesIO(data))) + image = image.convert("RGBA" if image.mode in ("RGBA", "LA", "P") else "RGB") + out = io.BytesIO() + image.save(out, format="WEBP", quality=QUALITY, method=4) + return out.getvalue() + except Exception as error: + print(f" ! could not decode: {error}") + return None + + +def rows_with_jpeg2000(db): + """Every path that names a JPEG 2000 file, wherever it is recorded.""" + like = " OR ".join(f"lower({{col}}) LIKE '%{s}'" for s in SUFFIXES) + found: dict[str, list[tuple[str, int, str]]] = {} + for table, columns, key in (("questions", ("image_path", "explanation_image_path"), "id"), + ("media_assets", ("path",), "id")): + for column in columns: + clause = like.format(col=column) + for row in db.execute(sa_text( + f"SELECT {key} AS id, {column} AS path FROM {table} " + f"WHERE {column} IS NOT NULL AND ({clause})")): + found.setdefault(row.path, []).append((table, row.id, column)) + return found + + +def main(apply: bool) -> int: + db = SessionLocal() + try: + found = rows_with_jpeg2000(db) + if not found: + print("Nothing left in JPEG 2000.") + return 0 + + print(f"{len(found)} file(s) referenced by {sum(len(v) for v in found.values())} row(s)\n") + converted = skipped = 0 + for key, references in sorted(found.items()): + target = converted_key(key) + where = ", ".join(f"{t}#{i}.{c}" for t, i, c in references) + print(f" {key}\n -> {target} ({where})") + + data = storage_service.load(key) + if not data: + print(" ! not in storage; left alone") + skipped += 1 + continue + if not apply: + continue + + if not storage_service.exists(target): + webp = to_webp(data) + if not webp: + skipped += 1 + continue + storage_service.save(target, webp, "image/webp") + print(f" {len(data):,} bytes -> {len(webp):,}") + for table, row_id, column in references: + db.execute(sa_text(f"UPDATE {table} SET {column} = :new WHERE {key_of(table)} = :id"), + {"new": target, "id": row_id}) + # The derivatives were made from the old key and are now orphaned. + thumbnails.forget(key) + converted += 1 + + if not apply: + print("\ndry run. Pass --apply to write.") + return 0 + db.commit() + print(f"\ndone. {converted} converted, {skipped} left alone. " + f"Originals are still in storage.") + return 0 + finally: + db.close() + + +def key_of(table: str) -> str: + return "id" + + +if __name__ == "__main__": + sys.exit(main("--apply" in sys.argv)) diff --git a/backend/scripts/triage_question_images.py b/backend/scripts/triage_question_images.py index fa4e7e4..57d1ecc 100644 --- a/backend/scripts/triage_question_images.py +++ b/backend/scripts/triage_question_images.py @@ -45,7 +45,7 @@ from app.database import SessionLocal from app.models.question import Question from app.routers.questions import _snapshot_question from app.services import vision_service -from app.services.ai_service import get_client, get_configured_model, get_model_for_task +from app.services.ai_service import chat, get_configured_model, get_model_for_task SCRIPT_EDITOR_ID = None @@ -120,7 +120,7 @@ def classify(stem: str, explanation: str, key: str, model: str, parts, _ = vision_service.image_context( None, [image], model_id=model, api_key=api_key, tool=tool, context=f"A figure printed with this board-exam question:\n{(stem or '')[:1000]}") - completion = get_client(api_key).chat.completions.create( + text = chat( model=model, temperature=0, messages=[{"role": "user", "content": [ @@ -129,8 +129,8 @@ def classify(stem: str, explanation: str, key: str, model: str, stem=(stem or "")[:4000] or "(empty)", explanation=(explanation or "")[:4000] or "(empty)")}, ]}], - ) - text = completion.choices[0].message.content or "" + api_key=api_key, + ) or "" except Exception as error: # a failed call must not decide anything return {"belongs": "unclear", "confidence": 0.0, "reason": f"model error: {error}"[:160]} diff --git a/backend/tests/test_ai_mode.py b/backend/tests/test_ai_mode.py index 7647e5e..eec5858 100644 --- a/backend/tests/test_ai_mode.py +++ b/backend/tests/test_ai_mode.py @@ -5,7 +5,7 @@ here is not what a model says but what the server does with it. The safety property — an invented citation cannot survive — has to hold whatever comes back. """ import unittest -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, patch import test_quiz_builder as fixtures from app.models.article import Article, ArticleSectionIndex, QuestionArticleLink @@ -16,12 +16,6 @@ from app.routers import ai_mode from app.services import ai_mode_service -def _stub_client(create): - client = Mock() - client.chat.completions.create = create - return client - - class CitationContractTests(unittest.TestCase): """The step that makes a hallucinated citation impossible rather than rare.""" @@ -133,10 +127,8 @@ class _AiModeBase(unittest.TestCase): def reply_with(self, text): """Stub the model, so the test is about the server's half of the contract.""" - response = type("R", (), {"choices": [type("C", (), { - "message": type("M", (), {"content": text})()})()]})() - return patch("app.services.ai_service.get_async_client", - return_value=_stub_client(AsyncMock(return_value=response))) + return patch("app.services.ai_service.achat", + new=AsyncMock(return_value=text)) def ask(self, message, conversation_id=None): if conversation_id is None: @@ -185,8 +177,8 @@ class AiModeRouteTests(_AiModeBase): def test_a_model_failure_is_reported_not_stored(self): conversation_id = self.client.post('/ai/conversations').json()['id'] - with patch("app.services.ai_service.get_async_client", - return_value=_stub_client(AsyncMock(side_effect=RuntimeError("down")))): + with patch("app.services.ai_service.achat", + new=AsyncMock(side_effect=RuntimeError("down"))): response = self.client.post(f'/ai/conversations/{conversation_id}/messages', json={'message': 'febrile seizure'}) self.assertEqual(response.status_code, 502) diff --git a/backend/tests/test_article_ai.py b/backend/tests/test_article_ai.py index 13f8f5a..02b506b 100644 --- a/backend/tests/test_article_ai.py +++ b/backend/tests/test_article_ai.py @@ -70,8 +70,8 @@ class ArticleAiTests(unittest.TestCase): def test_draft_task_creates_and_refines_without_publishing(self): self.redis.set.return_value = True - ai = patch('app.services.ai_service.get_client').start().return_value.chat.completions.create - ai.return_value = Mock(choices=[Mock(message=Mock(content=json.dumps(DRAFT_RESPONSE)))]) + ai = patch('app.services.ai_service.chat').start() + ai.return_value = json.dumps(DRAFT_RESPONSE) with patch('app.tasks.quiz_tasks.SessionLocal', sessionmaker(bind=self.bank.engine)), \ patch('app.services.ai_service.get_model_for_task', return_value=('synthetic', None)): generate_article_draft('job-1', 3, 'Neonatal jaundice', '') @@ -92,7 +92,7 @@ class ArticleAiTests(unittest.TestCase): self.assertEqual(refreshed.slug, 'ai-draft-topic') # Invalid model section ids are replaced with valid hex ids. bad = dict(DRAFT_RESPONSE, sections=[{'id': 'bad-id', 'slug': 'bad', 'title': 'Bad', 'content': ''}]) - ai.return_value = Mock(choices=[Mock(message=Mock(content=json.dumps(bad)))]) + ai.return_value = json.dumps(bad) generate_article_draft('job-3', 3, 'Neonatal jaundice', 'Again', article.id) fixed = self.bank.db.query(Article).filter_by(id=article.id).one() self.assertTrue(all(re.fullmatch(r'[0-9a-f]{32}', s['id']) for s in fixed.sections), fixed.sections) diff --git a/backend/tests/test_login_links.py b/backend/tests/test_login_links.py new file mode 100644 index 0000000..b396e48 --- /dev/null +++ b/backend/tests/test_login_links.py @@ -0,0 +1,220 @@ +"""Signing in with a link 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 link +works once and not after that; 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_link import LoginLink +from app.routers import login_link +from app.services import email_service, login_links + + +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 LoginLinkTests(unittest.TestCase): + def setUp(self): + self.bank = fixtures.BuilderTests() + self.bank.setUp() + self.bank.owner.email = "owner@example.com" + self.bank.db.commit() + self.client = self.bank.client + self.client.app.include_router(login_link.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_link, "MIN_SECONDS", 0.01) + floor.start() + self.addCleanup(floor.stop) + + sent = patch.object(email_service, "send_login_link_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-link", json={"email": email}) + + def token_for(self, email="owner@example.com"): + 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 spend(self, token): + return self.client.post("/auth/login-link/consume", json={"token": token}) + + # ── 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_link.SENT}) + # And the only difference behind it is one that never reaches the caller. + self.assertEqual(self.bank.db.query(LoginLink).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_link, "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_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_link.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_link.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 links", refused.json()["detail"]) + # Nothing was issued on the refused attempt. + self.assertEqual(self.bank.db.query(LoginLink).count(), login_link.PER_ADDRESS_CALLS) + + def test_the_network_limit_is_the_looser_of_the_two(self): + self.assertGreater(login_link.PER_IP_CALLS, login_link.PER_ADDRESS_CALLS) + # Ask from one address until the per-network limit is the one that stops + # it — a different address each time, so the tighter limit cannot. + for n in range(login_link.PER_IP_CALLS): + self.assertEqual(self.ask(f"person{n}@example.com").status_code, 200) + refused = self.ask(f"person{login_link.PER_IP_CALLS}@example.com") + self.assertEqual(refused.status_code, 429) + self.assertIn("from this network", refused.json()["detail"]) + + # ── the link itself ──────────────────────────────────────────────── + + def test_a_link_signs_you_in_once_and_never_again(self): + token = self.token_for() + first = self.spend(token) + self.assertEqual(first.status_code, 200, first.text) + self.assertTrue(first.json()["access_token"]) + second = self.spend(token) + self.assertEqual(second.status_code, 400) + self.assertIn("no longer valid", second.json()["detail"]) + + def test_an_expired_link_is_refused(self): + token = self.token_for() + row = self.bank.db.query(LoginLink).one() + row.expires_at = datetime.utcnow() - timedelta(seconds=1) + self.bank.db.commit() + self.assertEqual(self.spend(token).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.token_for() + row = self.bank.db.query(LoginLink).one() + self.assertAlmostEqual((row.expires_at - row.created_at).total_seconds(), 15 * 60, delta=5) + + def test_a_second_request_retires_the_first_link(self): + stale = self.token_for() + fresh = self.token_for() + self.assertEqual(self.spend(stale).status_code, 400) + self.assertEqual(self.spend(fresh).status_code, 200) + + def test_a_token_that_was_never_issued_reads_the_same_as_a_spent_one(self): + token = self.token_for() + self.spend(token) + invented = self.spend("not-a-token-anybody-issued") + spent = self.spend(token) + self.assertEqual(invented.status_code, spent.status_code) + self.assertEqual(invented.json(), spent.json()) + + def test_the_table_holds_a_fingerprint_rather_than_the_token(self): + token = self.token_for() + row = self.bank.db.query(LoginLink).one() + self.assertNotIn(token, str(row.token_hash)) + self.assertEqual(row.token_hash, hashlib.sha256(token.encode()).hexdigest()) + + def test_the_mail_carries_the_address_the_name_and_the_token(self): + token = self.token_for() + self.assertEqual(self.sent.await_args.args[:2], ("owner@example.com", "Owner")) + self.assertTrue(token) + + # ── 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() + refused = self.spend(self.token_for()) + self.assertEqual(refused.status_code, 403) + self.assertIn("Email not verified", refused.json()["detail"]) + + def test_sso_only_closes_this_door_as_well_as_the_password_one(self): + 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.bank.db.query(LoginLink).count(), 0) + + +class ServiceTests(unittest.TestCase): + """The claim is a single UPDATE, so two holders of one token cannot both win.""" + + def setUp(self): + self.bank = fixtures.BuilderTests() + self.bank.setUp() + self.addCleanup(self.bank.tearDown) + + def test_only_one_of_two_simultaneous_claims_succeeds(self): + token = login_links.issue(self.bank.db, self.bank.owner) + self.assertIsNotNone(login_links.consume(self.bank.db, token)) + self.assertIsNone(login_links.consume(self.bank.db, token)) + + def test_rows_long_past_their_expiry_are_swept_on_the_next_request(self): + login_links.issue(self.bank.db, self.bank.owner) + stale = self.bank.db.query(LoginLink).one() + stale.expires_at = datetime.utcnow() - timedelta(days=3) + self.bank.db.commit() + login_links.issue(self.bank.db, self.bank.owner) + self.assertEqual(self.bank.db.query(LoginLink).count(), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_related_privacy.py b/backend/tests/test_related_privacy.py index 8e52443..a256f6b 100644 --- a/backend/tests/test_related_privacy.py +++ b/backend/tests/test_related_privacy.py @@ -2,7 +2,6 @@ from datetime import datetime from pathlib import Path from tempfile import TemporaryDirectory -from types import SimpleNamespace import unittest from unittest.mock import AsyncMock, Mock, patch @@ -46,9 +45,8 @@ class PrivacyTests(unittest.TestCase): self.can_see = patch.object(teach.vision_service, 'can_see', return_value=True).start() self.find_similar = teach._find_similar_questions self.similar = patch.object(teach, '_find_similar_questions', return_value=[]).start() - self.ai = AsyncMock(return_value=SimpleNamespace( - choices=[SimpleNamespace(message=SimpleNamespace(content='Tutor reply\n> Follow up'))])) - patch('app.services.ai_service.get_async_client').start().return_value.chat.completions.create = self.ai + self.ai = AsyncMock(return_value='Tutor reply\n> Follow up') + patch('app.services.ai_service.achat', new=self.ai).start() self.embedding = patch('app.services.embedding_service.embed_question').start() def tearDown(self): diff --git a/backend/tests/test_vision_fallback.py b/backend/tests/test_vision_fallback.py index ab819c0..bab2b52 100644 --- a/backend/tests/test_vision_fallback.py +++ b/backend/tests/test_vision_fallback.py @@ -17,6 +17,7 @@ from sqlalchemy.orm import sessionmaker from app.config import settings from app.models.ai_model_config import AIModelConfig from app.services import vision_service +from app.services.ai_service import ProxyError from app.services.vision_service import Image, VisionUnavailable @@ -26,9 +27,10 @@ def catalogue_rows(*pairs): for name, says in pairs]} -def stub_client(reply="A chest radiograph with a right lower lobe opacity."): - create = Mock(return_value=Mock(choices=[Mock(message=Mock(content=reply))])) - return Mock(**{"chat.completions.create": create}), create +def stub_chat(reply="A chest radiograph with a right lower lobe opacity."): + """`ai_service.chat` hands back the message text itself, so the stub is the + text — there is no envelope left to imitate.""" + return Mock(return_value=reply) class VisionFallbackTests(unittest.TestCase): @@ -65,8 +67,8 @@ class VisionFallbackTests(unittest.TestCase): def test_a_model_that_can_see_is_handed_the_image_itself(self): self.catalogue(("seeing-model", True)) - client, create = stub_client() - with patch.object(vision_service, "get_client", return_value=client): + create = stub_chat() + with patch.object(vision_service, "chat", create): parts, handoff = vision_service.image_context( self.db, [self.image], model_id="seeing-model") @@ -79,8 +81,8 @@ class VisionFallbackTests(unittest.TestCase): def test_a_model_that_cannot_see_gets_the_tool_model_s_description(self): self.catalogue(("blind-model", False), ("tool-vision", True)) self.tool_model() - client, create = stub_client() - with patch.object(vision_service, "get_client", return_value=client): + create = stub_chat() + with patch.object(vision_service, "chat", create): parts, handoff = vision_service.image_context( self.db, [self.image], model_id="blind-model", context="A 4-year-old…") @@ -109,8 +111,8 @@ class VisionFallbackTests(unittest.TestCase): def test_a_tool_model_that_cannot_see_either_is_refused_before_it_is_called(self): self.catalogue(("blind-model", False), ("also-blind", False)) self.tool_model("also-blind") - client, create = stub_client() - with patch.object(vision_service, "get_client", return_value=client): + create = stub_chat() + with patch.object(vision_service, "chat", create): with self.assertRaises(VisionUnavailable) as raised: vision_service.image_context(self.db, [self.image], model_id="blind-model") self.assertIn("also-blind", str(raised.exception)) @@ -119,9 +121,8 @@ class VisionFallbackTests(unittest.TestCase): def test_a_failing_tool_model_is_an_error_rather_than_a_text_only_answer(self): self.catalogue(("blind-model", False), ("tool-vision", True)) self.tool_model() - client = Mock() - client.chat.completions.create.side_effect = RuntimeError("upstream 500") - with patch.object(vision_service, "get_client", return_value=client): + create = Mock(side_effect=RuntimeError("upstream 500")) + with patch.object(vision_service, "chat", create): with self.assertRaises(VisionUnavailable) as raised: vision_service.image_context(self.db, [self.image], model_id="blind-model") self.assertIn("upstream 500", str(raised.exception)) @@ -162,37 +163,47 @@ class CapabilityLookupTests(unittest.TestCase): def test_a_model_the_catalogue_says_nothing_about_is_probed_once(self): response = Mock(json=Mock(return_value=catalogue_rows(("quiet", None)))) - client, create = stub_client("ok") + create = stub_chat("ok") with patch("httpx.get", return_value=response), \ - patch.object(vision_service, "get_client", return_value=client): + patch.object(vision_service, "chat", create): self.assertTrue(vision_service.can_see("quiet")) self.assertTrue(vision_service.can_see("quiet")) create.assert_called_once() self.assertEqual(create.call_args.kwargs["max_tokens"], 1) def test_a_refused_probe_is_remembered_and_a_broken_one_is_not(self): - class Refused(Exception): - status_code = 400 - + # The real error the proxy layer raises, not a stand-in: the probe sorts + # refusals by `status_code`, and that ProxyError carries one is the + # whole reason it exists. response = Mock(json=Mock(return_value=catalogue_rows(("quiet", None)))) - client = Mock() - client.chat.completions.create.side_effect = Refused("image input not supported") + create = Mock(side_effect=ProxyError(400, "image input not supported")) with patch("httpx.get", return_value=response), \ - patch.object(vision_service, "get_client", return_value=client): + patch.object(vision_service, "chat", create): self.assertFalse(vision_service.can_see("quiet")) self.assertFalse(vision_service.can_see("quiet")) - client.chat.completions.create.assert_called_once() + create.assert_called_once() # A timeout says nothing about the model, so nothing is kept: asking # again asks the model again. vision_service._probes.clear() - client.chat.completions.create.reset_mock() - client.chat.completions.create.side_effect = TimeoutError("proxy is down") + create.reset_mock() + create.side_effect = TimeoutError("proxy is down") with patch("httpx.get", return_value=response), \ - patch.object(vision_service, "get_client", return_value=client): + patch.object(vision_service, "chat", create): self.assertFalse(vision_service.can_see("quiet")) self.assertFalse(vision_service.can_see("quiet")) - self.assertEqual(client.chat.completions.create.call_count, 2) + self.assertEqual(create.call_count, 2) + + def test_a_5xx_probe_is_not_remembered_either(self): + # Same shape as a refusal, opposite meaning: the proxy broke, the model + # said nothing, so the verdict must not be cached. + response = Mock(json=Mock(return_value=catalogue_rows(("quiet", None)))) + create = Mock(side_effect=ProxyError(503, "upstream unavailable")) + with patch("httpx.get", return_value=response), \ + patch.object(vision_service, "chat", create): + self.assertFalse(vision_service.can_see("quiet")) + self.assertFalse(vision_service.can_see("quiet")) + self.assertEqual(create.call_count, 2) class TutorFigureTests(unittest.TestCase): @@ -232,8 +243,8 @@ class TutorFigureTests(unittest.TestCase): self.privacy.db.add(AIModelConfig(name="tool-vision", model_id="tool-vision", task="tool", is_active=True, is_default=True)) self.privacy.db.commit() - client, create = stub_client("A frontal chest radiograph.") - with patch.object(vision_service, "get_client", return_value=client): + create = stub_chat("A frontal chest radiograph.") + with patch.object(vision_service, "chat", create): response = self.chat() self.assertEqual(response.status_code, 200, response.text) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 30cda11..5b02796 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -54,6 +54,7 @@ const CoursesPage = lazyPage(() => import('./pages/CoursesPage')) const CourseDetailPage = lazyPage(() => import('./pages/CourseDetailPage')) const CourseEditorPage = lazyPage(() => import('./pages/CourseEditorPage')) const SsoCallbackPage = lazyPage(() => import('./pages/SsoCallbackPage')) +const SignInLinkPage = lazyPage(() => import('./pages/SignInLinkPage')) function LoadingFallback() { return
@@ -201,6 +202,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/pages/LoginCaptcha.test.jsx b/frontend/src/pages/LoginCaptcha.test.jsx index e7901d6..ca63ecf 100644 --- a/frontend/src/pages/LoginCaptcha.test.jsx +++ b/frontend/src/pages/LoginCaptcha.test.jsx @@ -23,7 +23,10 @@ afterEach(() => { document.getElementById('cap-widget-script')?.remove() }) function mount(Component) { render(} />Signed in successfully
} />) } -async function submitLogin() { +async function submitLogin({ revealPassword = false } = {}) { + // The standalone page leads with a link 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' })) 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') @@ -35,7 +38,7 @@ async function submitLogin() { it('logs in on the standalone page without rendering, loading or submitting a captcha', async () => { mount(LoginPage) - await submitLogin() + await submitLogin({ revealPassword: true }) // 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() diff --git a/frontend/src/pages/LoginLink.test.jsx b/frontend/src/pages/LoginLink.test.jsx new file mode 100644 index 0000000..de642c2 --- /dev/null +++ b/frontend/src/pages/LoginLink.test.jsx @@ -0,0 +1,133 @@ +import { StrictMode } from 'react' +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 SignInLinkPage from './SignInLinkPage' +import { AuthProvider } from '../context/AuthContext' +import api from '../api/client' + +const SENT = 'If that address has an account, a sign-in link is on its way.' + +beforeEach(() => { + vi.resetAllMocks() + localStorage.clear() + api.get.mockImplementation(url => Promise.resolve({ + data: url === '/auth/sso/config' ? { sso_enabled: false } + : url === '/auth/signup-policy' ? { registration_open: true } + : { id: 1, name: 'Test', role: 'user' }, + })) + api.post.mockResolvedValue({ data: { message: SENT } }) +}) + +function mountLogin() { + render( + + } /> + Signed in successfully} /> + + ) +} + +function mountLink(search = '?token=synthetic-link-token', strict = false) { + const tree = ( + + + } /> + Signed in successfully} /> + + + ) + render(strict ? {tree} : tree) +} + +it('offers a link by email first and asks for no password to send one', async () => { + mountLogin() + expect(screen.queryByLabelText('Password')).not.toBeInTheDocument() + await userEvent.type(screen.getByLabelText('Email'), 'owner@example.test') + await userEvent.click(screen.getByRole('button', { name: 'Continue with email' })) + expect(api.post).toHaveBeenCalledWith('/auth/login-link', { email: 'owner@example.test' }) + expect(await screen.findByText(SENT)).toBeInTheDocument() +}) + +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.' } }) + mountLogin() + await userEvent.type(screen.getByLabelText('Email'), 'nobody@example.test') + await userEvent.click(screen.getByRole('button', { name: 'Continue with email' })) + expect(await screen.findByText('A differently worded but equally uncommitted answer.')).toBeInTheDocument() + expect(screen.queryByText(/we sent|no account|not registered/i)).not.toBeInTheDocument() +}) + +it('shows the reason when a request is refused', async () => { + api.post.mockRejectedValue({ response: { status: 429, data: { detail: 'Too many sign-in links requested for that address. Please wait a few minutes.' } } }) + mountLogin() + await userEvent.type(screen.getByLabelText('Email'), 'owner@example.test') + await userEvent.click(screen.getByRole('button', { name: 'Continue with email' })) + expect(await screen.findByText(/Too many sign-in links/)).toBeInTheDocument() +}) + +it('keeps a password one click away for whoever has one', async () => { + api.post.mockResolvedValue({ data: { access_token: 'synthetic-login-token' } }) + mountLogin() + 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-link', expect.anything()) +}) + +it('leaves single sign-on the first thing on the page', async () => { + api.get.mockImplementation(url => Promise.resolve({ + data: url === '/auth/sso/config' ? { sso_enabled: true, sso_only: false, provider_name: 'Test SSO' } + : url === '/auth/signup-policy' ? { registration_open: true } + : { id: 1, name: 'Test', role: 'user' }, + })) + mountLogin() + const sso = await screen.findByRole('link', { name: 'Sign in with Test SSO' }) + const email = screen.getByRole('button', { name: 'Continue with email' }) + expect(sso.compareDocumentPosition(email) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() +}) + +it('offers registration only while the site is open to it', async () => { + api.get.mockImplementation(url => Promise.resolve({ + data: url === '/auth/sso/config' ? { sso_enabled: false } + : url === '/auth/signup-policy' ? { registration_open: false } + : { id: 1, name: 'Test', role: 'user' }, + })) + mountLogin() + expect(await screen.findByRole('button', { name: 'Continue with email' })).toBeInTheDocument() + await waitFor(() => expect(screen.queryByRole('link', { name: 'Sign up' })).not.toBeInTheDocument()) +}) + +it('spends a followed link once and signs the reader in', async () => { + api.post.mockResolvedValue({ data: { access_token: 'synthetic-link-session' } }) + // Rendered strictly, because that is what development does, and a link spent + // twice is a link that fails on arrival. + mountLink('?token=synthetic-link-token', true) + expect(await screen.findByText('Signed in successfully')).toBeInTheDocument() + expect(api.post).toHaveBeenCalledTimes(1) + expect(api.post).toHaveBeenCalledWith('/auth/login-link/consume', { token: 'synthetic-link-token' }) + expect(localStorage.getItem('token')).toBe('synthetic-link-session') +}) + +it('says a spent or expired link is no good and offers another', async () => { + api.post.mockRejectedValue({ response: { status: 400, data: { detail: 'That sign-in link is no longer valid. If you asked more than once, open the most recent email, or request a new link.' } } }) + mountLink() + expect(await screen.findByText(/no longer valid/)).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Back to sign in' })).toHaveAttribute('href', '/login') +}) + +it('does not call the API for a link with no token at all', async () => { + mountLink('') + expect(await screen.findByText(/incomplete/)).toBeInTheDocument() + expect(api.post).not.toHaveBeenCalled() +}) diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index e255d46..d58580d 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -3,15 +3,27 @@ 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 LINK_SENT = 'If that address has an account, a sign-in link is on its way.' + export default function LoginPage() { const [email, setEmail] = useState('') const [password, setPassword] = 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) + const [linkSent, setLinkSent] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) const [unverified, setUnverified] = useState(false) const [resendSent, setResendSent] = useState(false) const [resending, setResending] = useState(false) const [ssoConfig, setSsoConfig] = useState(null) + // 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 } = useAuth() const navigate = useNavigate() const searchParams = new URLSearchParams(window.location.search) @@ -19,24 +31,34 @@ export default function LoginPage() { useEffect(() => { api.get('/auth/sso/config').then(r => setSsoConfig(r.data)).catch(() => {}) + 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 handleSubmit = async (e) => { e.preventDefault() setError('') setUnverified(false) setLoading(true) try { - await login(email, password) - navigate('/') + if (withPassword) { + await login(email, password) + navigate('/') + } else { + const res = await api.post('/auth/login-link', { email }) + setLinkSent(res.data?.message || LINK_SENT) + } } catch (err) { - if (err.response?.status === 403) { + if (withPassword && err.response?.status === 403) { setUnverified(true) } else { - 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('Login failed') + failed(err, withPassword ? 'Login failed' : 'Could not send a sign-in link. Please try again.') } } finally { setLoading(false) @@ -55,6 +77,29 @@ export default function LoginPage() { } } + const usePassword = () => { setLinkSent(''); setError(''); setWithPassword(true) } + + if (linkSent) { + return ( +
+
+
📧
+

Check your email

+

{linkSent}

+

+ The link works once and expires in 15 minutes. Open it on any device. +

+ +
+ Have a password? +
+
+
+ ) + } + return (
@@ -96,20 +141,41 @@ export default function LoginPage() { setEmail(e.target.value)} required />
-
- - setPassword(e.target.value)} required /> -
+ {withPassword && ( +
+ + setPassword(e.target.value)} required /> +
+ )} -
- Forgot password? -
-
- Don't have an account? Sign up -
+ {withPassword ? ( +
+ + Forgot password? +
+ ) : ( +
+ {/* No mail to wait for if you already have a password. */} + +
+ )} + {/* Only where there is a door. Registration can be turned off + site-wide, and this offered a locked one — the only way to find + out was to fill the form in and be refused. */} + {policy?.registration_open !== false && ( +
+ Don't have an account? Sign up +
+ )} )}
diff --git a/frontend/src/pages/SignInLinkPage.jsx b/frontend/src/pages/SignInLinkPage.jsx new file mode 100644 index 0000000..60415cb --- /dev/null +++ b/frontend/src/pages/SignInLinkPage.jsx @@ -0,0 +1,54 @@ +import { useEffect, useRef, useState } from 'react' +import { useSearchParams, useNavigate, Link } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' +import api from '../api/client' + +/** + * Where a sign-in link lands. + * + * The link points here rather than straight at the API because spending it is + * a POST made from script: mail gateways and preview panes fetch every URL in + * a message, and a single-use token spent by a scanner is a dead link by the + * time its owner clicks it. + */ +export default function SignInLinkPage() { + const [searchParams] = useSearchParams() + const token = searchParams.get('token') + const navigate = useNavigate() + const { loginWithToken } = useAuth() + const [error, setError] = useState('') + // Asking twice spends the link and then fails on its own second request. + // React deliberately runs an effect twice in development, and state set + // inside the first run is not there yet when the second begins — so the + // guard has to be a ref. + const asked = useRef(false) + + useEffect(() => { + if (asked.current) return + asked.current = true + if (!token) { + setError('That link is incomplete. Request a new one.') + return + } + api.post('/auth/login-link/consume', { token }) + .then(res => loginWithToken(res.data.access_token)) + // Replaced rather than pushed: the token is in this URL, and there is no + // reason for the back button to bring it back. + .then(() => navigate('/', { replace: true })) + .catch(err => setError(err.response?.data?.detail + || 'That sign-in link could not be used. Request a new one.')) + }, [token]) + + if (!error) return
Signing you in...
+ + return ( +
+
+
🔗
+

This link didn't work

+

{error}

+ Back to sign in +
+
+ ) +}