feat(auth): enterprise authentication and user management

This commit is contained in:
Schubert Ferenc 2026-07-02 22:19:12 +02:00
parent 4bc8b20a21
commit 86a32a942c
36 changed files with 2239 additions and 324 deletions

64
.gitignore vendored Normal file
View file

@ -0,0 +1,64 @@
# ===========================
# Python
# ===========================
.venv/
venv/
__pycache__/
*.py[cod]
*.pyo
.pytest_cache/
.mypy_cache/
# ===========================
# Next.js
# ===========================
.next/
node_modules/
out/
# ===========================
# Environment
# ===========================
.env
.env.local
.env.development.local
.env.production.local
# ===========================
# macOS
# ===========================
.DS_Store
# ===========================
# VS Code
# ===========================
.vscode/
# ===========================
# Logs
# ===========================
*.log
# ===========================
# Coverage
# ===========================
coverage/
htmlcov/
# ===========================
# Alembic
# ===========================
alembic/__pycache__/
# ===========================
# Python build
# ===========================
build/
dist/
*.egg-info/
# ===========================
# Temporary
# ===========================
*.tmp
*.swp

View file

@ -0,0 +1,65 @@
"""extend users enterprise fields
Revision ID: 3f4c7b8d1e2a
Revises: 0a247aadcfda
Create Date: 2026-07-02 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "3f4c7b8d1e2a"
down_revision: Union[str, Sequence[str], None] = "0a247aadcfda"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("first_name", sa.String(length=100), server_default="", nullable=False),
)
op.add_column(
"users",
sa.Column("last_name", sa.String(length=100), server_default="", nullable=False),
)
op.add_column(
"users",
sa.Column("role", sa.String(length=50), server_default="user", nullable=False),
)
op.add_column(
"users",
sa.Column("is_active", sa.Boolean(), server_default=sa.true(), nullable=False),
)
op.add_column(
"users",
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.add_column(
"users",
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
def downgrade() -> None:
op.drop_index(op.f("ix_users_email"), table_name="users")
op.drop_column("users", "updated_at")
op.drop_column("users", "created_at")
op.drop_column("users", "is_active")
op.drop_column("users", "role")
op.drop_column("users", "last_name")
op.drop_column("users", "first_name")

View file

@ -1,10 +1,14 @@
from fastapi import APIRouter, Depends, HTTPException, Response from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.security import create_access_token, verify_password from app.core.security import (
create_access_token,
get_access_token_expire_seconds,
verify_password,
)
from app.db.database import get_db from app.db.database import get_db
from app.models.user import User from app.models.user import User
from app.schemas.user import LoginRequest from app.schemas.user import LoginRequest, LoginResponse
router = APIRouter( router = APIRouter(
prefix="/auth", prefix="/auth",
@ -12,32 +16,30 @@ router = APIRouter(
) )
@router.post("/login") @router.post("/login", response_model=LoginResponse)
def login( def login(
login: LoginRequest, login: LoginRequest,
response: Response,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
user = db.query(User).filter(User.username == login.username).first() user = db.query(User).filter(User.username == login.username).first()
if not user: if not user:
raise HTTPException(status_code=401, detail="Ungültige Anmeldedaten") raise HTTPException(
status_code=401,
detail="Ungültige Anmeldedaten",
)
if not verify_password(login.password, user.password_hash): if not verify_password(login.password, user.password_hash):
raise HTTPException(status_code=401, detail="Ungültige Anmeldedaten") raise HTTPException(
status_code=401,
detail="Ungültige Anmeldedaten",
)
token = create_access_token(user.username) token = create_access_token(user.username)
response.set_cookie(
key="access_token",
value=token,
httponly=True,
secure=False,
samesite="lax",
max_age=3600,
)
return { return {
"message": "Login erfolgreich", "access_token": token,
"user": user.username, "token_type": "bearer",
} "expires_in": get_access_token_expire_seconds(),
"user": user,
}

View file

@ -1,24 +1,191 @@
from fastapi import APIRouter, Depends import logging
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.security import verify_access_token
from app.db.database import get_db from app.db.database import get_db
from app.models.user import User
from app.repositories.user_repository import UserRepository from app.repositories.user_repository import UserRepository
from app.schemas.user import UserCreate, UserResponse from app.schemas.user import UserCreate, UserPasswordUpdate, UserResponse, UserUpdate
logger = logging.getLogger(__name__)
router = APIRouter( router = APIRouter(
prefix="/users", prefix="/users",
tags=["Users"] tags=["Users"],
) )
bearer_scheme = HTTPBearer(auto_error=False)
@router.get("/", response_model=list[UserResponse])
def get_users(db: Session = Depends(get_db)): def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: Session = Depends(get_db),
) -> User:
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Nicht authentifiziert",
headers={"WWW-Authenticate": "Bearer"},
)
username = verify_access_token(credentials.credentials)
user = UserRepository.get_by_username(db, username)
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Nicht authentifiziert",
headers={"WWW-Authenticate": "Bearer"},
)
return user
def require_role(*allowed_roles: str):
def dependency(current_user: User = Depends(get_current_user)) -> User:
if current_user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Keine Berechtigung",
)
return current_user
return dependency
def get_user_or_404(db: Session, user_id: int) -> User:
db_user = UserRepository.get_by_id(db, user_id)
if db_user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Benutzer nicht gefunden",
)
return db_user
def raise_conflict(field: str) -> None:
if field == "username":
detail = "Benutzername ist bereits vergeben"
else:
detail = "E-Mail ist bereits vergeben"
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=detail,
)
@router.get("", response_model=list[UserResponse])
@router.get("/", response_model=list[UserResponse], include_in_schema=False)
def get_users(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
logger.info("users.list", extra={"actor_user_id": current_user.id})
return UserRepository.get_all(db) return UserRepository.get_all(db)
@router.post("/", response_model=UserResponse) @router.get("/{user_id}", response_model=UserResponse)
def get_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
logger.info(
"users.detail",
extra={"actor_user_id": current_user.id, "target_user_id": user_id},
)
return get_user_or_404(db, user_id)
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"/",
response_model=UserResponse,
status_code=status.HTTP_201_CREATED,
include_in_schema=False,
)
def create_user( def create_user(
user: UserCreate, user: UserCreate,
db: Session = Depends(get_db) db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
): ):
return UserRepository.create(db, user) conflict = UserRepository.find_conflict(
db,
username=user.username,
email=str(user.email),
)
if conflict is not None:
raise_conflict(conflict[0])
logger.info("users.create", extra={"actor_user_id": current_user.id})
return UserRepository.create(db, user)
@router.put("/{user_id}", response_model=UserResponse)
def update_user(
user_id: int,
user: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
db_user = get_user_or_404(db, user_id)
conflict = UserRepository.find_conflict(
db,
username=user.username,
email=str(user.email),
exclude_user_id=user_id,
)
if conflict is not None:
raise_conflict(conflict[0])
logger.info(
"users.update",
extra={"actor_user_id": current_user.id, "target_user_id": user_id},
)
return UserRepository.update(db, db_user, user)
@router.put("/{user_id}/password", response_model=UserResponse)
def update_user_password(
user_id: int,
password_update: UserPasswordUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
db_user = get_user_or_404(db, user_id)
logger.info(
"users.password_update",
extra={"actor_user_id": current_user.id, "target_user_id": user_id},
)
return UserRepository.update_password(db, db_user, password_update)
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if current_user.id == user_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Der aktuell angemeldete Benutzer darf sich nicht selbst löschen",
)
db_user = get_user_or_404(db, user_id)
logger.info(
"users.delete",
extra={"actor_user_id": current_user.id, "target_user_id": user_id},
)
UserRepository.delete(db, db_user)
return Response(status_code=status.HTTP_204_NO_CONTENT)

View file

@ -7,6 +7,8 @@ class Settings(BaseSettings):
app_name: str = "Hermes API" app_name: str = "Hermes API"
app_version: str = "0.1.0" app_version: str = "0.1.0"
access_token_expire_minutes: int = 60
jwt_issuer: str = "hermes"
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",

View file

@ -1,6 +1,7 @@
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from jose import jwt from fastapi import HTTPException, status
from jose import JWTError, jwt
from pwdlib import PasswordHash from pwdlib import PasswordHash
from app.core.config import settings from app.core.config import settings
@ -8,7 +9,11 @@ from app.core.config import settings
password_hash = PasswordHash.recommended() password_hash = PasswordHash.recommended()
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 ACCESS_TOKEN_TYPE = "access"
def get_access_token_expire_seconds() -> int:
return settings.access_token_expire_minutes * 60
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
@ -20,11 +25,15 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
def create_access_token(subject: str) -> str: def create_access_token(subject: str) -> str:
expire = datetime.now(UTC) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) issued_at = datetime.now(UTC)
expire = issued_at + timedelta(minutes=settings.access_token_expire_minutes)
payload = { payload = {
"sub": subject, "sub": subject,
"iat": issued_at,
"exp": expire, "exp": expire,
"iss": settings.jwt_issuer,
"type": ACCESS_TOKEN_TYPE,
} }
return jwt.encode( return jwt.encode(
@ -32,3 +41,31 @@ def create_access_token(subject: str) -> str:
settings.secret_key, settings.secret_key,
algorithm=ALGORITHM, algorithm=ALGORITHM,
) )
def verify_access_token(token: str) -> str:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Nicht authentifiziert",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token,
settings.secret_key,
algorithms=[ALGORITHM],
issuer=settings.jwt_issuer,
)
subject = payload.get("sub")
token_type = payload.get("type")
except JWTError as exc:
raise credentials_exception from exc
if not isinstance(subject, str) or not subject:
raise credentials_exception
if token_type != ACCESS_TOKEN_TYPE:
raise credentials_exception
return subject

View file

@ -1,5 +1,12 @@
import logging
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi import HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from starlette import status
from starlette.requests import Request
from app.api.auth import router as auth_router from app.api.auth import router as auth_router
from app.api.users import router as users_router from app.api.users import router as users_router
@ -11,21 +18,52 @@ app = FastAPI(
description="Backend von Olympus", description="Backend von Olympus",
) )
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:3000",
"http://127.0.0.1:3000",
"https://crm.funktechnik-schubert.de",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(users_router) app.include_router(users_router)
logger = logging.getLogger(__name__)
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
logger.warning(
"http_exception",
extra={
"path": request.url.path,
"method": request.method,
"status_code": exc.status_code,
},
)
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
headers=exc.headers,
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
logger.warning(
"validation_exception",
extra={"path": request.url.path, "method": request.method},
)
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"detail": jsonable_encoder(exc.errors())},
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
logger.exception(
"unhandled_exception",
extra={"path": request.url.path, "method": request.method},
)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Interner Serverfehler"},
)
@app.get("/") @app.get("/")
def root(): def root():
@ -40,4 +78,4 @@ def health():
return { return {
"status": "healthy", "status": "healthy",
"database": check_database(), "database": check_database(),
} }

View file

@ -1,4 +1,9 @@
from datetime import datetime
from sqlalchemy import Boolean
from sqlalchemy import DateTime
from sqlalchemy import String from sqlalchemy import String
from sqlalchemy import func
from sqlalchemy.orm import Mapped from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column from sqlalchemy.orm import mapped_column
@ -10,6 +15,18 @@ class User(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
first_name: Mapped[str] = mapped_column(
String(100),
default="",
server_default=""
)
last_name: Mapped[str] = mapped_column(
String(100),
default="",
server_default=""
)
username: Mapped[str] = mapped_column( username: Mapped[str] = mapped_column(
String(50), String(50),
unique=True, unique=True,
@ -23,4 +40,27 @@ class User(Base):
password_hash: Mapped[str] = mapped_column( password_hash: Mapped[str] = mapped_column(
String(255) String(255)
) )
role: Mapped[str] = mapped_column(
String(50),
default="user",
server_default="user"
)
is_active: Mapped[bool] = mapped_column(
Boolean,
default=True,
server_default="true"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now()
)

View file

@ -1,27 +1,98 @@
from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.user import User
from app.schemas.user import UserCreate
from app.core.security import hash_password from app.core.security import hash_password
from app.models.user import User
from app.schemas.user import UserCreate, UserPasswordUpdate, UserUpdate
class UserRepository: class UserRepository:
@staticmethod
def get_all(db: Session) -> list[User]:
return list(db.scalars(select(User).order_by(User.created_at.desc())))
@staticmethod @staticmethod
def get_all(db: Session): def get_by_id(db: Session, user_id: int) -> User | None:
return db.query(User).all() return db.get(User, user_id)
@staticmethod @staticmethod
def create(db: Session, user: UserCreate): def get_by_username(db: Session, username: str) -> User | None:
return db.scalar(select(User).where(User.username == username))
@staticmethod
def find_conflict(
db: Session,
*,
username: str,
email: str,
exclude_user_id: int | None = None,
) -> tuple[str, User] | None:
username_query = select(User).where(User.username == username)
email_query = select(User).where(User.email == email)
if exclude_user_id is not None:
username_query = username_query.where(User.id != exclude_user_id)
email_query = email_query.where(User.id != exclude_user_id)
username_user = db.scalar(username_query)
if username_user is not None:
return ("username", username_user)
email_user = db.scalar(email_query)
if email_user is not None:
return ("email", email_user)
return None
@staticmethod
def create(db: Session, user: UserCreate) -> User:
db_user = User( db_user = User(
username=user.username, first_name=user.first_name,
email=user.email, last_name=user.last_name,
password_hash=hash_password(user.password), username=user.username,
) email=str(user.email),
role=user.role,
is_active=user.is_active,
password_hash=hash_password(user.password),
)
db.add(db_user) db.add(db_user)
db.commit() db.commit()
db.refresh(db_user) db.refresh(db_user)
return db_user return db_user
@staticmethod
def update(db: Session, db_user: User, user: UserUpdate) -> User:
db_user.first_name = user.first_name
db_user.last_name = user.last_name
db_user.username = user.username
db_user.email = str(user.email)
db_user.role = user.role
db_user.is_active = user.is_active
if user.password:
db_user.password_hash = hash_password(user.password)
db.commit()
db.refresh(db_user)
return db_user
@staticmethod
def update_password(
db: Session,
db_user: User,
password_update: UserPasswordUpdate,
) -> User:
db_user.password_hash = hash_password(password_update.password)
db.commit()
db.refresh(db_user)
return db_user
@staticmethod
def delete(db: Session, db_user: User) -> None:
db.delete(db_user)
db.commit()

View file

@ -1,21 +1,79 @@
from pydantic import BaseModel, EmailStr from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
UserRole = Literal["admin", "manager", "user"]
class UserCreate(BaseModel): class UserBase(BaseModel):
username: str first_name: str = Field(default="", max_length=100)
last_name: str = Field(default="", max_length=100)
username: str = Field(min_length=3, max_length=50)
email: EmailStr email: EmailStr
password: str role: UserRole = "user"
is_active: bool = True
@field_validator("first_name", "last_name", "username", mode="before")
@classmethod
def normalize_text(cls, value: object) -> str:
if value is None:
return ""
return str(value).strip()
@field_validator("username")
@classmethod
def validate_username(cls, value: str) -> str:
allowed = value.replace(".", "").replace("_", "").replace("-", "")
if not allowed.isalnum():
raise ValueError(
"Benutzername darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten"
)
return value
class UserCreate(UserBase):
password: str = Field(min_length=8, max_length=128)
class UserUpdate(UserBase):
password: str | None = Field(default=None, max_length=128)
@field_validator("password")
@classmethod
def validate_optional_password(cls, value: str | None) -> str | None:
if value is None or value == "":
return None
if len(value) < 8:
raise ValueError("Passwort muss mindestens 8 Zeichen lang sein")
return value
class UserPasswordUpdate(BaseModel):
password: str = Field(min_length=8, max_length=128)
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
password: str password: str
class UserResponse(BaseModel): class UserResponse(BaseModel):
id: int id: int
first_name: str
last_name: str
username: str username: str
email: EmailStr email: EmailStr
role: UserRole
is_active: bool
created_at: datetime
updated_at: datetime
model_config = { model_config = ConfigDict(from_attributes=True)
"from_attributes": True
}
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int
user: UserResponse

View file

@ -8,6 +8,9 @@ services:
DATABASE_URL: postgresql+psycopg://olympus:FsFs03285310!!!@olympus-db:5432/olympus DATABASE_URL: postgresql+psycopg://olympus:FsFs03285310!!!@olympus-db:5432/olympus
APP_NAME: Hermes API APP_NAME: Hermes API
APP_VERSION: 0.1.0 APP_VERSION: 0.1.0
SECRET_KEY: ${SECRET_KEY}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
JWT_ISSUER: ${JWT_ISSUER:-hermes}
ports: ports:
- "8000:8000" - "8000:8000"

View file

@ -3,6 +3,7 @@ services:
build: build:
context: ./backend/hermes context: ./backend/hermes
dockerfile: dockerfile dockerfile: dockerfile
container_name: hermes-api container_name: hermes-api
restart: unless-stopped restart: unless-stopped
@ -11,6 +12,8 @@ services:
APP_NAME: Hermes API APP_NAME: Hermes API
APP_VERSION: 0.1.0 APP_VERSION: 0.1.0
SECRET_KEY: ${SECRET_KEY} SECRET_KEY: ${SECRET_KEY}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
JWT_ISSUER: ${JWT_ISSUER:-hermes}
ports: ports:
- "8000:8000" - "8000:8000"
@ -22,12 +25,15 @@ services:
build: build:
context: ./frontend/athena context: ./frontend/athena
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: athena-web container_name: athena-web
restart: unless-stopped restart: unless-stopped
environment: environment:
NODE_ENV: production NODE_ENV: production
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} HERMES_INTERNAL_URL: http://hermes:8000
AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-false}
ATHENA_PUBLIC_ORIGIN: ${ATHENA_PUBLIC_ORIGIN:-http://localhost:3001}
ports: ports:
- "3001:3000" - "3001:3000"

View file

@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server";
import {
getHermesUrl,
readJson,
setAuthCookie,
upstreamConfigurationErrorResponse,
upstreamUnavailableResponse,
} from "@/lib/server/hermes";
import { assertSameOrigin } from "@/lib/server/request-guards";
type HermesLoginResponse = {
access_token: string;
expires_in: number;
user: {
id: number;
username: string;
email: string;
};
};
export async function POST(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const credentials = await request.json();
const hermesUrl = getHermesUrl();
if (!hermesUrl) {
return upstreamConfigurationErrorResponse();
}
let hermesResponse: Response;
try {
hermesResponse = await fetch(`${hermesUrl}/auth/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(credentials),
cache: "no-store",
});
} catch {
return upstreamUnavailableResponse();
}
const data = await readJson(hermesResponse);
if (!hermesResponse.ok) {
return NextResponse.json(data, {
status: hermesResponse.status,
});
}
const loginData = data as HermesLoginResponse;
const response = NextResponse.json({
user: loginData.user,
});
setAuthCookie(response, loginData.access_token, loginData.expires_in);
return response;
}

View file

@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { clearAuthCookie } from "@/lib/server/hermes";
import { assertSameOrigin } from "@/lib/server/request-guards";
export async function POST(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
const response = NextResponse.json({ ok: true });
clearAuthCookie(response);
return response;
}

View file

@ -0,0 +1,87 @@
import { NextRequest } from "next/server";
import {
clearAuthCookie,
getAccessToken,
getHermesUrl,
hermesJsonResponse,
unauthorizedResponse,
upstreamConfigurationErrorResponse,
upstreamUnavailableResponse,
} from "@/lib/server/hermes";
import { assertSameOrigin } from "@/lib/server/request-guards";
type Params = {
params: Promise<{
id: string;
}>;
};
async function proxyUserRequest(request: NextRequest, { params }: Params) {
const token = await getAccessToken();
if (!token) {
return unauthorizedResponse();
}
const hermesUrl = getHermesUrl();
if (!hermesUrl) {
return upstreamConfigurationErrorResponse();
}
const { id } = await params;
const action = request.nextUrl.searchParams.get("action");
const path = action === "password" ? `/users/${id}/password` : `/users/${id}`;
let hermesResponse: Response;
try {
hermesResponse = await fetch(`${hermesUrl}${path}`, {
method: request.method,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: request.method === "GET" || request.method === "DELETE"
? undefined
: await request.text(),
cache: "no-store",
});
} catch {
return upstreamUnavailableResponse();
}
if (hermesResponse.status === 401) {
const response = await hermesJsonResponse(hermesResponse);
clearAuthCookie(response);
return response;
}
return hermesJsonResponse(hermesResponse);
}
export async function GET(request: NextRequest, context: Params) {
return proxyUserRequest(request, context);
}
export async function PUT(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyUserRequest(request, context);
}
export async function DELETE(request: NextRequest, context: Params) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyUserRequest(request, context);
}

View file

@ -0,0 +1,65 @@
import { NextRequest } from "next/server";
import {
clearAuthCookie,
getAccessToken,
getHermesUrl,
hermesJsonResponse,
unauthorizedResponse,
upstreamConfigurationErrorResponse,
upstreamUnavailableResponse,
} from "@/lib/server/hermes";
import { assertSameOrigin } from "@/lib/server/request-guards";
async function proxyUsersRequest(request: NextRequest) {
const token = await getAccessToken();
if (!token) {
return unauthorizedResponse();
}
const hermesUrl = getHermesUrl();
if (!hermesUrl) {
return upstreamConfigurationErrorResponse();
}
let hermesResponse: Response;
try {
hermesResponse = await fetch(`${hermesUrl}/users${request.nextUrl.search}`, {
method: request.method,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
"Content-Type": "application/json",
},
body: request.method === "GET" ? undefined : await request.text(),
cache: "no-store",
});
} catch {
return upstreamUnavailableResponse();
}
if (hermesResponse.status === 401) {
const response = await hermesJsonResponse(hermesResponse);
clearAuthCookie(response);
return response;
}
return hermesJsonResponse(hermesResponse);
}
export async function GET(request: NextRequest) {
return proxyUsersRequest(request);
}
export async function POST(request: NextRequest) {
const originError = assertSameOrigin(request);
if (originError) {
return originError;
}
return proxyUsersRequest(request);
}

View file

@ -1,24 +1,20 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginPage() { export default function LoginPage() {
const router = useRouter();
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
async function handleLogin(e: React.FormEvent) { async function handleLogin(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
const response = await fetch( try {
`${process.env.NEXT_PUBLIC_API_URL}/auth/login`, const response = await fetch("/api/login", {
{
method: "POST", method: "POST",
credentials: "include",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
@ -26,15 +22,20 @@ export default function LoginPage() {
username, username,
password, password,
}), }),
});
const data = await response.json();
if (response.ok) {
window.location.href = "/dashboard";
return;
} }
);
if (response.ok) { setError(data.detail ?? "Login fehlgeschlagen");
router.push("/dashboard"); } catch (err) {
return; console.error(err);
setError("Technischer Fehler");
} }
setError("Benutzername oder Passwort ist falsch.");
} }
return ( return (
@ -63,12 +64,11 @@ export default function LoginPage() {
/> />
{error && ( {error && (
<p className="mb-4 text-red-600"> <p className="mb-4 text-red-600">{error}</p>
{error}
</p>
)} )}
<button <button
type="submit"
className="w-full rounded bg-blue-600 p-3 text-white hover:bg-blue-700" className="w-full rounded bg-blue-600 p-3 text-white hover:bg-blue-700"
> >
Anmelden Anmelden
@ -76,4 +76,4 @@ export default function LoginPage() {
</form> </form>
</main> </main>
); );
} }

View file

@ -1,5 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export default function HomePage() { export default function HomePage() {
redirect("/login"); redirect("/dashboard");
} }

View file

@ -0,0 +1,235 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import type { FormEvent } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ArrowLeft, KeyRound, Pencil } from "lucide-react";
import StatusBadge from "@/components/common/StatusBadge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import UserFormDialog from "@/components/users/UserFormDialog";
import { api } from "@/lib/api";
import type { User, UserPayload } from "@/types/user";
function getErrorMessage(error: unknown) {
if (typeof error === "object" && error !== null && "response" in error) {
const response = (error as { response?: { data?: { detail?: string } } }).response;
return response?.data?.detail ?? "Aktion konnte nicht abgeschlossen werden";
}
return "Aktion konnte nicht abgeschlossen werden";
}
function formatDateTime(value: string) {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
export default function UserDetailPage() {
const params = useParams<{ id: string }>();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [formError, setFormError] = useState("");
const [passwordOpen, setPasswordOpen] = useState(false);
const [password, setPassword] = useState("");
const [passwordError, setPasswordError] = useState("");
const loadUser = useCallback(async () => {
try {
const response = await api.get<User>(`/users/${params.id}`);
setUser(response.data);
} catch (err) {
setError(getErrorMessage(err));
} finally {
setLoading(false);
}
}, [params.id]);
useEffect(() => {
queueMicrotask(() => {
void loadUser();
});
}, [loadUser]);
async function saveUser(payload: UserPayload) {
if (!user) {
return;
}
setPending(true);
setFormError("");
try {
const response = await api.put<User>(`/users/${user.id}`, payload);
setUser(response.data);
setFormOpen(false);
} catch (err) {
setFormError(getErrorMessage(err));
} finally {
setPending(false);
}
}
async function savePassword(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!user || password.length < 8) {
setPasswordError("Passwort muss mindestens 8 Zeichen lang sein");
return;
}
setPending(true);
setPasswordError("");
try {
const response = await api.put<User>(`/users/${user.id}?action=password`, {
password,
});
setUser(response.data);
setPassword("");
setPasswordOpen(false);
} catch (err) {
setPasswordError(getErrorMessage(err));
} finally {
setPending(false);
}
}
if (loading) {
return <div className="rounded-lg border bg-white p-8 text-slate-500">Benutzer wird geladen...</div>;
}
if (error || !user) {
return (
<div className="space-y-4">
<Link
href="/users"
className="inline-flex h-8 items-center justify-center rounded-lg border border-border bg-background px-2.5 text-sm font-medium hover:bg-muted"
>
Zurück
</Link>
<div className="rounded-lg border bg-white p-8 text-red-600">
{error || "Benutzer nicht gefunden"}
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<Link
href="/users"
className="inline-flex items-center gap-2 text-sm text-slate-500 hover:text-slate-900"
>
<ArrowLeft size={16} />
Zurück zur Benutzerliste
</Link>
<h1 className="mt-3 text-3xl font-bold text-slate-950">{user.username}</h1>
<p className="mt-1 text-sm text-slate-500">{user.email}</p>
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={() => setPasswordOpen(true)}>
<KeyRound size={16} />
Passwort ändern
</Button>
<Button type="button" onClick={() => setFormOpen(true)}>
<Pencil size={16} />
Bearbeiten
</Button>
</div>
</div>
<section className="rounded-lg border bg-white p-6">
<h2 className="text-lg font-semibold">Informationen</h2>
<dl className="mt-6 grid gap-5 md:grid-cols-2">
<Detail label="Vorname" value={user.first_name || "-"} />
<Detail label="Nachname" value={user.last_name || "-"} />
<Detail label="Benutzername" value={user.username} />
<Detail label="E-Mail" value={user.email} />
<Detail label="Rolle" value={user.role} />
<div>
<dt className="text-sm text-slate-500">Status</dt>
<dd className="mt-1"><StatusBadge active={user.is_active} /></dd>
</div>
<Detail label="Erstellt" value={formatDateTime(user.created_at)} />
<Detail label="Aktualisiert" value={formatDateTime(user.updated_at)} />
</dl>
</section>
<UserFormDialog
open={formOpen}
user={user}
pending={pending}
serverError={formError}
onOpenChange={setFormOpen}
onSubmit={saveUser}
/>
<Dialog open={passwordOpen} onOpenChange={setPasswordOpen}>
<DialogContent>
<form onSubmit={savePassword} className="space-y-5">
<DialogHeader>
<DialogTitle>Passwort ändern</DialogTitle>
<DialogDescription>
Das Passwort wird serverseitig mit pwdlib gehasht.
</DialogDescription>
</DialogHeader>
<div className="space-y-1.5">
<Label>Neues Passwort</Label>
<Input
type="password"
value={password}
aria-invalid={Boolean(passwordError)}
onChange={(event) => setPassword(event.target.value)}
/>
{passwordError && <p className="text-xs text-red-600">{passwordError}</p>}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={pending}
onClick={() => setPasswordOpen(false)}
>
Abbrechen
</Button>
<Button type="submit" disabled={pending || password.length < 8}>
{pending ? "Speichern..." : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
}
function Detail({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-sm text-slate-500">{label}</dt>
<dd className="mt-1 font-medium text-slate-950">{value}</dd>
</div>
);
}

View file

@ -1,66 +1,306 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Plus } from "lucide-react";
import { api } from "@/lib/api"; import ConfirmDialog from "@/components/common/ConfirmDialog";
import { User } from "@/types/user"; import SearchInput from "@/components/common/SearchInput";
import { Button } from "@/components/ui/button";
import UserDialog from "@/components/users/UserDialog"; import UserFormDialog from "@/components/users/UserFormDialog";
import UserTable from "@/components/users/UserTable"; import UserTable from "@/components/users/UserTable";
import { api } from "@/lib/api";
import type { User, UserPayload, UserRole } from "@/types/user";
const pageSize = 10;
type StatusFilter = "all" | "active" | "inactive";
function getErrorMessage(error: unknown) {
if (typeof error === "object" && error !== null && "response" in error) {
const response = (error as { response?: { data?: { detail?: string } } }).response;
return response?.data?.detail ?? "Aktion konnte nicht abgeschlossen werden";
}
return "Aktion konnte nicht abgeschlossen werden";
}
function getSortableValue(user: User, key: string) {
const value = user[key as keyof User];
return typeof value === "string" ? value.toLowerCase() : value;
}
export default function UsersPage() { export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [formError, setFormError] = useState("");
const [deleteError, setDeleteError] = useState("");
const [pending, setPending] = useState(false);
const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState<UserRole | "all">("all");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [sortKey, setSortKey] = useState("created_at");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const [page, setPage] = useState(1);
const [formOpen, setFormOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [deleteUser, setDeleteUser] = useState<User | null>(null);
const [users, setUsers] = useState<User[]>([]); const loadUsers = useCallback(async () => {
try {
const response = await api.get<User[]>("/users");
setUsers(response.data);
} catch (err) {
setError(getErrorMessage(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { useEffect(() => {
loadUsers(); queueMicrotask(() => {
}, []); void loadUsers();
});
}, [loadUsers]);
function loadUsers() { const filteredUsers = useMemo(() => {
const term = search.trim().toLowerCase();
api return users
.get<User[]>("/users/") .filter((user) => {
.then((res) => { if (!term) {
return true;
}
console.log("API:", res.data); return [
user.first_name,
user.last_name,
user.username,
user.email,
user.role,
].some((value) => value.toLowerCase().includes(term));
})
.filter((user) => roleFilter === "all" || user.role === roleFilter)
.filter((user) => {
if (statusFilter === "all") {
return true;
}
setUsers(res.data); return statusFilter === "active" ? user.is_active : !user.is_active;
})
.sort((first, second) => {
const firstValue = getSortableValue(first, sortKey);
const secondValue = getSortableValue(second, sortKey);
}) if (firstValue === secondValue) {
.catch((err) => { return 0;
}
console.error("API Fehler:", err); const result = firstValue > secondValue ? 1 : -1;
return sortDirection === "asc" ? result : -result;
});
}, [roleFilter, search, sortDirection, sortKey, statusFilter, users]);
}); const pageCount = Math.max(1, Math.ceil(filteredUsers.length / pageSize));
const currentPage = Math.min(page, pageCount);
const pageUsers = filteredUsers.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
);
function handleSort(key: string) {
if (sortKey === key) {
setSortDirection((current) => (current === "asc" ? "desc" : "asc"));
return;
} }
return ( setSortKey(key);
setSortDirection("asc");
}
<> function openCreateDialog() {
setSelectedUser(null);
setFormError("");
setFormOpen(true);
}
<div className="flex justify-between items-center mb-8"> function openEditDialog(user: User) {
setSelectedUser(user);
setFormError("");
setFormOpen(true);
}
<h1 className="text-4xl font-bold"> async function saveUser(payload: UserPayload) {
setPending(true);
setFormError("");
Benutzer try {
if (selectedUser) {
const response = await api.put<User>(`/users/${selectedUser.id}`, payload);
setUsers((current) => current.map((user) => (
user.id === selectedUser.id ? response.data : user
)));
} else {
const response = await api.post<User>("/users", payload);
setUsers((current) => [response.data, ...current]);
}
</h1> setFormOpen(false);
} catch (err) {
setFormError(getErrorMessage(err));
} finally {
setPending(false);
}
}
<UserDialog /> async function confirmDelete() {
if (!deleteUser) {
return;
}
setPending(true);
setDeleteError("");
try {
await api.delete(`/users/${deleteUser.id}`);
setUsers((current) => current.filter((user) => user.id !== deleteUser.id));
setDeleteUser(null);
} catch (err) {
setDeleteError(getErrorMessage(err));
} finally {
setPending(false);
}
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-950">Benutzer</h1>
<p className="mt-1 text-sm text-slate-500">
{filteredUsers.length} von {users.length} Benutzern
</p>
</div>
<Button type="button" onClick={openCreateDialog}>
<Plus size={16} />
Neuer Benutzer
</Button>
</div>
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 lg:flex-row lg:items-center">
<SearchInput
value={search}
onChange={(value) => {
setSearch(value);
setPage(1);
}}
placeholder="Benutzer suchen"
/>
<select
value={roleFilter}
onChange={(event) => {
setRoleFilter(event.target.value as UserRole | "all");
setPage(1);
}}
className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value="all">Alle Rollen</option>
<option value="admin">Admin</option>
<option value="manager">Manager</option>
<option value="user">Benutzer</option>
</select>
<select
value={statusFilter}
onChange={(event) => {
setStatusFilter(event.target.value as StatusFilter);
setPage(1);
}}
className="h-8 rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value="all">Alle Status</option>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
</div>
<UserTable
users={pageUsers}
loading={loading}
error={error}
sortKey={sortKey}
sortDirection={sortDirection}
onSort={handleSort}
onEdit={openEditDialog}
onDelete={setDeleteUser}
/>
<div className="flex items-center justify-between text-sm text-slate-600">
<span>
Seite {currentPage} von {pageCount}
</span>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
disabled={currentPage === 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
Zurück
</Button>
<Button
type="button"
variant="outline"
disabled={currentPage === pageCount}
onClick={() => setPage((current) => Math.min(pageCount, current + 1))}
>
Weiter
</Button>
</div>
</div>
<UserFormDialog
open={formOpen}
user={selectedUser}
pending={pending}
serverError={formError}
onOpenChange={setFormOpen}
onSubmit={saveUser}
/>
<ConfirmDialog
open={Boolean(deleteUser)}
title="Benutzer löschen"
description="Möchten Sie den Benutzer wirklich löschen?"
pending={pending}
onOpenChange={(open) => {
if (!open) {
setDeleteUser(null);
setDeleteError("");
}
}}
onConfirm={confirmDelete}
>
{deleteUser && (
<dl className="grid gap-2 text-sm">
<div className="flex justify-between gap-4">
<dt className="text-slate-500">Benutzername</dt>
<dd className="font-medium">{deleteUser.username}</dd>
</div> </div>
<div className="flex justify-between gap-4">
<pre className="mb-6 rounded bg-black p-4 text-green-400 overflow-auto"> <dt className="text-slate-500">E-Mail</dt>
<dd className="font-medium">{deleteUser.email}</dd>
{JSON.stringify(users, null, 2)} </div>
<div className="flex justify-between gap-4">
</pre> <dt className="text-slate-500">Rolle</dt>
<dd className="font-medium">{deleteUser.role}</dd>
<UserTable users={users} /> </div>
{deleteError && <p className="text-red-600">{deleteError}</p>}
</> </dl>
)}
); </ConfirmDialog>
</div>
} );
}

View file

@ -1,3 +1,5 @@
import LogoutButton from "@/components/LogoutButton";
export default function Header() { export default function Header() {
return ( return (
@ -10,14 +12,20 @@ export default function Header() {
</h1> </h1>
<div className="font-semibold"> <div className="flex items-center gap-4">
<span className="font-semibold">
admin.schubert admin.schubert
</span>
<LogoutButton />
</div> </div>
</header> </header>
); );
} }

View file

@ -0,0 +1,33 @@
"use client";
import { useRouter } from "next/navigation";
import { LogOut } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function LogoutButton() {
const router = useRouter();
async function handleLogout() {
await fetch("/api/logout", {
method: "POST",
});
router.push("/login");
router.refresh();
}
return (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleLogout}
aria-label="Abmelden"
title="Abmelden"
>
<LogOut size={16} />
Abmelden
</Button>
);
}

View file

@ -13,7 +13,7 @@ const menu = [
{ {
icon: LayoutDashboard, icon: LayoutDashboard,
name: "Dashboard", name: "Dashboard",
href: "/", href: "/dashboard",
}, },
{ {
icon: Users, icon: Users,
@ -83,4 +83,4 @@ export default function Sidebar() {
</aside> </aside>
); );
} }

View file

@ -0,0 +1,67 @@
"use client";
import type { ReactNode } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
type Props = {
open: boolean;
title: string;
description: string;
confirmLabel?: string;
pending?: boolean;
children?: ReactNode;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
};
export default function ConfirmDialog({
open,
title,
description,
confirmLabel = "Löschen",
pending = false,
children,
onOpenChange,
onConfirm,
}: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{children && <div className="rounded-lg border bg-slate-50 p-3">{children}</div>}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={pending}
>
Abbrechen
</Button>
<Button
type="button"
variant="destructive"
onClick={onConfirm}
disabled={pending}
>
{pending ? "Wird gelöscht..." : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,117 @@
"use client";
import type { ReactNode } from "react";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
export type DataTableColumn<T> = {
key: string;
label: string;
sortable?: boolean;
className?: string;
render: (row: T) => ReactNode;
};
type Props<T> = {
columns: DataTableColumn<T>[];
rows: T[];
rowKey: (row: T) => string | number;
sortKey: string;
sortDirection: "asc" | "desc";
loading?: boolean;
error?: string;
emptyTitle: string;
emptyDescription: string;
onSort: (key: string) => void;
};
export default function DataTable<T>({
columns,
rows,
rowKey,
sortKey,
sortDirection,
loading = false,
error,
emptyTitle,
emptyDescription,
onSort,
}: Props<T>) {
return (
<div className="overflow-hidden rounded-lg border bg-white">
<div className="overflow-x-auto">
<table className="w-full min-w-[920px] text-sm">
<thead className="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
{columns.map((column) => {
const active = sortKey === column.key;
const SortIcon = active
? sortDirection === "asc"
? ArrowUp
: ArrowDown
: ArrowUpDown;
return (
<th key={column.key} className={column.className ?? "px-4 py-3"}>
{column.sortable ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-1 text-xs uppercase tracking-wide"
onClick={() => onSort(column.key)}
>
{column.label}
<SortIcon size={14} />
</Button>
) : (
column.label
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{loading && (
<tr>
<td colSpan={columns.length} className="px-4 py-12 text-center text-slate-500">
Daten werden geladen...
</td>
</tr>
)}
{!loading && error && (
<tr>
<td colSpan={columns.length} className="px-4 py-12 text-center text-red-600">
{error}
</td>
</tr>
)}
{!loading && !error && rows.length === 0 && (
<tr>
<td colSpan={columns.length} className="px-4 py-12 text-center">
<p className="font-medium text-slate-900">{emptyTitle}</p>
<p className="mt-1 text-slate-500">{emptyDescription}</p>
</td>
</tr>
)}
{!loading && !error && rows.map((row) => (
<tr key={rowKey(row)} className="border-t hover:bg-slate-50">
{columns.map((column) => (
<td key={column.key} className={column.className ?? "px-4 py-3"}>
{column.render(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,29 @@
"use client";
import { Search } from "lucide-react";
import { Input } from "@/components/ui/input";
type Props = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
};
export default function SearchInput({
value,
onChange,
placeholder = "Suchen",
}: Props) {
return (
<div className="relative w-full max-w-sm">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
<Input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className="pl-8"
/>
</div>
);
}

View file

@ -0,0 +1,20 @@
import { cn } from "@/lib/utils";
type Props = {
active: boolean;
};
export default function StatusBadge({ active }: Props) {
return (
<span
className={cn(
"inline-flex h-6 items-center rounded-full px-2 text-xs font-medium",
active
? "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-600/20"
: "bg-slate-100 text-slate-600 ring-1 ring-slate-500/20",
)}
>
{active ? "Aktiv" : "Inaktiv"}
</span>
);
}

View file

@ -1,133 +0,0 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/lib/api";
export default function UserDialog() {
const [open, setOpen] = useState(false);
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
async function saveUser() {
console.log("Speichern geklickt");
try {
const response = await api.post("/users/", {
username,
email,
password,
});
console.log(response.data);
setOpen(false);
window.location.reload();
} catch (error) {
console.error(error);
}
}
return (
<>
<Button onClick={() => setOpen(true)}>
+ Neuer Benutzer
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Neuer Benutzer
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>Benutzername</Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div>
<Label>E-Mail</Label>
<Input
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<Label>Passwort</Label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button onClick={saveUser}>
Speichern
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -0,0 +1,253 @@
"use client";
import { useMemo, useState } from "react";
import type { FormEvent, ReactNode } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { User, UserPayload, UserRole } from "@/types/user";
const roles: Array<{ value: UserRole; label: string }> = [
{ value: "admin", label: "Admin" },
{ value: "manager", label: "Manager" },
{ value: "user", label: "Benutzer" },
];
const emptyForm: UserPayload = {
first_name: "",
last_name: "",
username: "",
email: "",
role: "user",
is_active: true,
password: "",
};
type Props = {
open: boolean;
user?: User | null;
pending?: boolean;
serverError?: string;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: UserPayload) => Promise<void>;
};
export default function UserFormDialog({
open,
user,
pending = false,
serverError,
onOpenChange,
onSubmit,
}: Props) {
const isEditing = Boolean(user);
const initialForm = user
? {
first_name: user.first_name,
last_name: user.last_name,
username: user.username,
email: user.email,
role: user.role,
is_active: user.is_active,
password: "",
}
: emptyForm;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
{open && (
<UserForm
key={user?.id ?? "new"}
initialForm={initialForm}
isEditing={isEditing}
pending={pending}
serverError={serverError}
onCancel={() => onOpenChange(false)}
onSubmit={onSubmit}
/>
)}
</DialogContent>
</Dialog>
);
}
function UserForm({
initialForm,
isEditing,
pending,
serverError,
onCancel,
onSubmit,
}: {
initialForm: UserPayload;
isEditing: boolean;
pending: boolean;
serverError?: string;
onCancel: () => void;
onSubmit: (payload: UserPayload) => Promise<void>;
}) {
const [form, setForm] = useState<UserPayload>(initialForm);
const errors = useMemo(() => {
return {
username: form.username.trim().length < 3
? "Mindestens 3 Zeichen"
: "",
email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)
? ""
: "Gültige E-Mail erforderlich",
password: !isEditing && (form.password?.length ?? 0) < 8
? "Mindestens 8 Zeichen"
: "",
};
}, [form.email, form.password, form.username, isEditing]);
const isValid = Object.values(errors).every((error) => !error);
function updateField<K extends keyof UserPayload>(key: K, value: UserPayload[K]) {
setForm((current) => ({
...current,
[key]: value,
}));
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!isValid) {
return;
}
const payload = {
...form,
password: form.password?.trim() || undefined,
};
await onSubmit(payload);
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<DialogHeader>
<DialogTitle>{isEditing ? "Benutzer bearbeiten" : "Benutzer erstellen"}</DialogTitle>
<DialogDescription>
Stammdaten, Rolle und Status des Benutzers verwalten.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Vorname">
<Input
value={form.first_name}
onChange={(event) => updateField("first_name", event.target.value)}
/>
</Field>
<Field label="Nachname">
<Input
value={form.last_name}
onChange={(event) => updateField("last_name", event.target.value)}
/>
</Field>
<Field label="Benutzername" error={errors.username}>
<Input
value={form.username}
aria-invalid={Boolean(errors.username)}
onChange={(event) => updateField("username", event.target.value)}
/>
</Field>
<Field label="E-Mail" error={errors.email}>
<Input
type="email"
value={form.email}
aria-invalid={Boolean(errors.email)}
onChange={(event) => updateField("email", event.target.value)}
/>
</Field>
<Field label="Rolle">
<select
value={form.role}
onChange={(event) => updateField("role", event.target.value as UserRole)}
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
{roles.map((role) => (
<option key={role.value} value={role.value}>
{role.label}
</option>
))}
</select>
</Field>
<Field label="Status">
<label className="flex h-8 items-center gap-2 rounded-lg border px-2.5 text-sm">
<input
type="checkbox"
checked={form.is_active}
onChange={(event) => updateField("is_active", event.target.checked)}
/>
Aktiv
</label>
</Field>
<Field
label={isEditing ? "Neues Passwort" : "Passwort"}
error={errors.password}
>
<Input
type="password"
value={form.password ?? ""}
aria-invalid={Boolean(errors.password)}
onChange={(event) => updateField("password", event.target.value)}
/>
</Field>
</div>
{serverError && <p className="text-sm text-red-600">{serverError}</p>}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={pending}
>
Abbrechen
</Button>
<Button type="submit" disabled={!isValid || pending}>
{pending ? "Speichern..." : "Speichern"}
</Button>
</DialogFooter>
</form>
);
}
function Field({
label,
error,
children,
}: {
label: string;
error?: string;
children: ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label>{label}</Label>
{children}
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
);
}

View file

@ -1,63 +1,133 @@
"use client"; "use client";
import { User } from "@/types/user"; import Link from "next/link";
import { Edit, Eye, Trash2 } from "lucide-react";
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
import StatusBadge from "@/components/common/StatusBadge";
import { Button, buttonVariants } from "@/components/ui/button";
import type { User } from "@/types/user";
type Props = { type Props = {
users: User[]; users: User[];
loading: boolean;
error?: string;
sortKey: string;
sortDirection: "asc" | "desc";
onSort: (key: string) => void;
onEdit: (user: User) => void;
onDelete: (user: User) => void;
}; };
export default function UserTable({ users }: Props) { function formatDate(value: string) {
return new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(new Date(value));
}
export default function UserTable({
users,
loading,
error,
sortKey,
sortDirection,
onSort,
onEdit,
onDelete,
}: Props) {
const columns: DataTableColumn<User>[] = [
{
key: "first_name",
label: "Vorname",
sortable: true,
render: (user) => user.first_name || "-",
},
{
key: "last_name",
label: "Nachname",
sortable: true,
render: (user) => user.last_name || "-",
},
{
key: "username",
label: "Benutzername",
sortable: true,
render: (user) => <span className="font-medium text-slate-900">{user.username}</span>,
},
{
key: "email",
label: "E-Mail",
sortable: true,
render: (user) => user.email,
},
{
key: "role",
label: "Rolle",
sortable: true,
render: (user) => user.role,
},
{
key: "is_active",
label: "Status",
sortable: true,
render: (user) => <StatusBadge active={user.is_active} />,
},
{
key: "created_at",
label: "Erstellt",
sortable: true,
render: (user) => formatDate(user.created_at),
},
{
key: "actions",
label: "Aktionen",
className: "px-4 py-3 text-right",
render: (user) => (
<div className="flex justify-end gap-2">
<Link
href={`/users/${user.id}`}
className={buttonVariants({ variant: "ghost", size: "icon-sm" })}
title="Details"
>
<Eye size={16} />
</Link>
<Button
type="button"
variant="ghost"
size="icon-sm"
title="Bearbeiten"
onClick={() => onEdit(user)}
>
<Edit size={16} />
</Button>
<Button
type="button"
variant="destructive"
size="icon-sm"
title="Löschen"
onClick={() => onDelete(user)}
>
<Trash2 size={16} />
</Button>
</div>
),
},
];
return ( return (
<div className="overflow-hidden rounded-xl border bg-white shadow"> <DataTable
columns={columns}
<table className="w-full"> rows={users}
rowKey={(user) => user.id}
<thead className="bg-slate-100"> sortKey={sortKey}
sortDirection={sortDirection}
<tr> loading={loading}
error={error}
<th className="p-4 text-left">ID</th> emptyTitle="Keine Benutzer gefunden"
<th className="p-4 text-left">Benutzername</th> emptyDescription="Passe Suche oder Filter an oder erstelle einen neuen Benutzer."
<th className="p-4 text-left">E-Mail</th> onSort={onSort}
<th className="p-4 text-center">Aktionen</th> />
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr
key={user.id}
className="border-t hover:bg-slate-50"
>
<td className="p-4">{user.id}</td>
<td className="p-4">{user.username}</td>
<td className="p-4">{user.email}</td>
<td className="p-4 text-center">
<button className="mr-3 rounded bg-blue-600 px-3 py-1 text-white hover:bg-blue-700">
Bearbeiten
</button>
<button className="rounded bg-red-600 px-3 py-1 text-white hover:bg-red-700">
Löschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
); );
} }

View file

@ -6,7 +6,9 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
NEXT_PUBLIC_API_URL: https://api.funktechnik-schubert.de HERMES_INTERNAL_URL: https://api.funktechnik-schubert.de
AUTH_COOKIE_SECURE: true
ATHENA_PUBLIC_ORIGIN: https://crm.funktechnik-schubert.de
ports: ports:
- "3001:3000" - "3001:3000"

View file

@ -1,5 +1,5 @@
import axios from "axios"; import axios from "axios";
export const api = axios.create({ export const api = axios.create({
baseURL: "http://localhost:8000", baseURL: "/api",
}); });

View file

@ -0,0 +1,103 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
const ACCESS_TOKEN_COOKIE = "access_token";
const DEFAULT_COOKIE_MAX_AGE_SECONDS = 60 * 60;
export function getHermesUrl() {
return process.env.HERMES_INTERNAL_URL?.replace(/\/$/, "") ?? null;
}
export async function getAccessToken() {
const cookieStore = await cookies();
return cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;
}
function isSecureCookieEnabled() {
if (process.env.AUTH_COOKIE_SECURE) {
return process.env.AUTH_COOKIE_SECURE !== "false";
}
return process.env.NODE_ENV === "production";
}
function normalizeMaxAge(maxAge?: number) {
if (typeof maxAge !== "number" || !Number.isFinite(maxAge) || maxAge <= 0) {
return DEFAULT_COOKIE_MAX_AGE_SECONDS;
}
return Math.floor(maxAge);
}
export function setAuthCookie(response: NextResponse, token: string, maxAge: number) {
response.cookies.set({
name: ACCESS_TOKEN_COOKIE,
value: token,
httpOnly: true,
secure: isSecureCookieEnabled(),
sameSite: "lax",
path: "/",
maxAge: normalizeMaxAge(maxAge),
});
}
export function clearAuthCookie(response: NextResponse) {
response.cookies.set({
name: ACCESS_TOKEN_COOKIE,
value: "",
httpOnly: true,
secure: isSecureCookieEnabled(),
sameSite: "lax",
path: "/",
maxAge: 0,
});
}
export function unauthorizedResponse() {
return NextResponse.json(
{ detail: "Nicht authentifiziert" },
{ status: 401 },
);
}
export function upstreamUnavailableResponse() {
return NextResponse.json(
{ detail: "Hermes ist nicht erreichbar" },
{ status: 502 },
);
}
export function upstreamConfigurationErrorResponse() {
return NextResponse.json(
{ detail: "Hermes ist nicht konfiguriert" },
{ status: 500 },
);
}
export async function readJson(response: Response) {
const text = await response.text();
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch {
return { detail: text };
}
}
export async function hermesJsonResponse(response: Response) {
if (response.status === 204) {
return new NextResponse(null, {
status: 204,
});
}
const body = await readJson(response);
return NextResponse.json(body, {
status: response.status,
});
}

View file

@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
function normalizeOrigin(origin: string) {
return origin.replace(/\/$/, "");
}
function getConfiguredOrigins() {
return (process.env.ATHENA_PUBLIC_ORIGIN ?? "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean)
.map(normalizeOrigin);
}
function getForwardedOrigin(request: NextRequest) {
const forwardedHost = request.headers.get("x-forwarded-host");
if (!forwardedHost) {
return null;
}
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "https";
const firstHost = forwardedHost.split(",")[0]?.trim();
const firstProto = forwardedProto.split(",")[0]?.trim();
if (!firstHost || !firstProto) {
return null;
}
return `${firstProto}://${firstHost}`;
}
export function assertSameOrigin(request: NextRequest) {
const origin = request.headers.get("origin");
if (!origin) {
return null;
}
const requestOrigins = [
request.nextUrl.origin,
getForwardedOrigin(request),
...getConfiguredOrigins(),
]
.filter((value): value is string => Boolean(value))
.map(normalizeOrigin);
if (!requestOrigins.includes(normalizeOrigin(origin))) {
return NextResponse.json(
{ detail: "Ungültiger Ursprung der Anfrage" },
{ status: 403 },
);
}
return null;
}

View file

@ -1,9 +1,14 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) { export function proxy(request: NextRequest) {
const token = request.cookies.get("access_token"); const token = request.cookies.get("access_token");
const isLoginPage = request.nextUrl.pathname === "/login";
if (!token) { if (isLoginPage && token) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
if (!isLoginPage && !token) {
return NextResponse.redirect(new URL("/login", request.url)); return NextResponse.redirect(new URL("/login", request.url));
} }
@ -12,7 +17,8 @@ export function middleware(request: NextRequest) {
export const config = { export const config = {
matcher: [ matcher: [
"/login",
"/dashboard/:path*", "/dashboard/:path*",
"/users/:path*", "/users/:path*",
], ],
}; };

View file

@ -1,5 +1,23 @@
export type UserRole = "admin" | "manager" | "user";
export interface User { export interface User {
id: number; id: number;
username: string; first_name: string;
email: string; last_name: string;
} username: string;
email: string;
role: UserRole;
is_active: boolean;
created_at: string;
updated_at: string;
}
export type UserPayload = {
first_name: string;
last_name: string;
username: string;
email: string;
role: UserRole;
is_active: boolean;
password?: string;
};