Merge branch 'phase/025-einstellungen'
@@ -17,6 +17,18 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### 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
|
## [0.15.0] – 2026-09-18
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt:** 10 h 28 min
|
**Gesamt:** 11 h 38 min
|
||||||
|
|
||||||
| Issue | Zeit |
|
| Issue | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
| #022 Cluster-Abgleich | 0 h 25 min |
|
| #022 Cluster-Abgleich | 0 h 25 min |
|
||||||
| #023 Feldtest PBS | 0 h 35 min |
|
| #023 Feldtest PBS | 0 h 35 min |
|
||||||
| #024 OWASP-Härtung | 0 h 45 min |
|
| #024 OWASP-Härtung | 0 h 45 min |
|
||||||
|
| #025 Einstellungen | 1 h 10 min |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -52,7 +53,7 @@
|
|||||||
|
|
||||||
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
||||||
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
||||||
**Version:** 0.15.0
|
**Version:** 0.16.0
|
||||||
|
|
||||||
### Erledigt
|
### Erledigt
|
||||||
- Auftrag gelesen, Rückfragen gestellt
|
- 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
|
- 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
|
- 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
|
### Nächster Schritt
|
||||||
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
|
- #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)
|
- 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
|
set -o pipefail
|
||||||
|
|
||||||
DASHBOARD_URL="${PROXLEDGER_URL:-@@DASHBOARD_URL@@}"
|
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=""
|
TOKEN=""
|
||||||
CA_CERT=""
|
CA_CERT=""
|
||||||
AUTO_UPDATE="no"
|
AUTO_UPDATE="@@AUTO_UPDATE@@"
|
||||||
|
case "$AUTO_UPDATE" in yes | no) ;; *) AUTO_UPDATE="no" ;; esac
|
||||||
AKTION="install"
|
AKTION="install"
|
||||||
|
|
||||||
CONFIG_VERZEICHNIS="/etc/proxledger"
|
CONFIG_VERZEICHNIS="/etc/proxledger"
|
||||||
@@ -202,14 +210,14 @@ ProtectSystem=full
|
|||||||
ProtectHome=true
|
ProtectHome=true
|
||||||
UNIT
|
UNIT
|
||||||
|
|
||||||
cat >"${SYSTEMD_VERZEICHNIS}/proxledger.timer" <<'UNIT'
|
cat >"${SYSTEMD_VERZEICHNIS}/proxledger.timer" <<UNIT
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Täglicher ProxLedger-Report
|
Description=ProxLedger-Report
|
||||||
|
|
||||||
[Timer]
|
[Timer]
|
||||||
OnCalendar=daily
|
OnCalendar=${TIMER_ONCALENDAR}
|
||||||
# Verteilt die Meldungen aller Hosts über vier Stunden.
|
# Verteilt die Meldungen aller Hosts über diesen Zeitraum.
|
||||||
RandomizedDelaySec=4h
|
RandomizedDelaySec=${TIMER_STREUUNG}
|
||||||
Persistent=true
|
Persistent=true
|
||||||
Unit=proxledger.service
|
Unit=proxledger.service
|
||||||
|
|
||||||
@@ -219,7 +227,7 @@ UNIT
|
|||||||
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now proxledger.timer >/dev/null
|
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 …"
|
hinweis "Sende ersten Report …"
|
||||||
if "$AGENT_PFAD" --report; then
|
if "$AGENT_PFAD" --report; then
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ auch mit Hosts hinter NAT.
|
|||||||
7. [Warnungen](#7-warnungen)
|
7. [Warnungen](#7-warnungen)
|
||||||
8. [Benachrichtigungen einrichten](#8-benachrichtigungen-einrichten)
|
8. [Benachrichtigungen einrichten](#8-benachrichtigungen-einrichten)
|
||||||
9. [Exporte](#9-exporte)
|
9. [Exporte](#9-exporte)
|
||||||
|
9a. [Einstellungen](#9a-einstellungen)
|
||||||
10. [Sicherung und Aktualisierung](#10-sicherung-und-aktualisierung)
|
10. [Sicherung und Aktualisierung](#10-sicherung-und-aktualisierung)
|
||||||
11. [Fehlersuche](#11-fehlersuche)
|
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
|
## 10. Sicherung und Aktualisierung
|
||||||
|
|
||||||
**Sichern:**
|
**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 |
|
| [#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) |
|
| [#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 |
|
| [#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",
|
"name": "proxledger",
|
||||||
"version": "0.15.0",
|
"version": "0.16.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const SEITEN = [
|
|||||||
{ name: "tokens", pfad: "/einstellungen/tokens", titel: "Agent-Tokens" },
|
{ name: "tokens", pfad: "/einstellungen/tokens", titel: "Agent-Tokens" },
|
||||||
{ name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" },
|
{ name: "benachrichtigungen", pfad: "/einstellungen/benachrichtigungen", titel: "Benachrichtigungen" },
|
||||||
{ name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" },
|
{ name: "audit", pfad: "/einstellungen/audit", titel: "Protokoll" },
|
||||||
|
{ name: "system", pfad: "/einstellungen/system", titel: "Systemeinstellungen" },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function sitzungsToken() {
|
async function sitzungsToken() {
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
|
import { SystemFormular } from "@/components/einstellungen/SystemFormular";
|
||||||
import { ContentHeader } from "@/components/layout/ContentHeader";
|
import { ContentHeader } from "@/components/layout/ContentHeader";
|
||||||
import { Card } from "@/components/ui/Card";
|
import { Card } from "@/components/ui/Card";
|
||||||
import { APP_VERSION } from "@/lib/version";
|
import { APP_VERSION } from "@/lib/version";
|
||||||
|
import { einstellungen } from "@/server/einstellungen";
|
||||||
|
|
||||||
export const metadata = { title: "System" };
|
export const metadata = { title: "System" };
|
||||||
|
|
||||||
export default function SystemSeite() {
|
export default async function SystemSeite() {
|
||||||
|
const werte = await einstellungen();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ContentHeader titel="System" brotkrumen={[{ titel: "Einstellungen" }, { titel: "System" }]} />
|
<ContentHeader titel="System" brotkrumen={[{ titel: "Einstellungen" }, { titel: "System" }]} />
|
||||||
<div className="app-content">
|
<div className="app-content">
|
||||||
<div className="container-fluid">
|
<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">
|
<dl className="row mb-0">
|
||||||
<dt className="col-4 fw-normal text-body-secondary">Version</dt>
|
<dt className="col-4 fw-normal text-body-secondary">Version</dt>
|
||||||
<dd className="col-8">{APP_VERSION}</dd>
|
<dd className="col-8">{APP_VERSION}</dd>
|
||||||
<dt className="col-4 fw-normal text-body-secondary">Datenquelle</dt>
|
<dt className="col-4 fw-normal text-body-secondary">Zeitzone des Servers</dt>
|
||||||
<dd className="col-8">Beispieldaten (Prototyp, #002)</dd>
|
<dd className="col-8">{Intl.DateTimeFormat().resolvedOptions().timeZone}</dd>
|
||||||
<dt className="col-4 fw-normal text-body-secondary">Warnregeln</dt>
|
<dt className="col-4 fw-normal text-body-secondary">Nur in der Umgebung</dt>
|
||||||
<dd className="col-8">Konfigurierbar ab #011</dd>
|
<dd className="col-8">
|
||||||
|
<code>APP_SECRET</code> und <code>DATABASE_URL</code> – beide werden gebraucht, bevor
|
||||||
|
die Datenbank gelesen werden kann.
|
||||||
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { csvAntwort, csvDatei } from "@/lib/csv";
|
|||||||
import { formatDatum } from "@/lib/format";
|
import { formatDatum } from "@/lib/format";
|
||||||
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
||||||
import { hostListe, type HostFilter } from "@/server/queries/hostliste";
|
import { hostListe, type HostFilter } from "@/server/queries/hostliste";
|
||||||
|
import { einstellungen } from "@/server/einstellungen";
|
||||||
|
|
||||||
type ExportHost = Awaited<ReturnType<typeof hostListe>>["hosts"][number];
|
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);
|
const datum = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
if (new URL(request.url).searchParams.get("format") === "pdf") {
|
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(
|
return pdfAntwort(
|
||||||
Bericht({
|
Bericht({
|
||||||
|
firma: firmaAngaben,
|
||||||
titel: "Host-Liste",
|
titel: "Host-Liste",
|
||||||
untertitel: `${alle.length} Hosts`,
|
untertitel: `${alle.length} Hosts`,
|
||||||
quer: true,
|
quer: true,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { PRODUKT_TEXT, STATUS_TEXT, statusAusDatenbank } from "@/lib/anzeige";
|
|||||||
import { formatDatum, formatEuro } from "@/lib/format";
|
import { formatDatum, formatEuro } from "@/lib/format";
|
||||||
import { Abschnitt, Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
import { Abschnitt, Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
||||||
import { kundeMitDetails, laufzeitenVonKunde, summen, summenProJahr } from "@/server/queries/kunden";
|
import { kundeMitDetails, laufzeitenVonKunde, summen, summenProJahr } from "@/server/queries/kunden";
|
||||||
|
import { einstellungen } from "@/server/einstellungen";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -15,9 +16,12 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
|
|||||||
const gesamt = summen(laufzeiten);
|
const gesamt = summen(laufzeiten);
|
||||||
const proJahr = summenProJahr(laufzeiten);
|
const proJahr = summenProJahr(laufzeiten);
|
||||||
const datum = new Date().toISOString().slice(0, 10);
|
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(
|
return pdfAntwort(
|
||||||
Bericht({
|
Bericht({
|
||||||
|
firma: firmaAngaben,
|
||||||
titel: kunde.name,
|
titel: kunde.name,
|
||||||
untertitel: `Kundennummer ${kunde.kundennummer}${kunde.ansprechpartner ? ` · ${kunde.ansprechpartner}` : ""}`,
|
untertitel: `Kundennummer ${kunde.kundennummer}${kunde.ansprechpartner ? ` · ${kunde.ansprechpartner}` : ""}`,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { csvAntwort, csvDatei } from "@/lib/csv";
|
import { csvAntwort, csvDatei } from "@/lib/csv";
|
||||||
import { formatDatum, formatEuro } from "@/lib/format";
|
import { formatDatum, formatEuro } from "@/lib/format";
|
||||||
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
||||||
|
import { einstellungen } from "@/server/einstellungen";
|
||||||
import {
|
import {
|
||||||
kundenListe,
|
kundenListe,
|
||||||
offeneRechnungspunkteProKunde,
|
offeneRechnungspunkteProKunde,
|
||||||
@@ -30,8 +31,12 @@ export async function GET(request: Request) {
|
|||||||
const datum = new Date().toISOString().slice(0, 10);
|
const datum = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
if (format === "pdf") {
|
if (format === "pdf") {
|
||||||
|
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
|
||||||
|
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
|
||||||
|
|
||||||
return pdfAntwort(
|
return pdfAntwort(
|
||||||
Bericht({
|
Bericht({
|
||||||
|
firma: firmaAngaben,
|
||||||
titel: "Kundenübersicht",
|
titel: "Kundenübersicht",
|
||||||
untertitel: `${zeilen.length} Kunden`,
|
untertitel: `${zeilen.length} Kunden`,
|
||||||
quer: true,
|
quer: true,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { CopyableCode } from "@/components/ui/CopyableCode";
|
|||||||
import { PRODUKT_TEXT } from "@/lib/anzeige";
|
import { PRODUKT_TEXT } from "@/lib/anzeige";
|
||||||
import { formatDatum, formatEuro } from "@/lib/format";
|
import { formatDatum, formatEuro } from "@/lib/format";
|
||||||
import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen";
|
import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen";
|
||||||
|
import { einstellungen, faelligkeitsVorschlag } from "@/server/einstellungen";
|
||||||
import { laufzeitDetail } from "@/server/queries/rechnungen";
|
import { laufzeitDetail } from "@/server/queries/rechnungen";
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
|
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 verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
|
||||||
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : 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)}`;
|
const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -148,9 +155,9 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
werte={{
|
werte={{
|
||||||
nummer: einkauf?.nummer ?? null,
|
nummer: einkauf?.nummer ?? null,
|
||||||
datum: einkauf?.datum.toISOString() ?? null,
|
datum: einkauf?.datum.toISOString() ?? null,
|
||||||
faelligAm: einkauf?.faelligAm?.toISOString() ?? null,
|
faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag,
|
||||||
nettoBetrag: einkaufBetrag,
|
nettoBetrag: einkaufBetrag,
|
||||||
ustSatz: Number(einkauf?.ustSatz ?? 19),
|
ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz),
|
||||||
rabatt:
|
rabatt:
|
||||||
einkauf?.rabatt === null || einkauf?.rabatt === undefined
|
einkauf?.rabatt === null || einkauf?.rabatt === undefined
|
||||||
? null
|
? null
|
||||||
@@ -179,8 +186,8 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
erstellt: verkauf?.erstellt ?? false,
|
erstellt: verkauf?.erstellt ?? false,
|
||||||
nummer: verkauf?.nummer ?? null,
|
nummer: verkauf?.nummer ?? null,
|
||||||
datum: verkauf?.datum?.toISOString() ?? null,
|
datum: verkauf?.datum?.toISOString() ?? null,
|
||||||
nettoBetrag: verkaufBetrag,
|
nettoBetrag: verkaufVorschlag,
|
||||||
ustSatz: Number(verkauf?.ustSatz ?? 19),
|
ustSatz: Number(verkauf?.ustSatz ?? vorgaben.steuersatz),
|
||||||
bezahlt: verkauf?.bezahlt ?? false,
|
bezahlt: verkauf?.bezahlt ?? false,
|
||||||
bezahltAm: verkauf?.bezahltAm?.toISOString() ?? null,
|
bezahltAm: verkauf?.bezahltAm?.toISOString() ?? null,
|
||||||
anhangId: verkauf?.anhangId ?? null,
|
anhangId: verkauf?.anhangId ?? null,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { csvAntwort, csvDatei } from "@/lib/csv";
|
|||||||
import { formatDatum, formatEuro } from "@/lib/format";
|
import { formatDatum, formatEuro } from "@/lib/format";
|
||||||
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
import { Bericht, pdfAntwort, Tabelle } from "@/server/pdf/dokument";
|
||||||
import { rechnungsListe, type RechnungsFilter } from "@/server/queries/rechnungen";
|
import { rechnungsListe, type RechnungsFilter } from "@/server/queries/rechnungen";
|
||||||
|
import { einstellungen } from "@/server/einstellungen";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -21,8 +22,12 @@ export async function GET(request: Request) {
|
|||||||
const zeitraum = filter.jahr ? `Jahr ${filter.jahr}` : "alle Jahre";
|
const zeitraum = filter.jahr ? `Jahr ${filter.jahr}` : "alle Jahre";
|
||||||
|
|
||||||
if (format === "pdf") {
|
if (format === "pdf") {
|
||||||
|
const { firmaName, firmaAnschrift, firmaEmail, zeitzone } = await einstellungen();
|
||||||
|
const firmaAngaben = { name: firmaName, anschrift: firmaAnschrift, email: firmaEmail, zeitzone };
|
||||||
|
|
||||||
return pdfAntwort(
|
return pdfAntwort(
|
||||||
Bericht({
|
Bericht({
|
||||||
|
firma: firmaAngaben,
|
||||||
titel: "Rechnungsliste",
|
titel: "Rechnungsliste",
|
||||||
untertitel: `${zeilen.length} Laufzeiten · ${zeitraum}`,
|
untertitel: `${zeilen.length} Laufzeiten · ${zeitraum}`,
|
||||||
quer: true,
|
quer: true,
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
/** Aktuelles Agent-Skript, wird vom Installer und vom Update-Mechanismus geladen. */
|
/** Aktuelles Agent-Skript, wird vom Installer und vom Update-Mechanismus geladen. */
|
||||||
export async function GET(request: Request) {
|
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");
|
return skriptAntwort(inhalt, "proxledger-agent.sh");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { cookies, headers } from "next/headers";
|
|||||||
import { clientIp } from "@/lib/ratelimit";
|
import { clientIp } from "@/lib/ratelimit";
|
||||||
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
|
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
|
||||||
import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer";
|
import { AnmeldeFehler, mitPasswortAnmelden } from "@/server/auth/benutzer";
|
||||||
|
import { einstellungen } from "@/server/einstellungen";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -62,13 +63,17 @@ export async function POST(request: Request) {
|
|||||||
const kopfzeilen = await headers();
|
const kopfzeilen = await headers();
|
||||||
const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen));
|
const benutzer = await mitPasswortAnmelden(email, passwort, clientIp(kopfzeilen));
|
||||||
|
|
||||||
const token = await sitzungErzeugen({
|
const { sitzungsdauerStunden } = await einstellungen();
|
||||||
|
const token = await sitzungErzeugen(
|
||||||
|
{
|
||||||
benutzerId: benutzer.id,
|
benutzerId: benutzer.id,
|
||||||
email: benutzer.email,
|
email: benutzer.email,
|
||||||
name: benutzer.name,
|
name: benutzer.name,
|
||||||
herkunft: "passwort",
|
herkunft: "passwort",
|
||||||
});
|
},
|
||||||
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen());
|
sitzungsdauerStunden,
|
||||||
|
);
|
||||||
|
(await cookies()).set(SITZUNGS_COOKIE, token, cookieOptionen(sitzungsdauerStunden));
|
||||||
|
|
||||||
return Response.redirect(new URL(ziel, request.url), 303);
|
return Response.redirect(new URL(ziel, request.url), 303);
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { cookies } from "next/headers";
|
|||||||
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
|
import { cookieOptionen, SITZUNGS_COOKIE, sitzungErzeugen } from "@/lib/sitzung";
|
||||||
import { mitOidcAnmelden } from "@/server/auth/benutzer";
|
import { mitOidcAnmelden } from "@/server/auth/benutzer";
|
||||||
import { anmeldungAbschliessen, oidcEinstellungen } from "@/server/auth/oidc";
|
import { anmeldungAbschliessen, oidcEinstellungen } from "@/server/auth/oidc";
|
||||||
|
import { einstellungen as systemEinstellungen } from "@/server/einstellungen";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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 angaben = await anmeldungAbschliessen(einstellungen, new URL(request.url), pruefwert, zustand);
|
||||||
const benutzer = await mitOidcAnmelden(angaben.subject, angaben.email, angaben.name);
|
const benutzer = await mitOidcAnmelden(angaben.subject, angaben.email, angaben.name);
|
||||||
|
|
||||||
const token = await sitzungErzeugen({
|
const { sitzungsdauerStunden } = await systemEinstellungen();
|
||||||
|
const token = await sitzungErzeugen(
|
||||||
|
{
|
||||||
benutzerId: benutzer.id,
|
benutzerId: benutzer.id,
|
||||||
email: benutzer.email,
|
email: benutzer.email,
|
||||||
name: benutzer.name,
|
name: benutzer.name,
|
||||||
herkunft: "oidc",
|
herkunft: "oidc",
|
||||||
});
|
},
|
||||||
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen());
|
sitzungsdauerStunden,
|
||||||
|
);
|
||||||
|
speicher.set(SITZUNGS_COOKIE, token, cookieOptionen(sitzungsdauerStunden));
|
||||||
|
|
||||||
return Response.redirect(new URL(ziel.startsWith("/") ? ziel : "/", request.url), 302);
|
return Response.redirect(new URL(ziel.startsWith("/") ? ziel : "/", request.url), 302);
|
||||||
} catch (fehler) {
|
} 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 …`. */
|
/** Installationsskript für den One-Liner `curl -fsSL <url>/install.sh | bash -s -- --token …`. */
|
||||||
export async function GET(request: Request) {
|
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");
|
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";
|
export const SITZUNGS_COOKIE = "pl_sitzung";
|
||||||
|
/** Rückfall, solange nichts in den Einstellungen steht (#025). */
|
||||||
export const SITZUNGSDAUER_STUNDEN = 12;
|
export const SITZUNGSDAUER_STUNDEN = 12;
|
||||||
|
|
||||||
/** Eine gelesene Sitzung weiß zusätzlich, wann sie ausgestellt wurde – nötig für den Widerruf. */
|
/** 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);
|
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 })
|
return new SignJWT({ ...sitzung })
|
||||||
.setProtectedHeader({ alg: "HS256" })
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
.setSubject(sitzung.benutzerId)
|
.setSubject(sitzung.benutzerId)
|
||||||
.setIssuedAt()
|
.setIssuedAt()
|
||||||
.setIssuer("proxledger")
|
.setIssuer("proxledger")
|
||||||
.setExpirationTime(`${SITZUNGSDAUER_STUNDEN}h`)
|
.setExpirationTime(`${dauerStunden}h`)
|
||||||
.sign(schluessel());
|
.sign(schluessel());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,12 +74,12 @@ export function ueberHttps(): boolean {
|
|||||||
return process.env.NODE_ENV === "production";
|
return process.env.NODE_ENV === "production";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cookieOptionen() {
|
export function cookieOptionen(dauerStunden: number = SITZUNGSDAUER_STUNDEN) {
|
||||||
return {
|
return {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax" as const,
|
sameSite: "lax" as const,
|
||||||
secure: ueberHttps(),
|
secure: ueberHttps(),
|
||||||
path: "/",
|
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,
|
datum: rechnungsdatum,
|
||||||
faelligAm: datum(formular.get("faelligAm")),
|
faelligAm: datum(formular.get("faelligAm")),
|
||||||
nettoBetrag: netto,
|
nettoBetrag: netto,
|
||||||
ustSatz: betrag(formular.get("ustSatz")) ?? 19,
|
ustSatz: betrag(formular.get("ustSatz")) ?? undefined,
|
||||||
rabatt: betrag(formular.get("rabatt")),
|
rabatt: betrag(formular.get("rabatt")),
|
||||||
zahlungsart: formular.get("zahlungsart")?.toString().trim() || null,
|
zahlungsart: formular.get("zahlungsart")?.toString().trim() || null,
|
||||||
bezahlt: formular.get("bezahlt") === "on",
|
bezahlt: formular.get("bezahlt") === "on",
|
||||||
@@ -139,7 +139,7 @@ export async function kundenrechnungSpeichern(
|
|||||||
nummer: formular.get("nummer")?.toString().trim() || null,
|
nummer: formular.get("nummer")?.toString().trim() || null,
|
||||||
datum: datum(formular.get("datum")),
|
datum: datum(formular.get("datum")),
|
||||||
nettoBetrag: betrag(formular.get("nettoBetrag")),
|
nettoBetrag: betrag(formular.get("nettoBetrag")),
|
||||||
ustSatz: betrag(formular.get("ustSatz")) ?? 19,
|
ustSatz: betrag(formular.get("ustSatz")) ?? undefined,
|
||||||
bezahlt: formular.get("bezahlt") === "on",
|
bezahlt: formular.get("bezahlt") === "on",
|
||||||
bezahltAm: datum(formular.get("bezahltAm")),
|
bezahltAm: datum(formular.get("bezahltAm")),
|
||||||
anhangId,
|
anhangId,
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { einstellungen, onCalendar } from "@/server/einstellungen";
|
||||||
import { AGENT_VERSION } from "./http";
|
import { AGENT_VERSION } from "./http";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liefert die Agent-Skripte aus dem Verzeichnis `agent/` aus und setzt dabei
|
* Liefert die Agent-Skripte aus dem Verzeichnis `agent/` aus und setzt dabei Dashboard-Adresse,
|
||||||
* Dashboard-Adresse und Agent-Version ein.
|
* Agent-Version und die Vorgaben aus *Einstellungen → System* ein (Melderhythmus, Streuung,
|
||||||
|
* Selbstaktualisierung).
|
||||||
*/
|
*/
|
||||||
export async function skriptLaden(datei: "install.sh" | "proxledger-agent.sh", basisUrl: string) {
|
export async function skriptLaden(datei: "install.sh" | "proxledger-agent.sh", basisUrl: string) {
|
||||||
const pfad = path.join(process.cwd(), "agent", datei);
|
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
|
return inhalt
|
||||||
.replaceAll("@@DASHBOARD_URL@@", basisUrl.replace(/\/$/, ""))
|
.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;
|
if (process.env.APP_URL) return process.env.APP_URL;
|
||||||
return new URL(request.url).origin;
|
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 { prisma } from "@/lib/db";
|
||||||
import { RateLimit } from "@/lib/ratelimit";
|
import { RateLimit } from "@/lib/ratelimit";
|
||||||
import { protokolliere } from "@/server/audit";
|
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 {}
|
export class AnmeldeFehler extends Error {}
|
||||||
|
|
||||||
@@ -15,8 +32,9 @@ export async function mitPasswortAnmelden(
|
|||||||
passwort: string,
|
passwort: string,
|
||||||
quelle: string,
|
quelle: string,
|
||||||
): Promise<BenutzerModel> {
|
): Promise<BenutzerModel> {
|
||||||
|
const { anmeldeversuche } = await einstellungen();
|
||||||
const kennung = `${quelle}:${email.toLowerCase()}`;
|
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.");
|
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.
|
* 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> {
|
export async function mitOidcAnmelden(subject: string, email: string, name: string): Promise<BenutzerModel> {
|
||||||
const normalisiert = email.toLowerCase().trim();
|
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 =
|
const vorhanden =
|
||||||
(await prisma.benutzer.findUnique({ where: { oidcSubject: subject } })) ??
|
(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.");
|
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
|
const benutzer = vorhanden
|
||||||
? await prisma.benutzer.update({
|
? await prisma.benutzer.update({
|
||||||
where: { id: vorhanden.id },
|
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,
|
untertitel,
|
||||||
children,
|
children,
|
||||||
quer = false,
|
quer = false,
|
||||||
|
firma,
|
||||||
}: {
|
}: {
|
||||||
titel: string;
|
titel: string;
|
||||||
untertitel?: string;
|
untertitel?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
quer?: boolean;
|
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", {
|
const erstellt = new Intl.DateTimeFormat("de-DE", {
|
||||||
timeZone: "Europe/Berlin",
|
timeZone: firma?.zeitzone || "Europe/Berlin",
|
||||||
dateStyle: "long",
|
dateStyle: "long",
|
||||||
}).format(new Date());
|
}).format(new Date());
|
||||||
|
|
||||||
@@ -138,8 +141,11 @@ export function Bericht({
|
|||||||
<View style={stile.kopf} fixed>
|
<View style={stile.kopf} fixed>
|
||||||
<Logo />
|
<Logo />
|
||||||
<View style={stile.kopfText}>
|
<View style={stile.kopfText}>
|
||||||
<Text style={stile.marke}>ProxLedger</Text>
|
<Text style={stile.marke}>{firma?.name || "ProxLedger"}</Text>
|
||||||
<Text style={stile.untertitel}>Proxmox Subscription Dashboard</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>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { protokolliere } from "@/server/audit";
|
import { protokolliere } from "@/server/audit";
|
||||||
|
import { einstellungen } from "@/server/einstellungen";
|
||||||
|
|
||||||
export class RechnungsFehler extends Error {}
|
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 (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
|
||||||
if (!eingabe.nummer.trim()) throw new RechnungsFehler("Bitte eine Rechnungsnummer angeben.");
|
if (!eingabe.nummer.trim()) throw new RechnungsFehler("Bitte eine Rechnungsnummer angeben.");
|
||||||
|
|
||||||
|
const { steuersatz: vorgabeSteuersatz } = await einstellungen();
|
||||||
const nummer = eingabe.nummer.trim();
|
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)}`;
|
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: {
|
data: {
|
||||||
datum: eingabe.datum,
|
datum: eingabe.datum,
|
||||||
faelligAm: eingabe.faelligAm,
|
faelligAm: eingabe.faelligAm,
|
||||||
ustSatz: eingabe.ustSatz ?? 19,
|
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
|
||||||
rabatt: eingabe.rabatt ?? null,
|
rabatt: eingabe.rabatt ?? null,
|
||||||
zahlungsart: eingabe.zahlungsart ?? null,
|
zahlungsart: eingabe.zahlungsart ?? null,
|
||||||
bezahlt: eingabe.bezahlt,
|
bezahlt: eingabe.bezahlt,
|
||||||
@@ -94,7 +96,7 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
|
|||||||
datum: eingabe.datum,
|
datum: eingabe.datum,
|
||||||
faelligAm: eingabe.faelligAm,
|
faelligAm: eingabe.faelligAm,
|
||||||
nettoBetrag: eingabe.nettoBetrag,
|
nettoBetrag: eingabe.nettoBetrag,
|
||||||
ustSatz: eingabe.ustSatz ?? 19,
|
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
|
||||||
rabatt: eingabe.rabatt ?? null,
|
rabatt: eingabe.rabatt ?? null,
|
||||||
zahlungsart: eingabe.zahlungsart ?? null,
|
zahlungsart: eingabe.zahlungsart ?? null,
|
||||||
bezahlt: eingabe.bezahlt,
|
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.");
|
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 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 bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`;
|
||||||
const betrag = eingabe.nettoBetrag ?? 0;
|
const betrag = eingabe.nettoBetrag ?? 0;
|
||||||
@@ -160,7 +163,7 @@ export async function kundenrechnungErfassen(laufzeitId: string, eingabe: Kunden
|
|||||||
erstellt: eingabe.erstellt,
|
erstellt: eingabe.erstellt,
|
||||||
nummer,
|
nummer,
|
||||||
datum: eingabe.datum ?? null,
|
datum: eingabe.datum ?? null,
|
||||||
ustSatz: eingabe.ustSatz ?? 19,
|
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
|
||||||
bezahlt: eingabe.bezahlt,
|
bezahlt: eingabe.bezahlt,
|
||||||
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
|
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
|
||||||
anhangId: eingabe.anhangId ?? null,
|
anhangId: eingabe.anhangId ?? null,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Cron } from "croner";
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich";
|
import { alertsAbgleichen, warnregelnSicherstellen } from "@/server/alerts/abgleich";
|
||||||
|
import { altdatenLoeschen } from "@/server/aufraeumen";
|
||||||
import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand";
|
import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/versand";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,6 +15,7 @@ import { neueAlertsMelden, zusammenfassungSenden } from "@/server/notifications/
|
|||||||
const ZEITZONE = "Europe/Berlin";
|
const ZEITZONE = "Europe/Berlin";
|
||||||
const ZEITPLAN = process.env.WORKER_CRON ?? "0 * * * *"; // stündlich zur vollen Stunde
|
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 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) {
|
function protokoll(nachricht: string) {
|
||||||
console.log(`[${new Date().toISOString()}] ${nachricht}`);
|
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() {
|
async function start() {
|
||||||
await warnregelnSicherstellen();
|
await warnregelnSicherstellen();
|
||||||
protokoll(
|
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.
|
// Einmal sofort, damit nach einem Neustart nicht bis zur nächsten vollen Stunde gewartet wird.
|
||||||
@@ -72,6 +85,7 @@ async function start() {
|
|||||||
const aufgaben = [
|
const aufgaben = [
|
||||||
new Cron(ZEITPLAN, { timezone: ZEITZONE }, durchlauf),
|
new Cron(ZEITPLAN, { timezone: ZEITZONE }, durchlauf),
|
||||||
new Cron(ZUSAMMENFASSUNG_ZEITPLAN, { timezone: ZEITZONE }, zusammenfassung),
|
new Cron(ZUSAMMENFASSUNG_ZEITPLAN, { timezone: ZEITZONE }, zusammenfassung),
|
||||||
|
new Cron(AUFRAEUM_ZEITPLAN, { timezone: ZEITZONE }, aufraeumen),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||