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