/**
* 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 (
);
}
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 */}
{group.logic}
{t('abac.groupConditions', 'Conditions')}
}
onClick={addCondition}
>
{t('abac.addCondition', 'Add')}
{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 */}
{
onChange({
...group,
groups: [...(group.groups || []), createEmptyGroup('AND')],
});
}}
className="mt-2 text-xs text-primary-600 hover:text-primary-700 flex items-center gap-1"
>
{t('abac.addNestedGroup', '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 */}
{
setPrincipalType(e.target.value as PrincipalType);
setPrincipalId('');
}}
/>
setPrincipalId(e.target.value)}
placeholder={t('abac.selectPrincipal', 'Select...')}
/>
{/* Effect + Priority */}
setEffect(e.target.value as 'allow' | 'deny')}
/>
setPriority(parseInt(e.target.value) || 0)}
/>
{/* Enabled */}
setEnabled(e.target.checked)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
{t('abac.enabled', 'Enabled')}
{/* Conditions Builder */}
{t('abac.conditions', 'Conditions')}
{!conditions && (
}
onClick={() => setConditions(createEmptyGroup('AND'))}
>
{t('abac.addConditions', 'Add 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 */}
{t('abac.cancel', 'Cancel')}
{initial ? t('abac.update', 'Update') : t('abac.create', 'Create')}
);
}
// ─── 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 (
}
onClick={handleCreate}
>
{t('abac.newPolicy', 'New Policy')}
}
onClick={onClose}
>
{t('abac.close', 'Close')}
}
>
{/* 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.')}
}
onClick={handleCreate}
>
{t('abac.createFirst', 'Create first policy')}
)}
{/* 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}
{
e.stopPropagation();
handleEdit(policy);
}}
className="text-secondary-400 hover:text-primary-600 min-h-touch min-w-touch flex items-center justify-center rounded-md"
aria-label={t('abac.editPolicy', 'Edit policy')}
>
{
e.stopPropagation();
setDeletingPolicy(policy);
}}
className="text-secondary-400 hover:text-danger-500 min-h-touch min-w-touch flex items-center justify-center rounded-md"
aria-label={t('abac.deletePolicy', 'Delete policy')}
>
{/* 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)}
/>
);
}