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
+4
View File
@@ -50,6 +50,8 @@ SQLite (better-sqlite3, FTS5), Docker Multi-Stage — deploybar als Portainer-St
| 🎮 **Server-Monitor** | DiscordGSM-Stil: eigener Setup-Tab, pro Server ein Live-Embed (🟢/🔴, Spieler-Balken mit %, Map, Spieler-Liste, klickbarer Connect-Link, Ping); **300+ Spiele via gamedig** (Minecraft, Rust, CS2, Valheim, ARK …) + FiveM + HTTP-Check; Down/Up-Alerts (🚨 nach 2 Fehlversuchen, ✅ mit Downtime bei Recovery) in eigenen Alert-Kanal; Spielerzahl in der Presence |
| 👥 **Team-Rechte** | Team-Mitglieder bekommen gezielten Web-Zugriff (Composer, Bewerbungen, Rollen, Server, …) — Brand, System, API-Keys bleiben Owner-only; **Audit-Log** im Team-Tab zeigt dem Owner die letzten Aktionen |
| 🔑 **Single Sign-On** | Andere Dienste (Kanban, Platform …) nutzen den Discord-Login des Bots mit und bekommen die Discord-Rollen gleich mitgeliefert — [Anleitung](docs/sso.md) |
| 🎨 **Geteiltes Design** | `/brand.css` + `/brand-nav.js` geben jeder anderen App den D4RKST3R-Look samt Navigation — Farben kommen live aus dem Brand-Tab |
| 🏠 **Portal** | Dienste (Gitea, Kanban, Cloud …) im Setup pflegen → Kacheln auf der Startseite und Links in der geteilten Navigation |
| ⚖️ **Rechtstexte** | `/impressum` + `/datenschutz` aus dem Setup gepflegt; Member können ihre Daten selbst löschen (DSGVO Art. 17) |
| 💡 **Feature-Voting** | `/wunsch` → Voting-Post mit 👍; Top-Wünsche öffentlich auf der Roadmap-Seite |
| 🎉 **Giveaways** | `/giveaway` (Admin): Teilnahme-Button, automatische Ziehung nach Ablauf; am Gewinner-Post: 🔁 Neu auslosen (Admin) + 👥 Teilnehmerliste |
@@ -274,6 +276,8 @@ urllib.request.urlopen(req)
| `POST /api/compose` · `GET/PUT/DELETE /api/templates` | Team (content) | Embed-Composer senden/bearbeiten · Vorlagen |
| `GET /api/profile` · `GET/POST /api/myroles*` · `POST /api/wishes*` | Member | Profil · Rollen-Selfservice · Wunsch einreichen/voten |
| `GET /sso/authorize` · `POST /sso/verify` | App-Secret | **Single Sign-On** für andere Dienste — siehe [docs/sso.md](docs/sso.md) |
| `GET /brand.css` · `/brand-nav.js` | — | Design-Tokens + Navigation für andere Apps (CORS offen) |
| `GET /api/services` | — | Dienste-Kacheln fürs Portal (Pflege: Scope `settings`) |
| `GET /api/legal` · `DELETE /api/profile` | — / Member | Impressums-Angaben · DSGVO-Löschung |
| `GET/PUT /api/settings`, `POST /api/settings/test/:target` | Admin | Setup-Seite |
| `GET /api/commits?page=` · `DELETE /api/devlogs/:id` | Admin | Commit-Archiv · Devlog löschen |
+26 -1
View File
@@ -101,7 +101,32 @@ app.get('/auth/callback', async (req, res) => {
});
```
## 5. Hinweise
## 5. Aussehen mitnehmen (Design-System)
Damit die angebundene App nicht wie ein Fremdkörper wirkt, liefert der Bot die
Brand-Tokens und eine fertige Navigation aus:
```html
<link rel="stylesheet" href="https://bot.d4rkst3r.de/brand.css">
<script defer src="https://bot.d4rkst3r.de/brand-nav.js"></script>
```
* **`/brand.css`** — CSS-Variablen (`--neon`, `--bg`, `--display` …) plus ein paar
Basis-Klassen: `.d4rk-card`, `.d4rk-btn`, `.d4rk-title`, `.d4rk-tag`.
Die Farben kommen live aus dem Brand-Tab — änderst du sie dort, ändern sie sich
überall mit (Cache: 5 Minuten).
* **`/brand-nav.js`** — fügt oben die D4RKST3R-Leiste ein, mit Links zurück zum Hub
und zu allen im Setup gepflegten Diensten. Die Links stecken fertig im Skript,
es sind also keine weiteren Anfragen und kein CORS nötig.
Die Schriften installierst du in der App selbst, damit sie lokal ausgeliefert werden
(kein Google-Fonts-Aufruf):
```bash
npm i @fontsource/bebas-neue @fontsource/barlow-condensed @fontsource/share-tech-mono
```
## 6. Hinweise
* Der Verify-Aufruf muss **serverseitig** erfolgen — das Secret darf nie im Browser landen.
* Nur Mitglieder des konfigurierten Discord-Servers können sich anmelden; alle anderen
+17
View File
@@ -28,12 +28,14 @@ export default function Home() {
const [servers, setServers] = useState(null);
const [latest, setLatest] = useState(null);
const [invite, setInvite] = useState(null);
const [services, setServices] = useState([]);
useEffect(() => {
apiGet('/api/serverstats').then(setStats).catch(() => {});
apiGet('/api/servers').then((d) => setServers(d.servers)).catch(() => {});
apiGet('/api/devlogs?page=1').then((d) => setLatest(d.items?.[0] ?? null)).catch(() => {});
apiGet('/api/invite').then((d) => setInvite(d.invite)).catch(() => {});
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
window.scrollTo(0, 0);
}, []);
@@ -95,6 +97,21 @@ export default function Home() {
))}
</div>
{services.length > 0 && (
<div className="roadmap-group" style={{ marginTop: '2.5rem' }}>
<h2 className="settings-title">// Dienste</h2>
<div className="home-tiles">
{services.map((s) => (
<a className="card home-tile" href={s.url} target="_blank" rel="noreferrer" key={s.id}>
<span className="home-tile-icon">{s.icon}</span>
<span className="home-tile-title">{s.name} <span className="home-tile-ext"></span></span>
{s.description && <span className="home-tile-desc">{s.description}</span>}
</a>
))}
</div>
</div>
)}
{latest && (
<div className="roadmap-group" style={{ marginTop: '2.5rem' }}>
<h2 className="settings-title">// Neuestes Devlog</h2>
+63
View File
@@ -161,6 +161,10 @@ export default function Settings({ me }) {
const [botDesc, setBotDesc] = useState('');
const [team, setTeam] = useState([]);
const [audit, setAudit] = useState([]);
// Portal-Dienste (Kacheln auf der Startseite)
const emptyService = { id: null, name: '', url: '', icon: '', description: '', sort: 0 };
const [services, setServices] = useState([]);
const [serviceDraft, setServiceDraft] = useState(emptyService);
// SSO-Apps (Fremd-Dienste mit Discord-Login)
const [ssoApps, setSsoApps] = useState([]);
const [ssoDraft, setSsoDraft] = useState({ slug: '', name: '', redirect_prefix: '' });
@@ -208,6 +212,7 @@ export default function Settings({ me }) {
apiGet('/api/webadmins').then((d) => setTeam(d.admins)).catch(() => {});
apiGet('/api/auditlog').then((d) => setAudit(d.entries)).catch(() => {});
apiGet('/api/ssoapps').then((d) => setSsoApps(d.apps)).catch(() => {});
apiGet('/api/services').then((d) => setServices(d.services)).catch(() => {});
}, [me.admin, me.scopes?.length]);
useEffect(() => {
@@ -241,6 +246,28 @@ export default function Settings({ me }) {
if (res) setTeam(res.admins);
}
async function saveService() {
if (!serviceDraft.name.trim() || !serviceDraft.url.trim()) {
flash('✗ Name und URL nötig');
return;
}
try {
const res = serviceDraft.id
? await apiPut(`/api/services/${serviceDraft.id}`, serviceDraft)
: await apiPost('/api/services', serviceDraft);
setServices(res.services);
setServiceDraft(emptyService);
flash('✓ Dienst gespeichert');
} catch {
flash('✗ Speichern fehlgeschlagen (URL prüfen)');
}
}
async function removeService(id) {
const res = await apiDelete(`/api/services/${id}`).catch(() => null);
if (res) setServices(res.services);
}
async function createSsoApp() {
if (!ssoDraft.slug.trim() || !ssoDraft.name.trim() || !ssoDraft.redirect_prefix.trim()) {
flash('✗ Kürzel, Name und Rücksprung-URL nötig');
@@ -1246,6 +1273,42 @@ export default function Settings({ me }) {
</div>
<div className="settings-section">
<h2 className="settings-title">// Dienste (Portal)</h2>
<p className="section-intro">
Erscheinen als Kacheln auf der Startseite z. B. Gitea, Kanban oder die Cloud.
</p>
{services.length > 0 && (
<div className="key-list" style={{ marginBottom: '1.2rem' }}>
{services.map((s) => (
<div className="key-row" key={s.id}>
<span className="key-name">{s.icon} {s.name}</span>
<span className="key-used" title={s.url}>{s.url}</span>
<button className="btn btn-mini" onClick={() => setServiceDraft({
id: s.id, name: s.name, url: s.url, icon: s.icon,
description: s.description ?? '', sort: s.sort ?? 0,
})}>Bearbeiten</button>
<button className="card-delete" title="Dienst entfernen" onClick={() => removeService(s.id)}></button>
</div>
))}
</div>
)}
<div className="field">
<label>{serviceDraft.id ? `Dienst #${serviceDraft.id} bearbeiten` : 'Neuer Dienst'}</label>
<div className="field-row">
<input type="text" style={{ flex: 'none', width: '4rem' }} placeholder="🗂️" value={serviceDraft.icon} onChange={(e) => setServiceDraft({ ...serviceDraft, icon: e.target.value })} />
<input type="text" style={{ flex: 'none', width: '10rem' }} placeholder="Kanban" value={serviceDraft.name} onChange={(e) => setServiceDraft({ ...serviceDraft, name: e.target.value })} />
<input type="text" placeholder="https://kanban.d4rkst3r.de" value={serviceDraft.url} onChange={(e) => setServiceDraft({ ...serviceDraft, url: e.target.value })} />
<input type="number" style={{ flex: 'none', width: '5rem' }} placeholder="Pos." value={serviceDraft.sort} onChange={(e) => setServiceDraft({ ...serviceDraft, sort: e.target.value })} />
</div>
<div className="field-row" style={{ marginTop: '.6rem' }}>
<input type="text" placeholder="Kurzbeschreibung für die Kachel" value={serviceDraft.description} onChange={(e) => setServiceDraft({ ...serviceDraft, description: e.target.value })} />
<button className="btn btn-save" onClick={saveService}>{serviceDraft.id ? 'Aktualisieren' : 'Hinzufügen'}</button>
{serviceDraft.id && <button className="btn btn-ghost" onClick={() => setServiceDraft(emptyService)}>Abbrechen</button>}
</div>
</div>
</div>
<div className="settings-section">
<h2 className="settings-title">// Impressum &amp; Datenschutz</h2>
<p className="field-hint" style={{ marginBottom: '.9rem' }}>
+1
View File
@@ -1511,3 +1511,4 @@ a.sha:hover { border-color: var(--neon); background: rgba(245, 197, 24, .07); }
}
.btn-danger { border-color: rgba(242, 63, 67, .5); color: #f23f43; }
.btn-danger:hover { border-color: #f23f43; background: rgba(242, 63, 67, .1); }
.home-tile-ext { font-size: .8rem; color: var(--muted2); }
+24
View File
@@ -516,6 +516,30 @@ export function deleteUserData(userId) {
return result;
}
// Portal: eigene Dienste (Gitea, Kanban, Cloud …) für die Kachel-Übersicht
db.exec(`
CREATE TABLE IF NOT EXISTS services (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '🔗',
description TEXT NOT NULL DEFAULT '',
sort INTEGER NOT NULL DEFAULT 0
);
`);
const insertService = db.prepare(
'INSERT INTO services (name, url, icon, description, sort) VALUES (@name, @url, @icon, @description, @sort)'
);
const updateServiceStmt = db.prepare(
'UPDATE services SET name = @name, url = @url, icon = @icon, description = @description, sort = @sort WHERE id = @id'
);
const listServicesStmt = db.prepare('SELECT * FROM services ORDER BY sort, name');
const deleteServiceStmt = db.prepare('DELETE FROM services WHERE id = ?');
export const createService = (s) => insertService.run(s).lastInsertRowid;
export const updateService = (s) => updateServiceStmt.run(s).changes > 0;
export const listServices = () => listServicesStmt.all();
export const deleteService = (id) => deleteServiceStmt.run(id).changes > 0;
// SSO: Fremd-Apps (Kanban, Platform …), die den Discord-Login des Bots mitnutzen
db.exec(`
CREATE TABLE IF NOT EXISTS sso_apps (
+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 () => ({
+167
View File
@@ -0,0 +1,167 @@
// Geteiltes Design-System: andere Dienste (Kanban, Platform …) binden diese
// beiden Dateien ein und sehen sofort aus wie der Rest — Farben kommen live aus
// dem Brand-Tab, eine Änderung dort wirkt überall.
//
// <link rel="stylesheet" href="https://bot.d4rkst3r.de/brand.css">
// <script defer src="https://bot.d4rkst3r.de/brand-nav.js"></script>
//
// Die Dienste-Links werden serverseitig eingebettet — die Fremd-App braucht
// dadurch keinen CORS-Zugriff auf die API.
import { listServices } from '../db.js';
import { brandName, publicUrl } from '../runtime-settings.js';
import { getSetting } from '../db.js';
const CACHE_SECONDS = 300;
/** In JS-String einbetten (Anführungszeichen/Zeilenumbrüche entschärfen) */
const js = (value) => JSON.stringify(String(value ?? ''));
export function registerBrandRoutes(app) {
app.get('/brand.css', async (request, reply) => {
const neon = getSetting('brand_color') || '#f5c518';
const neon2 = getSetting('brand_color2') || '#ff4d00';
return reply
.type('text/css; charset=utf-8')
.header('cache-control', `public, max-age=${CACHE_SECONDS}`)
.header('access-control-allow-origin', '*')
.send(`/* D4RKST3R Design-Tokens — erzeugt von ${publicUrl()} */
:root {
--neon: ${neon};
--neon2: ${neon2};
--bg: #0a0a0a;
--bg2: #111;
--bg3: #161616;
--text: #e8e0d0;
--muted: #8a8378;
--muted2: #666;
--border: color-mix(in srgb, ${neon} 15%, transparent);
--success: #00ff88;
--danger: #f23f43;
/* Schriften per @fontsource in der App installieren:
npm i @fontsource/bebas-neue @fontsource/barlow-condensed @fontsource/share-tech-mono */
--display: 'Bebas Neue', Impact, sans-serif;
--body: 'Barlow Condensed', system-ui, sans-serif;
--mono: 'Share Tech Mono', ui-monospace, monospace;
}
.d4rk-body {
background: var(--bg); color: var(--text);
font-family: var(--body); font-size: 17px; line-height: 1.6;
}
.d4rk-title {
font-family: var(--display); letter-spacing: .03em;
color: var(--text); line-height: .95;
}
.d4rk-tag {
font-family: var(--mono); font-size: .7rem; letter-spacing: .3em;
text-transform: uppercase; color: var(--neon2);
}
.d4rk-card {
background: var(--bg2); border: 1px solid rgba(255,255,255,.06);
border-radius: 12px; padding: 1.3rem 1.6rem;
transition: border-color .25s, transform .25s, box-shadow .25s;
}
.d4rk-card:hover {
border-color: var(--border); transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(0,0,0,.45);
}
.d4rk-btn {
display: inline-flex; align-items: center; gap: .5rem;
font-family: var(--mono); font-size: .7rem;
letter-spacing: .2em; text-transform: uppercase;
color: var(--neon); background: none;
border: 1px solid var(--border); border-radius: 999px;
padding: .5rem 1.2rem; cursor: pointer;
transition: background .2s, border-color .2s, box-shadow .2s;
}
.d4rk-btn:hover {
border-color: var(--neon); background: color-mix(in srgb, var(--neon) 8%, transparent);
box-shadow: 0 0 16px color-mix(in srgb, var(--neon) 15%, transparent);
}
/* Navbar, die brand-nav.js einfügt */
.d4rk-nav {
position: sticky; top: 0; z-index: 200;
display: flex; align-items: center; gap: 1.6rem; flex-wrap: wrap;
padding: 1rem 2rem;
background: rgba(10,10,10,.9);
backdrop-filter: blur(14px) saturate(1.2);
-webkit-backdrop-filter: blur(14px) saturate(1.2);
border-bottom: 1px solid var(--border);
}
.d4rk-nav-logo {
font-family: var(--display); font-size: 1.5rem; letter-spacing: .15em;
color: var(--neon); text-decoration: none; line-height: 1;
}
.d4rk-nav-logo span { color: var(--neon2); }
.d4rk-nav a:not(.d4rk-nav-logo) {
position: relative;
font-family: var(--mono); font-size: .7rem;
letter-spacing: .25em; text-transform: uppercase;
color: var(--muted); text-decoration: none; padding: .3rem 0;
transition: color .2s;
}
.d4rk-nav a:not(.d4rk-nav-logo)::after {
content: ''; position: absolute; left: 0; right: 0; bottom: -2px; height: 2px;
background: linear-gradient(90deg, var(--neon), var(--neon2)); border-radius: 2px;
transform: scaleX(0); transform-origin: left; transition: transform .25s ease;
}
.d4rk-nav a:not(.d4rk-nav-logo):hover { color: var(--text); }
.d4rk-nav a:not(.d4rk-nav-logo):hover::after { transform: scaleX(1); }
.d4rk-nav-spacer { flex: 1; }
@media (max-width: 640px) {
.d4rk-nav { padding: .8rem 1rem; gap: 1rem; }
}
`);
});
app.get('/brand-nav.js', async (request, reply) => {
const hub = publicUrl();
const links = [
{ name: 'Hub', url: hub },
{ name: 'Devlog', url: `${hub}/devlogs` },
{ name: 'Roadmap', url: `${hub}/roadmap` },
...listServices().map((s) => ({ name: s.name, url: s.url })),
];
const [first, ...rest] = brandName();
return reply
.type('application/javascript; charset=utf-8')
.header('cache-control', `public, max-age=${CACHE_SECONDS}`)
.header('access-control-allow-origin', '*')
.send(`// D4RKST3R-Navigation — erzeugt von ${hub}
(function () {
var links = ${JSON.stringify(links)};
function build() {
if (document.querySelector('.d4rk-nav')) return;
var nav = document.createElement('header');
nav.className = 'd4rk-nav';
var logo = document.createElement('a');
logo.className = 'd4rk-nav-logo';
logo.href = ${js(hub)};
logo.innerHTML = ${js(`${first}${rest.slice(0, -1).join('')}`)} + '<span>' + ${js(rest.slice(-1).join(''))} + '</span>';
nav.appendChild(logo);
links.forEach(function (link) {
var a = document.createElement('a');
a.href = link.url;
a.textContent = link.name;
// aktuelle Seite hervorheben
if (location.href.indexOf(link.url) === 0) a.style.color = 'var(--neon)';
nav.appendChild(a);
});
document.body.insertBefore(nav, document.body.firstChild);
document.body.classList.add('d4rk-body');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', build);
} else {
build();
}
})();
`);
});
}
+2
View File
@@ -17,6 +17,7 @@ import { registerApiRoutes } from './api.js';
import { registerApiV1 } from './api-v1.js';
import { registerDevlogEndpoint } from './devlog-endpoint.js';
import { registerSsoRoutes } from './sso.js';
import { registerBrandRoutes } from './brand.js';
import { imagesDir } from '../bot/devlog-archive.js';
// Gebautes React-Frontend (frontend/dist) — im Container immer vorhanden,
@@ -69,6 +70,7 @@ export async function startWebServer(client) {
registerApiV1(app, client);
registerDevlogEndpoint(app, client);
registerSsoRoutes(app, client);
registerBrandRoutes(app);
// Healthcheck (für Portainer/NPM)
app.get('/health', async () => ({ status: 'ok' }));