`DELETE /host/upload/{id}` and `DELETE /host/comment/{id}` were complete on the
backend — transactional, SSE-broadcasting, audit-logged — and had zero frontend
callers. The feed context sheet offered "Löschen" only when
`target.user_id === myUserId`, so the only lever a host actually had against an
unwanted photo was banning the uploader.
That is both disproportionate and ineffective. A ban doesn't retract what was
already posted, and it makes things strictly worse for comments: the ban check
runs BEFORE the ownership check on the guest delete route, so banning an abusive
author leaves their comment on screen and permanently undeletable by them. With
no host affordance, nobody could remove it at all.
- feed: hosts/admins get "Beitrag entfernen" on other people's posts, routed to
the host endpoint (the guest route 403s anything the caller doesn't own) with
moderation-specific confirm copy. Own-post "Löschen" is unchanged.
- lightbox: same for comments, via /host/comment/{id}.
- Ban semantics are deliberately untouched (USER_JOURNEYS §10 — banned users keep
read access and cannot write). The deadlock is broken by giving the host a way
in, not by loosening the ban.
Live role (this had to come first). `getRole()` decodes the JWT claim, but the
token is never reissued — the backend slides the session row forward and treats
the DB row as authoritative. The claim is therefore frozen for the token's
lifetime: up to 30 days. A guest promoted at the party saw no Host-Dashboard and
no moderation actions until they signed out and back in, even though
`/me/context` had been returning their real role on every page load and 4 of its
6 call sites dropped the field on the floor.
Add `role-store.ts`: seeded from the claim so there's no flash of the wrong nav,
then corrected by every `/me/context` response. Point the ad-hoc `getRole()`
callers at it (account, upload, host, admin, and the new feed gate). The host and
admin dashboards now derive `myRole` reactively, so a demotion disables their
controls immediately instead of at next login.
Tests: 04-host/moderation-ui drives the real UI — host removes a guest photo and
it's gone from /feed server-side; a plain guest is offered nothing on someone
else's post (the mirror that keeps the first test honest); a promoted guest gains
the dashboard on reload while their token still carries `role: guest`; and a host
removes the comment of an already-banned guest, asserting first that the author's
own delete 403s so the deadlock is real.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1131 lines
38 KiB
Svelte
1131 lines
38 KiB
Svelte
<script lang="ts">
|
||
import { goto } from '$app/navigation';
|
||
import { getToken } from '$lib/auth';
|
||
import { role as myRoleStore } from '$lib/role-store';
|
||
import { api } from '$lib/api';
|
||
import { onMount } from 'svelte';
|
||
import { toast, toastError } from '$lib/toast-store';
|
||
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
|
||
import Modal from '$lib/components/Modal.svelte';
|
||
import IconButton from '$lib/components/IconButton.svelte';
|
||
import { PRESETS, DEFAULT_SEED, buildPaletteCss, type ThemeConfig } from '$lib/theme/palette';
|
||
import {
|
||
previewTheme,
|
||
PALETTE_CACHE_KEY,
|
||
loadEventConfig,
|
||
commentsEnabled
|
||
} from '$lib/event-config-store';
|
||
import { onDestroy } from 'svelte';
|
||
|
||
interface StatsDto {
|
||
user_count: number;
|
||
upload_count: number;
|
||
comment_count: number;
|
||
disk_total_bytes: number;
|
||
disk_used_bytes: number;
|
||
disk_free_bytes: number;
|
||
}
|
||
|
||
interface ExportJob {
|
||
id: string;
|
||
type: string;
|
||
status: string;
|
||
progress_pct: number;
|
||
error_message: string | null;
|
||
created_at: string;
|
||
completed_at: string | null;
|
||
}
|
||
|
||
interface UserSummary {
|
||
id: string;
|
||
display_name: string;
|
||
role: string;
|
||
is_banned: boolean;
|
||
uploads_hidden: boolean;
|
||
upload_count: number;
|
||
total_upload_bytes: number;
|
||
created_at: string;
|
||
}
|
||
|
||
type ConfigKind = 'number' | 'bool' | 'text';
|
||
interface ConfigField {
|
||
key: string;
|
||
label: string;
|
||
kind: ConfigKind;
|
||
hint?: string;
|
||
}
|
||
interface ConfigGroup {
|
||
title: string;
|
||
fields: ConfigField[];
|
||
}
|
||
|
||
// Grouped sections — adding a new key is one entry in the right group, no other
|
||
// code changes required. The form renders each field based on `kind`.
|
||
const CONFIG_GROUPS: ConfigGroup[] = [
|
||
{
|
||
title: 'Limits & Größen',
|
||
fields: [
|
||
{ key: 'max_image_size_mb', label: 'Max. Bildgröße (MB)', kind: 'number' },
|
||
{ key: 'max_video_size_mb', label: 'Max. Videogröße (MB)', kind: 'number' }
|
||
// compression_concurrency is set via COMPRESSION_WORKER_CONCURRENCY at
|
||
// boot, not live — omitted so it isn't a dead no-op control.
|
||
]
|
||
},
|
||
{
|
||
title: 'Rate-Limits',
|
||
fields: [
|
||
{
|
||
key: 'rate_limits_enabled',
|
||
label: 'Rate-Limits aktiv',
|
||
kind: 'bool',
|
||
hint: 'Hauptschalter — wenn aus, sind alle Rate-Limits deaktiviert.'
|
||
},
|
||
{ key: 'upload_rate_enabled', label: 'Upload-Limit aktiv', kind: 'bool' },
|
||
{ key: 'feed_rate_enabled', label: 'Feed-Limit aktiv', kind: 'bool' },
|
||
{ key: 'export_rate_enabled', label: 'Export-Limit aktiv', kind: 'bool' },
|
||
{ key: 'join_rate_enabled', label: 'Join-Limit aktiv', kind: 'bool' },
|
||
{ key: 'upload_rate_per_hour', label: 'Upload-Limit pro Stunde', kind: 'number' },
|
||
{ key: 'feed_rate_per_min', label: 'Feed-Anfragen pro Minute', kind: 'number' },
|
||
{ key: 'export_rate_per_day', label: 'Export-Downloads pro Tag', kind: 'number' }
|
||
]
|
||
},
|
||
{
|
||
title: 'Quoten',
|
||
fields: [
|
||
{
|
||
key: 'quota_enabled',
|
||
label: 'Quoten aktiv',
|
||
kind: 'bool',
|
||
hint: 'Hauptschalter — wenn aus, wird nichts geprüft.'
|
||
},
|
||
{ key: 'storage_quota_enabled', label: 'Speicher-Quote aktiv', kind: 'bool' },
|
||
{
|
||
key: 'upload_count_quota_enabled',
|
||
label: 'Upload-Anzahl-Quote aktiv',
|
||
kind: 'bool',
|
||
hint: 'Reserviert für künftige Anzahl-Limits.'
|
||
},
|
||
{ key: 'quota_tolerance', label: 'Toleranz (0–1)', kind: 'number' },
|
||
{ key: 'estimated_guest_count', label: 'Geschätzte Gästezahl', kind: 'number' }
|
||
]
|
||
},
|
||
{
|
||
title: 'Datenschutzhinweis',
|
||
fields: [
|
||
{
|
||
key: 'privacy_note',
|
||
label: 'Datenschutzhinweis (freier Text)',
|
||
kind: 'text',
|
||
hint: 'Wird wörtlich im Konto-Bereich angezeigt. Kein HTML — Leerzeichen und Zeilenumbrüche werden übernommen.'
|
||
}
|
||
]
|
||
}
|
||
];
|
||
|
||
function isTrue(v: string | undefined): boolean {
|
||
if (!v) return false;
|
||
return ['true', '1', 'yes', 'on'].includes(v.trim().toLowerCase());
|
||
}
|
||
|
||
function toggleBool(key: string) {
|
||
configDraft = { ...configDraft, [key]: isTrue(configDraft[key]) ? 'false' : 'true' };
|
||
}
|
||
|
||
// ── Colour theme ─────────────────────────────────────────────────────────
|
||
// The theme lives in the same `config` table but needs a bespoke UI (swatches +
|
||
// pickers + live preview) rather than the generic field renderer. Selecting a
|
||
// preset or nudging a colour recolours the whole admin page instantly (preview);
|
||
// Save persists it and refreshes every open client via the event-updated SSE.
|
||
let themePreset = $state('champagne-gold');
|
||
let themePrimary = $state(DEFAULT_SEED);
|
||
let themeAccent = $state(DEFAULT_SEED);
|
||
let themeSaving = $state(false);
|
||
// The persisted theme, so leaving without saving reverts the live preview.
|
||
let savedTheme: ThemeConfig = {
|
||
preset: 'champagne-gold',
|
||
primary: DEFAULT_SEED,
|
||
accent: DEFAULT_SEED
|
||
};
|
||
|
||
function currentTheme(): ThemeConfig {
|
||
return { preset: themePreset, primary: themePrimary, accent: themeAccent };
|
||
}
|
||
|
||
async function loadTheme() {
|
||
try {
|
||
const b = await api.get<{
|
||
theme_preset: string;
|
||
theme_primary: string;
|
||
theme_accent: string;
|
||
}>('/event');
|
||
themePreset = b.theme_preset ?? 'champagne-gold';
|
||
themePrimary = b.theme_primary ?? DEFAULT_SEED;
|
||
themeAccent = b.theme_accent ?? DEFAULT_SEED;
|
||
savedTheme = currentTheme();
|
||
} catch {
|
||
/* keep defaults — the theme card just shows champagne-gold */
|
||
}
|
||
}
|
||
|
||
function selectPreset(id: string) {
|
||
themePreset = id;
|
||
if (id !== 'custom') {
|
||
const p = PRESETS.find((x) => x.id === id);
|
||
if (p) {
|
||
themePrimary = p.primary;
|
||
themeAccent = p.accent;
|
||
}
|
||
}
|
||
previewTheme(currentTheme());
|
||
}
|
||
|
||
// A colour picker moved → force preset to 'custom' and preview live.
|
||
function onCustomColor() {
|
||
themePreset = 'custom';
|
||
previewTheme(currentTheme());
|
||
}
|
||
|
||
async function saveTheme() {
|
||
themeSaving = true;
|
||
try {
|
||
const theme = currentTheme();
|
||
await api.patch('/admin/config', {
|
||
theme_preset: theme.preset,
|
||
theme_primary: theme.primary,
|
||
theme_accent: theme.accent
|
||
});
|
||
savedTheme = theme;
|
||
try {
|
||
localStorage.setItem(PALETTE_CACHE_KEY, buildPaletteCss(theme));
|
||
} catch {
|
||
/* non-fatal */
|
||
}
|
||
toast('Farbschema gespeichert.', 'success');
|
||
} catch (e: unknown) {
|
||
toastError(e);
|
||
} finally {
|
||
themeSaving = false;
|
||
}
|
||
}
|
||
|
||
function resetTheme() {
|
||
selectPreset('champagne-gold');
|
||
}
|
||
|
||
const themeDirty = $derived(
|
||
themePreset !== savedTheme.preset ||
|
||
themePrimary.toLowerCase() !== savedTheme.primary.toLowerCase() ||
|
||
themeAccent.toLowerCase() !== savedTheme.accent.toLowerCase()
|
||
);
|
||
|
||
// Leaving the admin page without saving must not strand an unsaved preview on the
|
||
// rest of the app — revert to the persisted theme. loadEventConfig re-applies the
|
||
// authoritative palette + cache.
|
||
onDestroy(() => {
|
||
void loadEventConfig();
|
||
});
|
||
|
||
type AdminTab = 'stats' | 'config' | 'export' | 'users';
|
||
const TAB_LABELS: Record<AdminTab, string> = {
|
||
stats: 'Stats',
|
||
config: 'Config',
|
||
export: 'Export',
|
||
users: 'Nutzer'
|
||
};
|
||
|
||
let activeTab = $state<AdminTab>('stats');
|
||
|
||
let stats = $state<StatsDto | null>(null);
|
||
let config = $state<Record<string, string>>({});
|
||
let configDraft = $state<Record<string, string>>({});
|
||
let exportJobs = $state<ExportJob[]>([]);
|
||
let users = $state<UserSummary[]>([]);
|
||
let loading = $state(true);
|
||
let saving = $state(false);
|
||
let error = $state<string | null>(null);
|
||
let exportJobsRefreshing = $state(false);
|
||
|
||
// Nutzer tab state
|
||
let userSearch = $state('');
|
||
let filteredUsers = $derived(
|
||
userSearch.trim()
|
||
? users.filter((u) => u.display_name.toLowerCase().includes(userSearch.toLowerCase()))
|
||
: users
|
||
);
|
||
|
||
// Ban modal state
|
||
let banTarget = $state<UserSummary | null>(null);
|
||
let banSubmitting = $state(false);
|
||
|
||
// PIN reset state — `pinModal` holds the freshly-issued plaintext PIN. We forget it
|
||
// the moment the modal closes.
|
||
let pinResetTarget = $state<UserSummary | null>(null);
|
||
let pinResetSubmitting = $state(false);
|
||
let pinModal = $state<{ name: string; pin: string } | null>(null);
|
||
|
||
// Live role, not the frozen JWT claim: a demotion must disable these controls at once.
|
||
const myRole = $derived($myRoleStore);
|
||
|
||
// Generic confirm-then-run for irreversible / privilege-changing actions
|
||
// (promote, demote, unban, release gallery). Reuses the shared ConfirmSheet.
|
||
interface PendingConfirm {
|
||
title: string;
|
||
message: string;
|
||
confirmLabel: string;
|
||
tone: 'default' | 'danger';
|
||
run: () => Promise<void>;
|
||
}
|
||
let confirmAction = $state<PendingConfirm | null>(null);
|
||
|
||
async function runConfirmAction() {
|
||
const action = confirmAction;
|
||
if (!action) return;
|
||
await action.run();
|
||
confirmAction = null;
|
||
}
|
||
|
||
onMount(async () => {
|
||
const token = getToken();
|
||
if (!token) {
|
||
goto('/admin/login');
|
||
return;
|
||
}
|
||
// Trust the *live* role, not the JWT claim: a mid-session demote leaves a
|
||
// stale 'admin' in the token, but the backend now 403s every admin call.
|
||
// Bounce a demoted admin to the feed instead of leaving them on a dashboard
|
||
// that errors on load (mirrors the host page).
|
||
try {
|
||
const ctx = await api.get<{ role: string }>('/me/context');
|
||
if (ctx.role !== 'admin') {
|
||
goto('/feed');
|
||
return;
|
||
}
|
||
} catch {
|
||
// Expired/invalid session (api.ts cleared it) — send them to re-auth.
|
||
goto('/admin/login');
|
||
return;
|
||
}
|
||
await reload();
|
||
});
|
||
|
||
async function reload() {
|
||
loading = true;
|
||
error = null;
|
||
try {
|
||
[stats, config, exportJobs, users] = await Promise.all([
|
||
api.get<StatsDto>('/admin/stats'),
|
||
api.get<Record<string, string>>('/admin/config'),
|
||
api.get<ExportJob[]>('/admin/export/jobs'),
|
||
api.get<UserSummary[]>('/host/users')
|
||
]);
|
||
configDraft = { ...config };
|
||
void loadTheme();
|
||
} catch (e: unknown) {
|
||
error = e instanceof Error ? e.message : 'Fehler beim Laden.';
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
}
|
||
|
||
async function refreshExportJobs() {
|
||
exportJobsRefreshing = true;
|
||
try {
|
||
exportJobs = await api.get<ExportJob[]>('/admin/export/jobs');
|
||
} finally {
|
||
exportJobsRefreshing = false;
|
||
}
|
||
}
|
||
|
||
// Keys rendered as number inputs — used to reject empty/invalid numeric saves.
|
||
const NUMBER_KEYS = new Set(
|
||
CONFIG_GROUPS.flatMap((g) => g.fields.filter((f) => f.kind === 'number').map((f) => f.key))
|
||
);
|
||
|
||
async function saveConfig() {
|
||
// Don't let a cleared number field persist as an empty/NaN config value.
|
||
for (const key of NUMBER_KEYS) {
|
||
const v = configDraft[key];
|
||
if (
|
||
v !== undefined &&
|
||
v !== config[key] &&
|
||
(String(v).trim() === '' || !Number.isFinite(Number(v)))
|
||
) {
|
||
toastError(new Error('Bitte gib für alle Zahlenfelder einen gültigen Wert ein.'));
|
||
return;
|
||
}
|
||
}
|
||
saving = true;
|
||
try {
|
||
const changes: Record<string, string> = {};
|
||
for (const key of Object.keys(configDraft)) {
|
||
if (configDraft[key] !== config[key]) {
|
||
changes[key] = String(configDraft[key]);
|
||
}
|
||
}
|
||
if (Object.keys(changes).length === 0) {
|
||
toast('Keine Änderungen.', 'info');
|
||
return;
|
||
}
|
||
await api.patch('/admin/config', changes);
|
||
config = { ...configDraft };
|
||
toast('Konfiguration gespeichert.', 'success');
|
||
} catch (e: unknown) {
|
||
toastError(e);
|
||
} finally {
|
||
saving = false;
|
||
}
|
||
}
|
||
|
||
async function releaseGallery() {
|
||
try {
|
||
await api.post('/host/gallery/release');
|
||
toast('Galerie wurde freigegeben. Export wird vorbereitet…', 'success');
|
||
} catch (e: unknown) {
|
||
toastError(e);
|
||
}
|
||
}
|
||
|
||
function openBanModal(user: UserSummary) {
|
||
banTarget = user;
|
||
}
|
||
|
||
async function confirmBan() {
|
||
if (!banTarget) return;
|
||
banSubmitting = true;
|
||
try {
|
||
await api.post(`/host/users/${banTarget.id}/ban`, {});
|
||
toast(`${banTarget.display_name} wurde gesperrt.`, 'success');
|
||
banTarget = null;
|
||
users = await api.get<UserSummary[]>('/host/users');
|
||
} catch (e: unknown) {
|
||
toastError(e);
|
||
} finally {
|
||
banSubmitting = false;
|
||
}
|
||
}
|
||
|
||
async function unban(user: UserSummary) {
|
||
try {
|
||
await api.post(`/host/users/${user.id}/unban`);
|
||
toast(`Sperre für ${user.display_name} aufgehoben.`, 'success');
|
||
users = await api.get<UserSummary[]>('/host/users');
|
||
} catch (e: unknown) {
|
||
toastError(e);
|
||
}
|
||
}
|
||
|
||
async function promoteToHost(user: UserSummary) {
|
||
try {
|
||
await api.patch(`/host/users/${user.id}/role`, { role: 'host' });
|
||
toast(`${user.display_name} ist jetzt Host.`, 'success');
|
||
users = await api.get<UserSummary[]>('/host/users');
|
||
} catch (e: unknown) {
|
||
toastError(e);
|
||
}
|
||
}
|
||
|
||
async function demoteToGuest(user: UserSummary) {
|
||
try {
|
||
await api.patch(`/host/users/${user.id}/role`, { role: 'guest' });
|
||
toast(`${user.display_name} ist jetzt Gast.`, 'success');
|
||
users = await api.get<UserSummary[]>('/host/users');
|
||
} catch (e: unknown) {
|
||
toastError(e);
|
||
}
|
||
}
|
||
|
||
function askResetPin(user: UserSummary) {
|
||
pinResetTarget = user;
|
||
}
|
||
|
||
async function confirmResetPin() {
|
||
if (!pinResetTarget) return;
|
||
pinResetSubmitting = true;
|
||
try {
|
||
const res = await api.post<{ pin: string }>(`/host/users/${pinResetTarget.id}/pin-reset`);
|
||
pinModal = { name: pinResetTarget.display_name, pin: res.pin };
|
||
pinResetTarget = null;
|
||
} catch (e: unknown) {
|
||
toastError(e);
|
||
} finally {
|
||
pinResetSubmitting = false;
|
||
}
|
||
}
|
||
|
||
function copyPinModal() {
|
||
if (!pinModal) return;
|
||
navigator.clipboard.writeText(pinModal.pin);
|
||
toast('PIN kopiert.', 'success');
|
||
}
|
||
|
||
/** True iff the current caller may reset this target's PIN. Mirrors the backend
|
||
* rules in `handlers::host::reset_user_pin`. */
|
||
function canResetPinFor(target: UserSummary): boolean {
|
||
if (target.role === 'admin') return false;
|
||
if (myRole === 'admin') return true; // any non-admin
|
||
if (myRole === 'host') return target.role === 'guest';
|
||
return false;
|
||
}
|
||
|
||
function formatBytes(bytes: number): string {
|
||
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
||
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||
}
|
||
|
||
function diskPct(s: StatsDto): number {
|
||
if (s.disk_total_bytes === 0) return 0;
|
||
return Math.round((s.disk_used_bytes / s.disk_total_bytes) * 100);
|
||
}
|
||
|
||
function jobLabel(type: string): string {
|
||
return type === 'zip' ? 'ZIP-Archiv' : 'HTML-Viewer';
|
||
}
|
||
|
||
function statusBadgeClass(status: string): string {
|
||
switch (status) {
|
||
case 'done':
|
||
return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-200';
|
||
case 'running':
|
||
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200';
|
||
case 'failed':
|
||
return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200';
|
||
default:
|
||
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
|
||
}
|
||
}
|
||
|
||
function statusLabel(status: string): string {
|
||
switch (status) {
|
||
case 'pending':
|
||
return 'Ausstehend';
|
||
case 'running':
|
||
return 'Läuft';
|
||
case 'done':
|
||
return 'Fertig';
|
||
case 'failed':
|
||
return 'Fehlgeschlagen';
|
||
default:
|
||
return status;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<!-- Confirmation for irreversible / privilege-changing actions (promote/demote/unban/release). -->
|
||
<ConfirmSheet
|
||
open={confirmAction !== null}
|
||
title={confirmAction?.title ?? ''}
|
||
message={confirmAction?.message ?? ''}
|
||
confirmLabel={confirmAction?.confirmLabel ?? 'Bestätigen'}
|
||
tone={confirmAction?.tone ?? 'default'}
|
||
onConfirm={runConfirmAction}
|
||
onCancel={() => (confirmAction = null)}
|
||
/>
|
||
|
||
<!-- PIN reset confirmation — pure yes/no, uses the shared ConfirmSheet. -->
|
||
<ConfirmSheet
|
||
open={pinResetTarget !== null}
|
||
title="PIN zurücksetzen"
|
||
message={pinResetTarget
|
||
? `Eine neue PIN für ${pinResetTarget.display_name} wird erzeugt. Die alte PIN funktioniert dann nicht mehr.`
|
||
: ''}
|
||
confirmLabel={pinResetSubmitting ? 'Wird erzeugt…' : 'Neue PIN erzeugen'}
|
||
tone="danger"
|
||
onConfirm={confirmResetPin}
|
||
onCancel={() => (pinResetTarget = null)}
|
||
/>
|
||
|
||
<!-- One-time PIN display modal — focus-trapped, aria-modal, Escape-dismissable. -->
|
||
<Modal open={pinModal !== null} titleId="admin-pin-modal-title" onClose={() => (pinModal = null)}>
|
||
{#if pinModal}
|
||
<h2 id="admin-pin-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">
|
||
Neue PIN für {pinModal.name}
|
||
</h2>
|
||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||
Zeige diese PIN dem Benutzer. Sie wird nur einmal angezeigt — beim Schließen wird sie
|
||
verworfen.
|
||
</p>
|
||
<div
|
||
class="mb-4 flex items-center justify-between rounded-lg bg-amber-50 px-4 py-3 dark:bg-amber-950/30"
|
||
>
|
||
<span class="font-mono text-3xl font-bold tracking-widest text-gray-900 dark:text-gray-100"
|
||
>{pinModal.pin}</span
|
||
>
|
||
<button
|
||
onclick={copyPinModal}
|
||
class="rounded-md bg-amber-100 px-3 py-1.5 text-sm font-medium text-amber-800 hover:bg-amber-200 active:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60 dark:active:bg-amber-900/60"
|
||
>
|
||
Kopieren
|
||
</button>
|
||
</div>
|
||
<button onclick={() => (pinModal = null)} class="btn btn-primary btn-block btn-sm">
|
||
Schließen
|
||
</button>
|
||
{/if}
|
||
</Modal>
|
||
|
||
<!-- Ban modal — ban always hides now, so this is a plain confirm (no checkbox). -->
|
||
<Modal open={banTarget !== null} titleId="admin-ban-modal-title" onClose={() => (banTarget = null)}>
|
||
{#if banTarget}
|
||
<h2 id="admin-ban-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">
|
||
Benutzer sperren
|
||
</h2>
|
||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||
<strong>{banTarget.display_name}</strong> wird gesperrt: alle Uploads verschwinden aus Galerie,
|
||
Diashow und Export, und die Sitzung wird beendet. Rückgängig machbar über „Entsperren“.
|
||
</p>
|
||
<div class="flex gap-2">
|
||
<button onclick={() => (banTarget = null)} class="btn btn-secondary btn-sm flex-1"
|
||
>Abbrechen</button
|
||
>
|
||
<button onclick={confirmBan} disabled={banSubmitting} class="btn btn-danger btn-sm flex-1">
|
||
{banSubmitting ? 'Wird gesperrt…' : 'Sperren'}
|
||
</button>
|
||
</div>
|
||
{/if}
|
||
</Modal>
|
||
|
||
<div class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950">
|
||
<!-- Header -->
|
||
<div
|
||
class="border-b border-gray-200 bg-white pt-[env(safe-area-inset-top)] dark:border-gray-800 dark:bg-gray-900"
|
||
>
|
||
<div class="mx-auto flex max-w-3xl items-center gap-3 px-4 py-4">
|
||
<IconButton label="Zurück" onclick={() => goto('/account')} class="shrink-0">
|
||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
|
||
/>
|
||
</svg>
|
||
</IconButton>
|
||
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Admin-Dashboard</h1>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Inner tab bar -->
|
||
<div
|
||
class="sticky top-0 z-20 overflow-x-auto border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
|
||
>
|
||
<div class="mx-auto flex max-w-3xl min-w-max">
|
||
{#each Object.entries(TAB_LABELS) as [tab, label] (tab)}
|
||
<button
|
||
onclick={() => (activeTab = tab as AdminTab)}
|
||
class="px-5 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors
|
||
{activeTab === tab
|
||
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}"
|
||
>
|
||
{label}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mx-auto max-w-3xl p-4">
|
||
{#if loading}
|
||
<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
|
||
{:else if error}
|
||
<div
|
||
role="alert"
|
||
class="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300"
|
||
>
|
||
{error}
|
||
</div>
|
||
{:else}
|
||
<!-- ── Stats tab ────────────────────────────────────────────────── -->
|
||
{#if activeTab === 'stats'}
|
||
<div class="space-y-3">
|
||
{#if stats}
|
||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||
<div class="card p-4 text-center">
|
||
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||
{stats.user_count}
|
||
</p>
|
||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Gäste</p>
|
||
</div>
|
||
<div class="card p-4 text-center">
|
||
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||
{stats.upload_count}
|
||
</p>
|
||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Uploads</p>
|
||
</div>
|
||
{#if $commentsEnabled}
|
||
<div class="card p-4 text-center">
|
||
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||
{stats.comment_count}
|
||
</p>
|
||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Kommentare</p>
|
||
</div>
|
||
{/if}
|
||
<div class="card p-4 text-center">
|
||
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||
{diskPct(stats)} %
|
||
</p>
|
||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Speicher</p>
|
||
</div>
|
||
</div>
|
||
<!-- Disk bar -->
|
||
<div class="card p-5">
|
||
<div
|
||
class="mb-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"
|
||
>
|
||
<span>Speicherauslastung</span>
|
||
<span
|
||
>{formatBytes(stats.disk_used_bytes)} / {formatBytes(
|
||
stats.disk_total_bytes
|
||
)}</span
|
||
>
|
||
</div>
|
||
<div class="h-2.5 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||
<div
|
||
class="h-full rounded-full transition-all {diskPct(stats) >= 90
|
||
? 'bg-red-500'
|
||
: diskPct(stats) >= 75
|
||
? 'bg-amber-500'
|
||
: 'bg-blue-500'}"
|
||
style="width: {diskPct(stats)}%"
|
||
></div>
|
||
</div>
|
||
<p class="mt-1.5 text-xs text-gray-400 dark:text-gray-500">
|
||
{formatBytes(stats.disk_free_bytes)} frei
|
||
</p>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- ── Config tab ───────────────────────────────────────────────── -->
|
||
{:else if activeTab === 'config'}
|
||
<div class="relative space-y-3 pb-20">
|
||
<!-- ── Colour theme ─────────────────────────────────────────── -->
|
||
<div class="card">
|
||
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
|
||
<h3
|
||
class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
|
||
>
|
||
Farbschema
|
||
</h3>
|
||
</div>
|
||
<div class="space-y-4 px-5 py-4">
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||
Wähle eine Vorlage oder eigene Farben. Die Vorschau wird sofort angewendet;
|
||
„Speichern“ übernimmt sie für alle Gäste. Neutrale Töne (Grau/Silber) bleiben
|
||
bewusst unverändert.
|
||
</p>
|
||
|
||
<!-- Preset swatches -->
|
||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||
{#each PRESETS as preset (preset.id)}
|
||
<button
|
||
type="button"
|
||
onclick={() => selectPreset(preset.id)}
|
||
class="flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors {themePreset ===
|
||
preset.id
|
||
? 'border-primary-400 bg-primary-50 dark:bg-primary-950/40'
|
||
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'}"
|
||
aria-pressed={themePreset === preset.id}
|
||
>
|
||
<span
|
||
class="h-5 w-5 shrink-0 rounded-full ring-1 ring-black/10"
|
||
style="background-color: {preset.primary}"
|
||
></span>
|
||
<span class="min-w-0 truncate text-gray-800 dark:text-gray-200"
|
||
>{preset.label}</span
|
||
>
|
||
</button>
|
||
{/each}
|
||
<button
|
||
type="button"
|
||
onclick={() => selectPreset('custom')}
|
||
class="flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors {themePreset ===
|
||
'custom'
|
||
? 'border-primary-400 bg-primary-50 dark:bg-primary-950/40'
|
||
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'}"
|
||
aria-pressed={themePreset === 'custom'}
|
||
>
|
||
<span
|
||
class="h-5 w-5 shrink-0 rounded-full ring-1 ring-black/10"
|
||
style="background: conic-gradient(from 0deg, #b0506a, #c6a24a, #5c7a5c, #3f5f8a, #b0506a)"
|
||
></span>
|
||
<span class="text-gray-800 dark:text-gray-200">Eigene</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Custom colour pickers -->
|
||
{#if themePreset === 'custom'}
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<label class="text-sm">
|
||
<span class="mb-1 block font-medium text-gray-700 dark:text-gray-300"
|
||
>Primär</span
|
||
>
|
||
<span class="flex items-center gap-2">
|
||
<input
|
||
type="color"
|
||
bind:value={themePrimary}
|
||
oninput={onCustomColor}
|
||
class="h-9 w-9 cursor-pointer rounded border border-gray-200 bg-transparent p-0.5 dark:border-gray-700"
|
||
aria-label="Primärfarbe"
|
||
/>
|
||
<input
|
||
type="text"
|
||
bind:value={themePrimary}
|
||
oninput={onCustomColor}
|
||
class="input font-mono text-sm uppercase"
|
||
maxlength="7"
|
||
/>
|
||
</span>
|
||
</label>
|
||
<label class="text-sm">
|
||
<span class="mb-1 block font-medium text-gray-700 dark:text-gray-300"
|
||
>Akzent</span
|
||
>
|
||
<span class="flex items-center gap-2">
|
||
<input
|
||
type="color"
|
||
bind:value={themeAccent}
|
||
oninput={onCustomColor}
|
||
class="h-9 w-9 cursor-pointer rounded border border-gray-200 bg-transparent p-0.5 dark:border-gray-700"
|
||
aria-label="Akzentfarbe"
|
||
/>
|
||
<input
|
||
type="text"
|
||
bind:value={themeAccent}
|
||
oninput={onCustomColor}
|
||
class="input font-mono text-sm uppercase"
|
||
maxlength="7"
|
||
/>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||
Die gewählte Farbe ist der Button-/Signalton; hellere und dunklere Abstufungen
|
||
werden automatisch daraus abgeleitet. Für gute Lesbarkeit reicht ein kräftiger,
|
||
eher dunkler Ton (weiße Button-Schrift).
|
||
</p>
|
||
{/if}
|
||
|
||
<!-- Mini live preview -->
|
||
<div
|
||
class="flex flex-wrap items-center gap-2 rounded-lg bg-gray-50 p-3 dark:bg-gray-800/50"
|
||
>
|
||
<button type="button" class="btn btn-primary btn-sm" tabindex="-1"
|
||
>Beispiel-Button</button
|
||
>
|
||
<span class="chip chip-active" style="pointer-events: none">#hochzeit</span>
|
||
<span class="text-sm font-medium text-primary-600 dark:text-primary-400"
|
||
>Akzenttext</span
|
||
>
|
||
</div>
|
||
|
||
<div class="flex gap-2">
|
||
<button
|
||
type="button"
|
||
onclick={saveTheme}
|
||
disabled={themeSaving || !themeDirty}
|
||
class="btn btn-primary btn-sm"
|
||
>
|
||
{themeSaving ? 'Wird gespeichert…' : 'Farbschema speichern'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onclick={resetTheme}
|
||
class="btn btn-secondary btn-sm"
|
||
disabled={themePreset === 'champagne-gold'}
|
||
>
|
||
Zurücksetzen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{#each CONFIG_GROUPS as group (group.title)}
|
||
<div class="card">
|
||
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
|
||
<h3
|
||
class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
|
||
>
|
||
{group.title}
|
||
</h3>
|
||
</div>
|
||
<div class="space-y-4 px-5 py-4">
|
||
{#each group.fields as field (field.key)}
|
||
<div>
|
||
{#if field.kind === 'bool'}
|
||
<label class="flex cursor-pointer items-start gap-3" for={field.key}>
|
||
<input
|
||
id={field.key}
|
||
type="checkbox"
|
||
class="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||
checked={isTrue(configDraft[field.key])}
|
||
onchange={() => toggleBool(field.key)}
|
||
/>
|
||
<div class="min-w-0">
|
||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"
|
||
>{field.label}</span
|
||
>
|
||
{#if field.hint}
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">{field.hint}</p>
|
||
{/if}
|
||
</div>
|
||
</label>
|
||
{:else if field.kind === 'text'}
|
||
<label
|
||
for={field.key}
|
||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||
>{field.label}</label
|
||
>
|
||
<textarea
|
||
id={field.key}
|
||
rows="6"
|
||
bind:value={configDraft[field.key]}
|
||
class="input resize-none font-mono text-sm"
|
||
></textarea>
|
||
{#if field.hint}
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{field.hint}</p>
|
||
{/if}
|
||
{:else}
|
||
<label
|
||
for={field.key}
|
||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||
>{field.label}</label
|
||
>
|
||
<input
|
||
id={field.key}
|
||
type="number"
|
||
step="any"
|
||
min="0"
|
||
inputmode="decimal"
|
||
bind:value={configDraft[field.key]}
|
||
class="input text-sm"
|
||
/>
|
||
{#if field.hint}
|
||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{field.hint}</p>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
|
||
<!-- Sticky save button -->
|
||
<div
|
||
class="sticky bottom-0 -mx-4 border-t border-gray-100 bg-white px-5 py-3 dark:border-gray-800 dark:bg-gray-900 sm:mx-0 sm:rounded-b-xl"
|
||
>
|
||
<button onclick={saveConfig} disabled={saving} class="btn btn-primary btn-block">
|
||
{saving ? 'Wird gespeichert…' : 'Speichern'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Export tab ───────────────────────────────────────────────── -->
|
||
{:else if activeTab === 'export'}
|
||
<div class="space-y-3">
|
||
<!-- Gallery release -->
|
||
<div class="card p-5">
|
||
<h3 class="mb-3 font-semibold text-gray-900 dark:text-gray-100">Galerie</h3>
|
||
<button
|
||
onclick={() =>
|
||
(confirmAction = {
|
||
title: 'Galerie freigeben?',
|
||
message:
|
||
'Gäste können dann alle Fotos herunterladen. Das kann nicht rückgängig gemacht werden.',
|
||
confirmLabel: 'Freigeben',
|
||
tone: 'danger',
|
||
run: releaseGallery
|
||
})}
|
||
class="btn btn-primary btn-sm"
|
||
>
|
||
Galerie freigeben
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Export jobs -->
|
||
<div class="card p-5">
|
||
<div class="mb-4 flex items-center justify-between">
|
||
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Export-Jobs</h3>
|
||
<button
|
||
onclick={refreshExportJobs}
|
||
disabled={exportJobsRefreshing}
|
||
class="text-xs text-blue-600 hover:underline disabled:opacity-50 dark:text-blue-400"
|
||
>
|
||
{exportJobsRefreshing ? 'Lädt…' : 'Aktualisieren'}
|
||
</button>
|
||
</div>
|
||
{#if exportJobs.length === 0}
|
||
<p class="text-sm text-gray-400 dark:text-gray-500">Noch keine Export-Jobs.</p>
|
||
{:else}
|
||
<div class="space-y-3">
|
||
{#each exportJobs as job (job.type)}
|
||
<div class="rounded-lg border border-gray-100 p-3 dark:border-gray-700">
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"
|
||
>{jobLabel(job.type)}</span
|
||
>
|
||
<span
|
||
class="rounded-full px-2 py-0.5 text-xs font-medium {statusBadgeClass(
|
||
job.status
|
||
)}"
|
||
>
|
||
{statusLabel(job.status)}
|
||
</span>
|
||
</div>
|
||
{#if job.status === 'running'}
|
||
<div class="mt-2">
|
||
<div
|
||
class="mb-1 flex justify-between text-xs text-gray-500 dark:text-gray-400"
|
||
>
|
||
<span>Fortschritt</span><span>{job.progress_pct} %</span>
|
||
</div>
|
||
<div
|
||
class="h-1.5 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700"
|
||
>
|
||
<div
|
||
class="h-full rounded-full bg-blue-500 transition-all"
|
||
style="width: {job.progress_pct}%"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
{#if job.error_message}
|
||
<p class="mt-1 text-xs text-red-600 dark:text-red-400">{job.error_message}</p>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Nutzer tab ───────────────────────────────────────────────── -->
|
||
{:else if activeTab === 'users'}
|
||
<div class="card overflow-hidden">
|
||
<!-- Search -->
|
||
<div class="p-4">
|
||
<div
|
||
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-700 dark:bg-gray-900"
|
||
>
|
||
<svg
|
||
class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||
/>
|
||
</svg>
|
||
<input
|
||
type="search"
|
||
placeholder="Nutzer suchen…"
|
||
bind:value={userSearch}
|
||
class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none dark:text-gray-100 dark:placeholder-gray-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
{#if filteredUsers.length === 0}
|
||
<p class="px-5 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
|
||
Keine Treffer.
|
||
</p>
|
||
{:else}
|
||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||
{#each filteredUsers as user (user.id)}
|
||
<div class="flex items-center gap-3 px-5 py-3">
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex flex-wrap items-center gap-1.5">
|
||
<span class="font-medium text-gray-900 dark:text-gray-100"
|
||
>{user.display_name}</span
|
||
>
|
||
{#if user.role === 'host'}
|
||
<span class="badge badge-primary">Host</span>
|
||
{:else if user.role === 'admin'}
|
||
<span class="badge badge-gold">Admin</span>
|
||
{/if}
|
||
{#if user.is_banned}
|
||
<span class="badge badge-danger">Gesperrt</span>
|
||
{/if}
|
||
</div>
|
||
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||
{user.upload_count} Upload{user.upload_count !== 1 ? 's' : ''} · {formatBytes(
|
||
user.total_upload_bytes
|
||
)}
|
||
</p>
|
||
</div>
|
||
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
|
||
{#if user.role !== 'admin'}
|
||
{#if user.is_banned}
|
||
<button
|
||
onclick={() =>
|
||
(confirmAction = {
|
||
title: 'Sperre aufheben?',
|
||
message: `${user.display_name} kann danach wieder hochladen${$commentsEnabled ? ', liken und kommentieren' : ' und liken'}.`,
|
||
confirmLabel: 'Entsperren',
|
||
tone: 'default',
|
||
run: () => unban(user)
|
||
})}
|
||
class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||
>
|
||
Entsperren
|
||
</button>
|
||
{:else}
|
||
{#if user.role === 'guest'}
|
||
<button
|
||
onclick={() =>
|
||
(confirmAction = {
|
||
title: 'Zum Host befördern?',
|
||
message: `${user.display_name} erhält Host-Rechte: sperren, PIN zurücksetzen und Galerie verwalten. Das lässt sich nur durch Degradieren rückgängig machen.`,
|
||
confirmLabel: 'Befördern',
|
||
tone: 'default',
|
||
run: () => promoteToHost(user)
|
||
})}
|
||
class="rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60"
|
||
>
|
||
Host
|
||
</button>
|
||
{/if}
|
||
{#if user.role === 'host'}
|
||
<button
|
||
onclick={() =>
|
||
(confirmAction = {
|
||
title: 'Zum Gast degradieren?',
|
||
message: `${user.display_name} verliert alle Host-Rechte.`,
|
||
confirmLabel: 'Degradieren',
|
||
tone: 'danger',
|
||
run: () => demoteToGuest(user)
|
||
})}
|
||
class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||
>
|
||
Degradieren
|
||
</button>
|
||
{/if}
|
||
{#if canResetPinFor(user)}
|
||
<button
|
||
onclick={() => askResetPin(user)}
|
||
class="rounded-lg bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-100 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60"
|
||
>
|
||
PIN zurücksetzen
|
||
</button>
|
||
{/if}
|
||
<button
|
||
onclick={() => openBanModal(user)}
|
||
class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100 dark:bg-red-950/40 dark:text-red-300 dark:hover:bg-red-950/60"
|
||
>
|
||
Sperren
|
||
</button>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
</div>
|