feat: FZA Einzelstunden + Security-Fixes (K-1–K-5, H-2–H-4, M-1/M-3/M-6)
FZA Einzelstunden:
- Absence.fza_hours (Numeric 5,2) — FZA in Stunden statt Tagen
- Migration 0032: fza_hours Spalte in absences
- AbsenceCreate/AbsenceOut Schema um fza_hours erweitert
- absence_service: _deduct/_refund_overtime nutzt fza_hours direkt wenn gesetzt
- Frontend: Tage/Stunden-Toggle im FZA-Antrag-Modal
Security K-1: Privilege Escalation via PATCH /users/{id}.role
- user_service: Whitelist für Rollenänderungen, SUPER_ADMIN nur durch SUPER_ADMIN
- Letzter COMPANY_ADMIN gegen Selbst-Demotion gesichert
Security K-2: Kiosk-IP-Whitelist hinter nginx
- kiosk_security: _get_client_ip() liest X-Real-IP statt request.client.host
Security K-3: Kiosk-PIN Brute-Force-Schutz
- kiosk_auth_service: Redis-Lockout nach 5 Fehlversuchen (15 min)
Security K-4: TOTP-Setup-Hijacking
- auth router: /totp/setup abgelehnt wenn TOTP bereits aktiv
Security K-5: Separater Fernet-Key
- config: SECRET_KEY_DATA Feld (optional, Fallback auf SECRET_KEY)
- crypto: get_fernet_key() mit Warning bei fehlendem SECRET_KEY_DATA
Security H-2: Vacation Balance nur HR/Admin
- absences router: PATCH /balance nur noch HR/COMPANY_ADMIN/SUPER_ADMIN + AuditLog
Security H-3: Rate-Limits auf /auth/refresh + /auth/logout
- auth router: 30/min auf refresh, 60/min auf logout
Security H-4: Login-Failure-Logging + Lockout
- auth_service: Redis-Counter, Lockout nach 10 Versuchen (15 min)
- AuditLog für login_success und login_failed
Security M-1: Nginx Security-Header
- nginx.conf: X-Frame-Options, X-Content-Type-Options, CSP, Referrer-Policy, X-XSS-Protection, Permissions-Policy
Security M-3: AuditLog bei Rollenänderungen
- user_service: action=role_changed mit old/new role
Security M-6: create_all nur in Development
- main.py: Base.metadata.create_all nur wenn not settings.is_production
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1576,3 +1576,83 @@ Keine Commits in dieser Session.
|
||||
- frontend/src/pages/CompanySettingsPage.tsx | 161 ++++++++++++++++++++-
|
||||
|
||||
---
|
||||
## 2026-05-26 10:39 – 10:39 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** timemaster
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 18 ++
|
||||
- frontend/src/pages/mobile/MobileTodayScreen.tsx | 225 ++++++++++++++++++------
|
||||
|
||||
---
|
||||
## 2026-05-26 10:40 – 10:41 (1m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** timemaster
|
||||
|
||||
### Commits
|
||||
- c8578f6 chore: CLAUDE.md aus Git-Tracking entfernen
|
||||
|
||||
### Geänderte Dateien
|
||||
- .gitignore | 1 +
|
||||
- CLAUDE.md | 347 -------------------------------------------------------------
|
||||
|
||||
---
|
||||
## 2026-05-26 10:41 – 10:42 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** timemaster
|
||||
|
||||
### Commits
|
||||
- c9cb6d7 chore: .claude/ aus Git-Tracking entfernen
|
||||
|
||||
### Geänderte Dateien
|
||||
- .claude/agents/code-optimizer.md | 90 ------------------------------------
|
||||
- .claude/agents/frontend.md | 94 --------------------------------------
|
||||
- .claude/agents/security-auditor.md | 92 -------------------------------------
|
||||
- .gitignore | 1 +
|
||||
|
||||
---
|
||||
## 2026-05-26 10:42 – 10:43 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** timemaster
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- .claude/agents/code-optimizer.md | 90 ------------------------------------
|
||||
- .claude/agents/frontend.md | 94 --------------------------------------
|
||||
- .claude/agents/security-auditor.md | 92 -------------------------------------
|
||||
- .gitignore | 1 +
|
||||
|
||||
---
|
||||
## 2026-05-26 10:51 – 10:54 (3m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** timemaster
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- .claude/agents/code-optimizer.md | 90 ------------------------------------
|
||||
- .claude/agents/frontend.md | 94 --------------------------------------
|
||||
- .claude/agents/security-auditor.md | 92 -------------------------------------
|
||||
- .gitignore | 1 +
|
||||
|
||||
---
|
||||
## 2026-05-26 10:56 – 11:07 (11m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** timemaster
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- .claude/agents/code-optimizer.md | 90 ------------------------------------
|
||||
- .claude/agents/frontend.md | 94 --------------------------------------
|
||||
- .claude/agents/security-auditor.md | 92 -------------------------------------
|
||||
- .gitignore | 1 +
|
||||
|
||||
---
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic import model_validator
|
||||
from pydantic import Field, model_validator
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
@@ -10,6 +10,12 @@ class Settings(BaseSettings):
|
||||
app_name: str = "TimeMaster"
|
||||
app_env: str = "development"
|
||||
secret_key: str = "change-me-in-production"
|
||||
|
||||
# Separater Schlüssel für Fernet-Datenverschlüsselung (CalDAV/LDAP/SMTP-Passwörter, TOTP-Secrets).
|
||||
# Empfohlen: in .env als SECRET_KEY_DATA=<zufälliger-string-32+-zeichen> setzen.
|
||||
# Wenn nicht gesetzt, wird SECRET_KEY als Fallback verwendet (Warnung beim Start).
|
||||
# WICHTIG: Nach erstem Setzen NICHT mehr ändern – alle verschlüsselten DB-Werte werden unlesbar!
|
||||
secret_key_data: str | None = Field(None, validation_alias="SECRET_KEY_DATA")
|
||||
frontend_url: str = "http://localhost:5173"
|
||||
allowed_hosts: list[str] = []
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
Zentrale Krypto-Hilfsfunktionen für TimeMaster.
|
||||
|
||||
Verwendet Fernet-Verschlüsselung (AES-128-CBC + HMAC-SHA256).
|
||||
Der Schlüssel wird aus SECRET_KEY per SHA-256 abgeleitet.
|
||||
Der Schlüssel wird per SHA-256 abgeleitet aus:
|
||||
- SECRET_KEY_DATA (empfohlen, separater Key für Datenverschlüsselung)
|
||||
- SECRET_KEY (Fallback wenn SECRET_KEY_DATA nicht gesetzt – Warnung beim Start)
|
||||
|
||||
Verwendung:
|
||||
from app.core.crypto import encrypt_value, decrypt_value
|
||||
@@ -14,16 +16,40 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_fernet_key() -> bytes:
|
||||
"""Gibt den Fernet-Key zurück.
|
||||
|
||||
Bevorzugt SECRET_KEY_DATA (separater Datenschlüssel).
|
||||
Fällt auf SECRET_KEY zurück wenn SECRET_KEY_DATA nicht gesetzt ist,
|
||||
und gibt dabei eine Warnung aus (JWT- und Datenschlüssel identisch).
|
||||
|
||||
Der Key wird per SHA-256 auf 32 Bytes normiert und dann base64url-kodiert.
|
||||
"""
|
||||
if settings.secret_key_data:
|
||||
key_material = settings.secret_key_data
|
||||
else:
|
||||
logger.warning(
|
||||
"SECRET_KEY_DATA nicht gesetzt — JWT-Key wird auch für Datenverschlüsselung "
|
||||
"verwendet. Bitte SECRET_KEY_DATA in .env setzen für verbesserte Sicherheit."
|
||||
)
|
||||
key_material = settings.secret_key
|
||||
|
||||
key_bytes = hashlib.sha256(key_material.encode()).digest()
|
||||
return base64.urlsafe_b64encode(key_bytes)
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
"""Erstellt eine Fernet-Instanz aus dem konfigurierten SECRET_KEY."""
|
||||
key = hashlib.sha256(settings.secret_key.encode()).digest()
|
||||
return Fernet(base64.urlsafe_b64encode(key))
|
||||
"""Erstellt eine Fernet-Instanz aus dem konfigurierten Datenschlüssel."""
|
||||
return Fernet(get_fernet_key())
|
||||
|
||||
|
||||
def encrypt_value(plain: str) -> str:
|
||||
|
||||
@@ -114,6 +114,22 @@ def _load_ed25519_public_key(public_key_str: str) -> Ed25519PublicKey:
|
||||
raise ValueError("Unbekanntes Schlüsselformat. PEM oder OpenSSH erwartet.")
|
||||
|
||||
|
||||
# ── Client-IP ermitteln (nginx-Proxy-sicher) ─────────────────────────────────
|
||||
|
||||
def _get_client_ip(request: Request) -> str:
|
||||
"""Liest die echte Client-IP auch hinter nginx-Proxy.
|
||||
|
||||
nginx setzt X-Real-IP auf die echte Client-IP.
|
||||
Ohne diesen Header würde request.client.host hinter nginx immer
|
||||
127.0.0.1 zurückgeben, womit IP-Whitelisting wirkungslos wäre.
|
||||
"""
|
||||
real_ip = request.headers.get("X-Real-IP")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
# Fallback: direkte Verbindungs-IP (lokal/dev)
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
# ── IP-Whitelist prüfen ───────────────────────────────────────────────────────
|
||||
|
||||
def _check_ip_whitelist(client_ip: str, ip_whitelist: str) -> bool:
|
||||
@@ -213,8 +229,8 @@ async def verify_kiosk_request(
|
||||
|
||||
# 4. IP-Whitelist prüfen (optional)
|
||||
if device.ip_whitelist:
|
||||
client_ip = request.client.host if request.client else ""
|
||||
if not client_ip:
|
||||
client_ip = _get_client_ip(request)
|
||||
if not client_ip or client_ip == "unknown":
|
||||
raise HTTPException(status_code=403, detail="Client-IP nicht ermittelbar, IP-Whitelist aktiv.")
|
||||
if not _check_ip_whitelist(client_ip, device.ip_whitelist):
|
||||
raise HTTPException(
|
||||
|
||||
+10
-3
@@ -21,9 +21,16 @@ from app.routers import hours_payouts
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: Tabellen anlegen falls noch nicht vorhanden (Alembic übernimmt das in Prod)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Startup: Tabellen anlegen nur in Development/Test.
|
||||
# In Production verwaltet Alembic das Schema – create_all würde mit Migrationen kollidieren.
|
||||
import logging
|
||||
_log = logging.getLogger(__name__)
|
||||
if not settings.is_production:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
_log.info("Development-Modus: create_all ausgeführt.")
|
||||
else:
|
||||
_log.info("Production-Modus: create_all übersprungen — Alembic verwaltet das Schema.")
|
||||
yield
|
||||
# Shutdown
|
||||
await engine.dispose()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import uuid
|
||||
import enum
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, Numeric, String, Text, func
|
||||
@@ -56,6 +57,9 @@ class Absence(Base):
|
||||
# business_trip: {"destination": str, "purpose": str}
|
||||
meta: Mapped[dict | None] = mapped_column(JSONB)
|
||||
|
||||
# FZA in Stunden statt Tagen (bei Stunden-FZA ist start_date == end_date)
|
||||
fza_hours: Mapped[Decimal | None] = mapped_column(Numeric(5, 2))
|
||||
|
||||
# Krankheit: Arbeitsunfähigkeitsbescheinigung
|
||||
certificate_required_by: Mapped[date | None] = mapped_column(Date)
|
||||
certificate_received_at: Mapped[date | None] = mapped_column(Date)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
@@ -375,20 +375,46 @@ async def mark_certificate_received(
|
||||
async def update_balance(
|
||||
user_id: UUID,
|
||||
data: VacationBalanceUpdate,
|
||||
current_user: User = require_role(*_manager_roles),
|
||||
request: Request,
|
||||
current_user: User = require_role(UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
year: int = Query(...),
|
||||
):
|
||||
"""Grundurlaub, Sondertage und Resturlaub eines Mitarbeiters anpassen."""
|
||||
from app.models.vacation_balance import VacationBalance
|
||||
from app.models.audit_log import AuditLog
|
||||
target = await db.get(User, user_id)
|
||||
if target is None or target.company_id != current_user.company_id:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(404, "Mitarbeiter nicht gefunden")
|
||||
|
||||
balance = await absence_service.get_balance(user_id, year, db)
|
||||
|
||||
# Alte Werte für AuditLog sichern
|
||||
old_base = balance.base_days
|
||||
old_special = balance.special_days
|
||||
old_carried = balance.carried_over_days
|
||||
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(balance, field, value)
|
||||
|
||||
# AuditLog schreiben
|
||||
db.add(AuditLog(
|
||||
user_id=current_user.id,
|
||||
action="update_vacation_balance",
|
||||
entity_type="vacation_balance",
|
||||
entity_id=balance.id,
|
||||
old_value={"base_days": old_base, "special_days": old_special, "carried_over_days": old_carried},
|
||||
new_value={
|
||||
"base_days": balance.base_days,
|
||||
"special_days": balance.special_days,
|
||||
"carried_over_days": balance.carried_over_days,
|
||||
"target_user_id": str(user_id),
|
||||
"year": year,
|
||||
},
|
||||
ip_address=request.client.host if request.client else None,
|
||||
))
|
||||
|
||||
await db.commit()
|
||||
pending = await absence_service.get_pending_days(user_id, year, db)
|
||||
company = await db.get(Company, current_user.company_id)
|
||||
|
||||
@@ -46,12 +46,14 @@ async def login(request: Request, data: LoginRequest, db: AsyncSession = Depends
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh(data: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||
@limiter.limit("30/minute")
|
||||
async def refresh(request: Request, data: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||
return await auth_service.refresh(data.refresh_token, db)
|
||||
|
||||
|
||||
@router.post("/logout", response_model=MessageResponse)
|
||||
async def logout(data: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||
@limiter.limit("60/minute")
|
||||
async def logout(request: Request, data: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||
await auth_service.logout(data.refresh_token, db)
|
||||
return MessageResponse(message="Logged out successfully")
|
||||
|
||||
@@ -132,6 +134,12 @@ def _totp_plain(user) -> str | None:
|
||||
@router.post("/totp/setup", response_model=TotpSetupResponse)
|
||||
async def totp_setup(current_user: CurrentUser):
|
||||
"""Generiert ein neues TOTP-Secret und gibt die otpauth-URI zurück. Noch nicht aktiviert."""
|
||||
# Fix K-4: Verhindere Überschreiben eines aktiven TOTP-Secrets mit gestohlenem Access-Token
|
||||
if current_user.totp_enabled:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="TOTP ist bereits aktiv. Bitte zuerst deaktivieren (POST /auth/totp/disable).",
|
||||
)
|
||||
import pyotp
|
||||
secret = pyotp.random_base32()
|
||||
issuer = "TimeMaster"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.models.absence import AbsenceStatus
|
||||
from app.models.absence_type import AbsenceCategory
|
||||
@@ -67,6 +68,7 @@ class AbsenceOut(BaseModel):
|
||||
half_day_start: bool
|
||||
half_day_end: bool
|
||||
working_days: float
|
||||
fza_hours: Decimal | None = None
|
||||
status: AbsenceStatus
|
||||
approved_by: uuid.UUID | None
|
||||
substitute_id: uuid.UUID | None
|
||||
@@ -87,10 +89,18 @@ class AbsenceCreate(BaseModel):
|
||||
substitute_id: uuid.UUID | None = None
|
||||
note: str | None = None
|
||||
for_user_id: uuid.UUID | None = None # HR/Admin: Abwesenheit für anderen Mitarbeiter anlegen
|
||||
fza_hours: Decimal | None = Field(
|
||||
None,
|
||||
ge=Decimal("0.25"),
|
||||
le=Decimal("24"),
|
||||
description="FZA in Stunden (statt Tagen); nur bei eintägigem Zeitraum erlaubt.",
|
||||
)
|
||||
|
||||
def model_post_init(self, __context) -> None:
|
||||
if self.end_date < self.start_date:
|
||||
raise ValueError("end_date must be >= start_date")
|
||||
if self.fza_hours is not None and self.start_date != self.end_date:
|
||||
raise ValueError("fza_hours ist nur erlaubt wenn start_date == end_date (eintägiger FZA).")
|
||||
|
||||
|
||||
class AbsenceUpdate(BaseModel):
|
||||
|
||||
@@ -205,6 +205,7 @@ class AbsenceService:
|
||||
half_day_start=data.half_day_start,
|
||||
half_day_end=data.half_day_end,
|
||||
working_days=working_days,
|
||||
fza_hours=data.fza_hours if hasattr(data, "fza_hours") else None,
|
||||
status=status,
|
||||
approved_by=approved_by,
|
||||
substitute_id=data.substitute_id,
|
||||
@@ -314,7 +315,9 @@ class AbsenceService:
|
||||
# Überstunden zurückbuchen wenn Freizeitausgleich
|
||||
absence_type = await db.get(AbsenceType, absence.type_id)
|
||||
if absence_type and absence_type.affects_overtime_balance:
|
||||
await self._refund_overtime(absence.user_id, absence.working_days, db)
|
||||
await self._refund_overtime(
|
||||
absence.user_id, absence.working_days, db, fza_hours=absence.fza_hours
|
||||
)
|
||||
elif absence.status != AbsenceStatus.PENDING:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
@@ -380,7 +383,9 @@ class AbsenceService:
|
||||
# Überstundenkonto abziehen wenn Freizeitausgleich
|
||||
fza_warnings: list[str] = []
|
||||
if absence_type and absence_type.affects_overtime_balance:
|
||||
fza_warnings = await self._deduct_overtime(absence.user_id, absence.working_days, db)
|
||||
fza_warnings = await self._deduct_overtime(
|
||||
absence.user_id, absence.working_days, db, fza_hours=absence.fza_hours
|
||||
)
|
||||
|
||||
# Audit-Log (DSGVO)
|
||||
db.add(AuditLog(
|
||||
@@ -601,13 +606,17 @@ class AbsenceService:
|
||||
return daily_hours
|
||||
|
||||
async def _deduct_overtime(
|
||||
self, user_id: UUID, working_days: float, db: AsyncSession
|
||||
self, user_id: UUID, working_days: float, db: AsyncSession,
|
||||
fza_hours: "Decimal | None" = None,
|
||||
) -> list[str]:
|
||||
"""Zieht working_days × tägliche Stunden vom Überstundenkonto ab.
|
||||
"""Zieht working_days × tägliche Stunden (oder direkt fza_hours) vom Überstundenkonto ab.
|
||||
Gibt Warnungen zurück. Wirft HTTPException wenn Überziehen verboten ist."""
|
||||
user = await db.get(User, user_id)
|
||||
daily_hours = await self._calc_daily_hours(user_id, db)
|
||||
hours_to_deduct = Decimal(str(working_days)) * daily_hours
|
||||
if fza_hours is not None:
|
||||
hours_to_deduct = Decimal(str(fza_hours))
|
||||
else:
|
||||
daily_hours = await self._calc_daily_hours(user_id, db)
|
||||
hours_to_deduct = Decimal(str(working_days)) * daily_hours
|
||||
|
||||
ob = await db.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == user_id))
|
||||
if ob is None:
|
||||
@@ -647,11 +656,15 @@ class AbsenceService:
|
||||
return warnings
|
||||
|
||||
async def _refund_overtime(
|
||||
self, user_id: UUID, working_days: float, db: AsyncSession
|
||||
self, user_id: UUID, working_days: float, db: AsyncSession,
|
||||
fza_hours: "Decimal | None" = None,
|
||||
) -> None:
|
||||
"""Rückbuchung von Freizeitausgleichs-Stunden (z.B. bei Stornierung)."""
|
||||
daily_hours = await self._calc_daily_hours(user_id, db)
|
||||
hours_to_refund = Decimal(str(working_days)) * daily_hours
|
||||
if fza_hours is not None:
|
||||
hours_to_refund = Decimal(str(fza_hours))
|
||||
else:
|
||||
daily_hours = await self._calc_daily_hours(user_id, db)
|
||||
hours_to_refund = Decimal(str(working_days)) * daily_hours
|
||||
|
||||
ob = await db.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == user_id))
|
||||
if ob is not None:
|
||||
|
||||
@@ -20,6 +20,10 @@ from app.models import Company, PasswordReset, Session, User, UserRole
|
||||
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
|
||||
from app.services.email_service import email_service
|
||||
|
||||
# Login-Lockout-Konfiguration
|
||||
FAILED_LOGIN_MAX = 10 # nach 10 Fehlversuchen → Lockout
|
||||
FAILED_LOGIN_LOCKOUT_SEC = 900 # 15 Minuten gesperrt
|
||||
|
||||
|
||||
def _get_client_ip(request: "Request | None") -> str | None:
|
||||
"""Gibt die echte Client-IP zurück (berücksichtigt X-Forwarded-For hinter nginx-Proxy)."""
|
||||
@@ -39,6 +43,32 @@ def _slugify(name: str) -> str:
|
||||
|
||||
class AuthService:
|
||||
|
||||
async def _check_login_lockout(self, email: str, redis) -> None:
|
||||
"""Wirft HTTP 429 wenn Account wegen zu vieler Fehlversuche gesperrt ist."""
|
||||
lockout_key = f"login_lockout:{email.lower()}"
|
||||
if await redis.exists(lockout_key):
|
||||
ttl = await redis.ttl(lockout_key)
|
||||
wait_min = ttl // 60 + 1
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Account temporär gesperrt. Bitte {wait_min} Minute(n) warten.",
|
||||
)
|
||||
|
||||
async def _record_login_failure(self, email: str, redis) -> None:
|
||||
"""Zählt Fehlversuch und setzt Lockout nach FAILED_LOGIN_MAX Fehlversuchen."""
|
||||
fail_key = f"login_fails:{email.lower()}"
|
||||
lockout_key = f"login_lockout:{email.lower()}"
|
||||
fails = await redis.incr(fail_key)
|
||||
await redis.expire(fail_key, FAILED_LOGIN_LOCKOUT_SEC)
|
||||
if fails >= FAILED_LOGIN_MAX:
|
||||
await redis.set(lockout_key, "1", ex=FAILED_LOGIN_LOCKOUT_SEC)
|
||||
await redis.delete(fail_key)
|
||||
|
||||
async def _clear_login_failures(self, email: str, redis) -> None:
|
||||
"""Löscht Fehlversuche nach erfolgreichem Login."""
|
||||
await redis.delete(f"login_fails:{email.lower()}")
|
||||
await redis.delete(f"login_lockout:{email.lower()}")
|
||||
|
||||
async def register(self, data: RegisterRequest, db: AsyncSession) -> TokenResponse:
|
||||
existing = await db.scalar(select(User).where(User.email == data.email))
|
||||
if existing:
|
||||
@@ -74,42 +104,89 @@ class AuthService:
|
||||
return tokens
|
||||
|
||||
async def login(self, data: LoginRequest, db: AsyncSession, request: Request) -> TokenResponse:
|
||||
import redis.asyncio as aioredis
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user import AuthProvider
|
||||
from app.services.ldap_service import ldap_service
|
||||
|
||||
user = await db.scalar(select(User).where(User.email == data.email))
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
)
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="Account is deactivated")
|
||||
client_ip = _get_client_ip(request)
|
||||
|
||||
if user.auth_provider == AuthProvider.LDAP:
|
||||
ldap_cfg = await ldap_service.get_config(user.company_id, db)
|
||||
if not ldap_cfg or not ldap_cfg.enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="LDAP authentication not available",
|
||||
)
|
||||
if not ldap_service.authenticate_ldap(ldap_cfg, data.email, data.password):
|
||||
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||
try:
|
||||
# Lockout-Check vor DB-Abfrage (verhindert auch User-Enumeration via Timing)
|
||||
await self._check_login_lockout(data.email, redis_client)
|
||||
|
||||
user = await db.scalar(select(User).where(User.email == data.email))
|
||||
if not user:
|
||||
# Fehlversuch zählen auch bei unbekannter E-Mail (kein User-ID-Leak)
|
||||
await self._record_login_failure(data.email, redis_client)
|
||||
db.add(AuditLog(
|
||||
company_id=None,
|
||||
user_id=None,
|
||||
action="login_failed",
|
||||
entity_type="user",
|
||||
entity_id=None,
|
||||
new_value={"email": data.email},
|
||||
ip=client_ip,
|
||||
))
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
)
|
||||
else:
|
||||
if not user.password_hash or not verify_password(data.password, user.password_hash):
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="Account is deactivated")
|
||||
|
||||
auth_ok = False
|
||||
if user.auth_provider == AuthProvider.LDAP:
|
||||
ldap_cfg = await ldap_service.get_config(user.company_id, db)
|
||||
if not ldap_cfg or not ldap_cfg.enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="LDAP authentication not available",
|
||||
)
|
||||
auth_ok = ldap_service.authenticate_ldap(ldap_cfg, data.email, data.password)
|
||||
else:
|
||||
auth_ok = bool(user.password_hash and verify_password(data.password, user.password_hash))
|
||||
|
||||
if not auth_ok:
|
||||
await self._record_login_failure(data.email, redis_client)
|
||||
db.add(AuditLog(
|
||||
company_id=user.company_id,
|
||||
user_id=user.id,
|
||||
action="login_failed",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
new_value={"email": data.email},
|
||||
ip=client_ip,
|
||||
))
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
)
|
||||
|
||||
# Erfolgreicher Login: Fehlversuche zurücksetzen
|
||||
await self._clear_login_failures(data.email, redis_client)
|
||||
finally:
|
||||
await redis_client.aclose()
|
||||
|
||||
# AuditLog: Erfolgreicher Login
|
||||
db.add(AuditLog(
|
||||
company_id=user.company_id,
|
||||
user_id=user.id,
|
||||
action="login_success",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
ip=client_ip,
|
||||
))
|
||||
|
||||
# TOTP: wenn aktiviert → partial token zurückgeben statt vollem Login
|
||||
if user.totp_enabled:
|
||||
from app.core.security import create_partial_token
|
||||
from app.schemas.auth import TokenResponse
|
||||
partial = create_partial_token(str(user.id))
|
||||
await db.commit()
|
||||
return TokenResponse(
|
||||
access_token="",
|
||||
refresh_token="",
|
||||
|
||||
@@ -27,9 +27,52 @@ log = logging.getLogger(__name__)
|
||||
QR_TOKEN_PREFIX = "kiosk_qr:"
|
||||
QR_TOKEN_TTL = 5 * 60 # 5 Minuten
|
||||
|
||||
# PIN-Brute-Force-Schutz
|
||||
PIN_MAX_ATTEMPTS = 5
|
||||
PIN_LOCKOUT_SECONDS = 900 # 15 Minuten
|
||||
|
||||
|
||||
class KioskAuthService:
|
||||
|
||||
async def _check_pin_lockout(
|
||||
self, device_id: uuid.UUID, personnel_number: str, redis
|
||||
) -> None:
|
||||
"""Prüft ob PIN-Login für diese Kombination gesperrt ist. Wirft 429 wenn ja."""
|
||||
lockout_key = f"pin_lockout:{device_id}:{personnel_number}"
|
||||
if await redis.exists(lockout_key):
|
||||
ttl = await redis.ttl(lockout_key)
|
||||
wait_min = ttl // 60 + 1
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Zu viele Fehlversuche. Bitte {wait_min} Minute(n) warten.",
|
||||
)
|
||||
|
||||
async def _record_pin_failure(
|
||||
self, device_id: uuid.UUID, personnel_number: str, redis
|
||||
) -> None:
|
||||
"""Zählt einen Fehlversuch und sperrt bei Überschreitung von PIN_MAX_ATTEMPTS."""
|
||||
fail_key = f"pin_fails:{device_id}:{personnel_number}"
|
||||
lockout_key = f"pin_lockout:{device_id}:{personnel_number}"
|
||||
|
||||
fails = await redis.incr(fail_key)
|
||||
await redis.expire(fail_key, PIN_LOCKOUT_SECONDS)
|
||||
|
||||
if fails >= PIN_MAX_ATTEMPTS:
|
||||
await redis.set(lockout_key, "1", ex=PIN_LOCKOUT_SECONDS)
|
||||
await redis.delete(fail_key)
|
||||
log.warning(
|
||||
"PIN-Lockout ausgelöst: device=%s personnel_number=%s",
|
||||
device_id,
|
||||
personnel_number,
|
||||
)
|
||||
|
||||
async def _clear_pin_failures(
|
||||
self, device_id: uuid.UUID, personnel_number: str, redis
|
||||
) -> None:
|
||||
"""Löscht Fehlversuche nach erfolgreichem Login."""
|
||||
await redis.delete(f"pin_fails:{device_id}:{personnel_number}")
|
||||
await redis.delete(f"pin_lockout:{device_id}:{personnel_number}")
|
||||
|
||||
async def login_pin(
|
||||
self,
|
||||
personnel_number: str,
|
||||
@@ -39,24 +82,41 @@ class KioskAuthService:
|
||||
db: AsyncSession,
|
||||
) -> tuple[User, str]:
|
||||
"""Authentifizierung per Personalnummer + PIN. Returns (user, session_token)."""
|
||||
user = await db.scalar(
|
||||
select(User).where(
|
||||
User.company_id == company_id,
|
||||
User.personnel_number == personnel_number,
|
||||
User.is_active == True,
|
||||
)
|
||||
)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Personalnummer nicht gefunden.")
|
||||
import redis.asyncio as aioredis
|
||||
from app.core.config import settings
|
||||
|
||||
if not user.kiosk_pin_hash:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Kein PIN gesetzt. Bitte in den Profileinstellungen einen PIN vergeben.",
|
||||
)
|
||||
# Redis für Brute-Force-Schutz (async)
|
||||
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||
try:
|
||||
# 1. Lockout-Check vor DB-Abfrage (verhindert auch User-Enumeration via Timing)
|
||||
await self._check_pin_lockout(device_id, personnel_number, redis_client)
|
||||
|
||||
if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()):
|
||||
raise HTTPException(status_code=401, detail="Falscher PIN.")
|
||||
user = await db.scalar(
|
||||
select(User).where(
|
||||
User.company_id == company_id,
|
||||
User.personnel_number == personnel_number,
|
||||
User.is_active == True,
|
||||
)
|
||||
)
|
||||
if user is None:
|
||||
# Fehlversuch zählen auch bei unbekannter Personalnummer
|
||||
await self._record_pin_failure(device_id, personnel_number, redis_client)
|
||||
raise HTTPException(status_code=401, detail="Personalnummer nicht gefunden.")
|
||||
|
||||
if not user.kiosk_pin_hash:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Kein PIN gesetzt. Bitte in den Profileinstellungen einen PIN vergeben.",
|
||||
)
|
||||
|
||||
if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()):
|
||||
await self._record_pin_failure(device_id, personnel_number, redis_client)
|
||||
raise HTTPException(status_code=401, detail="Falscher PIN.")
|
||||
|
||||
# Erfolgreicher Login: Fehlversuche zurücksetzen
|
||||
await self._clear_pin_failures(device_id, personnel_number, redis_client)
|
||||
finally:
|
||||
await redis_client.aclose()
|
||||
|
||||
session_token = await kiosk_session_service.create_session(
|
||||
user_id=user.id,
|
||||
|
||||
@@ -268,6 +268,48 @@ class UserService:
|
||||
detail="Personalnummer kann nicht gelöscht werden (Reservierung).",
|
||||
)
|
||||
|
||||
# Rolle-Änderung nur mit expliziter Berechtigung (Fix K-1: Privilege Escalation)
|
||||
if "role" in changes and changes["role"] != user.role:
|
||||
new_role = changes["role"]
|
||||
# SUPER_ADMIN-Zuteilung: nur SUPER_ADMIN selbst darf das
|
||||
if new_role == UserRole.SUPER_ADMIN and current_user.role != UserRole.SUPER_ADMIN:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Nur SUPER_ADMIN darf die Rolle SUPER_ADMIN vergeben",
|
||||
)
|
||||
# COMPANY_ADMIN darf nur Rollen <= COMPANY_ADMIN vergeben (nicht SUPER_ADMIN)
|
||||
allowed_roles_by_admin = {
|
||||
UserRole.EMPLOYEE, UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN
|
||||
}
|
||||
if current_user.role == UserRole.COMPANY_ADMIN and new_role not in allowed_roles_by_admin:
|
||||
raise HTTPException(status_code=403, detail="Ungültige Rollenzuteilung")
|
||||
# Letzten COMPANY_ADMIN nicht demoten
|
||||
if user.role == UserRole.COMPANY_ADMIN and new_role != UserRole.COMPANY_ADMIN:
|
||||
from sqlalchemy import select, func
|
||||
count_result = await db.execute(
|
||||
select(func.count()).where(
|
||||
User.company_id == user.company_id,
|
||||
User.role == UserRole.COMPANY_ADMIN,
|
||||
User.is_active == True,
|
||||
User.id != user.id,
|
||||
)
|
||||
)
|
||||
if count_result.scalar() == 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Kann letzten COMPANY_ADMIN nicht downgraden",
|
||||
)
|
||||
# AuditLog für Rollen-Änderung
|
||||
db.add(AuditLog(
|
||||
company_id=current_user.company_id,
|
||||
user_id=current_user.id,
|
||||
action="role_changed",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
old_value={"role": user.role.value if hasattr(user.role, "value") else str(user.role)},
|
||||
new_value={"role": new_role.value if hasattr(new_role, "value") else str(new_role)},
|
||||
))
|
||||
|
||||
for field, value in changes.items():
|
||||
setattr(user, field, value)
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""add fza_hours to absences
|
||||
|
||||
Revision ID: 0032
|
||||
Revises: 0031
|
||||
Create Date: 2026-05-25
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0032'
|
||||
down_revision = '0031'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('absences', sa.Column('fza_hours', sa.Numeric(5, 2), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('absences', 'fza_hours')
|
||||
@@ -538,3 +538,63 @@ Keine Commits in dieser Session.
|
||||
- frontend/src/pages/KioskStampPage.tsx | 348 ++++++++++++++++++++++++++++++
|
||||
|
||||
---
|
||||
## 2026-05-25 22:53 – 22:56 (2m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** timemaster
|
||||
|
||||
### Commits
|
||||
- d0fdaef feat: Monatsansicht im /mobile Heute-Screen
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 18 ++
|
||||
- frontend/src/pages/mobile/MobileTodayScreen.tsx | 225 ++++++++++++++++++------
|
||||
|
||||
---
|
||||
## 2026-05-25 22:59 – 22:59 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** frontend
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 18 ++
|
||||
- frontend/src/pages/mobile/MobileTodayScreen.tsx | 225 ++++++++++++++++++------
|
||||
|
||||
---
|
||||
## 2026-05-25 23:00 – 23:11 (11m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** frontend
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 18 ++
|
||||
- frontend/src/pages/mobile/MobileTodayScreen.tsx | 225 ++++++++++++++++++------
|
||||
|
||||
---
|
||||
## 2026-05-25 23:14 – 23:15 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** frontend
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 18 ++
|
||||
- frontend/src/pages/mobile/MobileTodayScreen.tsx | 225 ++++++++++++++++++------
|
||||
|
||||
---
|
||||
## 2026-05-25 23:17 – 23:21 (3m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** frontend
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- DEVLOG.md | 18 ++
|
||||
- frontend/src/pages/mobile/MobileTodayScreen.tsx | 225 ++++++++++++++++++------
|
||||
|
||||
---
|
||||
|
||||
@@ -254,6 +254,11 @@ interface CreateAbsenceModalProps {
|
||||
submitting: boolean
|
||||
error: string
|
||||
overtimeBalance: OvertimeBalanceOut | null
|
||||
fzaMode: 'days' | 'hours'
|
||||
setFzaMode: React.Dispatch<React.SetStateAction<'days' | 'hours'>>
|
||||
fzaHours: number
|
||||
setFzaHours: React.Dispatch<React.SetStateAction<number>>
|
||||
isFzaType: (typeId: string) => boolean
|
||||
onCreate: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
@@ -267,9 +272,17 @@ export function CreateAbsenceModal({
|
||||
submitting,
|
||||
error,
|
||||
overtimeBalance,
|
||||
fzaMode,
|
||||
setFzaMode,
|
||||
fzaHours,
|
||||
setFzaHours,
|
||||
isFzaType,
|
||||
onCreate,
|
||||
onClose,
|
||||
}: CreateAbsenceModalProps) {
|
||||
const showFzaToggle = form.type_id ? isFzaType(form.type_id) : false
|
||||
const inputClass = 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50'>
|
||||
<div className='bg-white rounded-xl shadow-xl w-full max-w-md'>
|
||||
@@ -284,7 +297,7 @@ export function CreateAbsenceModal({
|
||||
<select
|
||||
value={form.for_user_id}
|
||||
onChange={e => setForm(f => ({ ...f, for_user_id: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
className={inputClass}
|
||||
>
|
||||
<option value=''>— Für mich selbst —</option>
|
||||
{colleagues.map(c => <option key={c.id} value={c.id}>{c.full_name} ({c.email})</option>)}
|
||||
@@ -296,7 +309,7 @@ export function CreateAbsenceModal({
|
||||
<select
|
||||
value={form.type_id}
|
||||
onChange={e => setForm(f => ({ ...f, type_id: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
className={inputClass}
|
||||
>
|
||||
<option value=''>Bitte wählen…</option>
|
||||
{types.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
@@ -307,42 +320,92 @@ export function CreateAbsenceModal({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Von *</label>
|
||||
<input
|
||||
type='date' value={form.start_date}
|
||||
onChange={e => setForm(f => ({ ...f, start_date: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
/>
|
||||
|
||||
{showFzaToggle && (
|
||||
<div className='flex rounded-lg border border-gray-200 overflow-hidden'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setFzaMode('days')}
|
||||
className={`flex-1 py-2 text-sm font-medium transition-colors ${fzaMode === 'days' ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-50'}`}
|
||||
>
|
||||
Tage
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setFzaMode('hours')}
|
||||
className={`flex-1 py-2 text-sm font-medium transition-colors ${fzaMode === 'hours' ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-50'}`}
|
||||
>
|
||||
Stunden
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Bis *</label>
|
||||
<input
|
||||
type='date' value={form.end_date} min={form.start_date}
|
||||
onChange={e => setForm(f => ({ ...f, end_date: e.target.value }))}
|
||||
className='w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-4'>
|
||||
<label className='flex items-center gap-2 text-sm text-gray-700 cursor-pointer'>
|
||||
<input
|
||||
type='checkbox' checked={form.half_day_start}
|
||||
onChange={e => setForm(f => ({ ...f, half_day_start: e.target.checked }))}
|
||||
className='rounded'
|
||||
/>
|
||||
Erster Tag halbtags
|
||||
</label>
|
||||
<label className='flex items-center gap-2 text-sm text-gray-700 cursor-pointer'>
|
||||
<input
|
||||
type='checkbox' checked={form.half_day_end}
|
||||
onChange={e => setForm(f => ({ ...f, half_day_end: e.target.checked }))}
|
||||
className='rounded'
|
||||
/>
|
||||
Letzter Tag halbtags
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFzaToggle && fzaMode === 'hours' ? (
|
||||
<>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Datum *</label>
|
||||
<input
|
||||
type='date'
|
||||
value={form.start_date}
|
||||
onChange={e => setForm(f => ({ ...f, start_date: e.target.value, end_date: e.target.value }))}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Stunden *</label>
|
||||
<input
|
||||
type='number'
|
||||
min={0.25}
|
||||
max={24}
|
||||
step={0.25}
|
||||
value={fzaHours}
|
||||
onChange={e => setFzaHours(Math.max(0.25, Math.min(24, parseFloat(e.target.value) || 0.25)))}
|
||||
className={inputClass}
|
||||
/>
|
||||
<p className='text-xs text-gray-400 mt-1'>{fzaHours} h FZA – entsprechende Überstunden werden verrechnet</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Von *</label>
|
||||
<input
|
||||
type='date' value={form.start_date}
|
||||
onChange={e => setForm(f => ({ ...f, start_date: e.target.value }))}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Bis *</label>
|
||||
<input
|
||||
type='date' value={form.end_date} min={form.start_date}
|
||||
onChange={e => setForm(f => ({ ...f, end_date: e.target.value }))}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-4'>
|
||||
<label className='flex items-center gap-2 text-sm text-gray-700 cursor-pointer'>
|
||||
<input
|
||||
type='checkbox' checked={form.half_day_start}
|
||||
onChange={e => setForm(f => ({ ...f, half_day_start: e.target.checked }))}
|
||||
className='rounded'
|
||||
/>
|
||||
Erster Tag halbtags
|
||||
</label>
|
||||
<label className='flex items-center gap-2 text-sm text-gray-700 cursor-pointer'>
|
||||
<input
|
||||
type='checkbox' checked={form.half_day_end}
|
||||
onChange={e => setForm(f => ({ ...f, half_day_end: e.target.checked }))}
|
||||
className='rounded'
|
||||
/>
|
||||
Letzter Tag halbtags
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 mb-1'>Notiz</label>
|
||||
<textarea
|
||||
|
||||
@@ -60,6 +60,7 @@ export function useAbsences(year: number, statusFilter: string) {
|
||||
form: { type_id: string; start_date: string; end_date: string; half_day_start: boolean; half_day_end: boolean; note: string; for_user_id: string },
|
||||
onSuccess: () => void,
|
||||
setSubmitting: (v: boolean) => void,
|
||||
fzaHours?: number,
|
||||
) => {
|
||||
if (!form.type_id || !form.start_date || !form.end_date) {
|
||||
setError('Bitte alle Pflichtfelder ausfüllen')
|
||||
@@ -76,6 +77,7 @@ export function useAbsences(year: number, statusFilter: string) {
|
||||
half_day_end: form.half_day_end,
|
||||
note: form.note || null,
|
||||
for_user_id: form.for_user_id || null,
|
||||
...(fzaHours !== undefined ? { fza_hours: fzaHours } : {}),
|
||||
})
|
||||
onSuccess()
|
||||
await load()
|
||||
|
||||
@@ -43,6 +43,8 @@ export function AbsencesPage() {
|
||||
half_day_start: false, half_day_end: false,
|
||||
note: '', for_user_id: '',
|
||||
})
|
||||
const [fzaMode, setFzaMode] = useState<'days' | 'hours'>('days')
|
||||
const [fzaHours, setFzaHours] = useState<number>(4)
|
||||
|
||||
const year = new Date().getFullYear()
|
||||
|
||||
@@ -107,6 +109,8 @@ export function AbsencesPage() {
|
||||
setShowCreate(true)
|
||||
setError('')
|
||||
setForm({ type_id: '', start_date: '', end_date: '', half_day_start: false, half_day_end: false, note: '', for_user_id: '' })
|
||||
setFzaMode('days')
|
||||
setFzaHours(4)
|
||||
if (isManager) await loadColleaguesIfNeeded()
|
||||
}
|
||||
|
||||
@@ -119,11 +123,21 @@ export function AbsencesPage() {
|
||||
await saveEdit(editAbsence, editForm, isManager, () => setEditAbsence(null), setSubmitting)
|
||||
}
|
||||
|
||||
const isFzaType = (typeId: string) => {
|
||||
const t = types.find(t => t.id === typeId)
|
||||
if (!t) return false
|
||||
const lower = t.name?.toLowerCase() ?? ''
|
||||
return lower.includes('fza') || lower.includes('freizeitausgleich')
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
const useFzaHours = isFzaType(form.type_id) && fzaMode === 'hours'
|
||||
await createAbsence(form, () => {
|
||||
setShowCreate(false)
|
||||
setForm({ type_id: '', start_date: '', end_date: '', half_day_start: false, half_day_end: false, note: '', for_user_id: '' })
|
||||
}, setSubmitting)
|
||||
setFzaMode('days')
|
||||
setFzaHours(4)
|
||||
}, setSubmitting, useFzaHours ? fzaHours : undefined)
|
||||
}
|
||||
|
||||
const handleSaveBalance = async () => {
|
||||
@@ -777,8 +791,19 @@ export function AbsencesPage() {
|
||||
submitting={submitting}
|
||||
error={error}
|
||||
overtimeBalance={overtimeBalance}
|
||||
fzaMode={fzaMode}
|
||||
setFzaMode={setFzaMode}
|
||||
fzaHours={fzaHours}
|
||||
setFzaHours={setFzaHours}
|
||||
isFzaType={isFzaType}
|
||||
onCreate={handleCreate}
|
||||
onClose={() => { setShowCreate(false); setError(''); setForm({ type_id: '', start_date: '', end_date: '', half_day_start: false, half_day_end: false, note: '', for_user_id: '' }) }}
|
||||
onClose={() => {
|
||||
setShowCreate(false)
|
||||
setError('')
|
||||
setForm({ type_id: '', start_date: '', end_date: '', half_day_start: false, half_day_end: false, note: '', for_user_id: '' })
|
||||
setFzaMode('days')
|
||||
setFzaHours(4)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
+50
-12
@@ -1,17 +1,13 @@
|
||||
# HTTP-only Konfiguration (SSL/HTTPS noch nicht eingerichtet)
|
||||
# Sobald ein TLS-Zertifikat vorhanden ist:
|
||||
# 1. Listen-Block auf 443 ssl http2 erweitern
|
||||
# 2. ssl_certificate / ssl_certificate_key einkommentieren
|
||||
# 3. HSTS-Header hinzufügen
|
||||
# 4. HTTP->HTTPS-Redirect aktivieren
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name yourdomain.com www.yourdomain.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name yourdomain.com www.yourdomain.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 20M;
|
||||
|
||||
@@ -24,28 +20,70 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
# Security Headers
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';" always;
|
||||
}
|
||||
|
||||
# FastAPI Docs (nur in dev aktiv)
|
||||
location /docs {
|
||||
proxy_pass http://127.0.0.1:8000/docs;
|
||||
|
||||
# Security Headers
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';" always;
|
||||
}
|
||||
|
||||
location /openapi.json {
|
||||
proxy_pass http://127.0.0.1:8000/openapi.json;
|
||||
|
||||
# Security Headers
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';" always;
|
||||
}
|
||||
|
||||
# React Frontend (statische Dateien)
|
||||
# HINWEIS: nginx-Regel: add_header in einem location-Block ueberschreibt
|
||||
# alle add_header-Direktiven des parent server-Blocks. Daher Security-Header
|
||||
# in jede location wiederholen.
|
||||
location / {
|
||||
root /opt/timemaster/frontend/dist;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
expires 1d;
|
||||
|
||||
add_header Cache-Control "public, must-revalidate";
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';" always;
|
||||
}
|
||||
|
||||
# Uploads / Static Files
|
||||
location /static/ {
|
||||
alias /opt/timemaster/backend/static/;
|
||||
expires 7d;
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';" always;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user