diff --git a/features/INDEX.md b/features/INDEX.md index 973c3a0..65f7951 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -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 | diff --git a/features/PROJ-13-system-admin-rolle.md b/features/PROJ-13-system-admin-rolle.md index df3e6b7..13e3b73 100644 --- a/features/PROJ-13-system-admin-rolle.md +++ b/features/PROJ-13-system-admin-rolle.md @@ -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 diff --git a/features/PROJ-14-synapse-raum-uebersicht.md b/features/PROJ-14-synapse-raum-uebersicht.md index 1221678..d51dba2 100644 --- a/features/PROJ-14-synapse-raum-uebersicht.md +++ b/features/PROJ-14-synapse-raum-uebersicht.md @@ -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") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a8831ee..0b90ca3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + {/* PROJ-14: system-admin area. The guard is nav-level only — + /api/system-admin/** re-checks SYSTEM_ADMIN server-side. */} + }> + } /> + } /> + } /> diff --git a/frontend/src/hooks/use-synapse-rooms.ts b/frontend/src/hooks/use-synapse-rooms.ts new file mode 100644 index 0000000..4c39ee9 --- /dev/null +++ b/frontend/src/hooks/use-synapse-rooms.ts @@ -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; +} + +export const ROOMS_PAGE_SIZE = 25; + +async function parseError(res: Response): Promise { + 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([]); + const [pagination, setPagination] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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() }; +} diff --git a/frontend/src/pages/SystemAdminRoomsPage.tsx b/frontend/src/pages/SystemAdminRoomsPage.tsx new file mode 100644 index 0000000..d35c4d2 --- /dev/null +++ b/frontend/src/pages/SystemAdminRoomsPage.tsx @@ -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(null); + const [deleting, setDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(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 ( +
+
+
+

Matrix-Räume

+

+ Alle Räume der angebundenen Synapse-Instanz, vereinsübergreifend. +

+
+ +
+ +
+ setSearchInput(e.target.value)} + placeholder="Raumname oder Alias suchen…" + aria-label="Räume durchsuchen" + className="max-w-sm" + /> + + {search && ( + + )} +
+ + {error && ( + + + {error} + + + + )} + +
+ + + + Raum + Mitglieder + Zuordnung + Erstellt am + Aktion + + + + {loading ? ( + + + Lädt… + + + ) : error ? ( + + + Keine Daten verfügbar + + + ) : rooms.length === 0 ? ( + + + {search ? "Keine Räume gefunden" : "Keine Räume vorhanden"} + + + ) : ( + rooms.map((room) => ( + + + {room.name ?? "(ohne Namen)"} + + {room.canonicalAlias ?? room.matrixRoomId} + + + {room.joinedMembers} + + {room.assignment ? ( + {assignmentLabel(room)} + ) : ( + nicht zugeordnet + )} + + + {room.createdAt ? new Date(room.createdAt).toLocaleDateString("de-DE") : "—"} + + + + + + )) + )} + +
+
+ +
+

+ {total} Räume gesamt +

+
+ + +
+
+ + { + if (!open && !deleting) { + setRoomToDelete(null); + setDeleteError(null); + } + }} + > + + + Raum endgültig löschen? + + {roomToDelete && ( + <> + „{roomToDelete.name ?? roomToDelete.matrixRoomId}“ wird bei Synapse gelöscht und + gepurged. Alle Nachrichten und Mitgliedschaften gehen unwiderruflich verloren. + + )} + + + {roomToDelete?.assignment && ( + + + Dieser Raum ist aktiv {assignmentLabel(roomToDelete)} zugeordnet — der Team-Chat + wird dadurch zerstört. + + + )} + {deleteError && ( + + {deleteError} + + )} + + Abbrechen + { + // 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"} + + + + +
+ ); +} diff --git a/frontend/src/routes/SystemAdminRoute.tsx b/frontend/src/routes/SystemAdminRoute.tsx new file mode 100644 index 0000000..ce5d536 --- /dev/null +++ b/frontend/src/routes/SystemAdminRoute.tsx @@ -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 ; + } + return ; +}