frontend_nyla/src/components/RbacExportImport/RbacExportImport.tsx
2026-01-21 00:32:52 +01:00

459 lines
15 KiB
TypeScript

/**
* RbacExportImport
*
* Component for exporting and importing RBAC configurations.
* Supports mandate-level and global exports with different import modes.
*/
import React, { useState, useRef, useCallback, useEffect } from 'react';
import {
FaDownload,
FaUpload,
FaFileExport,
FaFileImport,
FaSpinner,
FaCheckCircle,
FaExclamationTriangle,
FaInfoCircle,
FaTrash,
FaEye,
} from 'react-icons/fa';
import {
useRbacExportImport,
type RbacExport,
type ImportMode,
type RbacImportResult,
} from '../../hooks/useRbacExportImport';
import styles from './RbacExportImport.module.css';
// =============================================================================
// TYPES
// =============================================================================
interface RbacExportImportProps {
mandateId?: string;
mandateName?: string;
isGlobal?: boolean;
featureCode?: string;
}
// =============================================================================
// IMPORT MODE OPTIONS
// =============================================================================
const IMPORT_MODES: { value: ImportMode; label: string; description: string; icon: React.ReactNode }[] = [
{
value: 'merge',
label: 'Zusammenführen',
description: 'Bestehende Regeln aktualisieren, neue hinzufügen',
icon: <FaCheckCircle style={{ color: '#38a169' }} />,
},
{
value: 'add_only',
label: 'Nur hinzufügen',
description: 'Nur neue Regeln hinzufügen, bestehende nicht ändern',
icon: <FaInfoCircle style={{ color: '#3182ce' }} />,
},
{
value: 'replace',
label: 'Ersetzen',
description: 'Alle bestehenden Regeln löschen und ersetzen',
icon: <FaExclamationTriangle style={{ color: '#d69e2e' }} />,
},
];
// =============================================================================
// PREVIEW COMPONENT
// =============================================================================
interface PreviewProps {
data: RbacExport;
onClose: () => void;
}
const ExportPreview: React.FC<PreviewProps> = ({ data, onClose }) => {
return (
<div className={styles.preview}>
<div className={styles.previewHeader}>
<h4 className={styles.previewTitle}>Export-Vorschau</h4>
<button className={styles.closeButton} onClick={onClose}></button>
</div>
<div className={styles.previewContent}>
<div className={styles.previewSection}>
<h5>Scope</h5>
<ul className={styles.previewList}>
<li><strong>Typ:</strong> {data.scope.type}</li>
{data.scope.mandateName && <li><strong>Mandant:</strong> {data.scope.mandateName}</li>}
{data.scope.featureCode && <li><strong>Feature:</strong> {data.scope.featureCode}</li>}
</ul>
</div>
<div className={styles.previewSection}>
<h5>Rollen ({data.roles.length})</h5>
<ul className={styles.previewList}>
{data.roles.slice(0, 5).map((role, i) => (
<li key={i}>
<code>{role.roleLabel}</code>
{role.featureCode && <span className={styles.featureBadge}>{role.featureCode}</span>}
</li>
))}
{data.roles.length > 5 && (
<li className={styles.moreItems}>... und {data.roles.length - 5} weitere</li>
)}
</ul>
</div>
<div className={styles.previewSection}>
<h5>Regeln ({data.accessRules.length})</h5>
<ul className={styles.previewList}>
{data.accessRules.slice(0, 5).map((rule, i) => (
<li key={i}>
<span className={styles.contextBadge}>{rule.context}</span>
<code>{rule.item || '(global)'}</code>
</li>
))}
{data.accessRules.length > 5 && (
<li className={styles.moreItems}>... und {data.accessRules.length - 5} weitere</li>
)}
</ul>
</div>
</div>
</div>
);
};
// =============================================================================
// IMPORT RESULT COMPONENT
// =============================================================================
interface ImportResultProps {
result: RbacImportResult;
onClose: () => void;
}
const ImportResult: React.FC<ImportResultProps> = ({ result, onClose }) => {
const isSuccess = result.status === 'success';
return (
<div className={`${styles.importResult} ${isSuccess ? styles.success : styles.error}`}>
<div className={styles.resultHeader}>
{isSuccess ? (
<FaCheckCircle className={styles.resultIcon} />
) : (
<FaExclamationTriangle className={styles.resultIcon} />
)}
<h4 className={styles.resultTitle}>
{isSuccess ? 'Import erfolgreich' : 'Import fehlgeschlagen'}
</h4>
<button className={styles.closeButton} onClick={onClose}></button>
</div>
<div className={styles.resultContent}>
<ul className={styles.resultStats}>
<li><strong>Modus:</strong> {IMPORT_MODES.find(m => m.value === result.mode)?.label}</li>
<li><strong>Rollen erstellt:</strong> {result.rolesCreated}</li>
<li><strong>Rollen aktualisiert:</strong> {result.rolesUpdated}</li>
<li><strong>Regeln erstellt:</strong> {result.rulesCreated}</li>
<li><strong>Regeln aktualisiert:</strong> {result.rulesUpdated}</li>
</ul>
{result.errors && result.errors.length > 0 && (
<div className={styles.resultErrors}>
<h5>Fehler:</h5>
<ul>
{result.errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
</div>
)}
</div>
</div>
);
};
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export const RbacExportImport: React.FC<RbacExportImportProps> = ({
mandateId,
mandateName,
isGlobal = false,
featureCode,
}) => {
const {
exporting,
importing,
error,
lastExport,
lastImportResult,
exportMandateRbac,
exportGlobalRbac,
importMandateRbac,
importGlobalRbac,
downloadExport,
parseImportFile,
reset,
} = useRbacExportImport();
const [importMode, setImportMode] = useState<ImportMode>('merge');
const [importFile, setImportFile] = useState<File | null>(null);
const [importData, setImportData] = useState<RbacExport | null>(null);
const [parseError, setParseError] = useState<string | null>(null);
const [showPreview, setShowPreview] = useState(false);
const [showResult, setShowResult] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Handle export
const handleExport = async () => {
let result;
if (isGlobal) {
result = await exportGlobalRbac(featureCode);
} else if (mandateId) {
result = await exportMandateRbac(mandateId, featureCode);
} else {
return;
}
if (result.success && result.data) {
downloadExport(result.data);
}
};
// Handle file selection
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setImportFile(file);
setParseError(null);
const result = await parseImportFile(file);
if (result.success && result.data) {
setImportData(result.data);
} else {
setParseError(result.error || 'Fehler beim Parsen');
setImportData(null);
}
};
// Handle import
const handleImport = async () => {
if (!importData) return;
let result;
if (isGlobal) {
result = await importGlobalRbac(importData, importMode);
} else if (mandateId) {
result = await importMandateRbac(mandateId, importData, importMode);
} else {
return;
}
if (result.success) {
setShowResult(true);
// Clear import state
setImportFile(null);
setImportData(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
// Clear import state
const handleClearImport = () => {
setImportFile(null);
setImportData(null);
setParseError(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
// Handle close result
const handleCloseResult = () => {
setShowResult(false);
reset();
};
return (
<div className={styles.rbacExportImport}>
{/* Export Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<FaFileExport className={styles.sectionIcon} />
<h3 className={styles.sectionTitle}>Export</h3>
</div>
<div className={styles.sectionContent}>
<p className={styles.sectionDescription}>
Exportiert alle Rollen und Berechtigungen
{isGlobal ? ' der globalen Templates' : ` des Mandanten "${mandateName || mandateId}"`}
{featureCode ? ` für Feature "${featureCode}"` : ''} als JSON-Datei.
</p>
<button
className={styles.primaryButton}
onClick={handleExport}
disabled={exporting || (!isGlobal && !mandateId)}
>
{exporting ? (
<>
<FaSpinner className="spinning" /> Exportieren...
</>
) : (
<>
<FaDownload /> RBAC exportieren
</>
)}
</button>
</div>
</div>
{/* Import Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<FaFileImport className={styles.sectionIcon} />
<h3 className={styles.sectionTitle}>Import</h3>
</div>
<div className={styles.sectionContent}>
{/* File Upload */}
<div className={styles.fileUpload}>
<input
type="file"
ref={fileInputRef}
accept=".json"
onChange={handleFileSelect}
className={styles.fileInput}
id="rbac-import-file"
/>
<label htmlFor="rbac-import-file" className={styles.fileLabel}>
{importFile ? (
<>
<FaCheckCircle className={styles.fileIcon} style={{ color: '#38a169' }} />
<span>{importFile.name}</span>
</>
) : (
<>
<FaUpload className={styles.fileIcon} />
<span>JSON-Datei auswählen oder hier ablegen</span>
</>
)}
</label>
{importFile && (
<button
className={styles.clearButton}
onClick={handleClearImport}
title="Datei entfernen"
>
<FaTrash />
</button>
)}
</div>
{/* Parse Error */}
{parseError && (
<div className={styles.errorMessage}>
<FaExclamationTriangle /> {parseError}
</div>
)}
{/* Import Data Info */}
{importData && (
<div className={styles.importInfo}>
<div className={styles.importStats}>
<span><strong>Rollen:</strong> {importData.roles.length}</span>
<span><strong>Regeln:</strong> {importData.accessRules.length}</span>
<span><strong>Quelle:</strong> {importData.scope.type}</span>
</div>
<button
className={styles.previewButton}
onClick={() => setShowPreview(true)}
>
<FaEye /> Vorschau
</button>
</div>
)}
{/* Import Mode Selection */}
{importData && (
<div className={styles.importModeSection}>
<h4 className={styles.importModeTitle}>Import-Modus</h4>
<div className={styles.importModes}>
{IMPORT_MODES.map(mode => (
<label
key={mode.value}
className={`${styles.importModeOption} ${importMode === mode.value ? styles.selected : ''}`}
>
<input
type="radio"
name="importMode"
value={mode.value}
checked={importMode === mode.value}
onChange={(e) => setImportMode(e.target.value as ImportMode)}
className={styles.radioInput}
/>
<span className={styles.modeIcon}>{mode.icon}</span>
<span className={styles.modeLabel}>{mode.label}</span>
<span className={styles.modeDescription}>{mode.description}</span>
</label>
))}
</div>
</div>
)}
{/* Import Button */}
{importData && (
<button
className={`${styles.primaryButton} ${importMode === 'replace' ? styles.danger : ''}`}
onClick={handleImport}
disabled={importing || (!isGlobal && !mandateId)}
>
{importing ? (
<>
<FaSpinner className="spinning" /> Importieren...
</>
) : (
<>
<FaUpload /> RBAC importieren
</>
)}
</button>
)}
{/* Warning for replace mode */}
{importMode === 'replace' && importData && (
<div className={styles.warningMessage}>
<FaExclamationTriangle />
<strong>Achtung:</strong> Im Modus "Ersetzen" werden alle bestehenden Rollen und Regeln gelöscht!
</div>
)}
</div>
</div>
{/* Error Message */}
{error && (
<div className={styles.errorMessage}>
<FaExclamationTriangle /> {error}
</div>
)}
{/* Preview Modal */}
{showPreview && importData && (
<div className={styles.modalOverlay} onClick={() => setShowPreview(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<ExportPreview data={importData} onClose={() => setShowPreview(false)} />
</div>
</div>
)}
{/* Result Modal */}
{showResult && lastImportResult && (
<div className={styles.modalOverlay} onClick={handleCloseResult}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<ImportResult result={lastImportResult} onClose={handleCloseResult} />
</div>
</div>
)}
</div>
);
};
export default RbacExportImport;