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
101 lines
3 KiB
Python
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)
|
|
|
|
|