feat(einstellungen): Betriebswerte im Dashboard einstellbar (#025)

Vier Bereiche unter Einstellungen -> System: Adresse und Agent,
Kaufmaennisches, Sicherheit sowie Firma und Darstellung. Alles liegt als
eine JSON-Zeile in der Tabelle Einstellung; fehlende Felder bekommen ihren
Vorgabewert, eine kaputte Zeile legt den Betrieb nicht lahm.

Die Werte wirken auch wirklich: Die Dashboard-Adresse und der Melderhythmus
landen im ausgelieferten Einzeiler und im systemd-Timer (live geprueft),
der Steuersatz in neuen Rechnungspositionen, die Sitzungsdauer im Token,
die Firmenangaben im Kopf der PDF-Dateien.

Nebenbei geschlossen: die OIDC-Luecke aus #024. Erlaubte Domaenen und ein
Schalter gegen automatisch angelegte Konten. Dazu raeumt der Worker nachts
nach den eingestellten Fristen auf - der juengste Report je Host bleibt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-18 23:58:30 +02:00
co-authored by Claude Opus 5
parent 19b0751c16
commit d416f3869a
38 changed files with 1014 additions and 60 deletions
+75
View File
@@ -0,0 +1,75 @@
"use server";
import { revalidatePath } from "next/cache";
import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { einstellungen, einstellungenSchema, einstellungenSpeichern } from "@/server/einstellungen";
export interface AktionsErgebnis {
fehler?: string;
felder?: Record<string, string>;
erfolg?: string;
}
const zahl = (formular: FormData, feld: string) => {
const roh = formular.get(feld)?.toString().replace(",", ".").trim();
return roh === undefined || roh === "" ? undefined : Number(roh);
};
const text = (formular: FormData, feld: string) => formular.get(feld)?.toString().trim() ?? "";
const schalter = (formular: FormData, feld: string) => formular.get(feld) === "an";
export async function einstellungenSpeichernAktion(
_zustand: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
await sitzungErforderlich();
const vorher = await einstellungen();
const ergebnis = einstellungenSchema.safeParse({
dashboardUrl: text(formular, "dashboardUrl"),
agentRhythmus: text(formular, "agentRhythmus") || vorher.agentRhythmus,
agentStreuungStunden: zahl(formular, "agentStreuungStunden"),
agentAutoUpdate: schalter(formular, "agentAutoUpdate"),
waehrung: text(formular, "waehrung") || vorher.waehrung,
steuersatz: zahl(formular, "steuersatz"),
zahlungszielTage: zahl(formular, "zahlungszielTage"),
margeProzent: zahl(formular, "margeProzent"),
sitzungsdauerStunden: zahl(formular, "sitzungsdauerStunden"),
anmeldeversuche: zahl(formular, "anmeldeversuche"),
oidcDomaenen: text(formular, "oidcDomaenen"),
oidcKontenAnlegen: schalter(formular, "oidcKontenAnlegen"),
aufbewahrungReportsTage: zahl(formular, "aufbewahrungReportsTage"),
aufbewahrungAuditTage: zahl(formular, "aufbewahrungAuditTage"),
firmaName: text(formular, "firmaName"),
firmaAnschrift: text(formular, "firmaAnschrift"),
firmaEmail: text(formular, "firmaEmail"),
zeitzone: text(formular, "zeitzone") || vorher.zeitzone,
});
if (!ergebnis.success) {
const felder: Record<string, string> = {};
for (const fehler of ergebnis.error.issues) {
const feld = fehler.path[0];
if (typeof feld === "string" && !felder[feld]) felder[feld] = fehler.message;
}
return { fehler: "Bitte die markierten Felder prüfen.", felder };
}
await einstellungenSpeichern(ergebnis.data);
// Nur die geänderten Felder ins Protokoll – sonst steht bei jedem Speichern alles darin.
const geaendert = Object.fromEntries(
Object.entries(ergebnis.data).filter(
([feld, wert]) => wert !== vorher[feld as keyof typeof vorher],
),
);
await protokolliere("einstellungen.gespeichert", "Einstellung", "system", geaendert);
revalidatePath("/einstellungen/system");
return { erfolg: "Einstellungen gespeichert." };
}
+2 -2
View File
@@ -106,7 +106,7 @@ export async function proxmoxRechnungSpeichern(
datum: rechnungsdatum,
faelligAm: datum(formular.get("faelligAm")),
nettoBetrag: netto,
ustSatz: betrag(formular.get("ustSatz")) ?? 19,
ustSatz: betrag(formular.get("ustSatz")) ?? undefined,
rabatt: betrag(formular.get("rabatt")),
zahlungsart: formular.get("zahlungsart")?.toString().trim() || null,
bezahlt: formular.get("bezahlt") === "on",
@@ -139,7 +139,7 @@ export async function kundenrechnungSpeichern(
nummer: formular.get("nummer")?.toString().trim() || null,
datum: datum(formular.get("datum")),
nettoBetrag: betrag(formular.get("nettoBetrag")),
ustSatz: betrag(formular.get("ustSatz")) ?? 19,
ustSatz: betrag(formular.get("ustSatz")) ?? undefined,
bezahlt: formular.get("bezahlt") === "on",
bezahltAm: datum(formular.get("bezahltAm")),
anhangId,
+17 -6
View File
@@ -1,21 +1,32 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { einstellungen, onCalendar } from "@/server/einstellungen";
import { AGENT_VERSION } from "./http";
/**
* Liefert die Agent-Skripte aus dem Verzeichnis `agent/` aus und setzt dabei
* Dashboard-Adresse und Agent-Version ein.
* Liefert die Agent-Skripte aus dem Verzeichnis `agent/` aus und setzt dabei Dashboard-Adresse,
* Agent-Version und die Vorgaben aus *Einstellungen → System* ein (Melderhythmus, Streuung,
* Selbstaktualisierung).
*/
export async function skriptLaden(datei: "install.sh" | "proxledger-agent.sh", basisUrl: string) {
const pfad = path.join(process.cwd(), "agent", datei);
const inhalt = await readFile(pfad, "utf8");
const [inhalt, werte] = await Promise.all([readFile(pfad, "utf8"), einstellungen()]);
return inhalt
.replaceAll("@@DASHBOARD_URL@@", basisUrl.replace(/\/$/, ""))
.replaceAll("@@AGENT_VERSION@@", AGENT_VERSION);
.replaceAll("@@AGENT_VERSION@@", AGENT_VERSION)
.replaceAll("@@TIMER_ONCALENDAR@@", onCalendar(werte.agentRhythmus))
.replaceAll("@@TIMER_STREUUNG@@", `${werte.agentStreuungStunden}h`)
.replaceAll("@@AUTO_UPDATE@@", werte.agentAutoUpdate ? "yes" : "no");
}
/** Bevorzugt die konfigurierte Adresse; sonst die Herkunft der Anfrage. */
export function basisUrl(request: Request): string {
/**
* Bevorzugt die im Dashboard eingetragene Adresse, dann `APP_URL`, sonst die Herkunft der Anfrage.
* Die Reihenfolge ist wichtig: Hinter einem Reverse Proxy stimmt die Herkunft oft nicht.
*/
export async function basisUrl(request: Request): Promise<string> {
const werte = await einstellungen();
if (werte.dashboardUrl) return werte.dashboardUrl;
if (process.env.APP_URL) return process.env.APP_URL;
return new URL(request.url).origin;
}
+50
View File
@@ -0,0 +1,50 @@
import { prisma } from "@/lib/db";
import { einstellungen } from "@/server/einstellungen";
/**
* Löscht alte Reports und Protokolleinträge nach den eingestellten Fristen (#025).
*
* Der jüngste Report jedes Hosts bleibt **immer** erhalten – sonst stünde ein Host, der lange
* nicht gemeldet hat, plötzlich ohne jede Angabe da. Eine Frist von 0 heißt: nichts löschen.
*/
export async function altdatenLoeschen(jetzt: Date = new Date()): Promise<{
reports: number;
protokoll: number;
}> {
const { aufbewahrungReportsTage, aufbewahrungAuditTage } = await einstellungen();
let reports = 0;
if (aufbewahrungReportsTage > 0) {
const grenze = new Date(jetzt.getTime() - aufbewahrungReportsTage * 86_400_000);
const neueste = await prisma.report.groupBy({
by: ["hostId"],
_max: { erhobenAm: true },
});
const behalten = await prisma.report.findMany({
where: {
OR: neueste
.filter((eintrag) => eintrag._max.erhobenAm !== null)
.map((eintrag) => ({ hostId: eintrag.hostId, erhobenAm: eintrag._max.erhobenAm as Date })),
},
select: { id: true },
});
const ergebnis = await prisma.report.deleteMany({
where: {
erhobenAm: { lt: grenze },
id: { notIn: behalten.map((eintrag) => eintrag.id) },
},
});
reports = ergebnis.count;
}
let protokoll = 0;
if (aufbewahrungAuditTage > 0) {
const grenze = new Date(jetzt.getTime() - aufbewahrungAuditTage * 86_400_000);
const ergebnis = await prisma.auditEintrag.deleteMany({ where: { erstelltAm: { lt: grenze } } });
protokoll = ergebnis.count;
}
return { reports, protokoll };
}
+34 -4
View File
@@ -3,9 +3,26 @@ import type { BenutzerModel } from "@/generated/prisma/models";
import { prisma } from "@/lib/db";
import { RateLimit } from "@/lib/ratelimit";
import { protokolliere } from "@/server/audit";
import { domaeneErlaubt, einstellungen } from "@/server/einstellungen";
/** Anmeldeversuche: fünf pro Viertelstunde je Kennung, damit Raten teuer wird. */
export const anmeldeRateLimit = new RateLimit(5, 5 / 900);
/**
* Anmeldeversuche je Kennung und Viertelstunde. Die Zahl steht in den Einstellungen;
* für jede Einstellung wird eine eigene Bremse gehalten, damit der Zustand beim Ändern
* nicht verlorengeht.
*/
const bremsen = new Map<number, RateLimit>();
export function anmeldeBremse(versuche: number): RateLimit {
const vorhanden = bremsen.get(versuche);
if (vorhanden) return vorhanden;
const neu = new RateLimit(versuche, versuche / 900);
bremsen.set(versuche, neu);
return neu;
}
/** Vorgabe für Aufrufer ohne Einstellungen (Tests). */
export const anmeldeRateLimit = anmeldeBremse(5);
export class AnmeldeFehler extends Error {}
@@ -15,8 +32,9 @@ export async function mitPasswortAnmelden(
passwort: string,
quelle: string,
): Promise<BenutzerModel> {
const { anmeldeversuche } = await einstellungen();
const kennung = `${quelle}:${email.toLowerCase()}`;
if (!anmeldeRateLimit.pruefe(kennung).erlaubt) {
if (!anmeldeBremse(anmeldeversuche).pruefe(kennung).erlaubt) {
throw new AnmeldeFehler("Zu viele Versuche. Bitte einige Minuten warten.");
}
@@ -41,10 +59,17 @@ export async function mitPasswortAnmelden(
/**
* Ordnet eine OIDC-Anmeldung einem Benutzer zu: erst über die Subject-ID, sonst über die E-Mail.
* Ein unbekanntes Konto wird angelegt – wer sich bei authentik anmelden darf, darf auch hier arbeiten.
* Ob ein unbekanntes Konto angelegt werden darf und welche E-Mail-Domänen zugelassen sind,
* steht in den Einstellungen (#025). Vorgabe bleibt: anlegen, alle Domänen.
*/
export async function mitOidcAnmelden(subject: string, email: string, name: string): Promise<BenutzerModel> {
const normalisiert = email.toLowerCase().trim();
const { oidcDomaenen, oidcKontenAnlegen } = await einstellungen();
if (!domaeneErlaubt(normalisiert, oidcDomaenen)) {
await protokolliere("anmeldung.abgelehnt", "Benutzer", null, { email: normalisiert, grund: "domaene" });
throw new AnmeldeFehler("Diese E-Mail-Domäne ist für die Anmeldung nicht zugelassen.");
}
const vorhanden =
(await prisma.benutzer.findUnique({ where: { oidcSubject: subject } })) ??
@@ -54,6 +79,11 @@ export async function mitOidcAnmelden(subject: string, email: string, name: stri
throw new AnmeldeFehler("Dieses Konto ist deaktiviert.");
}
if (!vorhanden && !oidcKontenAnlegen) {
await protokolliere("anmeldung.abgelehnt", "Benutzer", null, { email: normalisiert, grund: "kein-konto" });
throw new AnmeldeFehler("Für diese Adresse gibt es hier kein Konto.");
}
const benutzer = vorhanden
? await prisma.benutzer.update({
where: { id: vorhanden.id },
+120
View File
@@ -0,0 +1,120 @@
import { z } from "zod";
import { prisma } from "@/lib/db";
/**
* Betriebseinstellungen, die sich im Dashboard ändern lassen (#025).
*
* Alles liegt in **einer** Zeile der Tabelle `Einstellung` als JSON – so ist ein Speichern
* ein einziger Schreibvorgang, und fehlende Felder bekommen ihren Vorgabewert.
*
* Bewusst **nicht** hier: `APP_SECRET` und `DATABASE_URL`. Die braucht die Anwendung, bevor sie
* die Datenbank überhaupt lesen kann; sie bleiben in der Umgebung.
*/
const SCHLUESSEL = "system";
export const einstellungenSchema = z.object({
// --- Adresse und Agent ---
/** Adresse, die im Installations-Einzeiler und in Benachrichtigungen steht. Leer = aus `APP_URL`. */
dashboardUrl: z.union([z.string().url("Bitte eine vollständige Adresse angeben."), z.literal("")]).default(""),
agentRhythmus: z.enum(["taeglich", "zwoelfstuendlich", "stuendlich"]).default("taeglich"),
/** Zufällige Verzögerung, damit nicht alle Hosts gleichzeitig melden. */
agentStreuungStunden: z.number().int().min(0).max(12).default(4),
agentAutoUpdate: z.boolean().default(false),
// --- Kaufmännisches ---
waehrung: z.enum(["EUR", "CHF"]).default("EUR"),
steuersatz: z.number().min(0).max(100).default(19),
zahlungszielTage: z.number().int().min(0).max(120).default(14),
/** Vorschlag für den Aufschlag auf den Einkaufspreis beim Anlegen einer Kundenrechnung. */
margeProzent: z.number().min(0).max(500).default(20),
// --- Sicherheit ---
sitzungsdauerStunden: z.number().int().min(1).max(168).default(12),
anmeldeversuche: z.number().int().min(3).max(50).default(5),
/** Komma-getrennte Liste erlaubter E-Mail-Domänen für OIDC. Leer = alle. */
oidcDomaenen: z.string().default(""),
/** Darf sich jemand ohne vorhandenes Konto über OIDC anmelden? */
oidcKontenAnlegen: z.boolean().default(true),
/** 0 = unbegrenzt aufbewahren. */
aufbewahrungReportsTage: z.number().int().min(0).max(3650).default(365),
aufbewahrungAuditTage: z.number().int().min(0).max(3650).default(730),
// --- Firma und Darstellung ---
firmaName: z.string().max(120).default(""),
firmaAnschrift: z.string().max(400).default(""),
firmaEmail: z.union([z.string().email("Keine gültige E-Mail-Adresse."), z.literal("")]).default(""),
zeitzone: z.string().min(1).default("Europe/Berlin"),
});
export type Einstellungen = z.infer<typeof einstellungenSchema>;
export const VORGABEN: Einstellungen = einstellungenSchema.parse({});
/**
* Kurzer Zwischenspeicher: Die Einstellungen werden bei fast jeder Anfrage gebraucht,
* ändern sich aber selten. Zehn Sekunden reichen, damit eine Änderung sofort spürbar ist.
*/
let zwischenspeicher: { werte: Einstellungen; bis: number } | null = null;
export async function einstellungen(): Promise<Einstellungen> {
if (zwischenspeicher && zwischenspeicher.bis > Date.now()) return zwischenspeicher.werte;
const eintrag = await prisma.einstellung.findUnique({ where: { schluessel: SCHLUESSEL } });
// Unbekannte oder kaputte Werte fallen auf die Vorgabe zurück, statt den Betrieb anzuhalten.
const ergebnis = einstellungenSchema.safeParse(eintrag?.wert ?? {});
const werte = ergebnis.success ? ergebnis.data : VORGABEN;
zwischenspeicher = { werte, bis: Date.now() + 10_000 };
return werte;
}
export async function einstellungenSpeichern(werte: Einstellungen): Promise<void> {
await prisma.einstellung.upsert({
where: { schluessel: SCHLUESSEL },
create: { schluessel: SCHLUESSEL, wert: werte },
update: { wert: werte },
});
zwischenspeicher = null;
}
/** Nur für Tests: erzwingt das nächste Lesen aus der Datenbank. */
export function zwischenspeicherLeeren(): void {
zwischenspeicher = null;
}
/**
* Fälligkeitsdatum, das ein leeres Rechnungsformular vorschlägt: heute plus Zahlungsziel.
* Bewusst hier und nicht in der Seite – eine Komponente darf beim Rendern nicht die Uhr lesen.
*/
export async function faelligkeitsVorschlag(jetzt: Date = new Date()): Promise<string> {
const { zahlungszielTage } = await einstellungen();
return new Date(jetzt.getTime() + zahlungszielTage * 86_400_000).toISOString();
}
/** systemd-Angabe für den Melderhythmus des Agents. */
export function onCalendar(rhythmus: Einstellungen["agentRhythmus"]): string {
switch (rhythmus) {
case "stuendlich":
return "hourly";
case "zwoelfstuendlich":
return "0/12:00:00";
default:
return "daily";
}
}
/**
* Prüft, ob eine E-Mail-Adresse zu den erlaubten OIDC-Domänen gehört.
* Ohne Angabe ist jede Domäne erlaubt – so verhält es sich wie bisher.
*/
export function domaeneErlaubt(email: string, domaenen: string): boolean {
const liste = domaenen
.split(",")
.map((eintrag) => eintrag.trim().toLowerCase().replace(/^@/, ""))
.filter(Boolean);
if (liste.length === 0) return true;
const domaene = email.toLowerCase().split("@")[1] ?? "";
return liste.includes(domaene);
}
+9 -3
View File
@@ -121,14 +121,17 @@ export function Bericht({
untertitel,
children,
quer = false,
firma,
}: {
titel: string;
untertitel?: string;
children: React.ReactNode;
quer?: boolean;
/** Firmenangaben und Zeitzone aus den Einstellungen (#025). */
firma?: { name: string; anschrift: string; email: string; zeitzone: string };
}) {
const erstellt = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
timeZone: firma?.zeitzone || "Europe/Berlin",
dateStyle: "long",
}).format(new Date());
@@ -138,8 +141,11 @@ export function Bericht({
<View style={stile.kopf} fixed>
<Logo />
<View style={stile.kopfText}>
<Text style={stile.marke}>ProxLedger</Text>
<Text style={stile.untertitel}>Proxmox Subscription Dashboard</Text>
<Text style={stile.marke}>{firma?.name || "ProxLedger"}</Text>
<Text style={stile.untertitel}>
{firma?.anschrift ? firma.anschrift.replace(/\n/g, " · ") : "Proxmox Subscription Dashboard"}
</Text>
{firma?.email ? <Text style={stile.untertitel}>{firma.email}</Text> : null}
</View>
</View>
+6 -3
View File
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit";
import { einstellungen } from "@/server/einstellungen";
export class RechnungsFehler extends Error {}
@@ -67,6 +68,7 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
if (!eingabe.nummer.trim()) throw new RechnungsFehler("Bitte eine Rechnungsnummer angeben.");
const { steuersatz: vorgabeSteuersatz } = await einstellungen();
const nummer = eingabe.nummer.trim();
const bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`;
@@ -79,7 +81,7 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
data: {
datum: eingabe.datum,
faelligAm: eingabe.faelligAm,
ustSatz: eingabe.ustSatz ?? 19,
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
rabatt: eingabe.rabatt ?? null,
zahlungsart: eingabe.zahlungsart ?? null,
bezahlt: eingabe.bezahlt,
@@ -94,7 +96,7 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
datum: eingabe.datum,
faelligAm: eingabe.faelligAm,
nettoBetrag: eingabe.nettoBetrag,
ustSatz: eingabe.ustSatz ?? 19,
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
rabatt: eingabe.rabatt ?? null,
zahlungsart: eingabe.zahlungsart ?? null,
bezahlt: eingabe.bezahlt,
@@ -146,6 +148,7 @@ export async function kundenrechnungErfassen(laufzeitId: string, eingabe: Kunden
throw new RechnungsFehler("Der Host ist keinem Kunden zugewiesen – ohne Kunde keine Kundenrechnung.");
}
const { steuersatz: vorgabeSteuersatz } = await einstellungen();
const nummer = eingabe.nummer?.trim() || null;
const bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`;
const betrag = eingabe.nettoBetrag ?? 0;
@@ -160,7 +163,7 @@ export async function kundenrechnungErfassen(laufzeitId: string, eingabe: Kunden
erstellt: eingabe.erstellt,
nummer,
datum: eingabe.datum ?? null,
ustSatz: eingabe.ustSatz ?? 19,
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
bezahlt: eingabe.bezahlt,
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
anhangId: eingabe.anhangId ?? null,