dialog: Diskussionsplattform mit Foren, Rollen & Moderation + RLS-Fix

Auth/RLS-Fix (Schreiben gab 400):
- supabase.js: eigener supabaseAuth-Client für Login/Token-Check, damit
  signInWithPassword den Service-Daten-Client nicht prozessweit aufs
  User-Token umstellt (sonst lief insert als role=authenticated → RLS-Block).

Rollen (admin > editor > user):
- auth.js: roleOf() aus app_metadata.role + ADMIN_EMAILS, requireModerator.
- users.js: Rolle anzeigen/setzen über GoTrue app_metadata; .env-Admins fix.

Datenmodell (schema.sql):
- forums (Kategorien) + threads; Seed Allgemein/Projekte/Technik/Off-Topic
  und Sonder-Kategorie Beiträge. Library-Beiträge werden als Threads
  gespiegelt (dialog-store.syncLibrary).

API (routes/dialog.js, dialog-store.js):
- öffentlich: /api/forums, /api/forums/:slug, /api/recent, /api/thread
- eingeloggt: POST /api/threads (Thread starten, nur in Foren)
- Moderation: /api/mod/* (sperren/ausblenden), Admin: /api/admin/forums CRUD
- comments: Lock-Prüfung beim Schreiben, Moderation darf jede löschen.

Frontend:
- static/dialog.js: Router (Übersicht-Split-View | Forum | Thread),
  neuer Thread, Mod-Leiste, subtiles Login (dezente Zeile statt Formular).
- Admin-UI: Tabs Foren + Moderation, Rollen-Dropdown bei Autor:innen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 16:09:19 +02:00
parent 2749451107
commit bd85570259
14 changed files with 916 additions and 211 deletions
+10 -37
View File
@@ -1,18 +1,8 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { supabase } from '../supabase.js';
import { listEntries } from '../files.js';
import { supabase, supabaseAuth } from '../supabase.js';
import { roleOf } from '../auth.js';
import { profileFor, threadLocked } from '../dialog-store.js';
// Dialog: flache Wortmeldungen pro Thread (= Beitrags-Pfad), optionaler Bezug.
const SITE_DIR = process.env.SITE_DIR || '/site';
// Anzeigename + Avatar aus dem Profil (data/authors.json), Fallback = Mail-Teil.
async function profileFor(email) {
try {
const all = JSON.parse(await readFile(path.join(SITE_DIR, 'data', 'authors.json'), 'utf8'));
return all[email] || null;
} catch { return null; }
}
// Dialog: flache Wortmeldungen pro Thread (= Thread-Key), optionaler Bezug.
const COLS = 'id,thread,parent_id,author_name,author_avatar,body,created_at,deleted';
@@ -27,31 +17,13 @@ export async function listComments(c) {
return c.json(out);
}
// ÖFFENTLICH: Übersicht aller begonnenen Dialoge (Threads mit Wortmeldungen).
export async function listThreads(c) {
const { data, error } = await supabase.from('comments').select('thread,created_at,deleted');
if (error) return c.json({ error: error.message }, 500);
const map = {};
for (const r of data || []) {
if (r.deleted) continue;
const t = map[r.thread] || (map[r.thread] = { thread: r.thread, count: 0, last: r.created_at });
t.count += 1;
if (r.created_at > t.last) t.last = r.created_at;
}
let titles = {};
try { (await listEntries()).forEach((e) => { titles[e.url] = e.title; }); } catch { /* egal */ }
const out = Object.values(map)
.map((t) => ({ ...t, title: titles[t.thread] || t.thread }))
.sort((a, b) => (b.last || '').localeCompare(a.last || ''));
return c.json(out);
}
// EINGELOGGT: Wortmeldung schreiben.
export async function createComment(c) {
const user = c.get('user');
const email = c.get('email');
const { thread, body, parent_id } = await c.req.json();
if (!thread || !body || !body.trim()) return c.json({ error: 'thread und Text nötig' }, 400);
if (await threadLocked(thread)) return c.json({ error: 'Thread ist gesperrt' }, 403);
const prof = await profileFor(email);
const row = {
@@ -67,14 +39,14 @@ export async function createComment(c) {
return c.json(data, 201);
}
// EINGELOGGT: eigene Wortmeldung (oder als Admin jede) löschen.
// EINGELOGGT: eigene Wortmeldung löschen; Moderation (Admin/Redakteur) jede.
export async function deleteComment(c) {
const user = c.get('user');
const isAdmin = c.get('isAdmin');
const canModerate = c.get('canModerate');
const id = c.req.param('id');
const { data: row, error: e1 } = await supabase.from('comments').select('user_id').eq('id', id).single();
if (e1 || !row) return c.json({ error: 'Nicht gefunden' }, 404);
if (!isAdmin && row.user_id !== user.id) return c.json({ error: 'Kein Recht' }, 403);
if (!canModerate && row.user_id !== user.id) return c.json({ error: 'Kein Recht' }, 403);
const { error } = await supabase.from('comments').update({ deleted: true }).eq('id', id);
if (error) return c.json({ error: error.message }, 400);
return c.json({ ok: true });
@@ -84,12 +56,13 @@ export async function deleteComment(c) {
export async function login(c) {
const { email, password } = await c.req.json();
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
const { data, error } = await supabaseAuth.auth.signInWithPassword({ email, password });
if (error) return c.json({ error: error.message }, 401);
const prof = await profileFor((data.user.email || '').toLowerCase());
return c.json({
access_token: data.session.access_token,
email: data.user.email,
name: prof?.name || (data.user.email || '').split('@')[0],
role: roleOf(data.user),
});
}
+93
View File
@@ -0,0 +1,93 @@
import { Hono } from 'hono';
import { supabase } from '../supabase.js';
import { requireAdmin, requireModerator } from '../auth.js';
import {
forumsWithCounts, forumWithThreads, recentComments, createThread, recentForModeration, threadMeta,
} from '../dialog-store.js';
// ── Öffentliche Lese-Handler ─────────────────────────────────────────────
export async function listForums(c) {
try { return c.json(await forumsWithCounts()); }
catch (e) { return c.json({ error: String(e) }, 500); }
}
export async function showForum(c) {
const data = await forumWithThreads(c.req.param('slug'));
if (!data) return c.json({ error: 'Forum nicht gefunden' }, 404);
return c.json(data);
}
export async function recent(c) {
return c.json(await recentComments(Number(c.req.query('limit')) || 20));
}
export async function threadInfo(c) {
const key = c.req.query('key');
if (!key) return c.json({ error: 'key fehlt' }, 400);
const meta = await threadMeta(key);
if (!meta) return c.json({ error: 'Thread nicht gefunden' }, 404);
return c.json(meta);
}
// ── Eingeloggt: neuen Thread starten ─────────────────────────────────────
export async function newThread(c) {
const user = c.get('user');
const email = c.get('email');
const { forum_id, forum_slug, title, body } = await c.req.json();
const res = await createThread({ forumId: forum_id, forumSlug: forum_slug, title, body, user, email });
if (res.error) return c.json({ error: res.error }, 400);
return c.json(res.thread, 201);
}
// ── Moderation (Admin + Redakteur) ───────────────────────────────────────
export const mod = new Hono();
mod.use('*', requireModerator);
// Feed: letzte Wortmeldungen + alle Threads (zum Moderieren/Sperren).
mod.get('/overview', async (c) => c.json(await recentForModeration()));
// Thread sperren/entsperren.
mod.post('/thread-lock', async (c) => {
const { key, locked } = await c.req.json();
if (!key) return c.json({ error: 'key nötig' }, 400);
const { error } = await supabase.from('threads').update({ locked: !!locked }).eq('key', key);
if (error) return c.json({ error: error.message }, 400);
return c.json({ ok: true });
});
// Thread ausblenden (löschen).
mod.post('/thread-delete', async (c) => {
const { key } = await c.req.json();
if (!key) return c.json({ error: 'key nötig' }, 400);
const { error } = await supabase.from('threads').update({ deleted: true }).eq('key', key);
if (error) return c.json({ error: error.message }, 400);
return c.json({ ok: true });
});
// ── Foren-Verwaltung (nur Admin) ─────────────────────────────────────────
export const adminForums = new Hono();
adminForums.use('*', requireAdmin);
adminForums.get('/', async (c) => {
const { data, error } = await supabase.from('forums').select('*').order('sort');
if (error) return c.json({ error: error.message }, 500);
return c.json(data || []);
});
adminForums.post('/', async (c) => {
const { slug, name, description, color, sort } = await c.req.json();
if (!slug || !name) return c.json({ error: 'slug und name nötig' }, 400);
const row = { slug: String(slug).trim(), name: String(name).trim(),
description: description || '', color: color || null, sort: Number(sort) || 0 };
const { data, error } = await supabase.from('forums').insert(row).select('*').single();
if (error) return c.json({ error: error.message }, 400);
return c.json(data, 201);
});
adminForums.put('/:id', async (c) => {
const patch = await c.req.json();
const allowed = {};
for (const k of ['name', 'description', 'color', 'sort', 'slug']) if (k in patch) allowed[k] = patch[k];
const { data, error } = await supabase.from('forums').update(allowed).eq('id', c.req.param('id')).select('*').single();
if (error) return c.json({ error: error.message }, 400);
return c.json(data);
});
adminForums.delete('/:id', async (c) => {
const id = c.req.param('id');
const { data: f } = await supabase.from('forums').select('kind').eq('id', id).single();
if (f?.kind === 'library') return c.json({ error: 'Beiträge-Kategorie kann nicht gelöscht werden' }, 400);
const { error } = await supabase.from('forums').delete().eq('id', id);
if (error) return c.json({ error: error.message }, 400);
return c.json({ ok: true });
});
+22 -10
View File
@@ -1,6 +1,6 @@
import { Hono } from 'hono';
import { supabase } from '../supabase.js';
import { requireAdmin } from '../auth.js';
import { requireAdmin, roleOf } from '../auth.js';
// Autoren-/Nutzerverwaltung über die GoTrue-Admin-API (Service-Key). Nur Admins.
const ADMINS = (process.env.ADMIN_EMAILS || '')
@@ -12,12 +12,18 @@ users.use('*', requireAdmin);
users.get('/', async (c) => {
const { data, error } = await supabase.auth.admin.listUsers();
if (error) return c.json({ error: error.message }, 500);
const list = (data?.users || []).map((u) => ({
id: u.id,
email: u.email,
created_at: u.created_at,
isAdmin: ADMINS.includes((u.email || '').toLowerCase()),
}));
const list = (data?.users || []).map((u) => {
const role = roleOf(u);
return {
id: u.id,
email: u.email,
created_at: u.created_at,
role,
isAdmin: role === 'admin',
// Admins aus der .env lassen sich nicht per UI herabstufen.
fixedAdmin: ADMINS.includes((u.email || '').toLowerCase()),
};
});
return c.json(list);
});
@@ -30,9 +36,15 @@ users.post('/', async (c) => {
});
users.put('/:id', async (c) => {
const { password } = await c.req.json();
if (!password) return c.json({ error: 'Passwort nötig' }, 400);
const { error } = await supabase.auth.admin.updateUserById(c.req.param('id'), { password });
const { password, role } = await c.req.json();
const patch = {};
if (password) patch.password = password;
if (role) {
if (!['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
patch.app_metadata = { role };
}
if (!Object.keys(patch).length) return c.json({ error: 'Nichts zu ändern' }, 400);
const { error } = await supabase.auth.admin.updateUserById(c.req.param('id'), patch);
if (error) return c.json({ error: error.message }, 400);
return c.json({ ok: true });
});