feat(moderation): let a host remove a guest's photo or comment from the UI

`DELETE /host/upload/{id}` and `DELETE /host/comment/{id}` were complete on the
backend — transactional, SSE-broadcasting, audit-logged — and had zero frontend
callers. The feed context sheet offered "Löschen" only when
`target.user_id === myUserId`, so the only lever a host actually had against an
unwanted photo was banning the uploader.

That is both disproportionate and ineffective. A ban doesn't retract what was
already posted, and it makes things strictly worse for comments: the ban check
runs BEFORE the ownership check on the guest delete route, so banning an abusive
author leaves their comment on screen and permanently undeletable by them. With
no host affordance, nobody could remove it at all.

- feed: hosts/admins get "Beitrag entfernen" on other people's posts, routed to
  the host endpoint (the guest route 403s anything the caller doesn't own) with
  moderation-specific confirm copy. Own-post "Löschen" is unchanged.
- lightbox: same for comments, via /host/comment/{id}.
- Ban semantics are deliberately untouched (USER_JOURNEYS §10 — banned users keep
  read access and cannot write). The deadlock is broken by giving the host a way
  in, not by loosening the ban.

Live role (this had to come first). `getRole()` decodes the JWT claim, but the
token is never reissued — the backend slides the session row forward and treats
the DB row as authoritative. The claim is therefore frozen for the token's
lifetime: up to 30 days. A guest promoted at the party saw no Host-Dashboard and
no moderation actions until they signed out and back in, even though
`/me/context` had been returning their real role on every page load and 4 of its
6 call sites dropped the field on the floor.

Add `role-store.ts`: seeded from the claim so there's no flash of the wrong nav,
then corrected by every `/me/context` response. Point the ad-hoc `getRole()`
callers at it (account, upload, host, admin, and the new feed gate). The host and
admin dashboards now derive `myRole` reactively, so a demotion disables their
controls immediately instead of at next login.

Tests: 04-host/moderation-ui drives the real UI — host removes a guest photo and
it's gone from /feed server-side; a plain guest is offered nothing on someone
else's post (the mirror that keeps the first test honest); a promoted guest gains
the dashboard on reload while their token still carries `role: guest`; and a host
removes the comment of an already-banned guest, asserting first that the author's
own delete 403s so the deadlock is real.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-27 22:33:33 +02:00
parent 0d8e83d392
commit be6d56f278
10 changed files with 257 additions and 34 deletions

View File

@@ -0,0 +1,149 @@
/**
* Regression guard — a host must be able to remove a guest's content FROM THE UI.
*
* `DELETE /host/upload/{id}` and `DELETE /host/comment/{id}` were fully implemented,
* transactional, SSE-broadcasting, audit-logged — and had zero frontend callers. The feed's
* context sheet offered "Löschen" only when `target.user_id === myUserId`, so the only lever
* a host actually had against an unwanted photo was banning the uploader. That is both
* disproportionate and ineffective: a ban doesn't retract what was already posted, and
* because the ban check runs BEFORE the ownership check on the guest delete route, banning
* the author makes their abusive comment permanently undeletable by them too.
*
* The API side was already covered (04-host/moderation). What was missing is the wiring,
* so these tests drive the real UI.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload, seedComment } from '../../helpers/seed';
import { BASE } from '../../helpers/env';
test.describe('Host — moderation from the UI', () => {
test("a host removes a guest's photo via the feed context sheet", async ({
page,
host,
guest,
signIn,
}) => {
const g = await guest('PhotoOffender');
const uploadId = await seedUpload(g.jwt);
await signIn(page, host);
await page.goto('/feed');
const card = page.locator('article').filter({ hasText: g.displayName }).first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.getByRole('button', { name: 'Mehr Aktionen' }).click();
const remove = page.getByRole('button', { name: /beitrag entfernen/i });
await expect(remove, 'a host must be offered a removal action on a guest post').toBeVisible();
await remove.click();
const sheet = page.getByTestId('confirm-sheet');
await expect(sheet).toBeVisible();
// Moderation copy, not "delete my post" copy.
await expect(sheet).toContainText(/beitrag entfernen/i);
await page.getByTestId('confirm-sheet-confirm').click();
await expect(card).not.toBeVisible({ timeout: 10_000 });
// And it is really gone server-side, not just dropped from the local list.
const res = await fetch(`${BASE}/api/v1/feed`, {
headers: { Authorization: `Bearer ${host.jwt}` },
});
const body = await res.json();
expect(body.uploads.some((u: { id: string }) => u.id === uploadId)).toBe(false);
});
test('a guest is NOT offered any delete action on someone elses post', async ({
page,
guest,
signIn,
}) => {
// The mirror that makes the test above meaningful: if this affordance rendered for
// everyone, the host test would still pass on a build that shipped moderation to guests.
const author = await guest('SomeAuthor');
await seedUpload(author.jwt);
const viewer = await guest('NosyViewer');
await signIn(page, viewer);
await page.goto('/feed');
const card = page.locator('article').filter({ hasText: author.displayName }).first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.getByRole('button', { name: 'Mehr Aktionen' }).click();
await expect(page.getByRole('button', { name: /beitrag entfernen/i })).toHaveCount(0);
await expect(page.getByRole('button', { name: /^löschen$/i })).toHaveCount(0);
});
test('a promoted guest gets host powers without signing out and back in', async ({
page,
api,
adminToken,
guest,
signIn,
}) => {
// The JWT is never reissued — the backend slides the session row forward and treats the
// DB row as authoritative. So the token of a promoted guest still claims `role: guest`
// for up to 30 days. The UI read that frozen claim, which meant a guest promoted at the
// party saw no Host-Dashboard and no moderation actions until they signed out and back
// in — while `/me/context` had been handing the client the real role all along.
const g = await guest('LatePromotion');
await signIn(page, g);
await page.goto('/account');
await expect(page.getByRole('link', { name: /host-dashboard/i })).toHaveCount(0);
// Promote mid-session. The token in localStorage is deliberately NOT refreshed.
await api.setRole(adminToken, g.userId, 'host');
const claim = JSON.parse(Buffer.from(g.jwt.split('.')[1], 'base64').toString());
expect(
claim.role,
'the token must still carry the stale claim for this to prove anything'
).toBe('guest');
await page.reload();
await expect(
page.getByRole('link', { name: /host-dashboard/i }),
'the live role from /me/context must win over the frozen JWT claim'
).toBeVisible({ timeout: 10_000 });
});
test('a host can remove the comment of a guest they have already banned', async ({
page,
api,
host,
guest,
signIn,
}) => {
// The deadlock this closes. Ban first, exactly as a host would react to abuse: from then
// on the author gets 403 on their own delete, so if the host has no removal affordance
// the comment is stuck on screen forever.
// The photo belongs to an innocent third party — a ban hides the banned user's OWN
// uploads, so if the comment sat on their own photo the whole card would vanish and
// there would be nothing left to moderate.
const victim = await guest('PhotoOwner');
const uploadId = await seedUpload(victim.jwt);
const author = await guest('CommentOffender');
const commentId = await seedComment(author.jwt, uploadId, 'unangebrachter Kommentar');
await api.banUser(host.jwt, author.userId);
// Confirm the deadlock really exists — the author cannot retract it themselves.
const selfDelete = await fetch(`${BASE}/api/v1/comment/${commentId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${author.jwt}` },
});
expect(selfDelete.status, 'a banned author is blocked from their own delete').toBe(403);
await signIn(page, host);
await page.goto('/feed');
const card = page.locator('article').filter({ hasText: victim.displayName }).first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.getByRole('button', { name: 'Bild vergrößern' }).click();
const comment = page.getByText('unangebrachter Kommentar');
await expect(comment).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Kommentar entfernen' }).first().click();
await expect(comment).toHaveCount(0, { timeout: 10_000 });
});
});

View File

@@ -4,6 +4,7 @@
import { api } from '$lib/api';
import { onSseEvent } from '$lib/sse';
import { getUserId } from '$lib/auth';
import { isStaff } from '$lib/role-store';
import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
import { doubletap } from '$lib/actions/doubletap';
import { focusTrap } from '$lib/actions/focus-trap';
@@ -114,9 +115,15 @@
}
}
async function deleteComment(id: string) {
/**
* `asHost` routes to the moderation endpoint. The guest route only ever deletes the
* caller's OWN comment, and it refuses a banned author outright — so without this a
* host who banned an abusive guest was left with the abuse still on screen and no way
* to remove it, since the ban itself blocks the author's own delete.
*/
async function deleteComment(id: string, asHost: boolean) {
try {
await api.delete(`/comment/${id}`);
await api.delete(asHost ? `/host/comment/${id}` : `/comment/${id}`);
comments = comments.filter((c) => c.id !== id);
} catch (e) {
toastError(e);
@@ -246,11 +253,11 @@
{formatTime(comment.created_at)}
</div>
</div>
{#if comment.user_id === userId}
{#if comment.user_id === userId || $isStaff}
<button
onclick={() => deleteComment(comment.id)}
onclick={() => deleteComment(comment.id, comment.user_id !== userId)}
class="shrink-0 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
aria-label="Löschen"
aria-label={comment.user_id === userId ? 'Löschen' : 'Kommentar entfernen'}
>
<svg
class="h-3.5 w-3.5"

View File

@@ -1,5 +1,6 @@
import { writable } from 'svelte/store';
import { api } from './api';
import { setRole } from './role-store';
import type { MeContextDto } from './types';
/**
@@ -31,6 +32,9 @@ export async function refreshEventState(): Promise<void> {
const seq = stateSeq;
try {
const ctx = await api.get<MeContextDto>('/me/context');
// The role is unaffected by the close/reopen race guarded below, so apply it
// unconditionally — this is one of the refreshes that used to drop it.
setRole(ctx.role);
// A close/reopen landed while this was in flight — its result is now authoritative;
// don't overwrite it with our possibly-stale snapshot.
if (seq !== stateSeq) return;

View File

@@ -0,0 +1,34 @@
import { derived, writable } from 'svelte/store';
import { getRole } from './auth';
export type Role = 'guest' | 'host' | 'admin';
/**
* The viewer's LIVE role.
*
* The JWT is never reissued — the backend slides the session row forward instead and
* deliberately ignores the token's own role claim (`auth/middleware.rs`: "the live user row
* is authoritative"). So `getRole()`, which decodes the claim, is frozen for the lifetime of
* the token: up to 30 days for a guest. A guest promoted to host saw no Host-Dashboard until
* they signed out and back in, even though `/me/context` had already told the client their
* real role on the very next page load — it was fetched and the `role` field dropped on the
* floor in 4 of its 6 call sites.
*
* This store is seeded from the claim (so there is no flash of the wrong nav on boot) and
* corrected by every `/me/context` response via `setRole`. Read this instead of calling
* `getRole()` ad hoc.
*/
export const role = writable<Role | null>(getRole());
/** True for host and admin — the "can moderate" predicate used across the UI. */
export const isStaff = derived(role, ($role) => $role === 'host' || $role === 'admin');
/** Apply the authoritative role from a `/me/context` response. */
export function setRole(next: Role | null): void {
role.set(next);
}
/** Re-seed from the token, e.g. straight after a login/join that minted a new one. */
export function syncRoleFromToken(): void {
role.set(getRole());
}

View File

@@ -16,6 +16,7 @@
import { api } from '$lib/api';
import type { MeContextDto } from '$lib/types';
import { eventState, markClosed, markOpened, refreshEventState } from '$lib/event-state-store';
import { setRole } from '$lib/role-store';
import { loadEventConfig } from '$lib/event-config-store';
let { children } = $props();
@@ -48,6 +49,9 @@
try {
const ctx = await api.get<MeContextDto>('/me/context');
privacyNote.set(ctx.privacy_note);
// The live role — the JWT claim is frozen for the token's lifetime, so a
// promotion/demotion only reaches the UI through this. See role-store.ts.
setRole(ctx.role);
eventState.set({
uploadsLocked: ctx.uploads_locked,
galleryReleased: ctx.gallery_released

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getToken, getDisplayName, getExpiry, getRole, clearAuth, currentPin } from '$lib/auth';
import { getToken, getDisplayName, getExpiry, clearAuth, currentPin } from '$lib/auth';
import { role, setRole } from '$lib/role-store';
import { clearQueue } from '$lib/upload-queue';
import { api } from '$lib/api';
import { onMount, onDestroy } from 'svelte';
@@ -16,7 +17,6 @@
import type { MeContextDto } from '$lib/types';
let displayName = $state<string | null>(null);
let role = $state<'guest' | 'host' | 'admin' | null>(null);
let expiry = $state<Date | null>(null);
let pinCopied = $state(false);
let leaveConfirmOpen = $state(false);
@@ -35,13 +35,13 @@
return;
}
displayName = getDisplayName();
role = getRole();
expiry = getExpiry();
// Refresh server-driven state. Quota + privacy note may have changed since last visit.
try {
const ctx = await api.get<MeContextDto>('/me/context');
privacyNote.set(ctx.privacy_note);
setRole(ctx.role);
} catch {
// non-fatal
}
@@ -177,10 +177,10 @@
</p>
<span
class="mt-0.5 inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold {roleColor(
role
$role
)}"
>
{roleLabel(role)}
{roleLabel($role)}
</span>
</div>
</div>
@@ -192,7 +192,7 @@
</div>
<!-- Dashboards section (host + admin only) -->
{#if role === 'host' || role === 'admin'}
{#if $role === 'host' || $role === 'admin'}
<div class="card overflow-hidden">
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
<h2
@@ -230,7 +230,7 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</a>
{#if role === 'admin'}
{#if $role === 'admin'}
<a
href="/admin"
class="flex items-center gap-3 border-t border-gray-100 px-5 py-4 transition hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-700/50"
@@ -433,7 +433,7 @@
<!-- Per-user quota widget — staff-only (host/admin); guests never see the
server-derived storage figures. -->
{#if (role === 'host' || role === 'admin') && $quotaStore.enabled && $quotaStore.limit_bytes != null}
{#if ($role === 'host' || $role === 'admin') && $quotaStore.enabled && $quotaStore.limit_bytes != null}
<div class="card p-5">
<h2
class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getToken, getRole } from '$lib/auth';
import { getToken } from '$lib/auth';
import { role as myRoleStore } from '$lib/role-store';
import { api } from '$lib/api';
import { onMount } from 'svelte';
import { toast, toastError } from '$lib/toast-store';
@@ -262,7 +263,8 @@
let pinResetSubmitting = $state(false);
let pinModal = $state<{ name: string; pin: string } | null>(null);
const myRole = getRole();
// Live role, not the frozen JWT claim: a demotion must disable these controls at once.
const myRole = $derived($myRoleStore);
// Generic confirm-then-run for irreversible / privilege-changing actions
// (promote, demote, unban, release gallery). Reuses the shared ConfirmSheet.

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getToken, getUserId } from '$lib/auth';
import { isStaff } from '$lib/role-store';
import { api } from '$lib/api';
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
import { onMount, onDestroy } from 'svelte';
@@ -34,7 +35,9 @@
let sentinel: HTMLDivElement;
let feedObserver: IntersectionObserver | null = null;
let inPlaceRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let pendingDeleteId = $state<string | null>(null);
// `asHost` picks the endpoint AND the copy: removing someone else's photo is a
// moderation action, not "delete my post", and it hits the host route.
let pendingDelete = $state<{ id: string; asHost: boolean } | null>(null);
// ─────────────────────────────────────────────────────────────────────────
// onMount A — DOM side-effects only (overscroll lock). Synchronous, returns
@@ -87,7 +90,19 @@
icon: '🗑',
tone: 'danger',
onClick: () => {
pendingDeleteId = target.id;
pendingDelete = { id: target.id, asHost: false };
}
});
} else if ($isStaff) {
// Moderation. Without this the only lever a host had against an unwanted photo
// was banning the uploader — which is both disproportionate and ineffective,
// since a ban does not retract what they already posted.
actions.unshift({
label: 'Beitrag entfernen',
icon: '🚫',
tone: 'danger',
onClick: () => {
pendingDelete = { id: target.id, asHost: true };
}
});
}
@@ -99,14 +114,18 @@
}
async function confirmDelete() {
const id = pendingDeleteId;
if (!id) return;
pendingDeleteId = null;
const pending = pendingDelete;
if (!pending) return;
pendingDelete = null;
try {
await api.delete(`/upload/${id}`);
uploads = uploads.filter((u) => u.id !== id);
if (selectedUpload?.id === id) selectedUpload = null;
void refreshQuota();
// The guest route rejects anything the caller doesn't own, so a host removing
// someone else's photo must go through the host route. That one also emits the
// `upload-deleted` SSE and writes an audit-log entry.
await api.delete(pending.asHost ? `/host/upload/${pending.id}` : `/upload/${pending.id}`);
uploads = uploads.filter((u) => u.id !== pending.id);
if (selectedUpload?.id === pending.id) selectedUpload = null;
// Only our own delete frees our quota; a host removal refunds the uploader.
if (!pending.asHost) void refreshQuota();
} catch (e) {
toastError(e);
}
@@ -969,13 +988,15 @@
<!-- Branded delete confirmation — replaces window.confirm() -->
<ConfirmSheet
open={pendingDeleteId !== null}
title="Beitrag löschen?"
message="Diese Aktion kann nicht rückgängig gemacht werden."
confirmLabel="Löschen"
open={pendingDelete !== null}
title={pendingDelete?.asHost ? 'Beitrag entfernen?' : 'Beitrag löschen?'}
message={pendingDelete?.asHost
? 'Der Beitrag verschwindet für alle Gäste. Diese Aktion kann nicht rückgängig gemacht werden.'
: 'Diese Aktion kann nicht rückgängig gemacht werden.'}
confirmLabel={pendingDelete?.asHost ? 'Entfernen' : 'Löschen'}
tone="danger"
onConfirm={confirmDelete}
onCancel={() => (pendingDeleteId = null)}
onCancel={() => (pendingDelete = null)}
/>
<!-- First-visit onboarding guide -->

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getToken, getRole, getUserId } from '$lib/auth';
import { getToken, getUserId } from '$lib/auth';
import { role as myRoleStore } from '$lib/role-store';
import { api } from '$lib/api';
import type { MeContextDto } from '$lib/types';
import { onMount, onDestroy } from 'svelte';
@@ -97,7 +98,8 @@
let pinResetSubmitting = $state(false);
let pinModal = $state<{ name: string; pin: string } | null>(null);
const myRole = getRole();
// Live role, not the frozen JWT claim: a demotion must disable these controls at once.
const myRole = $derived($myRoleStore);
const myUserId = getUserId();
// Generic confirm-then-run for the irreversible / privilege-changing actions

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getToken, getRole } from '$lib/auth';
import { getToken } from '$lib/auth';
import { isStaff } from '$lib/role-store';
import { addToQueue, loadQueue } from '$lib/upload-queue';
import { toast } from '$lib/toast-store';
import { showBottomNav } from '$lib/ui-store';
@@ -27,7 +28,6 @@
// The storage widget is staff-only (host/admin). Guests never see server-derived
// storage figures — the backend also zeroes the raw-disk fields for non-staff, so
// this is UI-consistency on top of an API guarantee, not the security boundary.
const isStaff = getRole() === 'host' || getRole() === 'admin';
// Quick-tag chips derived from caption as the user types
let captionTags = $derived.by(() => {
@@ -261,7 +261,7 @@
<!-- Per-user quota — staff-only (never shown to guests), and also hidden when
admin disabled enforcement. -->
{#if isStaff && $quotaStore.enabled && $quotaStore.limit_bytes != null}
{#if $isStaff && $quotaStore.enabled && $quotaStore.limit_bytes != null}
<div class="px-4 pt-3 text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center justify-between">
<span