"""Self-service account actions. Separate from `auth.py` (login/logout/me) and from `admin.py`: this is what a user may change about themselves. """ import logging import uuid from typing import Annotated, Literal from fastapi import APIRouter, Depends from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.deps import get_current_auth_session, get_current_user from app.auth.passwords import hash_password, verify_password from app.auth.sessions import revoke_user_sessions from app.db import get_db from app.errors import ApiError from app.models import AuthSession, Document, DocumentStatus, Template, User router = APIRouter(prefix="/account", tags=["account"]) logger = logging.getLogger("pablan.account") class LocalePreference(BaseModel): """The languages the interface ships in — the frontend bundles must cover exactly these.""" # null = follow the browser's Accept-Language again. locale: Literal["de", "en"] | None = None class PasswordChange(BaseModel): current_password: str = Field(min_length=1, max_length=200) new_password: str = Field(min_length=8, max_length=200) @router.post("/password", status_code=204) async def change_password( body: PasswordChange, user: Annotated[User, Depends(get_current_user)], session: Annotated[AuthSession, Depends(get_current_auth_session)], db: Annotated[AsyncSession, Depends(get_db)], ) -> None: """Change your own password, proving the current one first. Every other session of this user is revoked — a password change is how someone reacts to a suspected compromise, so other devices must lose access. The session doing the change survives, otherwise the user is thrown out of the app they are standing in. """ if not verify_password(user.password_hash, body.current_password): raise ApiError( 403, "Current password is incorrect.", "invalid_current_password" ) user.password_hash = hash_password(body.new_password) await revoke_user_sessions(db, user.id, keep_session_id=session.id) await db.commit() # Metadata only — never the password, not even its length. logger.info("password changed", extra={"event": "password_change"}) @router.put("/locale", status_code=204) async def set_locale( body: LocalePreference, user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ) -> None: """Pin the interface language, or clear it to follow the browser again. The backend only stores the choice — it never renders UI-language strings (see docs/architecture.md); the frontend does the translating. """ user.locale = body.locale await db.commit() # The starter catalog's blueprint about a PERSON (role, specialities, who to # ask) rather than a topic. What "the document about you" is made from, named # once here so the frontend does not have to know a blueprint id. PERSONAL_BLUEPRINT = "person" class PersonalDocument(BaseModel): """The caller's own document about themselves. Either they wrote one — then it is opened and edited like any other document — or they have not, and `template_id` says what to start it from. Both are null when the blueprint is not in this instance and nothing was written yet; the frontend falls back to the ordinary template picker. """ document_id: uuid.UUID | None = None title: str | None = None status: DocumentStatus | None = None template_id: uuid.UUID | None = None @router.get("/document") async def personal_document( user: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ) -> PersonalDocument: """What the profile page needs to show: your document about yourself, or the way to start it. Self-scoped, and authorship is the whole rule — a document someone else wrote about you is not this.""" document = ( await db.execute( select(Document) .where( Document.author_id == user.id, Document.meta["template"].astext == PERSONAL_BLUEPRINT, ) # The newest, if a second one was ever started. .order_by(Document.created_at.desc()) .limit(1) ) ).scalar_one_or_none() template_id = ( await db.execute( select(Template.id).where( Template.config["id"].astext == PERSONAL_BLUEPRINT ) ) ).scalar_one_or_none() if document is None: return PersonalDocument(template_id=template_id) return PersonalDocument( document_id=document.id, title=document.title, status=document.status, template_id=template_id, )