frontend_nyla/src/pages/admin/AccessManagementHub.tsx
2026-04-10 22:44:14 +02:00

582 lines
22 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.

/**
* AccessManagementHub
*
* Central admin page for feature instance access management.
* Shows mandate/feature context, overview stats, instance cards, and relationship diagram.
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Link } from 'react-router-dom';
import {
useFeatureAccess,
type FeatureInstance,
type Feature,
type FeatureAccessUser,
} from '../../hooks/useFeatureAccess';
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
import { FaBuilding, FaCube, FaUsers, FaCogs, FaSync, FaChartBar, FaLink, FaList, FaSitemap } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import api from '../../api';
import styles from './Admin.module.css';
import hubStyles from './AccessManagementHub.module.css';
import { InstanceDetailModal } from './InstanceDetailModal';
import { FeatureInstanceWizard } from './wizards/FeatureInstanceWizard';
import { InstanceHierarchyView } from './InstanceHierarchyView';
import { useLanguage } from '../../providers/language/LanguageContext';
import { labelAsI18nKey } from '../../types/mandate';
function getMandateName(mandate: Mandate): string {
if (mandate.label) return mandate.label;
if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
}
return mandate.name || mandate.id;
}
function getFeatureLabel(feature: Feature, t: (k: string) => string): string {
return t(labelAsI18nKey(feature.label, feature.code));
}
export interface InstanceWithStats extends FeatureInstance {
userCount?: number;
roleCount?: number;
}
export const AccessManagementHub: React.FC = () => {
const { t } = useLanguage();
const {
features,
instances,
loading,
error,
fetchFeatures,
fetchInstances,
syncInstanceRoles,
} = useFeatureAccess();
const { fetchMandates } = useUserMandates();
const { showSuccess, showError } = useToast();
const [mandates, setMandates] = useState<Mandate[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
const [selectedFeatureCode, setSelectedFeatureCode] = useState<string>('');
const [instancesWithStats, setInstancesWithStats] = useState<InstanceWithStats[]>([]);
const [statsLoading, setStatsLoading] = useState(false);
const [detailInstance, setDetailInstance] = useState<{
instance: FeatureInstance;
mandateId: string;
mandateName: string;
featureLabel: string;
} | null>(null);
const [showWizard, setShowWizard] = useState(false);
type ViewMode = 'list' | 'hierarchy';
const [viewMode, setViewMode] = useState<ViewMode>('hierarchy');
const [instanceUsersMap, setInstanceUsersMap] = useState<Record<string, FeatureAccessUser[]>>({});
const [hierarchyUsersLoading, setHierarchyUsersLoading] = useState(false);
const [instancesByMandate, setInstancesByMandate] = useState<Record<string, InstanceWithStats[]>>({});
useEffect(() => {
fetchFeatures();
fetchMandates().then(data => {
setMandates(data);
if (data.length > 0 && !selectedMandateId) {
setSelectedMandateId(data[0].id);
}
});
}, [fetchFeatures, fetchMandates]);
useEffect(() => {
if (selectedMandateId) {
fetchInstances(selectedMandateId, selectedFeatureCode || undefined);
} else {
setInstancesWithStats([]);
}
}, [selectedMandateId, selectedFeatureCode, fetchInstances]);
const loadInstanceStats = useCallback(async (mandateId: string, instanceList: FeatureInstance[]) => {
if (instanceList.length === 0) {
setInstancesWithStats([]);
return;
}
setStatsLoading(true);
try {
const results = await Promise.all(
instanceList.map(async (inst) => {
try {
const [usersRes, rolesRes] = await Promise.all([
api.get(`/api/features/instances/${inst.id}/users`, {
headers: { 'X-Mandate-Id': mandateId },
}),
api.get(`/api/features/instances/${inst.id}/available-roles`, {
headers: { 'X-Mandate-Id': mandateId },
}),
]);
const users = Array.isArray(usersRes.data) ? usersRes.data : usersRes.data?.items || [];
const roles = Array.isArray(rolesRes.data) ? rolesRes.data : [];
return {
...inst,
userCount: users.length,
roleCount: roles.length,
};
} catch {
return { ...inst, userCount: 0, roleCount: 0 };
}
})
);
setInstancesWithStats(results);
} catch {
setInstancesWithStats(instanceList.map((i) => ({ ...i, userCount: 0, roleCount: 0 })));
} finally {
setStatsLoading(false);
}
}, []);
useEffect(() => {
if (selectedMandateId && instances.length > 0 && !loading) {
loadInstanceStats(selectedMandateId, instances);
} else if (instances.length === 0) {
setInstancesWithStats([]);
}
}, [selectedMandateId, instances, loading, loadInstanceStats]);
const handleSyncRoles = async (instance: FeatureInstance) => {
if (!selectedMandateId) return;
try {
const result = await syncInstanceRoles(selectedMandateId, instance.id, true);
if (result.success && result.data) {
showSuccess(
'Rollen synchronisiert',
`Hinzugefügt: ${result.data.added}, Entfernt: ${result.data.removed}, Unverändert: ${result.data.unchanged}`
);
fetchInstances(selectedMandateId, selectedFeatureCode || undefined);
} else {
showError('Synchronisierung fehlgeschlagen', result.error || 'Fehler beim Synchronisieren');
}
} catch {
showError('Fehler', 'Rollen konnten nicht synchronisiert werden');
}
};
const handleOpenDetail = (instance: FeatureInstance, mandateIdOverride?: string) => {
const mandateId = mandateIdOverride ?? selectedMandateId;
const mandate = mandates.find((m) => m.id === mandateId);
const feature = features.find((f) => f.code === instance.featureCode);
setDetailInstance({
instance,
mandateId: mandateId || '',
mandateName: mandate ? getMandateName(mandate) : mandateId || '',
featureLabel: feature ? getFeatureLabel(feature, t) : instance.featureCode,
});
};
const handleCloseDetail = () => {
setDetailInstance(null);
};
const handleDetailSaved = () => {
if (selectedMandateId) {
fetchInstances(selectedMandateId, selectedFeatureCode || undefined);
}
setDetailInstance(null);
};
const handleWizardComplete = () => {
setShowWizard(false);
if (selectedMandateId) {
fetchInstances(selectedMandateId, selectedFeatureCode || undefined);
}
};
const filteredInstances = useMemo(() => {
if (!selectedFeatureCode) return instancesWithStats;
return instancesWithStats.filter((i) => i.featureCode === selectedFeatureCode);
}, [instancesWithStats, selectedFeatureCode]);
const loadAllHierarchyData = useCallback(async () => {
if (mandates.length === 0) {
setInstancesByMandate({});
setInstanceUsersMap({});
return;
}
setHierarchyUsersLoading(true);
try {
const mandateIds = mandates.map((m) => m.id);
const instancesResults = await Promise.all(
mandateIds.map(async (mandateId) => {
try {
const res = await api.get('/api/features/instances', {
headers: { 'X-Mandate-Id': mandateId },
});
const list = res.data?.items ?? (Array.isArray(res.data) ? res.data : []);
return { mandateId, instances: list as FeatureInstance[] };
} catch {
return { mandateId, instances: [] as FeatureInstance[] };
}
})
);
const byMandate: Record<string, InstanceWithStats[]> = {};
const allInstanceIds: { instanceId: string; mandateId: string }[] = [];
for (const { mandateId, instances } of instancesResults) {
const withStats = await Promise.all(
instances.map(async (inst) => {
try {
const [usersRes, rolesRes] = await Promise.all([
api.get(`/api/features/instances/${inst.id}/users`, {
headers: { 'X-Mandate-Id': mandateId },
}),
api.get(`/api/features/instances/${inst.id}/available-roles`, {
headers: { 'X-Mandate-Id': mandateId },
}),
]);
const users = Array.isArray(usersRes.data) ? usersRes.data : usersRes.data?.items ?? [];
const roles = Array.isArray(rolesRes.data) ? rolesRes.data : [];
allInstanceIds.push({ instanceId: inst.id, mandateId });
return {
...inst,
userCount: users.length,
roleCount: roles.length,
} as InstanceWithStats;
} catch {
allInstanceIds.push({ instanceId: inst.id, mandateId });
return { ...inst, userCount: 0, roleCount: 0 } as InstanceWithStats;
}
})
);
byMandate[mandateId] = withStats;
}
setInstancesByMandate(byMandate);
const usersMap: Record<string, FeatureAccessUser[]> = {};
await Promise.all(
allInstanceIds.map(async ({ instanceId, mandateId }) => {
try {
const res = await api.get(`/api/features/instances/${instanceId}/users`, {
headers: { 'X-Mandate-Id': mandateId },
});
const users = Array.isArray(res.data) ? res.data : res.data?.items ?? [];
usersMap[instanceId] = users as FeatureAccessUser[];
} catch {
usersMap[instanceId] = [];
}
})
);
setInstanceUsersMap(usersMap);
} finally {
setHierarchyUsersLoading(false);
}
}, [mandates]);
useEffect(() => {
if (viewMode === 'hierarchy' && mandates.length > 0) {
loadAllHierarchyData();
} else if (viewMode !== 'hierarchy') {
setInstancesByMandate({});
setInstanceUsersMap({});
}
}, [viewMode, mandates, loadAllHierarchyData]);
const overviewStats = useMemo(() => {
const totalUsers = filteredInstances.reduce((sum, i) => sum + (i.userCount ?? 0), 0);
const maxRoles = Math.max(0, ...filteredInstances.map((i) => i.roleCount ?? 0));
return {
instances: filteredInstances.length,
users: totalUsers,
roles: maxRoles,
};
}, [filteredInstances]);
const relationshipData = useMemo(() => {
if (!selectedMandateId || filteredInstances.length === 0) return null;
const mandate = mandates.find((m) => m.id === selectedMandateId);
return {
mandateName: mandate ? getMandateName(mandate) : selectedMandateId,
instances: filteredInstances.map((inst) => {
const feature = features.find((f) => f.code === inst.featureCode);
return {
id: inst.id,
label: inst.label,
featureLabel: feature ? getFeatureLabel(feature) : inst.featureCode,
userCount: inst.userCount ?? 0,
};
}),
};
}, [selectedMandateId, mandates, filteredInstances, features, t]);
if (error && !selectedMandateId) {
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={() => fetchFeatures()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Zugriffsverwaltung</h1>
<p className={styles.pageSubtitle}>
Feature-Instanzen, Benutzer und Rollen an einem Ort verwalten
</p>
</div>
</div>
<div className={hubStyles.filters}>
{/* Filter dropdowns only shown in list view - hierarchy shows everything */}
{viewMode === 'list' && (
<>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaBuilding style={{ marginRight: 8 }} />
Mandant:
</label>
<select
className={styles.filterSelect}
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
<option value="">{t('accessManagementHub.mandantWaehlen')}</option>
{mandates.map((m) => (
<option key={m.id} value={m.id}>
{getMandateName(m)}
</option>
))}
</select>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaCube style={{ marginRight: 8 }} />
Feature:
</label>
<select
className={styles.filterSelect}
value={selectedFeatureCode}
onChange={(e) => setSelectedFeatureCode(e.target.value)}
>
<option value="">{t('accessManagementHub.alle')}</option>
{features.map((f) => (
<option key={f.code} value={f.code}>
{getFeatureLabel(f, t)}
</option>
))}
</select>
</div>
{selectedMandateId && (
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() =>
fetchInstances(selectedMandateId, selectedFeatureCode || undefined)
}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
<button
className={styles.primaryButton}
onClick={() => setShowWizard(true)}
disabled={features.length === 0}
>
+ Neue Instanz erstellen
</button>
</div>
)}
</>
)}
</div>
<div className={hubStyles.viewModeSwitch}>
<div className={hubStyles.viewModeButtons}>
<button
type="button"
className={viewMode === 'list' ? hubStyles.viewModeActive : hubStyles.viewModeButton}
onClick={() => setViewMode('list')}
>
<FaList /> Listenansicht
</button>
<button
type="button"
className={viewMode === 'hierarchy' ? hubStyles.viewModeActive : hubStyles.viewModeButton}
onClick={() => setViewMode('hierarchy')}
>
<FaSitemap /> Hierarchie
</button>
</div>
<Link to="/admin/mandates" className={hubStyles.mandatesLink}>
<FaBuilding /> Mandanten verwalten
</Link>
<Link to="/admin/user-mandates" className={hubStyles.mandatesLink}>
<FaUsers /> Mandant-Benutzer
</Link>
</div>
{viewMode === 'hierarchy' ? (
<InstanceHierarchyView
mandates={mandates}
getMandateName={getMandateName}
instancesByMandate={instancesByMandate}
instanceUsersMap={instanceUsersMap}
features={features}
getFeatureLabel={getFeatureLabel}
loading={hierarchyUsersLoading}
onOpenDetail={handleOpenDetail}
/>
) : !selectedMandateId ? (
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('accessManagementHub.keinMandantAusgewaehlt')}</h3>
<p className={styles.emptyDescription}>
Wählen Sie einen Mandanten, um dessen Feature-Instanzen und Zugriffe zu verwalten.
</p>
</div>
) : (
<>
<div className={hubStyles.overviewRow}>
<div className={hubStyles.statsCard}>
<FaChartBar className={hubStyles.statsIcon} />
<div className={hubStyles.statsContent}>
<span className={hubStyles.statsValue}>
{loading || statsLoading ? '…' : overviewStats.instances}
</span>
<span className={hubStyles.statsLabel}>Instanzen</span>
</div>
</div>
<div className={hubStyles.statsCard}>
<FaUsers className={hubStyles.statsIcon} />
<div className={hubStyles.statsContent}>
<span className={hubStyles.statsValue}>
{loading || statsLoading ? '…' : overviewStats.users}
</span>
<span className={hubStyles.statsLabel}>{t('accessManagementHub.benutzer')}</span>
</div>
</div>
<div className={hubStyles.statsCard}>
<FaCogs className={hubStyles.statsIcon} />
<div className={hubStyles.statsContent}>
<span className={hubStyles.statsValue}>
{loading || statsLoading ? '…' : overviewStats.roles}
</span>
<span className={hubStyles.statsLabel}>{t('accessManagementHub.rollenMax')}</span>
</div>
</div>
{relationshipData && relationshipData.instances.length > 0 && (
<div className={hubStyles.diagramCard}>
<FaLink className={hubStyles.statsIcon} />
<div className={hubStyles.diagramContent}>
<span className={hubStyles.diagramTitle}>Beziehungen</span>
<div className={hubStyles.diagramFlow}>
<div className={hubStyles.diagramNode}>{relationshipData.mandateName}</div>
<div className={hubStyles.diagramNodes}>
{relationshipData.instances.slice(0, 5).map((inst) => (
<div key={inst.id} className={hubStyles.diagramNodeSmall}>
{inst.label} ({inst.userCount})
</div>
))}
{relationshipData.instances.length > 5 && (
<div className={hubStyles.diagramNodeSmall}>
+{relationshipData.instances.length - 5} weitere
</div>
)}
</div>
</div>
</div>
</div>
)}
</div>
<section className={hubStyles.section}>
<h2 className={hubStyles.sectionTitle}>Feature-Instanzen</h2>
{loading && filteredInstances.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('accessManagementHub.ladeInstanzen')}</span>
</div>
) : filteredInstances.length === 0 ? (
<div className={styles.emptyState}>
<FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('accessManagementHub.keineFeatureinstanzen')}</h3>
<p className={styles.emptyDescription}>
Erstellen Sie eine neue Instanz oder wählen Sie ein anderes Feature.
</p>
<button
className={styles.primaryButton}
onClick={() => setShowWizard(true)}
disabled={features.length === 0}
>
+ Erste Instanz erstellen
</button>
</div>
) : (
<div className={hubStyles.instanceGrid}>
{filteredInstances.map((inst) => (
<div key={inst.id} className={hubStyles.instanceCard}>
<div className={hubStyles.instanceCardHeader}>
<span className={hubStyles.instanceLabel}>{inst.label}</span>
<span
className={`${hubStyles.instanceBadge} ${inst.enabled ? hubStyles.badgeActive : hubStyles.badgeInactive}`}
>
{inst.enabled ? t('accessManagementHub.aktiv') : t('accessManagementHub.inaktiv')}
</span>
</div>
<div className={hubStyles.instanceMeta}>
<span>{getFeatureLabel(features.find((f) => f.code === inst.featureCode) || { code: inst.featureCode, label: inst.featureCode }, t)}</span>
<span>{inst.userCount ?? '—'} Benutzer</span>
<span>{inst.roleCount ?? '—'} Rollen</span>
</div>
<div className={hubStyles.instanceActions}>
<button
type="button"
className={hubStyles.cardAction}
onClick={() => handleOpenDetail(inst, selectedMandateId)}
>
<FaUsers /> Benutzer verwalten
</button>
<button
type="button"
className={hubStyles.cardAction}
onClick={() => handleSyncRoles(inst)}
disabled={!inst.enabled}
title={t('accessManagementHub.rollenSynchronisieren')}
>
<FaCogs /> Rollen sync
</button>
</div>
</div>
))}
</div>
)}
</section>
</>
)}
{detailInstance && (
<InstanceDetailModal
instance={detailInstance.instance}
mandateId={detailInstance.mandateId}
mandateName={detailInstance.mandateName}
featureLabel={detailInstance.featureLabel}
onClose={handleCloseDetail}
onSaved={handleDetailSaved}
/>
)}
{showWizard && (
<FeatureInstanceWizard
mandateId={selectedMandateId}
mandates={mandates}
features={features}
onClose={() => setShowWizard(false)}
onComplete={handleWizardComplete}
/>
)}
</div>
);
};
export default AccessManagementHub;