The account settings offered "Change password", and there is no password to change: accounts live at PedsHub SSO, which is also where a passkey, an address or a second factor is set. A form that writes a credential nothing checks is worse than no form — it invites somebody to secure their account by a route that does not secure it. The panel now keeps the display name and points at the provider for the rest. Retired with it: GET /auth/verify-email, POST /auth/resend-verification, POST /auth/forgot-password, POST /auth/reset-password, the new_password branch of PUT /auth/me, the two schemas behind them, and the three pages — VerifyEmailPage, ForgotPasswordPage, ResetPasswordPage — with their routes and the links into them. An account with no password cannot forget one. POST /auth/login stays, still refused while sso_only is set. It is the way back in if the provider is ever unreachable, together with the DEFAULT_ADMIN_EMAIL seed at startup, and removing it would leave no door at all on a bad day. The test that walked five password doors now walks that one and asserts the other four answer 404 rather than 403 — gone, not guarded. Verified live: all four endpoints 404, and the account panel shows a name field and "Manage your account ↗" to sso.pedshub.com/if/user/#/settings, with no password field anywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
82 lines
2.6 KiB
Python
82 lines
2.6 KiB
Python
from datetime import datetime
|
|
|
|
from pydantic import BaseModel, EmailStr, Field
|
|
|
|
|
|
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
|
|
|
|
|
|
class SsoExchangeRequest(BaseModel):
|
|
"""The one-time code the SSO redirect leaves in the address bar."""
|
|
|
|
code: str = Field(min_length=8, max_length=200)
|
|
|
|
|