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:
@@ -15,9 +15,19 @@ export const isAuthenticated = writable(false);
|
||||
*/
|
||||
export const currentPin = writable<string | null>(null);
|
||||
|
||||
export function getToken(): string | null {
|
||||
// Guest auth lives in localStorage (persists across sessions — a guest returning to the
|
||||
// event days later stays signed in). The ADMIN token lives in sessionStorage instead
|
||||
// (USER_JOURNEYS §11.1): its tighter 1-day lifetime is meant to bound exposure on a shared
|
||||
// "event laptop", and sessionStorage clears on tab/browser close, which localStorage defeats.
|
||||
// Reads check sessionStorage FIRST so an active admin token wins over any leftover guest
|
||||
// token on the same device.
|
||||
function readAuth(key: string): string | null {
|
||||
if (!browser) return null;
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
return sessionStorage.getItem(key) ?? localStorage.getItem(key);
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return readAuth(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function getPin(): string | null {
|
||||
@@ -37,13 +47,11 @@ export function clearPin(): void {
|
||||
}
|
||||
|
||||
export function getUserId(): string | null {
|
||||
if (!browser) return null;
|
||||
return localStorage.getItem(USER_ID_KEY);
|
||||
return readAuth(USER_ID_KEY);
|
||||
}
|
||||
|
||||
export function getDisplayName(): string | null {
|
||||
if (!browser) return null;
|
||||
return localStorage.getItem(DISPLAY_NAME_KEY);
|
||||
return readAuth(DISPLAY_NAME_KEY);
|
||||
}
|
||||
|
||||
export function getExpiry(): Date | null {
|
||||
@@ -69,6 +77,20 @@ export function setAuth(jwt: string, pin: string | null, userId: string, display
|
||||
isAuthenticated.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist an ADMIN session in sessionStorage (USER_JOURNEYS §11.1) rather than localStorage,
|
||||
* so the elevated credential does not survive a tab/browser close on a shared device — the
|
||||
* point of the tighter 1-day admin token. Admins have no recovery PIN. `getToken` reads
|
||||
* sessionStorage first, so this wins over any leftover guest token on the same device.
|
||||
*/
|
||||
export function setAdminAuth(jwt: string, userId: string, displayName?: string): void {
|
||||
if (!browser) return;
|
||||
sessionStorage.setItem(TOKEN_KEY, jwt);
|
||||
sessionStorage.setItem(USER_ID_KEY, userId);
|
||||
if (displayName) sessionStorage.setItem(DISPLAY_NAME_KEY, displayName);
|
||||
isAuthenticated.set(true);
|
||||
}
|
||||
|
||||
// Hook registry: cross-cutting stores (export-status, etc.) register a callback
|
||||
// here at import-time so they get reset on every clearAuth path — both the
|
||||
// explicit "Event verlassen" button and the api.ts 401 auto-clear. Keeps
|
||||
@@ -81,11 +103,15 @@ export function onClearAuth(fn: () => void): void {
|
||||
|
||||
export function clearAuth(): void {
|
||||
if (!browser) return;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_ID_KEY);
|
||||
// Clear the display name too — on a shared device the next user shouldn't see
|
||||
// the previous guest's name pre-filled on /join.
|
||||
localStorage.removeItem(DISPLAY_NAME_KEY);
|
||||
// Clear from BOTH stores — a guest token lives in localStorage, an admin token in
|
||||
// sessionStorage; either could be present, and a stale one must not linger.
|
||||
for (const store of [localStorage, sessionStorage]) {
|
||||
store.removeItem(TOKEN_KEY);
|
||||
store.removeItem(USER_ID_KEY);
|
||||
// Clear the display name too — on a shared device the next user shouldn't see
|
||||
// the previous guest's name pre-filled on /join.
|
||||
store.removeItem(DISPLAY_NAME_KEY);
|
||||
}
|
||||
// PIN is intentionally kept so the user can recover
|
||||
isAuthenticated.set(false);
|
||||
// Hooks fire in registration order. Keep them dependency-free of each other —
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// delta into its in-memory list.
|
||||
|
||||
import { getToken } from './auth';
|
||||
import { api } from './api';
|
||||
import { api, ApiError } from './api';
|
||||
import type { DeltaResponse } from './types';
|
||||
|
||||
type StreamTicketResponse = { ticket: string; server_time: string };
|
||||
@@ -45,7 +45,9 @@ const KNOWN_EVENTS = [
|
||||
'event-updated',
|
||||
'export-progress',
|
||||
'export-available',
|
||||
'pin-reset'
|
||||
'pin-reset',
|
||||
// A guest asked a host to reset their PIN — hosts refresh their pending-request badge.
|
||||
'pin-reset-requested'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -197,7 +199,7 @@ function extractCreatedAt(data: string): string | undefined {
|
||||
* in-memory list. Swallows errors — a failed delta is non-fatal; the next live
|
||||
* SSE event will keep the feed moving.
|
||||
*/
|
||||
async function deltaFetchAndFan(since: string): Promise<void> {
|
||||
async function deltaFetchAndFan(since: string, attempt = 0): Promise<void> {
|
||||
try {
|
||||
const response = await api.get<DeltaResponse>(
|
||||
`/feed/delta?since=${encodeURIComponent(since)}`
|
||||
@@ -206,11 +208,24 @@ async function deltaFetchAndFan(since: string): Promise<void> {
|
||||
// reconnect resumes exactly where the server left off (no browser-clock skew).
|
||||
lastEventTime = response.server_time;
|
||||
dispatch('feed-delta', JSON.stringify(response));
|
||||
} catch {
|
||||
// non-fatal
|
||||
} catch (e) {
|
||||
// A throttled delta (429) must NOT be silently dropped: live events keep advancing
|
||||
// `lastEventTime`, so the next reconnect would resume PAST this un-fetched gap and
|
||||
// lose it. Retry the SAME `since` with backoff so the gap [since, now] is still
|
||||
// covered regardless of how the live cursor moves in the meantime. Bounded, and only
|
||||
// reachable by a rapidly flapping EventSource hitting the per-user delta limit.
|
||||
if (e instanceof ApiError && e.status === 429 && attempt < MAX_DELTA_RETRIES) {
|
||||
const delayMs = DELTA_RETRY_BASE_MS * 2 ** attempt;
|
||||
setTimeout(() => void deltaFetchAndFan(since, attempt + 1), delayMs);
|
||||
}
|
||||
// Other errors are non-fatal — the next live SSE event keeps the feed moving.
|
||||
}
|
||||
}
|
||||
|
||||
/** Bounded backoff for a rate-limited reconnect delta (see `deltaFetchAndFan`). */
|
||||
const MAX_DELTA_RETRIES = 4;
|
||||
const DELTA_RETRY_BASE_MS = 2000;
|
||||
|
||||
// Page Visibility API: close while hidden, reopen on focus. On reopen `connectSse`'s
|
||||
// `onopen` runs the delta fetch.
|
||||
function handleVisibilityChange() {
|
||||
|
||||
@@ -27,6 +27,10 @@ export interface FeedResponse {
|
||||
export interface DeltaResponse {
|
||||
uploads: FeedUpload[];
|
||||
deleted_ids: string[];
|
||||
// Users hidden (banned / uploads_hidden) since the cursor. A ban is not a soft-delete so
|
||||
// it's absent from deleted_ids; the client evicts all uploads from these users, which
|
||||
// replays a ban the client missed while disconnected (esp. the unattended projector).
|
||||
hidden_user_ids: string[];
|
||||
// True when the delta hit the backend cap and is only the newest slice of the
|
||||
// gap — the client must full-refresh rather than merge (see feed-delta handler).
|
||||
truncated: boolean;
|
||||
|
||||
42
frontend/src/lib/upload-queue.test.ts
Normal file
42
frontend/src/lib/upload-queue.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { classifyUploadStatus } from './upload-queue';
|
||||
|
||||
/**
|
||||
* Regression guard for the upload-queue retry policy (H2 + M1). The bug being locked out:
|
||||
* every 4xx except 429 was classified terminal, and a terminal item has its blob PURGED
|
||||
* from IndexedDB. A 401 (a sliding session that lapsed, or a host PIN-reset) is a 4xx, so a
|
||||
* guest's queued photos were irrecoverably destroyed the moment the `online` auto-resume
|
||||
* fired against a dead session. 401 must be `auth` (blob kept, re-auth), NEVER `terminal`.
|
||||
*/
|
||||
describe('classifyUploadStatus', () => {
|
||||
it('2xx → success', () => {
|
||||
expect(classifyUploadStatus(200)).toBe('success');
|
||||
expect(classifyUploadStatus(201)).toBe('success');
|
||||
expect(classifyUploadStatus(299)).toBe('success');
|
||||
});
|
||||
|
||||
it('401 → auth, NOT terminal (never purge the blob on a dead session)', () => {
|
||||
expect(classifyUploadStatus(401)).toBe('auth');
|
||||
});
|
||||
|
||||
it('429 → rate_limit (back off, auto-resume)', () => {
|
||||
expect(classifyUploadStatus(429)).toBe('rate_limit');
|
||||
});
|
||||
|
||||
it('408 → transient (request timeout is retryable, not terminal)', () => {
|
||||
expect(classifyUploadStatus(408)).toBe('transient');
|
||||
});
|
||||
|
||||
it('genuinely permanent 4xx → terminal (locked / banned / released / quota)', () => {
|
||||
expect(classifyUploadStatus(403)).toBe('terminal'); // banned / locked / released
|
||||
expect(classifyUploadStatus(413)).toBe('terminal'); // quota exhausted
|
||||
expect(classifyUploadStatus(400)).toBe('terminal');
|
||||
expect(classifyUploadStatus(404)).toBe('terminal');
|
||||
});
|
||||
|
||||
it('5xx / unexpected → transient (retryable, blob kept)', () => {
|
||||
expect(classifyUploadStatus(500)).toBe('transient');
|
||||
expect(classifyUploadStatus(502)).toBe('transient');
|
||||
expect(classifyUploadStatus(503)).toBe('transient');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { openDB, type IDBPDatabase } from 'idb';
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { getToken, getUserId } from './auth';
|
||||
import { getToken, getUserId, clearAuth } from './auth';
|
||||
import { onSseEvent } from './sse';
|
||||
import { refreshQuota } from './quota-store';
|
||||
|
||||
export interface QueueItem {
|
||||
@@ -8,6 +9,9 @@ export interface QueueItem {
|
||||
userId: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
/** Source file's last-modified ms; part of the dedup key so two distinct photos that
|
||||
* happen to share a name and byte length don't collapse into one. */
|
||||
lastModified?: number;
|
||||
mimeType: string;
|
||||
caption: string;
|
||||
hashtags: string;
|
||||
@@ -50,6 +54,23 @@ function bindOnline(): void {
|
||||
}
|
||||
bindOnline();
|
||||
|
||||
// Resume the queue when the host reopens the event. A queued upload that hit a locked/
|
||||
// released event kept its blob and parked as a retryable `error` (LockedError); the
|
||||
// `event-opened` SSE flips it back to pending and re-drains, so a photo staged during a
|
||||
// lock isn't lost across a reopen. One-way import (sse.ts never imports this module).
|
||||
let sseBound = false;
|
||||
function bindSse(): void {
|
||||
if (sseBound || typeof window === 'undefined') return;
|
||||
onSseEvent('event-opened', () => {
|
||||
void (async () => {
|
||||
await requeueRetriable();
|
||||
await processQueue();
|
||||
})();
|
||||
});
|
||||
sseBound = true;
|
||||
}
|
||||
bindSse();
|
||||
|
||||
/**
|
||||
* Flip transient `error` items (5xx / a network drop that got marked before we could
|
||||
* reclassify it) back to `pending` so a resume actually retries them. Terminal `blocked`
|
||||
@@ -141,6 +162,47 @@ class TerminalError extends Error {
|
||||
*/
|
||||
class NetworkError extends Error {}
|
||||
|
||||
/**
|
||||
* The session is gone/expired (HTTP 401). This is emphatically NOT terminal: the blob is
|
||||
* KEPT (deleting it — the old behavior for any 4xx — destroyed a guest's staged photos the
|
||||
* instant their sliding session lapsed or a host reset their PIN, exactly when auto-resume
|
||||
* fires). We mark the item retryable and route to re-auth; once the user signs back in with
|
||||
* the same identity (via /recover), `loadQueue` re-associates these entries by userId and
|
||||
* they resume. Distinct from `TerminalError` so the 4xx branch can't purge the blob.
|
||||
*/
|
||||
class AuthError extends Error {}
|
||||
|
||||
/**
|
||||
* A REVERSIBLE 403 — the event is closed or the gallery was released, but a host can reopen
|
||||
* it. Backend tags these with code `uploads_locked` (distinct from a permanent `forbidden`
|
||||
* like a banned user). The blob is KEPT and the item parks as retryable; the `event-opened`
|
||||
* SSE (or a manual retry) resumes it. Without this, a photo staged during a lock was purged
|
||||
* as terminal and lost the moment the host reopened.
|
||||
*/
|
||||
class LockedError extends Error {}
|
||||
|
||||
/** Retry policy for an upload response status. */
|
||||
export type UploadOutcome = 'success' | 'rate_limit' | 'auth' | 'transient' | 'terminal';
|
||||
|
||||
/**
|
||||
* Classify an upload HTTP status into a retry policy. Pure + exported so the
|
||||
* data-loss-critical rules are unit-testable without an XHR/IndexedDB harness:
|
||||
* - 401 → `auth` (session gone: KEEP the blob, re-auth — NEVER purge it)
|
||||
* - 408 → `transient` (request timeout: retryable)
|
||||
* - 429 → `rate_limit`(back off, auto-resume)
|
||||
* - other 4xx → `terminal` (locked/banned/released/quota — the only case that purges)
|
||||
* - 2xx → `success`; 5xx/other → `transient`
|
||||
* The one rule that must never regress: a 401 is `auth`, not `terminal`.
|
||||
*/
|
||||
export function classifyUploadStatus(status: number): UploadOutcome {
|
||||
if (status >= 200 && status < 300) return 'success';
|
||||
if (status === 429) return 'rate_limit';
|
||||
if (status === 401) return 'auth';
|
||||
if (status === 408) return 'transient';
|
||||
if (status >= 400 && status < 500) return 'terminal';
|
||||
return 'transient';
|
||||
}
|
||||
|
||||
export async function loadQueue(): Promise<void> {
|
||||
const database = await getDb();
|
||||
const myUserId = getUserId();
|
||||
@@ -173,29 +235,40 @@ export async function loadQueue(): Promise<void> {
|
||||
})();
|
||||
}
|
||||
|
||||
/** Outcome of an `addToQueue` call, so the caller can tell the user when a file was NOT
|
||||
* actually queued (deduped, or the queue is full of un-evictable in-flight items). */
|
||||
export type EnqueueResult = 'queued' | 'duplicate' | 'full';
|
||||
|
||||
export async function addToQueue(
|
||||
file: File,
|
||||
caption: string,
|
||||
hashtags: string
|
||||
): Promise<void> {
|
||||
): Promise<EnqueueResult> {
|
||||
const database = await getDb();
|
||||
const userId = getUserId();
|
||||
if (!userId) return; // not authenticated — nothing to do
|
||||
// Not authenticated — nothing to queue. Return the silent 'duplicate' outcome rather than
|
||||
// 'full' so the caller doesn't show a misleading "queue full" toast. Practically
|
||||
// unreachable: the upload view sits behind a token guard.
|
||||
if (!userId) return 'duplicate';
|
||||
|
||||
// Dedup: don't queue the same file twice while an identical one is still unsent
|
||||
// (double-tap, re-added after a flaky reconnect). Matches on name+size+user.
|
||||
// (double-tap, re-added after a flaky reconnect). Matches on name+size+lastModified+user
|
||||
// — including lastModified so two genuinely different photos sharing a name and byte
|
||||
// length aren't silently collapsed into one.
|
||||
const mine = get(queueItems).filter((i) => i.userId === userId);
|
||||
const dup = mine.some(
|
||||
(i) =>
|
||||
i.fileName === file.name &&
|
||||
i.fileSize === file.size &&
|
||||
(i.lastModified ?? 0) === file.lastModified &&
|
||||
(i.status === 'pending' || i.status === 'uploading')
|
||||
);
|
||||
if (dup) return;
|
||||
if (dup) return 'duplicate';
|
||||
|
||||
// Cap the queue so stuck/blocked blobs can't grow IndexedDB without bound. When full,
|
||||
// evict the oldest terminal item (done/error/blocked) to make room; if none exist,
|
||||
// refuse silently rather than pile on.
|
||||
// refuse and tell the caller so it can surface a "queue full" message (silent loss of a
|
||||
// photo the user believed was queued is the worst outcome here).
|
||||
if (mine.length >= MAX_QUEUE_ITEMS) {
|
||||
const evictable = get(queueItems).find(
|
||||
(i) => i.userId === userId && (i.status === 'done' || i.status === 'error' || i.status === 'blocked')
|
||||
@@ -204,7 +277,7 @@ export async function addToQueue(
|
||||
await database.delete(STORE_NAME, evictable.id);
|
||||
queueItems.update((items) => items.filter((it) => it.id !== evictable.id));
|
||||
} else {
|
||||
return;
|
||||
return 'full';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +287,7 @@ export async function addToQueue(
|
||||
userId,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
lastModified: file.lastModified,
|
||||
mimeType: file.type,
|
||||
caption,
|
||||
hashtags,
|
||||
@@ -229,6 +303,7 @@ export async function addToQueue(
|
||||
userId,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
lastModified: file.lastModified,
|
||||
mimeType: file.type,
|
||||
caption,
|
||||
hashtags,
|
||||
@@ -238,6 +313,7 @@ export async function addToQueue(
|
||||
]);
|
||||
|
||||
processQueue();
|
||||
return 'queued';
|
||||
}
|
||||
|
||||
export async function retryItem(id: string): Promise<void> {
|
||||
@@ -305,9 +381,20 @@ async function processQueue(): Promise<void> {
|
||||
}, e.retryAfterSecs * 1000);
|
||||
break;
|
||||
}
|
||||
if (e instanceof AuthError) {
|
||||
// Dead session — stop the batch (every further item would 401 too). Items
|
||||
// stay retryable with blobs intact; the re-auth flow (clearAuth) takes over.
|
||||
break;
|
||||
}
|
||||
if (e instanceof LockedError) {
|
||||
// Event locked — stop the batch (every item would hit the same lock). Items
|
||||
// stay retryable with blobs intact; the `event-opened` SSE resumes them.
|
||||
break;
|
||||
}
|
||||
if (e instanceof NetworkError) {
|
||||
// Connectivity dropped mid-flight — the item is back to 'pending'. Stop the
|
||||
// loop; the `online` listener resumes it. No error state, no manual tap.
|
||||
// Connectivity dropped mid-flight. If offline the item is back to 'pending'
|
||||
// and the `online` listener resumes it; if the failure hit while nominally
|
||||
// online it's now a retryable 'error'. Either way, stop hammering here.
|
||||
break;
|
||||
}
|
||||
// Other errors are already handled inside uploadItem (marked 'error'/'blocked')
|
||||
@@ -364,37 +451,50 @@ async function uploadItem(id: string): Promise<void> {
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else if (xhr.status === 429) {
|
||||
// Rate limit only (quota-full is now a distinct 413, handled below as
|
||||
// terminal) — back off and auto-resume when the window lifts.
|
||||
const body = (() => {
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
const secs = typeof body.retry_after_secs === 'number' ? body.retry_after_secs : 60;
|
||||
return JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
switch (classifyUploadStatus(xhr.status)) {
|
||||
case 'success':
|
||||
resolve();
|
||||
break;
|
||||
case 'rate_limit': {
|
||||
// Back off and auto-resume when the window lifts (quota-full is a distinct
|
||||
// 413, classified 'terminal' below).
|
||||
const secs = typeof body?.retry_after_secs === 'number' ? body.retry_after_secs : 60;
|
||||
reject(new RateLimitError(secs));
|
||||
} catch {
|
||||
reject(new RateLimitError(60));
|
||||
break;
|
||||
}
|
||||
} else if (xhr.status >= 400 && xhr.status < 500) {
|
||||
// Any other 4xx is permanent for this blob (locked/banned/released/quota).
|
||||
// Retrying just re-hits the same rejection forever, so mark it terminal.
|
||||
let msg = 'Upload nicht möglich.';
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
if (body.message) msg = body.message;
|
||||
else if (xhr.status === 413) msg = 'Speicher-Limit erreicht.';
|
||||
} catch {
|
||||
if (xhr.status === 413) msg = 'Speicher-Limit erreicht.';
|
||||
}
|
||||
reject(new TerminalError(msg));
|
||||
} else {
|
||||
// 5xx / unexpected — transient, keep it retryable.
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
reject(new Error(body.message || `HTTP ${xhr.status}`));
|
||||
} catch {
|
||||
reject(new Error(`HTTP ${xhr.status}`));
|
||||
case 'auth':
|
||||
// Session expired/revoked. NOT terminal — keep the blob and re-auth. Lumping
|
||||
// this into the terminal bucket (which purges the blob) irrecoverably
|
||||
// destroyed queued photos whenever a sliding session lapsed or a host reset
|
||||
// the PIN — precisely when the `online` auto-resume kicks in.
|
||||
reject(new AuthError('Sitzung abgelaufen. Bitte melde dich erneut an.'));
|
||||
break;
|
||||
case 'transient':
|
||||
// 408 (request timeout) behaves like a network blip so it stays retryable;
|
||||
// 5xx is a generic retryable error. Neither purges the blob.
|
||||
if (xhr.status === 408) reject(new NetworkError('Zeitüberschreitung'));
|
||||
else reject(new Error(body?.message || `HTTP ${xhr.status}`));
|
||||
break;
|
||||
case 'terminal': {
|
||||
// A REVERSIBLE lock (event closed / gallery released) is tagged
|
||||
// `uploads_locked` by the backend — keep the blob and park it retryable so
|
||||
// a host reopen resumes it, instead of purging it like a permanent 4xx.
|
||||
if (body?.error === 'uploads_locked') {
|
||||
reject(new LockedError(body?.message || 'Event ist geschlossen.'));
|
||||
break;
|
||||
}
|
||||
// Any other 4xx the server will keep rejecting (banned / quota).
|
||||
let msg = body?.message || 'Upload nicht möglich.';
|
||||
if (!body?.message && xhr.status === 413) msg = 'Speicher-Limit erreicht.';
|
||||
reject(new TerminalError(msg));
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -420,12 +520,48 @@ async function uploadItem(id: string): Promise<void> {
|
||||
updateItemStatus(id, 'pending');
|
||||
throw e; // Propagate to processQueue for scheduling
|
||||
}
|
||||
if (e instanceof NetworkError) {
|
||||
// No connectivity — keep the item pending and propagate so processQueue stops
|
||||
// the loop (no point hammering while offline). The `online` listener resumes it.
|
||||
entry.status = 'pending';
|
||||
if (e instanceof LockedError) {
|
||||
// Event closed / gallery released, but a host can reopen — KEEP the blob and park
|
||||
// the item as retryable so it survives until reopen. The `event-opened` SSE
|
||||
// (bindSse) auto-resumes it; a manual "Erneut" also works. Never purge here.
|
||||
entry.status = 'error';
|
||||
entry.error = e.message;
|
||||
await database.put(STORE_NAME, entry);
|
||||
updateItemStatus(id, 'pending');
|
||||
updateItemStatus(id, 'error', e.message);
|
||||
throw e;
|
||||
}
|
||||
if (e instanceof AuthError) {
|
||||
// Dead session — KEEP the blob (never purge on auth failure) and mark retryable so
|
||||
// the file survives re-auth. Route through the app's single de-auth path (matches
|
||||
// api.ts's 401 handling). The blob persists in IndexedDB keyed by userId; once the
|
||||
// user signs back in with the SAME identity (via /recover), the next time the upload
|
||||
// view loads `loadQueue` re-associates this entry and it can be retried/resumed.
|
||||
entry.status = 'error';
|
||||
entry.error = e.message;
|
||||
await database.put(STORE_NAME, entry);
|
||||
updateItemStatus(id, 'error', e.message);
|
||||
clearAuth();
|
||||
throw e;
|
||||
}
|
||||
if (e instanceof NetworkError) {
|
||||
const offline = typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||
if (offline) {
|
||||
// Genuinely offline — keep the item pending; the `online` listener resumes it
|
||||
// automatically with no user action.
|
||||
entry.status = 'pending';
|
||||
await database.put(STORE_NAME, entry);
|
||||
updateItemStatus(id, 'pending');
|
||||
} else {
|
||||
// Network-level failure while the OS still reports online (server down,
|
||||
// connection refused, TLS error, captive portal). The `online` event will
|
||||
// NEVER fire in this case, so leaving it 'pending' would strand the item with
|
||||
// no retry path and no spinner. Mark it retryable 'error' so the user gets a
|
||||
// working "Erneut" button and a later real reconnect requeues it.
|
||||
entry.status = 'error';
|
||||
entry.error = 'Netzwerkfehler. Erneut versuchen.';
|
||||
await database.put(STORE_NAME, entry);
|
||||
updateItemStatus(id, 'error', 'Netzwerkfehler. Erneut versuchen.');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (e instanceof TerminalError) {
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user