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:
fabi
2026-07-13 21:06:28 +02:00
parent 641174717c
commit 36fe59caa5
28 changed files with 1185 additions and 302 deletions

View File

@@ -15,7 +15,7 @@
import { onSseEvent } from '$lib/sse';
import { api } from '$lib/api';
import type { MeContextDto } from '$lib/types';
import { eventState, markClosed, markOpened } from '$lib/event-state-store';
import { eventState, markClosed, markOpened, refreshEventState } from '$lib/event-state-store';
let { children } = $props();
@@ -69,7 +69,14 @@
}),
// Reflect a host closing/reopening uploads live, so the composer switches to a
// locked state immediately instead of a guest finding out via a rejected upload.
onSseEvent('event-closed', () => markClosed()),
// `event-closed` fires for BOTH a plain lock and a gallery release (release ⇒ lock),
// which markClosed can't tell apart — follow it with a server refresh so a release
// correctly flips `galleryReleased` too (else the composer briefly mislabels it as a
// plain lock until the next navigation).
onSseEvent('event-closed', () => {
markClosed();
void refreshEventState();
}),
onSseEvent('event-opened', () => markOpened())
);
});

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api, ApiError } from '$lib/api';
import { setAuth, getRole } from '$lib/auth';
import { setAdminAuth, getRole } from '$lib/auth';
import { browser } from '$app/environment';
// Already logged in as admin → go straight to dashboard
@@ -22,10 +22,10 @@
'/admin/login',
{ password }
);
// Admin sessions have no PIN; pass null so setAuth doesn't overwrite a guest PIN.
// Persist the real user id + name so the admin has an identity (own-post
// affordances on the feed, a name on the Account page rather than "Unbekannt").
setAuth(res.jwt, null, res.user_id, res.display_name);
// Admin session goes in sessionStorage (bounded to the tab; §11.1), with the real
// user id + name so the admin has an identity (own-post affordances on the feed, a
// name on the Account page rather than "Unbekannt"). No PIN for admins.
setAdminAuth(res.jwt, res.user_id, res.display_name);
goto('/admin');
} catch (e) {
if (e instanceof ApiError) {

View File

@@ -75,18 +75,33 @@
// to `new-upload` here — its payload arrives before compression finishes
// (preview_url is still null), and `SlideQueue.pushLive` dedupes by id, so the
// preview would never be picked up if we enqueued the pre-processed version first.
async function handleUploadProcessed(data: string) {
// COALESCE the fetch: a host bulk-uploading fires dozens of `upload-processed` events in
// seconds; one `/feed` fetch per event trips the per-user feed rate limit (429) and drops
// slides. Debounce into a single fetch of the newest slice instead.
let processedDebounce: ReturnType<typeof setTimeout> | null = null;
function handleUploadProcessed(data: string) {
try {
const payload = JSON.parse(data) as { upload_id: string };
if (!payload.upload_id) return;
const feed = await api.get<FeedResponse>('/feed?limit=20');
const found = feed.uploads.find((u) => u.id === payload.upload_id);
if (found) {
queue.pushLive(found);
if (!current) advance();
}
const { upload_id } = JSON.parse(data) as { upload_id: string };
if (!upload_id) return;
} catch {
// ignore — silent recovery; the next event will retry
return;
}
if (processedDebounce) clearTimeout(processedDebounce);
processedDebounce = setTimeout(() => {
processedDebounce = null;
void refreshRecentFromFeed();
}, 500);
}
async function refreshRecentFromFeed() {
try {
const feed = await api.get<FeedResponse>('/feed?limit=50');
for (const upload of feed.uploads) {
if (upload.preview_url || upload.thumbnail_url) queue.pushLive(upload);
}
if (!current) advance();
} catch {
// ignore — silent recovery; the next event or reconnect delta retries
}
}
@@ -107,10 +122,27 @@
function handleFeedDelta(data: string) {
try {
const delta = JSON.parse(data) as DeltaResponse;
// Apply deletions + ban-hides FIRST and unconditionally: both are explicit, uncapped
// lists, so a moderated/removed photo (or a banned user's whole set) must leave the
// rotation even when the delta is truncated. This replays a `user-hidden`/delete the
// projector missed while disconnected — the reason a banned guest's slides used to
// keep cycling all night. (We can't infer removals by absence from a capped /feed
// backfill — older slides beyond the newest 200 would be falsely dropped.)
for (const id of delta.deleted_ids) {
const result = queue.remove(id, current?.id ?? null);
if (result.wasCurrent) advance();
}
for (const userId of delta.hidden_user_ids) {
const result = queue.removeByUser(userId, current?.id ?? null);
if (result.wasCurrent) advance();
}
// A truncated delta's `uploads` is only the newest slice of a larger gap; merging it
// alone would skip the older-but-still-new items in between. Backfill new uploads from
// a full feed fetch (pushLive dedupes by id, so the current slide isn't disturbed).
if (delta.truncated) {
void backfillFromFeed();
return;
}
for (const upload of delta.uploads) {
// Only enqueue displayable (processed) items; pushLive dedupes by id, so a
// later `upload-processed` still refines anything that arrives raw.
@@ -122,6 +154,20 @@
}
}
// Full-feed backfill used when a reconnect delta overflows the cap. Merges via
// pushLive (id-deduped) so a long-running projector recovers the whole gap.
async function backfillFromFeed() {
try {
const feed = await api.get<FeedResponse>('/feed?limit=200');
for (const upload of feed.uploads) {
if (upload.preview_url || upload.thumbnail_url) queue.pushLive(upload);
}
if (!current) advance();
} catch {
// ignore — the next live event keeps the show moving
}
}
function handleUserHidden(data: string) {
try {
const payload = JSON.parse(data) as { user_id: string };

View File

@@ -231,6 +231,20 @@
onSseEvent('feed-delta', (data) => {
try {
const delta = JSON.parse(data) as DeltaResponse;
// Evict removed content FIRST and unconditionally — deletions AND ban-hides are
// explicit, uncapped lists, so they apply even on a truncated delta (the stale-pill
// refresh below only prunes page 1, so a banned user's cards beyond page 1 would
// otherwise linger). Replays a `user-hidden`/delete missed while the tab was hidden.
if (delta.deleted_ids.length) {
const dead = new Set(delta.deleted_ids);
uploads = uploads.filter((u) => !dead.has(u.id));
if (selectedUpload && dead.has(selectedUpload.id)) selectedUpload = null;
}
if (delta.hidden_user_ids.length) {
const hidden = new Set(delta.hidden_user_ids);
uploads = uploads.filter((u) => !hidden.has(u.user_id));
if (selectedUpload && hidden.has(selectedUpload.user_id)) selectedUpload = null;
}
if (delta.truncated) {
// Missed more than the backend delta cap while backgrounded — the
// delta is only the newest slice, so merging would leave a silent gap
@@ -245,10 +259,6 @@
const fresh = delta.uploads.filter((u) => !seen.has(u.id));
if (fresh.length) uploads = [...fresh, ...uploads];
}
if (delta.deleted_ids.length) {
const dead = new Set(delta.deleted_ids);
uploads = uploads.filter((u) => !dead.has(u.id));
}
// A delta reconciles new uploads and deletions, but not like/comment
// counts that changed on already-visible cards while we were
// disconnected or lagged (a `resync`). Debounced page-1 refresh merges

View File

@@ -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>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { goto, afterNavigate } from '$app/navigation';
import { api, ApiError } from '$lib/api';
import { setAuth, getPin, getToken } from '$lib/auth';
import { setAuth, getPin, getToken, clearPin } from '$lib/auth';
import { browser } from '$app/environment';
import IconButton from '$lib/components/IconButton.svelte';
@@ -28,6 +28,26 @@
let pin = $state('');
let error = $state('');
let loading = $state(false);
let pinRequestLoading = $state(false);
let pinRequestSent = $state(false);
// Forgot the PIN entirely (never noted it, or a host reset it): ask a host to reset it.
// The endpoint always 204s (no name enumeration), so optimistically confirm regardless.
async function requestPinReset() {
if (!displayName.trim()) {
error = 'Bitte gib zuerst deinen Namen ein.';
return;
}
pinRequestLoading = true;
try {
await api.post('/recover/request', { display_name: displayName.trim() });
} catch {
// Non-fatal (rate limit etc.) — still confirm so the user isn't stuck.
} finally {
pinRequestLoading = false;
pinRequestSent = true;
}
}
// Pre-fill PIN from localStorage if available
if (browser) {
@@ -53,6 +73,10 @@
} catch (e) {
if (e instanceof ApiError) {
error = e.message;
// A wrong PIN here often means the locally-cached PIN is stale (a host reset it
// while this device was offline and missed the `pin-reset` SSE). Drop the cached
// value so it doesn't keep pre-filling the field with the dead PIN.
if (e.status === 401) clearPin();
} else {
error = 'Ein Fehler ist aufgetreten.';
}
@@ -124,6 +148,24 @@
</button>
</form>
<!-- PIN lost entirely (never noted, or reset by a host while offline): a soft dead-end
without this — offer the host-reset request path. -->
{#if pinRequestSent}
<p class="mt-4 text-center text-sm text-green-700 dark:text-green-400" data-testid="recover-pin-request-sent">
Anfrage gesendet. Bitte einen Host, deine PIN zurückzusetzen — komm danach mit der neuen PIN zurück.
</p>
{:else}
<button
type="button"
onclick={requestPinReset}
disabled={pinRequestLoading}
data-testid="recover-request-pin-reset"
class="mt-4 w-full text-center text-sm text-blue-600 underline disabled:opacity-50 dark:text-blue-400"
>
{pinRequestLoading ? 'Wird gesendet…' : 'PIN vergessen? Host um Zurücksetzen bitten'}
</button>
{/if}
<p class="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
Noch kein Konto?
<a href="/join" class="text-blue-600 hover:underline dark:text-blue-400">Neu beitreten</a>

View File

@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { getToken } from '$lib/auth';
import { addToQueue, loadQueue } from '$lib/upload-queue';
import { toast } from '$lib/toast-store';
import { showBottomNav } from '$lib/ui-store';
import { pendingFiles, pendingCaption, clearPending } from '$lib/pending-upload-store';
import { get } from 'svelte/store';
@@ -98,8 +99,18 @@
submitting = true;
vibrate(10);
const hashtagsString = captionTags.join(',');
let full = 0;
for (const sf of stagedFiles) {
await addToQueue(sf.file, caption, hashtagsString);
const result = await addToQueue(sf.file, caption, hashtagsString);
if (result === 'full') full++;
}
// Don't let a full queue silently swallow photos the user thinks were queued.
if (full > 0) {
toast(
`Warteschlange voll ${full} ${full === 1 ? 'Foto' : 'Fotos'} nicht hinzugefügt. Bitte warte, bis laufende Uploads fertig sind.`,
'error',
6000
);
}
clearPending();
goto('/feed');