Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffc80a8fcf | ||
|
|
d416f3869a |
@@ -17,6 +17,18 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
### Sicherheit
|
||||
|
||||
## [0.16.0] – 2026-09-18
|
||||
|
||||
### Hinzugefügt
|
||||
- *Einstellungen → System*: Dashboard-Adresse, Melderhythmus und Selbstaktualisierung der Agents, kaufmännische Vorgaben, Sicherheitsgrenzen, Firmenangaben und Zeitzone (#025)
|
||||
- Die Dashboard-Adresse und der Melderhythmus landen im ausgelieferten Installations-Einzeiler und im systemd-Timer (#025)
|
||||
- OIDC lässt sich auf bestimmte E-Mail-Domänen begrenzen; das automatische Anlegen unbekannter Konten ist abschaltbar (#025)
|
||||
- Nächtliches Aufräumen nach einstellbaren Aufbewahrungsfristen für Reports und Protokoll – der jüngste Report jedes Hosts bleibt erhalten (#025)
|
||||
- Firmenangaben erscheinen im Kopf der PDF-Ausgaben (#025)
|
||||
|
||||
### Geändert
|
||||
- Steuersatz, Zahlungsziel und Aufschlag kommen aus den Einstellungen statt aus fest eingetragenen 19 % (#025)
|
||||
|
||||
## [0.15.0] – 2026-09-18
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Zeitübersicht
|
||||
|
||||
**Gesamt:** 10 h 28 min
|
||||
**Gesamt:** 11 h 38 min
|
||||
|
||||
| Issue | Zeit |
|
||||
|---|---|
|
||||
@@ -30,6 +30,7 @@
|
||||
| #022 Cluster-Abgleich | 0 h 25 min |
|
||||
| #023 Feldtest PBS | 0 h 35 min |
|
||||
| #024 OWASP-Härtung | 0 h 45 min |
|
||||
| #025 Einstellungen | 1 h 10 min |
|
||||
|
||||
| Meilenstein | Zeit |
|
||||
|---|---|
|
||||
@@ -52,7 +53,7 @@
|
||||
|
||||
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
||||
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
||||
**Version:** 0.15.0
|
||||
**Version:** 0.16.0
|
||||
|
||||
### Erledigt
|
||||
- Auftrag gelesen, Rückfragen gestellt
|
||||
@@ -142,6 +143,10 @@
|
||||
- Durchsicht nach OWASP (#024): drei echte Lücken gefunden – Server Actions ohne eigene Zugriffsprüfung, Login-CSRF und fehlende CSP. Alle behoben und mit Tests abgesichert
|
||||
- Erkenntnis: Der Schutz lag vollständig in einer einzigen Schicht (`proxy.ts`). Das fällt erst auf, wenn man die Endpunkte einzeln betrachtet statt die Oberfläche
|
||||
|
||||
- Einstellungen im Dashboard (#025): vier Bereiche, alle Werte wirken auch wirklich – der Melderhythmus zum Beispiel bis in den erzeugten systemd-Timer hinein, live geprüft
|
||||
- Dabei die OIDC-Lücke aus #024 geschlossen: Domänenliste und Schalter gegen automatisch angelegte Konten
|
||||
- Bewusst nicht gebaut: „stumm nach X Tagen“ (steht schon bei den Warnregeln) und die Währung in der Browseransicht
|
||||
|
||||
### Nächster Schritt
|
||||
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
|
||||
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
|
||||
|
||||
@@ -16,9 +16,17 @@ set -o nounset
|
||||
set -o pipefail
|
||||
|
||||
DASHBOARD_URL="${PROXLEDGER_URL:-@@DASHBOARD_URL@@}"
|
||||
# Melderhythmus und Streuung setzt das Dashboard beim Ausliefern ein (Einstellungen → System).
|
||||
# Wird das Skript unverändert aus dem Quelltext ausgeführt, bleiben die Platzhalter stehen;
|
||||
# dann gelten die Vorgaben.
|
||||
TIMER_ONCALENDAR="@@TIMER_ONCALENDAR@@"
|
||||
TIMER_STREUUNG="@@TIMER_STREUUNG@@"
|
||||
case "$TIMER_ONCALENDAR" in *@@*) TIMER_ONCALENDAR="daily" ;; esac
|
||||
case "$TIMER_STREUUNG" in *@@*) TIMER_STREUUNG="4h" ;; esac
|
||||
TOKEN=""
|
||||
CA_CERT=""
|
||||
AUTO_UPDATE="no"
|
||||
AUTO_UPDATE="@@AUTO_UPDATE@@"
|
||||
case "$AUTO_UPDATE" in yes | no) ;; *) AUTO_UPDATE="no" ;; esac
|
||||
AKTION="install"
|
||||
|
||||
CONFIG_VERZEICHNIS="/etc/proxledger"
|
||||
@@ -202,14 +210,14 @@ ProtectSystem=full
|
||||
ProtectHome=true
|
||||
UNIT
|
||||
|
||||
cat >"${SYSTEMD_VERZEICHNIS}/proxledger.timer" <<'UNIT'
|
||||
cat >"${SYSTEMD_VERZEICHNIS}/proxledger.timer" <<UNIT
|
||||
[Unit]
|
||||
Description=Täglicher ProxLedger-Report
|
||||
Description=ProxLedger-Report
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
# Verteilt die Meldungen aller Hosts über vier Stunden.
|
||||
RandomizedDelaySec=4h
|
||||
OnCalendar=${TIMER_ONCALENDAR}
|
||||
# Verteilt die Meldungen aller Hosts über diesen Zeitraum.
|
||||
RandomizedDelaySec=${TIMER_STREUUNG}
|
||||
Persistent=true
|
||||
Unit=proxledger.service
|
||||
|
||||
@@ -219,7 +227,7 @@ UNIT
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now proxledger.timer >/dev/null
|
||||
erfolg "systemd-Timer aktiviert (täglich, zufällig um bis zu 4 Stunden versetzt)."
|
||||
erfolg "systemd-Timer aktiviert (${TIMER_ONCALENDAR}, zufällig um bis zu ${TIMER_STREUUNG} versetzt)."
|
||||
|
||||
hinweis "Sende ersten Report …"
|
||||
if "$AGENT_PFAD" --report; then
|
||||
|
||||
@@ -17,6 +17,7 @@ auch mit Hosts hinter NAT.
|
||||
7. [Warnungen](#7-warnungen)
|
||||
8. [Benachrichtigungen einrichten](#8-benachrichtigungen-einrichten)
|
||||
9. [Exporte](#9-exporte)
|
||||
9a. [Einstellungen](#9a-einstellungen)
|
||||
10. [Sicherung und Aktualisierung](#10-sicherung-und-aktualisierung)
|
||||
11. [Fehlersuche](#11-fehlersuche)
|
||||
|
||||
@@ -310,6 +311,36 @@ Jahresliste für die Buchhaltung.
|
||||
|
||||
---
|
||||
|
||||
## 9a. Einstellungen
|
||||
|
||||
Unter *Einstellungen → System* steht alles, was sich im laufenden Betrieb ändern lässt:
|
||||
|
||||

|
||||
|
||||
**Adresse und Agent.** Die Dashboard-Adresse landet im Installations-Einzeiler und in den
|
||||
Benachrichtigungen. Bleibt das Feld leer, gilt `APP_URL` aus der Umgebung. Melderhythmus, Streuung und
|
||||
Selbstaktualisierung gelten für **neu installierte** Agents – bestehende übernehmen sie erst, wenn der
|
||||
Einzeiler dort erneut läuft.
|
||||
|
||||
**Kaufmännisches.** Steuersatz, Zahlungsziel und Aufschlag füllen leere Rechnungsfelder vor. Der
|
||||
Aufschlag rechnet aus dem Einkaufspreis einen Vorschlag für den Verkaufspreis; eingetragene Beträge
|
||||
bleiben unberührt.
|
||||
|
||||
**Sicherheit.** Sitzungsdauer und Anmeldeversuche gelten ab der nächsten Anmeldung. Die beiden
|
||||
OIDC-Felder sind wichtig, wenn ein Identitätsanbieter viele Konten kennt: Ohne Einschränkung darf
|
||||
jeder, der sich dort anmelden kann, hier mitarbeiten. Mit einer Domänenliste oder abgeschaltetem
|
||||
„Konten anlegen“ kommt nur herein, wer hier vorgesehen ist. Die Aufbewahrungsfristen räumen nachts um
|
||||
halb vier auf; der jüngste Report jedes Hosts bleibt immer erhalten, sonst stünde ein lange stummer
|
||||
Host plötzlich ohne Angaben da.
|
||||
|
||||
**Firma und Darstellung.** Name, Anschrift und E-Mail stehen im Kopf der erzeugten PDF-Dateien, die
|
||||
Zeitzone gilt für PDF, CSV und Benachrichtigungen.
|
||||
|
||||
> `APP_SECRET` und `DATABASE_URL` stehen bewusst **nicht** hier: Beide werden gebraucht, bevor die
|
||||
> Anwendung die Datenbank überhaupt lesen kann.
|
||||
|
||||
---
|
||||
|
||||
## 10. Sicherung und Aktualisierung
|
||||
|
||||
**Sichern:**
|
||||
|
||||
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 173 KiB |
@@ -0,0 +1,47 @@
|
||||
# #025 Einstellungen im Dashboard
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.16.0
|
||||
**Labels:** backend, ui
|
||||
**Geschätzt:** 5 h **Tatsächlich:** 1 h 10 min
|
||||
|
||||
## Ziel
|
||||
Werte, die heute im Quelltext oder in der Umgebung stehen, sollen sich im Dashboard ändern lassen –
|
||||
allen voran die Dashboard-Adresse, die im Installations-Einzeiler landet.
|
||||
|
||||
## Aufgaben
|
||||
- [x] Typisierter Einstellungsdienst mit Vorgaben und Prüfung (zod), alles in einer JSON-Zeile
|
||||
- [x] Oberfläche unter *Einstellungen → System* mit vier Bereichen
|
||||
- [x] **Adresse und Agent:** Dashboard-Adresse, Melderhythmus, Streuung, Selbstaktualisierung
|
||||
- [x] **Kaufmännisches:** Währung, Steuersatz, Zahlungsziel, Aufschlag
|
||||
- [x] **Sicherheit:** Sitzungsdauer, Anmeldeversuche, OIDC-Domänen, Konten anlegen, Aufbewahrungsfristen
|
||||
- [x] **Firma und Darstellung:** Name, Anschrift, E-Mail, Zeitzone
|
||||
- [x] Werte wirksam machen (nicht nur speichern)
|
||||
- [x] Nächtliches Aufräumen nach den Aufbewahrungsfristen
|
||||
- [x] Tests und Handbuchkapitel
|
||||
|
||||
## Wo die Werte wirken
|
||||
| Einstellung | Wirkt in |
|
||||
|---|---|
|
||||
| Dashboard-Adresse | `/install.sh`, `/agent.sh`, Benachrichtigungen |
|
||||
| Rhythmus, Streuung, Selbstaktualisierung | erzeugte systemd-Timer und `agent.conf` |
|
||||
| Steuersatz | neue Rechnungspositionen (Einkauf und Verkauf) |
|
||||
| Zahlungsziel | vorgeschlagenes Fälligkeitsdatum |
|
||||
| Aufschlag | vorgeschlagener Verkaufspreis aus dem Einkaufspreis |
|
||||
| Sitzungsdauer | Gültigkeit des Tokens und Lebensdauer des Cookies |
|
||||
| Anmeldeversuche | Ratenbremse der Anmeldung |
|
||||
| OIDC-Domänen, Konten anlegen | `mitOidcAnmelden` – schließt die in #024 vermerkte Lücke |
|
||||
| Aufbewahrungsfristen | nächtlicher Lauf im Worker (03:30) |
|
||||
| Firma, Zeitzone | Kopf und Datum der PDF-Ausgaben |
|
||||
|
||||
## Entscheidungen
|
||||
- **Eine JSON-Zeile statt vieler Schlüssel:** Speichern ist ein Schreibvorgang, fehlende Felder
|
||||
bekommen ihren Vorgabewert, und eine kaputte Zeile legt den Betrieb nicht lahm – dann gelten die Vorgaben.
|
||||
- **Zehn Sekunden Zwischenspeicher:** Die Werte werden bei fast jeder Anfrage gebraucht, ändern sich
|
||||
aber selten. Eine Änderung ist trotzdem sofort spürbar.
|
||||
- **`APP_SECRET` und `DATABASE_URL` bleiben in der Umgebung**; beide werden vor dem ersten
|
||||
Datenbankzugriff gebraucht.
|
||||
- **„Stumm nach X Tagen“ bewusst weggelassen:** Das steht schon als Schwellwert bei den Warnregeln.
|
||||
Zwei Stellen für denselben Wert wären eine Fehlerquelle.
|
||||
- **Währung und Zeitzone wirken vorerst nur serverseitig** (PDF, CSV, Benachrichtigungen). Die
|
||||
Browseransicht rechnet weiterhin fest in Euro und Europe/Berlin – das nachzuziehen heißt, die
|
||||
Formatierung durch alle Client-Komponenten zu reichen, und gehört in einen eigenen Schritt.
|
||||
@@ -28,4 +28,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
||||
| [#022](022-cluster-abgleich.md) | Cluster-Abgleich | erledigt | v0.14.0 | 3 h | 0 h 25 min |
|
||||
| [#023](023-feldtest-pbs-pmg.md) | Feldtest auf PBS und PMG | offen | v1.0.0 | 2 h | 0 h 35 min (PBS erledigt, PMG offen) |
|
||||
| [#024](024-owasp-haertung.md) | Härtung nach OWASP | erledigt | v0.15.0 | 4 h | 0 h 45 min |
|
||||
| | **Summe** | | | **125 h** | **10 h 28 min** |
|
||||
| [#025](025-einstellungen.md) | Einstellungen im Dashboard | erledigt | v0.16.0 | 5 h | 1 h 10 min |
|
||||
| | **Summe** | | | **130 h** | **11 h 38 min** |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proxledger",
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -48,6 +48,7 @@ const SEITEN = [
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
async function sitzungsToken() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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." };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { altdatenLoeschen } from "@/server/aufraeumen";
|
||||
import { einstellungenSpeichern, VORGABEN, zwischenspeicherLeeren } from "@/server/einstellungen";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
const JETZT = new Date("2026-09-18T12:00:00Z");
|
||||
const tageVorher = (tage: number) => new Date(JETZT.getTime() - tage * 86_400_000);
|
||||
|
||||
async function hostMitReports(tage: number[]) {
|
||||
const host = await testPrisma.host.create({
|
||||
data: { agentId: "agent-1", hostname: "pve-01", produkt: "PVE", letzterReportAm: JETZT },
|
||||
});
|
||||
|
||||
for (const alter of tage) {
|
||||
await testPrisma.report.create({
|
||||
data: {
|
||||
hostId: host.id,
|
||||
reportId: `report-${alter}`,
|
||||
erhobenAm: tageVorher(alter),
|
||||
empfangenAm: tageVorher(alter),
|
||||
subStatus: "ACTIVE",
|
||||
rohdaten: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Altdaten löschen", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
zwischenspeicherLeeren();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testPrisma.$disconnect();
|
||||
});
|
||||
|
||||
it("löscht Reports, die älter sind als die Frist", async () => {
|
||||
await einstellungenSpeichern({ ...VORGABEN, aufbewahrungReportsTage: 30 });
|
||||
await hostMitReports([1, 10, 45, 400]);
|
||||
|
||||
const ergebnis = await altdatenLoeschen(JETZT);
|
||||
|
||||
expect(ergebnis.reports).toBe(2);
|
||||
expect(await testPrisma.report.count()).toBe(2);
|
||||
});
|
||||
|
||||
it("behält den jüngsten Report jedes Hosts, auch wenn er zu alt ist", async () => {
|
||||
await einstellungenSpeichern({ ...VORGABEN, aufbewahrungReportsTage: 30 });
|
||||
await hostMitReports([200, 400]);
|
||||
|
||||
await altdatenLoeschen(JETZT);
|
||||
|
||||
const uebrig = await testPrisma.report.findMany();
|
||||
expect(uebrig).toHaveLength(1);
|
||||
expect(uebrig[0].erhobenAm).toEqual(tageVorher(200));
|
||||
});
|
||||
|
||||
it("löscht nichts, wenn die Frist auf 0 steht", async () => {
|
||||
await einstellungenSpeichern({
|
||||
...VORGABEN,
|
||||
aufbewahrungReportsTage: 0,
|
||||
aufbewahrungAuditTage: 0,
|
||||
});
|
||||
await hostMitReports([400, 800]);
|
||||
await testPrisma.auditEintrag.create({
|
||||
data: { aktion: "test", entitaet: "Host", erstelltAm: tageVorher(3000) },
|
||||
});
|
||||
|
||||
const ergebnis = await altdatenLoeschen(JETZT);
|
||||
|
||||
expect(ergebnis).toEqual({ reports: 0, protokoll: 0 });
|
||||
expect(await testPrisma.report.count()).toBe(2);
|
||||
expect(await testPrisma.auditEintrag.count()).toBe(1);
|
||||
});
|
||||
|
||||
it("räumt auch das Protokoll auf", async () => {
|
||||
await einstellungenSpeichern({ ...VORGABEN, aufbewahrungAuditTage: 365 });
|
||||
for (const alter of [10, 400]) {
|
||||
await testPrisma.auditEintrag.create({
|
||||
data: { aktion: "test", entitaet: "Host", erstelltAm: tageVorher(alter) },
|
||||
});
|
||||
}
|
||||
|
||||
const ergebnis = await altdatenLoeschen(JETZT);
|
||||
|
||||
expect(ergebnis.protokoll).toBe(1);
|
||||
expect(await testPrisma.auditEintrag.count()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { domaeneErlaubt, einstellungenSchema, onCalendar, VORGABEN } from "@/server/einstellungen";
|
||||
|
||||
describe("Einstellungen", () => {
|
||||
it("füllt fehlende Werte mit den Vorgaben", () => {
|
||||
const werte = einstellungenSchema.parse({});
|
||||
expect(werte.steuersatz).toBe(19);
|
||||
expect(werte.sitzungsdauerStunden).toBe(12);
|
||||
expect(werte.agentRhythmus).toBe("taeglich");
|
||||
expect(werte.oidcKontenAnlegen).toBe(true);
|
||||
});
|
||||
|
||||
it("übernimmt gesetzte Werte und lässt den Rest auf Vorgabe", () => {
|
||||
const werte = einstellungenSchema.parse({ steuersatz: 7.7, waehrung: "CHF" });
|
||||
expect(werte.steuersatz).toBe(7.7);
|
||||
expect(werte.waehrung).toBe("CHF");
|
||||
expect(werte.zahlungszielTage).toBe(VORGABEN.zahlungszielTage);
|
||||
});
|
||||
|
||||
it("weist unsinnige Werte ab", () => {
|
||||
expect(einstellungenSchema.safeParse({ steuersatz: 250 }).success).toBe(false);
|
||||
expect(einstellungenSchema.safeParse({ sitzungsdauerStunden: 0 }).success).toBe(false);
|
||||
expect(einstellungenSchema.safeParse({ dashboardUrl: "kein-url" }).success).toBe(false);
|
||||
expect(einstellungenSchema.safeParse({ anmeldeversuche: 1 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("lässt eine leere Dashboard-Adresse zu (dann gilt die Umgebung)", () => {
|
||||
expect(einstellungenSchema.parse({ dashboardUrl: "" }).dashboardUrl).toBe("");
|
||||
});
|
||||
|
||||
it("übersetzt den Rhythmus nach systemd", () => {
|
||||
expect(onCalendar("taeglich")).toBe("daily");
|
||||
expect(onCalendar("stuendlich")).toBe("hourly");
|
||||
expect(onCalendar("zwoelfstuendlich")).toBe("0/12:00:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Erlaubte OIDC-Domänen", () => {
|
||||
it("lässt ohne Angabe jede Domäne zu", () => {
|
||||
expect(domaeneErlaubt("wer@auch-immer.de", "")).toBe(true);
|
||||
});
|
||||
|
||||
it("lässt nur die genannten Domänen zu", () => {
|
||||
expect(domaeneErlaubt("fw@wilde-server.de", "wilde-server.de")).toBe(true);
|
||||
expect(domaeneErlaubt("fremd@example.com", "wilde-server.de")).toBe(false);
|
||||
});
|
||||
|
||||
it("versteht mehrere Domänen, Leerzeichen und führendes @", () => {
|
||||
const liste = " @wilde-server.de, kunde.de ";
|
||||
expect(domaeneErlaubt("a@kunde.de", liste)).toBe(true);
|
||||
expect(domaeneErlaubt("b@WILDE-SERVER.DE", liste)).toBe(true);
|
||||
expect(domaeneErlaubt("c@fremd.de", liste)).toBe(false);
|
||||
});
|
||||
|
||||
it("weist eine Adresse ohne Domäne ab", () => {
|
||||
expect(domaeneErlaubt("kaputt", "kunde.de")).toBe(false);
|
||||
});
|
||||
});
|
||||