ui-nyla/src/pages/admin/AccessManagementHub.tsx
ValueOn AG 0ad9006b94
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m26s
panel fixes 3
2026-06-11 22:55:09 +02:00

607 lines
23 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.

// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* 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 { StackLayout } from '../../components/Layout/StackLayout';
import { Panel } from '../../components/Layout/Panel';
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 { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
function getMandateName(mandate: Mandate): string {
return mandateDisplayLabel(mandate);
}
function getFeatureLabel(feature: Feature): string {
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(
t('Rollen synchronisiert'),
t('Hinzugefügt: {added}, Entfernt: {removed}, Unverändert: {unchanged}', {
added: result.data.added,
removed: result.data.removed,
unchanged: result.data.unchanged,
})
);
fetchInstances(selectedMandateId, selectedFeatureCode || undefined);
} else {
showError(t('Synchronisierung fehlgeschlagen'), result.error || t('Fehler beim Synchronisieren'));
}
} catch {
showError(t('Fehler'), t('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, t]);
if (error && !selectedMandateId) {
return (
<StackLayout variant="scroll">
<StackLayout.Body>
<Panel variant="card" title={t('Fehler')} id="access-hub-error">
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler')}: {error}
</p>
<button className={styles.secondaryButton} onClick={() => fetchFeatures()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</Panel>
</StackLayout.Body>
</StackLayout>
);
}
return (
<>
<StackLayout variant="scroll">
<StackLayout.Header>
<div>
<h1 className={styles.pageTitle}>{t('Zugriffsverwaltung')}</h1>
<p className={styles.pageSubtitle}>
{t('Feature-Instanzen, Benutzer und Rollen an einem Ort verwalten')}
</p>
</div>
</StackLayout.Header>
<StackLayout.Body>
<Panel variant="toolbar" title={t('Filter')} id="access-hub-toolbar">
<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 }} />
{t('Mandant')}:
</label>
<select
className={styles.filterSelect}
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
<option value="">{t('Mandant wählen')}</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 }} />
{t('Feature')}:
</label>
<select
className={styles.filterSelect}
value={selectedFeatureCode}
onChange={(e) => setSelectedFeatureCode(e.target.value)}
>
<option value="">{t('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' : ''} /> {t('Aktualisieren')}
</button>
<button
className={styles.primaryButton}
onClick={() => setShowWizard(true)}
disabled={features.length === 0}
>
+ {t('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 /> {t('Listenansicht')}
</button>
<button
type="button"
className={viewMode === 'hierarchy' ? hubStyles.viewModeActive : hubStyles.viewModeButton}
onClick={() => setViewMode('hierarchy')}
>
<FaSitemap /> {t('Hierarchie')}
</button>
</div>
<Link to="/admin/mandates" className={hubStyles.mandatesLink}>
<FaBuilding /> {t('Mandanten verwalten')}
</Link>
<Link to="/admin/user-mandates" className={hubStyles.mandatesLink}>
<FaUsers /> {t('Mandant-Benutzer')}
</Link>
</div>
</Panel>
{viewMode === 'hierarchy' ? (
<InstanceHierarchyView
mandates={mandates}
getMandateName={getMandateName}
instancesByMandate={instancesByMandate}
instanceUsersMap={instanceUsersMap}
features={features}
getFeatureLabel={getFeatureLabel}
loading={hierarchyUsersLoading}
onOpenDetail={handleOpenDetail}
/>
) : !selectedMandateId ? (
<Panel variant="card" title={t('Kein Mandant ausgewählt')} id="access-hub-no-mandate">
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
<p className={styles.emptyDescription}>
{t('Wählen Sie einen Mandanten, um dessen Feature-Instanzen und Zugriffe zu verwalten.')}
</p>
</div>
</Panel>
) : (
<>
<Panel variant="dashboard" title={t('Übersicht')} id="access-hub-overview">
<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}>{t('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('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('Rollen (max)')}</span>
</div>
</div>
{relationshipData && relationshipData.instances.length > 0 && (
<div className={hubStyles.diagramCard}>
<FaLink className={hubStyles.statsIcon} />
<div className={hubStyles.diagramContent}>
<span className={hubStyles.diagramTitle}>{t('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} {t('weitere')}
</div>
)}
</div>
</div>
</div>
</div>
)}
</div>
</Panel>
<Panel variant="dashboard" title={t('Feature-Instanzen')} id="access-hub-instances">
<section className={hubStyles.section}>
<h2 className={hubStyles.sectionTitle}>{t('Feature-Instanzen')}</h2>
{loading && filteredInstances.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Lade Instanzen')}</span>
</div>
) : filteredInstances.length === 0 ? (
<div className={styles.emptyState}>
<FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Keine Feature-Instanzen')}</h3>
<p className={styles.emptyDescription}>
{t('Erstellen Sie eine neue Instanz oder wählen Sie ein anderes Feature.')}
</p>
<button
className={styles.primaryButton}
onClick={() => setShowWizard(true)}
disabled={features.length === 0}
>
+ {t('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('Aktiv') : t('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 ?? '—'} {t('Benutzer')}
</span>
<span>
{inst.roleCount ?? '—'} {t('Rollen')}
</span>
</div>
<div className={hubStyles.instanceActions}>
<button
type="button"
className={hubStyles.cardAction}
onClick={() => handleOpenDetail(inst, selectedMandateId)}
>
<FaUsers /> {t('Benutzer verwalten')}
</button>
<button
type="button"
className={hubStyles.cardAction}
onClick={() => handleSyncRoles(inst)}
disabled={!inst.enabled}
title={t('Rollen synchronisieren')}
>
<FaCogs /> {t('Rollen sync')}
</button>
</div>
</div>
))}
</div>
)}
</section>
</Panel>
</>
)}
</StackLayout.Body>
</StackLayout>
{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}
/>
)}
</>
);
};
export default AccessManagementHub;