Creator Workspace: Anmeldung, Sitzungen und Audit-Log

Erster funktionierender Login fuer /workspace. Bewusst OHNE neue
Abhaengigkeiten: Node 24 bringt node:sqlite mit, Hashing und Zufall
kommen aus node:crypto. Nichts zu kompilieren, keine fremde Lieferkette
an einer Stelle, an der es um Zugangsdaten geht.

Sicherheit:
- Codes liegen nur als scrypt-Hash (N=32768) mit eigenem Salt in der DB
- Vergleich in konstanter Zeit (timingSafeEqual)
- Sitzungstoken 32 Byte Zufall, in der DB nur als SHA-256
- Cookie httpOnly, SameSite=lax, Path=/workspace, secure abhaengig von
  req.secure (live immer an, nur der lokale http-Test kommt ohne aus)
- Sperre nach 8 Fehlversuchen je IP fuer 10 Minuten -- danach ist auch
  der richtige Code blockiert (geprueft)
- gleiche Fehlermeldung bei falscher Rolle und falschem Code
- Audit-Log fuer Anmeldung, Fehlversuch, Sperre, Abmeldung, Codewechsel

Die Datenbank liegt AUSSERHALB des Repos (../workspace-daten/): sonst
waere sie ueber express.static aus dem Netz erreichbar, und ein git pull
wuerde Nutzdaten anfassen.

workspace.js kann die Website nicht mitreissen: node:sqlite wird erst bei
Bedarf per createRequire geladen, jede Route faengt ihre Fehler selbst ab.
Faellt die DB aus, antwortet nur /workspace/api/* mit 503.

Codes werden ausschliesslich auf der Kommandozeile erzeugt
(server/workspace-code.js) und dort einmal angezeigt -- nie in Git.

Ausserdem: start.html als geschuetzte Seite nach dem Anmelden. Der Schutz
sitzt serverseitig vor express.static, nicht nur im Browser.
This commit is contained in:
2026-08-27 22:35:55 +02:00
parent 1f769c9de7
commit f3a55af80f
7 changed files with 688 additions and 1 deletions
+11
View File
@@ -13,6 +13,7 @@ import { fileURLToPath } from "node:url";
import { gateMiddleware } from "./gate.js";
import { webdesignGate } from "./webdesign-gate.js";
import { inhaltsrichtlinie } from "./inhaltsrichtlinie.js";
import { workspaceRouter } from "./workspace.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const SITE_DIR = join(__dirname, "..");
@@ -58,6 +59,16 @@ app.use(gateMiddleware);
lesbar. Ausführliche Begründung im Kopf von webdesign-gate.js. */
app.use(webdesignGate);
/* Creator Workspace (27.08.2026): Anmeldung, Sitzungen und Audit-Log für
/workspace. Muss VOR express.static stehen, weil der Router auch die
angemeldeten Seiten schützt -- stünde er dahinter, würde express.static
/workspace/start.html vorher ungeschützt ausliefern.
Das Modul ist so gebaut, dass es diese Website nicht mitreißen kann:
Es öffnet beim Laden keine Datenbank, und jede Route fängt ihre Fehler
selbst ab (Begründung im Kopf von workspace.js). */
app.use(workspaceRouter);
/* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen).
SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert
ohne diese Sperre daher nicht nur die eigentliche Website aus, sondern auch den Server-
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env node
/* =====================================================================
workspace-code.js — Zugangscodes für den Creator Workspace verwalten.
Läuft NUR auf der Kommandozeile des Servers. Der Code wird dabei einmal
angezeigt und danach nirgends mehr gespeichert -- in der Datenbank liegt
ausschließlich sein scrypt-Hash. Geht ein Code verloren, wird ein neuer
erzeugt; wiederherstellen lässt er sich nicht. Das ist Absicht.
Aufrufe:
node server/workspace-code.js liste
node server/workspace-code.js anlegen "Dogfather" admin
node server/workspace-code.js neu 3
node server/workspace-code.js sperren 3
node server/workspace-code.js entsperren 3
node server/workspace-code.js protokoll [anzahl]
===================================================================== */
import {
personAnlegen, personenListe, personSperren, codeNeu, protokollLesen,
} from "./workspace.js";
const [, , befehl, ...rest] = process.argv;
function zeigeCode(p) {
const linie = "─".repeat(52);
console.log(`\n${linie}`);
console.log(` ${p.name} · Rolle: ${p.rolle} · Nummer: ${p.id}`);
console.log(` Zugangscode: ${p.code}`);
console.log(`${linie}`);
console.log(" Dieser Code wird NUR JETZT angezeigt. Sicher weitergeben");
console.log(" (z. B. über Bitwarden) und hier nicht notieren.\n");
}
try {
switch (befehl) {
case "anlegen": {
const [name, rolle] = rest;
if (!name || !rolle) throw new Error('Aufruf: anlegen "Name" <admin|creator|scout>');
zeigeCode(personAnlegen(name, rolle));
break;
}
case "neu": {
const id = Number(rest[0]);
if (!id) throw new Error("Aufruf: neu <Nummer>");
zeigeCode(codeNeu(id));
console.log(" Alle offenen Sitzungen dieser Person wurden beendet.\n");
break;
}
case "sperren":
case "entsperren": {
const id = Number(rest[0]);
if (!id) throw new Error(`Aufruf: ${befehl} <Nummer>`);
personSperren(id, befehl === "entsperren");
console.log(`Person ${id} ist jetzt ${befehl === "entsperren" ? "aktiv" : "gesperrt"}.`);
break;
}
case "liste": {
const alle = personenListe();
if (!alle.length) { console.log("Noch keine Personen angelegt."); break; }
console.log("\n Nr Rolle Name Status Sitzungen Letzte Anmeldung");
console.log(" ".padEnd(78, "─"));
for (const p of alle) {
console.log(
` ${String(p.id).padStart(2)} ${p.rolle.padEnd(8)} ${p.name.slice(0, 20).padEnd(20)} `
+ `${(p.aktiv ? "aktiv" : "gesperrt").padEnd(9)} ${String(p.sitzungen).padStart(6)} `
+ `${p.letzter_login ? p.letzter_login.slice(0, 16).replace("T", " ") : "—"}`);
}
console.log("");
break;
}
case "protokoll": {
const eintraege = protokollLesen(Number(rest[0]) || 20);
if (!eintraege.length) { console.log("Protokoll ist leer."); break; }
console.log("");
for (const e of eintraege) {
console.log(` ${e.zeitpunkt.slice(0, 19).replace("T", " ")} ${(e.aktion || "").padEnd(24)} `
+ `${(e.rolle || "—").padEnd(8)} ${(e.detail || "").padEnd(18)} ${e.ip || ""}`);
}
console.log("");
break;
}
default:
console.log(`
Zugangscodes für den Creator Workspace
liste alle Personen anzeigen
anlegen "Name" <rolle> Person anlegen, Code wird einmal angezeigt
neu <Nr> neuen Code erzeugen (beendet alte Sitzungen)
sperren <Nr> / entsperren <Nr> Zugang sperren oder wieder freigeben
protokoll [Anzahl] letzte Einträge des Audit-Logs
Rollen: admin, creator, scout
`);
}
} catch (fehler) {
console.error(`\nFehler: ${fehler.message}\n`);
process.exit(1);
}
+344
View File
@@ -0,0 +1,344 @@
/* =====================================================================
workspace.js — Anmeldung und Sitzungen für den Creator Workspace
(/workspace). Eigenständiger Router, nach dem Muster von gate.js und
webdesign-gate.js.
BEWUSST OHNE NEUE ABHÄNGIGKEITEN. Node 24 bringt `node:sqlite` mit, das
Hashen und die Zufallswerte kommen aus `node:crypto`. Damit gibt es
nichts zu kompilieren, nichts zu aktualisieren und keine fremde
Lieferkette in einem Bereich, in dem es um Zugangsdaten geht.
WICHTIG — dieses Modul darf die Website niemals mitreißen. Es läuft im
selben Prozess wie dogfather-universe.com. Deshalb:
* beim Laden wird KEINE Datenbank geöffnet (siehe `db()` weiter unten),
* jede Route fängt ihre Fehler selbst ab,
* schlägt die Datenbank fehl, antwortet nur /workspace/api/* mit 503 —
die restliche Seite merkt davon nichts.
Sicherheitsentscheidungen und ihre Gründe stehen jeweils direkt an der
betreffenden Stelle.
===================================================================== */
import express from "express";
import {
randomBytes, scryptSync, timingSafeEqual, createHash,
} from "node:crypto";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { mkdirSync } from "node:fs";
import { createRequire } from "node:module";
const __dirname = dirname(fileURLToPath(import.meta.url));
/* `node:sqlite` wird bewusst NICHT oben importiert, sondern erst in db()
nachgeladen. Ein fehlgeschlagener Import an dieser Stelle würde den
gesamten Website-Prozess beim Start abbrechen -- also auch
dogfather-universe.com. So bleibt der Schaden auf /workspace/api/
beschränkt. (Vorhanden ab Node 22; auf dem Server läuft Node 24.) */
const require = createRequire(pathToFileURL(__dirname + "/"));
/* Die Datenbank liegt bewusst AUSSERHALB des Repo-Ordners. Zwei Gründe:
express.static liefert den Repo-Ordner aus (eine .db darin wäre über das
Netz erreichbar), und ein `git pull` darf Nutzdaten nie anfassen. */
const DB_PFAD = process.env.WORKSPACE_DB
|| join(__dirname, "..", "..", "workspace-daten", "workspace.db");
const COOKIE = "dfw_sitzung";
const SITZUNG_STUNDEN = 12;
const VERSUCHE_MAX = 8; // pro IP
const VERSUCHE_FENSTER_MIN = 10;
const ROLLEN = new Set(["admin", "creator", "scout"]);
/* scrypt-Parameter. N=2^15 braucht auf diesem Server rund 150 ms — spürbar
genug, um Rateversuche auszubremsen, und unauffällig für einen echten
Anmeldevorgang. Die Werte werden je Datensatz mitgespeichert, damit sie
später erhöht werden können, ohne alte Codes ungültig zu machen. */
const SCRYPT = { N: 32768, r: 8, p: 1, keylen: 64 };
let _db = null;
let _dbFehler = null;
function db() {
if (_db) return _db;
if (_dbFehler) throw _dbFehler;
try {
const { DatabaseSync } = require("node:sqlite");
mkdirSync(dirname(DB_PFAD), { recursive: true });
const d = new DatabaseSync(DB_PFAD);
d.exec(`
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS personen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
rolle TEXT NOT NULL CHECK (rolle IN ('admin','creator','scout')),
code_hash TEXT NOT NULL,
code_salt TEXT NOT NULL,
code_n INTEGER NOT NULL,
aktiv INTEGER NOT NULL DEFAULT 1,
erstellt TEXT NOT NULL,
letzter_login TEXT
);
CREATE TABLE IF NOT EXISTS sitzungen (
token_hash TEXT PRIMARY KEY,
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
erstellt TEXT NOT NULL,
gueltig_bis TEXT NOT NULL,
ip TEXT,
browser TEXT
);
/* Audit-Log. Im Konzept ausdrücklich gefordert ("Login-Historie,
kritische Aktionen protokollieren"). Nur anhängen, nie ändern. */
CREATE TABLE IF NOT EXISTS protokoll (
id INTEGER PRIMARY KEY AUTOINCREMENT,
zeitpunkt TEXT NOT NULL,
person_id INTEGER,
rolle TEXT,
aktion TEXT NOT NULL,
detail TEXT,
ip TEXT
);
CREATE TABLE IF NOT EXISTS versuche (
ip TEXT NOT NULL,
zeitpunkt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_versuche_ip ON versuche (ip, zeitpunkt);
CREATE INDEX IF NOT EXISTS idx_sitzungen_gueltig ON sitzungen (gueltig_bis);
`);
_db = d;
return d;
} catch (fehler) {
_dbFehler = fehler;
console.error("[workspace] Datenbank nicht verfügbar:", fehler?.message);
throw fehler;
}
}
/* ---------- Hilfsmittel ---------------------------------------------- */
const jetzt = () => new Date().toISOString();
function hashe(code, salt, N) {
return scryptSync(code, salt, SCRYPT.keylen,
{ N, r: SCRYPT.r, p: SCRYPT.p, maxmem: 256 * 1024 * 1024 }).toString("hex");
}
/* Vergleich in konstanter Zeit. Ein normaler ===-Vergleich bricht beim
ersten abweichenden Zeichen ab; aus den Laufzeitunterschieden lässt sich
ein Geheimnis Zeichen für Zeichen erraten. */
function gleich(a, b) {
const x = Buffer.from(String(a), "utf8");
const y = Buffer.from(String(b), "utf8");
if (x.length !== y.length) return false;
return timingSafeEqual(x, y);
}
const tokenHash = (t) => createHash("sha256").update(t).digest("hex");
function protokolliere(aktion, { personId = null, rolle = null, detail = null, ip = null } = {}) {
try {
db().prepare(
"INSERT INTO protokoll (zeitpunkt, person_id, rolle, aktion, detail, ip) VALUES (?,?,?,?,?,?)"
).run(jetzt(), personId, rolle, aktion, detail, ip);
} catch { /* Ein fehlgeschlagener Protokolleintrag darf nichts blockieren. */ }
}
function zuVieleVersuche(ip) {
const grenze = new Date(Date.now() - VERSUCHE_FENSTER_MIN * 60_000).toISOString();
db().prepare("DELETE FROM versuche WHERE zeitpunkt < ?").run(grenze);
const { anzahl } = db()
.prepare("SELECT COUNT(*) AS anzahl FROM versuche WHERE ip = ? AND zeitpunkt >= ?")
.get(ip, grenze);
return anzahl >= VERSUCHE_MAX;
}
/* ---------- Sitzung ---------------------------------------------------- */
export function sitzungLesen(req) {
const token = req.cookies?.[COOKIE];
if (!token) return null;
try {
const reihe = db().prepare(`
SELECT s.gueltig_bis, p.id, p.name, p.rolle, p.aktiv
FROM sitzungen s JOIN personen p ON p.id = s.person_id
WHERE s.token_hash = ?`).get(tokenHash(token));
if (!reihe || !reihe.aktiv) return null;
if (reihe.gueltig_bis < jetzt()) {
db().prepare("DELETE FROM sitzungen WHERE token_hash = ?").run(tokenHash(token));
return null;
}
return { id: reihe.id, name: reihe.name, rolle: reihe.rolle };
} catch { return null; }
}
function sitzungSetzen(res, person, req) {
const token = randomBytes(32).toString("hex");
const bis = new Date(Date.now() + SITZUNG_STUNDEN * 3600_000).toISOString();
db().prepare(
"INSERT INTO sitzungen (token_hash, person_id, erstellt, gueltig_bis, ip, browser) VALUES (?,?,?,?,?,?)"
).run(tokenHash(token), person.id, jetzt(), bis, req.ip || null,
(req.get("user-agent") || "").slice(0, 200));
res.cookie(COOKIE, token, {
httpOnly: true, // kein Zugriff aus JavaScript -> kein Diebstahl per XSS
/* Live immer true: Hinter Caddy ist req.secure dank `trust proxy` das
Ergebnis von X-Forwarded-Proto. Nur beim lokalen Test über http
fällt es weg -- sonst wäre der Ablauf gar nicht prüfbar, weil
Browser und curl ein Secure-Cookie über http verwerfen. */
secure: req.secure,
sameSite: "lax", // schützt vor fremden Formularen (CSRF)
path: "/workspace", // gilt nur hier, nicht auf der ganzen Domain
maxAge: SITZUNG_STUNDEN * 3600_000,
});
}
/* ---------- Router ----------------------------------------------------- */
export const workspaceRouter = express.Router();
/* Schutz der angemeldeten Seiten. Serverseitig, nicht nur im Browser --
sonst könnte man die Seite einfach direkt aufrufen. */
const GESCHUETZT = ["/workspace/start.html"];
workspaceRouter.use((req, res, next) => {
if (!GESCHUETZT.includes(req.path)) return next();
const person = sitzungLesen(req);
if (person) return next();
return res.redirect(302, "/workspace/");
});
workspaceRouter.post("/workspace/api/anmelden", (req, res) => {
const ip = req.ip || "?";
try {
if (zuVieleVersuche(ip)) {
protokolliere("anmeldung_gesperrt", { ip });
return res.status(429).json({ fehler: "zu_viele_versuche" });
}
const rolle = String(req.body?.rolle || "");
const code = String(req.body?.code || "");
if (!ROLLEN.has(rolle) || code.length < 4 || code.length > 200) {
db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt());
return res.status(401).json({ fehler: "ungueltig" });
}
/* Alle aktiven Personen dieser Rolle durchgehen. Es gibt bewusst KEIN
Benutzerfeld: Der Code allein identifiziert die Person. Bei der
erwarteten Größenordnung (eine Handvoll Personen je Rolle) ist das
unkritisch; ab etwa 50 Codes je Rolle sollte hier ein Präfix im Code
die Vorauswahl übernehmen, sonst wird die Anmeldung spürbar langsam. */
const kandidaten = db()
.prepare("SELECT id, name, rolle, code_hash, code_salt, code_n FROM personen WHERE rolle = ? AND aktiv = 1")
.all(rolle);
let gefunden = null;
for (const k of kandidaten) {
if (gleich(hashe(code, k.code_salt, k.code_n), k.code_hash)) { gefunden = k; break; }
}
if (!gefunden) {
db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt());
protokolliere("anmeldung_fehlgeschlagen", { rolle, ip });
/* Bewusst dieselbe Antwort wie bei falscher Rolle: Wer raten will,
soll nicht erfahren, ob wenigstens die Rolle gestimmt hat. */
return res.status(401).json({ fehler: "ungueltig" });
}
db().prepare("DELETE FROM versuche WHERE ip = ?").run(ip);
db().prepare("UPDATE personen SET letzter_login = ? WHERE id = ?").run(jetzt(), gefunden.id);
sitzungSetzen(res, gefunden, req);
protokolliere("anmeldung", { personId: gefunden.id, rolle, ip, detail: gefunden.name });
return res.json({ weiter: "/workspace/start.html", name: gefunden.name, rolle });
} catch (fehler) {
console.error("[workspace] Anmeldung fehlgeschlagen:", fehler?.message);
return res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
workspaceRouter.post("/workspace/api/abmelden", (req, res) => {
try {
const token = req.cookies?.[COOKIE];
if (token) {
const person = sitzungLesen(req);
db().prepare("DELETE FROM sitzungen WHERE token_hash = ?").run(tokenHash(token));
if (person) protokolliere("abmeldung", { personId: person.id, rolle: person.rolle, ip: req.ip });
}
} catch { /* Abmelden darf nie scheitern. */ }
res.clearCookie(COOKIE, { path: "/workspace" });
res.json({ ok: true });
});
workspaceRouter.get("/workspace/api/ich", (req, res) => {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
res.json({ name: person.name, rolle: person.rolle });
});
/* ---------- Verwaltung (nur über die Kommandozeile) --------------------
Wird von workspace-code.js benutzt. Bewusst nicht über das Netz
erreichbar: Codes werden auf dem Server erzeugt, einmal angezeigt und
nie gespeichert -- weder in Git noch in einer Notiz. */
/* Alphabet ohne 0/O und 1/I/l: Diese Codes werden abgetippt und
weitergegeben, Verwechslungen kosten sonst unnötig Nerven. */
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
export function codeErzeugen(gruppen = 4, laenge = 4) {
const roh = randomBytes(gruppen * laenge);
let aus = "";
for (let i = 0; i < gruppen * laenge; i++) {
if (i && i % laenge === 0) aus += "-";
aus += ALPHABET[roh[i] % ALPHABET.length];
}
return aus;
}
export function personAnlegen(name, rolle) {
if (!ROLLEN.has(rolle)) throw new Error(`Unbekannte Rolle: ${rolle}`);
const code = codeErzeugen();
const salt = randomBytes(16).toString("hex");
const hash = hashe(code, salt, SCRYPT.N);
const { lastInsertRowid } = db().prepare(
"INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)"
).run(name, rolle, hash, salt, SCRYPT.N, jetzt());
protokolliere("person_angelegt", { personId: Number(lastInsertRowid), rolle, detail: name });
return { id: Number(lastInsertRowid), name, rolle, code };
}
export function codeNeu(id) {
const person = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(id);
if (!person) throw new Error(`Keine Person mit Nummer ${id}`);
const code = codeErzeugen();
const salt = randomBytes(16).toString("hex");
db().prepare("UPDATE personen SET code_hash = ?, code_salt = ?, code_n = ? WHERE id = ?")
.run(hashe(code, salt, SCRYPT.N), salt, SCRYPT.N, id);
/* Alle offenen Sitzungen beenden -- ein neuer Code soll den alten Zugang
wirklich beenden, nicht nur die nächste Anmeldung betreffen. */
db().prepare("DELETE FROM sitzungen WHERE person_id = ?").run(id);
protokolliere("code_erneuert", { personId: id, rolle: person.rolle, detail: person.name });
return { ...person, code };
}
export function personSperren(id, aktiv = 0) {
db().prepare("UPDATE personen SET aktiv = ? WHERE id = ?").run(aktiv ? 1 : 0, id);
if (!aktiv) db().prepare("DELETE FROM sitzungen WHERE person_id = ?").run(id);
protokolliere(aktiv ? "person_entsperrt" : "person_gesperrt", { personId: id });
}
export function personenListe() {
return db().prepare(`
SELECT p.id, p.name, p.rolle, p.aktiv, p.erstellt, p.letzter_login,
(SELECT COUNT(*) FROM sitzungen s WHERE s.person_id = p.id) AS sitzungen
FROM personen p ORDER BY p.rolle, p.name`).all();
}
export function protokollLesen(anzahl = 20) {
return db().prepare(
"SELECT zeitpunkt, rolle, aktion, detail, ip FROM protokoll ORDER BY id DESC LIMIT ?"
).all(anzahl);
}
+102
View File
@@ -0,0 +1,102 @@
/* ===================================================================
Startseite nach dem Anmelden. Baut auf den Farben und Bausteinen von
gate.css auf, damit beides wie eine Anwendung wirkt und nicht wie zwei
verschiedene Seiten.
=================================================================== */
/* Grundschrift und Grundfarbe stehen in gate.css auf `body.gate` und
greifen hier deshalb NICHT. Ohne diese Wiederholung fällt die Seite auf
die Browservorgaben zurück: schwarze Serifenschrift auf dunklem Grund,
praktisch unlesbar. */
body.start {
display: block;
min-height: 100svh;
margin: 0;
padding: 0;
color: var(--text);
font-family: "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
font-size: 16px;
line-height: 1.55;
letter-spacing: .01em;
background:
radial-gradient(120% 80% at 12% -10%, rgba(63, 189, 245, .10), transparent 58%),
radial-gradient(100% 70% at 92% 8%, rgba(138, 118, 255, .10), transparent 60%),
var(--tinte);
}
/* ---------- Kopfleiste ------------------------------------------------ */
.kopfleiste {
position: sticky; top: 0; z-index: 5;
display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 14px clamp(16px, 4vw, 40px);
background: rgba(6, 9, 16, .82);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border-bottom: 1px solid var(--rand);
}
.kopfleiste .marke { margin: 0; font-size: .72rem; }
.kopfleiste__rechts { display: flex; align-items: center; gap: 12px; }
.wer {
font-size: .86rem; color: var(--text-leise);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 46vw;
}
.abmelden {
padding: 7px 14px;
background: rgba(255, 255, 255, .045);
border: 1px solid var(--rand);
border-radius: 9px;
color: var(--text); font: inherit; font-size: .84rem; cursor: pointer;
transition: border-color var(--tempo), background var(--tempo);
}
.abmelden:hover { background: rgba(255, 255, 255, .08); border-color: var(--rand-hell); }
/* ---------- Inhalt ---------------------------------------------------- */
.inhalt {
max-width: 880px;
margin: 0 auto;
padding: clamp(28px, 5vw, 56px) clamp(16px, 4vw, 40px) 64px;
}
.willkommen { margin-bottom: 38px; }
.willkommen .titel { margin-bottom: 6px; }
.bereiche {
list-style: none; margin: 0; padding: 0;
display: grid; gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(238px, 1fr));
}
.bereich {
display: flex; align-items: baseline; gap: 10px;
padding: 13px 15px;
background: rgba(255, 255, 255, .03);
border: 1px solid var(--rand);
border-radius: var(--radius-klein);
}
.bereich__name { font-weight: 600; font-size: .95rem; }
.bereich__stand {
margin-left: auto;
font-size: .72rem; letter-spacing: .04em; text-transform: uppercase;
color: var(--text-still);
white-space: nowrap;
}
/* Phase 1 ist in Arbeit - das wird hervorgehoben, der Rest bleibt ruhig. */
.bereich[data-phase="1"] { border-color: rgba(63, 189, 245, .34); }
.bereich[data-phase="1"] .bereich__stand { color: var(--akzent); }
.inhalt .fuss { margin-top: 40px; text-align: left; }
@media (max-width: 520px) {
.kopfleiste { padding: 12px 16px; }
.kopfleiste .marke { font-size: .64rem; }
.wer { max-width: 34vw; }
}
+1 -1
View File
@@ -94,7 +94,7 @@
laden(true);
try {
const antwort = await fetch('/api/anmelden', {
const antwort = await fetch('/workspace/api/anmelden', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rolle: gewaehlt, code: wert }),
+80
View File
@@ -0,0 +1,80 @@
/* ===================================================================
Startseite nach dem Anmelden.
Die Seite ist bereits serverseitig geschützt (workspace.js). Dieses
Skript holt nur noch, WER angemeldet ist, und regelt das Abmelden.
=================================================================== */
(() => {
'use strict';
const ROLLENTEXT = {
admin: 'Management · Gesamtübersicht und Freigaben',
creator: 'Creator · dein eigener Bereich',
scout: 'Scout · deine Pipeline und Kontakte',
};
/* Bereiche aus dem Konzept, mit der Phase aus dem Umsetzungsplan.
`rollen` steuert, wem ein Bereich überhaupt angezeigt wird - schon
jetzt, damit die Trennung von Anfang an sichtbar ist. */
const BEREICHE = [
{ name: 'Dashboard', phase: 1, rollen: ['admin', 'creator', 'scout'] },
{ name: 'Creator-Profile', phase: 1, rollen: ['admin'] },
{ name: 'Mein Profil', phase: 1, rollen: ['creator'] },
{ name: 'Aufgaben', phase: 1, rollen: ['admin', 'creator'] },
{ name: 'Kalender', phase: 1, rollen: ['admin', 'creator', 'scout'] },
{ name: 'Dateien', phase: 1, rollen: ['admin', 'creator'] },
{ name: 'LIVE-Analyse', phase: 2, rollen: ['admin', 'creator'] },
{ name: 'Content-Planung', phase: 2, rollen: ['admin', 'creator'] },
{ name: 'Technik', phase: 2, rollen: ['admin', 'creator'] },
{ name: 'Community', phase: 2, rollen: ['admin', 'creator'] },
{ name: 'Schutz & Regeln', phase: 2, rollen: ['admin', 'creator'] },
{ name: 'Reports & Review', phase: 2, rollen: ['admin', 'creator'] },
{ name: 'Calls', phase: 3, rollen: ['admin', 'creator', 'scout'] },
{ name: 'Scout-Pipeline', phase: 3, rollen: ['admin', 'scout'] },
{ name: 'Automationen', phase: 4, rollen: ['admin'] },
];
const $ = (id) => document.getElementById(id);
async function laden() {
let ich;
try {
const antwort = await fetch('/workspace/api/ich', { credentials: 'same-origin' });
if (!antwort.ok) { location.assign('/workspace/'); return; }
ich = await antwort.json();
} catch {
location.assign('/workspace/');
return;
}
$('wer').textContent = `${ich.name} · ${ich.rolle}`;
$('gruss').textContent = `Willkommen, ${ich.name}`;
$('rollentext').textContent = ROLLENTEXT[ich.rolle] || ich.rolle;
const liste = $('bereiche');
for (const b of BEREICHE.filter((b) => b.rollen.includes(ich.rolle))) {
const li = document.createElement('li');
li.className = 'bereich';
li.dataset.phase = String(b.phase);
const name = document.createElement('span');
name.className = 'bereich__name';
name.textContent = b.name;
const stand = document.createElement('span');
stand.className = 'bereich__stand';
stand.textContent = b.phase === 1 ? 'als Nächstes' : `Phase ${b.phase}`;
li.append(name, stand);
liste.append(li);
}
}
$('abmelden').addEventListener('click', async () => {
try {
await fetch('/workspace/api/abmelden', { method: 'POST', credentials: 'same-origin' });
} catch { /* Auch ohne Antwort weiterleiten - das Cookie ist dann spätestens abgelaufen. */ }
location.assign('/workspace/');
});
laden();
})();
+46
View File
@@ -0,0 +1,46 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Dogfather Universe · Creator Workspace</title>
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#05070d" />
<link rel="icon" href="assets/img/favicon.png" />
<link rel="stylesheet" href="assets/css/gate.css" />
<link rel="stylesheet" href="assets/css/start.css" />
</head>
<body class="start">
<header class="kopfleiste">
<p class="marke">Dogfather&nbsp;Universe · Creator&nbsp;Workspace</p>
<div class="kopfleiste__rechts">
<span class="wer" id="wer">…</span>
<button type="button" class="abmelden" id="abmelden">Abmelden</button>
</div>
</header>
<main class="inhalt">
<section class="willkommen">
<p class="marke">Angemeldet</p>
<h1 class="titel" id="gruss">Willkommen</h1>
<p class="unterzeile" id="rollentext">…</p>
</section>
<!-- Die Bereiche aus dem Konzept. Noch keiner ist gebaut - das steht hier
ehrlich dran, statt Kacheln anzubieten, die ins Leere führen. -->
<section>
<p class="feldschild">Bereiche aus dem Konzept</p>
<ul class="bereiche" id="bereiche"></ul>
</section>
<p class="fuss">
Anmeldung, Sitzung und Protokoll funktionieren bereits.
Die Bereiche werden nach dem Phasenplan gebaut.
</p>
</main>
<script src="assets/js/start.js" defer></script>
</body>
</html>