feat(tags): moderators and admins can delete public tags

Add DELETE /api/tags/:name (gated by isModerator(), which also passes
for admins per the admin-implies-moderator invariant in roles.ts).
The endpoint normalises the name the same way creation does so the
URL casing doesn't matter, then deletes the tag and detaches it from
every activity_tags row in one transaction.

UI: new "Etiketter" nav entry visible to moderators + admins, opens
a ModerateTags.svelte view with search-as-you-type (reusing the
/api/tags suggestion endpoint) and a Slett button per row. Private
tags are unaffected — they're encrypted in the activity payload and
never reach the server tag table.

Tests: 3 new cases on top of the admin suite — moderator can delete,
plain user gets 403 (anonymous gets 401), unknown tag gets 404.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 17:57:33 +02:00
commit 54d8ed22f4
5 changed files with 231 additions and 2 deletions

View file

@ -12,6 +12,7 @@
import Feedback from './components/Feedback.svelte';
import PublicList from './components/PublicList.svelte';
import Admin from './components/Admin.svelte';
import ModerateTags from './components/ModerateTags.svelte';
import ActivityPermalink from './components/ActivityPermalink.svelte';
import Personvern from './components/Personvern.svelte';
import TagPage from './components/TagPage.svelte';
@ -34,7 +35,7 @@
| 'login' | 'signup' | 'recovery'
| 'public-home' // "/" — public/semi only, anyone
| 'home' // "/home" — full authenticated dashboard
| 'profile' | 'feedback' | 'admin'
| 'profile' | 'feedback' | 'admin' | 'moderate-tags'
| 'public-list' // "/<username>/list"
| 'permalink' // "/a/:id"
| 'personvern' // "/personvern"
@ -210,6 +211,12 @@
{#if session.user.is_admin}
<button type="button" onclick={() => (view = 'admin')}>Admin</button>
{/if}
{#if session.user.is_moderator || session.user.is_admin}
<button type="button" onclick={() => (view = 'moderate-tags')}
aria-label="Moderer etiketter">
Etiketter
</button>
{/if}
<button type="button" onclick={() => (view = 'feedback')}
aria-label="Send tilbakemelding">
Tilbakemelding
@ -258,6 +265,8 @@
<Feedback onDone={goHome} />
{:else if view === 'admin'}
<Admin onDone={goHome} />
{:else if view === 'moderate-tags'}
<ModerateTags onDone={goHome} />
{:else if view === 'personvern'}
<Personvern onBack={leavePersonvern} />
{:else if view === 'tag'}

View file

@ -0,0 +1,124 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api, ApiError } from '../lib/api';
import type { TagSuggestion } from '../../../shared/types';
interface Props {
onDone: () => void;
}
let { onDone }: Props = $props();
let tags: TagSuggestion[] = $state([]);
let query = $state('');
let loading = $state(true);
let error: string | null = $state(null);
let busy: Record<string, boolean> = $state({});
onMount(() => load(''));
// Reload from the server whenever the search query changes. The endpoint
// is paginated at 50 — moderators search to narrow large tag sets rather
// than scrolling a single dump. Same behaviour as TagInput's suggestion fetch.
$effect(() => {
const q = query;
const timer = setTimeout(() => load(q), 150);
return () => clearTimeout(timer);
});
async function load(q: string) {
loading = true;
error = null;
try {
tags = await api.tagSuggestions(q, 50);
} catch (err) {
error = err instanceof ApiError && err.status === 403
? 'Bare moderatorer kan se denne siden.'
: 'Kunne ikke laste etiketter.';
} finally {
loading = false;
}
}
async function remove(name: string) {
if (!confirm(`Slett etiketten «${name}» fra alle aktiviteter?`)) return;
busy = { ...busy, [name]: true };
try {
await api.deleteTag(name);
tags = tags.filter((t) => t.name !== name);
} catch (err) {
error = err instanceof ApiError && err.status === 403
? 'Du har ikke rettigheter til å slette etiketter.'
: 'Sletting feilet.';
} finally {
const { [name]: _, ...rest } = busy;
busy = rest;
}
}
</script>
<section aria-label="Moderer etiketter">
<div class="row" style="justify-content: space-between; margin-bottom: 1rem;">
<h2 style="margin: 0;">Moderer etiketter</h2>
<button type="button" onclick={onDone}>Tilbake</button>
</div>
<p class="muted">
Sletting fjerner etiketten fra alle offentlige, anonyme og vennlige
aktiviteter den finnes på. Private etiketter er kryptert og ikke
synlige her.
</p>
<label for="tag-search" style="margin-top: 1rem;">Søk</label>
<input
id="tag-search"
type="search"
bind:value={query}
placeholder="Søk etter etikett …"
aria-label="Søk etter etikett"
/>
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if loading}
<p class="muted">Laster …</p>
{:else if tags.length === 0}
<p class="muted">
{query ? `Ingen etiketter matcher «${query}».` : 'Ingen etiketter ennå.'}
</p>
{:else}
<div class="card" style="padding: 0; overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="border-bottom: 1px solid var(--border);">
<th scope="col" style="text-align: left; padding: 0.5rem;">Etikett</th>
<th scope="col" style="text-align: right; padding: 0.5rem;">Bruk</th>
<th scope="col" style="padding: 0.5rem;"></th>
</tr>
</thead>
<tbody>
{#each tags as t (t.name)}
<tr style="border-bottom: 1px solid var(--border);">
<td style="padding: 0.5rem;">
<span class="tag" style="font-size: inherit;">{t.name}</span>
</td>
<td style="padding: 0.5rem; text-align: right;" class="muted">
{t.usage_count}
</td>
<td style="padding: 0.5rem; text-align: right;">
<button
class="danger"
type="button"
disabled={busy[t.name]}
onclick={() => remove(t.name)}
aria-label={`Slett etikett ${t.name}`}
>
Slett
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>

View file

@ -91,6 +91,10 @@ export const api = {
// --- tags -----------------------------------------------------------------
tagSuggestions: (q: string, limit = 20) =>
http<TagSuggestion[]>(`/tags?q=${encodeURIComponent(q)}&limit=${limit}`),
// Moderator/admin: delete a public tag entirely (also detaches it from all
// activities). Forbidden for regular users.
deleteTag: (name: string) =>
http<void>(`/tags/${encodeURIComponent(name)}`, { method: 'DELETE' }),
// --- users (opt-in public list) -------------------------------------------
publicList: (username: string) =>