Initial Claude implementation

This commit is contained in:
Ian Roddis committed 2026-06-22 14:26:44 -03:00
commit e2d3ab9c2a
63 files changed
+3930

No files matched your search

View File
Whitespace-only changes.
View File
Whitespace-only changes.
+82
View File
@@ -0,0 +1,82 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import role as role_crud
from app.crud import token as token_crud
from app.crud import user as user_crud
from app.deps import get_current_user
from app.models.user import User
from app.schemas.token import LogoutRequest, Token, TokenRefreshRequest
from app.schemas.user import RegisterRequest, UserRead
from app.services.auth import RefreshError, issue_token_pair, rotate_refresh_token
router = APIRouter()
DEFAULT_USER_ROLE = "user"
@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
if await user_crud.get_by_email(db, payload.email):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
)
default_role = await role_crud.get_by_name(db, DEFAULT_USER_ROLE)
role_ids = [default_role.id] if default_role else []
user = await user_crud.create(
db,
email=payload.email,
password=payload.password,
full_name=payload.full_name,
role_ids=role_ids,
)
return user
@router.post("/login", response_model=Token)
async def login(
form: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
):
# OAuth2 form uses ``username``; we treat it as the email.
user = await user_crud.authenticate(db, form.username, form.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
access, refresh = await issue_token_pair(db, user)
return Token(access_token=access, refresh_token=refresh)
@router.post("/refresh", response_model=Token)
async def refresh(payload: TokenRefreshRequest, db: AsyncSession = Depends(get_db)):
try:
access, refresh, _ = await rotate_refresh_token(db, payload.refresh_token)
except RefreshError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)
) from exc
return Token(access_token=access, refresh_token=refresh)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
payload: LogoutRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if payload.refresh_token:
try:
from app.core.security import decode_token
jti = decode_token(payload.refresh_token).get("jti")
if jti:
await token_crud.revoke_by_jti(db, jti)
except Exception: # noqa: BLE001 - best-effort revocation of a single token
pass
else:
await token_crud.revoke_all_for_user(db, user.id)
+81
View File
@@ -0,0 +1,81 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import role as role_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.rbac import PermissionRead, RoleCreate, RoleRead, RoleUpdate
router = APIRouter()
@router.get("", response_model=list[RoleRead])
async def list_roles(
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("roles:read")),
):
return await role_crud.list_roles(db)
@router.post("", response_model=RoleRead, status_code=status.HTTP_201_CREATED)
async def create_role(
payload: RoleCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("roles:write")),
):
if await role_crud.get_by_name(db, payload.name):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Role already exists"
)
return await role_crud.create(
db,
name=payload.name,
description=payload.description,
permission_ids=payload.permission_ids,
)
@router.get("/permissions", response_model=list[PermissionRead])
async def list_permissions(
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("roles:read")),
):
return await role_crud.list_permissions(db)
@router.get("/{role_id}", response_model=RoleRead)
async def get_role(
role_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("roles:read")),
):
role = await role_crud.get(db, role_id)
if role is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
return role
@router.patch("/{role_id}", response_model=RoleRead)
async def update_role(
role_id: int,
payload: RoleUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("roles:write")),
):
role = await role_crud.get(db, role_id)
if role is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
return await role_crud.update(db, role, payload.model_dump(exclude_unset=True))
@router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_role(
role_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("roles:write")),
):
role = await role_crud.get(db, role_id)
if role is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
await role_crud.delete(db, role)
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
from app.api import auth, roles, users
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(roles.router, prefix="/roles", tags=["roles"])
+82
View File
@@ -0,0 +1,82 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import user as user_crud
from app.deps import get_current_user, require_permission
from app.models.user import User
from app.schemas.user import UserCreate, UserRead, UserUpdate
router = APIRouter()
@router.get("/me", response_model=UserRead)
async def read_me(user: User = Depends(get_current_user)):
return user
@router.get("", response_model=list[UserRead])
async def list_users(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("users:read")),
):
return await user_crud.list_users(db, skip=skip, limit=limit)
@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def create_user(
payload: UserCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("users:write")),
):
if await user_crud.get_by_email(db, payload.email):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
)
return await user_crud.create(
db,
email=payload.email,
password=payload.password,
full_name=payload.full_name,
is_superuser=payload.is_superuser,
role_ids=payload.role_ids,
)
@router.get("/{user_id}", response_model=UserRead)
async def get_user(
user_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("users:read")),
):
user = await user_crud.get(db, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user
@router.patch("/{user_id}", response_model=UserRead)
async def update_user(
user_id: int,
payload: UserUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("users:write")),
):
user = await user_crud.get(db, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return await user_crud.update(db, user, payload.model_dump(exclude_unset=True))
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("users:delete")),
):
user = await user_crud.get(db, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
await user_crud.delete(db, user)
View File
Whitespace-only changes.
+52
View File
@@ -0,0 +1,52 @@
"""Application configuration.
Settings are read from environment variables first, then from a `.env` file.
Environment variables always take precedence over the file.
"""
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
)
# General
app_name: str = "FastAPI Skeleton"
debug: bool = False
# Security / JWT
secret_key: str = "change-me-please-use-a-long-random-string"
algorithm: str = "HS256"
access_token_expire_minutes: int = 15
refresh_token_expire_days: int = 7
# Database — single URL drives SQLite or Postgres.
# SQLite: sqlite+aiosqlite:///./app.db
# Postgres: postgresql+asyncpg://user:pass@host:5432/dbname
database_url: str = "sqlite+aiosqlite:///./app.db"
auto_create_tables: bool = True
# Web / cookies (the HTMX UI stores the access token in an httpOnly cookie)
cookie_name: str = "access_token"
cookie_secure: bool = False
# CORS
cors_origins: list[str] = ["*"]
# Bootstrap superuser (used by scripts/seed.py)
first_superuser_email: str = "admin@example.com"
first_superuser_password: str = "changethis"
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+46
View File
@@ -0,0 +1,46 @@
"""Async SQLAlchemy engine, session factory, and declarative base.
Works with SQLite (aiosqlite) or Postgres (asyncpg) depending on DATABASE_URL.
"""
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
class Base(DeclarativeBase):
"""Declarative base shared by all models."""
engine = create_async_engine(settings.database_url, echo=settings.debug, future=True)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""FastAPI dependency yielding a request-scoped session."""
async with AsyncSessionLocal() as session:
yield session
async def init_db() -> None:
"""Create all tables. Convenient for dev/SQLite and tests.
In production prefer Alembic migrations (``alembic upgrade head``).
"""
# Import models so they register with Base.metadata before create_all.
import app.models # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+72
View File
@@ -0,0 +1,72 @@
"""Password hashing and JWT token helpers."""
import hashlib
import uuid
from datetime import UTC, datetime, timedelta
import bcrypt
import jwt
from app.core.config import settings
ACCESS_TOKEN_TYPE = "access"
REFRESH_TOKEN_TYPE = "refresh"
# --- Passwords ---------------------------------------------------------------
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(password: str, hashed: str) -> bool:
try:
return bcrypt.checkpw(password.encode(), hashed.encode())
except ValueError:
return False
# --- JWT ---------------------------------------------------------------------
def _create_token(
subject: str | int,
expires_delta: timedelta,
token_type: str,
) -> tuple[str, dict]:
now = datetime.now(UTC)
payload: dict = {
"sub": str(subject),
"iat": now,
"exp": now + expires_delta,
"type": token_type,
"jti": uuid.uuid4().hex,
}
token = jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
return token, payload
def create_access_token(subject: str | int) -> str:
token, _ = _create_token(
subject,
timedelta(minutes=settings.access_token_expire_minutes),
ACCESS_TOKEN_TYPE,
)
return token
def create_refresh_token(subject: str | int) -> tuple[str, dict]:
"""Return ``(token, payload)`` so the caller can persist jti/exp for revocation."""
return _create_token(
subject,
timedelta(days=settings.refresh_token_expire_days),
REFRESH_TOKEN_TYPE,
)
def decode_token(token: str) -> dict:
"""Decode and validate a JWT. Raises ``jwt.PyJWTError`` on failure."""
return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
def hash_token(token: str) -> str:
"""Stable hash used to store refresh tokens without keeping the raw value."""
return hashlib.sha256(token.encode()).hexdigest()
View File
Whitespace-only changes.
+86
View File
@@ -0,0 +1,86 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.permission import Permission
from app.models.role import Role
async def get(db: AsyncSession, role_id: int) -> Role | None:
return await db.get(Role, role_id)
async def get_by_name(db: AsyncSession, name: str) -> Role | None:
result = await db.execute(select(Role).where(Role.name == name))
return result.scalar_one_or_none()
async def list_roles(db: AsyncSession) -> list[Role]:
result = await db.execute(select(Role).order_by(Role.id))
return list(result.scalars().all())
async def _resolve_permissions(db: AsyncSession, ids: list[int]) -> list[Permission]:
if not ids:
return []
result = await db.execute(select(Permission).where(Permission.id.in_(ids)))
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
name: str,
description: str | None = None,
permission_ids: list[int] | None = None,
) -> Role:
role = Role(
name=name,
description=description,
permissions=await _resolve_permissions(db, permission_ids or []),
)
db.add(role)
await db.commit()
await db.refresh(role)
return role
async def update(db: AsyncSession, role: Role, data: dict) -> Role:
if "permission_ids" in data and data["permission_ids"] is not None:
role.permissions = await _resolve_permissions(db, data.pop("permission_ids"))
else:
data.pop("permission_ids", None)
for field, value in data.items():
if value is not None:
setattr(role, field, value)
await db.commit()
await db.refresh(role)
return role
async def delete(db: AsyncSession, role: Role) -> None:
await db.delete(role)
await db.commit()
# --- Permissions -------------------------------------------------------------
async def list_permissions(db: AsyncSession) -> list[Permission]:
result = await db.execute(select(Permission).order_by(Permission.name))
return list(result.scalars().all())
async def get_permission_by_name(db: AsyncSession, name: str) -> Permission | None:
result = await db.execute(select(Permission).where(Permission.name == name))
return result.scalar_one_or_none()
async def create_permission(
db: AsyncSession, *, name: str, description: str | None = None
) -> Permission:
perm = Permission(name=name, description=description)
db.add(perm)
await db.commit()
await db.refresh(perm)
return perm
+66
View File
@@ -0,0 +1,66 @@
from datetime import UTC, datetime
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_token
from app.models.refresh_token import RefreshToken
async def store(
db: AsyncSession,
*,
user_id: int,
jti: str,
raw_token: str,
expires_at: datetime,
) -> RefreshToken:
record = RefreshToken(
jti=jti,
token_hash=hash_token(raw_token),
user_id=user_id,
expires_at=expires_at,
)
db.add(record)
await db.commit()
await db.refresh(record)
return record
async def get_active(
db: AsyncSession, *, jti: str, raw_token: str
) -> RefreshToken | None:
"""Return the token row only if it exists, matches, is not revoked, not expired."""
result = await db.execute(select(RefreshToken).where(RefreshToken.jti == jti))
record = result.scalar_one_or_none()
if record is None or record.revoked:
return None
if record.token_hash != hash_token(raw_token):
return None
expires_at = record.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC)
if expires_at < datetime.now(UTC):
return None
return record
async def revoke(db: AsyncSession, record: RefreshToken) -> None:
record.revoked = True
await db.commit()
async def revoke_by_jti(db: AsyncSession, jti: str) -> None:
await db.execute(
update(RefreshToken).where(RefreshToken.jti == jti).values(revoked=True)
)
await db.commit()
async def revoke_all_for_user(db: AsyncSession, user_id: int) -> None:
await db.execute(
update(RefreshToken)
.where(RefreshToken.user_id == user_id, RefreshToken.revoked.is_(False))
.values(revoked=True)
)
await db.commit()
+86
View File
@@ -0,0 +1,86 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_password, verify_password
from app.models.role import Role
from app.models.user import User
async def get(db: AsyncSession, user_id: int) -> User | None:
return await db.get(User, user_id)
async def get_by_email(db: AsyncSession, email: str) -> User | None:
result = await db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[User]:
result = await db.execute(select(User).order_by(User.id).offset(skip).limit(limit))
return list(result.scalars().all())
async def count(db: AsyncSession) -> int:
result = await db.execute(select(func.count()).select_from(User))
return result.scalar_one()
async def _resolve_roles(db: AsyncSession, role_ids: list[int]) -> list[Role]:
if not role_ids:
return []
result = await db.execute(select(Role).where(Role.id.in_(role_ids)))
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
email: str,
password: str,
full_name: str | None = None,
is_superuser: bool = False,
role_ids: list[int] | None = None,
) -> User:
user = User(
email=email,
hashed_password=hash_password(password),
full_name=full_name,
is_superuser=is_superuser,
roles=await _resolve_roles(db, role_ids or []),
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
async def update(db: AsyncSession, user: User, data: dict) -> User:
if "password" in data and data["password"]:
user.hashed_password = hash_password(data.pop("password"))
else:
data.pop("password", None)
if "role_ids" in data and data["role_ids"] is not None:
user.roles = await _resolve_roles(db, data.pop("role_ids"))
else:
data.pop("role_ids", None)
for field, value in data.items():
if value is not None:
setattr(user, field, value)
await db.commit()
await db.refresh(user)
return user
async def delete(db: AsyncSession, user: User) -> None:
await db.delete(user)
await db.commit()
async def authenticate(db: AsyncSession, email: str, password: str) -> User | None:
user = await get_by_email(db, email)
if not user or not verify_password(password, user.hashed_password):
return None
return user
+106
View File
@@ -0,0 +1,106 @@
"""Shared FastAPI dependencies: DB session, current user, RBAC enforcement."""
import jwt
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import ACCESS_TOKEN_TYPE, decode_token
from app.crud import user as user_crud
from app.models.user import User
# auto_error=False so we can fall back to the cookie used by the HTMX UI.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
class NotAuthenticated(Exception):
"""Raised by web dependencies; handled by redirecting to the login page."""
def _token_from_request(request: Request, bearer: str | None) -> str | None:
if bearer:
return bearer
return request.cookies.get(settings.cookie_name)
async def _user_from_token(token: str | None, db: AsyncSession) -> User | None:
if not token:
return None
try:
payload = decode_token(token)
except jwt.PyJWTError:
return None
if payload.get("type") != ACCESS_TOKEN_TYPE:
return None
sub = payload.get("sub")
if sub is None:
return None
user = await user_crud.get(db, int(sub))
if user is None or not user.is_active:
return None
return user
# --- JSON API auth -----------------------------------------------------------
async def get_current_user(
request: Request,
bearer: str | None = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
user = await _user_from_token(_token_from_request(request, bearer), db)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return user
def require_permission(*required: str):
"""Dependency factory enforcing that the user holds all given permissions."""
async def checker(user: User = Depends(get_current_user)) -> User:
if user.is_superuser:
return user
missing = set(required) - user.permissions
if missing:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing permission(s): {', '.join(sorted(missing))}",
)
return user
return checker
def require_superuser(user: User = Depends(get_current_user)) -> User:
if not user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Superuser required"
)
return user
# --- Web (HTMX) auth ---------------------------------------------------------
async def get_current_web_user(
request: Request,
db: AsyncSession = Depends(get_db),
) -> User:
"""Like get_current_user but reads the cookie and redirects on failure."""
token = request.cookies.get(settings.cookie_name)
user = await _user_from_token(token, db)
if user is None:
raise NotAuthenticated()
return user
async def get_optional_web_user(
request: Request,
db: AsyncSession = Depends(get_db),
) -> User | None:
token = request.cookies.get(settings.cookie_name)
return await _user_from_token(token, db)
+55
View File
@@ -0,0 +1,55 @@
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from app.api.router import api_router
from app.core.config import settings
from app.core.database import init_db
from app.deps import NotAuthenticated
from app.web.admin import router as admin_router
from app.web.pages import router as pages_router
STATIC_DIR = Path(__file__).resolve().parent / "static"
@asynccontextmanager
async def lifespan(app: FastAPI):
# Dev/SQLite convenience. Use Alembic migrations in production.
if settings.auto_create_tables:
await init_db()
yield
def create_app() -> FastAPI:
app = FastAPI(title=settings.app_name, debug=settings.debug, lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.include_router(api_router, prefix="/api")
app.include_router(pages_router)
app.include_router(admin_router)
@app.exception_handler(NotAuthenticated)
async def _redirect_to_login(request: Request, exc: NotAuthenticated):
return RedirectResponse(url="/login", status_code=302)
@app.get("/healthz", tags=["health"])
async def healthz():
return {"status": "ok"}
return app
app = create_app()
+15
View File
@@ -0,0 +1,15 @@
"""Import all models so they register with ``Base.metadata``."""
from app.models.associations import role_permissions, user_roles
from app.models.permission import Permission
from app.models.refresh_token import RefreshToken
from app.models.role import Role
from app.models.user import User
__all__ = [
"Permission",
"RefreshToken",
"Role",
"User",
"role_permissions",
"user_roles",
]
+18
View File
@@ -0,0 +1,18 @@
"""Many-to-many association tables for RBAC."""
from sqlalchemy import Column, ForeignKey, Table
from app.core.database import Base
user_roles = Table(
"user_roles",
Base.metadata,
Column("user_id", ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
Column("role_id", ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
)
role_permissions = Table(
"role_permissions",
Base.metadata,
Column("role_id", ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
Column("permission_id", ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True),
)
+19
View File
@@ -0,0 +1,19 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.associations import role_permissions
class Permission(Base):
__tablename__ = "permissions"
id: Mapped[int] = mapped_column(primary_key=True)
# Convention: "<resource>:<action>", e.g. "users:read".
name: Mapped[str] = mapped_column(String(128), unique=True, index=True)
description: Mapped[str | None] = mapped_column(String(255), default=None)
roles: Mapped[list["Role"]] = relationship( # noqa: F821
secondary=role_permissions,
back_populates="permissions",
)
+29
View File
@@ -0,0 +1,29 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class RefreshToken(Base):
"""Server-side record of an issued refresh token, enabling revocation.
The raw token is never stored — only its jti (for lookup) and a SHA-256
hash (for verification). Rotation marks the old row revoked and inserts a
new one; logout revokes the row(s) for a user.
"""
__tablename__ = "refresh_tokens"
id: Mapped[int] = mapped_column(primary_key=True)
jti: Mapped[str] = mapped_column(String(64), unique=True, index=True)
token_hash: Mapped[str] = mapped_column(String(64))
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
revoked: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
+23
View File
@@ -0,0 +1,23 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.associations import role_permissions, user_roles
class Role(Base):
__tablename__ = "roles"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True, index=True)
description: Mapped[str | None] = mapped_column(String(255), default=None)
users: Mapped[list["User"]] = relationship( # noqa: F821
secondary=user_roles,
back_populates="roles",
)
permissions: Mapped[list["Permission"]] = relationship( # noqa: F821
secondary=role_permissions,
back_populates="roles",
lazy="selectin",
)
+35
View File
@@ -0,0 +1,35 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.associations import user_roles
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
full_name: Mapped[str | None] = mapped_column(String(255), default=None)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
roles: Mapped[list["Role"]] = relationship( # noqa: F821
secondary=user_roles,
back_populates="users",
lazy="selectin",
)
@property
def permissions(self) -> set[str]:
"""Flattened set of permission names granted via this user's roles."""
return {perm.name for role in self.roles for perm in role.permissions}
def has_permission(self, name: str) -> bool:
return self.is_superuser or name in self.permissions
View File
Whitespace-only changes.
+31
View File
@@ -0,0 +1,31 @@
from pydantic import BaseModel, ConfigDict
class PermissionRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
description: str | None = None
class RoleBase(BaseModel):
name: str
description: str | None = None
class RoleCreate(RoleBase):
permission_ids: list[int] = []
class RoleUpdate(BaseModel):
name: str | None = None
description: str | None = None
permission_ids: list[int] | None = None
class RoleRead(RoleBase):
model_config = ConfigDict(from_attributes=True)
id: int
permissions: list[PermissionRead] = []
+17
View File
@@ -0,0 +1,17 @@
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenRefreshRequest(BaseModel):
refresh_token: str
class LogoutRequest(BaseModel):
# Optional: revoke a specific refresh token. If omitted, all of the
# current user's refresh tokens are revoked.
refresh_token: str | None = None
+39
View File
@@ -0,0 +1,39 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr
from app.schemas.rbac import RoleRead
class UserBase(BaseModel):
email: EmailStr
full_name: str | None = None
class UserCreate(UserBase):
password: str
is_superuser: bool = False
role_ids: list[int] = []
class UserUpdate(BaseModel):
email: EmailStr | None = None
full_name: str | None = None
password: str | None = None
is_active: bool | None = None
is_superuser: bool | None = None
role_ids: list[int] | None = None
class UserRead(UserBase):
model_config = ConfigDict(from_attributes=True)
id: int
is_active: bool
is_superuser: bool
created_at: datetime
roles: list[RoleRead] = []
class RegisterRequest(UserBase):
password: str
View File
Whitespace-only changes.
+65
View File
@@ -0,0 +1,65 @@
"""Token issuance / rotation shared by the JSON API and the HTMX UI."""
import jwt
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import (
REFRESH_TOKEN_TYPE,
create_access_token,
create_refresh_token,
decode_token,
)
from app.crud import token as token_crud
from app.models.user import User
async def issue_token_pair(db: AsyncSession, user: User) -> tuple[str, str]:
"""Create an access + refresh token and persist the refresh token for revocation."""
access = create_access_token(user.id)
refresh, payload = create_refresh_token(user.id)
await token_crud.store(
db,
user_id=user.id,
jti=payload["jti"],
raw_token=refresh,
expires_at=payload["exp"], # timezone-aware UTC datetime
)
return access, refresh
class RefreshError(Exception):
pass
async def rotate_refresh_token(db: AsyncSession, raw_refresh: str) -> tuple[str, str, User]:
"""Validate a refresh token, revoke it, and issue a fresh pair.
Raises ``RefreshError`` if the token is invalid, expired, or already revoked.
"""
try:
payload = decode_token(raw_refresh)
except jwt.PyJWTError as exc:
raise RefreshError("Invalid refresh token") from exc
if payload.get("type") != REFRESH_TOKEN_TYPE:
raise RefreshError("Not a refresh token")
jti = payload.get("jti")
sub = payload.get("sub")
if not jti or sub is None:
raise RefreshError("Malformed refresh token")
record = await token_crud.get_active(db, jti=jti, raw_token=raw_refresh)
if record is None:
raise RefreshError("Refresh token is revoked or expired")
# Local import avoids a circular import at module load time.
from app.crud import user as user_crud
user = await user_crud.get(db, int(sub))
if user is None or not user.is_active:
raise RefreshError("User is inactive or missing")
# Rotate: revoke the presented token, then mint a new pair.
await token_crud.revoke(db, record)
access, refresh = await issue_token_pair(db, user)
return access, refresh, user
+101
View File
@@ -0,0 +1,101 @@
:root {
--bg: #0f172a;
--surface: #1e293b;
--surface-2: #273449;
--border: #334155;
--text: #e2e8f0;
--muted: #94a3b8;
--primary: #6366f1;
--danger: #ef4444;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
a { color: #a5b4fc; text-decoration: none; }
a:hover { text-decoration: underline; }
.muted { color: var(--muted); }
.container { max-width: 1000px; margin: 0 auto; padding: 1.5rem; }
/* Top bar */
.topbar {
display: flex; align-items: center; gap: 1.5rem;
padding: 0.75rem 1.5rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.topbar .brand a { font-weight: 700; color: var(--text); }
.topbar nav { display: flex; gap: 1rem; }
.topbar .logout { margin-left: auto; display: flex; align-items: center; gap: 0.75rem; }
/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem;
margin-bottom: 1.25rem;
}
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; }
@media (max-width: 720px) { .grid { grid-template-columns: 1fr; } }
h1 { margin-top: 0; }
h2 { margin-top: 0; font-size: 1.1rem; }
/* Tables */
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
.table th { color: var(--muted); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }
.row-actions { display: flex; gap: 0.4rem; }
/* Tags / definition lists */
.tag {
display: inline-block; background: var(--surface-2); border: 1px solid var(--border);
border-radius: 999px; padding: 0.05rem 0.55rem; margin: 0 0.2rem 0.2rem 0; font-size: 0.8rem;
}
.kv { display: grid; grid-template-columns: auto 1fr; gap: 0.4rem 1rem; }
.kv dt { color: var(--muted); }
.kv dd { margin: 0; }
.links { padding-left: 1.1rem; }
/* Forms */
label { display: block; margin-bottom: 0.6rem; font-size: 0.9rem; }
input[type=text], input[type=email], input[type=password] {
display: block; width: 100%; margin-top: 0.25rem;
padding: 0.5rem; border-radius: 6px; border: 1px solid var(--border);
background: var(--bg); color: var(--text);
}
.check { display: flex; align-items: center; gap: 0.4rem; }
.check input { width: auto; margin: 0; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem 1rem; align-items: end; }
.form-grid > button { grid-column: 1 / -1; justify-self: start; }
.roles-field { grid-column: 1 / -1; border: 1px solid var(--border); border-radius: 6px; padding: 0.6rem; }
.roles-field legend { color: var(--muted); padding: 0 0.4rem; }
.inline-edit { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
/* Buttons */
.btn {
font: inherit; cursor: pointer; border: 1px solid var(--border);
background: var(--surface-2); color: var(--text);
padding: 0.4rem 0.8rem; border-radius: 6px;
}
.btn:hover { filter: brightness(1.1); }
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn-danger { background: transparent; border-color: var(--danger); color: #fca5a5; }
.btn-ghost { background: transparent; }
/* Auth */
.auth-shell { min-height: 100vh; display: grid; place-items: center; padding: 1rem; }
.auth-card { width: 100%; max-width: 360px; }
.auth-card h1 { margin: 0 0 0.25rem; font-size: 1.4rem; }
.auth-card button { width: 100%; margin-top: 0.5rem; }
.alert { background: rgba(239, 68, 68, 0.15); border: 1px solid var(--danger); color: #fca5a5; padding: 0.5rem 0.75rem; border-radius: 6px; margin-bottom: 0.75rem; }
+17
View File
@@ -0,0 +1,17 @@
<header class="topbar">
<div class="brand"><a href="/">{{ app_name }}</a></div>
<nav>
<a href="/">Dashboard</a>
{% if user.is_superuser or "users:read" in user.permissions %}
<a href="/admin/users">Users</a>
{% endif %}
{% if user.is_superuser or "roles:read" in user.permissions %}
<a href="/admin/roles">Roles</a>
{% endif %}
<a href="/docs" target="_blank">API docs</a>
</nav>
<form method="post" action="/logout" class="logout">
<span class="muted">{{ user.email }}</span>
<button type="submit" class="btn btn-ghost">Sign out</button>
</form>
</header>
+24
View File
@@ -0,0 +1,24 @@
<div id="role-list" class="card">
<table class="table">
<thead>
<tr><th>ID</th><th>Name</th><th>Description</th><th>Permissions</th><th></th></tr>
</thead>
<tbody>
{% for r in roles %}
<tr id="role-{{ r.id }}">
<td>{{ r.id }}</td>
<td>{{ r.name }}</td>
<td>{{ r.description or "" }}</td>
<td>{% for perm in r.permissions %}<span class="tag">{{ perm.name }}</span>{% endfor %}</td>
<td class="row-actions">
<button class="btn btn-danger"
hx-delete="/admin/roles/{{ r.id }}"
hx-target="#role-{{ r.id }}"
hx-swap="outerHTML"
hx-confirm="Delete role {{ r.name }}?">Delete</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
+19
View File
@@ -0,0 +1,19 @@
<tr id="user-{{ u.id }}">
<td>{{ u.id }}</td>
<td>{{ u.email }}</td>
<td>{{ u.full_name or "" }}</td>
<td>{{ "✓" if u.is_active else "—" }}</td>
<td>{{ "✓" if u.is_superuser else "—" }}</td>
<td>{% for role in u.roles %}<span class="tag">{{ role.name }}</span>{% endfor %}</td>
<td class="row-actions">
<button class="btn btn-ghost"
hx-get="/admin/users/{{ u.id }}/edit"
hx-target="#user-{{ u.id }}"
hx-swap="outerHTML">Edit</button>
<button class="btn btn-danger"
hx-delete="/admin/users/{{ u.id }}"
hx-target="#user-{{ u.id }}"
hx-swap="outerHTML"
hx-confirm="Delete user {{ u.email }}?">Delete</button>
</td>
</tr>
+30
View File
@@ -0,0 +1,30 @@
<tr id="user-{{ u.id }}">
<td colspan="7">
<form hx-post="/admin/users/{{ u.id }}"
hx-target="#user-{{ u.id }}"
hx-swap="outerHTML"
class="inline-edit">
<span class="muted">#{{ u.id }} · {{ u.email }}</span>
<label>Name <input type="text" name="full_name" value="{{ u.full_name or '' }}"></label>
<label>New password <input type="password" name="password" placeholder="(unchanged)"></label>
<label class="check"><input type="checkbox" name="is_active" value="true" {{ "checked" if u.is_active }}> Active</label>
<label class="check"><input type="checkbox" name="is_superuser" value="true" {{ "checked" if u.is_superuser }}> Superuser</label>
<fieldset class="roles-field">
<legend>Roles</legend>
{% for role in roles %}
<label class="check">
<input type="checkbox" name="role_ids" value="{{ role.id }}"
{{ "checked" if role in u.roles }}> {{ role.name }}
</label>
{% endfor %}
</fieldset>
<div class="row-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-ghost"
hx-get="/admin/users/{{ u.id }}/row"
hx-target="#user-{{ u.id }}"
hx-swap="outerHTML">Cancel</button>
</div>
</form>
</td>
</tr>
+14
View File
@@ -0,0 +1,14 @@
<div id="user-list" class="card">
<table class="table">
<thead>
<tr>
<th>ID</th><th>Email</th><th>Name</th><th>Active</th><th>Superuser</th><th>Roles</th><th></th>
</tr>
</thead>
<tbody>
{% for u in users %}
{% include "admin/_user_row.html" %}
{% endfor %}
</tbody>
</table>
</div>
+27
View File
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Roles · {{ app_name }}{% endblock %}
{% block body %}
{% include "_nav.html" %}
<main class="container">
<h1>Roles &amp; permissions</h1>
<div class="card">
<h2>Create role</h2>
<form hx-post="/admin/roles" hx-target="#role-list" hx-swap="outerHTML" class="form-grid">
<label>Name <input type="text" name="name" required></label>
<label>Description <input type="text" name="description"></label>
<fieldset class="roles-field">
<legend>Permissions</legend>
{% for perm in permissions %}
<label class="check"><input type="checkbox" name="permission_ids" value="{{ perm.id }}"> {{ perm.name }}</label>
{% else %}
<span class="muted">No permissions defined. Run the seed script.</span>
{% endfor %}
</fieldset>
<button type="submit" class="btn btn-primary">Add role</button>
</form>
</div>
{% include "admin/_roles_table.html" %}
</main>
{% endblock %}
+29
View File
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Users · {{ app_name }}{% endblock %}
{% block body %}
{% include "_nav.html" %}
<main class="container">
<h1>Users</h1>
<div class="card">
<h2>Create user</h2>
<form hx-post="/admin/users" hx-target="#user-list" hx-swap="outerHTML" class="form-grid">
<label>Email <input type="email" name="email" required></label>
<label>Full name <input type="text" name="full_name"></label>
<label>Password <input type="password" name="password" required></label>
<label class="check">
<input type="checkbox" name="is_superuser" value="true"> Superuser
</label>
<fieldset class="roles-field">
<legend>Roles</legend>
{% for role in roles %}
<label class="check"><input type="checkbox" name="role_ids" value="{{ role.id }}"> {{ role.name }}</label>
{% endfor %}
</fieldset>
<button type="submit" class="btn btn-primary">Add user</button>
</form>
</div>
{% include "admin/_users_table.html" %}
</main>
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<link rel="stylesheet" href="/static/css/style.css">
<script src="https://unpkg.com/htmx.org@1.9.12"
integrity="sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2"
crossorigin="anonymous"></script>
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Dashboard · {{ app_name }}{% endblock %}
{% block body %}
{% include "_nav.html" %}
<main class="container">
<h1>Dashboard</h1>
<p class="muted">Welcome back, {{ user.full_name or user.email }}.</p>
<div class="grid">
<div class="card">
<h2>Your account</h2>
<dl class="kv">
<dt>Email</dt><dd>{{ user.email }}</dd>
<dt>Superuser</dt><dd>{{ "Yes" if user.is_superuser else "No" }}</dd>
<dt>Roles</dt>
<dd>
{% for role in user.roles %}<span class="tag">{{ role.name }}</span>{% else %}<span class="muted">none</span>{% endfor %}
</dd>
<dt>Permissions</dt>
<dd>
{% if user.is_superuser %}<span class="tag">* (all)</span>
{% else %}
{% for perm in user.permissions|sort %}<span class="tag">{{ perm }}</span>{% else %}<span class="muted">none</span>{% endfor %}
{% endif %}
</dd>
</dl>
</div>
<div class="card">
<h2>Quick links</h2>
<ul class="links">
<li><a href="/admin/users">Manage users</a></li>
<li><a href="/admin/roles">Manage roles &amp; permissions</a></li>
<li><a href="/docs" target="_blank">Interactive API docs (Swagger)</a></li>
<li><a href="/redoc" target="_blank">API reference (ReDoc)</a></li>
</ul>
</div>
</div>
</main>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}Sign in · {{ app_name }}{% endblock %}
{% block body %}
<div class="auth-shell">
<form class="card auth-card" method="post" action="/login">
<h1>{{ app_name }}</h1>
<p class="muted">Sign in to continue</p>
{% if error %}<div class="alert">{{ error }}</div>{% endif %}
<label>Email
<input type="email" name="email" value="{{ email or '' }}" required autofocus>
</label>
<label>Password
<input type="password" name="password" required>
</label>
<button type="submit" class="btn btn-primary">Sign in</button>
</form>
</div>
{% endblock %}
View File
Whitespace-only changes.
+204
View File
@@ -0,0 +1,204 @@
"""HTMX-driven admin dashboard for users and roles."""
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import role as role_crud
from app.crud import user as user_crud
from app.deps import get_current_web_user
from app.models.user import User
from app.web.templates import templates
router = APIRouter(prefix="/admin", include_in_schema=False)
def web_require(*perms: str):
"""Web dependency: allow superusers or holders of all given permissions."""
async def checker(user: User = Depends(get_current_web_user)) -> User:
if user.is_superuser or set(perms).issubset(user.permissions):
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have access to this page.",
)
return checker
@router.get("", response_class=HTMLResponse)
async def admin_home():
return RedirectResponse(url="/admin/users", status_code=status.HTTP_302_FOUND)
# --- Users -------------------------------------------------------------------
@router.get("/users", response_class=HTMLResponse)
async def users_page(
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(web_require("users:read")),
):
return templates.TemplateResponse(
request,
"admin/users.html",
{
"user": user,
"users": await user_crud.list_users(db),
"roles": await role_crud.list_roles(db),
},
)
@router.post("/users", response_class=HTMLResponse)
async def create_user(
request: Request,
email: str = Form(...),
password: str = Form(...),
full_name: str = Form(""),
is_superuser: bool = Form(False),
role_ids: list[int] = Form(default=[]),
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("users:write")),
):
if not await user_crud.get_by_email(db, email):
await user_crud.create(
db,
email=email,
password=password,
full_name=full_name or None,
is_superuser=is_superuser,
role_ids=role_ids,
)
return templates.TemplateResponse(
request,
"admin/_users_table.html",
{"users": await user_crud.list_users(db), "roles": await role_crud.list_roles(db)},
)
@router.get("/users/{user_id}/edit", response_class=HTMLResponse)
async def edit_user_row(
request: Request,
user_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("users:write")),
):
target = await user_crud.get(db, user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
return templates.TemplateResponse(
request,
"admin/_user_row_edit.html",
{"u": target, "roles": await role_crud.list_roles(db)},
)
@router.get("/users/{user_id}/row", response_class=HTMLResponse)
async def user_row(
request: Request,
user_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("users:read")),
):
target = await user_crud.get(db, user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
return templates.TemplateResponse(request, "admin/_user_row.html", {"u": target})
@router.post("/users/{user_id}", response_class=HTMLResponse)
async def update_user(
request: Request,
user_id: int,
full_name: str = Form(""),
password: str = Form(""),
is_active: bool = Form(False),
is_superuser: bool = Form(False),
role_ids: list[int] = Form(default=[]),
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("users:write")),
):
target = await user_crud.get(db, user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
await user_crud.update(
db,
target,
{
"full_name": full_name or None,
"password": password or None,
"is_active": is_active,
"is_superuser": is_superuser,
"role_ids": role_ids,
},
)
return templates.TemplateResponse(request, "admin/_user_row.html", {"u": target})
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("users:delete")),
):
target = await user_crud.get(db, user_id)
if target is not None:
await user_crud.delete(db, target)
# Empty body — HTMX swaps the row out of existence.
return Response(status_code=status.HTTP_200_OK)
# --- Roles -------------------------------------------------------------------
@router.get("/roles", response_class=HTMLResponse)
async def roles_page(
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(web_require("roles:read")),
):
return templates.TemplateResponse(
request,
"admin/roles.html",
{
"user": user,
"roles": await role_crud.list_roles(db),
"permissions": await role_crud.list_permissions(db),
},
)
@router.post("/roles", response_class=HTMLResponse)
async def create_role(
request: Request,
name: str = Form(...),
description: str = Form(""),
permission_ids: list[int] = Form(default=[]),
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("roles:write")),
):
if not await role_crud.get_by_name(db, name):
await role_crud.create(
db,
name=name,
description=description or None,
permission_ids=permission_ids,
)
return templates.TemplateResponse(
request,
"admin/_roles_table.html",
{"roles": await role_crud.list_roles(db)},
)
@router.delete("/roles/{role_id}")
async def delete_role(
role_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("roles:write")),
):
role = await role_crud.get(db, role_id)
if role is not None:
await role_crud.delete(db, role)
return Response(status_code=status.HTTP_200_OK)
+69
View File
@@ -0,0 +1,69 @@
from fastapi import APIRouter, Depends, Form, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.crud import user as user_crud
from app.deps import get_current_web_user
from app.models.user import User
from app.services.auth import issue_token_pair
from app.web.templates import templates
router = APIRouter(include_in_schema=False)
def _set_auth_cookie(response: Response, access_token: str) -> None:
response.set_cookie(
key=settings.cookie_name,
value=access_token,
httponly=True,
secure=settings.cookie_secure,
samesite="lax",
max_age=settings.access_token_expire_minutes * 60,
path="/",
)
@router.get("/", response_class=HTMLResponse)
async def dashboard(
request: Request,
user: User = Depends(get_current_web_user),
):
return templates.TemplateResponse(
request, "dashboard.html", {"user": user}
)
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
return templates.TemplateResponse(request, "login.html", {})
@router.post("/login")
async def login_submit(
request: Request,
email: str = Form(...),
password: str = Form(...),
db: AsyncSession = Depends(get_db),
):
user = await user_crud.authenticate(db, email, password)
if user is None:
# Re-render the form with an error (HTMX swaps the form partial).
return templates.TemplateResponse(
request,
"login.html",
{"error": "Incorrect email or password", "email": email},
status_code=status.HTTP_401_UNAUTHORIZED,
)
access, _ = await issue_token_pair(db, user)
response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
_set_auth_cookie(response, access)
return response
@router.post("/logout")
async def logout(request: Request):
response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(settings.cookie_name, path="/")
return response
+10
View File
@@ -0,0 +1,10 @@
from pathlib import Path
from fastapi.templating import Jinja2Templates
from app.core.config import settings
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
templates.env.globals["app_name"] = settings.app_name