frontend_nyla/src/pages/admin/AdminMandateRolePermissionsPage.tsx
2026-02-09 12:49:39 +01:00

497 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* AdminMandateRolePermissionsPage
*
* Admin page for managing access rules (permissions) for mandate-level roles.
* Similar to TrusteeInstanceRolesView but for mandate/global roles.
*
* Shows:
* - System roles (admin, user, viewer) - read-only permissions
* - Global roles (mandateId=null) - editable permissions
* - Mandate-specific roles (mandateId=xyz) - editable permissions
*
* Each role can be expanded to show/edit its AccessRules via AccessRulesEditor.
*
* Includes a "Cleanup Duplicates" tool to find and remove duplicate AccessRules.
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useMandateRoles, type Role } from '../../hooks/useMandateRoles';
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
import { AccessRulesEditor } from '../../components/AccessRules';
import api from '../../api';
import {
FaUserShield,
FaShieldAlt,
FaSync,
FaChevronDown,
FaChevronRight,
FaGlobe,
FaBuilding,
FaFilter,
FaBroom,
FaTimes,
FaExclamationTriangle,
FaCheckCircle
} from 'react-icons/fa';
import styles from './Admin.module.css';
// Types for cleanup result
interface DuplicateGroup {
roleId: string;
context: string;
item: string;
totalCount: number;
keepId: string;
deleteCount: number;
deleteIds: string[];
}
interface CleanupResult {
dryRun: boolean;
totalRules: number;
uniqueSignatures: number;
duplicateGroups: number;
duplicateRulesToDelete: number;
deletedCount: number;
details: DuplicateGroup[];
}
export const AdminMandateRolePermissionsPage: React.FC = () => {
const {
roles,
loading,
error,
fetchRoles,
} = useMandateRoles();
const { fetchMandates } = useUserMandates();
// State
const [mandates, setMandates] = useState<Mandate[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('mandate');
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
// Cleanup state
const [showCleanupModal, setShowCleanupModal] = useState(false);
const [cleanupLoading, setCleanupLoading] = useState(false);
const [cleanupResult, setCleanupResult] = useState<CleanupResult | null>(null);
const [cleanupError, setCleanupError] = useState<string | null>(null);
const [cleanupPhase, setCleanupPhase] = useState<'idle' | 'preview' | 'done'>('idle');
// Load mandates on mount
useEffect(() => {
const loadMandates = async () => {
const data = await fetchMandates();
setMandates(data);
if (data.length > 0 && !selectedMandateId) {
setSelectedMandateId(data[0].id);
}
};
loadMandates();
}, [fetchMandates]);
// Load roles when mandate or scopeFilter changes
useEffect(() => {
if (selectedMandateId) {
fetchRoles(selectedMandateId, { scopeFilter });
}
}, [selectedMandateId, scopeFilter, fetchRoles]);
// Refetch roles
const handleRefresh = useCallback(async () => {
if (selectedMandateId) {
await fetchRoles(selectedMandateId, { scopeFilter });
}
}, [selectedMandateId, scopeFilter, fetchRoles]);
// Get description text from multilingual object
const getTextValue = (value: string | { [key: string]: string } | undefined): string => {
if (!value) return '';
if (typeof value === 'string') return value;
return value.de || value.en || Object.values(value)[0] || '';
};
// Toggle role expansion
const toggleRole = (roleId: string) => {
setExpandedRoleId(prev => prev === roleId ? null : roleId);
};
// Check if a role is a template (not bound to a specific mandate)
const _isTemplateRole = (role: Role): boolean => {
return !!role.isSystemRole || !role.mandateId;
};
// Get scope badge
const getScopeBadge = (role: Role) => {
if (role.isSystemRole) {
return (
<span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}>
<FaUserShield style={{ marginRight: 4 }} /> System-Template
</span>
);
}
if (!role.mandateId) {
return (
<span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}>
<FaGlobe style={{ marginRight: 4 }} /> Template
</span>
);
}
return (
<span className={styles.badge} style={{ background: 'var(--success-color, #38a169)', color: 'white' }}>
<FaBuilding style={{ marginRight: 4 }} /> Mandant
</span>
);
};
// --- Cleanup functions ---
const _openCleanupModal = useCallback(async () => {
setShowCleanupModal(true);
setCleanupError(null);
setCleanupResult(null);
setCleanupPhase('idle');
setCleanupLoading(true);
try {
const response = await api.post('/api/rbac/cleanup/duplicate-rules?dryRun=true');
setCleanupResult(response.data);
setCleanupPhase('preview');
} catch (err: any) {
setCleanupError(err?.response?.data?.detail || err?.message || 'Fehler beim Laden der Duplikate');
} finally {
setCleanupLoading(false);
}
}, []);
const _executeCleanup = useCallback(async () => {
setCleanupLoading(true);
setCleanupError(null);
try {
const response = await api.post('/api/rbac/cleanup/duplicate-rules?dryRun=false');
setCleanupResult(response.data);
setCleanupPhase('done');
// Refresh roles after cleanup
if (selectedMandateId) {
fetchRoles(selectedMandateId, { scopeFilter });
}
} catch (err: any) {
setCleanupError(err?.response?.data?.detail || err?.message || 'Fehler beim Bereinigen');
} finally {
setCleanupLoading(false);
}
}, [selectedMandateId, scopeFilter, fetchRoles]);
const _closeCleanupModal = useCallback(() => {
setShowCleanupModal(false);
setCleanupResult(null);
setCleanupError(null);
setCleanupPhase('idle');
}, []);
// Filter options for scope
const scopeOptions = useMemo(() => [
{ value: 'mandate', label: 'Mandanten-Rollen' },
{ value: 'all', label: 'Alle (inkl. Templates)' },
{ value: 'global', label: 'Nur Templates' },
], []);
if (error) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden: {error}</p>
<button className={styles.secondaryButton} onClick={handleRefresh}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
{/* Header */}
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
Rollen-Berechtigungen
</h1>
<p className={styles.pageSubtitle}>
Verwalten Sie die Zugriffsrechte für Mandanten- und globale Rollen
</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={_openCleanupModal}
disabled={loading}
title="Doppelte Regeln finden und bereinigen"
>
<FaBroom /> Duplikate bereinigen
</button>
<button
className={styles.secondaryButton}
onClick={handleRefresh}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
</div>
</div>
{/* Filters */}
<div className={styles.filterBar}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Mandant:</label>
<select
className={styles.filterSelect}
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
{mandates.map(mandate => (
<option key={mandate.id} value={mandate.id}>
{getTextValue(mandate.name)}
</option>
))}
</select>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaFilter style={{ marginRight: 4 }} /> Bereich:
</label>
<select
className={styles.filterSelect}
value={scopeFilter}
onChange={(e) => setScopeFilter(e.target.value as 'all' | 'mandate' | 'global')}
>
{scopeOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
{/* Info Box */}
<div className={styles.infoBox}>
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
<span>
Klicken Sie auf eine Rolle, um deren Berechtigungen (AccessRules) zu bearbeiten.
<strong> Template-Rollen</strong> sind schreibgeschützt - Änderungen an Templates wirken sich nur auf neu erstellte Mandanten aus.
<strong> Mandanten-Rollen</strong> sind direkt bearbeitbar.
</span>
</div>
{/* Loading State */}
{loading && (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Rollen...</span>
</div>
)}
{/* Empty State */}
{!loading && roles.length === 0 && (
<div className={styles.emptyState}>
<FaUserShield className={styles.emptyIcon} />
<p>Keine Rollen gefunden</p>
<p className={styles.emptyHint}>
{scopeFilter === 'mandate'
? 'Es gibt noch keine Mandanten-Rollen. System-Rollen werden bei der Mandant-Erstellung automatisch kopiert.'
: scopeFilter === 'global'
? 'Es gibt noch keine Rollen-Templates.'
: 'Es gibt noch keine Rollen für diesen Mandanten.'}
</p>
</div>
)}
{/* Roles List */}
{!loading && roles.length > 0 && (
<div className={styles.rolesList}>
{roles.map(role => (
<div key={role.id} className={styles.roleCard}>
{/* Role Header - Clickable to expand */}
<div
className={styles.roleHeader}
onClick={() => toggleRole(role.id)}
>
<div className={styles.roleInfo}>
<span className={styles.expandIcon}>
{expandedRoleId === role.id ? <FaChevronDown /> : <FaChevronRight />}
</span>
<span className={styles.roleLabel}>{role.roleLabel}</span>
<span className={styles.roleDescription}>
{getTextValue(role.description)}
</span>
</div>
<div className={styles.roleBadges}>
{getScopeBadge(role)}
</div>
</div>
{/* Expanded Content - AccessRulesEditor */}
{expandedRoleId === role.id && (
<div className={styles.roleContent}>
{_isTemplateRole(role) && (
<div className={styles.infoBox} style={{ marginBottom: '0.75rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
<FaUserShield style={{ marginRight: '0.5rem', color: 'var(--warning-color, #d69e2e)' }} />
<span>
Dies ist eine <strong>Template-Rolle</strong>. Änderungen an den Berechtigungen wirken sich nur auf neu erstellte Mandanten aus.
Bestehende Mandanten-Instanzen werden nicht aktualisiert.
</span>
</div>
)}
<AccessRulesEditor
roleId={role.id}
roleName={role.roleLabel}
isTemplate={_isTemplateRole(role)}
readOnly={false}
apiBasePath="/api/rbac"
mandateId={selectedMandateId}
/>
</div>
)}
</div>
))}
</div>
)}
{/* Cleanup Duplicates Modal */}
{showCleanupModal && (
<div className={styles.modalOverlay} onClick={_closeCleanupModal}>
<div className={styles.modal} style={{ maxWidth: '750px' }} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h3 className={styles.modalTitle}>
<FaBroom style={{ marginRight: '0.5rem' }} />
Doppelte Regeln bereinigen
</h3>
<button className={styles.modalClose} onClick={_closeCleanupModal}>
<FaTimes />
</button>
</div>
<div className={styles.modalContent}>
{/* Loading */}
{cleanupLoading && (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{cleanupPhase === 'idle' ? 'Analysiere Duplikate...' : 'Bereinige Duplikate...'}</span>
</div>
)}
{/* Error */}
{cleanupError && (
<div style={{ padding: '1rem', background: '#fed7d7', borderRadius: '6px', color: '#c53030', marginBottom: '1rem' }}>
<FaExclamationTriangle style={{ marginRight: '0.5rem' }} />
{cleanupError}
</div>
)}
{/* Results */}
{cleanupResult && !cleanupLoading && (
<>
{/* Summary Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '0.75rem', marginBottom: '1.25rem' }}>
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text-primary)' }}>{cleanupResult.totalRules}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>Regeln total</div>
</div>
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text-primary)' }}>{cleanupResult.uniqueSignatures}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>Eindeutige Regeln</div>
</div>
<div style={{ padding: '0.75rem', background: cleanupResult.duplicateGroups > 0 ? '#fff5f5' : '#f0fff4', borderRadius: '8px', textAlign: 'center', border: `1px solid ${cleanupResult.duplicateGroups > 0 ? '#fc8181' : '#9ae6b4'}` }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: cleanupResult.duplicateGroups > 0 ? '#c53030' : '#2f855a' }}>{cleanupResult.duplicateGroups}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>Duplikat-Gruppen</div>
</div>
<div style={{ padding: '0.75rem', background: cleanupResult.duplicateRulesToDelete > 0 ? '#fff5f5' : '#f0fff4', borderRadius: '8px', textAlign: 'center', border: `1px solid ${cleanupResult.duplicateRulesToDelete > 0 ? '#fc8181' : '#9ae6b4'}` }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: cleanupResult.duplicateRulesToDelete > 0 ? '#c53030' : '#2f855a' }}>
{cleanupPhase === 'done' ? cleanupResult.deletedCount : cleanupResult.duplicateRulesToDelete}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>
{cleanupPhase === 'done' ? 'Geloescht' : 'Zu loeschen'}
</div>
</div>
</div>
{/* Status Message */}
{cleanupPhase === 'done' && (
<div style={{ padding: '0.75rem 1rem', background: '#f0fff4', borderRadius: '6px', color: '#2f855a', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem', border: '1px solid #9ae6b4' }}>
<FaCheckCircle />
<span><strong>{cleanupResult.deletedCount}</strong> doppelte Regeln wurden erfolgreich entfernt.</span>
</div>
)}
{cleanupPhase === 'preview' && cleanupResult.duplicateGroups === 0 && (
<div style={{ padding: '0.75rem 1rem', background: '#f0fff4', borderRadius: '6px', color: '#2f855a', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem', border: '1px solid #9ae6b4' }}>
<FaCheckCircle />
<span>Keine Duplikate gefunden. Alles sauber!</span>
</div>
)}
{/* Details Table */}
{cleanupResult.details.length > 0 && (
<div style={{ marginTop: '0.5rem' }}>
<h4 style={{ fontSize: '0.875rem', fontWeight: 600, marginBottom: '0.5rem', color: 'var(--text-secondary)' }}>
Duplikat-Details {cleanupResult.details.length < cleanupResult.duplicateGroups && `(${cleanupResult.details.length} von ${cleanupResult.duplicateGroups})`}
</h4>
<div style={{ maxHeight: '300px', overflowY: 'auto', border: '1px solid var(--border-color)', borderRadius: '6px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8125rem' }}>
<thead>
<tr style={{ background: 'var(--bg-secondary)', position: 'sticky', top: 0 }}>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>Kontext</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>Item</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'center', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>Total</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'center', fontWeight: 600, borderBottom: '1px solid var(--border-color)', color: '#c53030' }}>Duplikate</th>
</tr>
</thead>
<tbody>
{cleanupResult.details.map((group, idx) => (
<tr key={idx} style={{ borderBottom: '1px solid var(--border-color)' }}>
<td style={{ padding: '0.375rem 0.75rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', background: 'var(--bg-tertiary)', padding: '0.125rem 0.375rem', borderRadius: '3px' }}>
{group.context}
</span>
</td>
<td style={{ padding: '0.375rem 0.75rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', background: 'var(--bg-tertiary)', padding: '0.125rem 0.375rem', borderRadius: '3px' }}>
{group.item}
</span>
</td>
<td style={{ padding: '0.375rem 0.75rem', textAlign: 'center' }}>{group.totalCount}</td>
<td style={{ padding: '0.375rem 0.75rem', textAlign: 'center', color: '#c53030', fontWeight: 600 }}>{group.deleteCount}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</div>
<div className={styles.modalFooter}>
<button className={styles.secondaryButton} onClick={_closeCleanupModal}>
{cleanupPhase === 'done' ? 'Schliessen' : 'Abbrechen'}
</button>
{cleanupPhase === 'preview' && cleanupResult && cleanupResult.duplicateRulesToDelete > 0 && (
<button
className={styles.dangerButton}
onClick={_executeCleanup}
disabled={cleanupLoading}
>
<FaBroom /> {cleanupResult.duplicateRulesToDelete} Duplikate loeschen
</button>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default AdminMandateRolePermissionsPage;