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