The upload queue gains `parkedFor`, so a photo rejected for a reason that cannot change on its own stops re-pushing itself. A ban used to come back as a generic `forbidden`, which purged the blob and moved the row to `blocked` — a terminal state with no retry button — so lifting a ban restored everything except the photo actually in flight. Ban and release are now distinct codes that keep the blob, charge no attempt, and tell the guest what has to happen. `releaseResolvedParks` drains them at boot from /me/context, because the live `user-shown` / `event-opened` events only reach a tab that was open when the host acted, and the usual sequence is the other way round. Two signals were firing on nothing. A filtered feed set `feedStale` on EVERY delta without deduping — and the delta cursor boundary is inclusive while sse.ts deliberately rewinds `lastEventTime`, so deltas routinely re-return rows already delivered. With the backstop polling every 60-120s, a guest who tapped a hashtag got a "Neue Beiträge" pill they could never clear, each tap costing a full filtered refetch. It now dedupes in both branches. The SSE liveness backstop had the mirror problem: `noteDelivered` harvested id, upload_id AND user_id from every payload, so by the time anything was deleted or anyone banned, their ids were already marked delivered from ordinary traffic about live content. The `deleted_ids` and `hidden_user_ids` clauses were false essentially always, leaving a half-open socket undetected while a host moderated into a feed nobody was listening to. Each event now records only the id its own clause tests. Also: /admin no longer bounces to /join on a cleared session — AUTH_ROUTES had the `/admin` prefix, which suppressed clearAuth() on the dashboard and let the login guard bounce back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
490 lines
16 KiB
Svelte
490 lines
16 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { goto } from '$app/navigation';
|
|
import { api, ApiError } from '$lib/api';
|
|
import { setAuth } from '$lib/auth';
|
|
import { markGuideSeen } from '$lib/onboarding';
|
|
import { focusTrap } from '$lib/actions/focus-trap';
|
|
|
|
// Show which event the guest is joining (USER_JOURNEYS §1). Public, pre-auth.
|
|
let eventName = $state('');
|
|
// The operator's own data notice, if they set one. Usually empty — see the notice block below.
|
|
let privacyNote = $state('');
|
|
let noticeOpen = $state(false);
|
|
onMount(async () => {
|
|
try {
|
|
const ev = await api.get<{ name: string; slug: string; privacy_note?: string }>('/event');
|
|
eventName = ev.name;
|
|
privacyNote = ev.privacy_note?.trim() ?? '';
|
|
} catch {
|
|
// Non-fatal — fall back to the generic heading if the lookup fails.
|
|
}
|
|
});
|
|
|
|
let displayName = $state('');
|
|
let error = $state('');
|
|
let loading = $state(false);
|
|
let showPinModal = $state(false);
|
|
let pin = $state('');
|
|
let copied = $state(false);
|
|
|
|
// Name-taken state — shown instead of the normal form
|
|
let nameTaken = $state(false);
|
|
let takenName = $state('');
|
|
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);
|
|
|
|
/**
|
|
* Stable idempotency key for THIS join attempt, surviving a reload or a PWA relaunch.
|
|
*
|
|
* The failure it closes: `/join` commits the account and the PIN hash, but the plaintext PIN
|
|
* only ever exists in the response body. Lose that response — the 5G-to-nothing handoff every
|
|
* venue car park has — and the retry used to 409 on the guest's own name, leaving them staring
|
|
* at a PIN prompt for a PIN nobody had ever seen. With a key the server recognises the retry
|
|
* and answers with a working PIN (it rotates it; see migration 027).
|
|
*
|
|
* Kept in localStorage rather than component state because the guest's instinctive response to
|
|
* a hung request is to reload the page, which would otherwise mint a fresh key and re-create
|
|
* the exact bug. Cleared once the join has demonstrably landed.
|
|
*/
|
|
const JOIN_KEY_STORAGE = 'eventsnap:join-attempt-id';
|
|
function joinAttemptId(): string {
|
|
let id: string | null = null;
|
|
try {
|
|
id = localStorage.getItem(JOIN_KEY_STORAGE);
|
|
} catch {
|
|
// Private mode / storage disabled. A per-call UUID is still better than none: it makes
|
|
// a retry within this page view idempotent, which is the common case.
|
|
}
|
|
if (!id) {
|
|
id = crypto.randomUUID();
|
|
try {
|
|
localStorage.setItem(JOIN_KEY_STORAGE, id);
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
}
|
|
return id;
|
|
}
|
|
|
|
async function handleJoin() {
|
|
if (!displayName.trim()) return;
|
|
loading = true;
|
|
error = '';
|
|
try {
|
|
const res = await api.post<{
|
|
jwt: string;
|
|
pin: string;
|
|
user_id: string;
|
|
is_new: boolean;
|
|
}>('/join', {
|
|
display_name: displayName.trim(),
|
|
client_join_id: joinAttemptId()
|
|
});
|
|
|
|
setAuth(res.jwt, res.pin, res.user_id, displayName.trim());
|
|
// The join landed and we hold the PIN — retire the key so a later deliberate join (a
|
|
// second guest on a shared device) is a new attempt rather than a retry of this one.
|
|
try {
|
|
localStorage.removeItem(JOIN_KEY_STORAGE);
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
pin = res.pin;
|
|
showPinModal = true;
|
|
} catch (e) {
|
|
if (e instanceof ApiError && e.code === 'conflict') {
|
|
takenName = displayName.trim();
|
|
nameTaken = true;
|
|
} else if (e instanceof ApiError) {
|
|
error = e.message;
|
|
} else {
|
|
error = 'Ein Fehler ist aufgetreten.';
|
|
}
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
async function handleInlineRecover() {
|
|
if (recoveryPin.length < 4) return;
|
|
recoveryLoading = true;
|
|
recoveryError = '';
|
|
try {
|
|
const res = await api.post<{ jwt: string; user_id: string }>('/recover', {
|
|
display_name: takenName,
|
|
pin: recoveryPin.trim()
|
|
});
|
|
setAuth(res.jwt, recoveryPin.trim(), res.user_id, takenName);
|
|
// Same as the dedicated /recover page: this is an EXISTING guest reclaiming their
|
|
// account, not a first-timer, so don't replay the onboarding guide at them.
|
|
markGuideSeen();
|
|
goto('/feed');
|
|
} catch (e) {
|
|
if (e instanceof ApiError) {
|
|
recoveryError = e.message;
|
|
} else {
|
|
recoveryError = 'Ein Fehler ist aufgetreten.';
|
|
}
|
|
} finally {
|
|
recoveryLoading = false;
|
|
}
|
|
}
|
|
|
|
function tryDifferentName() {
|
|
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;
|
|
setTimeout(() => (copied = false), 2000);
|
|
}
|
|
|
|
function goToFeed() {
|
|
goto('/feed');
|
|
}
|
|
|
|
function closePinModal() {
|
|
// setAuth has already run on join success — the user is authenticated.
|
|
// Closing the PIN reminder while leaving them on /join would render the
|
|
// (already-completed) join form again. Honor the dismissal by routing
|
|
// them where they actually want to be.
|
|
showPinModal = false;
|
|
goto('/feed');
|
|
}
|
|
|
|
// Strip non-digits synchronously in the input handler so paste of "1234X"
|
|
// never flashes the longer string. Auto-submits on the 4th digit so the
|
|
// user doesn't have to chase the (now cosmetic) Anmelden button.
|
|
function onRecoveryPinInput(e: Event) {
|
|
const el = e.currentTarget as HTMLInputElement;
|
|
const cleaned = el.value.replace(/\D/g, '').slice(0, 4);
|
|
if (cleaned !== el.value) el.value = cleaned;
|
|
recoveryPin = cleaned;
|
|
if (recoveryPin.length === 4 && !recoveryLoading) {
|
|
handleInlineRecover();
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div
|
|
class="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-gradient-to-b from-primary-50 via-gray-50 to-gray-50 px-4 py-10 dark:from-gray-900 dark:via-gray-950 dark:to-gray-950"
|
|
>
|
|
<!-- Soft celebratory glow behind the card — a champagne-gold and a silver wash
|
|
for the classic warm/cool wedding tension, kept subtle. -->
|
|
<div
|
|
aria-hidden="true"
|
|
class="pointer-events-none absolute -top-20 h-72 w-72 rounded-full bg-primary-200/30 blur-3xl dark:bg-primary-900/25"
|
|
></div>
|
|
<div
|
|
aria-hidden="true"
|
|
class="pointer-events-none absolute -bottom-24 right-0 h-72 w-72 rounded-full bg-gray-200/50 blur-3xl dark:bg-gray-800/40"
|
|
></div>
|
|
|
|
<div class="relative w-full max-w-sm">
|
|
<!-- Brand -->
|
|
<div class="mb-7 flex flex-col items-center text-center">
|
|
<div
|
|
class="mb-3 flex h-16 w-16 items-center justify-center rounded-2xl bg-blue-600 text-white shadow-lg shadow-primary-600/30"
|
|
>
|
|
<svg class="h-8 w-8" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
<path
|
|
d="M4 8.5A2.5 2.5 0 0 1 6.5 6h1.2c.5 0 .95-.28 1.17-.72l.42-.85A1.5 1.5 0 0 1 10.9 3.6h2.2c.57 0 1.09.32 1.34.83l.42.85c.22.44.67.72 1.17.72h1.2A2.5 2.5 0 0 1 20 8.5v7A2.5 2.5 0 0 1 17.5 18h-11A2.5 2.5 0 0 1 4 15.5v-7Z"
|
|
stroke="currentColor"
|
|
stroke-width="1.6"
|
|
/>
|
|
<circle cx="12" cy="12" r="3.2" stroke="currentColor" stroke-width="1.6" />
|
|
</svg>
|
|
</div>
|
|
<span
|
|
class="font-display text-2xl font-semibold tracking-tight text-gray-900 dark:text-gray-100"
|
|
>EventSnap</span
|
|
>
|
|
</div>
|
|
|
|
<!-- Invitation card -->
|
|
<div class="card p-6 shadow-xl">
|
|
{#if nameTaken}
|
|
<!-- Name-taken state: sign in with PIN or choose a different name -->
|
|
<div
|
|
class="mb-5 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800/60 dark:bg-amber-950/30"
|
|
>
|
|
<p class="font-semibold text-amber-900 dark:text-amber-200">
|
|
„{takenName}“ ist bereits vergeben.
|
|
</p>
|
|
<p class="mt-1 text-sm text-amber-800 dark:text-amber-300/90">
|
|
Wähle einen anderen Namen, z. B. einen Spitznamen oder füge deinen Nachnamen hinzu
|
|
(„{takenName}
|
|
M.“ oder „{takenName} aus Berlin“).
|
|
</p>
|
|
</div>
|
|
|
|
<p class="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Falls du das bist, melde dich mit deinem PIN an:
|
|
</p>
|
|
|
|
<form
|
|
onsubmit={(e) => {
|
|
e.preventDefault();
|
|
handleInlineRecover();
|
|
}}
|
|
>
|
|
<input
|
|
type="text"
|
|
value={recoveryPin}
|
|
oninput={onRecoveryPinInput}
|
|
placeholder="4-stelliger PIN"
|
|
maxlength={4}
|
|
inputmode="numeric"
|
|
pattern="[0-9]*"
|
|
data-testid="recovery-pin-input"
|
|
class="input mb-3 text-center font-mono text-2xl tracking-widest"
|
|
/>
|
|
|
|
{#if recoveryError}
|
|
<p class="mb-3 text-sm text-red-600 dark:text-red-400" data-testid="recovery-error">
|
|
{recoveryError}
|
|
</p>
|
|
{/if}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={recoveryLoading || recoveryPin.length < 4}
|
|
data-testid="recovery-submit"
|
|
class="btn btn-primary btn-block mb-3"
|
|
>
|
|
{recoveryLoading ? 'Wird angemeldet...' : 'Anmelden'}
|
|
</button>
|
|
</form>
|
|
|
|
<button
|
|
onclick={tryDifferentName}
|
|
data-testid="try-different-name"
|
|
class="btn btn-secondary btn-block"
|
|
>
|
|
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 -->
|
|
<p class="mb-1 text-center text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
Willkommen bei
|
|
</p>
|
|
{#if eventName}
|
|
<h1
|
|
class="mb-3 text-center text-3xl font-semibold text-gray-900 dark:text-gray-100"
|
|
data-testid="join-event-name"
|
|
>
|
|
{eventName}
|
|
</h1>
|
|
{:else}
|
|
<h1 class="mb-3 text-center text-3xl font-semibold text-gray-900 dark:text-gray-100">
|
|
dem Event
|
|
</h1>
|
|
{/if}
|
|
<p class="mb-6 text-center text-gray-600 dark:text-gray-400">
|
|
Gib deinen Namen ein, um Fotos zu teilen und die Galerie zu sehen.
|
|
</p>
|
|
|
|
<form
|
|
onsubmit={(e) => {
|
|
e.preventDefault();
|
|
handleJoin();
|
|
}}
|
|
>
|
|
<input
|
|
type="text"
|
|
bind:value={displayName}
|
|
placeholder="Dein Name"
|
|
maxlength={50}
|
|
data-testid="join-name-input"
|
|
class="input mb-3 text-lg"
|
|
/>
|
|
|
|
{#if error}
|
|
<p class="mb-3 text-sm text-red-600 dark:text-red-400" data-testid="join-error">
|
|
{error}
|
|
</p>
|
|
{/if}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={loading || !displayName.trim()}
|
|
data-testid="join-submit"
|
|
class="btn btn-primary btn-lg btn-block"
|
|
>
|
|
{loading ? 'Wird geladen...' : 'Beitreten'}
|
|
</button>
|
|
</form>
|
|
|
|
<!--
|
|
Data notice AT THE POINT OF COLLECTION.
|
|
|
|
There was none at all — not on this page, not anywhere pre-auth — while
|
|
PROJECT.md:407 claimed there was. For ~100 EU guests uploading photos of
|
|
identifiable people, including children, that was the most consequential gap in
|
|
the whole audit, and the least work to close.
|
|
|
|
The baseline text below is hardcoded rather than read from `privacy_note`,
|
|
because `privacy_note` defaults to '' (migration 009) — a notice an operator can
|
|
leave blank is not a notice. The operator's own text is shown IN ADDITION when
|
|
they have set one.
|
|
|
|
Summary visible without a tap (that is the part that has to be unmissable);
|
|
detail behind a disclosure so it does not bury the one field on the page.
|
|
-->
|
|
<div class="mt-5 border-t border-gray-200 pt-4 dark:border-gray-700">
|
|
<p class="text-xs leading-relaxed text-gray-600 dark:text-gray-400">
|
|
Mit dem Beitreten legst du ein Konto mit deinem Namen an. Deine Fotos, Kommentare und
|
|
dein Name sind für alle Gäste dieses Events sichtbar.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onclick={() => (noticeOpen = !noticeOpen)}
|
|
data-testid="join-privacy-toggle"
|
|
aria-expanded={noticeOpen}
|
|
class="mt-1 text-xs font-medium text-blue-600 hover:underline dark:text-blue-400"
|
|
>
|
|
{noticeOpen ? 'Weniger anzeigen' : 'Was passiert mit meinen Daten?'}
|
|
</button>
|
|
{#if noticeOpen}
|
|
<div
|
|
class="mt-2 space-y-2 text-xs leading-relaxed text-gray-600 dark:text-gray-400"
|
|
data-testid="join-privacy-note"
|
|
>
|
|
<p>
|
|
<strong class="text-gray-800 dark:text-gray-200">Was gespeichert wird:</strong> dein angezeigter
|
|
Name, deine hochgeladenen Fotos und Videos samt Aufnahmezeitpunkt, deine Bildtexte, Kommentare
|
|
und Likes. Dazu ein verschlüsselter Prüfwert deines PINs — der PIN selbst wird nicht gespeichert.
|
|
</p>
|
|
<p>
|
|
<strong class="text-gray-800 dark:text-gray-200">Wer es sehen kann:</strong> alle Gäste
|
|
dieses Events. Die Gastgeber können außerdem Beiträge und Kommentare entfernen. Am Ende
|
|
erhalten die Gastgeber ein Archiv mit allen Fotos.
|
|
</p>
|
|
<p>
|
|
<strong class="text-gray-800 dark:text-gray-200">Wie lange:</strong> bis die Gastgeber
|
|
das Event abschließen und die Installation abbauen. Du kannst eigene Fotos jederzeit selbst
|
|
löschen, und über „Mein Konto“ dein Konto samt aller Inhalte entfernen lassen.
|
|
</p>
|
|
<p>
|
|
Lade bitte keine Fotos von Personen hoch, die damit nicht einverstanden sind — bei
|
|
Kindern brauchst du das Einverständnis der Eltern.
|
|
</p>
|
|
{#if privacyNote}
|
|
<p class="border-t border-gray-200 pt-2 dark:border-gray-700">
|
|
<strong class="text-gray-800 dark:text-gray-200">Hinweis der Gastgeber:</strong>
|
|
{privacyNote}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<p class="mt-4 text-center text-sm">
|
|
<a
|
|
href="/recover"
|
|
data-testid="link-to-recover"
|
|
class="text-blue-600 hover:underline dark:text-blue-400"
|
|
>Ich habe bereits einen Account</a
|
|
>
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if showPinModal}
|
|
<div
|
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
|
data-testid="pin-modal"
|
|
>
|
|
<!-- Vertically centred, so anything taller than the viewport is clipped equally at both
|
|
ends — and this card is ~320 px, which a phone in landscape does not have. The
|
|
casualty would be "Weiter zur Galerie" at the very moment a first-time guest has to
|
|
get past it, with no visible scrollbar to suggest there is more. Cap the height and
|
|
scroll; `overscroll-contain` stops the join page behind from scrolling instead. -->
|
|
<div
|
|
class="card max-h-full w-full max-w-sm overflow-y-auto overscroll-contain p-6 shadow-xl"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="pin-modal-title"
|
|
use:focusTrap={{ onclose: closePinModal }}
|
|
>
|
|
<h2 id="pin-modal-title" class="mb-2 text-xl font-bold text-gray-900 dark:text-gray-100">
|
|
Dein Wiederherstellungs-PIN
|
|
</h2>
|
|
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
|
Merke dir diesen PIN! Du brauchst ihn, um dein Konto auf einem anderen Gerät
|
|
wiederherzustellen.
|
|
</p>
|
|
|
|
<div class="surface-muted mb-4 flex items-center justify-center gap-3 p-4">
|
|
<span
|
|
class="font-mono text-4xl font-bold tracking-widest text-gray-900 dark:text-gray-100"
|
|
data-testid="pin-display">{pin}</span
|
|
>
|
|
<button onclick={copyPin} data-testid="pin-copy" class="btn btn-secondary btn-sm min-h-11">
|
|
{copied ? 'Kopiert!' : 'Kopieren'}
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
onclick={goToFeed}
|
|
data-testid="continue-to-feed"
|
|
class="btn btn-primary btn-block mb-2"
|
|
>
|
|
Weiter zur Galerie
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={closePinModal}
|
|
class="btn btn-ghost btn-block text-sm text-gray-500 dark:text-gray-400"
|
|
>
|
|
Schließen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|