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:
+28
-5
@@ -1,22 +1,39 @@
|
||||
import { supabase } from './supabase.js';
|
||||
import { supabaseAuth } from './supabase.js';
|
||||
|
||||
// Admins aus der .env (ADMIN_EMAILS=a@x,b@y). Admins sehen/bearbeiten alles.
|
||||
// Rollen-Hierarchie: admin > editor (Redakteur) > user.
|
||||
// - admin: alles (Foren verwalten, moderieren, Nutzer/Rollen, Inhalte)
|
||||
// - editor: moderieren (Wortmeldungen ausblenden/löschen, Threads sperren)
|
||||
// - user: im Forum mitschreiben
|
||||
// Admins aus der .env (ADMIN_EMAILS=a@x,b@y) sind immer Admin (Bootstrap, damit
|
||||
// man sich nicht aussperrt). Zusätzlich kann eine Rolle in app_metadata.role
|
||||
// liegen (im Admin-UI vergeben).
|
||||
const ADMINS = (process.env.ADMIN_EMAILS || '')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
|
||||
// Verifiziert den Supabase-Access-Token und legt user/email/isAdmin im Kontext ab.
|
||||
export function roleOf(user) {
|
||||
const email = (user?.email || '').toLowerCase();
|
||||
const meta = (user?.app_metadata?.role || '').toLowerCase();
|
||||
if (ADMINS.includes(email) || meta === 'admin') return 'admin';
|
||||
if (meta === 'editor') return 'editor';
|
||||
return 'user';
|
||||
}
|
||||
|
||||
// Verifiziert den Supabase-Access-Token und legt user/email/role im Kontext ab.
|
||||
export async function requireAuth(c, next) {
|
||||
const header = c.req.header('Authorization') || '';
|
||||
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||
if (!token) return c.json({ error: 'Nicht eingeloggt' }, 401);
|
||||
|
||||
const { data, error } = await supabase.auth.getUser(token);
|
||||
const { data, error } = await supabaseAuth.auth.getUser(token);
|
||||
if (error || !data?.user) return c.json({ error: 'Ungültiges Token' }, 401);
|
||||
|
||||
const email = (data.user.email || '').toLowerCase();
|
||||
const role = roleOf(data.user);
|
||||
c.set('user', data.user);
|
||||
c.set('email', email);
|
||||
c.set('isAdmin', ADMINS.includes(email));
|
||||
c.set('role', role);
|
||||
c.set('isAdmin', role === 'admin');
|
||||
c.set('canModerate', role === 'admin' || role === 'editor');
|
||||
await next();
|
||||
}
|
||||
|
||||
@@ -25,3 +42,9 @@ export async function requireAdmin(c, next) {
|
||||
if (!c.get('isAdmin')) return c.json({ error: 'Nur für Admins' }, 403);
|
||||
await next();
|
||||
}
|
||||
|
||||
// Admins + Redakteure — fürs Moderieren (nach requireAuth einsetzen).
|
||||
export async function requireModerator(c, next) {
|
||||
if (!c.get('canModerate')) return c.json({ error: 'Nur für Moderation' }, 403);
|
||||
await next();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { supabase } from './supabase.js';
|
||||
import { listEntries } from './files.js';
|
||||
|
||||
// Daten-Schicht für den Dialog (Foren + Threads + Wortmeldungen).
|
||||
// Alle DB-Zugriffe laufen über den Service-Client (umgeht RLS).
|
||||
|
||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||
export const LIBRARY_SLUG = 'beitraege';
|
||||
|
||||
export 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; }
|
||||
}
|
||||
|
||||
// Library-Beiträge als Threads in der Kategorie „Beiträge" spiegeln, damit man
|
||||
// auf jeden Beitrag einen Dialog starten kann. Idempotent (upsert über key).
|
||||
export async function syncLibrary() {
|
||||
const { data: forum } = await supabase
|
||||
.from('forums').select('id').eq('slug', LIBRARY_SLUG).single();
|
||||
if (!forum) return;
|
||||
let entries = [];
|
||||
try { entries = (await listEntries()).filter((e) => e.kind === 'beitrag'); } catch { return; }
|
||||
if (!entries.length) return;
|
||||
const rows = entries.map((e) => ({
|
||||
forum_id: forum.id, key: e.url, title: e.title, url: e.url, kind: 'library',
|
||||
}));
|
||||
// Nicht title überschreiben? Doch — Titel kann sich ändern. user_id/locked bleiben.
|
||||
await supabase.from('threads').upsert(rows, { onConflict: 'key', ignoreDuplicates: false });
|
||||
}
|
||||
|
||||
// Wortmeldungen pro Thread-Key aggregieren: { [key]: {count, last} }.
|
||||
async function commentStats() {
|
||||
const { data } = await supabase.from('comments').select('thread,created_at,deleted');
|
||||
const map = {};
|
||||
for (const r of data || []) {
|
||||
if (r.deleted) continue;
|
||||
const t = map[r.thread] || (map[r.thread] = { count: 0, last: r.created_at });
|
||||
t.count += 1;
|
||||
if (r.created_at > t.last) t.last = r.created_at;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// Alle Foren mit Thread-/Wortmeldungs-Zahl und letzter Aktivität.
|
||||
export async function forumsWithCounts() {
|
||||
await syncLibrary();
|
||||
const [{ data: forums }, { data: threads }, stats] = await Promise.all([
|
||||
supabase.from('forums').select('*').order('sort'),
|
||||
supabase.from('threads').select('id,forum_id,key,deleted'),
|
||||
commentStats(),
|
||||
]);
|
||||
const byForum = {};
|
||||
for (const t of threads || []) {
|
||||
if (t.deleted) continue;
|
||||
const f = byForum[t.forum_id] || (byForum[t.forum_id] = { threads: 0, posts: 0, last: '' });
|
||||
f.threads += 1;
|
||||
const s = stats[t.key];
|
||||
if (s) { f.posts += s.count; if (s.last > f.last) f.last = s.last; }
|
||||
}
|
||||
return (forums || []).map((f) => ({
|
||||
...f,
|
||||
thread_count: byForum[f.id]?.threads || 0,
|
||||
post_count: byForum[f.id]?.posts || 0,
|
||||
last_at: byForum[f.id]?.last || null,
|
||||
}));
|
||||
}
|
||||
|
||||
// Ein Forum samt seiner Threads (nach letzter Aktivität sortiert).
|
||||
export async function forumWithThreads(slug) {
|
||||
const { data: forum } = await supabase.from('forums').select('*').eq('slug', slug).single();
|
||||
if (!forum) return null;
|
||||
if (forum.kind === 'library') await syncLibrary();
|
||||
const [{ data: threads }, stats] = await Promise.all([
|
||||
supabase.from('threads').select('*').eq('forum_id', forum.id).eq('deleted', false),
|
||||
commentStats(),
|
||||
]);
|
||||
const list = (threads || []).map((t) => ({
|
||||
key: t.key, title: t.title, url: t.url, kind: t.kind, locked: t.locked,
|
||||
author_name: t.author_name, created_at: t.created_at,
|
||||
count: stats[t.key]?.count || 0, last: stats[t.key]?.last || t.created_at,
|
||||
})).sort((a, b) => (b.last || '').localeCompare(a.last || ''));
|
||||
return { forum, threads: list };
|
||||
}
|
||||
|
||||
// Letzte Wortmeldungen über alles — für die linke Spalte der Übersicht.
|
||||
export async function recentComments(limit = 20) {
|
||||
await syncLibrary();
|
||||
const [{ data: comments }, { data: threads }, { data: forums }] = await Promise.all([
|
||||
supabase.from('comments').select('id,thread,author_name,body,created_at')
|
||||
.eq('deleted', false).order('created_at', { ascending: false }).limit(limit),
|
||||
supabase.from('threads').select('key,title,url,forum_id,kind'),
|
||||
supabase.from('forums').select('id,slug,name'),
|
||||
]);
|
||||
const tByKey = {}; for (const t of threads || []) tByKey[t.key] = t;
|
||||
const fById = {}; for (const f of forums || []) fById[f.id] = f;
|
||||
return (comments || []).map((c) => {
|
||||
const t = tByKey[c.thread];
|
||||
const f = t ? fById[t.forum_id] : null;
|
||||
return {
|
||||
id: c.id, body: c.body, author_name: c.author_name, created_at: c.created_at,
|
||||
thread_title: t?.title || c.thread,
|
||||
thread_url: t ? (t.kind === 'library' ? t.url : '/dialog/?thread=' + encodeURIComponent(c.thread)) : c.thread,
|
||||
forum_name: f?.name || null, forum_slug: f?.slug || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Moderations-Überblick: letzte Wortmeldungen + alle Threads (zum Sperren/Löschen).
|
||||
export async function recentForModeration() {
|
||||
const [comments, { data: threads }, { data: forums }] = await Promise.all([
|
||||
recentComments(50),
|
||||
supabase.from('threads').select('key,title,url,kind,forum_id,locked,deleted,author_name,created_at')
|
||||
.order('created_at', { ascending: false }),
|
||||
supabase.from('forums').select('id,name,slug'),
|
||||
]);
|
||||
const fById = {}; for (const f of forums || []) fById[f.id] = f;
|
||||
const stats = await commentStats();
|
||||
const t = (threads || []).map((x) => ({
|
||||
key: x.key, title: x.title, url: x.url, kind: x.kind, locked: x.locked, deleted: x.deleted,
|
||||
author_name: x.author_name, created_at: x.created_at,
|
||||
forum_name: fById[x.forum_id]?.name || null,
|
||||
count: stats[x.key]?.count || 0,
|
||||
}));
|
||||
return { comments, threads: t };
|
||||
}
|
||||
|
||||
// Neuen Thread in einem Forum anlegen (+ erste Wortmeldung). Gibt den Thread zurück.
|
||||
export async function createThread({ forumId, forumSlug, title, body, user, email }) {
|
||||
let fid = forumId;
|
||||
if (!fid && forumSlug) {
|
||||
const { data: f } = await supabase.from('forums').select('id,kind').eq('slug', forumSlug).single();
|
||||
if (!f) return { error: 'Forum unbekannt' };
|
||||
if (f.kind === 'library') return { error: 'In Beiträge entstehen Threads automatisch' };
|
||||
fid = f.id;
|
||||
}
|
||||
if (!fid) return { error: 'Forum nötig' };
|
||||
if (!title || !title.trim()) return { error: 'Titel nötig' };
|
||||
if (!body || !body.trim()) return { error: 'Erster Beitrag nötig' };
|
||||
|
||||
const prof = await profileFor(email);
|
||||
const name = prof?.name || email.split('@')[0];
|
||||
const key = 't/' + randomUUID();
|
||||
const { data: thread, error: e1 } = await supabase.from('threads').insert({
|
||||
forum_id: fid, key, title: title.trim(), url: '/dialog/?thread=' + encodeURIComponent(key),
|
||||
kind: 'forum', author_name: name, user_id: user.id,
|
||||
}).select('*').single();
|
||||
if (e1) return { error: e1.message };
|
||||
const { error: e2 } = await supabase.from('comments').insert({
|
||||
thread: key, user_id: user.id, author_name: name,
|
||||
author_avatar: prof?.avatar || null, body: body.trim(),
|
||||
});
|
||||
if (e2) return { error: e2.message };
|
||||
return { thread };
|
||||
}
|
||||
|
||||
// Ist ein Thread gesperrt? (verhindert neue Wortmeldungen)
|
||||
export async function threadLocked(key) {
|
||||
const { data } = await supabase.from('threads').select('locked').eq('key', key).single();
|
||||
return !!data?.locked;
|
||||
}
|
||||
|
||||
// Thread-Metadaten für die Thread-Ansicht (Titel, Forum-Rücklink, Lock-Status).
|
||||
export async function threadMeta(key) {
|
||||
const { data: t } = await supabase
|
||||
.from('threads').select('title,url,kind,locked,forum_id').eq('key', key).single();
|
||||
if (!t) return null;
|
||||
let forum = null;
|
||||
if (t.forum_id) {
|
||||
const { data: f } = await supabase.from('forums').select('slug,name').eq('id', t.forum_id).single();
|
||||
forum = f || null;
|
||||
}
|
||||
return { title: t.title, url: t.url, kind: t.kind, locked: !!t.locked, forum };
|
||||
}
|
||||
+15
-3
@@ -8,8 +8,12 @@ import publish from './routes/publish.js';
|
||||
import upload from './routes/upload.js';
|
||||
import profile from './routes/profile.js';
|
||||
import users from './routes/users.js';
|
||||
import { listComments, listThreads, createComment, deleteComment, login } from './routes/comments.js';
|
||||
import { listComments, createComment, deleteComment, login } from './routes/comments.js';
|
||||
import {
|
||||
listForums, showForum, recent, threadInfo, newThread, mod, adminForums,
|
||||
} from './routes/dialog.js';
|
||||
import { requireAuth } from './auth.js';
|
||||
import { syncLibrary } from './dialog-store.js';
|
||||
|
||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||
const ADMIN_DIR = process.env.ADMIN_DIR || '/app/admin-dist';
|
||||
@@ -21,13 +25,19 @@ const app = new Hono();
|
||||
app.get('/api/health', (c) => c.json({ ok: true, hugo: '0.161.1+extended' }));
|
||||
// Öffentlich (ohne Login): Dialog lesen, Übersicht, Login fürs Dialog-Widget.
|
||||
app.get('/api/comments', listComments);
|
||||
app.get('/api/threads', listThreads);
|
||||
app.get('/api/forums', listForums);
|
||||
app.get('/api/forums/:slug', showForum);
|
||||
app.get('/api/recent', recent);
|
||||
app.get('/api/thread', threadInfo);
|
||||
app.post('/api/auth/login', login);
|
||||
// Alles weitere unter /api/* braucht ein gültiges Supabase-Token.
|
||||
app.use('/api/*', requireAuth);
|
||||
app.get('/api/me', (c) => c.json({ email: c.get('email'), isAdmin: c.get('isAdmin') }));
|
||||
app.get('/api/me', (c) => c.json({ email: c.get('email'), role: c.get('role'), isAdmin: c.get('isAdmin'), canModerate: c.get('canModerate') }));
|
||||
app.post('/api/comments', createComment);
|
||||
app.delete('/api/comments/:id', deleteComment);
|
||||
app.post('/api/threads', newThread);
|
||||
app.route('/api/mod', mod);
|
||||
app.route('/api/admin/forums', adminForums);
|
||||
app.route('/api/content', content);
|
||||
app.route('/api/preview', preview);
|
||||
app.route('/api/publish', publish);
|
||||
@@ -63,4 +73,6 @@ app.use('/*', serveStatic({ root: `${SITE_DIR}/public` }));
|
||||
|
||||
serve({ fetch: app.fetch, port: PORT }, (info) => {
|
||||
console.log(`OPENBUREAU CMS läuft auf :${info.port} — Site + API + /_preview`);
|
||||
// Library-Beiträge als Threads in „Beiträge" spiegeln (nicht blockierend).
|
||||
syncLibrary().catch((e) => console.error('syncLibrary:', e?.message || e));
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
});
|
||||
|
||||
+11
-4
@@ -8,7 +8,14 @@ if (!url || !key) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Service-Role-Key: server-seitig, umgeht RLS. Niemals ins Frontend geben.
|
||||
export const supabase = createClient(url, key, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
});
|
||||
const opts = { auth: { persistSession: false, autoRefreshToken: false } };
|
||||
|
||||
// Daten-Client: Service-Role-Key, umgeht RLS. NUR für DB-Zugriffe (from/insert/…).
|
||||
// Wichtig: hier niemals signInWithPassword aufrufen — das schaltet den
|
||||
// Authorization-Header des Clients prozessweit auf das User-Token um (SIGNED_IN),
|
||||
// wodurch anschließende Inserts als role=authenticated laufen und an RLS scheitern.
|
||||
export const supabase = createClient(url, key, opts);
|
||||
|
||||
// Eigener Client nur für Auth (Login, Token-Prüfung). Getrennt, damit ein
|
||||
// signInWithPassword den Daten-Client oben nicht „vergiftet". Niemals ins Frontend.
|
||||
export const supabaseAuth = createClient(url, key, opts);
|
||||
|
||||
Reference in New Issue
Block a user