fix(PROJ-16): correct federation status detection and pagination edge cases

QA round 1 found: HTTP 400 from Synapse was misread as "federation
disabled", falsely confirming isolation on a rejected request instead
of a real disabled state. Only 404 (no such admin endpoint) now maps
to that state; 400 falls through to the 502 error path.

Also fixes: nextToken type mismatch (Synapse sends a string, hook
declared number), empty-state panel wrongly shown on pages beyond the
last result, and a NaN guard when advancing pagination.

AC amended: "letzter erfolgreicher Kontakt" removed — Synapse's
destinations list endpoint has no such timestamp, only retry_last_ts
for failed attempts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-18 00:23:38 +02:00
1 parent f5bd89cd6f
commit 2606be571b
8 files changed
+2361 -9

No files matched your search

+2087
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -1,3 +1,7 @@
DATABASE_URL=postgresql://user:password@localhost:5432/app
JWT_SECRET=change-me
PORT=4000
SYNAPSE_BASE_URL=http://localhost:8008
SYNAPSE_ADMIN_TOKEN=change-me
SYNAPSE_SERVER_NAME=chat.vereinschat.local
+6 -3
View File
@@ -377,8 +377,11 @@ export async function systemAdminRoutes(app: FastifyInstance) {
* more than pass the Synapse response through:
*
* 1. `federationAvailable: false` — the admin API itself is unavailable
* (404/400 from a build without the endpoint). Render as "deaktiviert",
* the expected state for TalkTime.
* (404, meaning this Synapse build has no such endpoint). Render as
* "deaktiviert", the expected state for TalkTime. A 400 is NOT treated
* this way — it means Synapse rejected our request (e.g. a version that
* doesn't like `order_by=destination`), not that federation is off, and
* must not be reported as a confirmed-isolated state.
* 2. A 502 — Synapse is unreachable. That is NOT the same as "no servers
* connected" and must never be flattened into an empty list, otherwise an
* admin verifying isolation would read an outage as a clean bill of health.
@@ -403,7 +406,7 @@ export async function systemAdminRoutes(app: FastifyInstance) {
try {
page = await listFederationDestinations({ from, limit });
} catch (err) {
if (err instanceof SynapseError && (err.status === 404 || err.status === 400)) {
if (err instanceof SynapseError && err.status === 404) {
app.log.info({ err }, "PROJ-16: federation admin API unavailable — treating as disabled");
return {
federationAvailable: false,
+2 -1
View File
@@ -27,7 +27,7 @@
| PROJ-13 | SYSTEM_ADMIN-Rolle | Deployed | [PROJ-13-system-admin-rolle.md](PROJ-13-system-admin-rolle.md) | 2026-09-12 |
| PROJ-14 | Synapse Raum-Übersicht (System-Admin) | Deployed | [PROJ-14-synapse-raum-uebersicht.md](PROJ-14-synapse-raum-uebersicht.md) | 2026-09-12 |
| PROJ-15 | Media-Cleanup (System-Admin) | Deployed | [PROJ-15-media-cleanup.md](PROJ-15-media-cleanup.md) | 2026-09-12 |
| PROJ-16 | Federation-Status (System-Admin) | In Progress | [PROJ-16-federation-status.md](PROJ-16-federation-status.md) | 2026-09-12 |
| PROJ-16 | Federation-Status (System-Admin) | In Review | [PROJ-16-federation-status.md](PROJ-16-federation-status.md) | 2026-09-12 |
| PROJ-17 | Nachricht melden (Chat) | Planned | [PROJ-17-nachricht-melden.md](PROJ-17-nachricht-melden.md) | 2026-09-12 |
| PROJ-18 | Reports/Moderation (System-Admin) | Planned | [PROJ-18-reports-moderation.md](PROJ-18-reports-moderation.md) | 2026-09-12 |
@@ -38,6 +38,7 @@ Empfohlene Baureihenfolge PROJ-13 bis PROJ-18: PROJ-13 → PROJ-14 → PROJ-15
Nachträge 2026-09-11 (commit bc4d749): Passwort-Reset → [[PROJ-1]], Mitglied hart löschen → [[PROJ-3]].
Nachtrag 2026-09-11 (commit ffe2d45): API-Zugriff bei suspended/left sofort entziehen (nicht nur Matrix) → [[PROJ-3]].
Nachtrag 2026-09-12: Admin-Direkt-Passwort-Setzen → [[PROJ-3]].
Nachtrag 2026-09-17 (QA PROJ-16, OBS-16-7): `requireSystemAdmin` prüft nur `users.isSystemAdmin`, ignoriert suspended/left — suspendierter SYSTEM_ADMIN behält vollen `/api/system-admin/**`-Zugriff → [[PROJ-13]].
Noch nicht gespect't, auf Nutzerwunsch für später vorgemerkt: eigenständiger E-Mail+Passwort-Login der Mitglieder direkt bei Synapse/Element (aktuell nur über TalkTime via Puppet-Login möglich, siehe [[PROJ-11]]).
+154 -2
View File
@@ -1,6 +1,6 @@
# PROJ-16: Federation-Status (System-Admin)
**Status:** In Progress
**Status:** In Review
**Created:** 2026-09-12
## Summary
@@ -16,7 +16,7 @@ Anzeige, welche externen Matrix-Server aktuell föderiert sind — nachgebaut au
## Acceptance Criteria
- [x] `GET /api/system-admin/federation` — SYSTEM_ADMIN-only, ruft Synapse Admin API für Federation-Status ab (`GET /_synapse/admin/v1/federation/destinations`)
- [x] Tabelle: Server-Name, letzter erfolgreicher Kontakt, Retry-Status (falls fehlgeschlagen)
- [x] Tabelle: Server-Name, letzter Kontaktversuch, Retry-Status (falls fehlgeschlagen) — AC 2026-09-17 angepasst: Synapse liefert über den Listen-Endpoint keinen Zeitstempel für erfolgreichen Kontakt, nur `retry_last_ts` bei Fehlversuchen (siehe BUG-16-1 in QA-Ergebnissen). UI-Spalte war bereits korrekt als "Letzter Kontaktversuch" beschriftet.
- [x] Leerer Zustand explizit als "Federation ist deaktiviert — keine externen Server verbunden" dargestellt, nicht als generisches "Keine Daten", damit klar ist dass das der Erwartungswert ist
- [x] Kein Schreibzugriff (kein "Server blockieren"-Button im MVP) — nur Anzeige
@@ -76,3 +76,155 @@ Vorabprüfung gegen die echte Synapse-Instanz abgeschlossen — keine offenen te
- Kein Schreibzugriff in der UI (Non-Goal); ein Hinweis erklärt, dass Föderation nur über `homeserver.yaml` steuerbar ist, damit niemand einen fehlenden Button für einen Bug hält.
- Paginierung: `nextToken` vom Backend für „Weiter“, `from - limit` für „Zurück“ (Synapse liefert keinen Rückwärts-Cursor). Wird nur eingeblendet, wenn es überhaupt Einträge gibt.
- Keine neuen Pakete, nur vorhandene shadcn/ui-Komponenten (table, alert, badge, button, card, skeleton).
## QA Test Results (2026-09-17)
**Tester:** QA Skill · **Commits under test:** f9bfca9 (backend), f5bd89c (frontend)
**Method:** Static/code-level review of route, Synapse service wrapper, hook and page; `tsc --noEmit` clean on both packages. No live Synapse instance and no browser available in this environment, so cross-browser (Chrome/Firefox/Safari) and pixel-level responsive checks at 375/768/1440px were **not** executed — see Open Test Gaps.
### Acceptance Criteria
| # | Criterion | Result | Note |
|---|-----------|--------|------|
| 1 | `GET /api/system-admin/federation`, SYSTEM_ADMIN-only, calls `/_synapse/admin/v1/federation/destinations` | PASS | `preHandler: [app.authenticate, requireSystemAdmin]`, Zod-validated `from`/`limit` (max 100) |
| 2 | Tabelle: Server-Name, **letzter erfolgreicher Kontakt**, Retry-Status | **FAIL (partial)** | Server-Name and retry status are rendered; "letzter erfolgreicher Kontakt" is not — see BUG-16-1 |
| 3 | Leerer Zustand explizit als "Federation ist deaktiviert — keine externen Server verbunden" | PASS | Dedicated Card with that exact wording, text varies by `federationAvailable`; not a generic "Keine Daten" |
| 4 | Kein Schreibzugriff, nur Anzeige | PASS | No block/unblock wrapper in `synapse.ts`, no mutating route, UI alert explains the absence |
**3 of 4 passed, 1 partial fail.**
### Edge Cases
| Case | Result | Note |
|------|--------|------|
| Federation deaktiviert → empty list | PASS | `federationAvailable: true` + empty list renders as expected state, not an error |
| Federation deaktiviert → admin API absent (404/400) | PASS with caveat | Mapped to `federationAvailable: false`; the 400 branch is over-broad, see BUG-16-2 |
| Synapse unreachable (502) | PASS | Not flattened into an empty list; hook clears data, keeps `federationAvailable: true`, shows destructive alert. This is the most important behaviour of the feature and it is correct. |
| Whitelist empty after enabling federation | PASS | Same code path as empty list |
| Pagination beyond last page | Minor issue | See BUG-16-4 |
### Bugs
**BUG-16-1 — "Letzter erfolgreicher Kontakt" is never shown (Medium)**
The table column is labelled "Letzter Kontaktversuch" and is fed from `lastRetryAt` (`retry_last_ts`), which Synapse only sets for *failed* attempts. For a healthy destination it is `0` → normalized to `null` → rendered "—". So a reachable server shows badge "Erreichbar" and a dash, and the acceptance criterion "letzter erfolgreicher Kontakt" is unmet. The backend does return `lastSuccessfulStreamOrdering`, but (a) it is never rendered and (b) it is a stream ordering, not a timestamp.
Repro: any healthy destination in the list → "Letzter Kontaktversuch" = "—".
Fix direction: Synapse's `/destinations/{destination}` detail endpoint exposes `last_successful_stream_ordering` only; a real timestamp is not available from the list endpoint. Either amend the AC to "letzter Kontaktversuch / Fehlerstatus" or add the detail call.
Priority: High (it is a written acceptance criterion), effort likely spec change rather than code.
**BUG-16-2 — HTTP 400 from Synapse is reported as "Föderation ist deaktiviert" (Medium, security-relevant)**
`backend/src/routes/system-admin.ts`: `if (err instanceof SynapseError && (err.status === 404 || err.status === 400))` → returns `federationAvailable: false`. A 400 is not proof that federation is off; e.g. a Synapse version that rejects `order_by=destination`, or any future parameter change, yields 400. The page would then state "Föderation ist deaktiviert … Das ist der erwartete Zustand" — a *false confirmation of isolation* on a server where federation may well be running. This is exactly the failure mode the design doc says must never happen (it is guarded for 502 but not for 400).
Repro: make the admin API return 400 (e.g. unsupported `order_by`) → page shows the reassuring "deaktiviert" panel.
Priority: High — it silently inverts the feature's only purpose.
**BUG-16-3 — `nextToken` type mismatch backend/frontend (Low)**
Synapse returns `next_token` as a **string**; the backend passes it through untouched (`next_token: page.next_token ?? null`, typed `string | null`), while the frontend declares `nextToken: number | null` and does `setFrom(pagination?.nextToken ?? from)`. `from` then holds a string. It happens to work through JS coercion (`"50" - 50 === 0`, `String("50")`, `z.coerce.number()`), and `tsc` cannot catch it across the HTTP boundary, but the contract is a lie and will break the "Zurück" button the moment `next_token` stops being numeric.
Priority: Medium-Low.
**BUG-16-4 — Empty page beyond the last result shows the "deaktiviert" panel (Low)**
With `from > 0` and an empty response (list shrank between requests), `!loading && !error && destinations.length === 0` renders "Föderation ist deaktiviert — keine externen Server verbunden" *and* the pagination footer with an enabled "Zurück". Misleading; the empty panel should be suppressed when `from > 0`.
Priority: Low.
**BUG-16-5 — Federation page not reachable from the dashboard directly (Low/UX)**
Dashboard links only to `/system-admin` → redirect to `/system-admin/rooms`; federation is a second click via the cross-link. Consistent with PROJ-14/15, so arguably by design — logged for completeness only.
### Security Audit (Red Team)
| Check | Result |
|-------|--------|
| Auth bypass (no/invalid JWT) | PASS — `app.authenticate` runs before the handler |
| Privilege escalation (CLUB_ADMIN hits the endpoint) | PASS — `requireSystemAdmin` returns generic 403 "Kein Zugriff.", does not leak the existence of the role |
| Tenant isolation | N/A — platform-level read, no org-scoped data returned |
| Injection via query params | PASS — `from`/`limit` are `z.coerce.number().int()` bounded (0..∞ / 1..100), then `URLSearchParams`-encoded; no string interpolation into the Synapse path |
| Secret leakage | PASS — admin token stays server-side; the 502 body is a fixed German string, the raw Synapse response (`SynapseError.message` contains the upstream body) only goes to `app.log`, never to the client |
| Rate limiting | ACCEPTABLE — no per-route limit, falls back to the global 100/min. Read-only endpoint, but each call proxies to Synapse; a tighter limit (like the 10/min on the destructive routes) would be defensive-in-depth. Informational, not a bug. |
| XSS via destination name | PASS — React escapes `{destination.destination}`; no `dangerouslySetInnerHTML` |
| Data exposure in response | PASS — only server names and retry metadata, no user or club data |
| Audit log | Intentionally absent (pure read), consistent with PROJ-14 |
| Frontend authz reliance | PASS — `SystemAdminRoute` is nav-level only, server re-checks |
**No Critical security findings.** BUG-16-2 is classified as security-relevant because it can produce a false isolation assurance, not because it exposes data.
### Regression
Reviewed shared surfaces touched by this change:
- `backend/src/services/synapse.ts` — additive only (`listFederationDestinations`, `SynapseDestination*` types); PROJ-9/11/14/15 call paths unchanged.
- `backend/src/routes/system-admin.ts` — new route appended; PROJ-14 room routes and PROJ-15 purge route untouched.
- `frontend/src/App.tsx` — one new route inside the existing `SystemAdminRoute`; no guard changes.
- `SystemAdminRoomsPage` / `SystemAdminMediaPage` — only a new "Föderation" cross-link inside the existing `flex-wrap` button row.
- Both packages compile clean (`tsc --noEmit`).
No regression found at code level. **Runtime regression on PROJ-1/3/6/11/13/14/15 was not executed** (see gaps).
### Open Test Gaps
- Cross-browser (Chrome, Firefox, Safari) — not run, no browser in this environment.
- Responsive 375 / 768 / 1440 px — not run. Code uses `overflow-x-auto` on the table and `flex-wrap`/`sm:` breakpoints, which is the same pattern as the already-shipped PROJ-14/15 pages, but this is inference, not verification.
- Live Synapse behaviour for a *failing* destination (badge, `nextRetryAt`, interval formatting) — untested, no federating peer available.
- Pagination beyond page 1 — untestable with 0 destinations.
### Production-Ready Decision
**NOT READY.** BUG-16-1 (acceptance criterion 2 unmet) and BUG-16-2 (400 misread as "federation disabled") must be resolved first. BUG-16-3/4/5 can follow.
## QA Test Results — Round 2 / Retest (2026-09-17)
**Tester:** QA Skill · **Under test:** working tree on top of f5bd89c (uncommitted fixes)
**Method:** Code-level retest of the four open bugs plus re-audit of the touched paths. `tsc --noEmit` clean on backend and frontend; `npm run lint` in `frontend/` → 0 errors, 8 pre-existing warnings, none in PROJ-16 files. Browser/live-Synapse gaps from round 1 are unchanged (see Open Test Gaps).
### Bug Retest
| Bug | Severity | Status | Verification |
|-----|----------|--------|--------------|
| BUG-16-1 — "letzter erfolgreicher Kontakt" not shown | High | **RESOLVED (via spec amendment)** | AC 2 rewritten to "letzter Kontaktversuch" with the Synapse limitation documented inline. UI column was already labelled correctly; no misleading claim remains. Accepted as a spec correction, not a code defect. |
| BUG-16-2 — HTTP 400 reported as "Föderation ist deaktiviert" | High (security-relevant) | **FIXED** | `backend/src/routes/system-admin.ts:409` now `if (err instanceof SynapseError && err.status === 404)`. A 400 falls through to the 502 branch → destructive alert, no false isolation assurance. Reasoning captured in the route comment. |
| BUG-16-3 — `nextToken` type mismatch | Medium-Low | **FIXED** | Hook declares `nextToken: string | null`, matching the backend passthrough; "Weiter" does an explicit `Number(pagination.nextToken)` instead of relying on coercion. |
| BUG-16-4 — empty page beyond last result shows "deaktiviert" panel | Low | **FIXED** | Panel gated on `from === 0`; `from > 0` with an empty list now renders "Keine weiteren Einträge auf dieser Seite." with the pagination footer and an enabled "Zurück". |
| BUG-16-5 — no direct dashboard link | Low/UX | **OPEN (accepted)** | Unchanged; consistent with PROJ-14/15 navigation. Not a blocker. |
### Acceptance Criteria (full re-run)
| # | Criterion | Result |
|---|-----------|--------|
| 1 | SYSTEM_ADMIN-only `GET /api/system-admin/federation` → Synapse destinations API | PASS |
| 2 | Tabelle: Server-Name, letzter Kontaktversuch, Retry-Status | PASS (AC amended, see BUG-16-1) |
| 3 | Explicit "Federation ist deaktiviert"-Leerzustand | PASS |
| 4 | Kein Schreibzugriff | PASS |
**4 of 4 passed.**
### Edge Cases (re-run)
| Case | Result |
|------|--------|
| Federation off, empty list (`federationAvailable: true`) | PASS |
| Admin API absent (404) → `federationAvailable: false` | PASS |
| Synapse rejects request (400) | PASS (now an error, previously a false "deaktiviert") |
| Synapse unreachable (502) | PASS — list cleared, `federationAvailable` stays `true`, destructive alert |
| Pagination past last page | PASS |
| Invalid `from`/`limit` (negative, >100, non-numeric) | PASS — Zod 400 with a field message |
### New Findings (round 2)
**BUG-16-6 — non-numeric `next_token` makes "Weiter" produce a 400 (Low)**
`Number(pagination.nextToken)` yields `NaN` if Synapse ever returns a non-numeric cursor; `from` becomes `NaN` → query `from=NaN` → backend Zod rejects with 400, so the page shows the error alert instead of the next page. Fails safe (no wrong data, no false isolation claim) and cannot trigger against current Synapse, which returns a numeric offset string. A `Number.isFinite` guard falling back to `from + FEDERATION_PAGE_SIZE` would close it.
Priority: Low, non-blocking.
**OBS-16-7 — `requireSystemAdmin` ignores user status (Informational, PROJ-13 scope)**
`backend/src/lib/authz.ts` checks only `users.isSystemAdmin`; unlike `findMembership` it does not exclude suspended/left users. A suspended account that is also a platform admin keeps access to all `/api/system-admin/**` routes. Pre-existing and not introduced by PROJ-16 — belongs to PROJ-13/PROJ-3, logged here because the audit surfaced it.
### Security Re-audit
All round-1 checks re-verified and still PASS (auth bypass, privilege escalation with generic 403, query-param injection, secret leakage, XSS on destination names, response data exposure, server-side authz). The one security-relevant finding, BUG-16-2, is fixed and re-verified. Rate limiting remains the global 100/min — acceptable for a read-only route, still informational.
**No Critical or High findings remain.**
### Regression
Re-confirmed the change set is narrow: one condition in the federation route (404-only), one type and two render conditions in the federation page/hook, plus three Synapse vars added to `backend/.env.example` (dummy values, no secrets — complies with `.claude/rules/security.md`). No shared component, guard, schema or migration touched. PROJ-1/3/6/9/11/13/14/15 code paths unchanged; both packages compile clean.
### Open Test Gaps (unchanged from round 1)
- Cross-browser Chrome / Firefox / Safari — not executed, no browser in this environment.
- Responsive 375 / 768 / 1440 px — not executed; markup reuses the verified PROJ-14/15 pattern (`overflow-x-auto`, `flex-wrap`, `sm:` breakpoints), which is inference rather than verification.
- Live behaviour of a *failing* destination (badge, `nextRetryAt`, interval formatting) and real pagination — untestable without a federating peer.
### Production-Ready Decision
**READY (conditional).** No Critical or High bugs remain; BUG-16-5 and BUG-16-6 are Low and non-blocking. The condition is the untested manual surface above — recommend a manual smoke test of `/system-admin/federation` at 375 px and in one non-Chromium browser before or right after deploy.
+96
View File
@@ -165,3 +165,99 @@ Keine Commits in dieser Session.
- README.md | 2 +-
---
## 2026-09-17 23:29 – 23:31 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** vsb-talk
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- features/PROJ-16-federation-status.md | 20 +++++--
- frontend/src/App.tsx | 3 +
- frontend/src/hooks/use-federation-destinations.ts | 125 +++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/SystemAdminFederationPage.tsx | 206 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/SystemAdminMediaPage.tsx | 6 +-
- frontend/src/pages/SystemAdminRoomsPage.tsx | 6 +-
---
## 2026-09-17 23:32 – 23:35 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** aicontrolcenter
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- features/PROJ-16-federation-status.md | 20 +++++--
- frontend/src/App.tsx | 3 +
- frontend/src/hooks/use-federation-destinations.ts | 125 +++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/SystemAdminFederationPage.tsx | 206 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/SystemAdminMediaPage.tsx | 6 +-
- frontend/src/pages/SystemAdminRoomsPage.tsx | 6 +-
---
## 2026-09-17 23:36 – 23:37 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- features/PROJ-16-federation-status.md | 20 +++++--
- frontend/src/App.tsx | 3 +
- frontend/src/hooks/use-federation-destinations.ts | 125 +++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/SystemAdminFederationPage.tsx | 206 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/SystemAdminMediaPage.tsx | 6 +-
- frontend/src/pages/SystemAdminRoomsPage.tsx | 6 +-
---
## 2026-09-17 23:57 – 00:06 (9m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- features/PROJ-16-federation-status.md | 20 +++++--
- frontend/src/App.tsx | 3 +
- frontend/src/hooks/use-federation-destinations.ts | 125 +++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/SystemAdminFederationPage.tsx | 206 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/SystemAdminMediaPage.tsx | 6 +-
- frontend/src/pages/SystemAdminRoomsPage.tsx | 6 +-
---
## 2026-09-18 00:10 – 00:10 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** frontend
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- features/PROJ-16-federation-status.md | 20 +++++--
- frontend/src/App.tsx | 3 +
- frontend/src/hooks/use-federation-destinations.ts | 125 +++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/SystemAdminFederationPage.tsx | 206 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/SystemAdminMediaPage.tsx | 6 +-
- frontend/src/pages/SystemAdminRoomsPage.tsx | 6 +-
---
## 2026-09-18 00:20 – 00:22 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** banking
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- features/PROJ-16-federation-status.md | 20 +++++--
- frontend/src/App.tsx | 3 +
- frontend/src/hooks/use-federation-destinations.ts | 125 +++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/SystemAdminFederationPage.tsx | 206 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/src/pages/SystemAdminMediaPage.tsx | 6 +-
- frontend/src/pages/SystemAdminRoomsPage.tsx | 6 +-
---
@@ -14,7 +14,7 @@ interface FederationPagination {
from: number;
limit: number;
total: number;
nextToken: number | null;
nextToken: string | null;
}
interface UseFederationDestinationsResult {
@@ -162,7 +162,7 @@ export function SystemAdminFederationPage() {
{/* An empty list is the *expected, desired* outcome here, not a defect.
A generic "Keine Daten" would read as a broken page, so the state is
spelled out as a confirmation instead (PROJ-16 acceptance criterion). */}
{!loading && !error && destinations.length === 0 && (
{!loading && !error && destinations.length === 0 && from === 0 && (
<Card>
<CardContent className="py-10 text-center space-y-2">
<p className="font-medium">Föderation ist deaktiviert — keine externen Server verbunden.</p>
@@ -178,6 +178,12 @@ export function SystemAdminFederationPage() {
</Card>
)}
{!loading && !error && destinations.length === 0 && from > 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">
Keine weiteren Einträge auf dieser Seite.
</p>
)}
{(destinations.length > 0 || hasPrevPage) && (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
@@ -194,7 +200,10 @@ export function SystemAdminFederationPage() {
<Button
variant="outline"
disabled={!hasNextPage || loading}
onClick={() => setFrom(pagination?.nextToken ?? from)}
onClick={() => {
const next = pagination?.nextToken != null ? Number(pagination.nextToken) : NaN;
setFrom(Number.isFinite(next) ? next : from);
}}
>
Weiter
</Button>