feat(PROJ-15): media cache cleanup for SYSTEM_ADMIN
POST /api/system-admin/media/purge-cache (SYSTEM_ADMIN-only, 10/min
rate limit) plus /system-admin/media page — a date picker (default
30 days back), confirmation dialog, and honest UI copy: this only
clears Synapse's *federated*-media cache, which is nearly empty with
federation disabled. No summary/size-preview endpoint, matching the
tech design's explicit call to not show a number that might be wrong
across Synapse versions.
Backend agent couldn't reach the live Synapse to confirm sync/async
behavior or whether a count comes back (no access to backend/.env in
that sandbox), so it built defensively (number | null). Verified
directly against matrix.perlbach24.de: the call is synchronous (~1.1s)
and does return a real count ({"deleted": 0}). The defensive handling
was unnecessary but harmless (correctly treats 0 as a real count, not
"no data") and stays in as a safety net for other Synapse versions.
Verified end-to-end on talkhub/matrix: purge call returns
{"deleted":0}, audit log gets a proper system_admin.media_cache.purged
entry (organization_id: null), non-admin gets 403, a future cutoff
date is rejected with 400. Test accounts/audit rows cleaned up.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
This commit is contained in:
1 parent
af0562e524
commit
509194d3e6
7 files changed
+383
-6
No files matched your search
@@ -5,7 +5,13 @@ import { db } from "../db/index.js";
|
||||
import { chatRooms, organizations, teams, pendingRoomDeletions } from "../db/schema.js";
|
||||
import { requireSystemAdmin } from "../lib/authz.js";
|
||||
import { logAudit } from "../lib/audit.js";
|
||||
import { SynapseError, deleteRoom, getRoomDeleteStatus, listRooms } from "../services/synapse.js";
|
||||
import {
|
||||
SynapseError,
|
||||
deleteRoom,
|
||||
getRoomDeleteStatus,
|
||||
listRooms,
|
||||
purgeMediaCache,
|
||||
} from "../services/synapse.js";
|
||||
|
||||
/**
|
||||
* PROJ-14: Matrix room IDs look like `!abc123:example.org`. Validating the shape
|
||||
@@ -26,6 +32,22 @@ const listQuerySchema = z.object({
|
||||
|
||||
const roomParamsSchema = z.object({ roomId: matrixRoomIdSchema });
|
||||
|
||||
/**
|
||||
* PROJ-15: cut-off for the media cache purge. Everything cached *before* this
|
||||
* instant is dropped, so a future timestamp would mean "purge the entire
|
||||
* cache" — rejected here rather than silently accepted, because the frontend's
|
||||
* confirmation dialog would then be describing a different action than the one
|
||||
* actually performed.
|
||||
*/
|
||||
const purgeMediaSchema = z.object({
|
||||
before: z
|
||||
.string()
|
||||
.datetime({ offset: true })
|
||||
.refine((value) => Date.parse(value) <= Date.now(), {
|
||||
message: "Der Stichtag darf nicht in der Zukunft liegen.",
|
||||
}),
|
||||
});
|
||||
|
||||
interface LocalAssignment {
|
||||
chatRoomId: string;
|
||||
matrixRoomId: string;
|
||||
@@ -274,4 +296,67 @@ export async function systemAdminRoutes(app: FastifyInstance) {
|
||||
return { deleted: true, hadLocalAssignment: local !== undefined };
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* PROJ-15: purges Synapse's cache of *remote* (federated) media older than
|
||||
* the given cut-off.
|
||||
*
|
||||
* Deliberately narrow: there is no "free up disk space" endpoint here, and no
|
||||
* per-file delete (PROJ-15 non-goal). Local uploads — including the profile
|
||||
* pictures members set through Element — are untouched by this, and since
|
||||
* TalkTime runs with federation disabled the realistic result is often
|
||||
* 0 files. The frontend states this plainly; the endpoint is kept honest to
|
||||
* match rather than dressed up as a general cleanup.
|
||||
*
|
||||
* There is no read-side companion (`GET .../media/summary` from the spec's
|
||||
* acceptance criteria): the tech design dropped the pre-purge size figure
|
||||
* because the Synapse admin API offers no version-independent way to obtain
|
||||
* it, and the spec's own edge case prefers showing no number to showing a
|
||||
* possibly wrong one.
|
||||
*/
|
||||
app.post(
|
||||
"/api/system-admin/media/purge-cache",
|
||||
{
|
||||
preHandler: [app.authenticate, requireSystemAdmin],
|
||||
// Same reasoning as the room delete: a destructive Synapse-side action
|
||||
// gets a tighter limit than the global 100/min.
|
||||
config: { rateLimit: { max: 10, timeWindow: "1 minute" } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsed = purgeMediaSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
||||
}
|
||||
const beforeTs = Date.parse(parsed.data.before);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await purgeMediaCache(beforeTs);
|
||||
} catch (err) {
|
||||
app.log.error({ err, beforeTs }, "PROJ-15: failed to purge media cache at Synapse");
|
||||
return reply
|
||||
.code(502)
|
||||
.send({ error: "Der Medien-Cache konnte bei Synapse nicht aufgeräumt werden." });
|
||||
}
|
||||
|
||||
// Audited only on success — a failed purge changed nothing, and logging
|
||||
// it as a completed action would make the audit trail misleading.
|
||||
await logAudit({
|
||||
// Platform-level action with no club context, like the room purge.
|
||||
organizationId: null,
|
||||
actorUserId: request.user.sub,
|
||||
action: "system_admin.media_cache.purged",
|
||||
targetType: "synapse_media_cache",
|
||||
targetId: new Date(beforeTs).toISOString(),
|
||||
metadata: { beforeTs, deleted: result.deleted },
|
||||
});
|
||||
|
||||
return {
|
||||
// null means "Synapse did not report a count", which the frontend must
|
||||
// render as "Aufräumen abgeschlossen" without a figure — not as 0.
|
||||
deleted: result.deleted,
|
||||
before: new Date(beforeTs).toISOString(),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -291,6 +291,43 @@ export async function getRoomDeleteStatus(deleteId: string): Promise<RoomDeleteS
|
||||
return { status: result.status, error: result.error ?? null };
|
||||
}
|
||||
|
||||
export interface PurgeMediaCacheResult {
|
||||
/**
|
||||
* Number of cached media files Synapse reports having removed, or null when
|
||||
* the deployed version answers without a usable count.
|
||||
*
|
||||
* PROJ-15 open question from the tech design ("does the purge report a
|
||||
* count?"): could not be verified against the live instance from the dev
|
||||
* environment, so this deliberately stays nullable rather than defaulting to
|
||||
* 0 — reporting "0 files removed" when the server simply didn't say would be
|
||||
* a wrong number, and the spec's edge case explicitly prefers "no figure"
|
||||
* over "a figure that might be false".
|
||||
*/
|
||||
deleted: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PROJ-15: purges the *remote* (federated) media cache older than `beforeTs`.
|
||||
*
|
||||
* Scope caveat, repeated here because it is easy to misread at the call site:
|
||||
* this does NOT touch locally uploaded media (e.g. the profile pictures members
|
||||
* set via Element). It only drops copies of files fetched from other
|
||||
* homeservers. With federation disabled it will usually report 0.
|
||||
*
|
||||
* Unlike the v2 room delete, this endpoint is synchronous — it returns once the
|
||||
* purge is done, so there is no delete_id to poll. `before_ts` is a query
|
||||
* parameter (milliseconds since epoch), not a JSON body field.
|
||||
*/
|
||||
export async function purgeMediaCache(beforeTs: number): Promise<PurgeMediaCacheResult> {
|
||||
const result = await synapseFetch<{ deleted?: number } | undefined>(
|
||||
`/_synapse/admin/v1/purge_media_cache?before_ts=${encodeURIComponent(String(beforeTs))}`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
return {
|
||||
deleted: typeof result?.deleted === "number" ? result.deleted : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function addUserToRoom(roomId: string, userId: string): Promise<void> {
|
||||
await synapseFetch(`/_synapse/admin/v1/join/${encodeURIComponent(roomId)}`, {
|
||||
method: "POST",
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
| 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 | 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) | In Progress | [PROJ-15-media-cleanup.md](PROJ-15-media-cleanup.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) | 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 |
|
||||
| PROJ-18 | Reports/Moderation (System-Admin) | Planned | [PROJ-18-reports-moderation.md](PROJ-18-reports-moderation.md) | 2026-09-12 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PROJ-15: Media-Cleanup (System-Admin)
|
||||
|
||||
**Status:** Planned
|
||||
**Status:** Deployed
|
||||
**Created:** 2026-09-12
|
||||
|
||||
## Summary
|
||||
@@ -71,3 +71,15 @@ Keine — nutzt die bestehende Synapse-Anbindung.
|
||||
|
||||
### Offene Punkte für den Backend-Skill
|
||||
Vor der Umsetzung gegen die tatsächlich eingesetzte Synapse-Version prüfen (Lehre aus PROJ-14): ob der Purge-Aufruf synchron oder asynchron antwortet, und ob eine Rückmeldung zur Anzahl/Größe der aufgeräumten Medien überhaupt mitgeliefert wird. Falls nicht, zeigt die Seite nur "Aufräumen angestoßen" statt einer Zahl, die es nicht gibt.
|
||||
|
||||
### Umsetzung (Backend-Skill, 2026-09-13)
|
||||
|
||||
- `POST /api/system-admin/media/purge-cache` (SYSTEM_ADMIN-only, Rate-Limit 10/min), Frontend-Seite `/system-admin/media`. Keine DB-Änderung, keine Migration, keine neuen Pakete.
|
||||
- **Abweichung von den Acceptance Criteria:** `GET /api/system-admin/media/summary` wurde **nicht** gebaut — das Tech Design (Abschnitt C, "Keine Vorab-Größenanzeige") verwirft die Größenanzeige bewusst. Das UI kommuniziert das Fehlen explizit.
|
||||
- **Abweichung:** Audit-Action heißt `system_admin.media_cache.purged` statt `system.media_purged` (Konsistenz mit dem bestehenden `system_admin.*`-Namensraum aus PROJ-14). `organizationId: null`, wie beim Raum-Purge.
|
||||
- **Offener Punkt aus dem Tech Design — jetzt live geklärt:** Direkt gegen `matrix.perlbach24.de` getestet (Assistant hatte Zugriff, der Backend-Agent nicht): `POST /_synapse/admin/v1/purge_media_cache` antwortet **synchron** (~1,1s) und liefert tatsächlich `{"deleted": 0}` — eine echte Zahl, kein fehlendes Feld. Die defensive `number | null`-Behandlung im Code war also nicht nötig, ist aber unschädlich (`typeof result?.deleted === "number"` behandelt `0` korrekt, kein `!result?.deleted`-Fallstrick) und bleibt als Absicherung für andere Synapse-Versionen drin.
|
||||
|
||||
**Ende-zu-Ende auf `talkhub`/`matrix` verifiziert:** Purge-Aufruf liefert `{"deleted":0,"before":"…"}` (200), Audit-Log-Eintrag `system_admin.media_cache.purged` mit `organization_id: null` erscheint korrekt, Non-Admin bekommt `403`, ein Stichtag in der Zukunft wird mit `400` abgelehnt. Testdaten aufgeräumt.
|
||||
|
||||
### Status
|
||||
**Production-ready: JA.**
|
||||
@@ -16,6 +16,7 @@ import { RolesPage } from "@/pages/RolesPage";
|
||||
import { AuditLogPage } from "@/pages/AuditLogPage";
|
||||
import { AcceptInvitationPage } from "@/pages/AcceptInvitationPage";
|
||||
import { SystemAdminRoomsPage } from "@/pages/SystemAdminRoomsPage";
|
||||
import { SystemAdminMediaPage } from "@/pages/SystemAdminMediaPage";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -40,6 +41,8 @@ function App() {
|
||||
<Route element={<SystemAdminRoute />}>
|
||||
<Route path="/system-admin" element={<Navigate to="/system-admin/rooms" replace />} />
|
||||
<Route path="/system-admin/rooms" element={<SystemAdminRoomsPage />} />
|
||||
{/* PROJ-15 */}
|
||||
<Route path="/system-admin/media" element={<SystemAdminMediaPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
|
||||
/** `YYYY-MM-DD` for an <input type="date">, `days` ago in local time. */
|
||||
function dateInputValue(daysAgo: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - daysAgo);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const DEFAULT_DAYS = 30;
|
||||
/**
|
||||
* Below this, the chosen cut-off is treated as unusually aggressive and the
|
||||
* confirmation dialog says so explicitly (PROJ-15 edge case).
|
||||
*/
|
||||
const AGGRESSIVE_DAYS = 7;
|
||||
|
||||
function daysBetween(dateValue: string): number {
|
||||
const chosen = new Date(`${dateValue}T00:00:00`).getTime();
|
||||
return Math.round((Date.now() - chosen) / 86_400_000);
|
||||
}
|
||||
|
||||
interface PurgeResult {
|
||||
deleted: number | null;
|
||||
before: string;
|
||||
}
|
||||
|
||||
export function SystemAdminMediaPage() {
|
||||
const { token } = useAuth();
|
||||
const [before, setBefore] = useState(() => dateInputValue(DEFAULT_DAYS));
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [purging, setPurging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<PurgeResult | null>(null);
|
||||
|
||||
const today = dateInputValue(0);
|
||||
const ageInDays = daysBetween(before);
|
||||
// The backend rejects future cut-offs; catch it here too so the confirmation
|
||||
// dialog can't describe an action the server will refuse.
|
||||
const isFuture = before > today;
|
||||
const isAggressive = !isFuture && ageInDays < AGGRESSIVE_DAYS;
|
||||
|
||||
async function onPurgeConfirmed() {
|
||||
if (!token || isFuture) return;
|
||||
setPurging(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/system-admin/media/purge-cache", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ before: new Date(`${before}T00:00:00`).toISOString() }),
|
||||
});
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
| (PurgeResult & { error?: string })
|
||||
| null;
|
||||
if (!res.ok) {
|
||||
throw new Error(body?.error ?? `Aufräumen fehlgeschlagen (${res.status})`);
|
||||
}
|
||||
setConfirmOpen(false);
|
||||
setResult({ deleted: body?.deleted ?? null, before: body?.before ?? before });
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setPurging(false);
|
||||
}
|
||||
}
|
||||
|
||||
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">Medien</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Wartung des Synapse-Medien-Zwischenspeichers.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/system-admin/rooms">Matrix-Räume</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/">Zurück zum Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Honest expectation-setting is a deliberate requirement of this feature,
|
||||
not filler text: the underlying Synapse action is far narrower than
|
||||
"Speicherplatz freigeben" and would otherwise be badly misread. */}
|
||||
<Alert>
|
||||
<AlertDescription className="space-y-2">
|
||||
<p>
|
||||
Diese Aktion leert ausschließlich den Zwischenspeicher für Medien{" "}
|
||||
<strong>anderer, föderierter Matrix-Server</strong>.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Nicht</strong> betroffen sind lokal hochgeladene Dateien — dazu gehören auch die
|
||||
Profilbilder, die Mitglieder über Element setzen. Da bei TalkTime die Föderation
|
||||
deaktiviert ist, werden voraussichtlich kaum oder gar keine Dateien gefunden.
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Eine Vorschau des freiwerdenden Speicherplatzes ist nicht verfügbar: Synapse liefert
|
||||
dafür keine versionsübergreifend verlässliche Angabe. Statt einer möglicherweise
|
||||
falschen Zahl wird hier keine angezeigt.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Card className="max-w-xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Föderations-Medien-Cache aufräumen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="before">Medien-Cache älter als</Label>
|
||||
<Input
|
||||
id="before"
|
||||
type="date"
|
||||
value={before}
|
||||
max={today}
|
||||
onChange={(e) => {
|
||||
setBefore(e.target.value);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isFuture
|
||||
? "Der Stichtag darf nicht in der Zukunft liegen."
|
||||
: `Alles, was vor diesem Datum zwischengespeichert wurde, wird entfernt (${ageInDays} Tage).`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && !confirmOpen && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{/* null ≠ 0: the backend passes through "Synapse reported no
|
||||
count", which must not be rendered as "0 Dateien". */}
|
||||
{result.deleted === null
|
||||
? "Aufräumen abgeschlossen. Synapse hat keine Anzahl zurückgemeldet."
|
||||
: `Aufräumen abgeschlossen: ${result.deleted} Datei(en) entfernt.`}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setConfirmOpen(true);
|
||||
}}
|
||||
disabled={isFuture || !token}
|
||||
>
|
||||
Aufräumen
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !purging) {
|
||||
setConfirmOpen(false);
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Medien-Cache aufräumen?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Alle föderierten Medien, die vor dem{" "}
|
||||
<strong>{new Date(`${before}T00:00:00`).toLocaleDateString("de-DE")}</strong>{" "}
|
||||
zwischengespeichert wurden, werden bei Synapse entfernt. Lokale Uploads und
|
||||
Profilbilder bleiben erhalten.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{isAggressive && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
Der gewählte Stichtag liegt nur {ageInDays} Tag(e) zurück. Das entfernt auch sehr
|
||||
frisch zwischengespeicherte Medien, die kurz darauf erneut geladen werden müssen.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={purging}>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(event) => {
|
||||
// Keep the dialog open while the request runs so errors stay visible.
|
||||
event.preventDefault();
|
||||
void onPurgeConfirmed();
|
||||
}}
|
||||
disabled={purging}
|
||||
>
|
||||
{purging ? "Räumt auf…" : "Jetzt aufräumen"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -109,9 +109,15 @@ export function SystemAdminRoomsPage() {
|
||||
Alle Räume der angebundenen Synapse-Instanz, vereinsübergreifend.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/">Zurück zum Dashboard</Link>
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
{/* PROJ-15: second system-admin subpage. */}
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/system-admin/media">Medien</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/">Zurück zum Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSearchSubmit} className="flex gap-2" role="search">
|
||||
|
||||
Reference in new issue
Block a user