"""Schemas for /accounts/me/security — session-policy management. See docs/plans/2026-05-13-session-expiration-policy.md §4.7 and §4.11. """ from datetime import datetime from typing import Literal, Optional from uuid import UUID from pydantic import BaseModel, Field class ActiveUser(BaseModel): """One row in the active-users list on GET /accounts/me/security. Rendered as 'name (email) · logged in 2d ago' on the Account Security page. `last_login_at` reflects the last successful sign-in, not the last refresh-token use — that requires the deferred refresh_tokens.last_used_at follow-up (see plan §9). """ user_id: UUID name: str email: str last_login_at: Optional[datetime] = None class SessionPolicyResponse(BaseModel): """GET /accounts/me/security — the policy in effect for this account. Surfaces both the override (which may be NULL) and the effective value (after defaults applied) so the frontend can show the current state without re-implementing the defaults logic. """ # Per-account override values, NULL = "use system default." idle_minutes: Optional[int] = Field( default=None, description="Account override; NULL means use the system default.", ) absolute_minutes: Optional[int] = Field(default=None) # Effective values after defaults applied (always non-NULL). effective_idle_minutes: int effective_absolute_minutes: int # System-imposed bounds for the Custom-preset form inputs. idle_minutes_min: int idle_minutes_max: int absolute_minutes_min: int absolute_minutes_max: int # Active sessions in this account — users with at least one un-revoked # refresh token. Drives the Active Sessions section in the UI. active_users: list[ActiveUser] = Field(default_factory=list) class SessionPolicyUpdateRequest(BaseModel): """PATCH /accounts/me/security — set or clear the per-account override. Pass `null` for either field to clear the override and fall back to the system default. Both bounds checks and the idle <= absolute invariant are validated against the *effective* values at the endpoint, since the DB CHECK constraint only covers the both-set case. """ idle_minutes: Optional[int] = None absolute_minutes: Optional[int] = None class RevokeSessionsRequest(BaseModel): """POST /accounts/me/security/revoke-sessions — bulk-revoke refresh tokens.""" scope: Literal["all", "others"] = "all" class RevokeSessionsResponse(BaseModel): revoked_count: int