pdf-quiz-generator/backend/app/schemas/auth.py
Daniel e2919e73c2
Some checks failed
Tests / backend (push) Failing after 8s
Tests / frontend (push) Failing after 31s
Tests / e2e (push) Failing after 42s
feat: no passwords here — sign-in belongs to the provider
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
2026-09-13 15:50:38 +02:00

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)