fix(ui): make the dashboards agree with each other and with what the code does
Host and admin implement the same four operations with independently written copy, and admin was stale or wrong in every case. Its release button was always enabled and always read "Galerie freigeben", so a second tap returned a 409; it showed no release state, no keepsake progress, no failure reason, no rebuild, and never refreshed after releasing. It now matches the host page. Both dashboards subscribed to SSE and never opened the connection — `onSseEvent` only registers a handler. Every subscription was inert, so the keepsake progress bar sat frozen after a release and PIN requests appeared only on a manual refresh. It happened to work when arriving straight from /feed, which connects, and /feed disconnects on destroy, so navigating to the dashboard killed it again. The unban confirm named neither of the two things a host most needs to know: unbanning also restores ALL of that guest's previously hidden photos to the gallery, diashow and export, and it retires and rebuilds a released keepsake, during which every guest's download is briefly unavailable. The ban modal warns that uploads vanish; nothing said they come back. Both now do, gated on the gallery actually being released. "Event verlassen" implied the account was being deleted, then the dialog said the guest could log back in. It calls `DELETE /session` — this device only, nothing deleted — so it is "Abmelden" now. Gallery release now states it locks uploads and is reversible; PIN reset states the guest is signed out on all devices. The keepsake download failed silently: nothing inspected the iframe result and the ticket POST always succeeded, so an over-limit tap did nothing at all. It now surfaces the (newly visible) 429 and confirms the download started. `/export` rendered "Export noch nicht verfügbar / Schau nach der Veranstaltung noch einmal vorbei" when the status request had merely FAILED — telling a guest to come back after an event that already happened. Both dashboards' error states gained a retry, which a host on a PWA with no URL bar otherwise has no way to reach. Modals were centred with no max-height, so on a short viewport the join PIN dialog clipped equally top and bottom — potentially putting "Weiter zur Galerie" off-screen at the moment a first-time guest must proceed. The ten moderation buttons were ~28px tall side by side, on the screen where a mis-tap bans the wrong guest; they are 44px now. Six German quotation marks paired the opening „ with an ASCII straight quote. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
import { role as myRoleStore } from '$lib/role-store';
|
||||
import { api } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
|
||||
import { toast, toastError } from '$lib/toast-store';
|
||||
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
@@ -36,6 +37,20 @@
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
// `/export/status` — the same derived view the host dashboard and the guest export page
|
||||
// use (released + per-half job state). Deliberately separate from `ExportJob` above,
|
||||
// which is the admin-only raw job-row listing.
|
||||
interface ExportStatusJob {
|
||||
status: string;
|
||||
progress_pct: number;
|
||||
error_message: string | null;
|
||||
}
|
||||
interface ExportStatusDto {
|
||||
released: boolean;
|
||||
zip: ExportStatusJob | null;
|
||||
html: ExportStatusJob | null;
|
||||
}
|
||||
|
||||
interface UserSummary {
|
||||
id: string;
|
||||
display_name: string;
|
||||
@@ -238,10 +253,17 @@
|
||||
themeAccent.toLowerCase() !== savedTheme.accent.toLowerCase()
|
||||
);
|
||||
|
||||
// SSE unsubscribers for the live keepsake status, torn down on destroy.
|
||||
let sseOff: Array<() => void> = [];
|
||||
let destroyed = false;
|
||||
|
||||
// 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(() => {
|
||||
destroyed = true;
|
||||
disconnectSse();
|
||||
for (const off of sseOff) off();
|
||||
void loadEventConfig();
|
||||
});
|
||||
|
||||
@@ -265,6 +287,36 @@
|
||||
let error = $state<string | null>(null);
|
||||
let exportJobsRefreshing = $state(false);
|
||||
|
||||
// ── Keepsake state (parity with the host dashboard) ─────────────────────────
|
||||
// The Export tab used to offer a release button that was always enabled and always
|
||||
// labelled "Galerie freigeben", with no indication of whether the release had already
|
||||
// happened, how the build was going, or why it failed — so a second tap just returned
|
||||
// 409 "bereits freigegeben", and a failed keepsake was invisible AND unrecoverable from
|
||||
// here. Same data, same controls and the same wording as /host now.
|
||||
let exportInfo = $state<ExportStatusDto | null>(null);
|
||||
let rebuilding = $state(false);
|
||||
|
||||
let exportReady = $derived(
|
||||
exportInfo?.zip?.status === 'done' && exportInfo?.html?.status === 'done'
|
||||
);
|
||||
// `&&`, not `||`: the keepsake needs BOTH halves, so one failing half means the whole
|
||||
// thing failed. With `||` a one-half failure would sit on "wird erstellt…" forever and
|
||||
// never reveal the retry.
|
||||
let exportGenerating = $derived(
|
||||
!!exportInfo?.released &&
|
||||
!exportReady &&
|
||||
exportInfo?.zip?.status !== 'failed' &&
|
||||
exportInfo?.html?.status !== 'failed'
|
||||
);
|
||||
let exportProgress = $derived(
|
||||
Math.min(exportInfo?.zip?.progress_pct ?? 0, exportInfo?.html?.progress_pct ?? 0)
|
||||
);
|
||||
// A disk failure usually fails both halves with the same text — take the first present
|
||||
// rather than printing it twice.
|
||||
let exportError = $derived(
|
||||
exportInfo?.zip?.error_message ?? exportInfo?.html?.error_message ?? null
|
||||
);
|
||||
|
||||
// Nutzer tab state
|
||||
let userSearch = $state('');
|
||||
let filteredUsers = $derived(
|
||||
@@ -326,6 +378,27 @@
|
||||
return;
|
||||
}
|
||||
await reload();
|
||||
|
||||
// The awaits above mean the component can already be destroyed by the time we get here
|
||||
// (navigated away mid-load). Svelte doesn't cancel an async onMount, so without this
|
||||
// guard onDestroy would have run with an empty `sseOff` and these handlers would leak
|
||||
// into the module-global SSE map forever. (Same guard as the host page.)
|
||||
if (destroyed) return;
|
||||
|
||||
// Open the stream — `onSseEvent` only registers a handler, it does not connect. Without
|
||||
// this the subscriptions below never fire and the keepsake status is frozen; see the
|
||||
// same fix and reasoning on the host dashboard. Idempotent.
|
||||
connectSse();
|
||||
|
||||
// Keepsake generation moves → refresh the status line and the job list without a
|
||||
// manual tap, exactly as the host dashboard does.
|
||||
sseOff = [
|
||||
onSseEvent('export-progress', () => void refreshExportStatus()),
|
||||
onSseEvent('export-available', () => {
|
||||
void refreshExportStatus();
|
||||
void refreshExportJobs();
|
||||
})
|
||||
];
|
||||
});
|
||||
|
||||
async function reload() {
|
||||
@@ -345,12 +418,33 @@
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
// Keepsake status is a secondary widget — fetched OUTSIDE the all-or-nothing block
|
||||
// above (and its error swallowing) so a transient /export/status failure can't blank
|
||||
// the whole dashboard. Mirrors the host page.
|
||||
void refreshExportStatus();
|
||||
}
|
||||
|
||||
/** Refetch just the keepsake status (released / live progress / failure reason). */
|
||||
async function refreshExportStatus() {
|
||||
try {
|
||||
exportInfo = await api.get<ExportStatusDto>('/export/status');
|
||||
} catch {
|
||||
/* non-fatal — the next refresh or SSE tick picks it up */
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshExportJobs() {
|
||||
exportJobsRefreshing = true;
|
||||
try {
|
||||
exportJobs = await api.get<ExportJob[]>('/admin/export/jobs');
|
||||
// The raw job rows and the derived status are two views of the same thing; a manual
|
||||
// refresh that updated only one of them would leave the two halves of this tab
|
||||
// disagreeing about whether the keepsake is ready.
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
exportJobs = await api.get<ExportJob[]>('/admin/export/jobs');
|
||||
})(),
|
||||
refreshExportStatus()
|
||||
]);
|
||||
} finally {
|
||||
exportJobsRefreshing = false;
|
||||
}
|
||||
@@ -400,8 +494,31 @@
|
||||
try {
|
||||
await api.post('/host/gallery/release');
|
||||
toast('Galerie wurde freigegeben. Export wird vorbereitet…', 'success');
|
||||
// Without this the Export tab kept showing the pre-release world: no jobs in the
|
||||
// list, the button still inviting a second (409-ing) release.
|
||||
await reload();
|
||||
} catch (e: unknown) {
|
||||
toastError(e);
|
||||
// Release stamps `export_released_at` before enqueuing the workers; if that second
|
||||
// step errored the event IS released, so reconcile rather than leaving a button that
|
||||
// reads "Galerie freigeben" and can now only 409.
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
|
||||
// The escape hatch for a failed or stale keepsake — the host dashboard has had it; without
|
||||
// it here the only recovery from the admin side was reopening uploads (which retracts the
|
||||
// release for every guest) or restarting the container.
|
||||
async function rebuildExport() {
|
||||
rebuilding = true;
|
||||
try {
|
||||
await api.post('/host/export/rebuild', {});
|
||||
toast('Keepsake wird neu erstellt…', 'success');
|
||||
await refreshExportJobs();
|
||||
} catch (e: unknown) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
rebuilding = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,6 +534,9 @@
|
||||
toast(`${banTarget.display_name} wurde gesperrt.`, 'success');
|
||||
banTarget = null;
|
||||
users = await api.get<UserSummary[]>('/host/users');
|
||||
// A ban on a released event retires and rebuilds the keepsake (`invalidate_and_arm`),
|
||||
// so the status block above is stale the moment this returns.
|
||||
void refreshExportStatus();
|
||||
} catch (e: unknown) {
|
||||
toastError(e);
|
||||
} finally {
|
||||
@@ -429,11 +549,33 @@
|
||||
await api.post(`/host/users/${user.id}/unban`);
|
||||
toast(`Sperre für ${user.display_name} aufgehoben.`, 'success');
|
||||
users = await api.get<UserSummary[]>('/host/users');
|
||||
// Same as ban: an unban rebuilds a released keepsake, so re-read the status.
|
||||
void refreshExportStatus();
|
||||
} catch (e: unknown) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy for the unban confirm — same text as the host dashboard, same reasons. Verified
|
||||
* against `unban_user` (backend/src/handlers/host.rs): besides lifting the write block it
|
||||
* clears `uploads_hidden` (so every photo the ban hid returns to gallery, diashow and
|
||||
* export) and calls `invalidate_and_arm` (so a released keepsake is retired and rebuilt,
|
||||
* and guest downloads are unavailable meanwhile). The ban modal warns that uploads vanish;
|
||||
* this is the sentence that says they come back.
|
||||
*/
|
||||
function unbanMessage(user: UserSummary): string {
|
||||
const actions = $commentsEnabled ? ', liken und kommentieren' : ' und liken';
|
||||
let msg =
|
||||
`${user.display_name} kann danach wieder hochladen${actions}. ` +
|
||||
`Alle bisher ausgeblendeten Uploads von ${user.display_name} erscheinen wieder in Galerie, Diashow und Export.`;
|
||||
if (exportInfo?.released) {
|
||||
msg +=
|
||||
' Das bereits freigegebene Keepsake wird deshalb neu erstellt — währenddessen können Gäste es kurz nicht herunterladen.';
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function promoteToHost(user: UserSummary) {
|
||||
try {
|
||||
await api.patch(`/host/users/${user.id}/role`, { role: 'host' });
|
||||
@@ -547,7 +689,8 @@
|
||||
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.`
|
||||
? // Same fix as the host page: the reset revokes every session, so say so.
|
||||
`Eine neue PIN für ${pinResetTarget.display_name} wird erzeugt. Die alte PIN funktioniert dann nicht mehr, und ${pinResetTarget.display_name} wird auf allen Geräten abgemeldet und muss sich mit der neuen PIN neu anmelden.`
|
||||
: ''}
|
||||
confirmLabel={pinResetSubmitting ? 'Wird erzeugt…' : 'Neue PIN erzeugen'}
|
||||
tone="danger"
|
||||
@@ -591,8 +734,24 @@
|
||||
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“.
|
||||
<!-- Was: "…und die Sitzung wird beendet." That is the opposite of what happens — the
|
||||
ban is deliberately READ-ONLY (backend/src/handlers/host.rs: sessions are not
|
||||
revoked, and the export ticket is intentionally not gated on is_banned). An
|
||||
admin told the user is logged out stops watching, while the guest keeps
|
||||
browsing and still downloads the keepsake. Text below matches the host page,
|
||||
which had it right. -->
|
||||
<strong>{banTarget.display_name}</strong> wird gesperrt: alle Uploads verschwinden aus
|
||||
Galerie, Diashow und Export, und Hochladen, Liken{$commentsEnabled ? ' und Kommentieren' : ''} werden
|
||||
blockiert. Der Lesezugriff (Feed ansehen, Keepsake herunterladen) bleibt bestehen. Rückgängig machbar
|
||||
über „Entsperren“.
|
||||
{#if exportInfo?.released}
|
||||
<!-- Mirror of the unban sentence: `ban_user` runs `invalidate_and_arm` in the same
|
||||
transaction, so on a released event the keepsake is retired and rebuilt and every
|
||||
guest's download 404s until that finishes. Only shown when there is a released
|
||||
keepsake to lose. -->
|
||||
Da die Galerie bereits freigegeben ist, wird das Keepsake ohne diese Uploads neu erstellt — währenddessen
|
||||
können Gäste es kurz nicht herunterladen.
|
||||
{/if}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button onclick={() => (banTarget = null)} class="btn btn-secondary btn-sm flex-1"
|
||||
@@ -647,11 +806,17 @@
|
||||
{#if loading}
|
||||
<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
|
||||
{:else if error}
|
||||
<!-- A retry, not just a verdict: `reload()` fails for transient reasons (wifi blip),
|
||||
and without this the only way out is a browser reload — not obvious, and not even
|
||||
reachable, in an installed PWA with no URL bar. -->
|
||||
<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}
|
||||
<p>{error}</p>
|
||||
<button onclick={() => void reload()} class="btn btn-secondary btn-sm mt-3">
|
||||
Erneut laden
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- ── Stats tab ────────────────────────────────────────────────── -->
|
||||
@@ -942,7 +1107,9 @@
|
||||
<!-- ── Export tab ───────────────────────────────────────────────── -->
|
||||
{:else if activeTab === 'export'}
|
||||
<div class="space-y-3">
|
||||
<!-- Gallery release -->
|
||||
<!-- Gallery release — deliberately the same controls, gating and wording as the
|
||||
host dashboard's Event-Einstellungen block. Two independently written UIs for
|
||||
one operation is how they drifted apart in the first place. -->
|
||||
<div class="card p-5">
|
||||
<h3 class="mb-3 font-semibold text-gray-900 dark:text-gray-100">Galerie</h3>
|
||||
<button
|
||||
@@ -950,15 +1117,96 @@
|
||||
(confirmAction = {
|
||||
title: 'Galerie freigeben?',
|
||||
message:
|
||||
'Gäste können dann alle Fotos herunterladen. Das kann nicht rückgängig gemacht werden.',
|
||||
// See the identical fix on the host page: the release is reversible via
|
||||
// "Uploads wieder öffnen", and it locks uploads at the same time.
|
||||
'Uploads werden dabei gesperrt, und Gäste können alle Fotos herunterladen. Rückgängig machbar: „Uploads wieder öffnen“ zieht die Freigabe zurück.',
|
||||
confirmLabel: 'Freigeben',
|
||||
tone: 'danger',
|
||||
run: releaseGallery
|
||||
})}
|
||||
disabled={exportInfo?.released}
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
Galerie freigeben
|
||||
{exportInfo?.released ? 'Galerie bereits freigegeben' : 'Galerie freigeben'}
|
||||
</button>
|
||||
|
||||
<!-- Live keepsake status. After release the ZIP/HTML still take minutes to build
|
||||
and guest downloads 404 until they're done, so an admin who can't see this
|
||||
announces "released!" while every download still fails. -->
|
||||
{#if exportInfo?.released}
|
||||
<div class="mt-3 rounded-lg bg-gray-50 px-3 py-2 text-xs dark:bg-gray-800/60">
|
||||
{#if exportGenerating}
|
||||
<p class="font-medium text-amber-700 dark:text-amber-300">
|
||||
Keepsake wird erstellt… {exportProgress}%
|
||||
</p>
|
||||
<div
|
||||
class="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-amber-500 transition-all"
|
||||
style="width: {exportProgress}%"
|
||||
></div>
|
||||
</div>
|
||||
{:else if exportReady}
|
||||
<p class="flex items-center justify-between gap-2">
|
||||
<span class="font-medium text-green-700 dark:text-green-300"
|
||||
>Keepsake ist bereit.</span
|
||||
>
|
||||
<a href="/export" class="font-medium text-blue-600 underline dark:text-blue-400"
|
||||
>Herunterladen</a
|
||||
>
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-red-700 dark:text-red-300">Keepsake-Erstellung fehlgeschlagen.</p>
|
||||
<!-- The reason, not just the verdict: the common failure is "not enough disk",
|
||||
and retrying without freeing space fails identically forever. The backend
|
||||
already wrote a message naming the numbers. -->
|
||||
{#if exportError}
|
||||
<p class="mt-1 text-red-700/80 dark:text-red-300/80">{exportError}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- ONE button, mounted in every state — deliberately OUTSIDE the branches
|
||||
above. Those branches are SSE-driven, and `rebuildExport` itself makes the
|
||||
backend broadcast `export-progress` at 0% immediately: a button living
|
||||
inside a branch would UNMOUNT ITSELF between mousedown and mouseup, and
|
||||
Chromium then fires no `click` at all. So the button's existence is
|
||||
invariant; only its label and `disabled` change. (Same reasoning as the
|
||||
host page — see the longer note there.) -->
|
||||
<button
|
||||
onclick={() => {
|
||||
// Rebuilding a READY keepsake is disruptive (guests tapping Herunterladen
|
||||
// get nothing until it finishes), so it asks first. A FAILED keepsake has
|
||||
// nothing to lose and retries immediately.
|
||||
if (exportReady) {
|
||||
confirmAction = {
|
||||
title: 'Keepsake neu erstellen?',
|
||||
message:
|
||||
'Das Keepsake wird aus dem aktuellen Stand der Galerie neu erzeugt. ' +
|
||||
'Während der Erstellung können Gäste es nicht herunterladen.',
|
||||
confirmLabel: 'Neu erstellen',
|
||||
tone: 'danger',
|
||||
run: rebuildExport
|
||||
};
|
||||
} else {
|
||||
void rebuildExport();
|
||||
}
|
||||
}}
|
||||
disabled={rebuilding || exportGenerating}
|
||||
data-testid="export-rebuild"
|
||||
class="mt-2 inline-flex min-h-11 items-center rounded-lg px-3 py-1.5 text-xs font-medium transition disabled:opacity-50
|
||||
{exportReady
|
||||
? 'text-gray-500 underline dark:text-gray-400'
|
||||
: 'bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-400'}"
|
||||
>
|
||||
{rebuilding
|
||||
? 'Wird gestartet…'
|
||||
: exportReady
|
||||
? 'Neu erstellen'
|
||||
: 'Erneut versuchen'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Export jobs -->
|
||||
@@ -1075,19 +1323,21 @@
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
|
||||
<!-- 44 px targets and a wider gap, not the ~28 px/6 px this had: these buttons
|
||||
sit shoulder to shoulder on a phone and a mis-tap here bans the wrong guest. -->
|
||||
<div class="flex shrink-0 flex-wrap justify-end gap-2">
|
||||
{#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'}.`,
|
||||
message: unbanMessage(user),
|
||||
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"
|
||||
class="inline-flex min-h-11 items-center 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>
|
||||
@@ -1102,7 +1352,7 @@
|
||||
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"
|
||||
class="inline-flex min-h-11 items-center 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>
|
||||
@@ -1117,7 +1367,7 @@
|
||||
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"
|
||||
class="inline-flex min-h-11 items-center 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>
|
||||
@@ -1125,14 +1375,14 @@
|
||||
{#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"
|
||||
class="inline-flex min-h-11 items-center 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"
|
||||
class="inline-flex min-h-11 items-center 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>
|
||||
|
||||
Reference in New Issue
Block a user