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
@@ -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>
);
}