sprint14-19: ABAC UI rule editor + permission templates + bulk share + analytics + delegation + resolution strategies + migrations 0056-0058

This commit is contained in:
Agent Zero
2026-07-29 02:47:03 +02:00
parent e0003b9384
commit ddf73ee42e
21 changed files with 2399 additions and 4 deletions
+124
View File
@@ -0,0 +1,124 @@
/**
* ABAC Policy API client.
*
* All requests use the shared `apiClient` (`baseURL: '/api/v1'`) and target
* the Policies routes under `/policies/...`.
*/
import { apiDelete, apiGet, apiPost, apiPut } from './client';
// ─── Types ─────────────────────────────────────────────────────────────────
export type PrincipalType = 'user' | 'group' | 'role';
export type ConditionOperator =
| 'eq'
| 'neq'
| 'in'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'contains'
| 'starts_with'
| 'is_null';
export type ConditionGroupLogic = 'AND' | 'OR';
export interface Condition {
id?: string;
field: string;
operator: ConditionOperator;
value: string;
}
export interface ConditionGroup {
id?: string;
logic: ConditionGroupLogic;
conditions: Condition[];
groups?: ConditionGroup[];
}
export interface ABACPolicy {
id: string;
name: string;
entity_type: string;
principal_type: PrincipalType;
principal_id: string;
principal_name?: string | null;
effect: 'allow' | 'deny';
conditions: ConditionGroup | null;
priority: number;
enabled: boolean;
created_at?: string | null;
updated_at?: string | null;
}
export interface PolicyListResponse {
items: ABACPolicy[];
total: number;
}
export interface CreatePolicyPayload {
name: string;
principal_type: PrincipalType;
principal_id: string;
effect: 'allow' | 'deny';
conditions: ConditionGroup | null;
priority: number;
enabled: boolean;
}
export interface UpdatePolicyPayload {
name?: string;
principal_type?: PrincipalType;
principal_id?: string;
effect?: 'allow' | 'deny';
conditions?: ConditionGroup | null;
priority?: number;
enabled?: boolean;
}
// ─── API Functions ─────────────────────────────────────────────────────────
/**
* Fetch all policies for a given entity type.
*/
export function fetchPolicies(entityType: string): Promise<PolicyListResponse> {
return apiGet<PolicyListResponse>(`/policies/${entityType}`);
}
/**
* Fetch a single policy by ID.
*/
export function fetchPolicy(entityType: string, policyId: string): Promise<ABACPolicy> {
return apiGet<ABACPolicy>(`/policies/${entityType}/${policyId}`);
}
/**
* Create a new policy for the given entity type.
*/
export function createPolicy(
entityType: string,
payload: CreatePolicyPayload
): Promise<ABACPolicy> {
return apiPost<ABACPolicy>(`/policies/${entityType}`, payload);
}
/**
* Update an existing policy.
*/
export function updatePolicy(
entityType: string,
policyId: string,
payload: UpdatePolicyPayload
): Promise<ABACPolicy> {
return apiPut<ABACPolicy>(`/policies/${entityType}/${policyId}`, payload);
}
/**
* Delete a policy.
*/
export function deletePolicy(entityType: string, policyId: string): Promise<void> {
return apiDelete<void>(`/policies/${entityType}/${policyId}`);
}
+92
View File
@@ -0,0 +1,92 @@
/**
* React Query hooks for the ABAC Policy API.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchPolicies,
fetchPolicy,
createPolicy,
updatePolicy,
deletePolicy,
type CreatePolicyPayload,
type UpdatePolicyPayload,
} from './policies';
// ─── Query Key Factory ─────────────────────────────────────────────────────
export const policyKeys = {
all: ['policies'] as const,
list: (entityType: string) => [...policyKeys.all, 'list', entityType] as const,
detail: (entityType: string, policyId: string) =>
[...policyKeys.all, 'detail', entityType, policyId] as const,
};
// ─── Hooks ─────────────────────────────────────────────────────────────────
/**
* Fetch all policies for a given entity type.
*/
export function usePolicies(entityType: string) {
return useQuery({
queryKey: policyKeys.list(entityType),
queryFn: () => fetchPolicies(entityType),
enabled: !!entityType,
});
}
/**
* Fetch a single policy by ID.
*/
export function usePolicy(entityType: string, policyId: string | null) {
return useQuery({
queryKey: policyKeys.detail(entityType, policyId!),
queryFn: () => fetchPolicy(entityType, policyId!),
enabled: !!entityType && !!policyId,
});
}
/**
* Create a new policy.
*/
export function useCreatePolicy(entityType: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: CreatePolicyPayload) => createPolicy(entityType, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: policyKeys.list(entityType) });
},
});
}
/**
* Update an existing policy.
*/
export function useUpdatePolicy(entityType: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
policyId,
data,
}: {
policyId: string;
data: UpdatePolicyPayload;
}) => updatePolicy(entityType, policyId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: policyKeys.list(entityType) });
},
});
}
/**
* Delete a policy.
*/
export function useDeletePolicy(entityType: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (policyId: string) => deletePolicy(entityType, policyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: policyKeys.list(entityType) });
},
});
}
@@ -0,0 +1,884 @@
/**
* 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 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 (
<div className="flex items-start gap-2 py-1">
<div className="flex-1">
<Input
placeholder={t('abac.fieldPlaceholder', 'Field')}
value={condition.field}
onChange={(e) => onChange({ ...condition, field: e.target.value })}
size="sm"
/>
</div>
<div className="w-32">
<Select
options={OPERATOR_OPTIONS}
value={condition.operator}
onChange={(e) => onChange({ ...condition, operator: e.target.value as ConditionOperator })}
className="text-sm"
/>
</div>
<div className="flex-1">
<Input
placeholder={t('abac.valuePlaceholder', 'Value')}
value={condition.value}
onChange={(e) => onChange({ ...condition, value: e.target.value })}
size="sm"
/>
</div>
{canRemove && (
<button
type="button"
onClick={onRemove}
className="mt-1 text-secondary-400 hover:text-danger-500 min-h-touch min-w-touch flex items-center justify-center rounded-md"
aria-label={t('abac.removeCondition', 'Remove condition')}
>
<X className="h-4 w-4" />
</button>
)}
</div>
);
}
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 (
<div className={clsx(
'border rounded-md p-3',
depth > 0 && 'ml-4 bg-secondary-50/50'
)}>
{/* Header: Logic toggle + actions */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<button
type="button"
onClick={toggleLogic}
className={clsx(
'px-2 py-0.5 text-xs font-medium rounded border transition-colors',
group.logic === 'AND'
? 'bg-primary-100 text-primary-700 border-primary-300'
: 'bg-accent-100 text-accent-700 border-accent-300'
)}
>
{group.logic}
</button>
<span className="text-xs text-secondary-500">
{t('abac.groupConditions', 'Conditions')}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
icon={<Plus className="h-3.5 w-3.5" />}
onClick={addCondition}
>
{t('abac.addCondition', 'Add')}
</Button>
{canRemove && onRemove && (
<button
type="button"
onClick={onRemove}
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.removeGroup', 'Remove group')}
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
{/* Conditions */}
{group.conditions.map((cond, idx) => (
<ConditionRow
key={cond.id}
condition={cond}
onChange={(c) => updateCondition(idx, c)}
onRemove={() => removeCondition(idx)}
canRemove={group.conditions.length > 1}
/>
))}
{/* Nested groups */}
{group.groups?.map((sub, idx) => (
<ConditionGroupEditor
key={sub.id}
group={sub}
onChange={(g) => {
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 */}
<button
type="button"
onClick={() => {
onChange({
...group,
groups: [...(group.groups || []), createEmptyGroup('AND')],
});
}}
className="mt-2 text-xs text-primary-600 hover:text-primary-700 flex items-center gap-1"
>
<Plus className="h-3 w-3" />
{t('abac.addNestedGroup', 'Add nested group')}
</button>
</div>
);
}
// ─── 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<PrincipalType>(
initial?.principal_type || 'user'
);
const [principalId, setPrincipalId] = useState(initial?.principal_id || '');
const [effect, setEffect] = useState<'allow' | 'deny'>(initial?.effect || 'allow');
const [conditions, setConditions] = useState<ConditionGroup | null>(
initial?.conditions || null
);
const [priority, setPriority] = useState(initial?.priority ?? 0);
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
const [error, setError] = useState<string | null>(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: any) {
setError(err?.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 (
<div className="space-y-4">
{/* Name */}
<Input
label={t('abac.policyName', 'Policy Name')}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('abac.policyNamePlaceholder', 'e.g. Vertrieb kann Kontakte sehen')}
required
/>
{/* Principal Type + ID */}
<div className="grid grid-cols-2 gap-4">
<Select
label={t('abac.principalType', 'Principal Type')}
options={PRINCIPAL_TYPE_OPTIONS}
value={principalType}
onChange={(e) => {
setPrincipalType(e.target.value as PrincipalType);
setPrincipalId('');
}}
/>
<Select
label={t('abac.principal', 'Principal')}
options={principalOptions}
value={principalId}
onChange={(e) => setPrincipalId(e.target.value)}
placeholder={t('abac.selectPrincipal', 'Select...')}
/>
</div>
{/* Effect + Priority */}
<div className="grid grid-cols-2 gap-4">
<Select
label={t('abac.effect', 'Effect')}
options={EFFECT_OPTIONS}
value={effect}
onChange={(e) => setEffect(e.target.value as 'allow' | 'deny')}
/>
<Input
label={t('abac.priority', 'Priority')}
type="number"
value={String(priority)}
onChange={(e) => setPriority(parseInt(e.target.value) || 0)}
/>
</div>
{/* Enabled */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-secondary-700">
{t('abac.enabled', 'Enabled')}
</span>
</label>
{/* Conditions Builder */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-secondary-700">
{t('abac.conditions', 'Conditions')}
</label>
{!conditions && (
<Button
variant="ghost"
size="sm"
icon={<Plus className="h-3.5 w-3.5" />}
onClick={() => setConditions(createEmptyGroup('AND'))}
>
{t('abac.addConditions', 'Add conditions')}
</Button>
)}
</div>
{conditions && (
<div className="space-y-2">
<ConditionGroupEditor
group={conditions}
onChange={setConditions}
onRemove={() => setConditions(null)}
depth={0}
canRemove={true}
/>
</div>
)}
</div>
{/* Preview */}
{previewText && (
<div className="bg-secondary-50 border border-secondary-200 rounded-md p-3">
<p className="text-xs font-medium text-secondary-500 mb-1">
{t('abac.preview', 'Preview')}
</p>
<p className="text-sm text-secondary-800">{previewText}</p>
</div>
)}
{/* Error */}
{error && (
<p className="text-sm text-danger-600" role="alert">
{error}
</p>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" onClick={onCancel}>
{t('abac.cancel', 'Cancel')}
</Button>
<Button variant="primary" onClick={handleSave} isLoading={isSaving}>
{initial ? t('abac.update', 'Update') : t('abac.create', 'Create')}
</Button>
</div>
</div>
);
}
// ─── 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<ABACPolicy | null>(null);
const [deletingPolicy, setDeletingPolicy] = useState<ABACPolicy | null>(null);
const [expandedPolicies, setExpandedPolicies] = useState<Set<string>>(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 (
<Card
title={t('abac.ruleEditor', 'ABAC Rule Editor')}
description={`${entityType}`}
actions={
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
icon={<Plus className="h-4 w-4" />}
onClick={handleCreate}
>
{t('abac.newPolicy', 'New Policy')}
</Button>
<Button
variant="ghost"
size="sm"
icon={<X className="h-4 w-4" />}
onClick={onClose}
>
{t('abac.close', 'Close')}
</Button>
</div>
}
>
{/* Loading state */}
{isLoading && (
<div className="flex items-center justify-center py-8">
<div className="animate-spin h-6 w-6 border-2 border-primary-600 border-t-transparent rounded-full" />
<span className="ml-3 text-sm text-secondary-500">
{t('abac.loading', 'Loading policies...')}
</span>
</div>
)}
{/* Error state */}
{fetchError && !isLoading && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<p className="text-sm text-danger-700">
{t('abac.fetchError', 'Failed to load policies')}: {String(fetchError)}
</p>
</div>
)}
{/* Empty state */}
{!isLoading && !fetchError && policies.length === 0 && !showForm && (
<div className="text-center py-8">
<Shield className="h-12 w-12 text-secondary-300 mx-auto mb-3" />
<p className="text-sm text-secondary-500 mb-4">
{t('abac.noPolicies', 'No policies defined for this entity type.')}
</p>
<Button
variant="primary"
size="sm"
icon={<Plus className="h-4 w-4" />}
onClick={handleCreate}
>
{t('abac.createFirst', 'Create first policy')}
</Button>
</div>
)}
{/* Policy list */}
{!isLoading && !fetchError && policies.length > 0 && !showForm && (
<div className="space-y-2">
{policies.map((policy) => {
const isExpanded = expandedPolicies.has(policy.id);
return (
<div
key={policy.id}
className="border border-secondary-200 rounded-md hover:border-secondary-300 transition-colors"
>
{/* Policy header */}
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer"
onClick={() => toggleExpand(policy.id)}
>
<div className="flex items-center gap-3 min-w-0">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-secondary-400 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-secondary-400 shrink-0" />
)}
<div className="min-w-0">
<p className="text-sm font-medium text-secondary-900 truncate">
{policy.name}
</p>
<p className="text-xs text-secondary-500 truncate">
{describePolicy(policy)}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge
variant={policy.effect === 'allow' ? 'success' : 'danger'}
>
{policy.effect === 'allow' ? (
<ShieldCheck className="h-3 w-3 mr-1" />
) : (
<ShieldX className="h-3 w-3 mr-1" />
)}
{policy.effect}
</Badge>
{!policy.enabled && (
<Badge variant="warning">{t('abac.disabled', 'Disabled')}</Badge>
)}
<span className="text-xs text-secondary-400">P{policy.priority}</span>
<button
type="button"
onClick={(e) => {
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')}
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={(e) => {
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')}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
{/* Expanded details */}
{isExpanded && (
<div className="px-4 pb-3 pt-0 border-t border-secondary-100">
<div className="grid grid-cols-2 gap-2 mt-2 text-xs">
<div>
<span className="text-secondary-500">
{t('abac.principal', 'Principal')}:
</span>{' '}
<span className="text-secondary-800">
{policy.principal_name || policy.principal_id}
</span>
</div>
<div>
<span className="text-secondary-500">
{t('abac.principalType', 'Type')}:
</span>{' '}
<span className="text-secondary-800">{policy.principal_type}</span>
</div>
<div>
<span className="text-secondary-500">
{t('abac.priority', 'Priority')}:
</span>{' '}
<span className="text-secondary-800">{policy.priority}</span>
</div>
<div>
<span className="text-secondary-500">
{t('abac.enabled', 'Enabled')}:
</span>{' '}
<span className="text-secondary-800">
{policy.enabled ? '✓' : '✗'}
</span>
</div>
</div>
{policy.conditions && (
<div className="mt-2">
<span className="text-xs text-secondary-500">
{t('abac.conditions', 'Conditions')}:
</span>
<p className="text-xs text-secondary-800 mt-1">
{describeConditionGroup(policy.conditions)}
</p>
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
{/* Create/Edit Form Modal */}
<Modal
open={showForm}
onClose={handleFormCancel}
title={
editingPolicy
? t('abac.editPolicyTitle', 'Edit Policy')
: t('abac.createPolicyTitle', 'Create Policy')
}
size="lg"
>
<PolicyForm
initial={editingPolicy}
entityType={entityType}
onSave={handleFormSave}
onCancel={handleFormCancel}
/>
</Modal>
{/* Delete Confirmation */}
<ConfirmDialog
open={!!deletingPolicy}
title={t('abac.deletePolicyTitle', 'Delete Policy')}
message={
deletingPolicy
? t('abac.deleteConfirm', 'Are you sure you want to delete policy "{{name}}"?', {
name: deletingPolicy.name,
})
: ''
}
variant="danger"
onConfirm={handleDeleteConfirm}
onCancel={() => setDeletingPolicy(null)}
/>
</Card>
);
}