From c227faa76d47d1ac4a308c48f8a4fc814712980a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 19:16:27 +0000 Subject: [PATCH] Add email/password auth: actor-based deps and /auth routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deps: every request resolves to an Actor — a signed-in user (session token, hash-stored) or a legacy env token with its historical semantics. require_admin/require_shared_write now accept admin users. - /auth: status probe, first-run setup (first account = admin), login, logout, me, change-password, forgot/reset (one-shot links), and admin user management with invite links + a last-admin lockout guard. - Invite/reset links are emailed when SMTP is configured and always returned to the admin; forgot never reveals account existence. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019ws7xj5Ej623hh4GXQCYYR --- src/handler/api/app.py | 2 + src/handler/api/deps.py | 158 +++++++++++---- src/handler/api/routes/auth.py | 341 +++++++++++++++++++++++++++++++++ src/handler/api/schemas.py | 110 +++++++++++ 4 files changed, 574 insertions(+), 37 deletions(-) create mode 100644 src/handler/api/routes/auth.py diff --git a/src/handler/api/app.py b/src/handler/api/app.py index 7c279af..deb7748 100644 --- a/src/handler/api/app.py +++ b/src/handler/api/app.py @@ -17,6 +17,7 @@ from ..config import get_settings from .routes import ( agents, approvals, + auth, claude, commands, hosts, @@ -44,6 +45,7 @@ def create_app() -> FastAPI: def health() -> dict: return {"status": "ok"} + app.include_router(auth.router) app.include_router(projects.router) app.include_router(agents.router) app.include_router(interaction.router) diff --git a/src/handler/api/deps.py b/src/handler/api/deps.py index c2b33d2..3357c84 100644 --- a/src/handler/api/deps.py +++ b/src/handler/api/deps.py @@ -1,24 +1,43 @@ -"""Shared dependencies: bearer auth and a per-request DB connection. +"""Shared dependencies: bearer auth (user sessions + legacy env tokens) and a +per-request DB connection. -Auth is a single global token (README 3.3), compared in constant time. Shared-context -writes may require a separate higher-trust token (README 3.4), falling back to the -global token when unset. +Two kinds of callers hold a bearer token: + +- **Users** — email + password accounts (``/auth``). Their bearer is an opaque session + token minted at login; the database stores only its hash. A user is either an admin + (sees and manages everything) or a regular account, which sees *shared* resources + (owner NULL) plus its own — the per-user separation of projects, skills, and tools. +- **Legacy env tokens** — ``AUTH_TOKEN`` / ``SHARED_CONTEXT_WRITE_TOKEN`` / + ``ADMIN_TOKEN``, compared in constant time exactly as before user accounts existed. + They keep working for scripts/CI and as a break-glass credential, with their original + semantics: they see every resource, and the admin token passes the admin gates. + +Every request resolves to one :class:`Actor`; route handlers consult it for ownership +decisions (``visible_scope`` / ``can_edit``). """ from __future__ import annotations import secrets from collections.abc import Iterator +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import Connection +from .. import authn from ..config import Settings, get_settings +from ..db import repository as repo from ..db.engine import connection _bearer = HTTPBearer(auto_error=False) +# How stale a session's last_used_at may get before we write a fresh one (the dashboard +# polls every few seconds; a write per poll would be pure churn). +_TOUCH_INTERVAL = timedelta(minutes=5) + def db_conn() -> Iterator[Connection]: with connection() as conn: @@ -31,52 +50,117 @@ def _check(provided: str | None, expected: str) -> bool: return secrets.compare_digest(provided, expected) -def require_auth( +@dataclass(frozen=True) +class Actor: + """Who is making this request: a signed-in user or a legacy env token.""" + + kind: str # "user" | "token" + user_id: int | None = None + email: str | None = None + is_admin: bool = False + shared_write: bool = False # may write shared_context keys + + @property + def sees_all(self) -> bool: + """Admins and legacy tokens see every resource (tokens keep their historical + all-access semantics for scripts); regular users see shared + their own.""" + return self.is_admin or self.kind == "token" + + @property + def visible_scope(self): + """The ``visible_to`` argument for repository list functions.""" + return repo.VISIBLE_ALL if self.sees_all else self.user_id + + @property + def label(self) -> str: + """The ``requested_by`` audit label for commands this actor enqueues.""" + if self.kind == "user": + return f"user:{self.user_id}:{self.email}" + return "operator:web" + + def can_edit(self, owner_user_id: int | None) -> bool: + """Mutation rule for owned resources: admins (and the legacy admin token) edit + anything; a user edits what they own. Shared rows (owner NULL) are admin-managed.""" + if self.is_admin: + return True + if self.kind == "user": + return owner_user_id is not None and owner_user_id == self.user_id + return False + + def can_view(self, owner_user_id: int | None) -> bool: + if self.sees_all: + return True + return owner_user_id is None or owner_user_id == self.user_id + + +def get_actor( creds: HTTPAuthorizationCredentials | None = Depends(_bearer), settings: Settings = Depends(get_settings), -) -> None: + conn: Connection = Depends(db_conn), +) -> Actor: + """Resolve the request's bearer to an :class:`Actor` or raise 401.""" token = creds.credentials if creds else None - # The shared-context write and admin tokens are higher-trust, so they also grant - # normal access; a single request carries one bearer, and it should never be rejected - # for being the more privileged one. - valid = ( - _check(token, settings.auth_token) - or _check(token, settings.effective_shared_write_token) - or _check(token, settings.effective_admin_token) + if token is None: + raise _unauthorized() + + # Legacy env tokens first (cheap constant-time compares). Order matters for the + # historical fallbacks: with ADMIN_TOKEN unset it falls back to AUTH_TOKEN, so the + # plain token must come out admin — checking the admin value first guarantees that. + if _check(token, settings.effective_admin_token): + return Actor(kind="token", is_admin=True, shared_write=True) + if _check(token, settings.effective_shared_write_token): + return Actor(kind="token", shared_write=True) + if _check(token, settings.auth_token): + return Actor(kind="token") + + # Otherwise it may be a user session token (hash-stored). + token_hash = authn.hash_token(token) + row = repo.get_session_user(conn, token_hash) + if row is None: + raise _unauthorized() + last_used = row.get("session_last_used_at") + if last_used is None or datetime.now(UTC) - last_used > _TOUCH_INTERVAL: + repo.touch_auth_session(conn, token_hash) + return Actor( + kind="user", + user_id=row["id"], + email=row["email"], + is_admin=bool(row["is_admin"]), + shared_write=bool(row["is_admin"]), ) - if not valid: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="invalid or missing bearer token", - headers={"WWW-Authenticate": "Bearer"}, - ) -def require_shared_write( - creds: HTTPAuthorizationCredentials | None = Depends(_bearer), - settings: Settings = Depends(get_settings), -) -> None: - """Gate for shared_context writes — the one table every project implicitly trusts.""" - token = creds.credentials if creds else None - if not _check(token, settings.effective_shared_write_token): +def _unauthorized() -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid or missing bearer token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def require_auth(actor: Actor = Depends(get_actor)) -> Actor: + return actor + + +def require_shared_write(actor: Actor = Depends(get_actor)) -> Actor: + """Gate for shared_context writes — the one table every project implicitly trusts. + Admin users, the admin token, and the dedicated shared-write token qualify.""" + if not actor.shared_write: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="shared-context write requires the shared-context write token", + detail="shared-context write requires the shared-context write token or an admin", headers={"WWW-Authenticate": "Bearer"}, ) + return actor -def require_admin( - creds: HTTPAuthorizationCredentials | None = Depends(_bearer), - settings: Settings = Depends(get_settings), -) -> None: - """Gate for the web control surface: enqueuing control commands, project/host CRUD, - and credential-pointer edits. Requires specifically the admin token (which defaults to - the global token when ADMIN_TOKEN is unset).""" - token = creds.credentials if creds else None - if not _check(token, settings.effective_admin_token): +def require_admin(actor: Actor = Depends(get_actor)) -> Actor: + """Gate for the global control surface: git servers, the Claude account login, + permission overrides, and user management. Admin users or the admin token.""" + if not actor.is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="this action requires the admin token", + detail="this action requires an admin", headers={"WWW-Authenticate": "Bearer"}, ) + return actor diff --git a/src/handler/api/routes/auth.py b/src/handler/api/routes/auth.py new file mode 100644 index 0000000..75156b4 --- /dev/null +++ b/src/handler/api/routes/auth.py @@ -0,0 +1,341 @@ +"""User accounts: sign-in, first-run setup, password resets, and admin user management. + +The account model replaces "know the API key" for humans: + +- **First run**: with zero accounts, ``POST /auth/setup`` creates the first one and it + is the admin. The UI probes ``GET /auth/status`` (unauthenticated, boolean-only) to + decide whether to show the setup form or the sign-in form. +- **Everyone after that** is created by an admin (``POST /auth/users``), which mints a + one-shot **invite link**; the invitee sets their own password through it. With SMTP + configured the link is emailed; either way it is returned to the admin. +- **Password reset**: self-serve ``POST /auth/forgot`` emails a short-lived reset link + (silent about whether the address exists); an admin can also mint a link directly + for any user. ``POST /auth/reset`` spends either kind of link. + +Sessions are opaque bearer tokens (hash-stored, TTL from ``SESSION_TTL_DAYS``) used +exactly like the legacy env token — the client keeps calling with +``Authorization: Bearer …``. Legacy tokens stay valid for scripts/CI and break-glass. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy import Connection + +from ... import authn, emailer +from ...config import Settings, get_settings +from ...db import repository as repo +from ..deps import Actor, db_conn, get_actor, require_admin +from ..schemas import ( + AuthStatusOut, + ChangePasswordIn, + ForgotIn, + ForgotOut, + LoginIn, + MeOut, + ResetIn, + ResetLinkOut, + SessionOut, + SetupIn, + UserCreatedOut, + UserCreateIn, + UserOut, + UserUpdateIn, +) + +router = APIRouter(prefix="/auth", tags=["auth"]) + +# A throwaway hash so a login attempt against an unknown email costs the same scrypt +# work as one against a real account (no timing oracle on address existence). +_DUMMY_HASH = authn.hash_password("not-a-real-password") + + +def _user_out(row: dict) -> dict: + return {**row, "has_password": bool(row.get("password_hash"))} + + +def _new_session(conn: Connection, user: dict, settings: Settings) -> dict: + repo.purge_expired_sessions(conn) # piggybacked housekeeping — no scheduler needed + token = authn.new_token() + expires = datetime.now(UTC) + timedelta(days=settings.session_ttl_days) + repo.create_auth_session(conn, user["id"], authn.hash_token(token), expires) + return {"token": token, "user": _user_out(user)} + + +def _base_url(request: Request, settings: Settings) -> str: + base = settings.public_base_url.strip() or str(request.base_url) + return base.rstrip("/") + + +def _mint_link( + conn: Connection, request: Request, settings: Settings, user: dict, purpose: str +) -> str: + ttl_hours = ( + settings.invite_token_ttl_hours if purpose == "invite" else settings.reset_token_ttl_hours + ) + token = authn.new_token() + repo.create_auth_token( + conn, + user["id"], + authn.hash_token(token), + purpose, + datetime.now(UTC) + timedelta(hours=ttl_hours), + ) + return f"{_base_url(request, settings)}/reset?token={token}" + + +def _try_email(user: dict, subject: str, body: str, settings: Settings) -> bool: + if not emailer.configured(settings): + return False + try: + emailer.send(user["email"], subject, body, settings) + return True + except emailer.EmailError: + # The link is still returned/usable; delivery failure must not lose it. + return False + + +# ---- public (unauthenticated) ---------------------------------------------------------- + + +@router.get("/status", response_model=AuthStatusOut) +def auth_status(conn: Connection = Depends(db_conn)) -> dict: + return { + "initialized": repo.count_users(conn) > 0, + "smtp_configured": emailer.configured(get_settings()), + } + + +@router.post("/setup", response_model=SessionOut, status_code=status.HTTP_201_CREATED) +def setup(body: SetupIn, conn: Connection = Depends(db_conn)) -> dict: + """Create the first account — the admin. Refused once any account exists.""" + if repo.count_users(conn) > 0: + raise HTTPException( + status.HTTP_409_CONFLICT, + detail="already set up — sign in, or ask an admin to invite you", + ) + email = body.email.strip().lower() + if "@" not in email: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="invalid email address") + user = repo.create_user( + conn, email, password_hash=authn.hash_password(body.password), is_admin=True + ) + return _new_session(conn, user, get_settings()) + + +@router.post("/login", response_model=SessionOut) +def login(body: LoginIn, conn: Connection = Depends(db_conn)) -> dict: + user = repo.get_user_by_email(conn, body.email) + stored = user["password_hash"] if user else _DUMMY_HASH + if not authn.verify_password(body.password, stored) or user is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="invalid email or password") + if user["disabled"]: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="this account is disabled") + return _new_session(conn, user, get_settings()) + + +@router.post("/forgot", response_model=ForgotOut) +def forgot( + body: ForgotIn, request: Request, conn: Connection = Depends(db_conn) +) -> dict: + """Self-serve reset. Always answers ``ok`` — whether the address has an account is + not revealed. Without SMTP nothing can be sent; the UI tells the user to ask an + admin for a reset link instead.""" + settings = get_settings() + if not emailer.configured(settings): + return {"ok": True, "emailed": False} + user = repo.get_user_by_email(conn, body.email) + if user is not None and not user["disabled"] and user.get("password_hash"): + link = _mint_link(conn, request, settings, user, "reset") + _try_email( + user, + "Reset your Handler password", + "A password reset was requested for this address.\n\n" + f"Reset it here (link expires in {settings.reset_token_ttl_hours}h):\n{link}\n\n" + "If you didn't request this, you can ignore this email.", + settings, + ) + return {"ok": True, "emailed": True} + + +@router.post("/reset", response_model=SessionOut) +def reset(body: ResetIn, conn: Connection = Depends(db_conn)) -> dict: + """Spend a reset/invite link: set the password and sign the user in. Every other + session for the account is revoked — a reset means the old credential is suspect.""" + token_row = repo.consume_auth_token(conn, authn.hash_token(body.token)) + if token_row is None: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="this link is invalid, expired, or already used — request a new one", + ) + user = repo.get_user(conn, token_row["user_id"]) + if user is None or user["disabled"]: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="this account is disabled") + user = repo.update_user( + conn, user["id"], password_hash=authn.hash_password(body.password) + ) + repo.delete_user_sessions(conn, user["id"]) + return _new_session(conn, user, get_settings()) + + +# ---- authenticated self-service -------------------------------------------------------- + + +@router.get("/me", response_model=MeOut) +def me(actor: Actor = Depends(get_actor)) -> dict: + return { + "kind": actor.kind, + "user_id": actor.user_id, + "email": actor.email, + "is_admin": actor.is_admin, + } + + +@router.post("/logout") +def logout( + request: Request, + actor: Actor = Depends(get_actor), + conn: Connection = Depends(db_conn), +) -> dict: + """Revoke the presented session token. A no-op for legacy env tokens (they are + configuration, not sessions).""" + if actor.kind == "user": + auth_header = request.headers.get("authorization", "") + token = auth_header.split(" ", 1)[1] if " " in auth_header else "" + repo.delete_auth_session(conn, authn.hash_token(token)) + return {"ok": True} + + +@router.post("/change-password") +def change_password( + body: ChangePasswordIn, + request: Request, + actor: Actor = Depends(get_actor), + conn: Connection = Depends(db_conn), +) -> dict: + if actor.kind != "user": + raise HTTPException( + status.HTTP_400_BAD_REQUEST, detail="env tokens have no password to change" + ) + user = repo.get_user(conn, actor.user_id) + if user is None or not authn.verify_password(body.current_password, user["password_hash"]): + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="current password is incorrect") + repo.update_user(conn, user["id"], password_hash=authn.hash_password(body.new_password)) + # Sign out every *other* session; the one making this change keeps working. + auth_header = request.headers.get("authorization", "") + token = auth_header.split(" ", 1)[1] if " " in auth_header else "" + repo.delete_user_sessions(conn, user["id"], keep_token_hash=authn.hash_token(token)) + return {"ok": True} + + +# ---- admin user management ------------------------------------------------------------- + + +def _target_or_404(conn: Connection, user_id: int) -> dict: + user = repo.get_user(conn, user_id) + if user is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"user {user_id} not found") + return user + + +def _guard_last_admin(conn: Connection, target: dict, detail: str) -> None: + """Refuse a change that would leave zero sign-in-capable admins (lockout guard). + Only matters when the target currently counts as an active admin.""" + if ( + target["is_admin"] + and not target["disabled"] + and target.get("password_hash") + and repo.count_active_admins(conn, exclude_user_id=target["id"]) == 0 + ): + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=detail) + + +@router.get("/users", response_model=list[UserOut], dependencies=[Depends(require_admin)]) +def list_users(conn: Connection = Depends(db_conn)) -> list[dict]: + return [_user_out(u) for u in repo.list_users(conn)] + + +@router.post( + "/users", + response_model=UserCreatedOut, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_admin)], +) +def create_user( + body: UserCreateIn, request: Request, conn: Connection = Depends(db_conn) +) -> dict: + settings = get_settings() + email = body.email.strip().lower() + if "@" not in email: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="invalid email address") + if repo.get_user_by_email(conn, email) is not None: + raise HTTPException(status.HTTP_409_CONFLICT, detail=f"'{email}' already has an account") + user = repo.create_user(conn, email, password_hash=None, is_admin=body.is_admin) + link = _mint_link(conn, request, settings, user, "invite") + emailed = _try_email( + user, + "You've been invited to Handler", + "An admin created a Handler account for this address.\n\n" + f"Set your password here (link expires in {settings.invite_token_ttl_hours // 24} " + f"days):\n{link}\n", + settings, + ) + return {"user": _user_out(user), "invite_url": link, "emailed": emailed} + + +@router.patch( + "/users/{user_id}", response_model=UserOut, dependencies=[Depends(require_admin)] +) +def update_user( + user_id: int, body: UserUpdateIn, conn: Connection = Depends(db_conn) +) -> dict: + target = _target_or_404(conn, user_id) + fields = body.model_dump(exclude_unset=True) + if fields.get("is_admin") is False or fields.get("disabled") is True: + _guard_last_admin( + conn, target, "refused: this is the last active admin — promote someone else first" + ) + return _user_out(repo.update_user(conn, user_id, **fields)) + + +@router.delete("/users/{user_id}", dependencies=[Depends(require_admin)]) +def delete_user( + user_id: int, + actor: Actor = Depends(require_admin), + conn: Connection = Depends(db_conn), +) -> dict: + target = _target_or_404(conn, user_id) + if actor.kind == "user" and actor.user_id == user_id: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, detail="you can't delete your own account" + ) + _guard_last_admin( + conn, target, "refused: this is the last active admin — promote someone else first" + ) + repo.delete_user(conn, user_id) + return {"deleted": target["email"], "note": "their projects/skills/tools became shared"} + + +@router.post( + "/users/{user_id}/reset-link", + response_model=ResetLinkOut, + dependencies=[Depends(require_admin)], +) +def mint_reset_link( + user_id: int, request: Request, conn: Connection = Depends(db_conn) +) -> dict: + """Admin-minted reset link — the escape hatch when SMTP is off (or the email never + arrived). Uses invite semantics (longer TTL) for accounts that never set a password.""" + settings = get_settings() + target = _target_or_404(conn, user_id) + purpose = "invite" if not target.get("password_hash") else "reset" + link = _mint_link(conn, request, settings, target, purpose) + emailed = _try_email( + target, + "Reset your Handler password", + f"An admin generated a password {purpose} link for your account:\n{link}\n", + settings, + ) + return {"reset_url": link, "emailed": emailed} diff --git a/src/handler/api/schemas.py b/src/handler/api/schemas.py index f8b9594..84d280b 100644 --- a/src/handler/api/schemas.py +++ b/src/handler/api/schemas.py @@ -104,6 +104,8 @@ class ProjectOut(BaseModel): root_dir: str git_remote: str | None = None credential_ref: str | None = None + # Owning user account; null = shared/legacy (visible to everyone, admin-managed). + owner_user_id: int | None = None created_at: datetime @@ -409,6 +411,7 @@ class ClaudeSkillOut(BaseModel): # Relative paths of auxiliary files (references/, scripts/, …) captured by the # install-from-prompt import; synced alongside SKILL.md, read-only over the API. files: list[str] = Field(default_factory=list) + owner_user_id: int | None = None created_at: datetime updated_at: datetime @@ -472,6 +475,7 @@ class ClaudeConnectorOut(BaseModel): url: str | None = None headers: dict[str, str] | None = None enabled: bool + owner_user_id: int | None = None created_at: datetime @@ -521,6 +525,7 @@ class ClaudePluginOut(BaseModel): marketplace: str marketplace_repo: str enabled: bool + owner_user_id: int | None = None created_at: datetime @@ -587,6 +592,7 @@ class ClaudeModelOut(BaseModel): enabled: bool # The key never leaves the server; this says whether one is stored. has_api_key: bool = False + owner_user_id: int | None = None created_at: datetime @@ -680,3 +686,107 @@ class MemoryGraphOut(BaseModel): notes: list[MemoryNoteOut] links: list[MemoryLinkOut] + + +# ---- user accounts & sessions (``/auth``) ---------------------------------------------- + + +class AuthStatusOut(BaseModel): + """Unauthenticated bootstrap probe: does the login page show a sign-in form or the + first-run setup form, and can the deployment send email?""" + + initialized: bool # any user account exists + smtp_configured: bool + + +class UserOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + email: str + is_admin: bool + disabled: bool + # False until an invited user sets their password through the invite link. + has_password: bool = False + created_at: datetime + + +class SetupIn(BaseModel): + """First-run: create the very first account, which becomes the admin.""" + + email: str = Field(min_length=3, max_length=254) + password: str = Field(min_length=8, max_length=1024) + + +class LoginIn(BaseModel): + email: str = Field(min_length=3, max_length=254) + password: str = Field(min_length=1, max_length=1024) + + +class SessionOut(BaseModel): + """A fresh bearer session token plus who it belongs to.""" + + token: str + user: UserOut + + +class MeOut(BaseModel): + """Who the presented bearer resolves to. Legacy env tokens have no user identity — + ``kind == "token"`` with null user fields.""" + + kind: str # "user" | "token" + user_id: int | None = None + email: str | None = None + is_admin: bool + + +class ChangePasswordIn(BaseModel): + current_password: str = Field(min_length=1, max_length=1024) + new_password: str = Field(min_length=8, max_length=1024) + + +class ForgotIn(BaseModel): + email: str = Field(min_length=3, max_length=254) + + +class ForgotOut(BaseModel): + """Always ``ok`` — whether the address has an account is deliberately not revealed.""" + + ok: bool = True + emailed: bool # False when SMTP is not configured (ask an admin for a reset link) + + +class ResetIn(BaseModel): + """Complete a reset or invite link: spend the one-shot token, set the password.""" + + token: str = Field(min_length=1, max_length=256) + password: str = Field(min_length=8, max_length=1024) + + +class UserCreateIn(BaseModel): + """Admin creates an account; the new user sets their password via the invite link.""" + + email: str = Field(min_length=3, max_length=254) + is_admin: bool = False + + +class UserCreatedOut(BaseModel): + user: UserOut + # The invite link is always returned (the admin can hand it over out-of-band); + # ``emailed`` says whether it was also delivered by SMTP. + invite_url: str + emailed: bool + + +class UserUpdateIn(BaseModel): + """Admin edits; omit a field to leave it unchanged.""" + + is_admin: bool | None = None + disabled: bool | None = None + + +class ResetLinkOut(BaseModel): + """An admin-minted reset (or invite) link for a user.""" + + reset_url: str + emailed: bool