459 lines
15 KiB
TypeScript
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;
|