diff --git a/cms/.env.example b/cms/.env.example
index 87fcd83..30f6505 100644
--- a/cms/.env.example
+++ b/cms/.env.example
@@ -15,6 +15,11 @@ SITE_URL=http://localhost:8080
# Öffentliche Supabase-Adresse (Browser/Admin erreichen Kong hierüber).
API_EXTERNAL_URL=http://localhost:8000
+# ═══ Rechte: wer ist Admin (sieht/bearbeitet ALLE Beiträge)? ═══
+# Komma-getrennte E-Mails. Alle anderen sehen nur Einträge, in denen ihre Mail
+# unter `authors` steht.
+ADMIN_EMAILS=karim@gabrielevarano.ch
+
# ═══ Optional: Ports ═══
APP_PORT=8080 # CMS: Site + /admin + /_preview + /api
KONG_HTTP_PORT=8000 # Supabase-API-Gateway
diff --git a/cms/README.md b/cms/README.md
index e5c91c0..a2a0caa 100644
--- a/cms/README.md
+++ b/cms/README.md
@@ -39,6 +39,18 @@ Supabase wird **nur noch für den Login** (GoTrue) gebraucht — keine Posts in
DB. Drafts liegen als `draft: true` in der Datei; der Live-Build lässt sie aus,
der Preview-Build (`--buildDrafts`) zeigt sie.
+## Rechte & Kollaboration
+
+- **Admin** (E-Mails in `ADMIN_EMAILS`) sieht und bearbeitet **alle** Einträge.
+- **Autor:innen** sehen nur Einträge, in denen ihre Mail unter `authors:` steht.
+ Beim Anlegen wird der Ersteller automatisch eingetragen.
+- **Kollaboration**: im Editor weitere E-Mails ins Feld „Autor:innen" → beide
+ haben Zugriff auf denselben Beitrag.
+- Bestehende Beiträge/Seiten/Rubriken **ohne** `authors:` sind nur für Admins
+ sichtbar; ein Admin kann Autor:innen zuweisen, um sie freizugeben.
+- Hinweis: `authors:` landet im Frontmatter (öffentliches Repo) — also E-Mails,
+ die du dort einträgst, sind im Repo sichtbar.
+
## Setup
### Schnellweg: Proxmox-LXC
diff --git a/cms/admin/src/App.jsx b/cms/admin/src/App.jsx
index 60d8dd4..62e1922 100644
--- a/cms/admin/src/App.jsx
+++ b/cms/admin/src/App.jsx
@@ -28,7 +28,7 @@ const EMPTY = {
isNew: true, path: '', type: 'beitrag', section: 'software', slug: '',
title: '', date: new Date().toISOString().slice(0, 10), weight: '',
color: '', layout: 'text', tags: '', summary: '', description: '',
- cover_image: '', external: '', toc: false, draft: true, body: '',
+ cover_image: '', external: '', authors: '', toc: false, draft: true, body: '',
};
export default function App() {
@@ -264,6 +264,9 @@ function Editor({ initial, onSaved, onMsg }) {
+
setF((p) => ({ ...p, body }))}
@@ -376,6 +379,7 @@ function fromRead(r) {
tags: Array.isArray(fm.tags) ? fm.tags.join(', ') : '',
summary: fm.summary || '', description: fm.description || '',
cover_image: fm.cover_image || '', external: fm.external || '',
+ authors: Array.isArray(fm.authors) ? fm.authors.join(', ') : (fm.authors || ''),
toc: !!fm.toc, draft: !!fm.draft, body: r.body || '',
};
}
@@ -391,6 +395,8 @@ function buildFrontmatter(f) {
if (f.layout) fm.layout = f.layout;
if (f.external) fm.external = f.external;
if (f.color) fm.color = f.color;
+ const authors = f.authors ? f.authors.split(',').map((t) => t.trim()).filter(Boolean) : [];
+ if (authors.length) fm.authors = authors;
if (f.toc) fm.toc = true;
if (f.draft) fm.draft = true;
return fm;
diff --git a/cms/api/src/auth.js b/cms/api/src/auth.js
index 7633e45..909dbb2 100644
--- a/cms/api/src/auth.js
+++ b/cms/api/src/auth.js
@@ -1,7 +1,10 @@
import { supabase } from './supabase.js';
-// Verifiziert den Supabase-Access-Token aus dem Authorization-Header gegen den
-// Supabase-Auth-Server. Schützt alle /api/* ausser /api/health.
+// Admins aus der .env (ADMIN_EMAILS=a@x,b@y). Admins sehen/bearbeiten alles.
+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 async function requireAuth(c, next) {
const header = c.req.header('Authorization') || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
@@ -10,6 +13,9 @@ export async function requireAuth(c, next) {
const { data, error } = await supabase.auth.getUser(token);
if (error || !data?.user) return c.json({ error: 'Ungültiges Token' }, 401);
+ const email = (data.user.email || '').toLowerCase();
c.set('user', data.user);
+ c.set('email', email);
+ c.set('isAdmin', ADMINS.includes(email));
await next();
}
diff --git a/cms/api/src/files.js b/cms/api/src/files.js
index 1b24d48..4635fff 100644
--- a/cms/api/src/files.js
+++ b/cms/api/src/files.js
@@ -40,6 +40,19 @@ function classify(rel) {
return { kind: 'seite', section: null };
}
+// authors-Frontmatter zu Array normalisieren (String oder Array erlaubt).
+export function normAuthors(a) {
+ if (Array.isArray(a)) return a.map(String).filter(Boolean);
+ if (a) return [String(a)];
+ return [];
+}
+
+// Hat diese E-Mail Zugriff (steht sie in der authors-Liste)?
+export function hasAccess(authors, email) {
+ const e = (email || '').toLowerCase();
+ return normAuthors(authors).some((a) => a.toLowerCase() === e);
+}
+
// Hugo-URL aus dem relativen Pfad.
export function urlFor(rel) {
let p = rel.replace(/\.md$/, '');
@@ -63,6 +76,7 @@ export async function listEntries() {
layout: data.layout || null,
draft: !!data.draft,
date: data.date ? String(data.date).slice(0, 10) : null,
+ authors: normAuthors(data.authors),
url: urlFor(rel),
});
}
diff --git a/cms/api/src/routes/content.js b/cms/api/src/routes/content.js
index 75b9b9c..9337604 100644
--- a/cms/api/src/routes/content.js
+++ b/cms/api/src/routes/content.js
@@ -1,28 +1,49 @@
import { Hono } from 'hono';
-import { listEntries, readEntry, writeEntry, entryExists } from '../files.js';
+import { listEntries, readEntry, writeEntry, entryExists, hasAccess, normAuthors } from '../files.js';
-// Dateibasiert: liest/schreibt die echten .md unter content/.
+// Dateibasiert + Rechte: Admin sieht/bearbeitet alles, Autor:innen nur Einträge,
+// in denen ihre Mail unter `authors` steht.
const content = new Hono();
-// Liste aller Einträge (Beiträge, Seiten, Rubriken).
content.get('/', async (c) => {
- try { return c.json(await listEntries()); }
- catch (e) { return c.json({ error: String(e.message || e) }, 500); }
+ const email = c.get('email'); const isAdmin = c.get('isAdmin');
+ try {
+ let items = await listEntries();
+ if (!isAdmin) items = items.filter((e) => hasAccess(e.authors, email));
+ return c.json(items);
+ } catch (e) { return c.json({ error: String(e.message || e) }, 500); }
});
-// Einen Eintrag lesen: /api/content/entry?path=library/software/stack.md
content.get('/entry', async (c) => {
- try { return c.json(await readEntry(c.req.query('path'))); }
- catch (e) { return c.json({ error: String(e.message || e) }, 400); }
+ const email = c.get('email'); const isAdmin = c.get('isAdmin');
+ try {
+ const entry = await readEntry(c.req.query('path'));
+ if (!isAdmin && !hasAccess(entry.frontmatter.authors, email)) {
+ return c.json({ error: 'Kein Zugriff auf diesen Eintrag' }, 403);
+ }
+ return c.json(entry);
+ } catch (e) { return c.json({ error: String(e.message || e) }, 400); }
});
-// Anlegen oder überschreiben.
content.put('/entry', async (c) => {
+ const email = c.get('email'); const isAdmin = c.get('isAdmin');
const { path: rel, frontmatter, body } = await c.req.json();
try {
- const created = !(await entryExists(rel));
- const saved = await writeEntry(rel, frontmatter, body);
- return c.json({ ok: true, path: saved, created });
+ const exists = await entryExists(rel);
+ if (exists && !isAdmin) {
+ const cur = await readEntry(rel);
+ if (!hasAccess(cur.frontmatter.authors, email)) {
+ return c.json({ error: 'Kein Zugriff auf diesen Eintrag' }, 403);
+ }
+ }
+ // authors zusammenführen; Ersteller wird beim Anlegen automatisch Autor.
+ const authors = normAuthors(frontmatter.authors);
+ if (!exists && email && !authors.some((a) => a.toLowerCase() === email)) {
+ authors.unshift(email);
+ }
+ const fm = { ...frontmatter, authors };
+ const saved = await writeEntry(rel, fm, body);
+ return c.json({ ok: true, path: saved, created: !exists });
} catch (e) { return c.json({ error: String(e.message || e) }, 400); }
});
diff --git a/cms/docker-compose.yml b/cms/docker-compose.yml
index acb178e..a4786a6 100644
--- a/cms/docker-compose.yml
+++ b/cms/docker-compose.yml
@@ -130,6 +130,7 @@ services:
# Server-seitig: intern über Kong, mit Service-Key.
SUPABASE_URL: http://kong:8000
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
+ ADMIN_EMAILS: ${ADMIN_EMAILS:-}
SITE_DIR: /site
PORT: 3000
GIT_PUBLISH: ${GIT_PUBLISH:-false}
diff --git a/cms/proxmox/create-openbureau-lxc.sh b/cms/proxmox/create-openbureau-lxc.sh
index 381d9db..1749c7e 100755
--- a/cms/proxmox/create-openbureau-lxc.sh
+++ b/cms/proxmox/create-openbureau-lxc.sh
@@ -42,6 +42,9 @@ GIT_TOKEN="${GIT_TOKEN:-}"
REPO_HOST="git.kgva.ch/karim/OPENBUREAU.git"
APP_DIR="/opt/openbureau"
+# Admin (sieht/bearbeitet ALLE Beiträge). Wird auch als erster Login-User vorgeschlagen.
+ADMIN_EMAIL="${ADMIN_EMAIL:-karim@gabrielevarano.ch}"
+
# Stack nach dem Setup direkt bauen + starten?
COMPOSE_UP="true"
##################################################################
@@ -57,6 +60,7 @@ if [ -t 0 ]; then
read -rp " Netzwerk-Bridge [${BRIDGE}]: " _x; BRIDGE="${_x:-$BRIDGE}"
read -rp " IP (dhcp | x.x.x.x/24) [${IP}]: " _x; IP="${_x:-$IP}"
[ "$IP" != "dhcp" ] && { read -rp " Gateway: " GATEWAY; }
+ read -rp " Admin-E-Mail [${ADMIN_EMAIL}]: " _x; ADMIN_EMAIL="${_x:-$ADMIN_EMAIL}"
fi
# --- 1. Template sicherstellen -------------------------------------------
@@ -142,6 +146,7 @@ pct exec "$CTID" -- bash -euo pipefail -c "
HOSTIP=\$(hostname -I | awk '{print \$1}')
sed -i \"s|^SITE_URL=.*|SITE_URL=http://\${HOSTIP}:8080|\" .env
sed -i \"s|^API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://\${HOSTIP}:8000|\" .env
+ sed -i \"s|^ADMIN_EMAILS=.*|ADMIN_EMAILS=${ADMIN_EMAIL}|\" .env
echo 'OK: .env generiert.'
fi
@@ -169,7 +174,7 @@ Login-User anlegen (im Container, nach dem Start):
-H "apikey: \$SERVICE_ROLE_KEY" \\
-H "Authorization: Bearer \$SERVICE_ROLE_KEY" \\
-H "Content-Type: application/json" \\
- -d '{"email":"karim@gabrielevarano.ch","password":"DEIN-PASSWORT","email_confirm":true}'
+ -d '{"email":"${ADMIN_EMAIL}","password":"DEIN-PASSWORT","email_confirm":true}'
Hinweise:
• :8000 ist das Supabase-API-Gateway (Kong), keine Web-Oberfläche.