584 lines
22 KiB
TypeScript
584 lines
22 KiB
TypeScript
/**
|
||
* 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';
|
||
|
||
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): string {
|
||
if (typeof feature.label === 'object') {
|
||
return feature.label.de || feature.label.en || feature.code;
|
||
}
|
||
return 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) : 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]);
|
||
|
||
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)}
|
||
</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 })}</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;
|