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>
214 lines
7.7 KiB
Svelte
214 lines
7.7 KiB
Svelte
<script lang="ts">
|
|
import { goto, afterNavigate } from '$app/navigation';
|
|
import { api, ApiError } from '$lib/api';
|
|
import { setAuth, getPin, getToken, clearPin, getDisplayName } from '$lib/auth';
|
|
import { markGuideSeen } from '$lib/onboarding';
|
|
import { browser } from '$app/environment';
|
|
import IconButton from '$lib/components/IconButton.svelte';
|
|
|
|
// We only want history.back() when the user actually reached /recover via in-app
|
|
// (client-side) navigation; on a cold load (deep link, new tab) history.back() would
|
|
// land on `about:blank`. `type === 'enter'` is SvelteKit's initial page load in BOTH
|
|
// SSR and CSR modes — keying on it (rather than `from === null`, which is only null on
|
|
// a cold load under SSR) keeps the check correct with `ssr = false`.
|
|
let cameFromApp = $state(false);
|
|
afterNavigate(({ from, type }) => {
|
|
cameFromApp = type !== 'enter' && from !== null;
|
|
});
|
|
|
|
function goBack() {
|
|
// Prefer the actual previous page (most users land here from /join or /account).
|
|
// Fall back to a sensible default based on auth state for deep-linked users.
|
|
if (cameFromApp) {
|
|
window.history.back();
|
|
return;
|
|
}
|
|
goto(getToken() ? '/feed' : '/join');
|
|
}
|
|
|
|
let displayName = $state('');
|
|
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) {
|
|
const savedPin = getPin();
|
|
if (savedPin) pin = savedPin;
|
|
}
|
|
|
|
async function handleRecover() {
|
|
if (!displayName.trim() || !pin.trim()) return;
|
|
loading = true;
|
|
error = '';
|
|
try {
|
|
const res = await api.post<{
|
|
jwt: string;
|
|
user_id: string;
|
|
}>('/recover', { display_name: displayName.trim(), pin: pin.trim() });
|
|
|
|
setAuth(res.jwt, pin.trim(), res.user_id, displayName.trim());
|
|
// Recovering proves this guest already has an account, so they have already been
|
|
// through onboarding — on their ORIGINAL device, whose localStorage this one does
|
|
// not share. Without this, every guest who switches phone, clears site data or
|
|
// opens the event in a second browser gets the full first-run guide again.
|
|
markGuideSeen();
|
|
// Surface a welcome-back toast on /feed after navigation. sessionStorage
|
|
// scopes the cue to the next page load so it doesn't replay on refresh.
|
|
if (browser) sessionStorage.setItem('eventsnap_just_recovered', displayName.trim());
|
|
goto('/feed');
|
|
} catch (e) {
|
|
if (e instanceof ApiError) {
|
|
error = e.message;
|
|
// A wrong PIN CAN mean the locally-cached PIN is stale (a host reset it while this
|
|
// device was offline and missed the `pin-reset` SSE), and then dropping it stops the
|
|
// field pre-filling with a dead value. `+layout.svelte` already handles the online
|
|
// case; this is the offline backstop.
|
|
//
|
|
// But it must be narrow, because the backend returns the SAME 401 for a wrong PIN
|
|
// and an UNKNOWN NAME (deliberately — it closes an enumeration and timing oracle).
|
|
// Clearing on any 401 meant a guest who mistyped their own name lost the only copy
|
|
// of their PIN: localStorage is where it lives, the server keeps only the bcrypt,
|
|
// and rejoining under the same name 409s. One typo, permanently locked out of their
|
|
// own account, needing a host with a dashboard open.
|
|
//
|
|
// So clear only when the evidence actually points at a stale cache: the name they
|
|
// submitted is the one this device belongs to, AND the PIN that was rejected is the
|
|
// cached one. Any other 401 leaves stored state untouched.
|
|
const submittedOwnName =
|
|
getDisplayName()?.trim().toLowerCase() === displayName.trim().toLowerCase();
|
|
const submittedCachedPin = getPin() !== null && pin.trim() === getPin();
|
|
if (e.status === 401 && submittedOwnName && submittedCachedPin) clearPin();
|
|
} else {
|
|
error = 'Ein Fehler ist aufgetreten.';
|
|
}
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
// Strip non-digits synchronously in the input handler (not via $effect on
|
|
// bind:value) so a paste of "1234X" doesn't flash the longer string between
|
|
// the bind setting `pin` and the reactive cleanup reassigning it. Mutating
|
|
// el.value before Svelte's next render means the field never displays the
|
|
// invalid intermediate state.
|
|
function onPinInput(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;
|
|
pin = cleaned;
|
|
if (pin.length === 4 && displayName.trim() && !loading) {
|
|
handleRecover();
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div
|
|
class="flex min-h-screen flex-col bg-gray-50 px-4 pt-[env(safe-area-inset-top)] dark:bg-gray-950"
|
|
>
|
|
<div class="-mx-4 flex items-center px-2 py-3">
|
|
<IconButton label="Zurück" onclick={goBack} data-testid="recover-back">
|
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
|
</svg>
|
|
</IconButton>
|
|
</div>
|
|
<div class="m-auto w-full max-w-sm">
|
|
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900 dark:text-gray-100">
|
|
Konto wiederherstellen
|
|
</h1>
|
|
<p class="mb-6 text-center text-gray-600 dark:text-gray-400">
|
|
Gib deinen Namen und deinen PIN ein.
|
|
</p>
|
|
|
|
<form
|
|
onsubmit={(e) => {
|
|
e.preventDefault();
|
|
handleRecover();
|
|
}}
|
|
>
|
|
<input
|
|
type="text"
|
|
bind:value={displayName}
|
|
placeholder="Dein Name"
|
|
maxlength={50}
|
|
data-testid="recover-name-input"
|
|
class="input mb-3 text-lg"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={pin}
|
|
oninput={onPinInput}
|
|
placeholder="4-stelliger PIN"
|
|
maxlength={4}
|
|
inputmode="numeric"
|
|
pattern="[0-9]*"
|
|
data-testid="recover-pin-input"
|
|
class="input mb-3 text-center text-2xl font-mono tracking-widest"
|
|
/>
|
|
|
|
{#if error}
|
|
<p class="mb-3 text-sm text-red-600 dark:text-red-400" data-testid="recover-error">
|
|
{error}
|
|
</p>
|
|
{/if}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={loading || !displayName.trim() || pin.length < 4}
|
|
data-testid="recover-submit"
|
|
class="btn btn-primary btn-lg btn-block"
|
|
>
|
|
{loading ? 'Wird geladen...' : 'Wiederherstellen'}
|
|
</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>
|
|
</p>
|
|
</div>
|
|
</div>
|