frontend_nyla/src/pages/admin/AdminUserAccessOverviewPage.tsx
ValueOn AG 7077e75fc7 fix 02
2026-02-10 09:25:48 +01:00

660 lines
25 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.

/**
* AdminUserAccessOverviewPage
*
* Admin page for viewing comprehensive user access permissions.
* Shows what pages a user can see and what data they can access.
*/
import React, { useState, useEffect } from 'react';
import { FaSync, FaUserShield, FaEye, FaDatabase, FaCube, FaChevronDown, FaChevronRight, FaCheckCircle, FaTimesCircle, FaInfoCircle } from 'react-icons/fa';
import api from '../../api';
import styles from './Admin.module.css';
interface UserOption {
id: string;
username: string;
email: string;
fullName: string;
isSysAdmin: boolean;
enabled: boolean;
}
interface RoleInfo {
id: string;
roleLabel: string;
description: { [key: string]: string };
scope: 'global' | 'mandate' | 'instance';
mandateId?: string;
featureInstanceId?: string;
source: string;
sourceMandateId?: string;
sourceMandateName?: string;
sourceInstanceId?: string;
sourceInstanceLabel?: string;
}
interface AccessEntry {
item: string;
view?: boolean;
read?: string;
create?: string;
update?: string;
delete?: string;
grantedByRoleLabels: string[];
roleScope: string;
}
interface MandateInfo {
id: string;
name: string;
label?: string;
roleIds: string[];
featureInstances: {
id: string;
label: string;
featureCode: string;
featureLabel: { [key: string]: string };
roleIds: string[];
}[];
}
interface UserAccessOverview {
user: UserOption;
isSysAdmin: boolean;
sysAdminNote?: string;
roles: RoleInfo[];
mandates: MandateInfo[];
uiAccess: AccessEntry[];
dataAccess: AccessEntry[];
resourceAccess: AccessEntry[];
}
type TabId = 'overview' | 'ui' | 'data' | 'resources';
export const AdminUserAccessOverviewPage: React.FC = () => {
const [users, setUsers] = useState<UserOption[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string>('');
const [overview, setOverview] = useState<UserAccessOverview | null>(null);
const [loading, setLoading] = useState(false);
const [loadingUsers, setLoadingUsers] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<TabId>('overview');
const [expandedRoles, setExpandedRoles] = useState<Set<string>>(new Set());
const [expandedMandates, setExpandedMandates] = useState<Set<string>>(new Set());
// Fetch users list on mount
useEffect(() => {
const fetchUsers = async () => {
try {
setLoadingUsers(true);
const response = await api.get('/api/admin/user-access-overview/users');
setUsers(response.data);
} catch (err: any) {
setError(err?.response?.data?.detail || err?.message || 'Failed to fetch users');
} finally {
setLoadingUsers(false);
}
};
fetchUsers();
}, []);
// Fetch access overview when user is selected
useEffect(() => {
if (!selectedUserId) {
setOverview(null);
return;
}
const fetchOverview = async () => {
try {
setLoading(true);
setError(null);
const response = await api.get(`/api/admin/user-access-overview/${selectedUserId}`);
const data = response.data;
setOverview(data);
// Auto-expand all mandates
setExpandedMandates(new Set(data.mandates?.map((m: MandateInfo) => m.id) || []));
} catch (err: any) {
setError(err?.response?.data?.detail || err?.message || 'Failed to fetch overview');
setOverview(null);
} finally {
setLoading(false);
}
};
fetchOverview();
}, [selectedUserId]);
const toggleRole = (roleId: string) => {
setExpandedRoles(prev => {
const newSet = new Set(prev);
if (newSet.has(roleId)) {
newSet.delete(roleId);
} else {
newSet.add(roleId);
}
return newSet;
});
};
const toggleMandate = (mandateId: string) => {
setExpandedMandates(prev => {
const newSet = new Set(prev);
if (newSet.has(mandateId)) {
newSet.delete(mandateId);
} else {
newSet.add(mandateId);
}
return newSet;
});
};
const getScopeColor = (scope: string): string => {
switch (scope) {
case 'instance': return '#10b981';
case 'mandate': return '#3b82f6';
case 'global': return '#8b5cf6';
default: return '#6b7280';
}
};
const getAccessLevelColor = (level: string): string => {
switch (level) {
case 'ALL': return '#10b981';
case 'GROUP': return '#3b82f6';
case 'MY': return '#f59e0b';
case 'NONE': return '#ef4444';
default: return '#6b7280';
}
};
const renderOverviewTab = () => {
if (!overview) return null;
return (
<div className={styles.scrollableContent}>
{/* SysAdmin Notice */}
{overview.isSysAdmin && (
<div className={styles.infoBox} style={{ background: '#fef3c7', borderColor: '#f59e0b' }}>
<FaInfoCircle style={{ marginRight: '0.5rem', color: '#f59e0b' }} />
<span>{overview.sysAdminNote || 'Dieser Benutzer ist SysAdmin und hat vollen Systemzugriff.'}</span>
</div>
)}
{/* Mandates & Feature Instances */}
<h3 style={{ marginBottom: '1rem', color: 'var(--text-primary)' }}>Mandate & Feature-Instanzen</h3>
{overview.mandates.length === 0 ? (
<p className={styles.emptyHint}>Keine Mandate-Zuordnungen vorhanden.</p>
) : (
<div className={styles.rolesList} style={{ flex: 'none', overflow: 'visible' }}>
{overview.mandates.map(mandate => (
<div key={mandate.id} className={styles.roleCard}>
<div
className={styles.roleHeader}
onClick={() => toggleMandate(mandate.id)}
>
<div className={styles.roleInfo}>
{expandedMandates.has(mandate.id) ? <FaChevronDown className={styles.expandIcon} /> : <FaChevronRight className={styles.expandIcon} />}
<span className={styles.roleLabel}>{mandate.label || mandate.name}</span>
<span className={styles.roleDescription}>
{mandate.featureInstances.length} Feature-Instanz(en)
</span>
</div>
</div>
{expandedMandates.has(mandate.id) && (
<div className={styles.roleContent}>
{mandate.featureInstances.length === 0 ? (
<p className={styles.emptyHint}>Keine Feature-Instanzen zugewiesen.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{mandate.featureInstances.map(instance => (
<div
key={instance.id}
style={{
padding: '0.75rem',
background: 'var(--bg-secondary)',
borderRadius: '6px',
border: '1px solid var(--border-color)',
}}
>
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>
{instance.label}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>
Feature: {instance.featureLabel?.de || instance.featureCode}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
Rollen: {instance.roleIds.length > 0
? overview.roles
.filter(r => instance.roleIds.includes(r.id))
.map(r => r.roleLabel)
.join(', ')
: 'Keine'
}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
))}
</div>
)}
{/* Roles */}
<h3 style={{ marginTop: '2rem', marginBottom: '1rem', color: 'var(--text-primary)' }}>Zugewiesene Rollen</h3>
{overview.roles.length === 0 ? (
<p className={styles.emptyHint}>Keine Rollen zugewiesen.</p>
) : (
<div className={styles.rolesList} style={{ flex: 'none', overflow: 'visible' }}>
{overview.roles.map(role => (
<div key={role.id} className={styles.roleCard}>
<div
className={styles.roleHeader}
onClick={() => toggleRole(role.id)}
>
<div className={styles.roleInfo}>
{expandedRoles.has(role.id) ? <FaChevronDown className={styles.expandIcon} /> : <FaChevronRight className={styles.expandIcon} />}
<span className={styles.roleLabel}>{role.roleLabel}</span>
<span
className={styles.badge}
style={{
background: getScopeColor(role.scope),
color: 'white',
marginLeft: '0.5rem'
}}
>
{role.scope}
</span>
</div>
</div>
{expandedRoles.has(role.id) && (
<div className={styles.roleContent}>
<div style={{ fontSize: '0.875rem' }}>
<p><strong>Beschreibung:</strong> {role.description?.de || role.description?.en || '-'}</p>
<p><strong>Quelle:</strong> {role.source === 'mandate'
? `Mandate: ${role.sourceMandateName}`
: `Feature-Instanz: ${role.sourceInstanceLabel}`
}</p>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
);
};
const renderUiAccessTab = () => {
if (!overview) return null;
return (
<div className={styles.scrollableContent}>
<div className={styles.infoBox}>
<FaInfoCircle style={{ marginRight: '0.5rem', color: 'var(--primary-color)' }} />
<span>UI-Zugriffsrechte bestimmen, welche Seiten und Views der Benutzer sehen kann.</span>
</div>
{overview.uiAccess.length === 0 ? (
<div className={styles.emptyState}>
<FaEye className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine UI-Berechtigungen</h3>
<p className={styles.emptyDescription}>
Diesem Benutzer wurden keine expliziten UI-Berechtigungen zugewiesen.
</p>
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--border-color)' }}>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>UI-Element</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '80px' }}>Sichtbar</th>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>Gewährt durch</th>
</tr>
</thead>
<tbody>
{overview.uiAccess.map((entry, idx) => (
<tr
key={idx}
style={{
borderBottom: '1px solid var(--border-color)',
background: idx % 2 === 0 ? 'transparent' : 'var(--bg-secondary)'
}}
>
<td style={{ padding: '0.75rem', fontFamily: 'monospace', fontSize: '0.875rem' }}>
{entry.item}
</td>
<td style={{ padding: '0.75rem', textAlign: 'center' }}>
{entry.view ? (
<FaCheckCircle style={{ color: '#10b981' }} />
) : (
<FaTimesCircle style={{ color: '#ef4444' }} />
)}
</td>
<td style={{ padding: '0.75rem', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
{entry.grantedByRoleLabels?.join(', ') || '-'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};
const renderDataAccessTab = () => {
if (!overview) return null;
return (
<div className={styles.scrollableContent}>
<div className={styles.infoBox}>
<FaInfoCircle style={{ marginRight: '0.5rem', color: 'var(--primary-color)' }} />
<span>
Daten-Zugriffsrechte: <strong>ALL</strong> = Alle Datensätze, <strong>GROUP</strong> = Gruppen-Datensätze,
<strong> MY</strong> = Eigene Datensätze, <strong>NONE</strong> = Kein Zugriff
</span>
</div>
{overview.dataAccess.length === 0 ? (
<div className={styles.emptyState}>
<FaDatabase className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Daten-Berechtigungen</h3>
<p className={styles.emptyDescription}>
Diesem Benutzer wurden keine expliziten Daten-Berechtigungen zugewiesen.
</p>
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--border-color)' }}>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>Tabelle/Feld</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '70px' }}>Lesen</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '70px' }}>Erstellen</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '70px' }}>Update</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '70px' }}>Löschen</th>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>Gewährt durch</th>
</tr>
</thead>
<tbody>
{overview.dataAccess.map((entry, idx) => (
<tr
key={idx}
style={{
borderBottom: '1px solid var(--border-color)',
background: idx % 2 === 0 ? 'transparent' : 'var(--bg-secondary)'
}}
>
<td style={{ padding: '0.75rem', fontFamily: 'monospace', fontSize: '0.875rem' }}>
{entry.item}
</td>
<td style={{ padding: '0.75rem', textAlign: 'center' }}>
<span
className={styles.badge}
style={{
background: getAccessLevelColor(entry.read || '-'),
color: 'white',
fontSize: '0.7rem'
}}
>
{entry.read || '-'}
</span>
</td>
<td style={{ padding: '0.75rem', textAlign: 'center' }}>
<span
className={styles.badge}
style={{
background: getAccessLevelColor(entry.create || '-'),
color: 'white',
fontSize: '0.7rem'
}}
>
{entry.create || '-'}
</span>
</td>
<td style={{ padding: '0.75rem', textAlign: 'center' }}>
<span
className={styles.badge}
style={{
background: getAccessLevelColor(entry.update || '-'),
color: 'white',
fontSize: '0.7rem'
}}
>
{entry.update || '-'}
</span>
</td>
<td style={{ padding: '0.75rem', textAlign: 'center' }}>
<span
className={styles.badge}
style={{
background: getAccessLevelColor(entry.delete || '-'),
color: 'white',
fontSize: '0.7rem'
}}
>
{entry.delete || '-'}
</span>
</td>
<td style={{ padding: '0.75rem', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
{entry.grantedByRoleLabels?.join(', ') || '-'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};
const renderResourceAccessTab = () => {
if (!overview) return null;
return (
<div className={styles.scrollableContent}>
<div className={styles.infoBox}>
<FaInfoCircle style={{ marginRight: '0.5rem', color: 'var(--primary-color)' }} />
<span>Ressourcen-Zugriffsrechte bestimmen, welche System-Ressourcen (z.B. AI-Modelle) der Benutzer verwenden kann.</span>
</div>
{overview.resourceAccess.length === 0 ? (
<div className={styles.emptyState}>
<FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Ressourcen-Berechtigungen</h3>
<p className={styles.emptyDescription}>
Diesem Benutzer wurden keine expliziten Ressourcen-Berechtigungen zugewiesen.
</p>
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--border-color)' }}>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>Ressource</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '80px' }}>Zugriff</th>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>Gewährt durch</th>
</tr>
</thead>
<tbody>
{overview.resourceAccess.map((entry, idx) => (
<tr
key={idx}
style={{
borderBottom: '1px solid var(--border-color)',
background: idx % 2 === 0 ? 'transparent' : 'var(--bg-secondary)'
}}
>
<td style={{ padding: '0.75rem', fontFamily: 'monospace', fontSize: '0.875rem' }}>
{entry.item}
</td>
<td style={{ padding: '0.75rem', textAlign: 'center' }}>
{entry.view ? (
<FaCheckCircle style={{ color: '#10b981' }} />
) : (
<FaTimesCircle style={{ color: '#ef4444' }} />
)}
</td>
<td style={{ padding: '0.75rem', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
{entry.grantedByRoleLabels?.join(', ') || '-'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};
if (error && !overview) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler: {error}</p>
<button
className={styles.secondaryButton}
onClick={() => window.location.reload()}
>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Benutzer-Zugriffsübersicht</h1>
<p className={styles.pageSubtitle}>Zeigt alle Berechtigungen eines Benutzers an</p>
</div>
</div>
{/* User Selection */}
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaUserShield style={{ marginRight: '0.5rem' }} />
Benutzer auswählen:
</label>
<select
className={styles.filterSelect}
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
disabled={loadingUsers}
style={{ minWidth: '300px' }}
>
<option value="">-- Benutzer wählen --</option>
{users.map(user => (
<option key={user.id} value={user.id}>
{user.fullName || user.username} ({user.email})
{user.isSysAdmin && ' [SysAdmin]'}
</option>
))}
</select>
</div>
{selectedUserId && (
<button
className={styles.secondaryButton}
onClick={() => setSelectedUserId(selectedUserId)}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
)}
</div>
{/* Content */}
{!selectedUserId ? (
<div className={styles.emptyState}>
<FaUserShield className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Benutzer auswählen</h3>
<p className={styles.emptyDescription}>
Wählen Sie einen Benutzer aus, um dessen Zugriffsberechtigungen anzuzeigen.
</p>
</div>
) : loading ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Zugriffsübersicht...</span>
</div>
) : overview ? (
<>
{/* User Info */}
<div className={styles.infoBox} style={{ marginBottom: '1rem', flexShrink: 0 }}>
<strong>{overview.user.fullName || overview.user.username}</strong>
<span style={{ margin: '0 1rem', color: 'var(--text-secondary)' }}>|</span>
<span>{overview.user.email}</span>
{overview.isSysAdmin && (
<>
<span style={{ margin: '0 1rem', color: 'var(--text-secondary)' }}>|</span>
<span className={styles.badge} style={{ background: '#f59e0b', color: 'white' }}>
SysAdmin
</span>
</>
)}
</div>
{/* Tabs */}
<div style={{
display: 'flex',
gap: '0.5rem',
marginBottom: '1rem',
borderBottom: '1px solid var(--border-color)',
paddingBottom: '0.5rem',
flexShrink: 0
}}>
<button
className={activeTab === 'overview' ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveTab('overview')}
style={{ padding: '0.5rem 1rem' }}
>
<FaUserShield /> Übersicht
</button>
<button
className={activeTab === 'ui' ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveTab('ui')}
style={{ padding: '0.5rem 1rem' }}
>
<FaEye /> UI-Zugriff ({overview.uiAccess.length})
</button>
<button
className={activeTab === 'data' ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveTab('data')}
style={{ padding: '0.5rem 1rem' }}
>
<FaDatabase /> Daten-Zugriff ({overview.dataAccess.length})
</button>
<button
className={activeTab === 'resources' ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveTab('resources')}
style={{ padding: '0.5rem 1rem' }}
>
<FaCube /> Ressourcen ({overview.resourceAccess.length})
</button>
</div>
{/* Tab Content */}
<div className={styles.tableContainer}>
{activeTab === 'overview' && renderOverviewTab()}
{activeTab === 'ui' && renderUiAccessTab()}
{activeTab === 'data' && renderDataAccessTab()}
{activeTab === 'resources' && renderResourceAccessTab()}
</div>
</>
) : null}
</div>
);
};
export default AdminUserAccessOverviewPage;