fix(user-flow): close offline-queue, export, session, ban & realtime gaps
Some checks failed
E2E / Playwright E2E (chromium-desktop) (push) Failing after 6m52s
E2E / Cross-UA smoke matrix (push) Failing after 3m50s

Comprehensive user-flow review across guest/host/admin roles, then fixes with
e2e regression guards. Highlights:

- Offline upload queue auto-resumes on reconnect: network errors keep items
  pending (not error), 4xx are terminal (no infinite retry), quota exhaustion
  returns a distinct 413; queue cap + dedup.
- Export lifecycle: release atomically locks uploads; reopen invalidates and
  re-release regenerates the keepsake; workers claim their job atomically so a
  reopen->re-release can't corrupt the ZIP; startup re-spawns interrupted
  exports.
- Sessions slide on activity (no 30-day cliff); JWT expiry deferred to the
  revocable session row; sign-out-everywhere + revoke-on-PIN-reset.
- Ban always hides content (v_feed / find_visible_media / export filter
  is_banned) but stays a read-only ban per USER_JOURNEYS §10 — sessions are
  not revoked, read access + keepsake download preserved.
- Realtime: server-clock SSE delta cursor; event-closed/opened drive the UI
  live; feed_delta rate-limited; like returns {liked, like_count} to fix
  multi-device drift; lightbox live comments; diashow delta backfill.
- Forgotten-PIN in-app request flow; simultaneous same-name join returns 409;
  quota increment is transactional; operator floor.

Adds e2e/specs/10-flow-review/ (offline resume, export integrity, deterministic
anti-race guard) and updates existing specs for the new contracts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-10 23:05:37 +02:00
parent 16d1f356be
commit 641174717c
38 changed files with 1400 additions and 153 deletions

View File

@@ -15,6 +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';
let { children } = $props();
@@ -39,6 +40,10 @@
try {
const ctx = await api.get<MeContextDto>('/me/context');
privacyNote.set(ctx.privacy_note);
eventState.set({
uploadsLocked: ctx.uploads_locked,
galleryReleased: ctx.gallery_released
});
} catch {
// Cross-cutting hydration on boot — failure is non-fatal; users without
// a session land on /join anyway, and the per-page mount will retry.
@@ -61,7 +66,11 @@
} catch {
// Malformed payload — discard; nothing actionable for the user.
}
})
}),
// 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()),
onSseEvent('event-opened', () => markOpened())
);
});

View File

@@ -20,6 +20,7 @@
let expiry = $state<Date | null>(null);
let pinCopied = $state(false);
let leaveConfirmOpen = $state(false);
let leaveEverywhere = $state(false);
let dataModeWarningOpen = $state(false);
// `pin` is sourced from the shared `currentPin` store so a global pin-reset SSE
@@ -99,10 +100,14 @@
setTimeout(() => (pinCopied = false), 2000);
}
async function handleLogout() {
async function handleLogout(everywhere = false) {
// Session-delete is best-effort: the JWT is going away on this device either way,
// so a network failure shouldn't block the user from leaving the event.
try { await api.delete('/session'); } catch { /* best-effort logout */ }
// `everywhere` revokes ALL of this user's sessions (other phones/laptops recovered
// via PIN), not just this device.
try {
await api.delete(everywhere ? '/sessions' : '/session');
} catch { /* best-effort logout */ }
// Wipe the IndexedDB upload queue so a second guest using the same device can't
// inherit (or be blamed for) this guest's pending uploads. Not done on a 401
// auto-clear — that path preserves the queue in case the user re-authenticates.
@@ -379,9 +384,9 @@
</svg>
</a>
<!-- Leave / logout -->
<!-- Leave / logout (this device) -->
<button
onclick={() => (leaveConfirmOpen = true)}
onclick={() => { leaveEverywhere = false; leaveConfirmOpen = true; }}
class="flex w-full items-center gap-3 border-t border-gray-100 px-5 py-4 text-left transition hover:bg-red-50 dark:border-gray-700 dark:hover:bg-red-950/30"
>
<svg class="h-5 w-5 text-red-500 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
@@ -389,6 +394,17 @@
</svg>
<span class="flex-1 text-sm font-medium text-red-600 dark:text-red-400">Event verlassen</span>
</button>
<!-- Sign out everywhere (all devices) -->
<button
onclick={() => { leaveEverywhere = true; leaveConfirmOpen = true; }}
class="flex w-full items-center gap-3 border-t border-gray-100 px-5 py-4 text-left transition hover:bg-red-50 dark:border-gray-700 dark:hover:bg-red-950/30"
>
<svg class="h-5 w-5 text-red-500 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
<span class="flex-1 text-sm font-medium text-red-600 dark:text-red-400">Auf allen Geräten abmelden</span>
</button>
</div>
</div>
</div>
@@ -435,10 +451,12 @@
<!-- Leave-confirm bottom sheet -->
<ConfirmSheet
open={leaveConfirmOpen}
title="Event verlassen?"
message="Du wirst abgemeldet. Mit deinem PIN kannst du jederzeit zurückkehren."
title={leaveEverywhere ? 'Auf allen Geräten abmelden?' : 'Event verlassen?'}
message={leaveEverywhere
? 'Alle deine Sitzungen (auch auf anderen Geräten) werden beendet. Mit deinem PIN kannst du jederzeit zurückkehren.'
: 'Du wirst abgemeldet. Mit deinem PIN kannst du jederzeit zurückkehren.'}
confirmLabel="Abmelden"
tone="danger"
onConfirm={handleLogout}
onConfirm={() => handleLogout(leaveEverywhere)}
onCancel={() => (leaveConfirmOpen = false)}
/>

View File

@@ -127,7 +127,6 @@
// Ban modal state
let banTarget = $state<UserSummary | null>(null);
let banHideUploads = $state(false);
let banSubmitting = $state(false);
// PIN reset state — `pinModal` holds the freshly-issued plaintext PIN. We forget it
@@ -254,14 +253,13 @@
function openBanModal(user: UserSummary) {
banTarget = user;
banHideUploads = false;
}
async function confirmBan() {
if (!banTarget) return;
banSubmitting = true;
try {
await api.post(`/host/users/${banTarget.id}/ban`, { hide_uploads: banHideUploads });
await api.post(`/host/users/${banTarget.id}/ban`, {});
toast(`${banTarget.display_name} wurde gesperrt.`, 'success');
banTarget = null;
users = await api.get<UserSummary[]>('/host/users');
@@ -416,17 +414,14 @@
{/if}
</Modal>
<!-- Ban modal — checkbox-bearing, so uses the Modal shell instead of ConfirmSheet. -->
<!-- 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">
Was soll mit den Uploads von <strong>{banTarget.display_name}</strong> passieren?
<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>
<label class="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<input type="checkbox" bind:checked={banHideUploads} class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600" />
<span class="text-sm text-gray-700 dark:text-gray-300">Uploads aus der Galerie ausblenden</span>
</label>
<div class="flex gap-2">
<button onclick={() => (banTarget = null)} class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:active:bg-gray-800">Abbrechen</button>
<button onclick={confirmBan} disabled={banSubmitting} class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 active:bg-red-700 disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-400 dark:active:bg-red-400">

View File

@@ -9,7 +9,7 @@
import { SlideQueue } from '$lib/diashow/queue';
import { transitions, findTransition } from '$lib/diashow/transitions';
import { acquireWakeLock, releaseWakeLock } from '$lib/diashow/wakelock';
import type { FeedUpload, FeedResponse } from '$lib/types';
import type { FeedUpload, FeedResponse, DeltaResponse } from '$lib/types';
const DWELL_OPTIONS = [3000, 6000, 10000];
@@ -100,6 +100,28 @@
}
}
// After an all-night projector reconnects (SSE drop, network blip), the live events
// it missed are gone. The SSE client fans out a `feed-delta` of everything since the
// last seen timestamp — merge it so the show backfills the gap instead of silently
// stalling on a stale set.
function handleFeedDelta(data: string) {
try {
const delta = JSON.parse(data) as DeltaResponse;
for (const id of delta.deleted_ids) {
const result = queue.remove(id, current?.id ?? null);
if (result.wasCurrent) advance();
}
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.
if (upload.preview_url || upload.thumbnail_url) queue.pushLive(upload);
}
if (!current) advance();
} catch {
// ignore — non-fatal; the next live event keeps the show moving
}
}
function handleUserHidden(data: string) {
try {
const payload = JSON.parse(data) as { user_id: string };
@@ -164,6 +186,7 @@
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
unsubs.push(onSseEvent('upload-deleted', handleUploadDeleted));
unsubs.push(onSseEvent('user-hidden', handleUserHidden));
unsubs.push(onSseEvent('feed-delta', handleFeedDelta));
void loadInitial();
});

View File

@@ -389,18 +389,24 @@
async function handleLike(id: string) {
try {
await api.post(`/upload/${id}/like`);
// Set state from the server's authoritative response rather than blind-inverting
// local state. On a second device (same recovered user), the `like-update`
// broadcast only carries `like_count` — so a blind invert would drift
// `liked_by_me` until refresh. The response gives both, exactly.
const res = await api.post<{ liked: boolean; like_count: number | null }>(`/upload/${id}/like`);
vibrate(10);
// like_count is null when the server's count query hiccuped — keep the current
// count in that case rather than adopting a wrong number.
uploads = uploads.map((u) =>
u.id === id
? { ...u, liked_by_me: !u.liked_by_me, like_count: u.liked_by_me ? u.like_count - 1 : u.like_count + 1 }
? { ...u, liked_by_me: res.liked, like_count: res.like_count ?? u.like_count }
: u
);
if (selectedUpload?.id === id) {
selectedUpload = {
...selectedUpload,
liked_by_me: !selectedUpload.liked_by_me,
like_count: selectedUpload.liked_by_me ? selectedUpload.like_count - 1 : selectedUpload.like_count + 1,
liked_by_me: res.liked,
like_count: res.like_count ?? selectedUpload.like_count,
};
}
} catch (e) {

View File

@@ -27,8 +27,16 @@
export_released: boolean;
}
interface PinResetRequest {
id: string;
user_id: string;
display_name: string;
created_at: string;
}
let event = $state<EventStatus | null>(null);
let users = $state<UserSummary[]>([]);
let pinResetRequests = $state<PinResetRequest[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
@@ -47,7 +55,6 @@
// Ban modal state
let banTarget = $state<UserSummary | null>(null);
let banHideUploads = $state(false);
let banSubmitting = $state(false);
// PIN reset modal state. `pinModal` holds the freshly-issued plaintext PIN; it is
@@ -114,9 +121,10 @@
loading = true;
error = null;
try {
[event, users] = await Promise.all([
[event, users, pinResetRequests] = await Promise.all([
api.get<EventStatus>('/host/event'),
api.get<UserSummary[]>('/host/users')
api.get<UserSummary[]>('/host/users'),
api.get<PinResetRequest[]>('/host/pin-reset-requests')
]);
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Fehler beim Laden.';
@@ -125,6 +133,25 @@
}
}
async function resetPinForRequest(req: PinResetRequest) {
try {
const res = await api.post<{ pin: string }>(`/host/users/${req.user_id}/pin-reset`);
pinModal = { name: req.display_name, pin: res.pin };
await reload();
} catch (e: unknown) {
toastError(e);
}
}
async function dismissPinRequest(req: PinResetRequest) {
try {
await api.delete(`/host/pin-reset-requests/${req.id}`);
pinResetRequests = pinResetRequests.filter((r) => r.id !== req.id);
} catch (e: unknown) {
toastError(e);
}
}
async function toggleEventLock() {
if (!event) return;
try {
@@ -153,14 +180,13 @@
function openBanModal(user: UserSummary) {
banTarget = user;
banHideUploads = false;
}
async function confirmBan() {
if (!banTarget) return;
banSubmitting = true;
try {
await api.post(`/host/users/${banTarget.id}/ban`, { hide_uploads: banHideUploads });
await api.post(`/host/users/${banTarget.id}/ban`, {});
toast(`${banTarget.display_name} wurde gesperrt.`, 'success');
banTarget = null;
await reload();
@@ -278,21 +304,14 @@
{/if}
</Modal>
<!-- Ban modal — needs a checkbox so it's not a pure ConfirmSheet, but still gets the same a11y shell. -->
<!-- Ban modal — ban always hides now, so this is a plain confirm (no checkbox). -->
<Modal open={banTarget !== null} titleId="host-ban-modal-title" onClose={() => (banTarget = null)}>
{#if banTarget}
<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">
Was soll mit den Uploads von <strong>{banTarget.display_name}</strong> passieren?
<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>
<label class="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<input
type="checkbox"
bind:checked={banHideUploads}
class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Uploads aus der Galerie ausblenden</span>
</label>
<div class="flex gap-2">
<button
onclick={() => (banTarget = null)}
@@ -336,6 +355,41 @@
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300">{error}</div>
{:else if event}
<!-- ── PIN-Reset-Anfragen ──────────────────────────────────────── -->
{#if pinResetRequests.length > 0}
<div class="overflow-hidden rounded-xl border border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30">
<div class="border-b border-amber-200 px-5 py-3 dark:border-amber-900">
<h2 class="font-semibold text-amber-900 dark:text-amber-200">
PIN vergessen — {pinResetRequests.length} Anfrage{pinResetRequests.length === 1 ? '' : 'n'}
</h2>
<p class="mt-0.5 text-xs text-amber-700 dark:text-amber-400">
Prüfe die Identität, bevor du eine PIN zurücksetzt.
</p>
</div>
<ul class="divide-y divide-amber-200 dark:divide-amber-900">
{#each pinResetRequests as req (req.id)}
<li class="flex items-center justify-between gap-3 px-5 py-3">
<span class="min-w-0 truncate font-medium text-amber-900 dark:text-amber-100">{req.display_name}</span>
<div class="flex shrink-0 gap-2">
<button
onclick={() => dismissPinRequest(req)}
class="rounded-lg border border-amber-300 px-3 py-1.5 text-sm text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-200 dark:hover:bg-amber-900/40"
>
Ablehnen
</button>
<button
onclick={() => resetPinForRequest(req)}
class="rounded-lg bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-400"
>
PIN zurücksetzen
</button>
</div>
</li>
{/each}
</ul>
</div>
{/if}
<!-- ── Statistiken ─────────────────────────────────────────────── -->
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<button

View File

@@ -29,6 +29,9 @@
let recoveryPin = $state('');
let recoveryError = $state('');
let recoveryLoading = $state(false);
// Forgot-PIN request state (asks a host to reset it).
let pinRequestSent = $state(false);
let pinRequestLoading = $state(false);
async function handleJoin() {
if (!displayName.trim()) return;
@@ -85,9 +88,24 @@
nameTaken = false;
recoveryPin = '';
recoveryError = '';
pinRequestSent = false;
// Keep displayName so the user can edit it slightly
}
// Forgot the PIN entirely: ask a host to reset it. The endpoint always 204s (no name
// enumeration), so we optimistically show a confirmation regardless.
async function requestPinReset() {
pinRequestLoading = true;
try {
await api.post('/recover/request', { display_name: takenName });
} catch {
// Non-fatal (rate limit etc.) — still show the confirmation so the user isn't stuck.
} finally {
pinRequestLoading = false;
pinRequestSent = true;
}
}
function copyPin() {
navigator.clipboard.writeText(pin);
copied = true;
@@ -173,6 +191,23 @@
Anderen Namen wählen
</button>
<!-- Forgot the PIN entirely — ask a host to reset it in-app. -->
{#if pinRequestSent}
<p class="mt-3 rounded-lg bg-green-50 px-4 py-3 text-center text-sm text-green-700 dark:bg-green-950/30 dark:text-green-300">
Anfrage gesendet. Bitte einen Host, deine PIN zurückzusetzen — danach kannst du dich
mit der neuen PIN anmelden.
</p>
{:else}
<button
onclick={requestPinReset}
disabled={pinRequestLoading}
data-testid="request-pin-reset"
class="mt-3 w-full text-center text-sm text-blue-600 underline decoration-dotted underline-offset-2 hover:text-blue-700 disabled:opacity-50 dark:text-blue-400"
>
{pinRequestLoading ? 'Wird gesendet…' : 'PIN vergessen? Host um Zurücksetzen bitten'}
</button>
{/if}
{:else}
<!-- Normal join form -->
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900 dark:text-gray-100">Willkommen!</h1>