diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a28a16 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/backend/hermes/alembic/versions/3f4c7b8d1e2a_extend_users_enterprise_fields.py b/backend/hermes/alembic/versions/3f4c7b8d1e2a_extend_users_enterprise_fields.py new file mode 100644 index 0000000..3d414ea --- /dev/null +++ b/backend/hermes/alembic/versions/3f4c7b8d1e2a_extend_users_enterprise_fields.py @@ -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") diff --git a/backend/hermes/app/api/auth.py b/backend/hermes/app/api/auth.py index b209265..c3609ee 100644 --- a/backend/hermes/app/api/auth.py +++ b/backend/hermes/app/api/auth.py @@ -1,10 +1,14 @@ -from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi import APIRouter, Depends, HTTPException 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.models.user import User -from app.schemas.user import LoginRequest +from app.schemas.user import LoginRequest, LoginResponse router = APIRouter( prefix="/auth", @@ -12,32 +16,30 @@ router = APIRouter( ) -@router.post("/login") +@router.post("/login", response_model=LoginResponse) def login( login: LoginRequest, - response: Response, db: Session = Depends(get_db), ): user = db.query(User).filter(User.username == login.username).first() 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): - raise HTTPException(status_code=401, detail="Ungültige Anmeldedaten") + raise HTTPException( + status_code=401, + detail="Ungültige Anmeldedaten", + ) token = create_access_token(user.username) - response.set_cookie( - key="access_token", - value=token, - httponly=True, - secure=False, - samesite="lax", - max_age=3600, - ) - return { - "message": "Login erfolgreich", - "user": user.username, - } \ No newline at end of file + "access_token": token, + "token_type": "bearer", + "expires_in": get_access_token_expire_seconds(), + "user": user, + } diff --git a/backend/hermes/app/api/users.py b/backend/hermes/app/api/users.py index e1cb0d3..5b834bc 100644 --- a/backend/hermes/app/api/users.py +++ b/backend/hermes/app/api/users.py @@ -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 app.core.security import verify_access_token from app.db.database import get_db +from app.models.user import User 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( 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) -@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( user: UserCreate, - db: Session = Depends(get_db) + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): - return UserRepository.create(db, user) \ No newline at end of file + 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) diff --git a/backend/hermes/app/core/config.py b/backend/hermes/app/core/config.py index 95ebea4..8a19737 100644 --- a/backend/hermes/app/core/config.py +++ b/backend/hermes/app/core/config.py @@ -7,6 +7,8 @@ class Settings(BaseSettings): app_name: str = "Hermes API" app_version: str = "0.1.0" + access_token_expire_minutes: int = 60 + jwt_issuer: str = "hermes" model_config = SettingsConfigDict( env_file=".env", diff --git a/backend/hermes/app/core/security.py b/backend/hermes/app/core/security.py index 915ca85..c53f5de 100644 --- a/backend/hermes/app/core/security.py +++ b/backend/hermes/app/core/security.py @@ -1,6 +1,7 @@ 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 app.core.config import settings @@ -8,7 +9,11 @@ from app.core.config import settings password_hash = PasswordHash.recommended() 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: @@ -20,11 +25,15 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: 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 = { "sub": subject, + "iat": issued_at, "exp": expire, + "iss": settings.jwt_issuer, + "type": ACCESS_TOKEN_TYPE, } return jwt.encode( @@ -32,3 +41,31 @@ def create_access_token(subject: str) -> str: settings.secret_key, 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 diff --git a/backend/hermes/app/main.py b/backend/hermes/app/main.py index 33a25d2..bc7ee85 100644 --- a/backend/hermes/app/main.py +++ b/backend/hermes/app/main.py @@ -1,5 +1,12 @@ +import logging + 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.users import router as users_router @@ -11,21 +18,52 @@ app = FastAPI( 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(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("/") def root(): @@ -40,4 +78,4 @@ def health(): return { "status": "healthy", "database": check_database(), - } \ No newline at end of file + } diff --git a/backend/hermes/app/models/user.py b/backend/hermes/app/models/user.py index fc393e9..eb0868f 100644 --- a/backend/hermes/app/models/user.py +++ b/backend/hermes/app/models/user.py @@ -1,4 +1,9 @@ +from datetime import datetime + +from sqlalchemy import Boolean +from sqlalchemy import DateTime from sqlalchemy import String +from sqlalchemy import func from sqlalchemy.orm import Mapped from sqlalchemy.orm import mapped_column @@ -10,6 +15,18 @@ class User(Base): 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( String(50), unique=True, @@ -23,4 +40,27 @@ class User(Base): password_hash: Mapped[str] = mapped_column( String(255) - ) \ No newline at end of file + ) + + 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() + ) diff --git a/backend/hermes/app/repositories/user_repository.py b/backend/hermes/app/repositories/user_repository.py index 81aa4e2..4877b78 100644 --- a/backend/hermes/app/repositories/user_repository.py +++ b/backend/hermes/app/repositories/user_repository.py @@ -1,27 +1,98 @@ +from sqlalchemy import select 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.models.user import User +from app.schemas.user import UserCreate, UserPasswordUpdate, UserUpdate class UserRepository: + @staticmethod + def get_all(db: Session) -> list[User]: + return list(db.scalars(select(User).order_by(User.created_at.desc()))) @staticmethod - def get_all(db: Session): - return db.query(User).all() + def get_by_id(db: Session, user_id: int) -> User | None: + return db.get(User, user_id) @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( - username=user.username, - email=user.email, - password_hash=hash_password(user.password), -) + first_name=user.first_name, + last_name=user.last_name, + 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.commit() db.refresh(db_user) - return db_user \ No newline at end of file + 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() diff --git a/backend/hermes/app/schemas/user.py b/backend/hermes/app/schemas/user.py index fcf596c..d622eaa 100644 --- a/backend/hermes/app/schemas/user.py +++ b/backend/hermes/app/schemas/user.py @@ -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): - username: str +class UserBase(BaseModel): + 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 - 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): username: str - password: str + password: str class UserResponse(BaseModel): id: int + first_name: str + last_name: str username: str email: EmailStr + role: UserRole + is_active: bool + created_at: datetime + updated_at: datetime - model_config = { - "from_attributes": True - } \ No newline at end of file + model_config = ConfigDict(from_attributes=True) + + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" + expires_in: int + user: UserResponse diff --git a/backend/hermes/docker-compose.yml b/backend/hermes/docker-compose.yml index 9a42c66..1f0635e 100644 --- a/backend/hermes/docker-compose.yml +++ b/backend/hermes/docker-compose.yml @@ -8,6 +8,9 @@ services: DATABASE_URL: postgresql+psycopg://olympus:FsFs03285310!!!@olympus-db:5432/olympus APP_NAME: Hermes API APP_VERSION: 0.1.0 + SECRET_KEY: ${SECRET_KEY} + ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} + JWT_ISSUER: ${JWT_ISSUER:-hermes} ports: - "8000:8000" diff --git a/docker-compose.yml b/docker-compose.yml index 91093de..f8ce432 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,7 @@ services: build: context: ./backend/hermes dockerfile: dockerfile + container_name: hermes-api restart: unless-stopped @@ -11,6 +12,8 @@ services: APP_NAME: Hermes API APP_VERSION: 0.1.0 SECRET_KEY: ${SECRET_KEY} + ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} + JWT_ISSUER: ${JWT_ISSUER:-hermes} ports: - "8000:8000" @@ -22,12 +25,15 @@ services: build: context: ./frontend/athena dockerfile: Dockerfile + container_name: athena-web restart: unless-stopped environment: 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: - "3001:3000" diff --git a/frontend/athena/app/api/login/route.ts b/frontend/athena/app/api/login/route.ts new file mode 100644 index 0000000..86b12b5 --- /dev/null +++ b/frontend/athena/app/api/login/route.ts @@ -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; +} diff --git a/frontend/athena/app/api/logout/route.ts b/frontend/athena/app/api/logout/route.ts new file mode 100644 index 0000000..dd2740d --- /dev/null +++ b/frontend/athena/app/api/logout/route.ts @@ -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; +} diff --git a/frontend/athena/app/api/users/[id]/route.ts b/frontend/athena/app/api/users/[id]/route.ts new file mode 100644 index 0000000..4a26b7b --- /dev/null +++ b/frontend/athena/app/api/users/[id]/route.ts @@ -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); +} diff --git a/frontend/athena/app/api/users/route.ts b/frontend/athena/app/api/users/route.ts new file mode 100644 index 0000000..ab8fcac --- /dev/null +++ b/frontend/athena/app/api/users/route.ts @@ -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); +} diff --git a/frontend/athena/app/login/page.tsx b/frontend/athena/app/login/page.tsx index 9bc693c..f9cbbcc 100644 --- a/frontend/athena/app/login/page.tsx +++ b/frontend/athena/app/login/page.tsx @@ -1,24 +1,20 @@ "use client"; import { useState } from "react"; -import { useRouter } from "next/navigation"; export default function LoginPage() { - const router = useRouter(); - const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); async function handleLogin(e: React.FormEvent) { e.preventDefault(); + setError(""); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/auth/login`, - { + try { + const response = await fetch("/api/login", { method: "POST", - credentials: "include", headers: { "Content-Type": "application/json", }, @@ -26,15 +22,20 @@ export default function LoginPage() { username, password, }), + }); + + const data = await response.json(); + + if (response.ok) { + window.location.href = "/dashboard"; + return; } - ); - if (response.ok) { - router.push("/dashboard"); - return; + setError(data.detail ?? "Login fehlgeschlagen"); + } catch (err) { + console.error(err); + setError("Technischer Fehler"); } - - setError("Benutzername oder Passwort ist falsch."); } return ( @@ -63,12 +64,11 @@ export default function LoginPage() { /> {error && ( -

- {error} -

+

{error}

)} + + + + +
+

Informationen

+
+ + + + + +
+
Status
+
+
+ + +
+
+ + + + + +
+ + Passwort ändern + + Das Passwort wird serverseitig mit pwdlib gehasht. + + + +
+ + setPassword(event.target.value)} + /> + {passwordError &&

{passwordError}

} +
+ + + + + +
+
+
+ + ); +} + +function Detail({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/frontend/athena/app/users/page.tsx b/frontend/athena/app/users/page.tsx index 2ab2886..b2b75f3 100644 --- a/frontend/athena/app/users/page.tsx +++ b/frontend/athena/app/users/page.tsx @@ -1,66 +1,306 @@ "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 { User } from "@/types/user"; - -import UserDialog from "@/components/users/UserDialog"; +import ConfirmDialog from "@/components/common/ConfirmDialog"; +import SearchInput from "@/components/common/SearchInput"; +import { Button } from "@/components/ui/button"; +import UserFormDialog from "@/components/users/UserFormDialog"; 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() { + const [users, setUsers] = useState([]); + 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("all"); + const [statusFilter, setStatusFilter] = useState("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(null); + const [deleteUser, setDeleteUser] = useState(null); - const [users, setUsers] = useState([]); + const loadUsers = useCallback(async () => { + try { + const response = await api.get("/users"); + setUsers(response.data); + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + }, []); - useEffect(() => { - loadUsers(); - }, []); + useEffect(() => { + queueMicrotask(() => { + void loadUsers(); + }); + }, [loadUsers]); - function loadUsers() { + const filteredUsers = useMemo(() => { + const term = search.trim().toLowerCase(); - api - .get("/users/") - .then((res) => { + return users + .filter((user) => { + 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); - }) - .catch((err) => { + if (firstValue === secondValue) { + 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); + } -
+ function openEditDialog(user: User) { + setSelectedUser(user); + setFormError(""); + setFormOpen(true); + } -

+ async function saveUser(payload: UserPayload) { + setPending(true); + setFormError(""); - Benutzer + try { + if (selectedUser) { + const response = await api.put(`/users/${selectedUser.id}`, payload); + setUsers((current) => current.map((user) => ( + user.id === selectedUser.id ? response.data : user + ))); + } else { + const response = await api.post("/users", payload); + setUsers((current) => [response.data, ...current]); + } -

+ setFormOpen(false); + } catch (err) { + setFormError(getErrorMessage(err)); + } finally { + setPending(false); + } + } - + 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 ( +
+
+
+

Benutzer

+

+ {filteredUsers.length} von {users.length} Benutzern +

+
+ + +
+ +
+ { + setSearch(value); + setPage(1); + }} + placeholder="Benutzer suchen" + /> + + + + +
+ + + +
+ + Seite {currentPage} von {pageCount} + +
+ + +
+
+ + + + { + if (!open) { + setDeleteUser(null); + setDeleteError(""); + } + }} + onConfirm={confirmDelete} + > + {deleteUser && ( +
+
+
Benutzername
+
{deleteUser.username}
- -
-
-                {JSON.stringify(users, null, 2)}
-
-            
- - - - - - ); - -} \ No newline at end of file +
+
E-Mail
+
{deleteUser.email}
+
+
+
Rolle
+
{deleteUser.role}
+
+ {deleteError &&

{deleteError}

} +
+ )} +
+
+ ); +} diff --git a/frontend/athena/components/Header.tsx b/frontend/athena/components/Header.tsx index b609a78..342dbe8 100644 --- a/frontend/athena/components/Header.tsx +++ b/frontend/athena/components/Header.tsx @@ -1,3 +1,5 @@ +import LogoutButton from "@/components/LogoutButton"; + export default function Header() { return ( @@ -10,14 +12,20 @@ export default function Header() { -
+
+ + admin.schubert + + + +
); -} \ No newline at end of file +} diff --git a/frontend/athena/components/LogoutButton.tsx b/frontend/athena/components/LogoutButton.tsx new file mode 100644 index 0000000..1f24b7b --- /dev/null +++ b/frontend/athena/components/LogoutButton.tsx @@ -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 ( + + ); +} diff --git a/frontend/athena/components/Sidebar.tsx b/frontend/athena/components/Sidebar.tsx index dcbe34b..053c0d9 100644 --- a/frontend/athena/components/Sidebar.tsx +++ b/frontend/athena/components/Sidebar.tsx @@ -13,7 +13,7 @@ const menu = [ { icon: LayoutDashboard, name: "Dashboard", - href: "/", + href: "/dashboard", }, { icon: Users, @@ -83,4 +83,4 @@ export default function Sidebar() { ); -} \ No newline at end of file +} diff --git a/frontend/athena/components/common/ConfirmDialog.tsx b/frontend/athena/components/common/ConfirmDialog.tsx new file mode 100644 index 0000000..a3e1b37 --- /dev/null +++ b/frontend/athena/components/common/ConfirmDialog.tsx @@ -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 ( + + + + {title} + {description} + + + {children &&
{children}
} + + + + + +
+
+ ); +} diff --git a/frontend/athena/components/common/DataTable.tsx b/frontend/athena/components/common/DataTable.tsx new file mode 100644 index 0000000..abf80d2 --- /dev/null +++ b/frontend/athena/components/common/DataTable.tsx @@ -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 = { + key: string; + label: string; + sortable?: boolean; + className?: string; + render: (row: T) => ReactNode; +}; + +type Props = { + columns: DataTableColumn[]; + 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({ + columns, + rows, + rowKey, + sortKey, + sortDirection, + loading = false, + error, + emptyTitle, + emptyDescription, + onSort, +}: Props) { + return ( +
+
+ + + + {columns.map((column) => { + const active = sortKey === column.key; + const SortIcon = active + ? sortDirection === "asc" + ? ArrowUp + : ArrowDown + : ArrowUpDown; + + return ( + + ); + })} + + + + + {loading && ( + + + + )} + + {!loading && error && ( + + + + )} + + {!loading && !error && rows.length === 0 && ( + + + + )} + + {!loading && !error && rows.map((row) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
+ {column.sortable ? ( + + ) : ( + column.label + )} +
+ Daten werden geladen... +
+ {error} +
+

{emptyTitle}

+

{emptyDescription}

+
+ {column.render(row)} +
+
+
+ ); +} diff --git a/frontend/athena/components/common/SearchInput.tsx b/frontend/athena/components/common/SearchInput.tsx new file mode 100644 index 0000000..77ec0a9 --- /dev/null +++ b/frontend/athena/components/common/SearchInput.tsx @@ -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 ( +
+ + onChange(event.target.value)} + placeholder={placeholder} + className="pl-8" + /> +
+ ); +} diff --git a/frontend/athena/components/common/StatusBadge.tsx b/frontend/athena/components/common/StatusBadge.tsx new file mode 100644 index 0000000..c99d92d --- /dev/null +++ b/frontend/athena/components/common/StatusBadge.tsx @@ -0,0 +1,20 @@ +import { cn } from "@/lib/utils"; + +type Props = { + active: boolean; +}; + +export default function StatusBadge({ active }: Props) { + return ( + + {active ? "Aktiv" : "Inaktiv"} + + ); +} diff --git a/frontend/athena/components/users/UserDialog.tsx b/frontend/athena/components/users/UserDialog.tsx deleted file mode 100644 index a76cd9f..0000000 --- a/frontend/athena/components/users/UserDialog.tsx +++ /dev/null @@ -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 ( - - <> - - - - - - - - - - - - Neuer Benutzer - - - - - -
- -
- - - - setUsername(e.target.value)} - /> - -
- -
- - - - setEmail(e.target.value)} - /> - -
- -
- - - - setPassword(e.target.value)} - /> - -
- -
- - - - - - - -
- -
- - - - ); - -} \ No newline at end of file diff --git a/frontend/athena/components/users/UserFormDialog.tsx b/frontend/athena/components/users/UserFormDialog.tsx new file mode 100644 index 0000000..455aefc --- /dev/null +++ b/frontend/athena/components/users/UserFormDialog.tsx @@ -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; +}; + +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 ( + + + {open && ( + onOpenChange(false)} + onSubmit={onSubmit} + /> + )} + + + ); +} + +function UserForm({ + initialForm, + isEditing, + pending, + serverError, + onCancel, + onSubmit, +}: { + initialForm: UserPayload; + isEditing: boolean; + pending: boolean; + serverError?: string; + onCancel: () => void; + onSubmit: (payload: UserPayload) => Promise; +}) { + const [form, setForm] = useState(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(key: K, value: UserPayload[K]) { + setForm((current) => ({ + ...current, + [key]: value, + })); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + + if (!isValid) { + return; + } + + const payload = { + ...form, + password: form.password?.trim() || undefined, + }; + + await onSubmit(payload); + } + + return ( +
+ + {isEditing ? "Benutzer bearbeiten" : "Benutzer erstellen"} + + Stammdaten, Rolle und Status des Benutzers verwalten. + + + +
+ + updateField("first_name", event.target.value)} + /> + + + + updateField("last_name", event.target.value)} + /> + + + + updateField("username", event.target.value)} + /> + + + + updateField("email", event.target.value)} + /> + + + + + + + + + + + + updateField("password", event.target.value)} + /> + +
+ + {serverError &&

{serverError}

} + + + + + +
+ ); +} + +function Field({ + label, + error, + children, +}: { + label: string; + error?: string; + children: ReactNode; +}) { + return ( +
+ + {children} + {error &&

{error}

} +
+ ); +} diff --git a/frontend/athena/components/users/UserTable.tsx b/frontend/athena/components/users/UserTable.tsx index bf83418..21e636c 100644 --- a/frontend/athena/components/users/UserTable.tsx +++ b/frontend/athena/components/users/UserTable.tsx @@ -1,63 +1,133 @@ "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 = { 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[] = [ + { + 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) => {user.username}, + }, + { + 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) => , + }, + { + 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) => ( +
+ + + + + +
+ ), + }, + ]; + return ( -
- - - - - - - - - - - - - - - - - - - {users.map((user) => ( - - - - - - - - - - - - ))} - - - -
IDBenutzernameE-MailAktionen
{user.id}{user.username}{user.email} - - - - - -
- -
+ user.id} + sortKey={sortKey} + sortDirection={sortDirection} + loading={loading} + error={error} + emptyTitle="Keine Benutzer gefunden" + emptyDescription="Passe Suche oder Filter an oder erstelle einen neuen Benutzer." + onSort={onSort} + /> ); -} \ No newline at end of file +} diff --git a/frontend/athena/docker-compose.yml b/frontend/athena/docker-compose.yml index 0d8decc..29db78c 100644 --- a/frontend/athena/docker-compose.yml +++ b/frontend/athena/docker-compose.yml @@ -6,7 +6,9 @@ services: environment: 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: - "3001:3000" diff --git a/frontend/athena/lib/api.ts b/frontend/athena/lib/api.ts index e57d862..02f43a1 100644 --- a/frontend/athena/lib/api.ts +++ b/frontend/athena/lib/api.ts @@ -1,5 +1,5 @@ import axios from "axios"; export const api = axios.create({ - baseURL: "http://localhost:8000", -}); \ No newline at end of file + baseURL: "/api", +}); diff --git a/frontend/athena/lib/server/hermes.ts b/frontend/athena/lib/server/hermes.ts new file mode 100644 index 0000000..2369a8e --- /dev/null +++ b/frontend/athena/lib/server/hermes.ts @@ -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, + }); +} diff --git a/frontend/athena/lib/server/request-guards.ts b/frontend/athena/lib/server/request-guards.ts new file mode 100644 index 0000000..f56f82a --- /dev/null +++ b/frontend/athena/lib/server/request-guards.ts @@ -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; +} diff --git a/frontend/athena/middleware.ts b/frontend/athena/proxy.ts similarity index 53% rename from frontend/athena/middleware.ts rename to frontend/athena/proxy.ts index cd079d2..18f7e1a 100644 --- a/frontend/athena/middleware.ts +++ b/frontend/athena/proxy.ts @@ -1,9 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; -export function middleware(request: NextRequest) { +export function proxy(request: NextRequest) { 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)); } @@ -12,7 +17,8 @@ export function middleware(request: NextRequest) { export const config = { matcher: [ + "/login", "/dashboard/:path*", "/users/:path*", ], -}; \ No newline at end of file +}; diff --git a/frontend/athena/types/user.ts b/frontend/athena/types/user.ts index e09fd9a..3c5f568 100644 --- a/frontend/athena/types/user.ts +++ b/frontend/athena/types/user.ts @@ -1,5 +1,23 @@ +export type UserRole = "admin" | "manager" | "user"; + export interface User { - id: number; - username: string; - email: string; -} \ No newline at end of file + id: number; + first_name: 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; +};