frontend_nyla/src/pages/admin/AdminUserAccessOverviewPage.tsx
2026-04-09 00:11:35 +02:00

720 lines
28 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';
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 | null;
roleIds: string[];
featureInstances: {
id: string;
label: string;
featureCode: string;
featureLabel: { [key: string]: string };
roleIds: string[];
}[];
}
function _mandateNameLine(mandate: MandateInfo): string {
const label = mandate.label?.trim();
if (label) {
return `${mandate.name} (${label})`;
}
return mandate.name;
}
function _roleDescriptionLine(role: RoleInfo): string {
return role.description?.de || role.description?.en || '';
}
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 { 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 || '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;
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 || 'Dieser Benutzer ist SysAdmin und hat vollen Systemzugriff.'}</span>
</div>
)}
<h3 style={{ marginBottom: '1rem', color: 'var(--text-primary)' }}>{t('adminUserAccessOverview.zugriffNachMandant')}</h3>
{overview.mandates.length === 0 ? (
<p className={styles.emptyHint}>{t('adminUserAccessOverview.keineMandatezuordnungenVorhanden')}</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}>{_mandateNameLine(mandate)}</span>
<span className={styles.roleDescription}>
{mandateRoles.length} Mandantenrolle(n) · {mandate.featureInstances.length} Feature-Instanz(en)
</span>
</div>
</div>
{expandedMandates.has(mandate.id) && (
<div className={styles.roleContent}>
{mandateRoles.length === 0 ? (
<p className={styles.emptyHint}>{t('adminUserAccessOverview.keineRollenDirektAmMandanten')}</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}>Feature-Instanzen</div>
{mandate.featureInstances.length === 0 ? (
<p className={styles.emptyHint}>{t('adminUserAccessOverview.keineFeatureinstanzenZugewiesen')}</p>
) : (
<div className={styles.accessOverviewInstanceStack}>
{mandate.featureInstances.map((instance) => {
const instanceRoles = _resolveRoles(instance.roleIds);
const featureTitle = instance.featureLabel?.de || 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' }}>
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)' }}>
Globale Rollen
</h3>
<p className={styles.emptyHint} style={{ marginTop: '-0.5rem', marginBottom: '1rem' }}>
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('adminUserAccessOverview.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('adminUserAccessOverview.uizugriffsrechteBestimmenWelcheSeitenUnd')}</span>
</div>
{overview.uiAccess.length === 0 ? (
<div className={styles.emptyState}>
<FaEye className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('adminUserAccessOverview.keineUiberechtigungen')}</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)' }}>{t('adminUserAccessOverview.gewaehrtDurch')}</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}>{t('adminUserAccessOverview.keineDatenberechtigungen')}</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' }}>{t('adminUserAccessOverview.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' }}>{t('adminUserAccessOverview.loeschen')}</th>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>{t('adminUserAccessOverview.gewaehrtDurch')}</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('adminUserAccessOverview.ressourcenzugriffsrechteBestimmenWelcheSystemressourcenZb')}</span>
</div>
{overview.resourceAccess.length === 0 ? (
<div className={styles.emptyState}>
<FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('adminUserAccessOverview.keineRessourcenberechtigungen')}</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)' }}>{t('adminUserAccessOverview.gewaehrtDurch')}</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}>Fehler: {error}</p>
<button
className={styles.secondaryButton}
onClick={() => window.location.reload()}
>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>{t('adminUserAccessOverview.benutzerzugriffsuebersicht')}</h1>
<p className={styles.pageSubtitle}>{t('adminUserAccessOverview.zeigtAlleBerechtigungenEinesBenutzers')}</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="">{t('adminUserAccessOverview.benutzerWaehlen')}</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}>{t('adminUserAccessOverview.benutzerAuswaehlen')}</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>{t('adminUserAccessOverview.ladeZugriffsuebersicht')}</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;