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
88 lines
2.5 KiB
Python
88 lines
2.5 KiB
Python
import uuid
|
|
from typing import Annotated, Literal
|
|
|
|
from fastapi import APIRouter, Depends, Request, Response
|
|
from pydantic import BaseModel, ConfigDict
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.auth.deps import get_current_user
|
|
from app.auth.passwords import burn_verification_time, verify_password
|
|
from app.auth.sessions import (
|
|
COOKIE_NAME,
|
|
clear_session_cookie,
|
|
create_auth_session,
|
|
set_session_cookie,
|
|
)
|
|
from app.db import get_db
|
|
from app.errors import ApiError
|
|
from app.models import AuthSession, User, UserRole
|
|
|
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
|
|
|
|
class LoginRequest(BaseModel):
|
|
email: str
|
|
password: str
|
|
|
|
|
|
class UserOut(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: uuid.UUID
|
|
email: str
|
|
name: str
|
|
role: UserRole
|
|
department_id: uuid.UUID | None
|
|
# Pinned interface language, or null to follow the browser. Flows to the
|
|
# UI via /me, so no page needs its own preference fetch. Typed as the
|
|
# closed set the API accepts, so the generated client is precise too.
|
|
locale: Literal["de", "en"] | None = None
|
|
|
|
|
|
@router.post("/login")
|
|
async def login(
|
|
body: LoginRequest,
|
|
response: Response,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
) -> UserOut:
|
|
email = body.email.strip().lower()
|
|
user = (
|
|
await db.execute(select(User).where(User.email == email))
|
|
).scalar_one_or_none()
|
|
if user is None:
|
|
burn_verification_time()
|
|
raise ApiError(401, "Invalid email or password.", "invalid_credentials")
|
|
if not verify_password(user.password_hash, body.password):
|
|
raise ApiError(401, "Invalid email or password.", "invalid_credentials")
|
|
|
|
session = await create_auth_session(db, user)
|
|
await db.commit()
|
|
set_session_cookie(response, session)
|
|
return UserOut.model_validate(user)
|
|
|
|
|
|
@router.post("/logout", status_code=204)
|
|
async def logout(
|
|
request: Request,
|
|
response: Response,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
) -> None:
|
|
raw = request.cookies.get(COOKIE_NAME)
|
|
if raw is not None:
|
|
try:
|
|
session_id = uuid.UUID(raw)
|
|
except ValueError:
|
|
session_id = None
|
|
if session_id is not None:
|
|
session = await db.get(AuthSession, session_id)
|
|
if session is not None:
|
|
await db.delete(session)
|
|
await db.commit()
|
|
clear_session_cookie(response)
|
|
|
|
|
|
@router.get("/me")
|
|
async def me(user: Annotated[User, Depends(get_current_user)]) -> UserOut:
|
|
return UserOut.model_validate(user)
|