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:
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user