from __future__ import annotations from datetime import datetime, timezone from typing import Optional from sqlmodel import Field, SQLModel def _now() -> datetime: return datetime.now(timezone.utc) class User(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) username: str = Field(index=True, unique=True) hashed_password: str role: str = Field(default="user") # "admin" | "user" is_active: bool = Field(default=True) created_at: datetime = Field(default_factory=_now) #: Bumped whenever this account's authority changes — password, role or #: active flag. Every token carries the value it was minted with, so a #: bump makes all outstanding tokens for this user fail their next check. #: Without it a password reset left the old tokens usable for their full #: lifetime (up to 30 days for a refresh token). token_version: int = Field(default=1) #: Subject claim of the identity provider this account signs in through. #: The only identifier a provider promises is stable, so it — not the #: username — is what an OIDC login matches on. oidc_subject: Optional[str] = Field(default=None, index=True) @property def oidc(self) -> bool: """Read by ``UserRead`` so the UI can label the account. Not a column.""" return bool(self.oidc_subject) # --- API schemas --- class UserRead(SQLModel): id: int username: str role: str is_active: bool #: True when this account is linked to the identity provider. oidc: bool = False class UserCreate(SQLModel): username: str password: str role: str = "admin" class UserUpdate(SQLModel): password: Optional[str] = None role: Optional[str] = None is_active: Optional[bool] = None class LoginRequest(SQLModel): username: str password: str class TokenPair(SQLModel): """Login/refresh response. ``refresh_token`` is optional in the body: the API sets it as an httpOnly cookie, and browsers never need (or should) see it. It is still returned when the caller opts in with ``?in_body=true`` so scripted clients that cannot hold a cookie jar keep working. """ access_token: str token_type: str = "bearer" refresh_token: Optional[str] = None class RefreshRequest(SQLModel): """Body for ``/api/auth/refresh``. Optional — the cookie is preferred.""" refresh_token: Optional[str] = None