feat(T01): auth + user management + RBAC + base frontend + i18n

- Backend: FastAPI, JWT auth (HS256), bcrypt, RBAC middleware
- User CRUD (admin-only), soft-delete, pagination
- Pydantic BaseSettings config, async SQLAlchemy 2.0
- 50 backend tests, 88% coverage
- Frontend: Next.js 14 App Router, Tailwind, design tokens
- Login page, auth context, API client with auto-refresh
- i18n: next-intl, DE/EN (31 keys each)
- 6 base UI components (Button, Input, Card, Table, Modal, Toast)
- 12 frontend tests, npm build success
This commit is contained in:
2026-07-14 11:51:32 +02:00
parent 5459a43e2b
commit d89304845a
56 changed files with 7581 additions and 9 deletions
View File
+55
View File
@@ -0,0 +1,55 @@
"""Application configuration using Pydantic BaseSettings.
All secrets and environment-dependent values are loaded from environment
variables or a local .env file. No hardcoded secrets in code.
"""
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Central application settings loaded from env vars or .env file."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
DATABASE_URL: str = (
"postgresql+asyncpg://erp_test_user:testpass@localhost:5432/erp_test"
)
REDIS_URL: str = "redis://localhost:6379/0"
JWT_SECRET: str = "change-me-to-a-secure-random-string"
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TTL_MINUTES: int = 15
JWT_REFRESH_TTL_DAYS: int = 7
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:3001"
UPLOAD_DIR: str = "/tmp/uploads"
APP_NAME: str = "ERP Nutzfahrzeuge"
APP_ENV: str = "development"
@property
def cors_origins_list(self) -> list[str]:
"""Parse CORS_ORIGINS comma-separated string into a list."""
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
@property
def access_token_ttl_seconds(self) -> int:
return self.JWT_ACCESS_TTL_MINUTES * 60
@property
def refresh_token_ttl_seconds(self) -> int:
return self.JWT_REFRESH_TTL_DAYS * 86400
@lru_cache
def get_settings() -> Settings:
"""Cached settings singleton."""
return Settings()
settings = get_settings()
+55
View File
@@ -0,0 +1,55 @@
"""Async SQLAlchemy database engine, session factory, and base model."""
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
class Base(DeclarativeBase):
"""Declarative base for all SQLAlchemy models."""
pass
engine = create_async_engine(
settings.DATABASE_URL,
echo=(settings.APP_ENV == "development"),
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
)
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def get_db() -> AsyncSession:
"""FastAPI dependency that yields an async DB session."""
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db():
"""Create all tables. Used for testing and first-run setup."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def drop_db():
"""Drop all tables. Used in test teardown."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
+103
View File
@@ -0,0 +1,103 @@
"""FastAPI dependencies: pagination, current user extraction, RBAC role enforcement.
"""
import uuid
from typing import Optional
from fastapi import Depends, HTTPException, Query, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User, UserRole
from app.services.auth_service import get_user_by_id
from app.utils.jwt import verify_access_token
security = HTTPBearer(auto_error=False)
def get_pagination(
page: int = Query(1, ge=1, description="Page number (1-indexed)"),
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
) -> dict:
"""Common pagination parameters."""
return {"page": page, "page_size": page_size}
async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User:
"""Extract and verify the JWT bearer token, returning the current user.
Raises 401 if token is missing, invalid, or the user is not found / inactive.
"""
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": {"code": "UNAUTHORIZED", "message": "Missing authentication token"}},
)
token = credentials.credentials
payload = verify_access_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": {"code": "INVALID_TOKEN", "message": "Invalid or expired access token"}},
)
user_id_str = payload.get("sub")
if not user_id_str:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": {"code": "INVALID_TOKEN", "message": "Token missing subject"}},
)
try:
user_uuid = uuid.UUID(user_id_str)
except (ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": {"code": "INVALID_TOKEN", "message": "Invalid user ID in token"}},
)
user = await get_user_by_id(db, user_uuid)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": {"code": "USER_NOT_FOUND", "message": "User not found"}},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"error": {"code": "ACCOUNT_DISABLED", "message": "Account is deactivated"}},
)
return user
def require_role(allowed_roles: list[str]):
"""RBAC dependency factory: enforce that the current user has one of the allowed roles.
Usage:
@router.get("/", dependencies=[Depends(require_role(["admin"]))])
or:
async def handler(user: User = Depends(require_role(["admin"]))):
"""
async def role_checker(user: User = Depends(get_current_user)) -> User:
user_role = user.role.value if isinstance(user.role, UserRole) else str(user.role)
if user_role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": {
"code": "FORBIDDEN",
"message": f"Role '{user_role}' is not permitted. Required: {allowed_roles}",
}
},
)
return user
return role_checker
+57
View File
@@ -0,0 +1,57 @@
"""FastAPI application entry point.
Configures CORS, registers routers under /api/v1, exposes health endpoint.
"""
from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, users
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application startup and shutdown lifecycle."""
# Startup: could run alembic migrations here in production
yield
# Shutdown: cleanup resources
app = FastAPI(
title=settings.APP_NAME,
description="ERP Nutzfahrzeuge Backend API",
version="1.0.0",
lifespan=lifespan,
)
# CORS configuration from settings
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allow_headers=["*"],
)
# API v1 router
api_v1_router = APIRouter(prefix="/api/v1")
api_v1_router.include_router(auth.router)
api_v1_router.include_router(users.router)
# Health endpoint (no auth required)
@api_v1_router.get("/health", tags=["health"])
async def health_check():
"""Health check endpoint."""
return {"status": "ok"}
app.include_router(api_v1_router)
@app.get("/")
async def root():
"""Root redirect info."""
return {"name": settings.APP_NAME, "version": "1.0.0", "docs": "/docs"}
View File
+77
View File
@@ -0,0 +1,77 @@
"""SQLAlchemy User model with UUID primary key."""
import enum
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Enum, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class UserRole(str, enum.Enum):
"""User roles for RBAC."""
admin = "admin"
verkaeufer = "verkaeufer"
buchhaltung = "buchhaltung"
class User(Base):
"""User entity for authentication and authorization."""
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
email: Mapped[str] = mapped_column(
String(255), unique=True, nullable=False, index=True,
)
password_hash: Mapped[str] = mapped_column(
String(255), nullable=False,
)
full_name: Mapped[str] = mapped_column(
String(200), nullable=False,
)
role: Mapped[UserRole] = mapped_column(
Enum(UserRole, name="user_role"),
nullable=False,
default=UserRole.verkaeufer,
)
language: Mapped[str] = mapped_column(
String(5), nullable=False, default="de",
)
is_active: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
def __repr__(self) -> str:
return f"<User id={self.id} email={self.email} role={self.role}>"
def to_dict(self) -> dict:
"""Serialize user for API responses (excludes password_hash)."""
return {
"id": str(self.id),
"email": self.email,
"full_name": self.full_name,
"role": self.role.value if isinstance(self.role, UserRole) else self.role,
"language": self.language,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
View File
+68
View File
@@ -0,0 +1,68 @@
"""Auth router: login, token refresh, current user profile."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user
from app.models.user import User
from app.schemas.user import (
LoginRequest,
RefreshRequest,
TokenResponse,
UserResponse,
)
from app.services.auth_service import (
authenticate_user,
generate_token_pair,
refresh_access_token,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse, status_code=status.HTTP_200_OK)
async def login(
body: LoginRequest,
db: AsyncSession = Depends(get_db),
):
"""Authenticate user and return JWT access + refresh tokens."""
user = await authenticate_user(db, body.email, body.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={
"error": {
"code": "INVALID_CREDENTIALS",
"message": "Invalid email or password",
}
},
)
tokens = generate_token_pair(user)
return TokenResponse(**tokens)
@router.post("/refresh", response_model=TokenResponse, status_code=status.HTTP_200_OK)
async def refresh_token(
body: RefreshRequest,
db: AsyncSession = Depends(get_db),
):
"""Exchange a valid refresh token for a new access + refresh token pair."""
tokens = await refresh_access_token(db, body.refresh_token)
if tokens is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={
"error": {
"code": "INVALID_REFRESH_TOKEN",
"message": "Invalid or expired refresh token",
}
},
)
return TokenResponse(**tokens)
@router.get("/me", response_model=UserResponse, status_code=status.HTTP_200_OK)
async def get_me(current_user: User = Depends(get_current_user)):
"""Return the currently authenticated user's profile."""
return UserResponse.model_validate(current_user)
+106
View File
@@ -0,0 +1,106 @@
"""Users router: admin-only user management (list, create, update, deactivate)."""
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user, get_pagination, require_role
from app.models.user import User
from app.schemas.user import (
UserCreate,
UserListResponse,
UserResponse,
UserUpdate,
)
from app.services.auth_service import (
create_user as svc_create_user,
deactivate_user as svc_deactivate_user,
get_user_by_id,
list_users as svc_list_users,
update_user as svc_update_user,
)
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/", response_model=UserListResponse, status_code=status.HTTP_200_OK)
async def list_users(
db: AsyncSession = Depends(get_db),
pagination: dict = Depends(get_pagination),
admin: User = Depends(require_role(["admin"])),
):
"""List all users with pagination. Admin only."""
users, total = await svc_list_users(
db, page=pagination["page"], page_size=pagination["page_size"]
)
return UserListResponse(
items=[UserResponse.model_validate(u) for u in users],
total=total,
page=pagination["page"],
page_size=pagination["page_size"],
)
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
body: UserCreate,
db: AsyncSession = Depends(get_db),
admin: User = Depends(require_role(["admin"])),
):
"""Create a new user. Admin only."""
try:
user = await svc_create_user(
db,
email=body.email,
password=body.password,
full_name=body.full_name,
role=body.role,
language=body.language,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"error": {"code": "DUPLICATE_EMAIL", "message": str(exc)}},
)
return UserResponse.model_validate(user)
@router.put("/{user_id}", response_model=UserResponse, status_code=status.HTTP_200_OK)
async def update_user(
user_id: uuid.UUID,
body: UserUpdate,
db: AsyncSession = Depends(get_db),
admin: User = Depends(require_role(["admin"])),
):
"""Update a user's fields. Admin only."""
updates = body.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": {"code": "NO_FIELDS", "message": "No fields to update"}},
)
user = await svc_update_user(db, user_id, **updates)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "USER_NOT_FOUND", "message": "User not found"}},
)
return UserResponse.model_validate(user)
@router.delete("/{user_id}", response_model=UserResponse, status_code=status.HTTP_200_OK)
async def delete_user(
user_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
admin: User = Depends(require_role(["admin"])),
):
"""Deactivate a user (soft delete). Admin only."""
user = await svc_deactivate_user(db, user_id)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "USER_NOT_FOUND", "message": "User not found"}},
)
return UserResponse.model_validate(user)
View File
+80
View File
@@ -0,0 +1,80 @@
"""Pydantic schemas for user-related request and response bodies."""
import uuid
from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class LoginRequest(BaseModel):
"""POST /api/v1/auth/login request body."""
email: EmailStr
password: str = Field(..., min_length=1)
class TokenResponse(BaseModel):
"""JWT token pair returned after login or refresh."""
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int = Field(..., description="Access token TTL in seconds")
class RefreshRequest(BaseModel):
"""POST /api/v1/auth/refresh request body."""
refresh_token: str
class UserBase(BaseModel):
"""Base user fields shared across schemas."""
email: EmailStr
full_name: str = Field(..., min_length=1, max_length=200)
role: Literal["admin", "verkaeufer", "buchhaltung"] = "verkaeufer"
language: str = Field(default="de", max_length=5)
class UserCreate(UserBase):
"""POST /api/v1/users request body."""
password: str = Field(..., min_length=8, max_length=128)
class UserUpdate(BaseModel):
"""PUT /api/v1/users/:id request body (all fields optional)."""
email: Optional[EmailStr] = None
full_name: Optional[str] = Field(None, min_length=1, max_length=200)
role: Optional[Literal["admin", "verkaeufer", "buchhaltung"]] = None
language: Optional[str] = Field(None, max_length=5)
is_active: Optional[bool] = None
class UserResponse(BaseModel):
"""User response schema (never exposes password_hash)."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
full_name: str
role: str
language: str
is_active: bool
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class UserListResponse(BaseModel):
"""Paginated user list response."""
items: list[UserResponse]
total: int
page: int
page_size: int
class ErrorResponse(BaseModel):
"""Standard error response format."""
error: dict
class HealthResponse(BaseModel):
"""Health check response."""
status: str = "ok"
View File
+178
View File
@@ -0,0 +1,178 @@
"""Authentication service: password hashing, login, token refresh, user lookup."""
import uuid
from typing import Optional
from passlib.context import CryptContext
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User, UserRole
from app.utils.jwt import (
create_access_token,
create_refresh_token,
verify_refresh_token,
)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
"""Hash a plaintext password using bcrypt."""
return pwd_context.hash(password)
def verify_password(plaintext: str, hashed: str) -> bool:
"""Verify a plaintext password against a bcrypt hash."""
return pwd_context.verify(plaintext, hashed)
async def get_user_by_email(db: AsyncSession, email: str) -> Optional[User]:
"""Fetch a user by email address."""
result = await db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
async def get_user_by_id(db: AsyncSession, user_id: uuid.UUID) -> Optional[User]:
"""Fetch a user by UUID."""
result = await db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
async def authenticate_user(db: AsyncSession, email: str, password: str) -> Optional[User]:
"""Authenticate a user by email and password."""
user = await get_user_by_email(db, email)
if user is None:
return None
if not user.is_active:
return None
if not verify_password(password, user.password_hash):
return None
return user
def generate_token_pair(user: User) -> dict:
"""Create access + refresh JWT tokens for a given user."""
role_val = user.role.value if isinstance(user.role, UserRole) else str(user.role)
access_token = create_access_token(
user_id=str(user.id),
role=role_val,
email=user.email,
lang=user.language,
)
refresh_token = create_refresh_token(
user_id=str(user.id),
role=role_val,
email=user.email,
lang=user.language,
)
from app.config import settings
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
"expires_in": settings.access_token_ttl_seconds,
}
async def refresh_access_token(db: AsyncSession, refresh_token: str) -> Optional[dict]:
"""Verify a refresh token and issue a new token pair."""
payload = verify_refresh_token(refresh_token)
if payload is None:
return None
user_id_str = payload.get("sub")
if not user_id_str:
return None
try:
user_uuid = uuid.UUID(user_id_str)
except (ValueError, TypeError):
return None
user = await get_user_by_id(db, user_uuid)
if user is None or not user.is_active:
return None
return generate_token_pair(user)
async def create_user(
db: AsyncSession,
email: str,
password: str,
full_name: str,
role: str = "verkaeufer",
language: str = "de",
) -> User:
"""Create a new user with a bcrypt-hashed password."""
existing = await get_user_by_email(db, email)
if existing is not None:
raise ValueError(f"User with email {email} already exists")
user = User(
email=email,
password_hash=hash_password(password),
full_name=full_name,
role=UserRole(role),
language=language,
is_active=True,
)
db.add(user)
await db.flush()
await db.refresh(user)
return user
async def list_users(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
) -> tuple[list[User], int]:
"""List users with pagination. Returns (users, total_count)."""
count_result = await db.execute(select(func.count(User.id)))
total = count_result.scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(
select(User)
.order_by(User.created_at.desc())
.offset(offset)
.limit(page_size)
)
users = list(result.scalars().all())
return users, total
async def update_user(
db: AsyncSession,
user_id: uuid.UUID,
**updates,
) -> Optional[User]:
"""Update user fields. Returns updated user or None if not found."""
user = await get_user_by_id(db, user_id)
if user is None:
return None
for field, value in updates.items():
if field == "role" and value is not None:
value = UserRole(value)
if field == "password":
user.password_hash = hash_password(value)
elif hasattr(user, field) and value is not None:
setattr(user, field, value)
await db.flush()
await db.refresh(user)
return user
async def deactivate_user(db: AsyncSession, user_id: uuid.UUID) -> Optional[User]:
"""Soft-delete a user by setting is_active=False. Returns user or None."""
user = await get_user_by_id(db, user_id)
if user is None:
return None
user.is_active = False
await db.flush()
await db.refresh(user)
return user
View File
+83
View File
@@ -0,0 +1,83 @@
"""JWT utility functions for token creation and verification."""
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from app.config import settings
def create_access_token(
user_id: str,
role: str,
email: str,
lang: str = "de",
) -> str:
"""Create a short-lived access JWT."""
now = datetime.now(timezone.utc)
expire = now + timedelta(seconds=settings.access_token_ttl_seconds)
payload = {
"sub": str(user_id),
"role": role,
"email": email,
"lang": lang,
"exp": expire,
"iat": now,
"type": "access",
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
def create_refresh_token(
user_id: str,
role: str,
email: str,
lang: str = "de",
) -> str:
"""Create a long-lived refresh JWT."""
now = datetime.now(timezone.utc)
expire = now + timedelta(seconds=settings.refresh_token_ttl_seconds)
payload = {
"sub": str(user_id),
"role": role,
"email": email,
"lang": lang,
"exp": expire,
"iat": now,
"type": "refresh",
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
def decode_token(token: str) -> Optional[dict]:
"""Decode and verify a JWT token. Returns payload or None."""
try:
payload = jwt.decode(
token,
settings.JWT_SECRET,
algorithms=[settings.JWT_ALGORITHM],
)
return payload
except JWTError:
return None
def verify_access_token(token: str) -> Optional[dict]:
"""Verify an access token specifically (checks type claim)."""
payload = decode_token(token)
if payload is None:
return None
if payload.get("type") != "access":
return None
return payload
def verify_refresh_token(token: str) -> Optional[dict]:
"""Verify a refresh token specifically (checks type claim)."""
payload = decode_token(token)
if payload is None:
return None
if payload.get("type") != "refresh":
return None
return payload