Files
ProxLedger/scripts/screenshots.mjs
T
florian.wildeandClaude Opus 5 2f45e33e20 feat(auth): Rollen, Benutzerverwaltung und Passkeys (#027, #028)
Drei Rollen: Administrator, Techniker, Kunde. Jede der 28 Aktionen nennt das
Recht, das sie verlangt; die Rechte stehen an einer Stelle. Kundenkonten
haengen an genau einem Kunden und sehen nur dessen Hosts und Rechnungen -
ohne Einkaufspreise und Marge. Die Einschraenkung sitzt in den Abfragen
selbst, damit keine Seite vergessen kann, sie weiterzugeben; sechs Tests
pruefen das.

Dabei gefunden: Neu ueber OIDC angelegte Konten haetten die Vorgabe des
Datenmodells geerbt - ADMIN. Jetzt gibt es dafuer eine Einstellung mit der
Vorgabe Techniker.

Passkeys (#028) als gleichwertiger, passwortloser Anmeldeweg neben Passwort
und OIDC. Mehrere je Konto, Aufgabe in einem kurzlebigen Cookie, rpID aus der
oeffentlichen Adresse. Ohne HTTPS sagt die Seite es, statt still zu scheitern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 00:46:29 +02:00

223 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Erzeugt die Bilder für das Handbuch (docs/anleitung/bilder/).
*
* npm run screenshots # heller Modus, Desktop
* npm run screenshots -- --alle # zusätzlich dunkler Modus und Mobilansicht
* npm run screenshots -- --seite name=/pfad # zusätzlich eine bestimmte Seite
*
* Voraussetzung: Das Dashboard läuft (npm start) und die Datenbank enthält Daten.
* Die Anmeldung erledigt das Skript selbst, indem es ein Sitzungs-Cookie erzeugt
* und in ein eigenes Firefox-Profil schreibt – so bleibt der Schutz der Anwendung unangetastet.
*/
import { spawn } from "node:child_process";
import { createServer } from "node:http";
import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import { request as httpAnfrage } from "node:http";
import { SignJWT } from "jose";
import pg from "pg";
import "dotenv/config";
const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
const ALLE = process.argv.includes("--alle");
/** Zusätzliche Seiten von der Befehlszeile: --seite name=/pfad */
const ZUSATZ = process.argv
.filter((wert, i) => process.argv[i - 1] === "--seite")
.map((eintrag) => {
const [name, ...rest] = eintrag.split("=");
return { name, pfad: rest.join("="), titel: "zusätzlich" };
});
// Firefox aus dem Snap-Paket kommt nicht an versteckte Verzeichnisse – daher im Home sichtbar ablegen.
const PROFIL_BASIS = path.join(homedir(), "proxledger-screenshots");
const SEITEN = [
{ name: "dashboard", pfad: "/", titel: "Dashboard" },
{ name: "hosts", pfad: "/hosts", titel: "Host-Tabelle" },
{ name: "host-detail", pfad: "/hosts?q=pve-brandl-01", titel: "Host-Suche" },
{ name: "kunden", pfad: "/kunden", titel: "Kundenübersicht" },
{ name: "nicht-zugeordnet", pfad: "/hosts/nicht-zugeordnet", titel: "Nicht zugeordnete Hosts" },
{ name: "rechnungen", pfad: "/rechnungen", titel: "Rechnungen" },
{ name: "jahresbahn", pfad: "/jahresbahn", titel: "Jahresbahn" },
{ name: "warnungen", pfad: "/warnungen", titel: "Warnungen" },
{ name: "warnregeln", pfad: "/einstellungen/warnregeln", titel: "Warnregeln" },
{ name: "tokens", pfad: "/einstellungen/tokens", titel: "Agent-Tokens" },
{ name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" },
{ name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" },
{ name: "system", pfad: "/einstellungen/system", titel: "Systemeinstellungen" },
{ name: "benutzer", pfad: "/einstellungen/benutzer", titel: "Benutzerverwaltung" },
{ name: "passkeys", pfad: "/einstellungen/passkeys", titel: "Passkeys" },
];
async function sitzungsToken() {
const geheimnis = process.env.APP_SECRET;
if (!geheimnis || geheimnis.length < 32) {
throw new Error("APP_SECRET fehlt oder ist zu kurz – ohne Sitzung keine Bilder.");
}
// Seit #027 schlägt die Sitzungsprüfung den Benutzer in der Datenbank nach: Ein erfundenes
// Konto landet wieder auf der Anmeldeseite. Deshalb hier ein echtes Administratorkonto nehmen.
// Kein Prisma-Client hier: Das Skript läuft als reines JavaScript, der erzeugte Client ist TypeScript.
const datenbank = new pg.Client({ connectionString: process.env.DATABASE_URL });
await datenbank.connect();
const { rows } = await datenbank.query(
`SELECT id, email, name FROM "Benutzer" WHERE rolle = 'ADMIN' AND aktiv = true ORDER BY "erstelltAm" LIMIT 1`,
);
await datenbank.end();
const benutzer = rows[0];
if (!benutzer) {
throw new Error("Kein aktiver Administrator in der Datenbank – erst `npm run db:seed` ausführen.");
}
return new SignJWT({
benutzerId: benutzer.id,
email: benutzer.email,
name: benutzer.name,
herkunft: "passwort",
})
.setProtectedHeader({ alg: "HS256" })
.setSubject(benutzer.id)
.setIssuedAt()
.setIssuer("proxledger")
.setExpirationTime("1h")
.sign(new TextEncoder().encode(geheimnis));
}
/**
* Kleiner Vermittler vor dem Dashboard: Er reicht jede Anfrage weiter und hängt die Sitzung
* als Kopfzeile an. Damit braucht der Browser kein Cookie – das Einschleusen in das Firefox-Profil
* erwies sich als unzuverlässig (Firefox verwirft fremd erzeugte Cookie-Dateien stillschweigend,
* die Aufnahmen zeigten dann kommentarlos die Anmeldeseite).
*/
function vermittlerStarten(token) {
const ziel = new URL(BASIS);
const server = createServer((anfrage, antwort) => {
const weiterleitung = {
hostname: ziel.hostname,
port: ziel.port || 80,
path: anfrage.url,
method: anfrage.method,
headers: { ...anfrage.headers, host: ziel.host, cookie: `pl_sitzung=${token}` },
};
const verbindung = httpAnfrage(weiterleitung, (antwortVomServer) => {
antwort.writeHead(antwortVomServer.statusCode ?? 502, antwortVomServer.headers);
antwortVomServer.pipe(antwort);
});
verbindung.on("error", () => antwort.writeHead(502).end("Dashboard nicht erreichbar"));
anfrage.pipe(verbindung);
});
return new Promise((fertig) => {
server.listen(0, "127.0.0.1", () => fertig({ server, port: server.address().port }));
});
}
/** Legt ein Profil mit dem gewünschten Farbschema an. */
function profilVorbereiten(name, dunkel) {
const verzeichnis = path.join(PROFIL_BASIS, name);
rmSync(verzeichnis, { recursive: true, force: true });
mkdirSync(verzeichnis, { recursive: true });
writeFileSync(
path.join(verzeichnis, "user.js"),
[
`user_pref("ui.systemUsesDarkTheme", ${dunkel ? 1 : 0});`,
`user_pref("layout.css.prefers-color-scheme.content-override", ${dunkel ? 0 : 1});`,
'user_pref("browser.shell.checkDefaultBrowser", false);',
"",
].join("\n"),
);
return verzeichnis;
}
/**
* Nimmt eine Seite auf. Bewusst asynchron: Der Vermittler läuft im selben Prozess, und ein
* blockierender Aufruf würde ihn währenddessen stumm schalten – Firefox liefe in die Zeitüberschreitung.
*/
function aufnehmen(profil, pfad, datei, breite, hoehe, adresse) {
return new Promise((fertig, fehlgeschlagen) => {
const vorgang = spawn(
"firefox",
[
"--profile",
profil,
"--headless",
"--window-size",
`${breite},${hoehe}`,
"--screenshot",
datei,
`${adresse}${pfad}`,
],
{ stdio: "ignore", timeout: 120_000 },
);
vorgang.on("error", fehlgeschlagen);
vorgang.on("close", () => fertig());
});
}
async function main() {
const antwort = await fetch(`${BASIS}/login`).catch(() => null);
if (!antwort?.ok) throw new Error(`${BASIS} ist nicht erreichbar – läuft das Dashboard?`);
mkdirSync(ZIEL, { recursive: true });
const token = await sitzungsToken();
const { server, port } = await vermittlerStarten(token);
const adresse = `http://127.0.0.1:${port}`;
const hell = profilVorbereiten("hell", false);
console.log(`Bilder nach ${ZIEL}`);
// Die Anmeldeseite zeigt sich auch mit gültiger Sitzung, sie ist öffentlich.
await aufnehmen(hell, "/login", path.join(ZIEL, "login.png"), 1280, 900, BASIS);
console.log(" login.png");
for (const seite of [...SEITEN, ...ZUSATZ]) {
await aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 1680, 1150, adresse);
console.log(` ${seite.name}.png – ${seite.titel}`);
}
if (ALLE) {
const dunkel = profilVorbereiten("dunkel", true);
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
const eintrag = SEITEN.find((s) => s.name === seite);
await aufnehmen(dunkel, eintrag.pfad, path.join(ZIEL, `${seite}-dunkel.png`), 1680, 1150, adresse);
console.log(` ${seite}-dunkel.png`);
}
for (const seite of ["dashboard", "hosts"]) {
const eintrag = SEITEN.find((s) => s.name === seite);
await aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500, adresse);
console.log(` mobil-${seite}.png`);
}
}
server.close();
if (existsSync(PROFIL_BASIS)) rmSync(PROFIL_BASIS, { recursive: true, force: true });
// Sicherung gegen stillen Fehlschlag: Alle Aufnahmen gleich groß heißt, jede zeigt die Anmeldeseite.
const groessen = SEITEN.map((seite) => statSync(path.join(ZIEL, `${seite.name}.png`)).size);
if (new Set(groessen).size === 1) {
throw new Error(
"Alle Aufnahmen sind gleich groß – vermutlich zeigt jede die Anmeldeseite. Sitzung prüfen.",
);
}
console.log("Fertig.");
}
main().catch((fehler) => {
console.error(fehler.message ?? fehler);
process.exit(1);
});