Compare commits
7 Commits
ecf7710b8e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b37edb6bda | |||
| 6aedf21bf6 | |||
| fed003ae31 | |||
| 685592362a | |||
| 2f639c068b | |||
| f518eb7a13 | |||
| 3d902a0bbe |
@@ -3,3 +3,6 @@ node_modules/
|
||||
.env.*
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# build output
|
||||
admin/dist/
|
||||
|
||||
+42
-14
@@ -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 1–9 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 +
|
||||
|
||||
+150
-166
@@ -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,30 +59,55 @@ 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>
|
||||
@@ -127,15 +132,15 @@ function Dashboard({ email }) {
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
@@ -149,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>
|
||||
</>
|
||||
)}
|
||||
@@ -162,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);
|
||||
@@ -170,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.
|
||||
@@ -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 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; }
|
||||
@@ -229,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}` }); }
|
||||
@@ -244,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>
|
||||
@@ -257,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>
|
||||
{titleField && (
|
||||
<label className="big">{titleField.label || 'Titel'}
|
||||
<input value={f[titleField.name] || ''} onChange={(e) => setField(titleField.name, e.target.value)} placeholder="Titel" />
|
||||
</label>
|
||||
)}
|
||||
|
||||
{shortFields.length > 0 && (
|
||||
<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>
|
||||
</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>
|
||||
{shortFields.map((fld) => <Field key={fld.name} field={fld} value={f[fld.name]} onChange={setField} onUpload={upload} busy={busy} />)}
|
||||
</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>
|
||||
{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.body} onChange={(body) => setF((p) => ({ ...p, body }))}
|
||||
onUpload={async (file) => (await api.upload(file)).url} />
|
||||
<RichEditor value={f[bodyField.name] || ''} onChange={(body) => setField(bodyField.name, body)} onUpload={upload} />
|
||||
</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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -416,53 +425,64 @@ 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 & 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, Plugins, Content-Modell ────────────────
|
||||
// ── System (nur Admin): core-Version, Update-Check, Admins, Plugins, Modell ──
|
||||
function System({ onMsg }) {
|
||||
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>;
|
||||
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></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">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) => (
|
||||
@@ -473,7 +493,7 @@ function System({ onMsg }) {
|
||||
<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. Plugins & 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>
|
||||
);
|
||||
@@ -710,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;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,9 @@ export const api = {
|
||||
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 }) }),
|
||||
|
||||
@@ -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 */ }
|
||||
}} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Generated
+9
-2
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openbureau-cms-api",
|
||||
"version": "0.6.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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -13,12 +13,22 @@ 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);
|
||||
@@ -64,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.
|
||||
@@ -88,6 +101,7 @@ 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({
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
// 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.
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
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';
|
||||
|
||||
// System-/Diagnose-Info für das Admin-Panel: welche core-Version läuft, welche
|
||||
// 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.
|
||||
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,
|
||||
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,
|
||||
@@ -26,13 +44,20 @@ export function systemInfo({ version, hugo }) {
|
||||
|
||||
const system = new Hono();
|
||||
system.use('*', requireAdmin);
|
||||
system.get('/', async (c) => {
|
||||
let version = '0.0.0';
|
||||
try {
|
||||
const pkg = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8'));
|
||||
version = pkg.version || version;
|
||||
} catch { /* package.json nicht lesbar → 0.0.0 */ }
|
||||
return c.json(systemInfo({ version, hugo: '0.161.1+extended' }));
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -17,6 +17,7 @@ test('systemInfo reflects core version, plugins and content model', () => {
|
||||
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');
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user