Self-hosted knowledge management for SMEs: a split-screen Markdown editor whose sections an LLM refines while you write, and RAG question answering over the documents that result. FastAPI + Postgres/pgvector on the back, SvelteKit on the front, everything OpenAI-compatible and self-hostable. Squashed into a single commit; the development history stays local. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
137 lines
4.7 KiB
Python
137 lines
4.7 KiB
Python
"""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,
|
|
)
|