Portal-Dienste und geteiltes Design-System für die anderen Apps

- Dienste (Gitea, Kanban, Cloud …) werden im System-Tab gepflegt und
  erscheinen als Kacheln auf der Startseite; GET /api/services ist
  öffentlich, Pflege braucht den settings-Scope
- /brand.css liefert die Design-Tokens (Farben live aus dem Brand-Tab,
  ändern sich damit überall mit) plus Basis-Klassen d4rk-card,
  d4rk-btn, d4rk-title, d4rk-tag
- /brand-nav.js baut die D4RKST3R-Leiste in jede fremde App ein, mit
  Links zum Hub und zu allen gepflegten Diensten; die Links stecken
  fertig im Skript, dadurch kein zweiter Request und kein CORS nötig
- Beide Dateien mit offenem CORS-Header und 5 Minuten Cache
- Doku für Anbindung + Schriften in docs/sso.md ergänzt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 13:28:50 +02:00
co-authored by Claude Fable 5
parent 70cae85064
commit c73b4e1074
9 changed files with 350 additions and 1 deletions
+46
View File
@@ -16,6 +16,7 @@ import {
getLevelRow, isPlaytester, saveWish, toggleWishVote, myWishVotes,
logAudit, listAudit, playerHistory, deleteUserData,
saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp,
createService, updateService, listServices, deleteService,
} from '../db.js';
import crypto from 'node:crypto';
import { lastResults, GAME_ICONS } from '../bot/server-monitor.js';
@@ -162,6 +163,51 @@ export function registerApiRoutes(app, client) {
}),
}));
// Dienste-Kacheln fürs Portal — öffentlich
app.get('/api/services', async () => ({ services: listServices() }));
/** Dienst-Body prüfen; antwortet bei Fehlern selbst */
function parseServiceBody(request, reply) {
const body = request.body ?? {};
const name = String(body.name ?? '').trim();
const url = String(body.url ?? '').trim();
if (!name || !/^https?:\/\/.+/.test(url)) {
reply.code(400).send({ error: 'name und url (http/https) nötig' });
return null;
}
return {
name: name.slice(0, 60),
url: url.slice(0, 300),
icon: (String(body.icon ?? '').trim() || '🔗').slice(0, 8),
description: String(body.description ?? '').trim().slice(0, 160),
sort: Number(body.sort) || 0,
};
}
app.post('/api/services', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const service = parseServiceBody(request, reply);
if (!service) return;
const id = createService(service);
logAudit(getSessionUser(request), 'dienst hinzugefügt', service.name);
return { ok: true, services: listServices(), id };
});
app.put('/api/services/:id', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
const service = parseServiceBody(request, reply);
if (!service) return;
updateService({ ...service, id: Number(request.params.id) });
logAudit(getSessionUser(request), 'dienst geändert', service.name);
return { ok: true, services: listServices() };
});
app.delete('/api/services/:id', async (request, reply) => {
if (requireScope(request, reply, 'settings')) return;
logAudit(getSessionUser(request), 'dienst gelöscht', String(request.params.id));
return { deleted: deleteService(Number(request.params.id)), services: listServices() };
});
// Level-Bestenliste + Server-Aktivität — öffentlich
app.get('/api/levels', async () => ({ levels: topLevels(50) }));
app.get('/api/serverstats', async () => ({