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
+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)