e9b8936091
Replace direct {error} JSX rendering with typeof check + .message fallback.
When error is an object (not a string), React showed [object Object].
Now renders error.message or fallback string.
884 lines
28 KiB
TypeScript
884 lines
28 KiB
TypeScript
/**
|
|
* 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 (
|
|
<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 })}
|
|
/>
|
|
</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 })}
|
|
/>
|
|
</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: 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 (
|
|
<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">
|
|
{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}
|
|
</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>
|
|
);
|
|
}
|