From f3a55af80f616d6b323c6fb8a6a230807cf3a4ff Mon Sep 17 00:00:00 2001 From: Dogfather Date: Thu, 27 Aug 2026 22:35:55 +0200 Subject: [PATCH] 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. --- server/index.js | 11 ++ server/workspace-code.js | 104 ++++++++++ server/workspace.js | 344 +++++++++++++++++++++++++++++++++ workspace/assets/css/start.css | 102 ++++++++++ workspace/assets/js/gate.js | 2 +- workspace/assets/js/start.js | 80 ++++++++ workspace/start.html | 46 +++++ 7 files changed, 688 insertions(+), 1 deletion(-) create mode 100644 server/workspace-code.js create mode 100644 server/workspace.js create mode 100644 workspace/assets/css/start.css create mode 100644 workspace/assets/js/start.js create mode 100644 workspace/start.html diff --git a/server/index.js b/server/index.js index d5dca11a..7e42cc9a 100644 --- a/server/index.js +++ b/server/index.js @@ -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- diff --git a/server/workspace-code.js b/server/workspace-code.js new file mode 100644 index 00000000..9c29f710 --- /dev/null +++ b/server/workspace-code.js @@ -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" '); + zeigeCode(personAnlegen(name, rolle)); + break; + } + + case "neu": { + const id = Number(rest[0]); + if (!id) throw new Error("Aufruf: neu "); + 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} `); + 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" Person anlegen, Code wird einmal angezeigt + neu neuen Code erzeugen (beendet alte Sitzungen) + sperren / entsperren 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); +} diff --git a/server/workspace.js b/server/workspace.js new file mode 100644 index 00000000..17127a45 --- /dev/null +++ b/server/workspace.js @@ -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); +} diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css new file mode 100644 index 00000000..297501ad --- /dev/null +++ b/workspace/assets/css/start.css @@ -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; } +} diff --git a/workspace/assets/js/gate.js b/workspace/assets/js/gate.js index a7dcb74c..82a2724e 100644 --- a/workspace/assets/js/gate.js +++ b/workspace/assets/js/gate.js @@ -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 }), diff --git a/workspace/assets/js/start.js b/workspace/assets/js/start.js new file mode 100644 index 00000000..67880673 --- /dev/null +++ b/workspace/assets/js/start.js @@ -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(); +})(); diff --git a/workspace/start.html b/workspace/start.html new file mode 100644 index 00000000..86d21232 --- /dev/null +++ b/workspace/start.html @@ -0,0 +1,46 @@ + + + + + +Dogfather Universe · Creator Workspace + + + + + + + + + +
+

Dogfather Universe · Creator Workspace

+
+ … + +
+
+ +
+
+

Angemeldet

+

Willkommen

+

…

+
+ + +
+

Bereiche aus dem Konzept

+
    +
    + +

    + Anmeldung, Sitzung und Protokoll funktionieren bereits. + Die Bereiche werden nach dem Phasenplan gebaut. +

    +
    + + + +