feat(PROJ-14): frontend for Synapse room overview

/system-admin/rooms — table (name/alias, members, TalkTime assignment,
created date, delete), search, pagination, delete confirmation dialog
that warns extra hard when the room is still bound to a live team.
SystemAdminRoute gates on user.isSystemAdmin (nav-level only, the
backend routes are the real enforcement).

Also corrected PROJ-13's status to Deployed — it was stuck at
"In Review" despite being live-verified end-to-end already.

Verified: /system-admin/rooms serves the SPA shell (200).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-12 21:10:44 +02:00
1 parent f81d2d7906
commit 4b03ca6c3f
7 files changed
+429 -10

No files matched your search

+2 -2
View File
@@ -24,8 +24,8 @@
| PROJ-10 | Deployment (Docker Compose + Synapse Installer) | Deployed | [PROJ-10-deployment.md](PROJ-10-deployment.md) | 2026-09-11 |
| PROJ-11 | Chat (Matrix Client-Server API) | Deployed | [PROJ-11-chat.md](PROJ-11-chat.md) | 2026-09-11 |
| PROJ-12 | Automatische Löschfrist für ausgetretene Mitglieder (DSGVO) | Planned | [PROJ-12-mitglieder-loeschfrist.md](PROJ-12-mitglieder-loeschfrist.md) | 2026-09-11 |
| PROJ-13 | SYSTEM_ADMIN-Rolle | In Review | [PROJ-13-system-admin-rolle.md](PROJ-13-system-admin-rolle.md) | 2026-09-12 |
| PROJ-14 | Synapse Raum-Übersicht (System-Admin) | In Progress | [PROJ-14-synapse-raum-uebersicht.md](PROJ-14-synapse-raum-uebersicht.md) | 2026-09-12 |
| 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) | In Review | [PROJ-14-synapse-raum-uebersicht.md](PROJ-14-synapse-raum-uebersicht.md) | 2026-09-12 |
| PROJ-15 | Media-Cleanup (System-Admin) | Planned | [PROJ-15-media-cleanup.md](PROJ-15-media-cleanup.md) | 2026-09-12 |
| PROJ-16 | Federation-Status (System-Admin) | Planned | [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 |
+1 -1
View File
@@ -1,6 +1,6 @@
# PROJ-13: SYSTEM_ADMIN-Rolle (Grundlage für System-Admin-Panel)
**Status:** In Review
**Status:** Deployed
**Created:** 2026-09-12
## Summary
+16 -7
View File
@@ -1,10 +1,19 @@
# PROJ-14: Synapse Raum-Übersicht (System-Admin)
**Status:** In Progress
**Status:** In Review
**Created:** 2026-09-12
## Implementation Notes (Frontend, 2026-09-12)
Frontend umgesetzt, Feature damit komplett (Backend siehe unten).
- `frontend/src/hooks/use-synapse-rooms.ts`: lädt `GET /api/system-admin/rooms` (`from`/`limit`/`search`), `ROOMS_PAGE_SIZE = 25`. Bei Fehler wird die Liste bewusst geleert **und** ein Fehlertext gesetzt — nie „stille" leere Tabelle bei nicht erreichbarer Synapse.
- `frontend/src/pages/SystemAdminRoomsPage.tsx`: Tabelle (Raum + Alias/Room-ID, Mitglieder, Zuordnung, Erstellt am, Löschen), Such-Formular (Submit statt Debounce), Zurück/Weiter-Paginierung analog PROJ-3, `AlertDialog` mit Zusatzwarnung wenn `assignment != null`. Fehler bleiben im Dialog sichtbar (`event.preventDefault()` im `AlertDialogAction`), statt ihn bei Fehlschlag zu schließen.
- `frontend/src/routes/SystemAdminRoute.tsx` (neu): Guard-Outlet auf `user.isSystemAdmin`, rein kosmetisch — Server prüft weiterhin selbst.
- `frontend/src/App.tsx`: `/system-admin` → Redirect auf `/system-admin/rooms`; der PROJ-13-Dashboard-Button zeigt damit nicht mehr ins Leere.
- Abweichung: keine `useToast`/Sonner-Meldungen — der Rest der App nutzt Inline-Fehler (`Alert`), das wurde beibehalten.
- Loading/Empty/Error sowie „keine Treffer bei aktiver Suche" sind als eigene Tabellen-Zustände abgedeckt; Tabelle ist mobil horizontal scrollbar.
## Implementation Notes (Backend, 2026-09-12)
Backend umgesetzt, Frontend-Seite (`/system-admin` Route + Tabelle + Lösch-Dialog) steht noch aus.
Backend umgesetzt (Frontend inzwischen nachgezogen, siehe oben).
- `backend/src/services/synapse.ts`: `listRooms()` (`GET /_synapse/admin/v1/rooms`, `from`/`limit`/`search_term` durchgereicht), `deleteRoom()` (v2 **async** Delete mit `purge: true` — v1 blockiert bei großen Räumen bis zum HTTP-Timeout), `normalizeCreationTs()` (Synapse liefert je nach Version Sekunden statt Millisekunden).
- `backend/src/routes/system-admin.ts` (neu, in `server.ts` registriert): `GET /api/system-admin/rooms` + `DELETE /api/system-admin/rooms/:roomId`, beide `preHandler: [app.authenticate, requireSystemAdmin]`.
- Keine neue Tabelle, keine Migration: `chat_rooms.matrix_room_id` ist bereits `unique` (Index vorhanden), Zuordnung via einem `inArray`-JOIN gegen `organizations`/`teams` (kein N+1).
@@ -24,11 +33,11 @@ Liste aller Matrix-Räume auf der angebundenen Synapse-Instanz für SYSTEM_ADMIN
3. Als SYSTEM_ADMIN will ich einen verwaisten Raum (keine TalkTime-Zuordnung mehr, z.B. nach Datenbereinigung) direkt löschen/purgen können.
## Acceptance Criteria
- [ ] `GET /api/system-admin/rooms` — SYSTEM_ADMIN-only, ruft `_synapse/admin/v1/rooms` ab, reichert um TalkTime-Zuordnung an (JOIN gegen lokale `chat_rooms`-Tabelle über `matrix_room_id`)
- [ ] Frontend-Tabelle: Raumname, Mitgliederzahl, TalkTime-Zuordnung (Vereinsname + Team, oder "nicht zugeordnet"), Erstellungsdatum
- [ ] `DELETE /api/system-admin/rooms/:roomId` — purged den Raum bei Synapse; falls in `chat_rooms` verzeichnet, wird der lokale Eintrag ebenfalls gelöscht (kaskadiert nicht automatisch, da externe ID)
- [ ] Bestätigungsdialog vor dem Löschen (destruktive Aktion, analog Mitglied-Löschen aus [[PROJ-3]])
- [ ] Pagination (Synapse Admin API liefert selbst paginiert, `from`/`limit`-Parameter durchreichen)
- [x] `GET /api/system-admin/rooms` — SYSTEM_ADMIN-only, ruft `_synapse/admin/v1/rooms` ab, reichert um TalkTime-Zuordnung an (JOIN gegen lokale `chat_rooms`-Tabelle über `matrix_room_id`)
- [x] Frontend-Tabelle: Raumname, Mitgliederzahl, TalkTime-Zuordnung (Vereinsname + Team, oder "nicht zugeordnet"), Erstellungsdatum
- [x] `DELETE /api/system-admin/rooms/:roomId` — purged den Raum bei Synapse; falls in `chat_rooms` verzeichnet, wird der lokale Eintrag ebenfalls gelöscht (kaskadiert nicht automatisch, da externe ID)
- [x] Bestätigungsdialog vor dem Löschen (destruktive Aktion, analog Mitglied-Löschen aus [[PROJ-3]])
- [x] Pagination (Synapse Admin API liefert selbst paginiert, `from`/`limit`-Parameter durchreichen)
## Edge Cases
- Raum ist ein aktiver Team-Raum (in `chat_rooms` verzeichnet, Team existiert noch) → Löschen weiterhin technisch möglich, aber Warnhinweis im Dialog ("Dieser Raum ist aktiv einem Team zugeordnet — Team-Chat wird dadurch zerstört")
+8
View File
@@ -3,6 +3,7 @@ import { Toaster } from "@/components/ui/sonner";
import { AuthProvider } from "@/lib/auth-context";
import { OrganizationProvider } from "@/lib/organization-context";
import { ProtectedRoute } from "@/routes/ProtectedRoute";
import { SystemAdminRoute } from "@/routes/SystemAdminRoute";
import { LoginPage } from "@/pages/LoginPage";
import { RegisterPage } from "@/pages/RegisterPage";
import { ForgotPasswordPage } from "@/pages/ForgotPasswordPage";
@@ -14,6 +15,7 @@ import { TeamsPage } from "@/pages/TeamsPage";
import { RolesPage } from "@/pages/RolesPage";
import { AuditLogPage } from "@/pages/AuditLogPage";
import { AcceptInvitationPage } from "@/pages/AcceptInvitationPage";
import { SystemAdminRoomsPage } from "@/pages/SystemAdminRoomsPage";
function App() {
return (
@@ -33,6 +35,12 @@ function App() {
<Route path="/teams" element={<TeamsPage />} />
<Route path="/roles" element={<RolesPage />} />
<Route path="/audit-log" element={<AuditLogPage />} />
{/* PROJ-14: system-admin area. The guard is nav-level only —
/api/system-admin/** re-checks SYSTEM_ADMIN server-side. */}
<Route element={<SystemAdminRoute />}>
<Route path="/system-admin" element={<Navigate to="/system-admin/rooms" replace />} />
<Route path="/system-admin/rooms" element={<SystemAdminRoomsPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+108
View File
@@ -0,0 +1,108 @@
import { useCallback, useEffect, useState } from "react";
export interface RoomAssignment {
organizationId: string;
organizationName: string | null;
teamId: string | null;
teamName: string | null;
scope: string;
}
export interface SynapseRoom {
matrixRoomId: string;
name: string | null;
canonicalAlias: string | null;
joinedMembers: number;
createdAt: string | null;
assignment: RoomAssignment | null;
}
interface Pagination {
from: number;
limit: number;
total: number;
nextFrom: number | null;
prevFrom: number | null;
}
interface UseSynapseRoomsResult {
rooms: SynapseRoom[];
pagination: Pagination | null;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
}
export const ROOMS_PAGE_SIZE = 25;
async function parseError(res: Response): Promise<string> {
try {
const body = (await res.json()) as { error?: string };
return body.error ?? `Anfrage fehlgeschlagen (${res.status})`;
} catch {
return `Anfrage fehlgeschlagen (${res.status})`;
}
}
/**
* PROJ-14: room list straight from Synapse (via our backend). Deliberately keeps
* `rooms` untouched when a request fails, so an unreachable Synapse surfaces as
* an error banner instead of an empty table that reads like "no rooms exist".
*/
export function useSynapseRooms(
token: string | null,
from: number,
search: string,
): UseSynapseRoomsResult {
const [rooms, setRooms] = useState<SynapseRoom[]>([]);
const [pagination, setPagination] = useState<Pagination | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(
async (signal?: AbortSignal) => {
if (!token) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
from: String(from),
limit: String(ROOMS_PAGE_SIZE),
});
if (search.trim()) {
params.set("search", search.trim());
}
const res = await fetch(`/api/system-admin/rooms?${params}`, {
headers: { Authorization: `Bearer ${token}` },
signal,
});
if (!res.ok) {
throw new Error(await parseError(res));
}
const body = (await res.json()) as { rooms: SynapseRoom[]; pagination: Pagination };
setRooms(body.rooms);
setPagination(body.pagination);
} catch (err) {
if ((err as Error).name !== "AbortError") {
setError((err as Error).message);
setRooms([]);
setPagination(null);
}
} finally {
setLoading(false);
}
},
[token, from, search],
);
useEffect(() => {
const controller = new AbortController();
void load(controller.signal);
return () => controller.abort();
}, [load]);
return { rooms, pagination, loading, error, refresh: () => load() };
}
+277
View File
@@ -0,0 +1,277 @@
import { useState, type FormEvent } from "react";
import { Link } from "react-router-dom";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuth } from "@/lib/auth-context";
import {
useSynapseRooms,
ROOMS_PAGE_SIZE,
type SynapseRoom,
} from "@/hooks/use-synapse-rooms";
function assignmentLabel(room: SynapseRoom): string {
if (!room.assignment) {
return "nicht zugeordnet";
}
const org = room.assignment.organizationName ?? "Unbekannter Verein";
return room.assignment.teamName ? `${org} · ${room.assignment.teamName}` : org;
}
export function SystemAdminRoomsPage() {
const { token } = useAuth();
const [from, setFrom] = useState(0);
const [search, setSearch] = useState("");
const [searchInput, setSearchInput] = useState("");
const [roomToDelete, setRoomToDelete] = useState<SynapseRoom | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const { rooms, pagination, loading, error, refresh } = useSynapseRooms(token, from, search);
function onSearchSubmit(event: FormEvent) {
event.preventDefault();
setFrom(0);
setSearch(searchInput);
}
async function onDeleteConfirmed() {
if (!roomToDelete || !token) return;
setDeleting(true);
setDeleteError(null);
try {
const res = await fetch(
`/api/system-admin/rooms/${encodeURIComponent(roomToDelete.matrixRoomId)}`,
{ method: "DELETE", headers: { Authorization: `Bearer ${token}` } },
);
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(body?.error ?? `Löschen fehlgeschlagen (${res.status})`);
}
setRoomToDelete(null);
await refresh();
} catch (err) {
setDeleteError((err as Error).message);
} finally {
setDeleting(false);
}
}
const total = pagination?.total ?? 0;
const hasNextPage = rooms.length === ROOMS_PAGE_SIZE && from + ROOMS_PAGE_SIZE < total;
return (
<main className="min-h-screen p-4 sm:p-6 space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold">Matrix-Räume</h1>
<p className="text-sm text-muted-foreground">
Alle Räume der angebundenen Synapse-Instanz, vereinsübergreifend.
</p>
</div>
<Button variant="outline" asChild>
<Link to="/">Zurück zum Dashboard</Link>
</Button>
</div>
<form onSubmit={onSearchSubmit} className="flex gap-2" role="search">
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Raumname oder Alias suchen…"
aria-label="Räume durchsuchen"
className="max-w-sm"
/>
<Button type="submit" variant="secondary">
Suchen
</Button>
{search && (
<Button
type="button"
variant="ghost"
onClick={() => {
setSearchInput("");
setSearch("");
setFrom(0);
}}
>
Zurücksetzen
</Button>
)}
</form>
{error && (
<Alert variant="destructive">
<AlertDescription className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<span>{error}</span>
<Button variant="outline" size="sm" onClick={() => void refresh()}>
Erneut versuchen
</Button>
</AlertDescription>
</Alert>
)}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Raum</TableHead>
<TableHead className="w-24 text-right">Mitglieder</TableHead>
<TableHead>Zuordnung</TableHead>
<TableHead className="w-40">Erstellt am</TableHead>
<TableHead className="w-28 text-right">Aktion</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
Lädt…
</TableCell>
</TableRow>
) : error ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
Keine Daten verfügbar
</TableCell>
</TableRow>
) : rooms.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
{search ? "Keine Räume gefunden" : "Keine Räume vorhanden"}
</TableCell>
</TableRow>
) : (
rooms.map((room) => (
<TableRow key={room.matrixRoomId}>
<TableCell>
<span className="font-medium">{room.name ?? "(ohne Namen)"}</span>
<span className="block text-xs text-muted-foreground break-all">
{room.canonicalAlias ?? room.matrixRoomId}
</span>
</TableCell>
<TableCell className="text-right">{room.joinedMembers}</TableCell>
<TableCell>
{room.assignment ? (
<span className="text-sm">{assignmentLabel(room)}</span>
) : (
<Badge variant="secondary">nicht zugeordnet</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{room.createdAt ? new Date(room.createdAt).toLocaleDateString("de-DE") : "—"}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => {
setDeleteError(null);
setRoomToDelete(room);
}}
>
Löschen
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{total} Räume gesamt
</p>
<div className="flex gap-2">
<Button
variant="outline"
disabled={from === 0 || loading}
onClick={() => setFrom((f) => Math.max(0, f - ROOMS_PAGE_SIZE))}
>
Zurück
</Button>
<Button
variant="outline"
disabled={!hasNextPage || loading}
onClick={() => setFrom((f) => f + ROOMS_PAGE_SIZE)}
>
Weiter
</Button>
</div>
</div>
<AlertDialog
open={roomToDelete !== null}
onOpenChange={(open) => {
if (!open && !deleting) {
setRoomToDelete(null);
setDeleteError(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Raum endgültig löschen?</AlertDialogTitle>
<AlertDialogDescription>
{roomToDelete && (
<>
„{roomToDelete.name ?? roomToDelete.matrixRoomId}“ wird bei Synapse gelöscht und
gepurged. Alle Nachrichten und Mitgliedschaften gehen unwiderruflich verloren.
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
{roomToDelete?.assignment && (
<Alert variant="destructive">
<AlertDescription>
Dieser Raum ist aktiv {assignmentLabel(roomToDelete)} zugeordnet — der Team-Chat
wird dadurch zerstört.
</AlertDescription>
</Alert>
)}
{deleteError && (
<Alert variant="destructive">
<AlertDescription>{deleteError}</AlertDescription>
</Alert>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Abbrechen</AlertDialogCancel>
<AlertDialogAction
onClick={(event) => {
// Keep the dialog open while the request runs so errors stay visible.
event.preventDefault();
void onDeleteConfirmed();
}}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting ? "Löscht…" : "Endgültig löschen"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</main>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "@/lib/auth-context";
/**
* PROJ-14: keeps the system-admin area out of the nav-flow for regular users.
* Purely cosmetic — every /api/system-admin route re-checks the flag server-side.
*/
export function SystemAdminRoute() {
const { user, initializing } = useAuth();
if (initializing) {
return null;
}
if (!user?.isSystemAdmin) {
return <Navigate to="/" replace />;
}
return <Outlet />;
}