Squashed 'cms/core/' changes from f709b5d..ac7538f
ac7538f chore: gitignore admin/dist build output 86f9f57 core: stage 7 — auth provider (config.auth: supabase | local), DB-less core git-subtree-dir: cms/core git-subtree-split: ac7538fa0c2c883e29fe67c8d8c15c5f40fa2230
This commit is contained in:
@@ -3,3 +3,6 @@ node_modules/
|
|||||||
.env.*
|
.env.*
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# build output
|
||||||
|
admin/dist/
|
||||||
|
|||||||
+37
-1
@@ -5,4 +5,40 @@ import { createClient } from '@supabase/supabase-js';
|
|||||||
const url = import.meta.env.VITE_SUPABASE_URL;
|
const url = import.meta.env.VITE_SUPABASE_URL;
|
||||||
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
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",
|
"name": "openbureau-cms-api",
|
||||||
"version": "0.1.0",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "openbureau-cms-api",
|
"name": "openbureau-cms-api",
|
||||||
"version": "0.1.0",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.7",
|
"@hono/node-server": "^1.13.7",
|
||||||
"@supabase/supabase-js": "^2.47.10",
|
"@supabase/supabase-js": "^2.47.10",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"hono": "^4.6.14",
|
"hono": "^4.6.14",
|
||||||
"marked": "^14.1.4",
|
"marked": "^14.1.4",
|
||||||
@@ -529,6 +530,12 @@
|
|||||||
"sprintf-js": "~1.0.2"
|
"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": {
|
"node_modules/color": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.7",
|
"@hono/node-server": "^1.13.7",
|
||||||
"@supabase/supabase-js": "^2.47.10",
|
"@supabase/supabase-js": "^2.47.10",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"hono": "^4.6.14",
|
"hono": "^4.6.14",
|
||||||
"marked": "^14.1.4",
|
"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 || {} };
|
return { id: p.sub, email: p.email || '', app_metadata: p.app_metadata || {} };
|
||||||
} catch { return null; }
|
} 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);
|
const { data, error } = await supabaseAuth.auth.getUser(token);
|
||||||
if (error || !data?.user) return null;
|
if (error || !data?.user) return null;
|
||||||
return data.user;
|
return data.user;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const config = mod.default || mod;
|
|||||||
config.collections ||= [];
|
config.collections ||= [];
|
||||||
config.plugins ||= [];
|
config.plugins ||= [];
|
||||||
config.admins ||= [];
|
config.admins ||= [];
|
||||||
|
config.auth ||= 'supabase'; // 'supabase' (GoTrue) | 'local' (file users, DB-less)
|
||||||
|
|
||||||
export const hasPlugin = (name) => config.plugins.includes(name);
|
export const hasPlugin = (name) => config.plugins.includes(name);
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -14,11 +14,20 @@ import users from './routes/users.js';
|
|||||||
import stats from './routes/stats.js';
|
import stats from './routes/stats.js';
|
||||||
import system from './routes/system.js';
|
import system from './routes/system.js';
|
||||||
import history from './routes/history.js';
|
import history from './routes/history.js';
|
||||||
|
import authRoute from './routes/auth.js';
|
||||||
import { requireAuth, requireAdmin } from './auth.js';
|
import { requireAuth, requireAdmin } from './auth.js';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
|
import { supabase } from './supabase.js';
|
||||||
import { manager } from './plugins.js';
|
import { manager } from './plugins.js';
|
||||||
import { runMigrations } from './migrate.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 SITE_DIR = process.env.SITE_DIR || '/site';
|
||||||
const ADMIN_DIR = process.env.ADMIN_DIR || '/app/admin-dist';
|
const ADMIN_DIR = process.env.ADMIN_DIR || '/app/admin-dist';
|
||||||
const PORT = Number(process.env.PORT || 3000);
|
const PORT = Number(process.env.PORT || 3000);
|
||||||
@@ -64,9 +73,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
|
// 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.
|
// den Login auf 10 Versuche/IP pro 5 Minuten). Pluginlose Site: nichts gemountet.
|
||||||
manager.mountPublic(app);
|
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.
|
// Öffentlich: Versionsverlauf der Beiträge (Git-History) — auf der Site anzeigbar.
|
||||||
app.route('/api/history', history);
|
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);
|
app.use('/api/*', requireAuth);
|
||||||
// Schreibzugriffe drosseln (Spam-Schutz, auch bei gekapertem Token):
|
// Schreibzugriffe drosseln (Spam-Schutz, auch bei gekapertem Token):
|
||||||
// 60 Mutationen/Minute je Nutzer. Lesen (GET) bleibt frei.
|
// 60 Mutationen/Minute je Nutzer. Lesen (GET) bleibt frei.
|
||||||
|
|||||||
@@ -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;
|
||||||
+10
-2
@@ -5,6 +5,10 @@ import { requireAdmin, roleOf } from '../auth.js';
|
|||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { contentStats } from '../collections.js';
|
import { contentStats } from '../collections.js';
|
||||||
import { manager } from '../plugins.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.
|
// Kennzahlen für die Admin-Übersicht. Nur Admins; rein lesend.
|
||||||
const stats = new Hono();
|
const stats = new Hono();
|
||||||
@@ -16,12 +20,16 @@ stats.get('/', async (c) => {
|
|||||||
try { content = contentStats(await listEntries(), config.collections); }
|
try { content = contentStats(await listEntries(), config.collections); }
|
||||||
catch { /* Filesystem nicht lesbar → leer */ }
|
catch { /* Filesystem nicht lesbar → leer */ }
|
||||||
|
|
||||||
// Nutzer nach Rolle (GoTrue).
|
// Nutzer nach Rolle: lokal aus der User-Datei, sonst aus GoTrue.
|
||||||
const users = { total: 0, admin: 0, editor: 0, user: 0 };
|
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 {
|
try {
|
||||||
const { data } = await supabase.auth.admin.listUsers();
|
const { data } = await supabase.auth.admin.listUsers();
|
||||||
for (const u of data?.users || []) { users.total++; users[roleOf(u)] = (users[roleOf(u)] || 0) + 1; }
|
for (const u of data?.users || []) { users.total++; users[roleOf(u)] = (users[roleOf(u)] || 0) + 1; }
|
||||||
} catch { /* GoTrue nicht erreichbar */ }
|
} catch { /* GoTrue nicht erreichbar */ }
|
||||||
|
}
|
||||||
|
|
||||||
// Plugin-Kennzahlen einsammeln (jedes Plugin unter seinem Namen). Nur geladene
|
// Plugin-Kennzahlen einsammeln (jedes Plugin unter seinem Namen). Nur geladene
|
||||||
// Plugins lesen die DB — eine pluginlose Site macht hier null DB-Zugriffe.
|
// Plugins lesen die DB — eine pluginlose Site macht hier null DB-Zugriffe.
|
||||||
|
|||||||
+24
-6
@@ -1,15 +1,23 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { supabase } from '../supabase.js';
|
import { supabase } from '../supabase.js';
|
||||||
import { requireAdmin, roleOf } from '../auth.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 || '')
|
const ADMINS = (process.env.ADMIN_EMAILS || '')
|
||||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||||
|
const isLocal = () => config.auth === 'local';
|
||||||
|
|
||||||
const users = new Hono();
|
const users = new Hono();
|
||||||
users.use('*', requireAdmin);
|
users.use('*', requireAdmin);
|
||||||
|
|
||||||
users.get('/', async (c) => {
|
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();
|
const { data, error } = await supabase.auth.admin.listUsers();
|
||||||
if (error) return c.json({ error: error.message }, 500);
|
if (error) return c.json({ error: error.message }, 500);
|
||||||
const list = (data?.users || []).map((u) => {
|
const list = (data?.users || []).map((u) => {
|
||||||
@@ -32,6 +40,10 @@ users.post('/', async (c) => {
|
|||||||
const { email, password, role } = await c.req.json();
|
const { email, password, role } = await c.req.json();
|
||||||
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
|
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 (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 };
|
const payload = { email, password, email_confirm: true };
|
||||||
if (role && role !== 'user') payload.app_metadata = { role };
|
if (role && role !== 'user') payload.app_metadata = { role };
|
||||||
const { data, error } = await supabase.auth.admin.createUser(payload);
|
const { data, error } = await supabase.auth.admin.createUser(payload);
|
||||||
@@ -41,19 +53,25 @@ users.post('/', async (c) => {
|
|||||||
|
|
||||||
users.put('/:id', async (c) => {
|
users.put('/:id', async (c) => {
|
||||||
const { password, role } = await c.req.json();
|
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 = {};
|
const patch = {};
|
||||||
if (password) patch.password = password;
|
if (password) patch.password = password;
|
||||||
if (role) {
|
if (role) patch.app_metadata = { 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);
|
|
||||||
const { error } = await supabase.auth.admin.updateUserById(c.req.param('id'), patch);
|
const { error } = await supabase.auth.admin.updateUserById(c.req.param('id'), patch);
|
||||||
if (error) return c.json({ error: error.message }, 400);
|
if (error) return c.json({ error: error.message }, 400);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
users.delete('/:id', async (c) => {
|
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'));
|
const { error } = await supabase.auth.admin.deleteUser(c.req.param('id'));
|
||||||
if (error) return c.json({ error: error.message }, 400);
|
if (error) return c.json({ error: error.message }, 400);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
|
|||||||
+16
-8
@@ -3,19 +3,27 @@ import { createClient } from '@supabase/supabase-js';
|
|||||||
const url = process.env.SUPABASE_URL;
|
const url = process.env.SUPABASE_URL;
|
||||||
const key = process.env.SUPABASE_SERVICE_KEY;
|
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 } };
|
const opts = { auth: { persistSession: false, autoRefreshToken: false } };
|
||||||
|
|
||||||
|
// 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/…).
|
// Daten-Client: Service-Role-Key, umgeht RLS. NUR für DB-Zugriffe (from/insert/…).
|
||||||
// Wichtig: hier niemals signInWithPassword aufrufen — das schaltet den
|
// Wichtig: hier niemals signInWithPassword aufrufen — das schaltet den
|
||||||
// Authorization-Header des Clients prozessweit auf das User-Token um (SIGNED_IN),
|
// Authorization-Header des Clients prozessweit auf das User-Token um (SIGNED_IN),
|
||||||
// wodurch anschließende Inserts als role=authenticated laufen und an RLS scheitern.
|
// wodurch anschließende Inserts als role=authenticated laufen und an RLS scheitern.
|
||||||
export const supabase = createClient(url, key, opts);
|
_supabase = createClient(url, key, opts);
|
||||||
|
|
||||||
// Eigener Client nur für Auth (Login, Token-Prüfung). Getrennt, damit ein
|
// Eigener Client nur für Auth (Login, Token-Prüfung). Getrennt, damit ein
|
||||||
// signInWithPassword den Daten-Client oben nicht „vergiftet". Niemals ins Frontend.
|
// signInWithPassword den Daten-Client oben nicht „vergiftet". Niemals ins Frontend.
|
||||||
export const supabaseAuth = createClient(url, key, opts);
|
_supabaseAuth = createClient(url, key, opts);
|
||||||
|
} else {
|
||||||
|
console.warn('supabase.js: kein SUPABASE_URL/SERVICE_KEY — DB-loser Betrieb (auth: local erwartet).');
|
||||||
|
}
|
||||||
|
|
||||||
|
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/);
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// No dialog plugin; a portfolio-shaped content model.
|
// No dialog plugin; a portfolio-shaped content model.
|
||||||
export default {
|
export default {
|
||||||
site: 'kgva',
|
site: 'kgva',
|
||||||
|
auth: 'local', // DB-less: file-based users + self-signed JWTs (no Supabase/Postgres)
|
||||||
admins: ['karim@gabrielevarano.ch'],
|
admins: ['karim@gabrielevarano.ch'],
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user