Compare commits

...

9 Commits

Author SHA1 Message Date
karim b37edb6bda Merge commit '6aedf21bf6dc495fdda89abadbd383fb3ea9e15e' 2026-06-30 19:52:57 +02:00
karim 6aedf21bf6 Squashed 'cms/core/' changes from 8f4068d..9676bb0
9676bb0 core: update-check is token-free (running vs vendored cms/core version)
59fb691 core: System panel fix + settings (update-check, self-service password, admins)
67bcb6a docs: HANDOVER — kgva LIVE on core (Variant A, in-place CT130); both sites on core

git-subtree-dir: cms/core
git-subtree-split: 9676bb0264a7caf254ccbc461bc803a6b3583127
2026-06-30 19:52:57 +02:00
karim fed003ae31 cms: adopt the schema-driven editor (core update) + tune config field types
git subtree pull of openbureau-core main brings the schema-driven admin editor
(renders sidebar + fields from /api/schema, unknown frontmatter preserved on save).
openbureau.config.js color→type:'color' (swatch) and layout options text/image/icon
so the editor reproduces the previous look.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:36:37 +02:00
karim 685592362a Squashed 'cms/core/' changes from ac7538f..8f4068d
8f4068d docs: HANDOVER — stage 6 (schema editor) + 9 (kgva on core) done; live cutovers pending
59b9e20 core: schema editor preserves unknown frontmatter (no data loss on save)
f8f4131 core: stage 6 finish — schema-driven editor (admin renders from /api/schema)
dd45602 docs: HANDOVER — stages 6 (partial)/7/8 done, openbureau on core; next = kgva

git-subtree-dir: cms/core
git-subtree-split: 8f4068d109d70d33b6619e039aff56ea3962802f
2026-06-30 18:36:26 +02:00
karim 2f639c068b Merge commit '685592362aa6f53e981f9b4801d9a2e8ca318605' 2026-06-30 18:36:26 +02:00
karim f518eb7a13 Squashed 'cms/core/' changes from f709b5d..ac7538f
ac7538f chore: gitignore admin/dist build output
86f9f57 core: stage 7 — auth provider (config.auth: supabase | local), DB-less core

git-subtree-dir: cms/core
git-subtree-split: ac7538fa0c2c883e29fe67c8d8c15c5f40fa2230
2026-06-30 01:24:44 +02:00
karim 3d902a0bbe Merge commit 'f518eb7a1394ee29b825e551e6557dd71cd95a28' 2026-06-30 01:24:44 +02:00
karim 42110059c7 Squashed 'cms/core/' changes from 2f52ec7..f709b5d
f709b5d core: stage 6 (part) — /api/schema + /api/system + admin System panel

git-subtree-dir: cms/core
git-subtree-split: f709b5dc8ded7f138b21ac0aad4dd0855cb75aba
2026-06-30 01:06:09 +02:00
karim ecf7710b8e Merge commit '42110059c7a42b317fd1391e3cd539a555f19226' 2026-06-30 01:06:09 +02:00
24 changed files with 869 additions and 216 deletions
+3
View File
@@ -3,3 +3,6 @@ node_modules/
.env.*
*.log
.DS_Store
# build output
admin/dist/
+42 -14
View File
@@ -12,7 +12,8 @@
`api/src/plugin-manager.js`, `api/src/plugins/dialog/index.js` + their tests.
- **Run tests:** `cd api && npm install && node --test`**44 green**. `node_modules`
is git-ignored; the fresh clone needs `npm install` once.
- **Immediate next action:** stage 5 + 5.5 **DONE** on the branch (commits `c4230a4`
- **Status:** stages 19 DONE; openbureau + kgva BOTH LIVE on core. openbureau on dev CT134 (schema editor + supabase); kgva LIVE at karimgabrielevarano.xyz via core (Variant A: CMS serves the site; CT130 rebuilt in-place — Docker + kgva-cms on :8081, nginx :80 proxies to it and keeps /media, static timer disabled, Caddy untouched). CAVEAT: kgva GIT_PUBLISH=false → edits on CT130 disk, not git-backed (enable GIT_PUBLISH + creds to fix). Rollback for kgva: restore nginx .static.bak + re-enable kgva-deploy.timer + stop kgva-cms. Original stage-5 note kept below for history.
- **Immediate next action (historic):** stage 5 + 5.5 **DONE** on the branch (commits `c4230a4`
cutover, `5e06337` DDL, `a93d11a` migration runner). Plugin-manager singleton
(`api/src/plugins.js`) wired into `index.js`/`stats.js`/`publish.js`, hard-coded
dialog removed, source moved under `api/src/plugins/dialog/`; DDL captured into
@@ -125,19 +126,46 @@ openbureau's extras become plugins (first: `dialog`); kgva enables none.
`pg` against `DATABASE_URL`, warning-and-skipping if unset. Needs `DATABASE_URL`
wired into the cms service env for openbureau (stage 8). Tested: migrate.test.js +
tracking SQL proven on the live dev Postgres (throwaway DB).
6. admin: `/api/schema` + App.jsx renders from schema (must reproduce openbureau's
editor 1:1 when fed `openbureau.config.js`).
7. **auth provider** (`config.auth` = `supabase` | `local`). Verify is already local
(HS256/JWT_SECRET in auth.js). Add a `local` provider: file-based users (bcrypt) +
self-signed JWTs of the SAME claim shape (sub/email/app_metadata.role/exp), and a
`/login` route to replace GoTrue; admin login (`admin/src/supabase.js`) calls it
instead of `signInWithPassword`; users.js CRUD edits the user file. Goal: a
dialog-less core runs Node+Hugo+nginx, **no Supabase/Postgres** (~80 MB). openbureau
stays `supabase`. Independent of the cutover — `supabase` is the default no-op.
8. cut openbureau over to consume core (= core + `dialog` plugin + `supabase` auth +
its config) — test login / list / edit / preview / publish / dialog — identical.
9. onboard kgva (`CMS_CONFIG=…/kgva.config.js`, `auth: 'local'`, no plugins) as the
2nd consumer — DB-less.
6. [DONE, commits f709b5d + f8f4131 + 59b9e20] schema-driven admin. `GET /api/schema`
+ `GET /api/system` + a System panel; AND the editor now renders sidebar groups +
fields from the site's collections (admin/src/fields.jsx: per-type controls
string/text/slug/number/date/bool/select/list/image/color/markdown; form↔frontmatter
+ path building). One admin serves any site. Unknown frontmatter is PRESERVED on
save (__raw) — no data loss. Verified working via kgva (stage 9). NOTE: openbureau
is NOT yet redeployed with the schema editor (its running image keeps the bespoke
one); when it is, its config field types (color→swatch, layout opts) are already
tuned. Visual review of the openbureau editor by Karim still recommended before
redeploying it.
7. [DONE, commit 86f9f57] **auth provider** (`config.auth` = `supabase` | `local`).
`api/src/auth-local.js` = file-based users (bcryptjs) + self-signed HS256 JWTs of
the SAME claim shape; `routes/auth.js` POST /api/auth/login (mounted only when
auth:local); supabase.js made null-safe (fail-fast moved to index.js); auth/stats/
users.js branch local↔GoTrue; admin supabase.js gains a local-auth SHIM (same
interface) when VITE_SUPABASE_URL is absent → App/api untouched, supabase path
byte-identical. examples/kgva.config.js → auth:'local'. Verified: 59 tests +
DB-less boot smoke (no Supabase: local login → /api/me admin → stats-from-file)
+ openbureau redeployed and confirmed unregressed (auth=supabase).
8. [DONE, OB main 3d902a0] openbureau consumes core. Mechanism: **git subtree** at
`OPENBUREAU/cms/core` (fits the pull-based deploy; update via
`git subtree pull --prefix=cms/core <core> main --squash`). docker-compose builds
the cms image from `./core`; `cms/openbureau.config.js` (CMS_CONFIG) carries the
content model; old vendored cms/api+cms/admin removed. DATABASE_URL left unset (the
stack's migrate service owns db/schema.sql incl. dialog). Deployed to dev CT 134
and FULL write-verified: login/list/edit/preview/publish/dialog all identical, no
git divergence (GIT_PUBLISH=false). Rollback image `cms-cms:rollback` kept.
9. [DONE — LIVE on core (Variant A)] kgva consumes core. In
`karim/kgva` (git.kgva.ch, main 51a94b8): `cms/core` = core subtree, `cms/kgva.config.js`
(project/page/section, auth:'local', no plugins), `docker-compose.yml` builds the core
image (Hugo 0.163.3, no VITE_SUPABASE_URL → local-auth admin), `cms/seed-admin.mjs`,
updated deploy/provision-lxc.sh + ADMIN.md. VERIFIED DB-less end-to-end on real content
(staged on CT134): local login → schema editor → 42 entries (de/en) → stats →
create/preview/cleanup; image galleries preserved. The LIVE site is UNCHANGED — CT130
still deploys statically (timer → `hugo` → nginx; hugo ignores cms/); the CMS is dormant.
PENDING (deliberate, his call — repoints a live domain): provision a kgva-core container
(deploy/provision-lxc.sh, free CTID + temp IP) and decide topology — CMS serves the site
(Caddy → it) OR CMS edits + GIT_PUBLISH=true commits to git and the CT130 static timer
keeps serving. Then Caddy cutover per deploy/README. openbureau also not yet redeployed
with the schema editor (stage 6 note).
## How to run / test
openbureau CMS stack: `OPENBUREAU/cms/docker-compose.yml` (Node api + Hugo +
+184 -166
View File
@@ -3,33 +3,10 @@ import ToastEditor from '@toast-ui/editor';
import '@toast-ui/editor/dist/toastui-editor.css';
import { supabase } from './supabase.js';
import { api } from './api.js';
// OPENBUREAU-Palette (Hex aus assets/css/custom.css) — für Dropdown + Punkte.
const COLORS = [
['', 'keine', 'transparent'],
['ajisai', 'Ajisai · Hortensie', '#A39EC4'],
['sakura', 'Sakura · Kirschblüte', '#C49EC4'],
['suna', 'Suna · Sand', '#C4C19E'],
['ichigo', 'Ichigo · Erdbeere', '#C49EA0'],
['yuyake', 'Yuyake · Sonnenuntergang', '#CEB188'],
['sora', 'Sora · Himmel', '#9EC3C4'],
['kusa', 'Kusa · Gras', '#9EC49F'],
['kori', 'Kori · Eis', '#A5B4CB'],
['amagumo', 'Amagumo · Regenwolke', '#4C4C4C'],
['yuki', 'Yuki · Schnee', '#F0F0F0'],
];
const hexOf = (name) => (COLORS.find((c) => c[0] === name) || [])[2] || 'transparent';
const LAYOUTS = ['', 'text', 'image', 'icon'];
const SECTIONS = ['buerofuehrung', 'software', 'theorie'];
const KIND_LABEL = { beitrag: 'Beiträge', biblio: 'Library', seite: 'Seiten', rubrik: 'Rubriken' };
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: '', authors: '', group: '', toc: false, draft: true, body: '',
};
import {
Field, blankForm, formFromFrontmatter, frontmatterFromForm, targetPath,
isShort, isBody, hexOf, DEFAULT_PALETTE,
} from './fields.jsx';
export default function App() {
const [session, setSession] = useState(null);
@@ -69,6 +46,9 @@ function Login() {
function Dashboard({ email }) {
const [entries, setEntries] = useState([]);
const [collections, setCollections] = useState(null);
const [plugins, setPlugins] = useState([]);
const [site, setSite] = useState('');
const [current, setCurrent] = useState(null);
const [query, setQuery] = useState('');
const [view, setView] = useState('content');
@@ -79,31 +59,57 @@ function Dashboard({ email }) {
try { setEntries(await api.list()); }
catch (e) { setMsg({ type: 'err', text: e.message }); }
}
useEffect(() => { refresh(); api.getMe().then(setMe).catch(() => {}); }, []);
useEffect(() => {
refresh();
api.getMe().then(setMe).catch(() => {});
api.schema().then((sc) => {
setCollections((sc.collections || []).slice().sort((a, b) => (a.order ?? 99) - (b.order ?? 99)));
setPlugins(sc.plugins || []); setSite(sc.site || '');
}).catch(() => setCollections([]));
}, []);
useEffect(() => { if (!msg) return; const t = setTimeout(() => setMsg(null), 4000); return () => clearTimeout(t); }, [msg]);
const cols = collections || [];
const hasDialog = plugins.includes('dialog');
const collectionOf = (kind) => cols.find((c) => c.kind === kind) || cols.find((c) => c.fallback) || cols[0] || null;
async function open(entry) {
try { setCurrent(fromRead(await api.read(entry.path))); }
catch (err) { setMsg({ type: 'err', text: err.message }); }
const col = collectionOf(entry.kind);
try {
const r = await api.read(entry.path);
const bodyField = (col?.fields || []).find(isBody);
setCurrent({
isNew: false, kind: entry.kind, path: entry.path,
...formFromFrontmatter(r.frontmatter || {}, col || { fields: [] }),
...(bodyField ? { [bodyField.name]: r.body || '' } : {}),
});
} catch (err) { setMsg({ type: 'err', text: err.message }); }
}
function openNew() {
const col = cols[0];
if (col) setCurrent({ isNew: true, kind: col.kind, path: '', ...blankForm(col) });
}
const q = query.trim().toLowerCase();
const filtered = q ? entries.filter((e) => e.title.toLowerCase().includes(q) || (e.section || '').includes(q)) : entries;
const groups = { beitrag: [], biblio: [], seite: [], rubrik: [] };
for (const e of filtered) (groups[e.kind] || groups.seite).push(e);
const filtered = q ? entries.filter((e) => (e.title || '').toLowerCase().includes(q) || (e.section || '').includes(q)) : entries;
const fallbackKind = (cols.find((c) => c.fallback) || cols[0] || {}).kind;
const groups = {};
for (const c of cols) groups[c.kind] = [];
for (const e of filtered) { const k = groups[e.kind] ? e.kind : fallbackKind; if (groups[k]) groups[k].push(e); }
return (
<div className="app">
<header className="topbar">
<span className="logo">OPENBUREAU</span>
<span className="logo">{(site || 'cms').toUpperCase()}</span>
<span className="logo-sub">Redaktion</span>
<nav className="nav">
{me?.isAdmin && <button className={view === 'overview' ? 'active' : ''} onClick={() => setView('overview')}>Übersicht</button>}
<button className={view === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</button>
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button>
{me?.canModerate && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>}
{me?.isAdmin && <button className={view === 'forums' ? 'active' : ''} onClick={() => setView('forums')}>Foren</button>}
{me?.canModerate && hasDialog && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>}
{me?.isAdmin && hasDialog && <button className={view === 'forums' ? 'active' : ''} onClick={() => setView('forums')}>Foren</button>}
{me?.isAdmin && <button className={view === 'users' ? 'active' : ''} onClick={() => setView('users')}>Autor:innen</button>}
{me?.isAdmin && <button className={view === 'system' ? 'active' : ''} onClick={() => setView('system')}>System</button>}
</nav>
<span className="spacer" />
<span className="who">{email}</span>
@@ -121,18 +127,20 @@ function Dashboard({ email }) {
<Forums onMsg={setMsg} />
) : view === 'moderation' ? (
<Moderation onMsg={setMsg} />
) : view === 'system' ? (
<System onMsg={setMsg} />
) : (
<>
<aside>
<button className="new" onClick={() => setCurrent({ ...EMPTY })}> Neuer Beitrag</button>
<button className="new" onClick={openNew} disabled={!cols.length}> Neu</button>
<div className="search"><span></span><input placeholder="Suchen…" value={query} onChange={(e) => setQuery(e.target.value)} /></div>
{['beitrag', 'biblio', 'seite', 'rubrik'].map((kind) => groups[kind].length > 0 && (
<div className="group" key={kind}>
<div className="group-title">{KIND_LABEL[kind]} <span>{groups[kind].length}</span></div>
{cols.map((c) => groups[c.kind]?.length > 0 && (
<div className="group" key={c.kind}>
<div className="group-title">{c.label} <span>{groups[c.kind].length}</span></div>
<ul className="list">
{groups[kind].map((e) => (
{groups[c.kind].map((e) => (
<li key={e.path} className={current?.path === e.path ? 'active' : ''} onClick={() => open(e)}>
<span className="dot" style={{ background: e.color ? hexOf(e.color) : 'var(--line)' }} />
<span className="dot" style={{ background: e.color ? hexOf(DEFAULT_PALETTE, e.color) : 'var(--line)' }} />
<span className="t">
<span className="t-title">{e.title}</span>
<span className="t-meta">{[e.section, e.date].filter(Boolean).join(' · ')}</span>
@@ -146,9 +154,9 @@ function Dashboard({ email }) {
</aside>
<main>
{current
? <Editor key={current.path || 'new'} initial={current}
? <Editor key={(current.path || 'new') + ':' + current.kind} initial={current} collections={cols}
onSaved={(loaded) => { setCurrent(loaded); refresh(); }} onMsg={setMsg} />
: <div className="empty"><p>Wähle links einen Eintrag oder leg einen neuen Beitrag an.</p></div>}
: <div className="empty"><p>Wähle links einen Eintrag oder leg einen neuen an.</p></div>}
</main>
</>
)}
@@ -159,7 +167,7 @@ function Dashboard({ email }) {
);
}
function Editor({ initial, onSaved, onMsg }) {
function Editor({ initial, collections, onSaved, onMsg }) {
const [f, setF] = useState(initial);
const [previewUrl, setPreviewUrl] = useState(null);
const [showPreview, setShowPreview] = useState(false);
@@ -167,17 +175,23 @@ function Editor({ initial, onSaved, onMsg }) {
const [busy, setBusy] = useState(false);
const editorRef = useRef(null);
const dragging = useRef(false);
const coverIn = useRef(null);
const set = (k) => (e) => setF({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value });
const isWiki = f.type === 'biblio' || (f.path || '').startsWith('library/');
async function pickCover(ev) {
const file = ev.target.files?.[0]; ev.target.value = '';
if (!file) return;
setBusy(true);
try { const { url } = await api.upload(file); setF((p) => ({ ...p, cover_image: url })); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
finally { setBusy(false); }
const cols = collections || [];
const collection = cols.find((c) => c.kind === f.kind) || cols[0] || { fields: [] };
const fields = collection.fields || [];
const titleField = fields.find((x) => x.name === 'title') || fields.find((x) => x.type === 'string' && x.required) || null;
const bodyField = fields.find(isBody);
const draftField = fields.find((x) => x.type === 'bool' && x.name === 'draft');
const shortFields = fields.filter((x) => x !== titleField && !isBody(x) && isShort(x));
const longFields = fields.filter((x) => x !== titleField && !isBody(x) && !isShort(x));
const isDraft = draftField ? !!f[draftField.name] : false;
const hasSlugField = fields.some((x) => x.name === 'slug');
const setField = (name, val) => setF((p) => ({ ...p, [name]: val }));
const upload = async (file) => (await api.upload(file)).url;
function changeKind(kind) {
const col = cols.find((c) => c.kind === kind);
if (col) setF({ isNew: true, kind, path: '', ...blankForm(col) });
}
// Ziehbarer Trenner Editor ↔ Vorschau.
@@ -194,25 +208,22 @@ function Editor({ initial, onSaved, onMsg }) {
}, []);
function startDrag(e) { dragging.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); }
function currentPath(data = f) {
if (!data.isNew) return data.path;
const slug = (data.slug || '').trim();
if (!slug) return '';
if (data.type === 'beitrag') return `archiv/${data.section}/${slug}.md`;
if (data.type === 'biblio') return `library/${slug}.md`;
return `${slug}.md`;
}
// overrides erlauben z.B. { draft: false } beim Publizieren.
async function save(overrides = {}) {
const data = { ...f, ...overrides };
const path = currentPath(data);
if (!path) { onMsg({ type: 'err', text: 'Bitte einen Slug angeben.' }); return null; }
if (!data.title.trim()) { onMsg({ type: 'err', text: 'Titel fehlt.' }); return null; }
const path = data.isNew ? targetPath(data, collection) : data.path;
if (!path) { onMsg({ type: 'err', text: 'Bitte einen Slug (und ggf. Pflichtsegmente wie Rubrik) angeben.' }); return null; }
if (titleField && !((data[titleField.name] || '').trim())) { onMsg({ type: 'err', text: 'Titel fehlt.' }); return null; }
setBusy(true);
try {
await api.save(path, buildFrontmatter(data), data.body);
const loaded = fromRead(await api.read(path));
const fm = frontmatterFromForm(data, collection);
const body = bodyField ? (data[bodyField.name] || '') : '';
await api.save(path, fm, body);
const r = await api.read(path);
const loaded = {
isNew: false, kind: data.kind, path,
...formFromFrontmatter(r.frontmatter || {}, collection),
...(bodyField ? { [bodyField.name]: r.body || '' } : {}),
};
onSaved(loaded); setF(loaded);
return path;
} catch (e) { onMsg({ type: 'err', text: e.message }); return null; }
@@ -226,8 +237,8 @@ function Editor({ initial, onSaved, onMsg }) {
finally { setBusy(false); }
}
async function publish() {
if (!confirm('Live publizieren? Der Beitrag wird aus „Entwurf“ genommen.')) return;
const path = await save({ draft: false }); // Publizieren = nicht mehr Entwurf
if (!confirm('Live publizieren?')) return;
const path = await save(draftField ? { [draftField.name]: false } : {});
if (!path) return;
setBusy(true);
try { const res = await api.publish(path); onMsg({ type: 'ok', text: `Live: ${res.url}` }); }
@@ -241,7 +252,7 @@ function Editor({ initial, onSaved, onMsg }) {
<div className="editor-head">
<div className="crumb">{f.isNew ? 'Neuer Eintrag' : f.path}</div>
<span className="spacer" />
{f.draft ? <span className="status draft">Entwurf</span> : <span className="status live">Veröffentlicht</span>}
{draftField && (isDraft ? <span className="status draft">Entwurf</span> : <span className="status live">Veröffentlicht</span>)}
<button className="toggle" onClick={() => setShowPreview((v) => !v)} title="Vorschau ein/aus">
{showPreview ? 'Vorschau ' : 'Vorschau ⤢'}
</button>
@@ -254,61 +265,37 @@ function Editor({ initial, onSaved, onMsg }) {
{f.isNew && (
<div className="row">
<label className="sm">Typ
<select value={f.type} onChange={set('type')}>
<option value="beitrag">Beitrag</option>
<option value="biblio">Library-Seite</option>
<option value="seite">Seite</option>
<select value={f.kind} onChange={(e) => changeKind(e.target.value)}>
{cols.map((c) => <option key={c.kind} value={c.kind}>{c.label}</option>)}
</select>
</label>
{f.type === 'beitrag' && (
<label className="sm">Rubrik
<select value={f.section} onChange={set('section')}>{SECTIONS.map((s) => <option key={s}>{s}</option>)}</select>
{!hasSlugField && (
<label className="sm">Dateiname
<input value={f.slug || ''} onChange={(e) => setField('slug', e.target.value)} placeholder="z. B. impressum" />
</label>
)}
<label>Slug<input value={f.slug} onChange={set('slug')} placeholder="z.B. neuer-beitrag" /></label>
</div>
)}
<label className="big">Titel<input value={f.title} onChange={set('title')} placeholder="Titel des Beitrags" /></label>
<div className="meta">
{isWiki && <label className="sm">Gruppe<input value={f.group} onChange={set('group')} placeholder="z. B. Begriffe" /></label>}
<label className="sm">Datum<input type="date" value={f.date} onChange={set('date')} /></label>
<label className="xs">Reihenfolge<input type="number" value={f.weight} onChange={set('weight')} placeholder="weight" /></label>
<label className="sm">Farbe
<div className="colorpick">
<span className="swatch" style={{ background: hexOf(f.color) }} />
<select value={f.color} onChange={set('color')}>{COLORS.map(([v, label]) => <option key={v} value={v}>{label}</option>)}</select>
</div>
{titleField && (
<label className="big">{titleField.label || 'Titel'}
<input value={f[titleField.name] || ''} onChange={(e) => setField(titleField.name, e.target.value)} placeholder="Titel" />
</label>
<label className="sm">Layout
<select value={f.layout} onChange={set('layout')}>{LAYOUTS.map((l) => <option key={l} value={l}>{l || '(automatisch)'}</option>)}</select>
</label>
<label>Tags<input value={f.tags} onChange={set('tags')} placeholder="komma, getrennt" /></label>
<label className="check"><input type="checkbox" checked={f.toc} onChange={set('toc')} /> Inhaltsverz.</label>
<label className="check"><input type="checkbox" checked={f.draft} onChange={set('draft')} /> Entwurf</label>
</div>
)}
<label>Kurztext (summary)<input value={f.summary} onChange={set('summary')} /></label>
<div className="row">
<label>Cover-Bild
<div className="cover-row">
<input value={f.cover_image} onChange={set('cover_image')} placeholder="/images/…jpg" />
<button type="button" onClick={() => coverIn.current?.click()} disabled={busy}>Hochladen</button>
<input ref={coverIn} type="file" accept="image/*" hidden onChange={pickCover} />
{f.cover_image && <span className="cover-thumb" style={{ backgroundImage: `url(${f.cover_image})` }} />}
</div>
</label>
<label>Externer Link<input value={f.external} onChange={set('external')} placeholder="https://…" /></label>
</div>
<label>Autor:innen (E-Mails, Komma für gemeinsamen Zugriff)
<input value={f.authors} onChange={set('authors')} placeholder="du@…, kollege@…" />
</label>
{shortFields.length > 0 && (
<div className="meta">
{shortFields.map((fld) => <Field key={fld.name} field={fld} value={f[fld.name]} onChange={setField} onUpload={upload} busy={busy} />)}
</div>
)}
<div className="rich">
<RichEditor value={f.body} onChange={(body) => setF((p) => ({ ...p, body }))}
onUpload={async (file) => (await api.upload(file)).url} />
</div>
{longFields.map((fld) => <Field key={fld.name} field={fld} value={f[fld.name]} onChange={setField} onUpload={upload} busy={busy} />)}
{bodyField && (
<div className="rich">
<RichEditor value={f[bodyField.name] || ''} onChange={(body) => setField(bodyField.name, body)} onUpload={upload} />
</div>
)}
</div>
</div>
@@ -402,6 +389,31 @@ function Profile({ onMsg }) {
<label>Über mich<textarea value={p.bio} onChange={set('bio')} rows={5} placeholder="Kurzer Text über dich…" /></label>
<div className="actions"><button className="primary" onClick={save} disabled={busy}>Speichern</button></div>
</div>
<PasswordChange onMsg={onMsg} />
</div>
);
}
function PasswordChange({ onMsg }) {
const [cur, setCur] = useState('');
const [nxt, setNxt] = useState('');
const [busy, setBusy] = useState(false);
async function submit(e) {
e.preventDefault();
if (nxt.length < 6) { onMsg({ type: 'err', text: 'Neues Passwort zu kurz (min. 6 Zeichen).' }); return; }
setBusy(true);
try { await api.changePassword(cur, nxt); onMsg({ type: 'ok', text: 'Passwort geändert.' }); setCur(''); setNxt(''); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
finally { setBusy(false); }
}
return (
<div className="profile-card">
<h2>Passwort ändern</h2>
<form onSubmit={submit}>
<label>Aktuelles Passwort<input type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" /></label>
<label>Neues Passwort<input type="password" value={nxt} onChange={(e) => setNxt(e.target.value)} autoComplete="new-password" /></label>
<div className="actions"><button className="primary" disabled={busy}>Passwort setzen</button></div>
</form>
</div>
);
}
@@ -413,38 +425,80 @@ function Overview({ onMsg, go }) {
if (!s) return <div className="empty"></div>;
const Card = ({ label, value, hint, to }) => (
<button className="stat-card" onClick={to ? () => go(to) : undefined} disabled={!to}>
<span className="stat-value">{value}</span>
<span className="stat-value">{value ?? 0}</span>
<span className="stat-label">{label}</span>
<span className="stat-hint">{hint || ' '}</span>
<span className="stat-hint">{hint || ' '}</span>
</button>
);
const content = s.content || {};
const d = s.dialog || { forums: 0, threads: 0, comments: 0 };
const hasDialog = (d.forums + d.threads + d.comments) > 0;
return (
<div className="overview">
<h2>Übersicht</h2>
<div className="stat-grid">
<Card label="Beiträge" value={s.content.beitraege} hint={`${s.content.entwuerfe} Entwürfe`} to="content" />
<Card label="Library-Seiten" value={s.content.library} to="content" />
<Card label="Seiten" value={s.content.seiten} />
<Card label="Autor:innen" value={s.users.total} hint={`${s.users.admin} Admin · ${s.users.editor} Red.`} to="users" />
<Card label="Foren" value={s.dialog.forums} to="forums" />
<Card label="Threads" value={s.dialog.threads} to="moderation" />
<Card label="Wortmeldungen" value={s.dialog.comments} to="moderation" />
{Object.entries(content).map(([k, v]) => <Card key={k} label={k} value={v} to="content" />)}
<Card label="Autor:innen" value={s.users?.total} hint={`${s.users?.admin || 0} Admin · ${s.users?.editor || 0} Red.`} to="users" />
{hasDialog && <Card label="Foren" value={d.forums} to="forums" />}
{hasDialog && <Card label="Threads" value={d.threads} to="moderation" />}
{hasDialog && <Card label="Wortmeldungen" value={d.comments} to="moderation" />}
</div>
<div className="overview-actions">
<h3>Schnellzugriff</h3>
<div className="quick">
<button onClick={() => go('content')}>Inhalte bearbeiten</button>
<button onClick={() => go('forums')}>Foren verwalten</button>
{hasDialog && <button onClick={() => go('forums')}>Foren verwalten</button>}
<button onClick={() => go('users')}>Autor:innen &amp; Rollen</button>
<a className="quick-link" href="/" target="_blank" rel="noreferrer">Website </a>
<a className="quick-link" href="/dialog/" target="_blank" rel="noreferrer">Dialog </a>
<a className="quick-link" href="/library/" target="_blank" rel="noreferrer">Library </a>
</div>
</div>
</div>
);
}
// ── System (nur Admin): core-Version, Update-Check, Admins, Plugins, Modell ──
function System({ onMsg }) {
const [s, setS] = useState(null);
const [upd, setUpd] = useState(null);
useEffect(() => {
api.system().then(setS).catch((e) => onMsg({ type: 'err', text: e.message }));
api.systemUpdate().then(setUpd).catch(() => {});
}, []);
if (!s) return <div className="empty"></div>;
const badge = !upd ? null
: upd.available ? <span className="status draft">core v{upd.vendored} bereit Rebuild nötig</span>
: upd.vendored ? <span className="status live">aktuell</span>
: <span className="muted">(kein Subtree)</span>;
return (
<div className="profile">
<div className="profile-card wide">
<h2>System</h2>
<div className="sysgrid">
<div><span className="muted">Core</span><b>{s.core.name} <span className="ver">v{s.core.version}</span></b> {badge}</div>
<div><span className="muted">Site</span><b>{s.site || '—'}</b></div>
<div><span className="muted">Auth</span><b>{s.auth}</b></div>
<div><span className="muted">Hugo</span><b>{s.hugo}</b></div>
</div>
<h3>Admins <span className="count-pill">{(s.admins || []).length}</span></h3>
<ul className="syslist">{(s.admins || []).map((a) => (
<li key={a}><b>{a}</b><span className="muted">aus Config (fix)</span></li>
))}</ul>
<h3>Plugins <span className="count-pill">{s.plugins.length}</span></h3>
{s.plugins.length
? <ul className="syslist">{s.plugins.map((p) => (
<li key={p.name}><b>{p.name}</b><span className="muted">{p.routes} Routen · {p.migrations} Migration(en)</span></li>
))}</ul>
: <p className="muted">Keine Plugins aktiv.</p>}
<h3>Content-Modell <span className="count-pill">{s.collections.length}</span></h3>
<ul className="syslist">{s.collections.map((c) => (
<li key={c.kind}><b>{c.label}</b><span className="muted">{c.kind} · {c.fields} Felder{c.statKey ? ` · ${c.statKey}` : ''}</span></li>
))}</ul>
<p className="muted who-mail">openbureau-core fährt diese Site. Updates werden serverseitig eingespielt (Update-Skript).</p>
</div>
</div>
);
}
// ── Autor:innen-Verwaltung (nur Admin) ──────────────────────────────────────
function Users({ onMsg, currentEmail }) {
const [list, setList] = useState(null);
@@ -676,39 +730,3 @@ function slugify(s) {
return (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
// ── Mapping Datei-Lesart → Formular ────────────────────────────────────────
function fromRead(r) {
const fm = r.frontmatter || {};
const p = r.path || '';
const type = p.startsWith('archiv/') ? 'beitrag' : p.startsWith('library/') ? 'biblio' : 'seite';
return {
isNew: false, path: r.path, type, section: '', slug: '',
title: fm.title || '', date: fm.date ? String(fm.date).slice(0, 10) : '',
weight: fm.weight ?? '', color: fm.color || '', layout: fm.layout || '',
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 || ''),
group: fm.group || '', toc: !!fm.toc, draft: !!fm.draft, body: r.body || '',
};
}
function buildFrontmatter(f) {
const fm = { title: f.title };
if (f.date) fm.date = f.date;
if (f.weight !== '' && f.weight != null) fm.weight = Number(f.weight);
const tags = f.tags ? f.tags.split(',').map((t) => t.trim()).filter(Boolean) : [];
if (tags.length) fm.tags = tags;
if (f.summary) fm.summary = f.summary;
if (f.description) fm.description = f.description;
if (f.cover_image) fm.cover_image = f.cover_image;
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.group) fm.group = f.group;
if (f.toc) fm.toc = true;
if (f.draft) fm.draft = true;
return fm;
}
+4
View File
@@ -45,6 +45,10 @@ export const api = {
saveProfile: (p) => call('/profile', { method: 'PUT', body: JSON.stringify(p) }),
getMe: () => call('/me'),
stats: () => call('/stats'),
system: () => call('/system'),
systemUpdate: () => call('/system/update'),
schema: () => call('/schema'),
changePassword: (current, next) => call('/account/password', { method: 'POST', body: JSON.stringify({ current, next }) }),
listUsers: () => call('/users'),
createUser: (email, password, role) => call('/users', { method: 'POST', body: JSON.stringify({ email, password, role }) }),
setPassword: (id, password) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ password }) }),
+154
View File
@@ -0,0 +1,154 @@
// Schema-driven form fields. The editor renders a collection's `fields` (from
// /api/schema) generically, so the SAME admin works for any site's content model
// (openbureau, kgva, …). Field types → controls; markdown ('body') is handled by
// the rich editor in App.jsx, not here.
//
// A field: { name, type, required?, options?, default?, hint?, palette? }
// Types: string | text | slug | number | date | bool | select | list | image |
// color | markdown (markdown rendered separately).
// openbureau's named colour palette — used for type:'color' when no per-field
// palette is given. Harmless elsewhere (sites without colour fields never see it).
export const DEFAULT_PALETTE = [
['', 'keine', 'transparent'],
['ajisai', 'Ajisai · Hortensie', '#A39EC4'], ['sakura', 'Sakura · Kirschblüte', '#C49EC4'],
['suna', 'Suna · Sand', '#C4C19E'], ['ichigo', 'Ichigo · Erdbeere', '#C49EA0'],
['yuyake', 'Yuyake · Sonnenuntergang', '#CEB188'], ['sora', 'Sora · Himmel', '#9EC3C4'],
['kusa', 'Kusa · Gras', '#9EC49F'], ['kori', 'Kori · Eis', '#A5B4CB'],
['amagumo', 'Amagumo · Regenwolke', '#4C4C4C'], ['yuki', 'Yuki · Schnee', '#F0F0F0'],
];
export const hexOf = (palette, name) => (palette.find((c) => c[0] === name) || [])[2] || 'transparent';
const SHORT = new Set(['string', 'slug', 'number', 'date', 'bool', 'select', 'color']);
export const isShort = (f) => SHORT.has(f.type);
export const isBody = (f) => f.type === 'markdown';
// Empty form for a new entry of `collection`.
export function blankForm(collection) {
const f = { __raw: {} };
for (const fld of collection.fields || []) {
if (isBody(fld)) { f[fld.name] = fld.default ?? ''; continue; }
if (fld.type === 'bool') f[fld.name] = fld.default ?? false;
else if (fld.type === 'list') f[fld.name] = '';
else f[fld.name] = fld.default ?? '';
}
return f;
}
// Frontmatter (from disk) → form values, per field type. The full original
// frontmatter is stashed under __raw so unknown keys survive a save (no data loss).
export function formFromFrontmatter(fm, collection) {
const f = { __raw: fm || {} };
for (const fld of collection.fields || []) {
const v = fm[fld.name];
if (fld.type === 'bool') f[fld.name] = !!v;
else if (fld.type === 'list') f[fld.name] = Array.isArray(v) ? v.join(', ') : (v || '');
else if (fld.type === 'date') f[fld.name] = v ? String(v).slice(0, 10) : '';
else if (fld.type === 'number') f[fld.name] = v ?? '';
else f[fld.name] = v ?? '';
}
return f;
}
// Form values → frontmatter. Starts from any preserved __raw (so fields not in the
// schema, e.g. image galleries, are kept), then sets/clears the schema fields.
export function frontmatterFromForm(form, collection) {
const fm = { ...(form.__raw || {}) };
const setOrDrop = (k, v) => { if (v === '' || v == null || (Array.isArray(v) && !v.length)) delete fm[k]; else fm[k] = v; };
for (const fld of collection.fields || []) {
const v = form[fld.name];
if (fld.type === 'bool') { if (v) fm[fld.name] = true; else delete fm[fld.name]; continue; }
if (fld.type === 'number') { setOrDrop(fld.name, v === '' || v == null ? '' : Number(v)); continue; }
if (fld.type === 'list') { setOrDrop(fld.name, (v || '').split(',').map((x) => x.trim()).filter(Boolean)); continue; }
setOrDrop(fld.name, v);
}
return fm;
}
// Build the target path from collection.path ( e.g. 'archiv/:section/:slug' ),
// falling back to '<slug>.md' (or '<id>.md' when there is no slug field).
export function targetPath(form, collection) {
const tmpl = collection.path;
const slug = (form.slug || '').trim();
if (tmpl) {
const rel = tmpl.replace(/:([a-z_]+)/gi, (_, k) => (form[k] || '').toString().trim());
if (rel.includes('//') || rel.endsWith('/') || /:/.test(rel)) return ''; // missing segment
return rel + '.md';
}
if (slug) return `${slug}.md`;
return '';
}
// One field control (everything except markdown/body).
export function Field({ field, value, onChange, onUpload, busy }) {
const set = (val) => onChange(field.name, val);
const label = field.label || field.name;
const palette = field.palette || DEFAULT_PALETTE;
if (field.type === 'bool') {
return <label className="check"><input type="checkbox" checked={!!value} onChange={(e) => set(e.target.checked)} /> {label}</label>;
}
if (field.type === 'select') {
return (
<label className="sm">{label}
<select value={value || ''} onChange={(e) => set(e.target.value)}>
{!field.required && <option value="">()</option>}
{(field.options || []).map((o) => <option key={o} value={o}>{o}</option>)}
</select>
</label>
);
}
if (field.type === 'color') {
return (
<label className="sm">{label}
<div className="colorpick">
<span className="swatch" style={{ background: hexOf(palette, value) }} />
<select value={value || ''} onChange={(e) => set(e.target.value)}>
{palette.map(([v, lbl]) => <option key={v} value={v}>{lbl}</option>)}
</select>
</div>
</label>
);
}
if (field.type === 'image') {
return (
<label>{label}
<div className="cover-row">
<input value={value || ''} onChange={(e) => set(e.target.value)} placeholder="/img/…" />
<ImageUpload onUpload={onUpload} onDone={set} busy={busy} />
{value && <span className="cover-thumb" style={{ backgroundImage: `url(${value})` }} />}
</div>
</label>
);
}
if (field.type === 'text') {
return <label>{label}<textarea value={value || ''} rows={2} onChange={(e) => set(e.target.value)} placeholder={field.hint || ''} /></label>;
}
if (field.type === 'number') {
return <label className="xs">{label}<input type="number" value={value ?? ''} onChange={(e) => set(e.target.value)} placeholder={field.hint || ''} /></label>;
}
if (field.type === 'date') {
return <label className="sm">{label}<input type="date" value={value || ''} onChange={(e) => set(e.target.value)} /></label>;
}
if (field.type === 'list') {
return <label>{label}<input value={value || ''} onChange={(e) => set(e.target.value)} placeholder={field.hint || 'komma, getrennt'} /></label>;
}
// string, slug, and any unknown type → text input
const cls = field.type === 'slug' ? 'sm' : '';
return <label className={cls}>{label}<input value={value || ''} onChange={(e) => set(e.target.value)} placeholder={field.hint || ''} /></label>;
}
import { useRef } from 'react';
function ImageUpload({ onUpload, onDone, busy }) {
const ref = useRef(null);
return (
<>
<button type="button" onClick={() => ref.current?.click()} disabled={busy}>Hochladen</button>
<input ref={ref} type="file" accept="image/*" hidden onChange={async (ev) => {
const file = ev.target.files?.[0]; ev.target.value = '';
if (!file) return;
try { onDone(await onUpload(file)); } catch { /* ignore */ }
}} />
</>
);
}
+10
View File
@@ -219,3 +219,13 @@ label.big input { font-family: var(--serif); font-weight: 600; }
.toast { position: fixed; bottom: 20px; right: 20px; padding: 11px 18px; border-radius: 11px; color: #fff; cursor: pointer; box-shadow: 0 10px 30px -12px rgba(0,0,0,.4); font-size: 13.5px; max-width: 380px; z-index: 50; }
.toast.ok { background: var(--ok); }
.toast.err { background: var(--accent); }
/* System-Panel */
.sysgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: .75rem; margin: .5rem 0 1rem; }
.sysgrid > div { display: flex; flex-direction: column; gap: .15rem; }
.sysgrid .muted { font-size: .8rem; }
.sysgrid b { font-size: 1.05rem; }
.sysgrid .ver { opacity: .6; font-weight: 400; }
.syslist { list-style: none; padding: 0; margin: .25rem 0 1rem; }
.syslist li { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding: .4rem 0; border-bottom: 1px solid var(--line); }
.syslist li .muted { font-size: .85rem; }
+37 -1
View File
@@ -5,4 +5,40 @@ import { createClient } from '@supabase/supabase-js';
const url = import.meta.env.VITE_SUPABASE_URL;
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
export const supabase = createClient(url, anonKey);
// Mit Supabase-URL: echter Client (openbureau, unverändert). Ohne (DB-loser core,
// auth: 'local', z. B. kgva): ein Shim mit DERSELBEN auth-Schnittstelle, die App/
// api nutzen — getSession / onAuthStateChange / signInWithPassword / signOut —
// gegen /api/auth/login, Token in localStorage. So bleibt der Supabase-Pfad
// völlig unangetastet, und der lokale Pfad braucht keine Änderung an App/api.
function localAuthClient() {
const KEY = 'cms_local_session';
const read = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } };
let listeners = [];
const emit = (s) => listeners.forEach((cb) => { try { cb(s ? 'SIGNED_IN' : 'SIGNED_OUT', s); } catch { /* ignore */ } });
return {
auth: {
async getSession() { return { data: { session: read() } }; },
onAuthStateChange(cb) {
listeners.push(cb);
return { data: { subscription: { unsubscribe() { listeners = listeners.filter((l) => l !== cb); } } } };
},
async signInWithPassword({ email, password }) {
try {
const r = await fetch('/api/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) return { error: { message: j.error || `HTTP ${r.status}` } };
const session = { access_token: j.access_token, user: { id: j.user.id, email: j.user.email } };
localStorage.setItem(KEY, JSON.stringify(session));
emit(session);
return { data: { session }, error: null };
} catch (e) { return { error: { message: String(e.message || e) } }; }
},
async signOut() { localStorage.removeItem(KEY); emit(null); return { error: null }; },
},
};
}
export const supabase = url ? createClient(url, anonKey) : localAuthClient();
+9 -2
View File
@@ -1,15 +1,16 @@
{
"name": "openbureau-cms-api",
"version": "0.1.0",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openbureau-cms-api",
"version": "0.1.0",
"version": "0.6.0",
"dependencies": {
"@hono/node-server": "^1.13.7",
"@supabase/supabase-js": "^2.47.10",
"bcryptjs": "^2.4.3",
"gray-matter": "^4.0.3",
"hono": "^4.6.14",
"marked": "^14.1.4",
@@ -529,6 +530,12 @@
"sprintf-js": "~1.0.2"
}
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openbureau-cms-api",
"version": "0.1.0",
"version": "0.7.0",
"private": true,
"type": "module",
"description": "Headless CMS backend für OPENBUREAU — schreibt Supabase-Posts in Hugo-content/, baut und serviert die Site.",
@@ -12,6 +12,7 @@
"dependencies": {
"@hono/node-server": "^1.13.7",
"@supabase/supabase-js": "^2.47.10",
"bcryptjs": "^2.4.3",
"gray-matter": "^4.0.3",
"hono": "^4.6.14",
"marked": "^14.1.4",
+109
View File
@@ -0,0 +1,109 @@
// Local auth provider — file-based users (bcrypt) + self-signed JWTs of the SAME
// claim shape as Supabase/GoTrue (sub / email / app_metadata.role / exp), so the
// rest of the engine (auth.js verifyToken, roleOf) treats local and supabase
// tokens identically. Lets a core instance run DB-less (no Supabase/Postgres):
// Node + Hugo + nginx. Activated by config.auth === 'local'.
//
// Users live in a JSON file (USERS_FILE, default <SITE_DIR>/cms-users.json):
// [{ id, email, password: <bcrypt hash>, role, created_at, last_sign_in_at }]
import path from 'node:path';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { sign } from 'hono/jwt';
const SITE_DIR = process.env.SITE_DIR || '/site';
const USERS_FILE = process.env.USERS_FILE || path.join(SITE_DIR, 'cms-users.json');
const JWT_SECRET = process.env.JWT_SECRET || '';
const ROLES = ['user', 'editor', 'admin'];
// bcryptjs is pure-JS (no native build) and lazily imported — a supabase-auth
// instance never loads it.
async function bcrypt() { return (await import('bcryptjs')).default; }
export async function loadUsers() {
try {
const j = JSON.parse(await readFile(USERS_FILE, 'utf8'));
return Array.isArray(j) ? j : (j.users || []);
} catch { return []; }
}
async function persist(users) {
await mkdir(path.dirname(USERS_FILE), { recursive: true });
await writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf8');
}
export async function hashPassword(pw) { return (await bcrypt()).hash(pw, 10); }
export async function verifyPassword(pw, hash) {
try { return await (await bcrypt()).compare(pw, hash); } catch { return false; }
}
// Mint a GoTrue-shaped HS256 access token (verified by auth.js with JWT_SECRET).
export async function mintToken(user, ttl = 3600) {
if (!JWT_SECRET) throw new Error('JWT_SECRET fehlt — für auth: local nötig');
const now = Math.floor(Date.now() / 1000);
return sign({
sub: user.id, email: user.email, role: 'authenticated',
app_metadata: { role: user.role || 'user' }, iat: now, exp: now + ttl,
}, JWT_SECRET, 'HS256');
}
export async function login(email, password) {
const users = await loadUsers();
const u = users.find((x) => (x.email || '').toLowerCase() === (email || '').toLowerCase());
if (!u || !u.password) return null;
if (!(await verifyPassword(password, u.password))) return null;
u.last_sign_in_at = new Date().toISOString();
await persist(users);
return {
access_token: await mintToken(u),
token_type: 'bearer',
user: { id: u.id, email: u.email, role: u.role || 'user' },
};
}
// ── Admin CRUD (mirrors routes/users.js shapes) ──────────────────────────────
export async function listUsers(adminEmails = []) {
const users = await loadUsers();
return users.map((u) => {
const fixedAdmin = adminEmails.includes((u.email || '').toLowerCase());
const role = u.role || 'user';
return {
id: u.id, email: u.email, created_at: u.created_at || null,
last_sign_in_at: u.last_sign_in_at || null,
role: fixedAdmin ? 'admin' : role, isAdmin: fixedAdmin || role === 'admin', fixedAdmin,
};
});
}
export async function countByRole(adminEmails = []) {
const list = await listUsers(adminEmails);
const out = { total: list.length, admin: 0, editor: 0, user: 0 };
for (const u of list) out[u.role] = (out[u.role] || 0) + 1;
return out;
}
export async function createUser({ email, password, role }) {
if (!email || !password) throw new Error('E-Mail und Passwort nötig');
if (role && !ROLES.includes(role)) throw new Error('Unbekannte Rolle');
const users = await loadUsers();
if (users.some((u) => (u.email || '').toLowerCase() === email.toLowerCase())) {
throw new Error('E-Mail existiert bereits');
}
const u = {
id: randomUUID(), email, password: await hashPassword(password),
role: role || 'user', created_at: new Date().toISOString(), last_sign_in_at: null,
};
users.push(u); await persist(users);
return u.id;
}
export async function updateUser(id, { password, role }) {
const users = await loadUsers();
const u = users.find((x) => x.id === id);
if (!u) throw new Error('Nutzer:in nicht gefunden');
if (password) u.password = await hashPassword(password);
if (role) { if (!ROLES.includes(role)) throw new Error('Unbekannte Rolle'); u.role = role; }
await persist(users);
}
export async function deleteUser(id) {
const users = await loadUsers();
const next = users.filter((u) => u.id !== id);
if (next.length === users.length) throw new Error('Nutzer:in nicht gefunden');
await persist(next);
}
+3
View File
@@ -17,6 +17,9 @@ async function verifyToken(token) {
return { id: p.sub, email: p.email || '', app_metadata: p.app_metadata || {} };
} catch { return null; }
}
// Fallback (kein JWT_SECRET): Remote-Prüfung gegen GoTrue — nur wenn ein
// Supabase-Auth-Client existiert (DB-loser Betrieb hat keinen).
if (!supabaseAuth) return null;
const { data, error } = await supabaseAuth.auth.getUser(token);
if (error || !data?.user) return null;
return data.user;
+1
View File
@@ -20,6 +20,7 @@ export const config = mod.default || mod;
config.collections ||= [];
config.plugins ||= [];
config.admins ||= [];
config.auth ||= 'supabase'; // 'supabase' (GoTrue) | 'local' (file users, DB-less)
export const hasPlugin = (name) => config.plugins.includes(name);
+26 -1
View File
@@ -12,11 +12,23 @@ import upload from './routes/upload.js';
import profile from './routes/profile.js';
import users from './routes/users.js';
import stats from './routes/stats.js';
import system from './routes/system.js';
import account from './routes/account.js';
import history from './routes/history.js';
import authRoute from './routes/auth.js';
import { requireAuth, requireAdmin } from './auth.js';
import { config } from './config.js';
import { supabase } from './supabase.js';
import { manager } from './plugins.js';
import { runMigrations } from './migrate.js';
// Fail-fast: auth:'supabase' ohne erreichbaren Service-Client ist ein Fehlstart.
// (auth:'local' braucht keinen — DB-loser Betrieb.)
if (config.auth === 'supabase' && !supabase) {
console.error('FEHLT: SUPABASE_URL/SERVICE_KEY für auth: supabase');
process.exit(1);
}
const SITE_DIR = process.env.SITE_DIR || '/site';
const ADMIN_DIR = process.env.ADMIN_DIR || '/app/admin-dist';
const PORT = Number(process.env.PORT || 3000);
@@ -62,9 +74,12 @@ app.get('/api/health', (c) => c.json({ ok: true, hugo: '0.161.1+extended' }));
// lesen + Widget-Login. Jede Route bringt ihre eigenen Limits mit (dialog drosselt
// den Login auf 10 Versuche/IP pro 5 Minuten). Pluginlose Site: nichts gemountet.
manager.mountPublic(app);
// Lokaler Login (nur auth: 'local') — ersetzt GoTrue, öffentlich + gedrosselt.
// Supabase-Sites loggen direkt gegen GoTrue ein und brauchen das nicht.
if (config.auth === 'local') app.route('/api/auth', authRoute);
// Öffentlich: Versionsverlauf der Beiträge (Git-History) — auf der Site anzeigbar.
app.route('/api/history', history);
// Alles weitere unter /api/* braucht ein gültiges Supabase-Token.
// Alles weitere unter /api/* braucht ein gültiges Token (Supabase oder lokal).
app.use('/api/*', requireAuth);
// Schreibzugriffe drosseln (Spam-Schutz, auch bei gekapertem Token):
// 60 Mutationen/Minute je Nutzer. Lesen (GET) bleibt frei.
@@ -85,6 +100,16 @@ app.route('/api/upload', upload);
app.route('/api/profile', profile);
app.route('/api/users', users);
app.route('/api/stats', stats);
app.route('/api/system', system);
app.route('/api/account', account);
// Content-Modell der Site fürs Admin (schema-getriebenes Rendern). Jede:r
// eingeloggte Nutzer:in darf es lesen — der Editor braucht es zum Aufbauen.
app.get('/api/schema', (c) => c.json({
site: config.site || null,
auth: config.auth || 'supabase',
plugins: config.plugins,
collections: config.collections,
}));
// --- Admin-SPA (im Container mitgebaut, unter /admin serviert) ---
app.get('/admin', (c) => c.redirect('/admin/'));
+38
View File
@@ -0,0 +1,38 @@
import { Hono } from 'hono';
import { config } from '../config.js';
import { supabase, supabaseAuth } from '../supabase.js';
import * as local from '../auth-local.js';
// Self-service for the logged-in user (after requireAuth). Currently: change own
// password — local provider edits the user file; supabase verifies the current
// password then updates via the admin API.
const account = new Hono();
account.post('/password', async (c) => {
let body = {};
try { body = await c.req.json(); } catch { /* 400 below */ }
const { current, next } = body;
if (!next || String(next).length < 6) return c.json({ error: 'Neues Passwort zu kurz (min. 6 Zeichen).' }, 400);
const email = (c.get('email') || '').toLowerCase();
const userId = c.get('user')?.id;
if (config.auth === 'local') {
const u = (await local.loadUsers()).find((x) => (x.email || '').toLowerCase() === email);
if (!u || !(await local.verifyPassword(current || '', u.password))) {
return c.json({ error: 'Aktuelles Passwort ist falsch.' }, 403);
}
await local.updateUser(u.id, { password: next });
return c.json({ ok: true });
}
// supabase: verify current via the dedicated auth client, then update via admin.
if (!supabaseAuth || !supabase) return c.json({ error: 'Auth nicht verfügbar' }, 500);
const { error: e1 } = await supabaseAuth.auth.signInWithPassword({ email, password: current || '' });
await supabaseAuth.auth.signOut().catch(() => {}); // Session sofort wieder lösen
if (e1) return c.json({ error: 'Aktuelles Passwort ist falsch.' }, 403);
const { error: e2 } = await supabase.auth.admin.updateUserById(userId, { password: next });
if (e2) return c.json({ error: e2.message }, 400);
return c.json({ ok: true });
});
export default account;
+20
View File
@@ -0,0 +1,20 @@
import { Hono } from 'hono';
import { rateLimit } from '../ratelimit.js';
import { login } from '../auth-local.js';
// Lokaler Login (auth: 'local') — ersetzt GoTrue. Liefert ein GoTrue-förmiges
// Token, das requireAuth genauso prüft wie ein Supabase-Token. Nur gemountet,
// wenn config.auth === 'local' (siehe index.js); Brute-Force gedrosselt.
const auth = new Hono();
auth.post('/login', rateLimit({ max: 10, windowMs: 5 * 60_000 }), async (c) => {
let body = {};
try { body = await c.req.json(); } catch { /* leerer/kaputter Body → 400 unten */ }
const { email, password } = body;
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
const res = await login(email, password);
if (!res) return c.json({ error: 'Login fehlgeschlagen' }, 401);
return c.json(res);
});
export default auth;
+14 -6
View File
@@ -5,6 +5,10 @@ import { requireAdmin, roleOf } from '../auth.js';
import { config } from '../config.js';
import { contentStats } from '../collections.js';
import { manager } from '../plugins.js';
import { countByRole } from '../auth-local.js';
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
// Kennzahlen für die Admin-Übersicht. Nur Admins; rein lesend.
const stats = new Hono();
@@ -16,12 +20,16 @@ stats.get('/', async (c) => {
try { content = contentStats(await listEntries(), config.collections); }
catch { /* Filesystem nicht lesbar → leer */ }
// Nutzer nach Rolle (GoTrue).
const users = { total: 0, admin: 0, editor: 0, user: 0 };
try {
const { data } = await supabase.auth.admin.listUsers();
for (const u of data?.users || []) { users.total++; users[roleOf(u)] = (users[roleOf(u)] || 0) + 1; }
} catch { /* GoTrue nicht erreichbar */ }
// Nutzer nach Rolle: lokal aus der User-Datei, sonst aus GoTrue.
let users = { total: 0, admin: 0, editor: 0, user: 0 };
if (config.auth === 'local') {
try { users = await countByRole(ADMIN_EMAILS); } catch { /* User-Datei fehlt → 0 */ }
} else if (supabase) {
try {
const { data } = await supabase.auth.admin.listUsers();
for (const u of data?.users || []) { users.total++; users[roleOf(u)] = (users[roleOf(u)] || 0) + 1; }
} catch { /* GoTrue nicht erreichbar */ }
}
// Plugin-Kennzahlen einsammeln (jedes Plugin unter seinem Namen). Nur geladene
// Plugins lesen die DB — eine pluginlose Site macht hier null DB-Zugriffe.
+63
View File
@@ -0,0 +1,63 @@
import { Hono } from 'hono';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { config } from '../config.js';
import { manager } from '../plugins.js';
import { requireAdmin } from '../auth.js';
const execFileP = promisify(execFile);
let _version = null;
async function coreVersion() {
if (_version) return _version;
try { _version = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8')).version || '0.0.0'; }
catch { _version = '0.0.0'; }
return _version;
}
let _hugo = null;
async function hugoVersion() {
if (_hugo) return _hugo;
try { const { stdout } = await execFileP('hugo', ['version']); _hugo = (stdout.match(/v[0-9][0-9.]*[^\s]*/) || ['?'])[0]; }
catch { _hugo = '?'; }
return _hugo;
}
// System-/Diagnose-Info fürs Admin-Panel. Reiner Builder (testbar); die Route hängt
// nur die zur Laufzeit ermittelten Versionen dran.
export function systemInfo({ version, hugo }) {
return {
core: { name: 'openbureau-core', version },
site: config.site || null,
auth: config.auth || 'supabase',
admins: config.admins || [],
plugins: manager.plugins.map((p) => ({
name: p.name, routes: (p.routes || []).length, migrations: (p.migrations || []).length,
})),
collections: config.collections.map((c) => ({
kind: c.kind, label: c.label, statKey: c.statKey || null, fields: (c.fields || []).length,
})),
hugo,
};
}
const system = new Hono();
system.use('*', requireAdmin);
system.get('/', async (c) => c.json(systemInfo({ version: await coreVersion(), hugo: await hugoVersion() })));
// Update-Check (token-frei): laufende Core-Version (Image, /app/package.json) vs. die
// im Repo VENDORTE Version (SITE_DIR/cms/core/api/package.json). Ist die vendorte neuer,
// wurde core aktualisiert aber noch nicht neu gebaut → "Rebuild nötig" (Update-Skript).
system.get('/update', async (c) => {
const running = await coreVersion();
const siteDir = process.env.SITE_DIR || '/site';
const vendorPath = path.join(siteDir, process.env.CORE_VENDOR_PATH || 'cms/core', 'api/package.json');
let vendored = null;
try { vendored = JSON.parse(await readFile(vendorPath, 'utf8')).version || null; }
catch { /* nicht als Subtree eingebunden → kein Vergleich möglich */ }
return c.json({ running, vendored, available: !!(vendored && vendored !== running) });
});
export default system;
+24 -6
View File
@@ -1,15 +1,23 @@
import { Hono } from 'hono';
import { supabase } from '../supabase.js';
import { requireAdmin, roleOf } from '../auth.js';
import { config } from '../config.js';
import * as local from '../auth-local.js';
// Autoren-/Nutzerverwaltung über die GoTrue-Admin-API (Service-Key). Nur Admins.
// Autoren-/Nutzerverwaltung. Mit auth:'local' gegen die User-Datei (DB-los),
// sonst über die GoTrue-Admin-API (Service-Key). Nur Admins.
const ADMINS = (process.env.ADMIN_EMAILS || '')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
const isLocal = () => config.auth === 'local';
const users = new Hono();
users.use('*', requireAdmin);
users.get('/', async (c) => {
if (isLocal()) {
try { return c.json(await local.listUsers(ADMINS)); }
catch (e) { return c.json({ error: String(e.message || e) }, 500); }
}
const { data, error } = await supabase.auth.admin.listUsers();
if (error) return c.json({ error: error.message }, 500);
const list = (data?.users || []).map((u) => {
@@ -32,6 +40,10 @@ users.post('/', async (c) => {
const { email, password, role } = await c.req.json();
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
if (role && !['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
if (isLocal()) {
try { return c.json({ ok: true, id: await local.createUser({ email, password, role }) }); }
catch (e) { return c.json({ error: String(e.message || e) }, 400); }
}
const payload = { email, password, email_confirm: true };
if (role && role !== 'user') payload.app_metadata = { role };
const { data, error } = await supabase.auth.admin.createUser(payload);
@@ -41,19 +53,25 @@ users.post('/', async (c) => {
users.put('/:id', async (c) => {
const { password, role } = await c.req.json();
if (role && !['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
if (!password && !role) return c.json({ error: 'Nichts zu ändern' }, 400);
if (isLocal()) {
try { await local.updateUser(c.req.param('id'), { password, role }); return c.json({ ok: true }); }
catch (e) { return c.json({ error: String(e.message || e) }, 400); }
}
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);
if (role) patch.app_metadata = { role };
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 });
});
users.delete('/:id', async (c) => {
if (isLocal()) {
try { await local.deleteUser(c.req.param('id')); return c.json({ ok: true }); }
catch (e) { return c.json({ error: String(e.message || e) }, 400); }
}
const { error } = await supabase.auth.admin.deleteUser(c.req.param('id'));
if (error) return c.json({ error: error.message }, 400);
return c.json({ ok: true });
+21 -13
View File
@@ -3,19 +3,27 @@ import { createClient } from '@supabase/supabase-js';
const url = process.env.SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_KEY;
if (!url || !key) {
console.error('FEHLT: SUPABASE_URL und/oder SUPABASE_SERVICE_KEY in .env');
process.exit(1);
}
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);
// Mit Keys: echte Clients. Ohne (DB-loser core, auth:'local'): Clients bleiben
// null; die Aufrufer (auth/stats/users) sind null-sicher und nutzen den lokalen
// Provider. DB-Plugins (dialog) laufen ohnehin nur mit Supabase. Den Fail-Fast
// für auth:'supabase' macht index.js (das kennt die config) — supabase.js bleibt
// bewusst config-frei, damit Unit-Tests es ohne CMS_CONFIG importieren können.
let _supabase = null;
let _supabaseAuth = null;
if (url && key) {
// 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.
_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.
_supabaseAuth = createClient(url, key, opts);
} else {
console.warn('supabase.js: kein SUPABASE_URL/SERVICE_KEY — DB-loser Betrieb (auth: local erwartet).');
}
// 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);
export const supabase = _supabase;
export const supabaseAuth = _supabaseAuth;
+67
View File
@@ -0,0 +1,67 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { rm, readFile } from 'node:fs/promises';
// Isolated users file + a known secret BEFORE importing the module (it reads env
// at import time). No Supabase needed — this is the DB-less path.
const USERS = join(tmpdir(), `cms-users-${process.pid}.json`);
process.env.USERS_FILE = USERS;
process.env.JWT_SECRET ||= 'test-secret-xyz';
const local = await import('../src/auth-local.js');
const { verify } = await import('hono/jwt');
test.after(async () => { await rm(USERS, { force: true }); });
test('create → list → login mints a GoTrue-shaped, verifiable token', async () => {
const id = await local.createUser({ email: 'Karim@Example.com', password: 'hunter2', role: 'admin' });
assert.ok(id);
const list = await local.listUsers(['someone@else.com']);
assert.equal(list.length, 1);
assert.equal(list[0].email, 'Karim@Example.com');
assert.equal(list[0].role, 'admin');
// wrong password rejected, right password accepted (case-insensitive email)
assert.equal(await local.login('karim@example.com', 'nope'), null);
const res = await local.login('karim@example.com', 'hunter2');
assert.ok(res?.access_token);
assert.equal(res.user.email, 'Karim@Example.com');
const claims = await verify(res.access_token, process.env.JWT_SECRET, 'HS256');
assert.equal(claims.sub, id);
assert.equal(claims.email, 'Karim@Example.com');
assert.equal(claims.app_metadata.role, 'admin'); // same shape auth.js/roleOf read
assert.ok(claims.exp > Math.floor(Date.now() / 1000));
});
test('passwords are bcrypt-hashed at rest (never plaintext)', async () => {
const raw = JSON.parse(await readFile(USERS, 'utf8'));
assert.match(raw[0].password, /^\$2[aby]\$/); // bcrypt hash prefix
assert.notEqual(raw[0].password, 'hunter2');
});
test('countByRole reflects roles; fixed-admin email forced to admin', async () => {
await local.createUser({ email: 'red@x.com', password: 'pw', role: 'editor' });
const counts = await local.countByRole([]);
assert.equal(counts.total, 2);
assert.equal(counts.admin, 1);
assert.equal(counts.editor, 1);
// an env-admin email overrides the stored role in the listing
const forced = await local.listUsers(['red@x.com']);
assert.equal(forced.find((u) => u.email === 'red@x.com').role, 'admin');
});
test('update + delete', async () => {
const id = await local.createUser({ email: 'tmp@x.com', password: 'pw' });
await local.updateUser(id, { role: 'editor' });
assert.equal((await local.listUsers([])).find((u) => u.id === id).role, 'editor');
await local.deleteUser(id);
assert.equal((await local.listUsers([])).some((u) => u.id === id), false);
await assert.rejects(() => local.deleteUser('nope'), /nicht gefunden/);
});
test('duplicate email rejected', async () => {
await assert.rejects(() => local.createUser({ email: 'red@x.com', password: 'pw' }), /existiert bereits/);
});
+31
View File
@@ -0,0 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
process.env.CMS_CONFIG ||= fileURLToPath(new URL('../../examples/openbureau.config.js', import.meta.url));
process.env.SUPABASE_URL ||= 'http://localhost';
process.env.SUPABASE_SERVICE_KEY ||= 'x';
process.env.JWT_SECRET ||= 'x';
process.env.SITE_DIR ||= '/tmp/nosite';
const { systemInfo } = await import('../src/routes/system.js');
test('systemInfo reflects core version, plugins and content model', () => {
const info = systemInfo({ version: '0.6.0', hugo: '0.161.1+extended' });
assert.equal(info.core.name, 'openbureau-core');
assert.equal(info.core.version, '0.6.0');
assert.equal(info.site, 'openbureau');
assert.equal(info.auth, 'supabase'); // openbureau.config.js sets auth: 'supabase'
assert.equal(info.hugo, '0.161.1+extended');
assert.deepEqual(info.admins, ['karim@gabrielevarano.ch']);
// dialog plugin is active with its routes + the one migration
const dialog = info.plugins.find((p) => p.name === 'dialog');
assert.ok(dialog, 'dialog plugin listed');
assert.ok(dialog.routes > 0);
assert.equal(dialog.migrations, 1);
// content model surfaced from the config collections
assert.deepEqual(info.collections.map((c) => c.kind).sort(), ['beitrag', 'biblio', 'rubrik', 'seite']);
const beitrag = info.collections.find((c) => c.kind === 'beitrag');
assert.equal(beitrag.label, 'Beiträge');
assert.ok(beitrag.fields > 0);
});
+1
View File
@@ -2,6 +2,7 @@
// No dialog plugin; a portfolio-shaped content model.
export default {
site: 'kgva',
auth: 'local', // DB-less: file-based users + self-signed JWTs (no Supabase/Postgres)
admins: ['karim@gabrielevarano.ch'],
plugins: [],
+3 -3
View File
@@ -19,8 +19,8 @@ export default {
{ name: 'slug', type: 'slug' },
{ name: 'date', type: 'date' },
{ name: 'weight', type: 'number' },
{ name: 'color', type: 'string' },
{ name: 'layout', type: 'select', options: ['text'], default: 'text' },
{ name: 'color', type: 'color' },
{ name: 'layout', type: 'select', options: ['text', 'image', 'icon'], default: 'text' },
{ name: 'tags', type: 'list' },
{ name: 'summary', type: 'text' },
{ name: 'cover_image', type: 'image' },
@@ -53,7 +53,7 @@ export default {
statKey: 'rubriken',
fields: [
{ name: 'title', type: 'string', required: true },
{ name: 'color', type: 'string' },
{ name: 'color', type: 'color' },
{ name: 'layout', type: 'string' },
{ name: 'weight', type: 'number' },
{ name: 'body', type: 'markdown' },
+3 -3
View File
@@ -20,8 +20,8 @@ export default {
{ name: 'slug', type: 'slug' },
{ name: 'date', type: 'date' },
{ name: 'weight', type: 'number' },
{ name: 'color', type: 'string' },
{ name: 'layout', type: 'select', options: ['text'], default: 'text' },
{ name: 'color', type: 'color' },
{ name: 'layout', type: 'select', options: ['text', 'image', 'icon'], default: 'text' },
{ name: 'tags', type: 'list' },
{ name: 'summary', type: 'text' },
{ name: 'cover_image', type: 'image' },
@@ -54,7 +54,7 @@ export default {
statKey: 'rubriken',
fields: [
{ name: 'title', type: 'string', required: true },
{ name: 'color', type: 'string' },
{ name: 'color', type: 'color' },
{ name: 'layout', type: 'string' },
{ name: 'weight', type: 'number' },
{ name: 'body', type: 'markdown' },