pdf-quiz-generator/backend/app/schemas/auth.py
Daniel cf2f42975e
Some checks failed
Tests / backend (push) Failing after 8s
Tests / frontend (push) Successful in 33s
Tests / e2e (push) Failing after 33s
feat: no invite codes and no email sign-in codes — that is the provider's job
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
2026-09-13 13:56:48 +02:00

101 lines
3 KiB
Python

from datetime import datetime
from pydantic import BaseModel, EmailStr, Field
class UserCreate(BaseModel):
email: EmailStr
password: str
name: str
#: Solved hCaptcha challenge. Absent when the site has no secret configured.
captcha_token: str | None = None
class UserResponse(BaseModel):
id: int
email: str
name: str
role: str
is_unthrottled: int = 0
created_at: datetime
# Whether there is one at all, never anything about it. Settings has to say
# "Set a password" or "Change password", and it cannot tell from the outside.
has_password: bool = False
# What the role *means*, computed once here rather than in every page that
# asks. The interface had been checking `user.is_moderator` for months on a
# payload that has never carried it, so every moderator-only control was
# hidden from moderators — including the AI draft panel, which is why
# "Draft with AI" appeared to do nothing.
is_moderator: bool = False
is_admin: bool = False
class Config:
from_attributes = True
@staticmethod
def of(user) -> "UserResponse":
return UserResponse(**{
"id": user.id, "email": user.email, "name": user.name, "role": user.role,
"is_unthrottled": user.is_unthrottled or 0, "created_at": user.created_at,
"has_password": bool(user.hashed_password),
"is_moderator": bool(user.is_moderator), "is_admin": bool(user.is_admin),
})
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
#: Seconds, so a client can schedule its own refresh rather than waiting to
#: be told no. Absent means "we did not say" — not "it never expires".
expires_in: int | None = None
#: Only when one was asked for. A browser does not need it: it has a
#: session it can renew by asking the person again. An app does.
refresh_token: str | None = None
class LoginRequest(BaseModel):
email: EmailStr
password: str
#: An app asks for a refresh token; the web app does not, so nothing
#: long-lived is minted for a browser that will never use it.
refresh: bool = False
#: How the client describes itself, shown to the person in their list of
#: sessions. "PedsHub for iPhone", not a user agent string.
device: str | None = Field(default=None, max_length=120)
class RefreshRequest(BaseModel):
refresh_token: str
class LogoutRequest(BaseModel):
#: Ends this session. Omit it and, with `everywhere`, all of them.
refresh_token: str | None = None
everywhere: bool = False
class UserUpdateRole(BaseModel):
role: str
class UserUpdateMe(BaseModel):
name: str | None = None
current_password: str | None = None
new_password: str | None = None
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str
new_password: str
class SsoExchangeRequest(BaseModel):
"""The one-time code the SSO redirect leaves in the address bar."""
code: str = Field(min_length=8, max_length=200)