diff --git a/HANDOVER.md b/HANDOVER.md
index afd5a9f..c9750c0 100644
--- a/HANDOVER.md
+++ b/HANDOVER.md
@@ -12,7 +12,7 @@
`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.
-- **Status:** stages 1–9 DONE (6 = full schema-driven editor; 9 = kgva on core, DB-less, staged-verified, repo pushed). openbureau runs on core (dev CT 134); kgva LIVE site still static (unchanged) with the CMS dormant in-repo. **Remaining: deliberate cutovers** — (a) redeploy openbureau with the schema editor after Karim eyeballs it; (b) provision the kgva-core CMS container + Caddy switch (his call, repoints a live domain). Original stage-5 note kept below for history.
+- **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
@@ -153,7 +153,7 @@ openbureau's extras become plugins (first: `dialog`); kgva enables none.
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 (repo + staged verify); live cutover PENDING] kgva consumes core. In
+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`,
diff --git a/admin/src/App.jsx b/admin/src/App.jsx
index 3f86b72..b43b042 100644
--- a/admin/src/App.jsx
+++ b/admin/src/App.jsx
@@ -389,6 +389,31 @@ function Profile({ onMsg }) {
+
+
+ );
+}
+
+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 (
+
);
}
@@ -431,6 +456,49 @@ function Overview({ onMsg, go }) {
);
}
+// ── System (nur Admin): core-Version, Update-Check, Admins, Plugins, Modell ──
+function System({ onMsg }) {
+ const [s, setS] = useState(null);
+ const [upd, setUpd] = useState(null);
+ useEffect(() => {
+ api.system().then(setS).catch((e) => onMsg({ type: 'err', text: e.message }));
+ api.systemUpdate().then(setUpd).catch(() => {});
+ }, []);
+ if (!s) return …
;
+ const badge = !upd ? null
+ : upd.available ? core v{upd.vendored} bereit — Rebuild nötig
+ : upd.vendored ? aktuell
+ : (kein Subtree);
+ return (
+
+
+
System
+
+
Core{s.core.name} v{s.core.version} {badge}
+
Site{s.site || '—'}
+
Auth{s.auth}
+
Hugo{s.hugo}
+
+
Admins {(s.admins || []).length}
+
{(s.admins || []).map((a) => (
+ - {a}aus Config (fix)
+ ))}
+
Plugins {s.plugins.length}
+ {s.plugins.length
+ ?
{s.plugins.map((p) => (
+ - {p.name}{p.routes} Routen · {p.migrations} Migration(en)
+ ))}
+ :
Keine Plugins aktiv.
}
+
Content-Modell {s.collections.length}
+
{s.collections.map((c) => (
+ - {c.label}{c.kind} · {c.fields} Felder{c.statKey ? ` · ${c.statKey}` : ''}
+ ))}
+
openbureau-core fährt diese Site. Updates werden serverseitig eingespielt (Update-Skript).
+
+
+ );
+}
+
// ── Autor:innen-Verwaltung (nur Admin) ──────────────────────────────────────
function Users({ onMsg, currentEmail }) {
const [list, setList] = useState(null);
diff --git a/admin/src/api.js b/admin/src/api.js
index af8e372..6697a2f 100644
--- a/admin/src/api.js
+++ b/admin/src/api.js
@@ -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 }) }),
diff --git a/api/package.json b/api/package.json
index f14f80e..1e88811 100644
--- a/api/package.json
+++ b/api/package.json
@@ -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.",
diff --git a/api/src/index.js b/api/src/index.js
index 08e632b..faab6be 100644
--- a/api/src/index.js
+++ b/api/src/index.js
@@ -13,6 +13,7 @@ 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';
@@ -100,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({
diff --git a/api/src/routes/account.js b/api/src/routes/account.js
new file mode 100644
index 0000000..c5c1cf7
--- /dev/null
+++ b/api/src/routes/account.js
@@ -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;
diff --git a/api/src/routes/system.js b/api/src/routes/system.js
index f774449..4f23cea 100644
--- a/api/src/routes/system.js
+++ b/api/src/routes/system.js
@@ -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;
diff --git a/api/test/system.test.js b/api/test/system.test.js
index 0ee8332..6f6e80e 100644
--- a/api/test/system.test.js
+++ b/api/test/system.test.js
@@ -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');