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

@@ -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 -->