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:
parent
dc349a9373
commit
54d8ed22f4
5 changed files with 231 additions and 2 deletions
|
|
@ -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'}
|
||||
|
|
|
|||
124
frontend/src/components/ModerateTags.svelte
Normal file
124
frontend/src/components/ModerateTags.svelte
Normal 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>
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { Hono } from 'hono';
|
||||
import { getDb } from './db';
|
||||
import { requireAuth, type AppVariables } from './session';
|
||||
import { isModerator } from './roles';
|
||||
import type { TagSuggestion } from '../shared/types';
|
||||
|
||||
/**
|
||||
|
|
@ -116,7 +118,7 @@ export function bulkTagsFor(activityIds: string[]): Map<string, string[]> {
|
|||
}
|
||||
|
||||
// --- Routes ------------------------------------------------------------------
|
||||
export const tagsRoutes = new Hono();
|
||||
export const tagsRoutes = new Hono<{ Variables: AppVariables }>();
|
||||
|
||||
// GET /api/tags?q=foo&limit=20
|
||||
tagsRoutes.get('/', (c) => {
|
||||
|
|
@ -143,3 +145,33 @@ tagsRoutes.get('/', (c) => {
|
|||
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
// --- DELETE /api/tags/:name -------------------------------------------------
|
||||
// Moderation: remove a public tag entirely. Detaches it from every
|
||||
// activity_tags row, then drops the tag itself. Private tags never reach the
|
||||
// server (they live encrypted in the activity payload), so this only touches
|
||||
// public/semi/friends data.
|
||||
//
|
||||
// Gated by isModerator() — admins also pass because admin implies moderator
|
||||
// (see server/roles.ts).
|
||||
tagsRoutes.delete('/:name', requireAuth, (c) => {
|
||||
const userId = c.get('userId');
|
||||
if (!isModerator(userId)) return c.json({ error: 'forbidden' }, 403);
|
||||
|
||||
const raw = c.req.param('name');
|
||||
const name = normaliseTag(decodeURIComponent(raw));
|
||||
if (!name) return c.json({ error: 'invalid_name' }, 400);
|
||||
|
||||
const db = getDb();
|
||||
const txn = db.transaction(() => {
|
||||
const tag = db.prepare('SELECT id FROM tags WHERE name = ?').get(name) as { id: string } | null;
|
||||
if (!tag) return false;
|
||||
db.prepare('DELETE FROM activity_tags WHERE tag_id = ?').run(tag.id);
|
||||
db.prepare('DELETE FROM tags WHERE id = ?').run(tag.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
const removed = txn();
|
||||
if (!removed) return c.json({ error: 'not_found' }, 404);
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -152,6 +152,66 @@ describe('admin user-list shape', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('tag deletion (moderator + admin)', () => {
|
||||
ttest('moderator can delete a public tag and it detaches from activities', async () => {
|
||||
const admin = await signupAndGetCookie(ctx, 'tag-admin@test.invalid');
|
||||
getDb().prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(admin.me.id);
|
||||
const mod = await signupAndGetCookie(ctx, 'tag-mod@test.invalid');
|
||||
getDb().prepare('UPDATE users SET is_moderator = 1 WHERE id = ?').run(mod.me.id);
|
||||
|
||||
// Two activities sharing a tag.
|
||||
await createActivity(ctx, admin.cookie, {
|
||||
visibility: 'public', title: 'with-tag-1', tags: ['Skismøring'],
|
||||
});
|
||||
await createActivity(ctx, admin.cookie, {
|
||||
visibility: 'semi', title: 'with-tag-2', tags: ['skismøring'],
|
||||
});
|
||||
|
||||
// Tag was normalised to lowercase.
|
||||
const before = await reqJson<{ name: string; usage_count: number }[]>(
|
||||
ctx, 'GET', '/api/tags?q=ski',
|
||||
);
|
||||
expect(before.some((t) => t.name === 'skismøring')).toBe(true);
|
||||
|
||||
const res = await req(ctx, 'DELETE', '/api/tags/skism%C3%B8ring', { cookie: mod.cookie });
|
||||
expect(res.status).toBe(204);
|
||||
|
||||
// Tag is gone from the suggestion list.
|
||||
const after = await reqJson<{ name: string }[]>(ctx, 'GET', '/api/tags?q=ski');
|
||||
expect(after.some((t) => t.name === 'skismøring')).toBe(false);
|
||||
|
||||
// activity_tags rows for the tag are also gone — verify the join surface.
|
||||
const db = getDb();
|
||||
const tagRow = db.prepare("SELECT id FROM tags WHERE name = 'skismøring'").get();
|
||||
expect(tagRow).toBeNull();
|
||||
});
|
||||
|
||||
ttest('plain user cannot delete a tag', async () => {
|
||||
const admin = await signupAndGetCookie(ctx, 'tag-admin2@test.invalid');
|
||||
getDb().prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(admin.me.id);
|
||||
const plain = await signupAndGetCookie(ctx, 'tag-plain@test.invalid');
|
||||
|
||||
await createActivity(ctx, admin.cookie, {
|
||||
visibility: 'public', title: 'plain-cannot', tags: ['victim-tag'],
|
||||
});
|
||||
|
||||
const res = await req(ctx, 'DELETE', '/api/tags/victim-tag', { cookie: plain.cookie });
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
// Anonymous: 401 (requireAuth fires before the role check).
|
||||
const anon = await req(ctx, 'DELETE', '/api/tags/victim-tag');
|
||||
expect(anon.status).toBe(401);
|
||||
});
|
||||
|
||||
ttest('deleting an unknown tag returns 404', async () => {
|
||||
const admin = await signupAndGetCookie(ctx, 'tag-admin3@test.invalid');
|
||||
getDb().prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(admin.me.id);
|
||||
|
||||
const res = await req(ctx, 'DELETE', '/api/tags/does-not-exist', { cookie: admin.cookie });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('role activity-delete crossover (admin/moderator can delete semi)', () => {
|
||||
ttest('admin can delete another user semi activity', async () => {
|
||||
const admin = await signupAndGetCookie(ctx, 'cross-admin@test.invalid');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue