frontend_nyla/src/pages/admin/AdminUserAccessOverviewPage.tsx

716 lines
27 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 | 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 [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)' }}>Zugriff nach Mandant</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) => {
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}>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}>Feature-Instanzen</div>
{mandate.featureInstances.length === 0 ? (
<p className={styles.emptyHint}>Keine Feature-Instanzen zugewiesen.</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>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>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} ${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}>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;