feat(16-05): wire full sync report + visible sync-request error (D-06, D-21)

- SyncResult interface grows additively: groupMembershipsAdded/Removed
  (D-21 backend gap, existed since Phase 15 but never wired into the
  frontend) plus groupsAdopted/Renamed/Deleted and defaultMarkerMoved
  (Plan 16-03).
- New syncRequestError state: a failed sync request (network error or
  !res.ok) now renders a visible text-sm text-destructive line instead
  of silently reporting a three-zero result as a successful no-op.
- Sync report container gains three new lines: group-membership counts,
  AD-group counts (always visible, even at zero), and a conditional
  amber "default marker reassigned" line shown only when
  defaultMarkerMoved > 0.
- Four new i18n keys under admin.ldap.sync in de.json/en.json.
This commit is contained in:
2026-08-06 16:37:07 +02:00
parent 184a3a1174
commit f66546df7e
3 changed files with 52 additions and 3 deletions
+42 -1
View File
@@ -63,6 +63,15 @@ interface SyncResult {
created: number;
updated: number;
deactivated: number;
// D-21: group-membership reconciliation counters — existed on the backend
// since Plan 15/D-21 but were never wired into this interface until now.
groupMembershipsAdded: number;
groupMembershipsRemoved: number;
// Plan 16-03/16-05: group reconciliation counters (SC-3/SC-4/D-05/D-06).
groupsAdopted: number;
groupsRenamed: number;
groupsDeleted: number;
defaultMarkerMoved: number;
errors: string[];
}
@@ -84,6 +93,7 @@ export default function AdminLdapPage() {
} | null>(null);
const [syncResult, setSyncResult] = useState<SyncResult | null>(null);
const [syncing, setSyncing] = useState(false);
const [syncRequestError, setSyncRequestError] = useState<string | null>(null);
// Form state for connection settings.
const [formData, setFormData] = useState({
@@ -265,6 +275,7 @@ export default function AdminLdapPage() {
const handleSync = async () => {
setSyncing(true);
setSyncResult(null);
setSyncRequestError(null);
try {
const res = await fetch(`${API_URL}/ldap/sync`, {
method: 'POST',
@@ -274,9 +285,13 @@ export default function AdminLdapPage() {
const data = await res.json();
setSyncResult(data);
await fetchConfig();
} else {
setSyncResult(null);
setSyncRequestError(t('sync.requestError'));
}
} catch {
setSyncResult({ created: 0, updated: 0, deactivated: 0, errors: ['Network error'] });
setSyncResult(null);
setSyncRequestError(t('sync.requestError'));
} finally {
setSyncing(false);
}
@@ -1287,6 +1302,12 @@ export default function AdminLdapPage() {
{syncing ? t('sync.syncing') : t('sync.trigger')}
</button>
{/* Sync request failure (Owner-Entscheidung 2026-08-06): a failed
sync request must never render as a report full of zeros. */}
{syncRequestError && (
<p className="text-sm text-destructive">{syncRequestError}</p>
)}
{/* Sync result display */}
{syncResult && (
<div className="rounded-md border border-border bg-muted/30 p-4">
@@ -1297,6 +1318,26 @@ export default function AdminLdapPage() {
deactivated: syncResult.deactivated,
})}
</p>
<p className="text-sm text-muted-foreground">
{t('sync.resultGroupMemberships', {
groupMembershipsAdded: syncResult.groupMembershipsAdded,
groupMembershipsRemoved: syncResult.groupMembershipsRemoved,
})}
</p>
<p className="text-sm text-muted-foreground">
{t('sync.resultGroups', {
groupsAdopted: syncResult.groupsAdopted,
groupsRenamed: syncResult.groupsRenamed,
groupsDeleted: syncResult.groupsDeleted,
})}
</p>
{syncResult.defaultMarkerMoved > 0 && (
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">
{t('sync.defaultMarkerMoved', {
defaultMarkerMoved: syncResult.defaultMarkerMoved,
})}
</p>
)}
{syncResult.errors.length > 0 && (
<div className="mt-2 space-y-1">
{syncResult.errors.map((err, i) => (