frontend_nyla/src/components/AccessRules/AccessRulesEditor.tsx
2026-01-25 03:01:07 +01:00

762 lines
23 KiB
TypeScript

/**
* AccessRulesEditor
*
* Main component for editing RBAC access rules for a role.
* Provides tabbed interface for DATA, UI, and RESOURCE rules.
*
* Features:
* - Checkbox-based compact table for DATA rules
* - Card view for UI/RESOURCE rules
* - Object catalog dropdown for adding new rules
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
FaTable,
FaDesktop,
FaServer,
FaCode,
FaPlus,
FaTrash,
FaSave,
FaUndo,
FaSpinner,
FaThList,
FaTh,
} from 'react-icons/fa';
import {
useAccessRules,
type AccessRule,
type RuleContext,
type AccessLevel,
type AccessRuleCreate,
} from '../../hooks/useAccessRules';
import { useCatalogObjects, type CatalogObject } from '../../hooks/useCatalogObjects';
import { AccessLevelSelect } from './AccessLevelSelect';
import { AccessRulesTable } from './AccessRulesTable';
import styles from './AccessRules.module.css';
// =============================================================================
// TYPES
// =============================================================================
interface AccessRulesEditorProps {
roleId: string;
roleName?: string;
isTemplate?: boolean;
readOnly?: boolean;
onSave?: () => void;
apiBasePath?: string;
mandateId?: string;
featureCode?: string; // Filter catalog objects to this feature only
}
type TabType = 'DATA' | 'UI' | 'RESOURCE' | 'JSON';
// =============================================================================
// RULE CARD COMPONENT
// =============================================================================
interface RuleCardProps {
rule: AccessRule;
readOnly?: boolean;
onUpdate: (ruleId: string, updates: Partial<AccessRule>) => void;
onDelete: (ruleId: string) => void;
}
const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete }) => {
const isDataRule = rule.context === 'DATA';
return (
<div className={styles.ruleCard}>
<div className={styles.ruleHeader}>
<div className={styles.ruleItem}>
<span className={styles.ruleItemIcon}>
{rule.context === 'DATA' ? <FaTable /> :
rule.context === 'UI' ? <FaDesktop /> : <FaServer />}
</span>
<span className={styles.ruleItemName}>{rule.item || '(global)'}</span>
</div>
{!readOnly && (
<div className={styles.ruleActions}>
<button
className={`${styles.iconButton} ${styles.danger}`}
onClick={() => onDelete(rule.id)}
title="Regel löschen"
>
<FaTrash />
</button>
</div>
)}
</div>
<div className={styles.permissionsGrid}>
{/* View Toggle */}
<div className={styles.permissionItem}>
<span className={styles.permissionLabel}>View</span>
<div className={styles.viewToggle}>
<input
type="checkbox"
checked={rule.view}
onChange={(e) => onUpdate(rule.id, { view: e.target.checked })}
disabled={readOnly}
className={styles.viewCheckbox}
/>
</div>
</div>
{/* CRUD Levels (only for DATA context) */}
{isDataRule ? (
<>
<div className={styles.permissionItem}>
<span className={styles.permissionLabel}>Read</span>
<AccessLevelSelect
value={rule.read}
onChange={(value) => onUpdate(rule.id, { read: value })}
disabled={readOnly}
compact
/>
</div>
<div className={styles.permissionItem}>
<span className={styles.permissionLabel}>Create</span>
<AccessLevelSelect
value={rule.create}
onChange={(value) => onUpdate(rule.id, { create: value })}
disabled={readOnly}
compact
/>
</div>
<div className={styles.permissionItem}>
<span className={styles.permissionLabel}>Update</span>
<AccessLevelSelect
value={rule.update}
onChange={(value) => onUpdate(rule.id, { update: value })}
disabled={readOnly}
compact
/>
</div>
<div className={styles.permissionItem}>
<span className={styles.permissionLabel}>Delete</span>
<AccessLevelSelect
value={rule.delete}
onChange={(value) => onUpdate(rule.id, { delete: value })}
disabled={readOnly}
compact
/>
</div>
</>
) : (
// For UI and RESOURCE, show empty placeholders to maintain grid
<div style={{ gridColumn: 'span 4' }} />
)}
</div>
</div>
);
};
// =============================================================================
// ADD RULE FORM
// =============================================================================
interface AddRuleFormProps {
context: RuleContext;
availableObjects: CatalogObject[];
onAdd: (rule: AccessRuleCreate) => void;
onCancel: () => void;
}
const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, onAdd, onCancel }) => {
const [item, setItem] = useState('');
const [useCustom, setUseCustom] = useState(false);
const [view, setView] = useState(true);
const [read, setRead] = useState<AccessLevel>('n');
const [create, setCreate] = useState<AccessLevel>('n');
const [update, setUpdate] = useState<AccessLevel>('n');
const [del, setDel] = useState<AccessLevel>('n');
// Group objects by feature
const groupedObjects = useMemo(() => {
const grouped: Record<string, CatalogObject[]> = {};
availableObjects.forEach(obj => {
if (!grouped[obj.featureCode]) {
grouped[obj.featureCode] = [];
}
grouped[obj.featureCode].push(obj);
});
return grouped;
}, [availableObjects]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newRule: AccessRuleCreate = {
context,
item: item.trim() || null,
view,
...(context === 'DATA' ? { read, create, update, delete: del } : {}),
};
onAdd(newRule);
};
const getPlaceholder = () => {
switch (context) {
case 'DATA':
return 'z.B. data.feature.trustee.TrusteePosition';
case 'UI':
return 'z.B. ui.feature.trustee.dashboard';
case 'RESOURCE':
return 'z.B. resource.feature.trustee.documents.create';
}
};
const getLabel = (obj: CatalogObject): string => {
return obj.label.de || obj.label.en || obj.objectKey;
};
return (
<form className={styles.addRuleForm} onSubmit={handleSubmit}>
<div className={styles.formGroup}>
<div className={styles.objectSelectorLabel}>
<label className={styles.formLabel}>Objekt auswählen</label>
<button
type="button"
className={styles.toggleCustomButton}
onClick={() => setUseCustom(!useCustom)}
>
{useCustom ? '← Aus Katalog wählen' : 'Freie Eingabe →'}
</button>
</div>
{useCustom ? (
<input
type="text"
value={item}
onChange={(e) => setItem(e.target.value)}
placeholder={getPlaceholder()}
className={styles.formInput}
autoFocus
/>
) : (
<select
value={item}
onChange={(e) => setItem(e.target.value)}
className={styles.formSelect}
>
<option value="">-- Global (alle Objekte) --</option>
{Object.entries(groupedObjects).map(([feature, objs]) => (
<optgroup key={feature} label={feature.toUpperCase()}>
{objs.map(obj => (
<option key={obj.objectKey} value={obj.objectKey}>
{obj.objectKey} - {getLabel(obj)}
</option>
))}
</optgroup>
))}
</select>
)}
<span className={styles.formHint}>
Leer lassen für globale Regel. Längster Match gewinnt bei Wildcards (z.B. data.feature.trustee.*).
</span>
</div>
<div className={styles.formGroup}>
<label className={styles.formLabel}>
<input
type="checkbox"
checked={view}
onChange={(e) => setView(e.target.checked)}
style={{ marginRight: '0.5rem' }}
/>
Sichtbar (View)
</label>
</div>
{context === 'DATA' && (
<div className={styles.addRuleMatrix}>
{/* Header Row */}
<div className={styles.matrixHeader}>
<div className={styles.matrixLabel}></div>
<div className={styles.matrixGroup}>Eigene (m)</div>
<div className={styles.matrixGroup}>Gruppe (g)</div>
<div className={styles.matrixGroup}>Alle (a)</div>
</div>
{/* CRUD Rows */}
{(['create', 'read', 'update', 'delete'] as const).map(op => {
const value = op === 'delete' ? del : op === 'create' ? create : op === 'update' ? update : read;
const setValue = op === 'delete' ? setDel : op === 'create' ? setCreate : op === 'update' ? setUpdate : setRead;
const labels = { create: 'Create', read: 'Read', update: 'Update', delete: 'Delete' };
return (
<div key={op} className={styles.matrixRow}>
<div className={styles.matrixLabel}>{labels[op]}</div>
{(['m', 'g', 'a'] as const).map(level => (
<div key={level} className={styles.matrixCell}>
<input
type="checkbox"
checked={value === level || (level === 'm' && (value === 'g' || value === 'a')) || (level === 'g' && value === 'a')}
onChange={(e) => {
if (e.target.checked) {
setValue(level);
} else {
// Deactivate: set to level below
const hierarchy: AccessLevel[] = ['n', 'm', 'g', 'a'];
const idx = hierarchy.indexOf(level);
setValue(hierarchy[idx - 1] || 'n');
}
}}
title={`${labels[op]} - ${level === 'm' ? 'Eigene' : level === 'g' ? 'Gruppe' : 'Alle'}`}
/>
</div>
))}
</div>
);
})}
</div>
)}
<div className={styles.formActions}>
<button type="button" className={styles.secondaryButton} onClick={onCancel}>
Abbrechen
</button>
<button type="submit" className={styles.primaryButton}>
<FaPlus /> Hinzufügen
</button>
</div>
</form>
);
};
// =============================================================================
// RULES SECTION
// =============================================================================
interface RulesSectionProps {
context: RuleContext;
rules: AccessRule[];
availableObjects: CatalogObject[];
readOnly?: boolean;
onUpdate: (ruleId: string, updates: Partial<AccessRule>) => void;
onDelete: (ruleId: string) => void;
onAdd: (rule: AccessRuleCreate) => void;
}
const RulesSection: React.FC<RulesSectionProps> = ({
context,
rules,
availableObjects,
readOnly,
onUpdate,
onDelete,
onAdd,
}) => {
const [showAddForm, setShowAddForm] = useState(false);
const [useTableView, setUseTableView] = useState(context === 'DATA'); // Default to table for DATA
const handleAdd = (rule: AccessRuleCreate) => {
onAdd(rule);
setShowAddForm(false);
};
const getEmptyIcon = () => {
switch (context) {
case 'DATA': return <FaTable />;
case 'UI': return <FaDesktop />;
case 'RESOURCE': return <FaServer />;
}
};
const getEmptyText = () => {
switch (context) {
case 'DATA': return 'Keine Daten-Regeln definiert';
case 'UI': return 'Keine UI-Regeln definiert';
case 'RESOURCE': return 'Keine Ressourcen-Regeln definiert';
}
};
return (
<div className={styles.rulesSection}>
{!readOnly && !showAddForm && (
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>
{rules.length} {rules.length === 1 ? 'Regel' : 'Regeln'}
</span>
<div className={styles.headerActions}>
{/* View Toggle */}
{context === 'DATA' && rules.length > 0 && (
<>
<button
className={`${styles.viewToggleButton} ${useTableView ? styles.active : ''}`}
onClick={() => setUseTableView(true)}
title="Tabellenansicht"
>
<FaThList />
</button>
<button
className={`${styles.viewToggleButton} ${!useTableView ? styles.active : ''}`}
onClick={() => setUseTableView(false)}
title="Kartenansicht"
>
<FaTh />
</button>
</>
)}
<button
className={styles.addButton}
onClick={() => setShowAddForm(true)}
>
<FaPlus /> Neue Regel
</button>
</div>
</div>
)}
{showAddForm && (
<AddRuleForm
context={context}
availableObjects={availableObjects}
onAdd={handleAdd}
onCancel={() => setShowAddForm(false)}
/>
)}
{rules.length === 0 && !showAddForm ? (
<div className={styles.emptyState}>
<div className={styles.emptyIcon}>{getEmptyIcon()}</div>
<p className={styles.emptyText}>{getEmptyText()}</p>
{!readOnly && (
<p className={styles.emptyHint}>
Klicken Sie auf "Neue Regel" um eine Berechtigung hinzuzufügen.
</p>
)}
</div>
) : useTableView && context === 'DATA' ? (
<AccessRulesTable
rules={rules}
context={context}
readOnly={readOnly}
onUpdate={onUpdate}
onDelete={onDelete}
/>
) : (
rules.map(rule => (
<RuleCard
key={rule.id}
rule={rule}
readOnly={readOnly}
onUpdate={onUpdate}
onDelete={onDelete}
/>
))
)}
</div>
);
};
// =============================================================================
// JSON EDITOR
// =============================================================================
interface JsonEditorProps {
rules: AccessRule[];
readOnly?: boolean;
onApply: (rules: AccessRule[]) => void;
}
const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) => {
const [jsonText, setJsonText] = useState('');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setJsonText(JSON.stringify(rules, null, 2));
setError(null);
}, [rules]);
const handleApply = () => {
try {
const parsed = JSON.parse(jsonText);
if (!Array.isArray(parsed)) {
throw new Error('JSON muss ein Array sein');
}
setError(null);
onApply(parsed);
} catch (err: any) {
setError(err.message);
}
};
return (
<div className={styles.jsonEditor}>
<textarea
value={jsonText}
onChange={(e) => setJsonText(e.target.value)}
className={styles.jsonTextarea}
readOnly={readOnly}
spellCheck={false}
/>
{error && <div className={styles.jsonError}>{error}</div>}
<p className={styles.jsonHint}>
Experten-Modus: Bearbeiten Sie die Regeln direkt als JSON.
Änderungen werden erst nach Klick auf "Anwenden" übernommen.
</p>
{!readOnly && (
<div className={styles.formActions}>
<button
type="button"
className={styles.primaryButton}
onClick={handleApply}
disabled={!!error}
>
JSON anwenden
</button>
</div>
)}
</div>
);
};
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
roleId,
roleName,
isTemplate = false,
readOnly = false,
onSave,
apiBasePath = '/api/rbac',
mandateId,
featureCode,
}) => {
const {
rules,
loading,
saving,
error,
fetchRules,
saveRules,
getGroupedRules,
updateRuleLocally,
addRuleLocally,
removeRuleLocally,
} = useAccessRules(roleId, apiBasePath, mandateId);
// Catalog objects for dropdown selection
const { objects: catalogObjects, fetchObjects } = useCatalogObjects();
const [activeTab, setActiveTab] = useState<TabType>('DATA');
const [hasChanges, setHasChanges] = useState(false);
const [originalRules, setOriginalRules] = useState<AccessRule[]>([]);
// Load rules on mount
useEffect(() => {
fetchRules().then(fetchedRules => {
setOriginalRules(fetchedRules);
});
}, [fetchRules]);
// Load catalog objects - filter by featureCode if provided
useEffect(() => {
fetchObjects(undefined, featureCode, mandateId);
}, [fetchObjects, featureCode, mandateId]);
// Get objects for current tab
const currentContextObjects = useMemo(() => {
if (activeTab === 'JSON') return [];
return catalogObjects[activeTab] || [];
}, [catalogObjects, activeTab]);
// Track changes
useEffect(() => {
setHasChanges(JSON.stringify(rules) !== JSON.stringify(originalRules));
}, [rules, originalRules]);
const groupedRules = getGroupedRules();
// Handlers
const handleUpdate = useCallback((ruleId: string, updates: Partial<AccessRule>) => {
updateRuleLocally(ruleId, updates);
}, [updateRuleLocally]);
const handleDelete = useCallback((ruleId: string) => {
if (window.confirm('Möchten Sie diese Regel wirklich löschen?')) {
removeRuleLocally(ruleId);
}
}, [removeRuleLocally]);
const handleAdd = useCallback((ruleData: AccessRuleCreate) => {
const newRule: AccessRule = {
id: `temp-${Date.now()}`, // Temporary ID
roleId,
context: ruleData.context,
item: ruleData.item || null,
view: ruleData.view ?? true,
read: ruleData.read ?? null,
create: ruleData.create ?? null,
update: ruleData.update ?? null,
delete: ruleData.delete ?? null,
};
addRuleLocally(newRule);
}, [roleId, addRuleLocally]);
const handleSave = async () => {
const result = await saveRules(rules);
if (result.success) {
setOriginalRules(rules);
setHasChanges(false);
onSave?.();
} else {
alert(result.error || 'Fehler beim Speichern');
}
};
const handleReset = () => {
if (window.confirm('Alle Änderungen verwerfen?')) {
fetchRules().then(fetchedRules => {
setOriginalRules(fetchedRules);
});
}
};
const handleJsonApply = (newRules: AccessRule[]) => {
// Replace all rules
newRules.forEach((rule, index) => {
if (!rule.id) {
rule.id = `temp-${Date.now()}-${index}`;
}
rule.roleId = roleId;
});
// This is a bit hacky - we need to update the store
// For now, we'll just save directly
saveRules(newRules);
};
// Render tabs
const tabs: { id: TabType; label: string; icon: React.ReactNode; count: number }[] = [
{ id: 'DATA', label: 'Daten', icon: <FaTable />, count: groupedRules.DATA.length },
{ id: 'UI', label: 'UI', icon: <FaDesktop />, count: groupedRules.UI.length },
{ id: 'RESOURCE', label: 'Ressourcen', icon: <FaServer />, count: groupedRules.RESOURCE.length },
{ id: 'JSON', label: 'JSON', icon: <FaCode />, count: rules.length },
];
if (loading) {
return (
<div className={styles.accessRulesEditor}>
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Berechtigungen...</span>
</div>
</div>
);
}
return (
<div className={styles.accessRulesEditor}>
<div className={styles.editorHeader}>
<h3 className={styles.editorTitle}>
Berechtigungen{roleName ? `: ${roleName}` : ''}
{isTemplate && <span className={styles.templateBadge}>Template</span>}
</h3>
{!readOnly && hasChanges && (
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={handleReset}
disabled={saving}
>
<FaUndo /> Zurücksetzen
</button>
</div>
)}
</div>
{error && (
<div className={styles.jsonError}>
Fehler: {error}
</div>
)}
<div className={styles.tabsContainer}>
<div className={styles.tabList}>
{tabs.map(tab => (
<button
key={tab.id}
className={`${styles.tab} ${activeTab === tab.id ? styles.active : ''}`}
onClick={() => setActiveTab(tab.id)}
>
<span className={styles.tabIcon}>{tab.icon}</span>
{tab.label}
<span className={styles.tabBadge}>{tab.count}</span>
</button>
))}
</div>
<div className={styles.tabContent}>
{activeTab === 'DATA' && (
<RulesSection
context="DATA"
rules={groupedRules.DATA}
availableObjects={catalogObjects.DATA || []}
readOnly={readOnly}
onUpdate={handleUpdate}
onDelete={handleDelete}
onAdd={handleAdd}
/>
)}
{activeTab === 'UI' && (
<RulesSection
context="UI"
rules={groupedRules.UI}
availableObjects={catalogObjects.UI || []}
readOnly={readOnly}
onUpdate={handleUpdate}
onDelete={handleDelete}
onAdd={handleAdd}
/>
)}
{activeTab === 'RESOURCE' && (
<RulesSection
context="RESOURCE"
rules={groupedRules.RESOURCE}
availableObjects={catalogObjects.RESOURCE || []}
readOnly={readOnly}
onUpdate={handleUpdate}
onDelete={handleDelete}
onAdd={handleAdd}
/>
)}
{activeTab === 'JSON' && (
<JsonEditor
rules={rules}
readOnly={readOnly}
onApply={handleJsonApply}
/>
)}
</div>
</div>
{!readOnly && (
<div className={styles.actionBar}>
<button
className={styles.primaryButton}
onClick={handleSave}
disabled={saving || !hasChanges}
>
{saving ? (
<>
<FaSpinner className="spinning" /> Speichern...
</>
) : (
<>
<FaSave /> Speichern
</>
)}
</button>
</div>
)}
</div>
);
};
export default AccessRulesEditor;