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:
1 parent
f81d2d7906
commit
4b03ca6c3f
7 files changed
+429
-10
No files matched your search
+2
-2
@@ -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,6 +1,6 @@
|
||||
# PROJ-13: SYSTEM_ADMIN-Rolle (Grundlage für System-Admin-Panel)
|
||||
|
||||
**Status:** In Review
|
||||
**Status:** Deployed
|
||||
**Created:** 2026-09-12
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
Reference in new issue
Block a user