Compare commits

...

5 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
11 changed files with 438 additions and 204 deletions
+42 -14
View File
@@ -12,7 +12,8 @@
`api/src/plugin-manager.js`, `api/src/plugins/dialog/index.js` + their tests. `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` - **Run tests:** `cd api && npm install && node --test`**44 green**. `node_modules`
is git-ignored; the fresh clone needs `npm install` once. 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 cutover, `5e06337` DDL, `a93d11a` migration runner). Plugin-manager singleton
(`api/src/plugins.js`) wired into `index.js`/`stats.js`/`publish.js`, hard-coded (`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 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` `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 + wired into the cms service env for openbureau (stage 8). Tested: migrate.test.js +
tracking SQL proven on the live dev Postgres (throwaway DB). tracking SQL proven on the live dev Postgres (throwaway DB).
6. admin: `/api/schema` + App.jsx renders from schema (must reproduce openbureau's 6. [DONE, commits f709b5d + f8f4131 + 59b9e20] schema-driven admin. `GET /api/schema`
editor 1:1 when fed `openbureau.config.js`). + `GET /api/system` + a System panel; AND the editor now renders sidebar groups +
7. **auth provider** (`config.auth` = `supabase` | `local`). Verify is already local fields from the site's collections (admin/src/fields.jsx: per-type controls
(HS256/JWT_SECRET in auth.js). Add a `local` provider: file-based users (bcrypt) + string/text/slug/number/date/bool/select/list/image/color/markdown; form↔frontmatter
self-signed JWTs of the SAME claim shape (sub/email/app_metadata.role/exp), and a + path building). One admin serves any site. Unknown frontmatter is PRESERVED on
`/login` route to replace GoTrue; admin login (`admin/src/supabase.js`) calls it save (__raw) — no data loss. Verified working via kgva (stage 9). NOTE: openbureau
instead of `signInWithPassword`; users.js CRUD edits the user file. Goal: a is NOT yet redeployed with the schema editor (its running image keeps the bespoke
dialog-less core runs Node+Hugo+nginx, **no Supabase/Postgres** (~80 MB). openbureau one); when it is, its config field types (color→swatch, layout opts) are already
stays `supabase`. Independent of the cutover — `supabase` is the default no-op. tuned. Visual review of the openbureau editor by Karim still recommended before
8. cut openbureau over to consume core (= core + `dialog` plugin + `supabase` auth + redeploying it.
its config) — test login / list / edit / preview / publish / dialog — identical. 7. [DONE, commit 86f9f57] **auth provider** (`config.auth` = `supabase` | `local`).
9. onboard kgva (`CMS_CONFIG=…/kgva.config.js`, `auth: 'local'`, no plugins) as the `api/src/auth-local.js` = file-based users (bcryptjs) + self-signed HS256 JWTs of
2nd consumer — DB-less. 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 ## How to run / test
openbureau CMS stack: `OPENBUREAU/cms/docker-compose.yml` (Node api + Hugo + openbureau CMS stack: `OPENBUREAU/cms/docker-compose.yml` (Node api + Hugo +
+154 -170
View File
@@ -3,33 +3,10 @@ import ToastEditor from '@toast-ui/editor';
import '@toast-ui/editor/dist/toastui-editor.css'; import '@toast-ui/editor/dist/toastui-editor.css';
import { supabase } from './supabase.js'; import { supabase } from './supabase.js';
import { api } from './api.js'; import { api } from './api.js';
import {
// OPENBUREAU-Palette (Hex aus assets/css/custom.css) — für Dropdown + Punkte. Field, blankForm, formFromFrontmatter, frontmatterFromForm, targetPath,
const COLORS = [ isShort, isBody, hexOf, DEFAULT_PALETTE,
['', 'keine', 'transparent'], } from './fields.jsx';
['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: '',
};
export default function App() { export default function App() {
const [session, setSession] = useState(null); const [session, setSession] = useState(null);
@@ -69,6 +46,9 @@ function Login() {
function Dashboard({ email }) { function Dashboard({ email }) {
const [entries, setEntries] = useState([]); const [entries, setEntries] = useState([]);
const [collections, setCollections] = useState(null);
const [plugins, setPlugins] = useState([]);
const [site, setSite] = useState('');
const [current, setCurrent] = useState(null); const [current, setCurrent] = useState(null);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [view, setView] = useState('content'); const [view, setView] = useState('content');
@@ -79,30 +59,55 @@ function Dashboard({ email }) {
try { setEntries(await api.list()); } try { setEntries(await api.list()); }
catch (e) { setMsg({ type: 'err', text: e.message }); } 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]); 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) { async function open(entry) {
try { setCurrent(fromRead(await api.read(entry.path))); } const col = collectionOf(entry.kind);
catch (err) { setMsg({ type: 'err', text: err.message }); } 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 q = query.trim().toLowerCase();
const filtered = q ? entries.filter((e) => e.title.toLowerCase().includes(q) || (e.section || '').includes(q)) : entries; const filtered = q ? entries.filter((e) => (e.title || '').toLowerCase().includes(q) || (e.section || '').includes(q)) : entries;
const groups = { beitrag: [], biblio: [], seite: [], rubrik: [] }; const fallbackKind = (cols.find((c) => c.fallback) || cols[0] || {}).kind;
for (const e of filtered) (groups[e.kind] || groups.seite).push(e); 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 ( return (
<div className="app"> <div className="app">
<header className="topbar"> <header className="topbar">
<span className="logo">OPENBUREAU</span> <span className="logo">{(site || 'cms').toUpperCase()}</span>
<span className="logo-sub">Redaktion</span> <span className="logo-sub">Redaktion</span>
<nav className="nav"> <nav className="nav">
{me?.isAdmin && <button className={view === 'overview' ? 'active' : ''} onClick={() => setView('overview')}>Übersicht</button>} {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 === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</button>
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button> <button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button>
{me?.canModerate && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>} {me?.canModerate && hasDialog && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>}
{me?.isAdmin && <button className={view === 'forums' ? 'active' : ''} onClick={() => setView('forums')}>Foren</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 === 'users' ? 'active' : ''} onClick={() => setView('users')}>Autor:innen</button>}
{me?.isAdmin && <button className={view === 'system' ? 'active' : ''} onClick={() => setView('system')}>System</button>} {me?.isAdmin && <button className={view === 'system' ? 'active' : ''} onClick={() => setView('system')}>System</button>}
</nav> </nav>
@@ -127,15 +132,15 @@ function Dashboard({ email }) {
) : ( ) : (
<> <>
<aside> <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> <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 && ( {cols.map((c) => groups[c.kind]?.length > 0 && (
<div className="group" key={kind}> <div className="group" key={c.kind}>
<div className="group-title">{KIND_LABEL[kind]} <span>{groups[kind].length}</span></div> <div className="group-title">{c.label} <span>{groups[c.kind].length}</span></div>
<ul className="list"> <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)}> <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">
<span className="t-title">{e.title}</span> <span className="t-title">{e.title}</span>
<span className="t-meta">{[e.section, e.date].filter(Boolean).join(' · ')}</span> <span className="t-meta">{[e.section, e.date].filter(Boolean).join(' · ')}</span>
@@ -149,9 +154,9 @@ function Dashboard({ email }) {
</aside> </aside>
<main> <main>
{current {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} /> 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> </main>
</> </>
)} )}
@@ -162,7 +167,7 @@ function Dashboard({ email }) {
); );
} }
function Editor({ initial, onSaved, onMsg }) { function Editor({ initial, collections, onSaved, onMsg }) {
const [f, setF] = useState(initial); const [f, setF] = useState(initial);
const [previewUrl, setPreviewUrl] = useState(null); const [previewUrl, setPreviewUrl] = useState(null);
const [showPreview, setShowPreview] = useState(false); const [showPreview, setShowPreview] = useState(false);
@@ -170,17 +175,23 @@ function Editor({ initial, onSaved, onMsg }) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const editorRef = useRef(null); const editorRef = useRef(null);
const dragging = useRef(false); 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 cols = collections || [];
const file = ev.target.files?.[0]; ev.target.value = ''; const collection = cols.find((c) => c.kind === f.kind) || cols[0] || { fields: [] };
if (!file) return; const fields = collection.fields || [];
setBusy(true); const titleField = fields.find((x) => x.name === 'title') || fields.find((x) => x.type === 'string' && x.required) || null;
try { const { url } = await api.upload(file); setF((p) => ({ ...p, cover_image: url })); } const bodyField = fields.find(isBody);
catch (e) { onMsg({ type: 'err', text: e.message }); } const draftField = fields.find((x) => x.type === 'bool' && x.name === 'draft');
finally { setBusy(false); } 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. // Ziehbarer Trenner Editor ↔ Vorschau.
@@ -197,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 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 = {}) { async function save(overrides = {}) {
const data = { ...f, ...overrides }; const data = { ...f, ...overrides };
const path = currentPath(data); const path = data.isNew ? targetPath(data, collection) : data.path;
if (!path) { onMsg({ type: 'err', text: 'Bitte einen Slug angeben.' }); return null; } if (!path) { onMsg({ type: 'err', text: 'Bitte einen Slug (und ggf. Pflichtsegmente wie Rubrik) angeben.' }); return null; }
if (!data.title.trim()) { onMsg({ type: 'err', text: 'Titel fehlt.' }); return null; } if (titleField && !((data[titleField.name] || '').trim())) { onMsg({ type: 'err', text: 'Titel fehlt.' }); return null; }
setBusy(true); setBusy(true);
try { try {
await api.save(path, buildFrontmatter(data), data.body); const fm = frontmatterFromForm(data, collection);
const loaded = fromRead(await api.read(path)); 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); onSaved(loaded); setF(loaded);
return path; return path;
} catch (e) { onMsg({ type: 'err', text: e.message }); return null; } } catch (e) { onMsg({ type: 'err', text: e.message }); return null; }
@@ -229,8 +237,8 @@ function Editor({ initial, onSaved, onMsg }) {
finally { setBusy(false); } finally { setBusy(false); }
} }
async function publish() { async function publish() {
if (!confirm('Live publizieren? Der Beitrag wird aus „Entwurf“ genommen.')) return; if (!confirm('Live publizieren?')) return;
const path = await save({ draft: false }); // Publizieren = nicht mehr Entwurf const path = await save(draftField ? { [draftField.name]: false } : {});
if (!path) return; if (!path) return;
setBusy(true); setBusy(true);
try { const res = await api.publish(path); onMsg({ type: 'ok', text: `Live: ${res.url}` }); } try { const res = await api.publish(path); onMsg({ type: 'ok', text: `Live: ${res.url}` }); }
@@ -244,7 +252,7 @@ function Editor({ initial, onSaved, onMsg }) {
<div className="editor-head"> <div className="editor-head">
<div className="crumb">{f.isNew ? 'Neuer Eintrag' : f.path}</div> <div className="crumb">{f.isNew ? 'Neuer Eintrag' : f.path}</div>
<span className="spacer" /> <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"> <button className="toggle" onClick={() => setShowPreview((v) => !v)} title="Vorschau ein/aus">
{showPreview ? 'Vorschau ' : 'Vorschau ⤢'} {showPreview ? 'Vorschau ' : 'Vorschau ⤢'}
</button> </button>
@@ -257,61 +265,37 @@ function Editor({ initial, onSaved, onMsg }) {
{f.isNew && ( {f.isNew && (
<div className="row"> <div className="row">
<label className="sm">Typ <label className="sm">Typ
<select value={f.type} onChange={set('type')}> <select value={f.kind} onChange={(e) => changeKind(e.target.value)}>
<option value="beitrag">Beitrag</option> {cols.map((c) => <option key={c.kind} value={c.kind}>{c.label}</option>)}
<option value="biblio">Library-Seite</option>
<option value="seite">Seite</option>
</select> </select>
</label> </label>
{f.type === 'beitrag' && ( {!hasSlugField && (
<label className="sm">Rubrik <label className="sm">Dateiname
<select value={f.section} onChange={set('section')}>{SECTIONS.map((s) => <option key={s}>{s}</option>)}</select> <input value={f.slug || ''} onChange={(e) => setField('slug', e.target.value)} placeholder="z. B. impressum" />
</label> </label>
)} )}
<label>Slug<input value={f.slug} onChange={set('slug')} placeholder="z.B. neuer-beitrag" /></label>
</div> </div>
)} )}
<label className="big">Titel<input value={f.title} onChange={set('title')} placeholder="Titel des Beitrags" /></label> {titleField && (
<label className="big">{titleField.label || 'Titel'}
<div className="meta"> <input value={f[titleField.name] || ''} onChange={(e) => setField(titleField.name, e.target.value)} placeholder="Titel" />
{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>
</label> </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> {shortFields.length > 0 && (
<div className="row"> <div className="meta">
<label>Cover-Bild {shortFields.map((fld) => <Field key={fld.name} field={fld} value={f[fld.name]} onChange={setField} onUpload={upload} busy={busy} />)}
<div className="cover-row"> </div>
<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>
<div className="rich"> {longFields.map((fld) => <Field key={fld.name} field={fld} value={f[fld.name]} onChange={setField} onUpload={upload} busy={busy} />)}
<RichEditor value={f.body} onChange={(body) => setF((p) => ({ ...p, body }))}
onUpload={async (file) => (await api.upload(file)).url} /> {bodyField && (
</div> <div className="rich">
<RichEditor value={f[bodyField.name] || ''} onChange={(body) => setField(bodyField.name, body)} onUpload={upload} />
</div>
)}
</div> </div>
</div> </div>
@@ -405,6 +389,31 @@ function Profile({ onMsg }) {
<label>Über mich<textarea value={p.bio} onChange={set('bio')} rows={5} placeholder="Kurzer Text über dich…" /></label> <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 className="actions"><button className="primary" onClick={save} disabled={busy}>Speichern</button></div>
</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> </div>
); );
} }
@@ -416,53 +425,64 @@ function Overview({ onMsg, go }) {
if (!s) return <div className="empty"></div>; if (!s) return <div className="empty"></div>;
const Card = ({ label, value, hint, to }) => ( const Card = ({ label, value, hint, to }) => (
<button className="stat-card" onClick={to ? () => go(to) : undefined} disabled={!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-label">{label}</span>
<span className="stat-hint">{hint || ' '}</span> <span className="stat-hint">{hint || ' '}</span>
</button> </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 ( return (
<div className="overview"> <div className="overview">
<h2>Übersicht</h2> <h2>Übersicht</h2>
<div className="stat-grid"> <div className="stat-grid">
<Card label="Beiträge" value={s.content.beitraege} hint={`${s.content.entwuerfe} Entwürfe`} to="content" /> {Object.entries(content).map(([k, v]) => <Card key={k} label={k} value={v} to="content" />)}
<Card label="Library-Seiten" value={s.content.library} to="content" /> <Card label="Autor:innen" value={s.users?.total} hint={`${s.users?.admin || 0} Admin · ${s.users?.editor || 0} Red.`} to="users" />
<Card label="Seiten" value={s.content.seiten} /> {hasDialog && <Card label="Foren" value={d.forums} to="forums" />}
<Card label="Autor:innen" value={s.users.total} hint={`${s.users.admin} Admin · ${s.users.editor} Red.`} to="users" /> {hasDialog && <Card label="Threads" value={d.threads} to="moderation" />}
<Card label="Foren" value={s.dialog.forums} to="forums" /> {hasDialog && <Card label="Wortmeldungen" value={d.comments} to="moderation" />}
<Card label="Threads" value={s.dialog.threads} to="moderation" />
<Card label="Wortmeldungen" value={s.dialog.comments} to="moderation" />
</div> </div>
<div className="overview-actions"> <div className="overview-actions">
<h3>Schnellzugriff</h3> <h3>Schnellzugriff</h3>
<div className="quick"> <div className="quick">
<button onClick={() => go('content')}>Inhalte bearbeiten</button> <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> <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="/" 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> </div>
</div> </div>
); );
} }
// ── System (nur Admin): core-Version, Plugins, Content-Modell ──────────────── // ── System (nur Admin): core-Version, Update-Check, Admins, Plugins, Modell ──
function System({ onMsg }) { function System({ onMsg }) {
const [s, setS] = useState(null); const [s, setS] = useState(null);
useEffect(() => { api.system().then(setS).catch((e) => onMsg({ type: 'err', text: e.message })); }, []); 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>; 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 ( return (
<div className="profile"> <div className="profile">
<div className="profile-card wide"> <div className="profile-card wide">
<h2>System</h2> <h2>System</h2>
<div className="sysgrid"> <div className="sysgrid">
<div><span className="muted">Core</span><b>{s.core.name} <span className="ver">v{s.core.version}</span></b></div> <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">Site</span><b>{s.site || '—'}</b></div>
<div><span className="muted">Auth</span><b>{s.auth}</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><span className="muted">Hugo</span><b>{s.hugo}</b></div>
</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> <h3>Plugins <span className="count-pill">{s.plugins.length}</span></h3>
{s.plugins.length {s.plugins.length
? <ul className="syslist">{s.plugins.map((p) => ( ? <ul className="syslist">{s.plugins.map((p) => (
@@ -473,7 +493,7 @@ function System({ onMsg }) {
<ul className="syslist">{s.collections.map((c) => ( <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> <li key={c.kind}><b>{c.label}</b><span className="muted">{c.kind} · {c.fields} Felder{c.statKey ? ` · ${c.statKey}` : ''}</span></li>
))}</ul> ))}</ul>
<p className="muted who-mail">openbureau-core fährt diese Site. Plugins &amp; Content-Modell kommen aus der Site-Config.</p> <p className="muted who-mail">openbureau-core fährt diese Site. Updates werden serverseitig eingespielt (Update-Skript).</p>
</div> </div>
</div> </div>
); );
@@ -710,39 +730,3 @@ function slugify(s) {
return (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '') return (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-').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;
}
+2
View File
@@ -46,7 +46,9 @@ export const api = {
getMe: () => call('/me'), getMe: () => call('/me'),
stats: () => call('/stats'), stats: () => call('/stats'),
system: () => call('/system'), system: () => call('/system'),
systemUpdate: () => call('/system/update'),
schema: () => call('/schema'), schema: () => call('/schema'),
changePassword: (current, next) => call('/account/password', { method: 'POST', body: JSON.stringify({ current, next }) }),
listUsers: () => call('/users'), listUsers: () => call('/users'),
createUser: (email, password, role) => call('/users', { method: 'POST', body: JSON.stringify({ email, password, role }) }), 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 }) }), 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 */ }
}} />
</>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "openbureau-cms-api", "name": "openbureau-cms-api",
"version": "0.6.0", "version": "0.7.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Headless CMS backend für OPENBUREAU — schreibt Supabase-Posts in Hugo-content/, baut und serviert die Site.", "description": "Headless CMS backend für OPENBUREAU — schreibt Supabase-Posts in Hugo-content/, baut und serviert die Site.",
+2
View File
@@ -13,6 +13,7 @@ import profile from './routes/profile.js';
import users from './routes/users.js'; import users from './routes/users.js';
import stats from './routes/stats.js'; import stats from './routes/stats.js';
import system from './routes/system.js'; import system from './routes/system.js';
import account from './routes/account.js';
import history from './routes/history.js'; import history from './routes/history.js';
import authRoute from './routes/auth.js'; import authRoute from './routes/auth.js';
import { requireAuth, requireAdmin } from './auth.js'; import { requireAuth, requireAdmin } from './auth.js';
@@ -100,6 +101,7 @@ app.route('/api/profile', profile);
app.route('/api/users', users); app.route('/api/users', users);
app.route('/api/stats', stats); app.route('/api/stats', stats);
app.route('/api/system', system); app.route('/api/system', system);
app.route('/api/account', account);
// Content-Modell der Site fürs Admin (schema-getriebenes Rendern). Jede:r // Content-Modell der Site fürs Admin (schema-getriebenes Rendern). Jede:r
// eingeloggte Nutzer:in darf es lesen — der Editor braucht es zum Aufbauen. // eingeloggte Nutzer:in darf es lesen — der Editor braucht es zum Aufbauen.
app.get('/api/schema', (c) => c.json({ app.get('/api/schema', (c) => c.json({
+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;
+38 -13
View File
@@ -1,21 +1,39 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { readFile } from 'node:fs/promises'; 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 { config } from '../config.js';
import { manager } from '../plugins.js'; import { manager } from '../plugins.js';
import { requireAdmin } from '../auth.js'; import { requireAdmin } from '../auth.js';
// System-/Diagnose-Info für das Admin-Panel: welche core-Version läuft, welche const execFileP = promisify(execFile);
// Plugins aktiv sind, welches Content-Modell die Site fährt. Rein lesend, Admin.
// Reine Builder-Funktion (testbar) — die Route hängt nur Version + Hugo dran. 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 }) { export function systemInfo({ version, hugo }) {
return { return {
core: { name: 'openbureau-core', version }, core: { name: 'openbureau-core', version },
site: config.site || null, site: config.site || null,
auth: config.auth || 'supabase', auth: config.auth || 'supabase',
admins: config.admins || [],
plugins: manager.plugins.map((p) => ({ plugins: manager.plugins.map((p) => ({
name: p.name, name: p.name, routes: (p.routes || []).length, migrations: (p.migrations || []).length,
routes: (p.routes || []).length,
migrations: (p.migrations || []).length,
})), })),
collections: config.collections.map((c) => ({ collections: config.collections.map((c) => ({
kind: c.kind, label: c.label, statKey: c.statKey || null, fields: (c.fields || []).length, kind: c.kind, label: c.label, statKey: c.statKey || null, fields: (c.fields || []).length,
@@ -26,13 +44,20 @@ export function systemInfo({ version, hugo }) {
const system = new Hono(); const system = new Hono();
system.use('*', requireAdmin); system.use('*', requireAdmin);
system.get('/', async (c) => {
let version = '0.0.0'; system.get('/', async (c) => c.json(systemInfo({ version: await coreVersion(), hugo: await hugoVersion() })));
try {
const pkg = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8')); // Update-Check (token-frei): laufende Core-Version (Image, /app/package.json) vs. die
version = pkg.version || version; // im Repo VENDORTE Version (SITE_DIR/cms/core/api/package.json). Ist die vendorte neuer,
} catch { /* package.json nicht lesbar → 0.0.0 */ } // wurde core aktualisiert aber noch nicht neu gebaut → "Rebuild nötig" (Update-Skript).
return c.json(systemInfo({ version, hugo: '0.161.1+extended' })); 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; export default system;
+1
View File
@@ -17,6 +17,7 @@ test('systemInfo reflects core version, plugins and content model', () => {
assert.equal(info.site, 'openbureau'); assert.equal(info.site, 'openbureau');
assert.equal(info.auth, 'supabase'); // openbureau.config.js sets auth: 'supabase' assert.equal(info.auth, 'supabase'); // openbureau.config.js sets auth: 'supabase'
assert.equal(info.hugo, '0.161.1+extended'); 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 // dialog plugin is active with its routes + the one migration
const dialog = info.plugins.find((p) => p.name === 'dialog'); const dialog = info.plugins.find((p) => p.name === 'dialog');
assert.ok(dialog, 'dialog plugin listed'); assert.ok(dialog, 'dialog plugin listed');
+3 -3
View File
@@ -19,8 +19,8 @@ export default {
{ name: 'slug', type: 'slug' }, { name: 'slug', type: 'slug' },
{ name: 'date', type: 'date' }, { name: 'date', type: 'date' },
{ name: 'weight', type: 'number' }, { name: 'weight', type: 'number' },
{ name: 'color', type: 'string' }, { name: 'color', type: 'color' },
{ name: 'layout', type: 'select', options: ['text'], default: 'text' }, { name: 'layout', type: 'select', options: ['text', 'image', 'icon'], default: 'text' },
{ name: 'tags', type: 'list' }, { name: 'tags', type: 'list' },
{ name: 'summary', type: 'text' }, { name: 'summary', type: 'text' },
{ name: 'cover_image', type: 'image' }, { name: 'cover_image', type: 'image' },
@@ -53,7 +53,7 @@ export default {
statKey: 'rubriken', statKey: 'rubriken',
fields: [ fields: [
{ name: 'title', type: 'string', required: true }, { name: 'title', type: 'string', required: true },
{ name: 'color', type: 'string' }, { name: 'color', type: 'color' },
{ name: 'layout', type: 'string' }, { name: 'layout', type: 'string' },
{ name: 'weight', type: 'number' }, { name: 'weight', type: 'number' },
{ name: 'body', type: 'markdown' }, { name: 'body', type: 'markdown' },
+3 -3
View File
@@ -20,8 +20,8 @@ export default {
{ name: 'slug', type: 'slug' }, { name: 'slug', type: 'slug' },
{ name: 'date', type: 'date' }, { name: 'date', type: 'date' },
{ name: 'weight', type: 'number' }, { name: 'weight', type: 'number' },
{ name: 'color', type: 'string' }, { name: 'color', type: 'color' },
{ name: 'layout', type: 'select', options: ['text'], default: 'text' }, { name: 'layout', type: 'select', options: ['text', 'image', 'icon'], default: 'text' },
{ name: 'tags', type: 'list' }, { name: 'tags', type: 'list' },
{ name: 'summary', type: 'text' }, { name: 'summary', type: 'text' },
{ name: 'cover_image', type: 'image' }, { name: 'cover_image', type: 'image' },
@@ -54,7 +54,7 @@ export default {
statKey: 'rubriken', statKey: 'rubriken',
fields: [ fields: [
{ name: 'title', type: 'string', required: true }, { name: 'title', type: 'string', required: true },
{ name: 'color', type: 'string' }, { name: 'color', type: 'color' },
{ name: 'layout', type: 'string' }, { name: 'layout', type: 'string' },
{ name: 'weight', type: 'number' }, { name: 'weight', type: 'number' },
{ name: 'body', type: 'markdown' }, { name: 'body', type: 'markdown' },