Initial Claude implementation
This commit is contained in:
commit
e2d3ab9c2a
63 files changed
+3930
No files matched your search
Whitespace-only changes.
Whitespace-only changes.
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
@@ -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)
|
||||
Whitespace-only changes.
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
Whitespace-only changes.
@@ -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] = []
|
||||
@@ -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
|
||||
@@ -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
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Roles · {{ app_name }}{% endblock %}
|
||||
{% block body %}
|
||||
{% include "_nav.html" %}
|
||||
<main class="container">
|
||||
<h1>Roles & 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 & 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 %}
|
||||
@@ -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 %}
|
||||
Whitespace-only changes.
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user