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
@@ -1,23 +1,32 @@
import { SystemFormular } from "@/components/einstellungen/SystemFormular";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { Card } from "@/components/ui/Card";
import { APP_VERSION } from "@/lib/version";
import { einstellungen } from "@/server/einstellungen";
export const metadata = { title: "System" };
export default function SystemSeite() {
export default async function SystemSeite() {
const werte = await einstellungen();
return (
<>
<ContentHeader titel="System" brotkrumen={[{ titel: "Einstellungen" }, { titel: "System" }]} />
<div className="app-content">
<div className="container-fluid">
<Card titel="Systeminformationen" rahmen="primary">
<SystemFormular werte={werte} appUrl={process.env.APP_URL ?? ""} />
<Card titel="Systeminformationen" rahmen="keiner">
<dl className="row mb-0">
<dt className="col-4 fw-normal text-body-secondary">Version</dt>
<dd className="col-8">{APP_VERSION}</dd>
<dt className="col-4 fw-normal text-body-secondary">Datenquelle</dt>
<dd className="col-8">Beispieldaten (Prototyp, #002)</dd>
<dt className="col-4 fw-normal text-body-secondary">Warnregeln</dt>
<dd className="col-8">Konfigurierbar ab #011</dd>
<dt className="col-4 fw-normal text-body-secondary">Zeitzone des Servers</dt>
<dd className="col-8">{Intl.DateTimeFormat().resolvedOptions().timeZone}</dd>
<dt className="col-4 fw-normal text-body-secondary">Nur in der Umgebung</dt>
<dd className="col-8">
<code>APP_SECRET</code> und <code>DATABASE_URL</code> – beide werden gebraucht, bevor
die Datenbank gelesen werden kann.
</dd>
</dl>
</Card>
</div>
@@ -3,6 +3,7 @@ import { csvAntwort, csvDatei } from "@/lib/csv";
import { formatDatum } from "@/lib/format";
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { hostListe, type HostFilter } from "@/server/queries/hostliste";
import { einstellungen } from "@/server/einstellungen";
type ExportHost = Awaited<ReturnType<typeof hostListe>>["hosts"][number];
@@ -34,8 +35,12 @@ export async function GET(request: Request) {
const datum = new Date().toISOString().slice(0, 10);
if (new URL(request.url).searchParams.get("format") === "pdf") {
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
return pdfAntwort(
Bericht({
firma: firmaAngaben,
titel: "Host-Liste",
untertitel: `${alle.length} Hosts`,
quer: true,
@@ -2,6 +2,7 @@ import { PRODUKT_TEXT, STATUS_TEXT, statusAusDatenbank } from "@/lib/anzeige";
import { formatDatum, formatEuro } from "@/lib/format";
import { Abschnitt, Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { kundeMitDetails, laufzeitenVonKunde, summen, summenProJahr } from "@/server/queries/kunden";
import { einstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic";
@@ -15,9 +16,12 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
const gesamt = summen(laufzeiten);
const proJahr = summenProJahr(laufzeiten);
const datum = new Date().toISOString().slice(0, 10);
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
return pdfAntwort(
Bericht({
firma: firmaAngaben,
titel: kunde.name,
untertitel: `Kundennummer ${kunde.kundennummer}${kunde.ansprechpartner ? ` · ${kunde.ansprechpartner}` : ""}`,
children: [
@@ -1,6 +1,7 @@
import { csvAntwort, csvDatei } from "@/lib/csv";
import { formatDatum, formatEuro } from "@/lib/format";
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { einstellungen } from "@/server/einstellungen";
import {
kundenListe,
offeneRechnungspunkteProKunde,
@@ -30,8 +31,12 @@ export async function GET(request: Request) {
const datum = new Date().toISOString().slice(0, 10);
if (format === "pdf") {
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
return pdfAntwort(
Bericht({
firma: firmaAngaben,
titel: "Kundenübersicht",
untertitel: `${zeilen.length} Kunden`,
quer: true,
+11 -4
View File
@@ -9,6 +9,7 @@ import { CopyableCode } from "@/components/ui/CopyableCode";
import { PRODUKT_TEXT } from "@/lib/anzeige";
import { formatDatum, formatEuro } from "@/lib/format";
import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen";
import { einstellungen, faelligkeitsVorschlag } from "@/server/einstellungen";
import { laufzeitDetail } from "@/server/queries/rechnungen";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
@@ -28,6 +29,12 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
// Vorgaben aus den Einstellungen füllen leere Felder vor (#025).
const [vorgaben, faelligVorschlag] = await Promise.all([einstellungen(), faelligkeitsVorschlag()]);
const verkaufVorschlag =
verkaufBetrag ??
(einkaufBetrag === null ? null : Math.round(einkaufBetrag * (1 + vorgaben.margeProzent / 100) * 100) / 100);
const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`;
return (
@@ -148,9 +155,9 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
werte={{
nummer: einkauf?.nummer ?? null,
datum: einkauf?.datum.toISOString() ?? null,
faelligAm: einkauf?.faelligAm?.toISOString() ?? null,
faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag,
nettoBetrag: einkaufBetrag,
ustSatz: Number(einkauf?.ustSatz ?? 19),
ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz),
rabatt:
einkauf?.rabatt === null || einkauf?.rabatt === undefined
? null
@@ -179,8 +186,8 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
erstellt: verkauf?.erstellt ?? false,
nummer: verkauf?.nummer ?? null,
datum: verkauf?.datum?.toISOString() ?? null,
nettoBetrag: verkaufBetrag,
ustSatz: Number(verkauf?.ustSatz ?? 19),
nettoBetrag: verkaufVorschlag,
ustSatz: Number(verkauf?.ustSatz ?? vorgaben.steuersatz),
bezahlt: verkauf?.bezahlt ?? false,
bezahltAm: verkauf?.bezahltAm?.toISOString() ?? null,
anhangId: verkauf?.anhangId ?? null,
@@ -2,6 +2,7 @@ import { csvAntwort, csvDatei } from "@/lib/csv";
import { formatDatum, formatEuro } from "@/lib/format";
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
import { rechnungsListe, type RechnungsFilter } from "@/server/queries/rechnungen";
import { einstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic";
@@ -21,8 +22,12 @@ export async function GET(request: Request) {
const zeitraum = filter.jahr ? `Jahr ${filter.jahr}` : "alle Jahre";
if (format === "pdf") {
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
return pdfAntwort(
Bericht({
firma: firmaAngaben,
titel: "Rechnungsliste",
untertitel: `${zeilen.length} Laufzeiten · ${zeitraum}`,
quer: true,
+1 -1
View File
@@ -4,6 +4,6 @@ export const dynamic = "force-dynamic";
/** Aktuelles Agent-Skript, wird vom Installer und vom Update-Mechanismus geladen. */
export async function GET(request: Request) {
const inhalt = await skriptLaden("proxledger-agent.sh", basisUrl(request));
const inhalt = await skriptLaden("proxledger-agent.sh", await basisUrl(request));
return skriptAntwort(inhalt, "proxledger-agent.sh");
}
+12 -7
View File
@@ -2,6 +2,7 @@ import { cookies, headers } from "next/headers";
import { clientIp } from "@/lib/ratelimit";
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer";
import { einstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic";
@@ -62,13 +63,17 @@ export async function POST(request: Request) {
const kopfzeilen = await headers();
const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen));
const token = await sitzungErzeugen({
benutzerId: benutzer.id,
email: benutzer.email,
name: benutzer.name,
herkunft: "passwort",
});
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen());
const { sitzungsdauerStunden } = await einstellungen();
const token = await sitzungErzeugen(
{
benutzerId: benutzer.id,
email: benutzer.email,
name: benutzer.name,
herkunft: "passwort",
},
sitzungsdauerStunden,
);
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen(sitzungsdauerStunden));
return Response.redirect(new URL(ziel, request.url), 303);
} catch (fehler) {
+12 -7
View File
@@ -2,6 +2,7 @@ import { cookies } from "next/headers";
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
import { mitOidcAnmelden } from "@/server/auth/benutzer";
import { anmeldungAbschliessen, oidcEinstellungen } from "@/server/auth/oidc";
import { einstellungen as systemEinstellungen } from "@/server/einstellungen";
export const dynamic = "force-dynamic";
@@ -25,13 +26,17 @@ export async function GET(request: Request) {
const angaben = await anmeldungAbschliessen(einstellungen, new URL(request.url), pruefwert, zustand);
const benutzer = await mitOidcAnmelden(angaben.subject, angaben.email, angaben.name);
const token = await sitzungErzeugen({
benutzerId: benutzer.id,
email: benutzer.email,
name: benutzer.name,
herkunft: "oidc",
});
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen());
const { sitzungsdauerStunden } = await systemEinstellungen();
const token = await sitzungErzeugen(
{
benutzerId: benutzer.id,
email: benutzer.email,
name: benutzer.name,
herkunft: "oidc",
},
sitzungsdauerStunden,
);
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen(sitzungsdauerStunden));
return Response.redirect(new URL(ziel.startsWith("/") ? ziel : "/", request.url), 302);
} catch (fehler) {
+1 -1
View File
@@ -4,6 +4,6 @@ export const dynamic = "force-dynamic";
/** Installationsskript für den One-Liner `curl -fsSL <url>/install.sh | bash -s -- --token …`. */
export async function GET(request: Request) {
const inhalt = await skriptLaden("install.sh", basisUrl(request));
const inhalt = await skriptLaden("install.sh", await basisUrl(request));
return skriptAntwort(inhalt, "install.sh");
}
@@ -0,0 +1,341 @@
"use client";
import { useActionState } from "react";
import { Card } from "@/components/ui/Card";
import { type AktionsErgebnis, einstellungenSpeichernAktion } from "@/server/actions/einstellungen";
import type { Einstellungen } from "@/server/einstellungen";
const LEER: AktionsErgebnis = {};
function Feld({
name,
beschriftung,
hinweis,
fehler,
children,
}: {
name: string;
beschriftung: string;
hinweis?: string;
fehler?: string;
children: React.ReactNode;
}) {
return (
<div className="mb-3">
<label className="form-label" htmlFor={name}>
{beschriftung}
</label>
{children}
{hinweis ? <div className="form-text">{hinweis}</div> : null}
{fehler ? <div className="invalid-feedback d-block">{fehler}</div> : null}
</div>
);
}
export function SystemFormular({ werte, appUrl }: { werte: Einstellungen; appUrl: string }) {
const [ergebnis, aktion, laeuft] = useActionState(einstellungenSpeichernAktion, LEER);
const fehler = ergebnis.felder ?? {};
return (
<form action={aktion}>
{ergebnis.fehler ? <div className="alert alert-danger">{ergebnis.fehler}</div> : null}
{ergebnis.erfolg ? <div className="alert alert-success">{ergebnis.erfolg}</div> : null}
<div className="row g-4">
<div className="col-12 col-xl-6">
<Card titel="Adresse und Agent" rahmen="primary">
<Feld
name="dashboardUrl"
beschriftung="Adresse des Dashboards"
hinweis={`Steht im Installations-Einzeiler und in Benachrichtigungen. Leer lassen heißt: aus der Umgebung (${appUrl || "nicht gesetzt"}).`}
fehler={fehler.dashboardUrl}
>
<input
type="url"
className="form-control"
id="dashboardUrl"
name="dashboardUrl"
defaultValue={werte.dashboardUrl}
placeholder="https://proxledger.example.de"
/>
</Feld>
<Feld
name="agentRhythmus"
beschriftung="Wie oft meldet ein Host?"
hinweis="Gilt für neu installierte Agents; bestehende übernehmen es bei der nächsten Installation."
>
<select className="form-select" id="agentRhythmus" name="agentRhythmus" defaultValue={werte.agentRhythmus}>
<option value="taeglich">täglich</option>
<option value="zwoelfstuendlich">alle 12 Stunden</option>
<option value="stuendlich">stündlich</option>
</select>
</Feld>
<Feld
name="agentStreuungStunden"
beschriftung="Zufällige Verzögerung (Stunden)"
hinweis="Verteilt die Meldungen, damit nicht alle Hosts gleichzeitig anklopfen."
fehler={fehler.agentStreuungStunden}
>
<input
type="number"
min={0}
max={12}
className="form-control"
id="agentStreuungStunden"
name="agentStreuungStunden"
defaultValue={werte.agentStreuungStunden}
/>
</Feld>
<div className="form-check">
<input
type="checkbox"
className="form-check-input pl-touch-target"
id="agentAutoUpdate"
name="agentAutoUpdate"
value="an"
defaultChecked={werte.agentAutoUpdate}
/>
<label className="form-check-label" htmlFor="agentAutoUpdate">
Agents dürfen sich selbst aktualisieren
</label>
<div className="form-text">
Der Agent lädt bei einer neueren Fassung das Skript vom Dashboard nach.
</div>
</div>
</Card>
</div>
<div className="col-12 col-xl-6">
<Card titel="Kaufmännisches" rahmen="info">
<Feld name="waehrung" beschriftung="Währung">
<select className="form-select" id="waehrung" name="waehrung" defaultValue={werte.waehrung}>
<option value="EUR">Euro (€)</option>
<option value="CHF">Schweizer Franken (CHF)</option>
</select>
</Feld>
<Feld
name="steuersatz"
beschriftung="Steuersatz in Prozent"
hinweis="Vorgabe für neue Rechnungspositionen."
fehler={fehler.steuersatz}
>
<input
type="number"
step="0.1"
min={0}
max={100}
className="form-control"
id="steuersatz"
name="steuersatz"
defaultValue={werte.steuersatz}
/>
</Feld>
<Feld
name="zahlungszielTage"
beschriftung="Zahlungsziel in Tagen"
hinweis="Setzt das Fälligkeitsdatum neuer Rechnungen vor."
fehler={fehler.zahlungszielTage}
>
<input
type="number"
min={0}
max={120}
className="form-control"
id="zahlungszielTage"
name="zahlungszielTage"
defaultValue={werte.zahlungszielTage}
/>
</Feld>
<Feld
name="margeProzent"
beschriftung="Aufschlag in Prozent"
hinweis="Vorschlag für den Verkaufspreis, ausgehend vom Einkaufspreis bei Proxmox."
fehler={fehler.margeProzent}
>
<input
type="number"
step="0.5"
min={0}
max={500}
className="form-control"
id="margeProzent"
name="margeProzent"
defaultValue={werte.margeProzent}
/>
</Feld>
</Card>
</div>
<div className="col-12 col-xl-6">
<Card titel="Sicherheit" rahmen="danger">
<Feld
name="sitzungsdauerStunden"
beschriftung="Sitzungsdauer in Stunden"
hinweis="Nach dieser Zeit ist eine neue Anmeldung nötig."
fehler={fehler.sitzungsdauerStunden}
>
<input
type="number"
min={1}
max={168}
className="form-control"
id="sitzungsdauerStunden"
name="sitzungsdauerStunden"
defaultValue={werte.sitzungsdauerStunden}
/>
</Feld>
<Feld
name="anmeldeversuche"
beschriftung="Anmeldeversuche je Viertelstunde"
fehler={fehler.anmeldeversuche}
>
<input
type="number"
min={3}
max={50}
className="form-control"
id="anmeldeversuche"
name="anmeldeversuche"
defaultValue={werte.anmeldeversuche}
/>
</Feld>
<Feld
name="oidcDomaenen"
beschriftung="Erlaubte E-Mail-Domänen für OIDC"
hinweis="Mehrere durch Komma trennen, etwa „wilde-server.de, kunde.de“. Leer heißt: alle."
>
<input
type="text"
className="form-control"
id="oidcDomaenen"
name="oidcDomaenen"
defaultValue={werte.oidcDomaenen}
placeholder="firma.de"
/>
</Feld>
<div className="form-check mb-3">
<input
type="checkbox"
className="form-check-input pl-touch-target"
id="oidcKontenAnlegen"
name="oidcKontenAnlegen"
value="an"
defaultChecked={werte.oidcKontenAnlegen}
/>
<label className="form-check-label" htmlFor="oidcKontenAnlegen">
Unbekannte OIDC-Anmeldungen legen ein Konto an
</label>
<div className="form-text">
Aus heißt: Nur wer hier schon ein Konto hat, kommt herein – auch wenn die Anmeldung
beim Identitätsanbieter klappt.
</div>
</div>
<Feld
name="aufbewahrungReportsTage"
beschriftung="Reports aufbewahren (Tage)"
hinweis="0 heißt: unbegrenzt. Der aktuelle Stand jedes Hosts bleibt immer erhalten."
fehler={fehler.aufbewahrungReportsTage}
>
<input
type="number"
min={0}
max={3650}
className="form-control"
id="aufbewahrungReportsTage"
name="aufbewahrungReportsTage"
defaultValue={werte.aufbewahrungReportsTage}
/>
</Feld>
<Feld
name="aufbewahrungAuditTage"
beschriftung="Protokoll aufbewahren (Tage)"
hinweis="0 heißt: unbegrenzt."
fehler={fehler.aufbewahrungAuditTage}
>
<input
type="number"
min={0}
max={3650}
className="form-control"
id="aufbewahrungAuditTage"
name="aufbewahrungAuditTage"
defaultValue={werte.aufbewahrungAuditTage}
/>
</Feld>
</Card>
</div>
<div className="col-12 col-xl-6">
<Card titel="Firma und Darstellung" rahmen="keiner">
<Feld
name="firmaName"
beschriftung="Firmenname"
hinweis="Erscheint im Kopf der erzeugten PDF-Rechnungen."
>
<input
type="text"
className="form-control"
id="firmaName"
name="firmaName"
defaultValue={werte.firmaName}
/>
</Feld>
<Feld name="firmaAnschrift" beschriftung="Anschrift">
<textarea
className="form-control"
id="firmaAnschrift"
name="firmaAnschrift"
rows={3}
defaultValue={werte.firmaAnschrift}
/>
</Feld>
<Feld name="firmaEmail" beschriftung="E-Mail-Adresse" fehler={fehler.firmaEmail}>
<input
type="email"
className="form-control"
id="firmaEmail"
name="firmaEmail"
defaultValue={werte.firmaEmail}
/>
</Feld>
<Feld
name="zeitzone"
beschriftung="Zeitzone"
hinweis="Gilt für PDF, CSV-Ausgaben und Benachrichtigungen."
fehler={fehler.zeitzone}
>
<input
type="text"
className="form-control"
id="zeitzone"
name="zeitzone"
defaultValue={werte.zeitzone}
placeholder="Europe/Berlin"
/>
</Feld>
</Card>
</div>
</div>
<div className="mb-4">
<button type="submit" className="btn btn-primary" disabled={laeuft}>
{laeuft ? "Wird gespeichert …" : "Einstellungen speichern"}
</button>
</div>
</form>
);
}
+8 -4
View File
@@ -6,6 +6,7 @@ import { SignJWT, jwtVerify } from "jose";
*/
export const SITZUNGS_COOKIE = "pl_sitzung";
/** Rückfall, solange nichts in den Einstellungen steht (#025). */
export const SITZUNGSDAUER_STUNDEN = 12;
/** Eine gelesene Sitzung weiß zusätzlich, wann sie ausgestellt wurde – nötig für den Widerruf. */
@@ -31,13 +32,16 @@ function schluessel(): Uint8Array {
return new TextEncoder().encode(geheimnis);
}
export async function sitzungErzeugen(sitzung: Sitzung): Promise<string> {
export async function sitzungErzeugen(
sitzung: Sitzung,
dauerStunden: number = SITZUNGSDAUER_STUNDEN,
): Promise<string> {
return new SignJWT({ ...sitzung })
.setProtectedHeader({ alg: "HS256" })
.setSubject(sitzung.benutzerId)
.setIssuedAt()
.setIssuer("proxledger")
.setExpirationTime(`${SITZUNGSDAUER_STUNDEN}h`)
.setExpirationTime(`${dauerStunden}h`)
.sign(schluessel());
}
@@ -70,12 +74,12 @@ export function ueberHttps(): boolean {
return process.env.NODE_ENV === "production";
}
export function cookieOptionen() {
export function cookieOptionen(dauerStunden: number = SITZUNGSDAUER_STUNDEN) {
return {
httpOnly: true,
sameSite: "lax" as const,
secure: ueberHttps(),
path: "/",
maxAge: SITZUNGSDAUER_STUNDEN * 3600,
maxAge: dauerStunden * 3600,
};
}
+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,
+15 -1
View File
@@ -2,6 +2,7 @@ import { Cron } from "croner";
import "dotenv/config";
import { prisma } from "@/lib/db";
import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich";
import { altdatenLoeschen } from "@/server/aufraeumen";
import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand";
/**
@@ -14,6 +15,7 @@ import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/
const ZEITZONE = "Europe/Berlin";
const ZEITPLAN = process.env.WORKER_CRON ?? "0 * * * *"; // stündlich zur vollen Stunde
const ZUSAMMENFASSUNG_ZEITPLAN = process.env.ZUSAMMENFASSUNG_CRON ?? "0 7 * * *"; // täglich 07:00 Uhr
const AUFRAEUM_ZEITPLAN = process.env.AUFRAEUM_CRON ?? "30 3 * * *"; // nachts, wenn nichts los ist
function protokoll(nachricht: string) {
console.log(`[${new Date().toISOString()}] ${nachricht}`);
@@ -60,10 +62,21 @@ async function zusammenfassung() {
}
}
async function aufraeumen() {
try {
const ergebnis = await altdatenLoeschen();
if (ergebnis.reports > 0 || ergebnis.protokoll > 0) {
protokoll(`Aufgeräumt: ${ergebnis.reports} Reports, ${ergebnis.protokoll} Protokolleinträge gelöscht.`);
}
} catch (fehler) {
console.error("Aufräumen fehlgeschlagen:", fehler);
}
}
async function start() {
await warnregelnSicherstellen();
protokoll(
`Worker gestartet. Auswertung "${ZEITPLAN}", Zusammenfassung "${ZUSAMMENFASSUNG_ZEITPLAN}" (${ZEITZONE}).`,
`Worker gestartet. Auswertung "${ZEITPLAN}", Zusammenfassung "${ZUSAMMENFASSUNG_ZEITPLAN}", Aufräumen "${AUFRAEUM_ZEITPLAN}" (${ZEITZONE}).`,
);
// Einmal sofort, damit nach einem Neustart nicht bis zur nächsten vollen Stunde gewartet wird.
@@ -72,6 +85,7 @@ async function start() {
const aufgaben = [
new Cron(ZEITPLAN, { timezone: ZEITZONE }, durchlauf),
new Cron(ZUSAMMENFASSUNG_ZEITPLAN, { timezone: ZEITZONE }, zusammenfassung),
new Cron(AUFRAEUM_ZEITPLAN, { timezone: ZEITZONE }, aufraeumen),
];
for (const signal of ["SIGINT", "SIGTERM"] as const) {