Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ecf7710b8e | |||
| 42110059c7 |
@@ -104,6 +104,7 @@ function Dashboard({ email }) {
|
|||||||
{me?.canModerate && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</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?.isAdmin && <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>}
|
||||||
</nav>
|
</nav>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<span className="who">{email}</span>
|
<span className="who">{email}</span>
|
||||||
@@ -121,6 +122,8 @@ function Dashboard({ email }) {
|
|||||||
<Forums onMsg={setMsg} />
|
<Forums onMsg={setMsg} />
|
||||||
) : view === 'moderation' ? (
|
) : view === 'moderation' ? (
|
||||||
<Moderation onMsg={setMsg} />
|
<Moderation onMsg={setMsg} />
|
||||||
|
) : view === 'system' ? (
|
||||||
|
<System onMsg={setMsg} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<aside>
|
<aside>
|
||||||
@@ -445,6 +448,37 @@ function Overview({ onMsg, go }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── System (nur Admin): core-Version, Plugins, Content-Modell ────────────────
|
||||||
|
function System({ onMsg }) {
|
||||||
|
const [s, setS] = useState(null);
|
||||||
|
useEffect(() => { api.system().then(setS).catch((e) => onMsg({ type: 'err', text: e.message })); }, []);
|
||||||
|
if (!s) return <div className="empty">…</div>;
|
||||||
|
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">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>Plugins <span className="count-pill">{s.plugins.length}</span></h3>
|
||||||
|
{s.plugins.length
|
||||||
|
? <ul className="syslist">{s.plugins.map((p) => (
|
||||||
|
<li key={p.name}><b>{p.name}</b><span className="muted">{p.routes} Routen · {p.migrations} Migration(en)</span></li>
|
||||||
|
))}</ul>
|
||||||
|
: <p className="muted">Keine Plugins aktiv.</p>}
|
||||||
|
<h3>Content-Modell <span className="count-pill">{s.collections.length}</span></h3>
|
||||||
|
<ul className="syslist">{s.collections.map((c) => (
|
||||||
|
<li key={c.kind}><b>{c.label}</b><span className="muted">{c.kind} · {c.fields} Felder{c.statKey ? ` · ${c.statKey}` : ''}</span></li>
|
||||||
|
))}</ul>
|
||||||
|
<p className="muted who-mail">openbureau-core fährt diese Site. Plugins & Content-Modell kommen aus der Site-Config.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Autor:innen-Verwaltung (nur Admin) ──────────────────────────────────────
|
// ── Autor:innen-Verwaltung (nur Admin) ──────────────────────────────────────
|
||||||
function Users({ onMsg, currentEmail }) {
|
function Users({ onMsg, currentEmail }) {
|
||||||
const [list, setList] = useState(null);
|
const [list, setList] = useState(null);
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export const api = {
|
|||||||
saveProfile: (p) => call('/profile', { method: 'PUT', body: JSON.stringify(p) }),
|
saveProfile: (p) => call('/profile', { method: 'PUT', body: JSON.stringify(p) }),
|
||||||
getMe: () => call('/me'),
|
getMe: () => call('/me'),
|
||||||
stats: () => call('/stats'),
|
stats: () => call('/stats'),
|
||||||
|
system: () => call('/system'),
|
||||||
|
schema: () => call('/schema'),
|
||||||
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 }) }),
|
||||||
|
|||||||
@@ -219,3 +219,13 @@ label.big input { font-family: var(--serif); font-weight: 600; }
|
|||||||
.toast { position: fixed; bottom: 20px; right: 20px; padding: 11px 18px; border-radius: 11px; color: #fff; cursor: pointer; box-shadow: 0 10px 30px -12px rgba(0,0,0,.4); font-size: 13.5px; max-width: 380px; z-index: 50; }
|
.toast { position: fixed; bottom: 20px; right: 20px; padding: 11px 18px; border-radius: 11px; color: #fff; cursor: pointer; box-shadow: 0 10px 30px -12px rgba(0,0,0,.4); font-size: 13.5px; max-width: 380px; z-index: 50; }
|
||||||
.toast.ok { background: var(--ok); }
|
.toast.ok { background: var(--ok); }
|
||||||
.toast.err { background: var(--accent); }
|
.toast.err { background: var(--accent); }
|
||||||
|
|
||||||
|
/* System-Panel */
|
||||||
|
.sysgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: .75rem; margin: .5rem 0 1rem; }
|
||||||
|
.sysgrid > div { display: flex; flex-direction: column; gap: .15rem; }
|
||||||
|
.sysgrid .muted { font-size: .8rem; }
|
||||||
|
.sysgrid b { font-size: 1.05rem; }
|
||||||
|
.sysgrid .ver { opacity: .6; font-weight: 400; }
|
||||||
|
.syslist { list-style: none; padding: 0; margin: .25rem 0 1rem; }
|
||||||
|
.syslist li { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding: .4rem 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.syslist li .muted { font-size: .85rem; }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openbureau-cms-api",
|
"name": "openbureau-cms-api",
|
||||||
"version": "0.1.0",
|
"version": "0.6.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.",
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import upload from './routes/upload.js';
|
|||||||
import profile from './routes/profile.js';
|
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 history from './routes/history.js';
|
import history from './routes/history.js';
|
||||||
import { requireAuth, requireAdmin } from './auth.js';
|
import { requireAuth, requireAdmin } from './auth.js';
|
||||||
|
import { config } from './config.js';
|
||||||
import { manager } from './plugins.js';
|
import { manager } from './plugins.js';
|
||||||
import { runMigrations } from './migrate.js';
|
import { runMigrations } from './migrate.js';
|
||||||
|
|
||||||
@@ -85,6 +87,15 @@ app.route('/api/upload', upload);
|
|||||||
app.route('/api/profile', profile);
|
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);
|
||||||
|
// Content-Modell der Site fürs Admin (schema-getriebenes Rendern). Jede:r
|
||||||
|
// eingeloggte Nutzer:in darf es lesen — der Editor braucht es zum Aufbauen.
|
||||||
|
app.get('/api/schema', (c) => c.json({
|
||||||
|
site: config.site || null,
|
||||||
|
auth: config.auth || 'supabase',
|
||||||
|
plugins: config.plugins,
|
||||||
|
collections: config.collections,
|
||||||
|
}));
|
||||||
|
|
||||||
// --- Admin-SPA (im Container mitgebaut, unter /admin serviert) ---
|
// --- Admin-SPA (im Container mitgebaut, unter /admin serviert) ---
|
||||||
app.get('/admin', (c) => c.redirect('/admin/'));
|
app.get('/admin', (c) => c.redirect('/admin/'));
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
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.
|
||||||
|
export function systemInfo({ version, hugo }) {
|
||||||
|
return {
|
||||||
|
core: { name: 'openbureau-core', version },
|
||||||
|
site: config.site || null,
|
||||||
|
auth: config.auth || 'supabase',
|
||||||
|
plugins: manager.plugins.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
routes: (p.routes || []).length,
|
||||||
|
migrations: (p.migrations || []).length,
|
||||||
|
})),
|
||||||
|
collections: config.collections.map((c) => ({
|
||||||
|
kind: c.kind, label: c.label, statKey: c.statKey || null, fields: (c.fields || []).length,
|
||||||
|
})),
|
||||||
|
hugo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const system = new Hono();
|
||||||
|
system.use('*', requireAdmin);
|
||||||
|
system.get('/', async (c) => {
|
||||||
|
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' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
export default system;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
process.env.CMS_CONFIG ||= fileURLToPath(new URL('../../examples/openbureau.config.js', import.meta.url));
|
||||||
|
process.env.SUPABASE_URL ||= 'http://localhost';
|
||||||
|
process.env.SUPABASE_SERVICE_KEY ||= 'x';
|
||||||
|
process.env.JWT_SECRET ||= 'x';
|
||||||
|
process.env.SITE_DIR ||= '/tmp/nosite';
|
||||||
|
|
||||||
|
const { systemInfo } = await import('../src/routes/system.js');
|
||||||
|
|
||||||
|
test('systemInfo reflects core version, plugins and content model', () => {
|
||||||
|
const info = systemInfo({ version: '0.6.0', hugo: '0.161.1+extended' });
|
||||||
|
assert.equal(info.core.name, 'openbureau-core');
|
||||||
|
assert.equal(info.core.version, '0.6.0');
|
||||||
|
assert.equal(info.site, 'openbureau');
|
||||||
|
assert.equal(info.auth, 'supabase'); // openbureau.config.js sets auth: 'supabase'
|
||||||
|
assert.equal(info.hugo, '0.161.1+extended');
|
||||||
|
// dialog plugin is active with its routes + the one migration
|
||||||
|
const dialog = info.plugins.find((p) => p.name === 'dialog');
|
||||||
|
assert.ok(dialog, 'dialog plugin listed');
|
||||||
|
assert.ok(dialog.routes > 0);
|
||||||
|
assert.equal(dialog.migrations, 1);
|
||||||
|
// content model surfaced from the config collections
|
||||||
|
assert.deepEqual(info.collections.map((c) => c.kind).sort(), ['beitrag', 'biblio', 'rubrik', 'seite']);
|
||||||
|
const beitrag = info.collections.find((c) => c.kind === 'beitrag');
|
||||||
|
assert.equal(beitrag.label, 'Beiträge');
|
||||||
|
assert.ok(beitrag.fields > 0);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user