/** * ABACRuleEditor — UI zum Erstellen und Bearbeiten von ABAC Policies. * * Features: * - Liste aller Policies für einen Entity-Type * - Neue Policy erstellen / bestehende bearbeiten * - Conditions Builder mit AND/OR Gruppen * - Policy löschen mit ConfirmDialog * - Text-basierte Vorschau der Policy */ import { asError } from '@/utils/errorTypes'; import React, { useState, useCallback, useMemo } from 'react'; import clsx from 'clsx'; import { Plus, Pencil, Trash2, X, Shield, ShieldCheck, ShieldX, GripVertical, ChevronDown, ChevronRight, Eye, EyeOff, ArrowUpDown, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { usePolicies, useCreatePolicy, useUpdatePolicy, useDeletePolicy, } from '../../api/policyHooks'; import { useUsers } from '../../api/users'; import { useGroups } from '../../api/groups'; import { useRoles } from '../../api/roles'; import { type ABACPolicy, type PrincipalType, type ConditionOperator, type ConditionGroupLogic, type Condition, type ConditionGroup, type CreatePolicyPayload, type UpdatePolicyPayload, } from '../../api/policies'; import { Card } from '../ui/Card'; import { Button } from '../ui/Button'; import { Badge } from '../ui/Badge'; import { Select, type SelectOption } from '../ui/Select'; import { Input } from '../ui/Input'; import { Modal } from '../ui/Modal'; import { ConfirmDialog } from '../ui/ConfirmDialog'; // ─── Constants ───────────────────────────────────────────────────────────── const OPERATOR_OPTIONS: SelectOption[] = [ { value: 'eq', label: '=' }, { value: 'neq', label: '≠' }, { value: 'in', label: 'in' }, { value: 'gt', label: '>' }, { value: 'gte', label: '≥' }, { value: 'lt', label: '<' }, { value: 'lte', label: '≤' }, { value: 'contains', label: 'contains' }, { value: 'starts_with', label: 'starts with' }, { value: 'is_null', label: 'is null' }, ]; const PRINCIPAL_TYPE_OPTIONS: SelectOption[] = [ { value: 'user', label: 'User' }, { value: 'group', label: 'Group' }, { value: 'role', label: 'Role' }, ]; const EFFECT_OPTIONS: SelectOption[] = [ { value: 'allow', label: 'Allow' }, { value: 'deny', label: 'Deny' }, ]; const LOGIC_OPTIONS: SelectOption[] = [ { value: 'AND', label: 'AND' }, { value: 'OR', label: 'OR' }, ]; // ─── Helpers ─────────────────────────────────────────────────────────────── function generateConditionId(): string { return `cond_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } function generateGroupId(): string { return `grp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } function createEmptyCondition(): Condition { return { id: generateConditionId(), field: '', operator: 'eq', value: '' }; } function createEmptyGroup(logic: ConditionGroupLogic = 'AND'): ConditionGroup { return { id: generateGroupId(), logic, conditions: [createEmptyCondition()], groups: [], }; } /** * Generate a human-readable description of a condition group. */ function describeConditionGroup(group: ConditionGroup | null): string { if (!group) return '—'; const parts: string[] = []; for (const cond of group.conditions) { if (!cond.field) continue; const opLabel = OPERATOR_OPTIONS.find((o) => o.value === cond.operator)?.label || cond.operator; if (cond.operator === 'is_null') { parts.push(`${cond.field} is null`); } else if (cond.operator === 'in') { parts.push(`${cond.field} ${opLabel} (${cond.value})`); } else { parts.push(`${cond.field} ${opLabel} ${cond.value}`); } } for (const sub of group.groups || []) { const subDesc = describeConditionGroup(sub); if (subDesc !== '—') { parts.push(`(${subDesc})`); } } if (parts.length === 0) return '—'; return parts.join(` ${group.logic} `); } /** * Generate a full human-readable policy description. */ function describePolicy(policy: ABACPolicy): string { const principalLabel = policy.principal_name || policy.principal_id; const effectLabel = policy.effect === 'allow' ? 'darf' : 'darf nicht'; const condDesc = describeConditionGroup(policy.conditions); if (condDesc === '—') { return `${policy.principal_type} „${principalLabel}“ ${effectLabel} auf ${policy.entity_type} zugreifen`; } return `${policy.principal_type} „${principalLabel}“ ${effectLabel} auf ${policy.entity_type} zugreifen, wenn ${condDesc}`; } // ─── Sub-Components ──────────────────────────────────────────────────────── interface ConditionRowProps { condition: Condition; onChange: (condition: Condition) => void; onRemove: () => void; canRemove: boolean; } function ConditionRow({ condition, onChange, onRemove, canRemove }: ConditionRowProps) { const { t } = useTranslation(); return (
onChange({ ...condition, field: e.target.value })} />
onChange({ ...condition, value: e.target.value })} />
{canRemove && ( )}
); } interface ConditionGroupEditorProps { group: ConditionGroup; onChange: (group: ConditionGroup) => void; onRemove?: () => void; depth: number; canRemove: boolean; } function ConditionGroupEditor({ group, onChange, onRemove, depth, canRemove, }: ConditionGroupEditorProps) { const { t } = useTranslation(); const addCondition = useCallback(() => { onChange({ ...group, conditions: [...group.conditions, createEmptyCondition()], }); }, [group, onChange]); const updateCondition = useCallback( (index: number, condition: Condition) => { const updated = [...group.conditions]; updated[index] = condition; onChange({ ...group, conditions: updated }); }, [group, onChange] ); const removeCondition = useCallback( (index: number) => { if (group.conditions.length <= 1) return; const updated = group.conditions.filter((_, i) => i !== index); onChange({ ...group, conditions: updated }); }, [group, onChange] ); const toggleLogic = useCallback(() => { onChange({ ...group, logic: group.logic === 'AND' ? 'OR' : 'AND', }); }, [group, onChange]); return (
0 && 'ml-4 bg-secondary-50/50' )}> {/* Header: Logic toggle + actions */}
{t('abac.groupConditions', 'Conditions')}
{canRemove && onRemove && ( )}
{/* Conditions */} {group.conditions.map((cond, idx) => ( updateCondition(idx, c)} onRemove={() => removeCondition(idx)} canRemove={group.conditions.length > 1} /> ))} {/* Nested groups */} {group.groups?.map((sub, idx) => ( { const updated = [...(group.groups || [])]; updated[idx] = g; onChange({ ...group, groups: updated }); }} onRemove={() => { const updated = (group.groups || []).filter((_, i) => i !== idx); onChange({ ...group, groups: updated }); }} depth={depth + 1} canRemove={true} /> ))} {/* Add nested group */}
); } // ─── Policy Form ─────────────────────────────────────────────────────────── interface PolicyFormProps { initial?: ABACPolicy | null; entityType: string; onSave: () => void; onCancel: () => void; } function PolicyForm({ initial, entityType, onSave, onCancel }: PolicyFormProps) { const { t } = useTranslation(); const createPolicy = useCreatePolicy(entityType); const updatePolicy = useUpdatePolicy(entityType); // Fetch principals for selectors const { data: usersData } = useUsers(); const { data: groupsData } = useGroups(); const { data: rolesData } = useRoles(); const [name, setName] = useState(initial?.name || ''); const [principalType, setPrincipalType] = useState( initial?.principal_type || 'user' ); const [principalId, setPrincipalId] = useState(initial?.principal_id || ''); const [effect, setEffect] = useState<'allow' | 'deny'>(initial?.effect || 'allow'); const [conditions, setConditions] = useState( initial?.conditions || null ); const [priority, setPriority] = useState(initial?.priority ?? 0); const [enabled, setEnabled] = useState(initial?.enabled ?? true); const [error, setError] = useState(null); // Build principal options based on selected type const principalOptions: SelectOption[] = useMemo(() => { if (principalType === 'user') { return (usersData?.items || []).map((u) => ({ value: u.id, label: u.name || u.email, })); } if (principalType === 'group') { return (groupsData?.items || []).map((g) => ({ value: g.id, label: g.name, })); } if (principalType === 'role') { return (rolesData?.items || []).map((r) => ({ value: r.id, label: r.name, })); } return []; }, [principalType, usersData, groupsData, rolesData]); const handleSave = useCallback(async () => { setError(null); if (!name.trim()) { setError(t('abac.nameRequired', 'Name is required')); return; } if (!principalId) { setError(t('abac.principalRequired', 'Principal is required')); return; } try { if (initial) { const payload: UpdatePolicyPayload = { name: name.trim(), principal_type: principalType, principal_id: principalId, effect, conditions, priority, enabled, }; await updatePolicy.mutateAsync({ policyId: initial.id, data: payload }); } else { const payload: CreatePolicyPayload = { name: name.trim(), principal_type: principalType, principal_id: principalId, effect, conditions, priority, enabled, }; await createPolicy.mutateAsync(payload); } onSave(); } catch (err: unknown) { const errObj = asError(err); setError(errObj?.message || t('abac.saveError', 'Failed to save policy')); } }, [ initial, name, principalType, principalId, effect, conditions, priority, enabled, createPolicy, updatePolicy, onSave, t, ]); const isSaving = createPolicy.isPending || updatePolicy.isPending; // Generate preview text const previewText = useMemo(() => { if (!name.trim() && !principalId) return ''; const mockPolicy: ABACPolicy = { id: initial?.id || 'new', name: name.trim() || '(unnamed)', entity_type: entityType, principal_type: principalType, principal_id: principalId, principal_name: principalOptions.find((o) => o.value === principalId)?.label || null, effect, conditions, priority, enabled, }; return describePolicy(mockPolicy); }, [name, principalType, principalId, effect, conditions, priority, enabled, entityType, initial, principalOptions]); return (
{/* Name */} setName(e.target.value)} placeholder={t('abac.policyNamePlaceholder', 'e.g. Vertrieb kann Kontakte sehen')} required /> {/* Principal Type + ID */}
setPrincipalId(e.target.value)} placeholder={t('abac.selectPrincipal', 'Select...')} />
{/* Effect + Priority */}
setPriority(parseInt(e.target.value) || 0)} />
{/* Enabled */} {/* Conditions Builder */}
{!conditions && ( )}
{conditions && (
setConditions(null)} depth={0} canRemove={true} />
)}
{/* Preview */} {previewText && (

{t('abac.preview', 'Preview')}

{previewText}

)} {/* Error */} {error && (

{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}

)} {/* Actions */}
); } // ─── Main Component ──────────────────────────────────────────────────────── export interface ABACRuleEditorProps { entityType: string; onClose: () => void; } export function ABACRuleEditor({ entityType, onClose }: ABACRuleEditorProps) { const { t } = useTranslation(); const { data: policiesData, isLoading, error: fetchError } = usePolicies(entityType); const deletePolicy = useDeletePolicy(entityType); const [showForm, setShowForm] = useState(false); const [editingPolicy, setEditingPolicy] = useState(null); const [deletingPolicy, setDeletingPolicy] = useState(null); const [expandedPolicies, setExpandedPolicies] = useState>(new Set()); const policies = policiesData?.items || []; const handleCreate = useCallback(() => { setEditingPolicy(null); setShowForm(true); }, []); const handleEdit = useCallback((policy: ABACPolicy) => { setEditingPolicy(policy); setShowForm(true); }, []); const handleFormSave = useCallback(() => { setShowForm(false); setEditingPolicy(null); }, []); const handleFormCancel = useCallback(() => { setShowForm(false); setEditingPolicy(null); }, []); const handleDeleteConfirm = useCallback(async () => { if (!deletingPolicy) return; try { await deletePolicy.mutateAsync(deletingPolicy.id); } catch { // Error is handled by the mutation } setDeletingPolicy(null); }, [deletingPolicy, deletePolicy]); const toggleExpand = useCallback((policyId: string) => { setExpandedPolicies((prev) => { const next = new Set(prev); if (next.has(policyId)) { next.delete(policyId); } else { next.add(policyId); } return next; }); }, []); return ( } > {/* Loading state */} {isLoading && (
{t('abac.loading', 'Loading policies...')}
)} {/* Error state */} {fetchError && !isLoading && (

{t('abac.fetchError', 'Failed to load policies')}: {String(fetchError)}

)} {/* Empty state */} {!isLoading && !fetchError && policies.length === 0 && !showForm && (

{t('abac.noPolicies', 'No policies defined for this entity type.')}

)} {/* Policy list */} {!isLoading && !fetchError && policies.length > 0 && !showForm && (
{policies.map((policy) => { const isExpanded = expandedPolicies.has(policy.id); return (
{/* Policy header */}
toggleExpand(policy.id)} >
{isExpanded ? ( ) : ( )}

{policy.name}

{describePolicy(policy)}

{policy.effect === 'allow' ? ( ) : ( )} {policy.effect} {!policy.enabled && ( {t('abac.disabled', 'Disabled')} )} P{policy.priority}
{/* Expanded details */} {isExpanded && (
{t('abac.principal', 'Principal')}: {' '} {policy.principal_name || policy.principal_id}
{t('abac.principalType', 'Type')}: {' '} {policy.principal_type}
{t('abac.priority', 'Priority')}: {' '} {policy.priority}
{t('abac.enabled', 'Enabled')}: {' '} {policy.enabled ? '✓' : '✗'}
{policy.conditions && (
{t('abac.conditions', 'Conditions')}:

{describeConditionGroup(policy.conditions)}

)}
)}
); })}
)} {/* Create/Edit Form Modal */} {/* Delete Confirmation */} setDeletingPolicy(null)} /> ); }