fix(user-flow): persona-audit fixes — ban replay, locked-upload data loss, host UX, admin session
Follows the perf + security + user-flow work with a role/persona audit (guest, host, admin, projector) and fixes across three review rounds. Highlights: HIGH - Ban now replays on reconnect. A ban isn't a soft-delete, and the `user-hidden` SSE has no replay, so a client that missed it (esp. the unattended diashow) kept cycling a banned user's slides. New `uploads_hidden_at` (migration 013) + `hidden_user_ids` in /feed/delta; feed + diashow evict those users. Applied even on a truncated delta. MEDIUM - Locked-upload data loss: a photo staged offline during a lock/release was purged as a terminal 4xx and lost when the host reopened. New reversible `uploads_locked` error code; the queue keeps the blob and auto-resumes on the `event-opened` SSE. - Reopen after release now warns (ConfirmSheet) that it revokes the published keepsake. - Host "forgotten-PIN" badge updates live (`pin-reset-requested` was broadcast but never in KNOWN_EVENTS / subscribed); host page also refetches on `pin-reset` so a two-host race can't hand out a conflicting PIN. - Ban modal copy fixed (read-only ban, not "session ended"); Degradieren/Sperren/Entsperren hidden on peer-host rows for non-admins (they always 403'd). - Host dashboard shows live keepsake generation progress / ready state + link to /export. - Admin JWT moved to sessionStorage (§11.1) to bound exposure on shared devices. Export generation guard (H1 from the prior round, hardened): per-(event,type) `release_seq` (migration 012) with seq-guarded claim/finalize/mark_failed/update_progress, per-generation temp/final paths, download follows `file_path`, prune only strictly-older generations. LOW: diashow coalesces upload-processed (avoids self-rate-limit); event-closed reconciles galleryReleased; /recover gains a forgot-PIN request + drops a stale cached PIN on 401; delta `>=` tie-break + 429 retry; misc copy/labels. Adds e2e: ban-replay, upload-lock-code, and rewrites export-reopen-rerelease with a data-completeness test. Reconciles USER_JOURNEYS §9/§11. Verified: cargo build clean, 40 unit tests, svelte-check 0 errors, 33 frontend unit tests, 155 e2e passing on chromium-desktop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
import { getToken, getRole, getUserId } from '$lib/auth';
|
||||
import { api } from '$lib/api';
|
||||
import type { MeContextDto } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { 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';
|
||||
@@ -34,12 +35,38 @@
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ExportJob {
|
||||
status: string;
|
||||
progress_pct: number;
|
||||
}
|
||||
interface ExportStatusDto {
|
||||
released: boolean;
|
||||
zip: ExportJob | null;
|
||||
html: ExportJob | null;
|
||||
}
|
||||
|
||||
let event = $state<EventStatus | null>(null);
|
||||
let users = $state<UserSummary[]>([]);
|
||||
let pinResetRequests = $state<PinResetRequest[]>([]);
|
||||
let exportInfo = $state<ExportStatusDto | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Live keepsake state for the host dashboard: are both halves done, and if not, how far.
|
||||
let exportReady = $derived(
|
||||
exportInfo?.zip?.status === 'done' && exportInfo?.html?.status === 'done'
|
||||
);
|
||||
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)
|
||||
);
|
||||
|
||||
// SSE unsubscribers, torn down on destroy.
|
||||
let sseOff: Array<() => void> = [];
|
||||
|
||||
// Collapsible section state
|
||||
let statsOpen = $state(true);
|
||||
let settingsOpen = $state(true);
|
||||
@@ -85,8 +112,13 @@
|
||||
confirmAction = null;
|
||||
}
|
||||
|
||||
/** Mirrors backend `handlers::host::reset_user_pin` authorisation rules. */
|
||||
function canResetPinFor(target: UserSummary): boolean {
|
||||
/**
|
||||
* Mirrors the backend authorisation rules shared by ban / unban / PIN-reset / set-role:
|
||||
* a plain host may only act on GUESTS; only an admin may act on a host; nobody may act on
|
||||
* an admin. Used to hide moderation buttons that would always 403 — a non-admin host must
|
||||
* not see Sperren/Degradieren/PIN on a peer-host row (they'd tap it and get a bare 403).
|
||||
*/
|
||||
function canModerate(target: UserSummary): boolean {
|
||||
if (target.role === 'admin') return false;
|
||||
if (myRole === 'admin') return true;
|
||||
if (myRole === 'host') return target.role === 'guest';
|
||||
@@ -115,16 +147,33 @@
|
||||
return;
|
||||
}
|
||||
await reload();
|
||||
|
||||
// Live updates so the dashboard doesn't need a manual reload:
|
||||
// - pin-reset-requested: a guest just asked for a reset → refresh the badge/list.
|
||||
// - pin-reset: some host resolved a reset → drop it from the list (two-host race:
|
||||
// the other host's stale row disappears instead of yielding a conflicting PIN).
|
||||
// - export-progress/-available: keepsake generation moves → refresh the status line.
|
||||
sseOff = [
|
||||
onSseEvent('pin-reset-requested', () => void refreshPinRequests()),
|
||||
onSseEvent('pin-reset', () => void refreshPinRequests()),
|
||||
onSseEvent('export-progress', () => void refreshExportStatus()),
|
||||
onSseEvent('export-available', () => void refreshExportStatus())
|
||||
];
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
for (const off of sseOff) off();
|
||||
});
|
||||
|
||||
async function reload() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
[event, users, pinResetRequests] = await Promise.all([
|
||||
[event, users, pinResetRequests, exportInfo] = await Promise.all([
|
||||
api.get<EventStatus>('/host/event'),
|
||||
api.get<UserSummary[]>('/host/users'),
|
||||
api.get<PinResetRequest[]>('/host/pin-reset-requests')
|
||||
api.get<PinResetRequest[]>('/host/pin-reset-requests'),
|
||||
api.get<ExportStatusDto>('/export/status')
|
||||
]);
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Laden.';
|
||||
@@ -133,6 +182,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Refetch just the pending PIN-reset requests (live badge; avoids a full-page reload). */
|
||||
async function refreshPinRequests() {
|
||||
try {
|
||||
pinResetRequests = await api.get<PinResetRequest[]>('/host/pin-reset-requests');
|
||||
} catch {
|
||||
/* non-fatal — the next manual reload picks it up */
|
||||
}
|
||||
}
|
||||
|
||||
/** Refetch just the keepsake export status (live progress/ready). */
|
||||
async function refreshExportStatus() {
|
||||
try {
|
||||
exportInfo = await api.get<ExportStatusDto>('/export/status');
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPinForRequest(req: PinResetRequest) {
|
||||
try {
|
||||
const res = await api.post<{ pin: string }>(`/host/users/${req.user_id}/pin-reset`);
|
||||
@@ -152,22 +219,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEventLock() {
|
||||
if (!event) return;
|
||||
async function reopenEvent() {
|
||||
try {
|
||||
if (event.uploads_locked) {
|
||||
await api.post('/host/event/open');
|
||||
toast('Uploads wurden wieder geöffnet.', 'success');
|
||||
} else {
|
||||
await api.post('/host/event/close');
|
||||
toast('Uploads wurden gesperrt.', 'success');
|
||||
}
|
||||
await api.post('/host/event/open');
|
||||
toast('Uploads wurden wieder geöffnet.', 'success');
|
||||
await reload();
|
||||
} catch (e: unknown) {
|
||||
toastError(e);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEventLock() {
|
||||
if (!event) return;
|
||||
if (event.uploads_locked) {
|
||||
// Reopening after a release INVALIDATES the published keepsake: it clears the
|
||||
// release + ready flags, so every guest's download 404s until the host re-releases.
|
||||
// That's destructive and easy to trigger by accident ("let a few more photos in"),
|
||||
// so gate it behind a danger confirm — but a plain lock (not yet released) reopens
|
||||
// with no ceremony.
|
||||
if (event.export_released) {
|
||||
confirmAction = {
|
||||
title: 'Galerie-Freigabe zurücknehmen?',
|
||||
message:
|
||||
'Wenn du die Uploads wieder öffnest, wird die veröffentlichte Galerie zurückgezogen. ' +
|
||||
'Gäste können das Keepsake erst nach einer erneuten Freigabe wieder herunterladen.',
|
||||
confirmLabel: 'Wieder öffnen',
|
||||
tone: 'danger',
|
||||
run: reopenEvent
|
||||
};
|
||||
return;
|
||||
}
|
||||
void reopenEvent();
|
||||
} else {
|
||||
void (async () => {
|
||||
try {
|
||||
await api.post('/host/event/close');
|
||||
toast('Uploads wurden gesperrt.', 'success');
|
||||
await reload();
|
||||
} catch (e: unknown) {
|
||||
toastError(e);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
async function releaseGallery() {
|
||||
try {
|
||||
await api.post('/host/gallery/release');
|
||||
@@ -175,6 +270,10 @@
|
||||
await reload();
|
||||
} catch (e: unknown) {
|
||||
toastError(e);
|
||||
// Release marks `export_released_at` before enqueuing the workers; if that second
|
||||
// step errored the event is already released, so reconcile the UI (otherwise the
|
||||
// button still reads "Galerie freigeben" and a retry just 409s "bereits freigegeben").
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +409,8 @@
|
||||
<h2 id="host-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“.
|
||||
Galerie, Diashow und Export, und Hochladen, Liken und Kommentieren werden blockiert. Der
|
||||
Lesezugriff (Feed ansehen, Keepsake herunterladen) bleibt bestehen. Rückgängig machbar über „Entsperren“.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@@ -409,7 +509,7 @@
|
||||
<div class="grid grid-cols-2 gap-3 border-t border-gray-100 p-4 dark:border-gray-700 sm:grid-cols-4">
|
||||
<div class="rounded-xl bg-gray-50 p-4 text-center dark:bg-gray-900/60">
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{users.length}</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Gäste</p>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Teilnehmer</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-gray-50 p-4 text-center dark:bg-gray-900/60">
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{users.reduce((s, u) => s + u.upload_count, 0)}</p>
|
||||
@@ -470,6 +570,27 @@
|
||||
{event.export_released ? 'Galerie bereits freigegeben' : 'Galerie freigeben'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Live keepsake status: after release the ZIP/HTML still take time to build, and
|
||||
downloads 404 until they're done — surface it so a host doesn't announce
|
||||
"released!" while guest downloads still fail. -->
|
||||
{#if event.export_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">Zum Download</a>
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-red-700 dark:text-red-300">Keepsake-Erstellung fehlgeschlagen. Öffne die Uploads erneut und gib die Galerie neu frei.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -529,6 +650,9 @@
|
||||
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
|
||||
{#if user.role !== 'admin'}
|
||||
{#if user.is_banned}
|
||||
<!-- Only show Entsperren to someone allowed to act on this user (a plain
|
||||
host can't unban a peer host — that's an admin-only action, F1). -->
|
||||
{#if canModerate(user)}
|
||||
<button
|
||||
onclick={() => (confirmAction = {
|
||||
title: 'Sperre aufheben?',
|
||||
@@ -541,6 +665,7 @@
|
||||
>
|
||||
Entsperren
|
||||
</button>
|
||||
{/if}
|
||||
{:else if user.id !== myUserId}
|
||||
<!-- Never render target-actions (promote/demote/PIN/ban) on the
|
||||
caller's own row: the backend rejects every self-action
|
||||
@@ -560,8 +685,9 @@
|
||||
Host
|
||||
</button>
|
||||
{/if}
|
||||
{#if user.role === 'host'}
|
||||
<!-- Hosts may demote other Hosts (never themselves); backend enforces. -->
|
||||
{#if user.role === 'host' && myRole === 'admin'}
|
||||
<!-- Only an admin may demote a host (F1). A plain host demoting a
|
||||
peer host is a 403 on the backend, so the button is hidden. -->
|
||||
<button
|
||||
onclick={() => (confirmAction = {
|
||||
title: 'Zum Gast degradieren?',
|
||||
@@ -575,7 +701,7 @@
|
||||
Degradieren
|
||||
</button>
|
||||
{/if}
|
||||
{#if canResetPinFor(user)}
|
||||
{#if canModerate(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"
|
||||
@@ -583,12 +709,14 @@
|
||||
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 canModerate(user)}
|
||||
<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}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user