ui-nyla/src/pages/admin/AdminUserAccessOverviewPage.tsx

735 lines
29 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';
import { useLanguage } from '../../providers/language/LanguageContext';
import { mandateDisplayLineLabelThenSlug } from '../../utils/mandateDisplayUtils';
interface UserOption {
id: string;
username: string;
email: string;
fullName: string;
isSysAdmin: boolean;
isPlatformAdmin: boolean;
enabled: boolean;
}
interface RoleInfo {
id: string;
roleLabel: string;
description: 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 | null;
roleIds: string[];
featureInstances: {
id: string;
label: string;
featureCode: string;
featureLabel: string;
roleIds: string[];
}[];
}
function _roleDescriptionLine(role: RoleInfo): string {
return role.description?.trim() || '';
}
interface UserAccessOverview {
user: UserOption;
isSysAdmin: boolean;
isPlatformAdmin: 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 { t } = useLanguage();
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 || t('Benutzer konnten nicht geladen werden'));
} finally {
setLoadingUsers(false);
}
};
fetchUsers();
}, [t]);
// 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 || t('Zugriffsübersicht konnte nicht geladen werden'));
setOverview(null);
} finally {
setLoading(false);
}
};
fetchOverview();
}, [selectedUserId, t]);
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;
const roleById = new Map(overview.roles.map((r) => [r.id, r]));
const globalRoles = overview.roles.filter((r) => r.scope === 'global');
const _resolveRoles = (roleIds: string[]): RoleInfo[] =>
roleIds.map((id) => roleById.get(id)).filter((r): r is RoleInfo => !!r);
return (
<div className={styles.scrollableContent}>
{overview.isSysAdmin && (
<div className={styles.infoBox} style={{ background: '#fef3c7', borderColor: '#f59e0b' }}>
<FaInfoCircle style={{ marginRight: '0.5rem', color: '#f59e0b' }} />
<span>{overview.sysAdminNote || t('Dieser Benutzer ist Systemadmin (Infrastruktur-Operator) und hat vollen Datenzugriff (RBAC-Bypass).')}</span>
</div>
)}
{overview.isPlatformAdmin && (
<div className={styles.infoBox} style={{ background: '#dbeafe', borderColor: '#3b82f6' }}>
<FaInfoCircle style={{ marginRight: '0.5rem', color: '#3b82f6' }} />
<span>{t('Dieser Benutzer ist Plattformadmin und kann mandantsübergreifend User, Mandate und RBAC-Regeln verwalten (kein RBAC-Bypass).')}</span>
</div>
)}
<h3 style={{ marginBottom: '1rem', color: 'var(--text-primary)' }}>{t('Zugriff nach Mandant')}</h3>
{overview.mandates.length === 0 ? (
<p className={styles.emptyHint}>{t('Keine Mandatszuordnungen vorhanden')}</p>
) : (
<div className={styles.rolesList} style={{ flex: 'none', overflow: 'visible' }}>
{overview.mandates.map((mandate) => {
const mandateRoles = _resolveRoles(mandate.roleIds);
return (
<div key={mandate.id} className={styles.roleCard}>
<div className={styles.roleHeader} onClick={() => toggleMandate(mandate.id)}>
<div className={styles.roleInfo} style={{ flexWrap: 'wrap', rowGap: '0.35rem' }}>
{expandedMandates.has(mandate.id) ? (
<FaChevronDown className={styles.expandIcon} />
) : (
<FaChevronRight className={styles.expandIcon} />
)}
<span className={styles.roleLabel}>{mandateDisplayLineLabelThenSlug(mandate)}</span>
<span className={styles.roleDescription}>
{t('{r} Mandantenrolle(n) · {i} Feature-Instanz(en)', {
r: mandateRoles.length,
i: mandate.featureInstances.length,
})}
</span>
</div>
</div>
{expandedMandates.has(mandate.id) && (
<div className={styles.roleContent}>
{mandateRoles.length === 0 ? (
<p className={styles.emptyHint}>{t('Keine Rollen direkt am Mandanten')}</p>
) : (
<ul className={styles.accessOverviewRoleBullets}>
{mandateRoles.map((r) => (
<li key={r.id}>
<strong>{r.roleLabel}</strong>
<span
className={styles.badge}
style={{
background: getScopeColor(r.scope),
color: 'white',
marginLeft: '0.35rem',
fontSize: '0.65rem',
verticalAlign: 'middle',
}}
>
{r.scope}
</span>
</li>
))}
</ul>
)}
<div className={styles.accessOverviewSubheading}>{t('Feature-Instanzen')}</div>
{mandate.featureInstances.length === 0 ? (
<p className={styles.emptyHint}>{t('Keine Feature-Instanzen zugewiesen')}</p>
) : (
<div className={styles.accessOverviewInstanceStack}>
{mandate.featureInstances.map((instance) => {
const instanceRoles = _resolveRoles(instance.roleIds);
const featureTitle = instance.featureLabel || instance.featureCode;
return (
<div key={instance.id} className={styles.accessOverviewInstanceBlock}>
<div className={styles.accessOverviewInstanceTitle}>
{instance.label}{' '}
<span className={styles.accessOverviewInstanceFeature}>({featureTitle})</span>
</div>
{instanceRoles.length === 0 ? (
<p className={styles.emptyHint} style={{ margin: '0.35rem 0 0' }}>
{t('Keine Rollen.')}
</p>
) : (
<ul className={styles.accessOverviewRoleBullets}>
{instanceRoles.map((r) => (
<li key={r.id}>
<strong>{r.roleLabel}</strong>
<span
className={styles.badge}
style={{
background: getScopeColor(r.scope),
color: 'white',
marginLeft: '0.35rem',
fontSize: '0.65rem',
verticalAlign: 'middle',
}}
>
{r.scope}
</span>
</li>
))}
</ul>
)}
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
{globalRoles.length > 0 && (
<>
<h3 style={{ marginTop: '2rem', marginBottom: '1rem', color: 'var(--text-primary)' }}>
{t('Globale Rollen')}
</h3>
<p className={styles.emptyHint} style={{ marginTop: '-0.5rem', marginBottom: '1rem' }}>
{t('Nicht an einen Mandanten gebunden.')}
</p>
<div className={styles.rolesList} style={{ flex: 'none', overflow: 'visible' }}>
{globalRoles.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>{t('Beschreibung')}</strong> {_roleDescriptionLine(role) || '—'}
</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>{t('UI-Zugriffsrechte bestimmen, welche Seiten und')}</span>
</div>
{overview.uiAccess.length === 0 ? (
<div className={styles.emptyState}>
<FaEye className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Keine UI-Berechtigungen')}</h3>
<p className={styles.emptyDescription}>
{t('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)' }}>{t('UI-Element')}</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '80px' }}>{t('Sichtbar')}</th>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>{t('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>
{t('Daten-Zugriffsrechte:')} <strong>ALL</strong> {t('= Alle Datensätze,')} <strong>GROUP</strong>{' '}
{t('= Gruppen-Datensätze,')} <strong>MY</strong> {t('= Eigene Datensätze,')} <strong>NONE</strong> {t('= Kein Zugriff')}
</span>
</div>
{overview.dataAccess.length === 0 ? (
<div className={styles.emptyState}>
<FaDatabase className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Keine Datenberechtigungen')}</h3>
<p className={styles.emptyDescription}>
{t('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)' }}>{t('Tabelle/Feld')}</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '70px' }}>{t('Lesen')}</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '70px' }}>{t('Erstellen')}</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '70px' }}>{t('Update')}</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '70px' }}>{t('Löschen')}</th>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>{t('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>{t('Ressourcenzugriffsrechte bestimmen, welche Systemressourcen z.B.')}</span>
</div>
{overview.resourceAccess.length === 0 ? (
<div className={styles.emptyState}>
<FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Keine Ressourcenberechtigungen')}</h3>
<p className={styles.emptyDescription}>
{t('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)' }}>{t('Ressource')}</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '80px' }}>{t('Zugriff')}</th>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>{t('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} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler')}: {error}
</p>
<button
className={styles.secondaryButton}
onClick={() => window.location.reload()}
>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>{t('Benutzerzugriffsübersicht')}</h1>
<p className={styles.pageSubtitle}>{t('Zeigt alle Berechtigungen eines Benutzers')}</p>
</div>
</div>
{/* User Selection */}
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaUserShield style={{ marginRight: '0.5rem' }} />
{t('Benutzer auswählen')}:
</label>
<select
className={styles.filterSelect}
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
disabled={loadingUsers}
style={{ minWidth: '300px' }}
>
<option value="">{t('Benutzer wählen')}</option>
{users.map(user => (
<option key={user.id} value={user.id}>
{user.fullName || user.username} ({user.email})
{user.isSysAdmin && ` [${t('SysAdmin')}]`}
{user.isPlatformAdmin && ` [${t('PlatformAdmin')}]`}
</option>
))}
</select>
</div>
{selectedUserId && (
<button
className={styles.secondaryButton}
onClick={() => setSelectedUserId(selectedUserId)}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
)}
</div>
{/* Content */}
{!selectedUserId ? (
<div className={styles.emptyState}>
<FaUserShield className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Benutzer auswählen')}</h3>
<p className={styles.emptyDescription}>
{t('Wählen Sie einen Benutzer aus, um dessen Zugriffsberechtigungen anzuzeigen.')}
</p>
</div>
) : loading ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('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' }}>
{t('SysAdmin')}
</span>
</>
)}
{overview.isPlatformAdmin && (
<>
<span style={{ margin: '0 1rem', color: 'var(--text-secondary)' }}>|</span>
<span className={styles.badge} style={{ background: '#3b82f6', color: 'white' }}>
{t('PlatformAdmin')}
</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 /> {t('Übersicht')}
</button>
<button
className={activeTab === 'ui' ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveTab('ui')}
style={{ padding: '0.5rem 1rem' }}
>
<FaEye /> {t('UI-Zugriff')} ({overview.uiAccess.length})
</button>
<button
className={activeTab === 'data' ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveTab('data')}
style={{ padding: '0.5rem 1rem' }}
>
<FaDatabase /> {t('Daten-Zugriff')} ({overview.dataAccess.length})
</button>
<button
className={activeTab === 'resources' ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveTab('resources')}
style={{ padding: '0.5rem 1rem' }}
>
<FaCube /> {t('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;