Initial
@@ -0,0 +1,148 @@
|
|||||||
|
name: EK-DOS-WEB bauen und ausrollen
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: 10.0.11.131
|
||||||
|
DEPLOY_USER: deploy
|
||||||
|
DEPLOY_ROOT: /var/www/ekdos
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# Die Oberfläche wird hier zu statischen Dateien kompiliert. Node existiert
|
||||||
|
# ausschliesslich in diesem Container; auf dem Zielserver läuft keines.
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: node:22-bookworm-slim
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: pnpm bereitstellen
|
||||||
|
run: corepack enable && corepack prepare pnpm@10.16.1 --activate
|
||||||
|
|
||||||
|
- name: Abhängigkeiten installieren
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Typprüfung getrennt, damit ein Typfehler nicht als Bündelfehler erscheint.
|
||||||
|
- name: Typen prüfen
|
||||||
|
run: pnpm typecheck
|
||||||
|
|
||||||
|
- name: Bündel bauen
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: frontend-dist
|
||||||
|
path: frontend/dist
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# Das Backend wird nicht kompiliert, aber geprüft: Syntax aller Dateien und
|
||||||
|
# ein Abhängigkeitsbaum ohne Entwicklungspakete.
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: php:8.5-cli
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Composer und Erweiterungen bereitstellen
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends git unzip libpq-dev
|
||||||
|
docker-php-ext-install pdo_pgsql
|
||||||
|
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
|
||||||
|
|
||||||
|
- name: Abhängigkeiten installieren
|
||||||
|
run: composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --ignore-platform-req=ext-redis
|
||||||
|
|
||||||
|
- name: Syntax prüfen
|
||||||
|
run: find src public bin -type f \( -name '*.php' -o -name 'ekdos' \) -print0 | xargs -0 -n1 php -l
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: backend-build
|
||||||
|
path: |
|
||||||
|
backend
|
||||||
|
!backend/.env
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# Ausrollen. Jede Veröffentlichung landet in einem eigenen Verzeichnis; erst
|
||||||
|
# der Symlink-Tausch macht sie sichtbar. Ein Fehlschlag lässt die laufende
|
||||||
|
# Fassung unberührt.
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [frontend, backend]
|
||||||
|
if: gitea.ref == 'refs/heads/main'
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: frontend-dist
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: backend-build
|
||||||
|
path: backend
|
||||||
|
|
||||||
|
- name: SSH einrichten
|
||||||
|
run: |
|
||||||
|
install -m 700 -d ~/.ssh
|
||||||
|
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Oberfläche übertragen
|
||||||
|
run: |
|
||||||
|
RELEASE="$DEPLOY_ROOT/releases/${{ gitea.sha }}"
|
||||||
|
ssh "$DEPLOY_USER@$DEPLOY_HOST" "mkdir -p '$RELEASE'"
|
||||||
|
rsync -az --delete dist/ "$DEPLOY_USER@$DEPLOY_HOST:$RELEASE/"
|
||||||
|
|
||||||
|
- name: Backend übertragen
|
||||||
|
run: |
|
||||||
|
# .env bleibt auf dem Server und wird nie überschrieben.
|
||||||
|
rsync -az --delete \
|
||||||
|
--exclude '.env' \
|
||||||
|
--exclude 'var/cache' \
|
||||||
|
backend/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_ROOT/backend/"
|
||||||
|
|
||||||
|
- name: Umschalten und neu laden
|
||||||
|
run: |
|
||||||
|
ssh "$DEPLOY_USER@$DEPLOY_HOST" bash -euo pipefail <<'REMOTE'
|
||||||
|
cd /var/www/ekdos
|
||||||
|
|
||||||
|
# Schema fortschreiben, bevor die neue Oberfläche sichtbar wird.
|
||||||
|
php backend/bin/ekdos migrate
|
||||||
|
|
||||||
|
# Atomarer Tausch: ln -sfn auf ein temporäres Ziel, dann umbenennen.
|
||||||
|
ln -sfn "releases/${GITEA_SHA:-$(ls -1t releases | head -1)}" current.new
|
||||||
|
mv -Tf current.new current
|
||||||
|
|
||||||
|
# Opcache hält den alten Quelltext, bis fpm neu lädt.
|
||||||
|
sudo systemctl reload php8.5-fpm
|
||||||
|
|
||||||
|
# Zwischengespeicherte n8n-Antworten passen womöglich nicht mehr
|
||||||
|
# zum neuen Abbildungscode.
|
||||||
|
php backend/bin/ekdos cache:flush
|
||||||
|
|
||||||
|
# Die letzten fünf Veröffentlichungen behalten.
|
||||||
|
ls -1t releases | tail -n +6 | xargs -r -I{} rm -rf "releases/{}"
|
||||||
|
REMOTE
|
||||||
|
env:
|
||||||
|
GITEA_SHA: ${{ gitea.sha }}
|
||||||
|
|
||||||
|
- name: Gesundheitsprüfung
|
||||||
|
run: |
|
||||||
|
ssh "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||||
|
'curl -fsS -o /dev/null -w "%{http_code}\n" http://127.0.0.1/api/health'
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Abhängigkeiten
|
||||||
|
node_modules/
|
||||||
|
backend/vendor/
|
||||||
|
|
||||||
|
# Baugebnisse
|
||||||
|
frontend/dist/
|
||||||
|
backend/var/
|
||||||
|
|
||||||
|
# Umgebung: gehört auf den Server, nie ins Repository
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Werkzeugreste
|
||||||
|
*.tsbuildinfo
|
||||||
|
.wrangler/
|
||||||
|
.sites-runtime/
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# Editor und Betriebssystem
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Die abgelöste Next.js-Fassung liegt bewusst noch im Arbeitsverzeichnis,
|
||||||
|
# gehört aber nicht in die neue Versionsgeschichte. Zeile entfernen, wer sie
|
||||||
|
# vor dem Löschen doch einchecken möchte.
|
||||||
|
legacy-nextjs/
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# EK-DOS-WEB
|
||||||
|
|
||||||
|
Büroanwendung der Elektro Krüger GmbH. Tickets, Angebote, Rechnungen, Kundenstamm,
|
||||||
|
interne Aufgaben, Stundennachweise und Online-Käufe -- die Fachdaten liegen in
|
||||||
|
n8n, die Benutzer in einer eigenen Datenbank.
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser
|
||||||
|
│ HTTPS, Sitzungscookie (undurchsichtig)
|
||||||
|
▼
|
||||||
|
nginx ────────────────┬──────────────────────────────┐
|
||||||
|
│ statisch │ /api/ über Unix-Socket │
|
||||||
|
▼ ▼ │
|
||||||
|
React-Bündel php8.5-fpm ──► PostgreSQL (Benutzer, Rollen, Protokoll)
|
||||||
|
(nur Dateien) Slim 4 ──► Redis (Sitzungen, n8n-Cache)
|
||||||
|
──► n8n (alle Fachdaten)
|
||||||
|
```
|
||||||
|
|
||||||
|
Auf dem Server läuft **kein Node**. Die Oberfläche wird im Gitea-Lauf zu
|
||||||
|
statischen Dateien kompiliert; ausgeliefert wird sie von nginx.
|
||||||
|
|
||||||
|
## Verzeichnisse
|
||||||
|
|
||||||
|
| Ordner | Inhalt |
|
||||||
|
| ------------------- | ----------------------------------------------------------------- |
|
||||||
|
| `backend/` | PHP 8.5, Slim 4. BFF-Sitzung, Benutzerverwaltung, n8n-Weiterleitung |
|
||||||
|
| `frontend/` | React 19 + Vite. Baut nach `frontend/dist/` |
|
||||||
|
| `deploy/nginx/` | nginx-Site |
|
||||||
|
| `deploy/php-fpm/` | php-fpm-Pool |
|
||||||
|
| `.gitea/workflows/` | Bau- und Ausrollstrecke |
|
||||||
|
| `docs/` | Betrieb und n8n-Schnittstelle |
|
||||||
|
| `legacy-nextjs/` | Die abgelöste Next.js-Fassung. Kann nach dem Umstieg weg |
|
||||||
|
|
||||||
|
## Wie es zusammenhängt
|
||||||
|
|
||||||
|
**Die Anmeldung** ist ein Backend-For-Frontend: der Browser hält nur eine
|
||||||
|
zufällige Kennung im Cookie, alles Weitere -- Benutzer, Rolle, Rechte -- liegt in
|
||||||
|
Redis. Das Cookie wächst dadurch nicht mit den Rechten mit, und eine
|
||||||
|
Rechteänderung wirkt sofort, weil das Backend die Sitzung wegwerfen kann.
|
||||||
|
|
||||||
|
**Die Rechte** ersetzen die früheren Namensabfragen. Statt `user === "Sascha"`
|
||||||
|
gibt es `Permission::OffersDelete`, und Rollen bündeln diese Rechte:
|
||||||
|
|
||||||
|
| Rolle | darf zusätzlich |
|
||||||
|
| ---------------- | -------------------------------------------------------------------- |
|
||||||
|
| `admin` | alles, einschließlich Benutzerverwaltung |
|
||||||
|
| `inhaber` | Angebote/Kunden/Aufgaben löschen, Online-Käufe eintragen und löschen |
|
||||||
|
| `buero` | Online-Käufe abschließen, Rechnungen bearbeiten und versenden |
|
||||||
|
|
||||||
|
`inhaber` entspricht dem, was Sascha vorher durfte, `buero` dem von Svenja.
|
||||||
|
|
||||||
|
**Die n8n-Daten** kommen weiter live aus den Webhooks, aber durch einen kurzen
|
||||||
|
Redis-Cache. Drei Dinge passieren dort: eine frische Antwort wird für 15--300
|
||||||
|
Sekunden wiederverwendet; läuft sie ab, während mehrere Ansichten gleichzeitig
|
||||||
|
fragen, holt genau **eine** die Daten (Single-Flight); und ist n8n nicht
|
||||||
|
erreichbar, wird die letzte bekannte Antwort ausgeliefert statt einer
|
||||||
|
Fehlerseite. Jeder schreibende Aufruf verwirft seine Gruppe sofort.
|
||||||
|
|
||||||
|
## Erste Einrichtung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
cp .env.example .env # DB, Redis, n8n-Geheimnisse eintragen
|
||||||
|
composer install --no-dev --optimize-autoloader
|
||||||
|
php bin/ekdos migrate
|
||||||
|
php bin/ekdos user:create # der erste Administrator
|
||||||
|
php bin/ekdos check # Postgres, Redis und n8n prüfen
|
||||||
|
```
|
||||||
|
|
||||||
|
Details zu nginx, php-fpm und dem Ausrollen: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
|
||||||
|
|
||||||
|
## Lokal entwickeln
|
||||||
|
|
||||||
|
Zwei Fenster:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php -S 127.0.0.1:8080 -t backend/public
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && pnpm install && pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Vite-Entwicklungsserver läuft auf `http://127.0.0.1:5173` und reicht `/api`
|
||||||
|
an den PHP-Server weiter. Ein anderes Backend lässt sich über `EKDOS_BACKEND`
|
||||||
|
angeben. Postgres und Redis müssen erreichbar sein; n8n nur, wenn die
|
||||||
|
Fachansichten Daten zeigen sollen -- Anmeldung und Benutzerverwaltung laufen ohne.
|
||||||
|
|
||||||
|
## Wartungs-CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php backend/bin/ekdos migrate # Schema fortschreiben
|
||||||
|
php backend/bin/ekdos user:create # Benutzer anlegen
|
||||||
|
php backend/bin/ekdos user:list # Benutzer auflisten
|
||||||
|
php backend/bin/ekdos user:password <name> # Passwort zurücksetzen
|
||||||
|
php backend/bin/ekdos cache:flush # n8n-Cache leeren
|
||||||
|
php backend/bin/ekdos check # Verbindungen prüfen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Was noch offen ist
|
||||||
|
|
||||||
|
`frontend/src/views/Dashboard.tsx` ist unverändert die eine große Komponente aus
|
||||||
|
der Next.js-Fassung (rund 4.700 Zeilen, 22 Ansichten in einer Zustandsmaschine).
|
||||||
|
Sie wurde bewusst **kopiert statt neu geschrieben**, damit an der Darstellung
|
||||||
|
nichts unbemerkt verrutscht. Herausgelöst sind bisher Anmeldung, Sitzung und die
|
||||||
|
neue Benutzeransicht.
|
||||||
|
|
||||||
|
Die Aufteilung in eine Ansicht pro Datei ist der nächste sinnvolle Schritt.
|
||||||
|
Der Weg dorthin ohne Umschreiben der Markup-Blöcke: den Zustand in einen
|
||||||
|
`AppState`-Kontext heben, und jede Ansichtsdatei oben genau die Namen
|
||||||
|
destrukturieren, die ihr JSX schon verwendet -- dann bleibt das Markup wörtlich
|
||||||
|
unverändert.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# EK-DOS-WEB Backend – Beispielkonfiguration.
|
||||||
|
# Kopieren nach .env und ausfuellen. Die Datei gehoert NICHT ins Repository.
|
||||||
|
|
||||||
|
APP_ENV=production
|
||||||
|
APP_DEBUG=false
|
||||||
|
|
||||||
|
# ── PostgreSQL: Benutzer, Rollen, Protokoll ────────────────────────────────
|
||||||
|
DB_DSN=pgsql:host=127.0.0.1;port=5432;dbname=ekdos
|
||||||
|
DB_USER=ekdos
|
||||||
|
DB_PASSWORD=
|
||||||
|
|
||||||
|
# ── Redis: Sitzungen (BFF-Ticket-Store) und n8n-Antwort-Cache ─────────────
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=0
|
||||||
|
REDIS_PREFIX=ekdos:
|
||||||
|
|
||||||
|
# ── n8n ───────────────────────────────────────────────────────────────────
|
||||||
|
# Der Dienst auf Port 5678 ist ausschliesslich ueber den CNAME erreichbar.
|
||||||
|
N8N_PUBLIC_BASE=https://n8n.elektro-krueger.eu
|
||||||
|
# Jeder weitere n8n-Dienst auf einem anderen Port wird ueber die RFC1918-Adresse
|
||||||
|
# angesprochen, z. B. N8n::internal(5679, '/webhook/...') -> http://10.0.11.131:5679/...
|
||||||
|
N8N_INTERNAL_HOST=10.0.11.131
|
||||||
|
N8N_TIMEOUT=15
|
||||||
|
N8N_CONNECT_TIMEOUT=5
|
||||||
|
|
||||||
|
# Geteilte Geheimnisse fuer geschuetzte n8n-Workflows.
|
||||||
|
N8N_CUSTOMER_KEY=
|
||||||
|
N8N_INVOICE_SYNC_SECRET=
|
||||||
|
# Optional: erlaubt n8n, den Cache aktiv zu verwerfen (POST /api/tickets/refresh).
|
||||||
|
N8N_REFRESH_SECRET=
|
||||||
|
|
||||||
|
# ── Sitzung ───────────────────────────────────────────────────────────────
|
||||||
|
SESSION_COOKIE=ekdos_session
|
||||||
|
# Leerlauf in Sekunden, danach ist eine erneute Anmeldung noetig.
|
||||||
|
SESSION_TTL=3600
|
||||||
|
# Hinter TLS immer true lassen. Nur fuer reines HTTP im LAN auf false setzen.
|
||||||
|
SESSION_COOKIE_SECURE=true
|
||||||
|
|
||||||
|
# ── Anmeldebremse (Redis) ─────────────────────────────────────────────────
|
||||||
|
LOGIN_MAX_ATTEMPTS=10
|
||||||
|
LOGIN_WINDOW=900
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/vendor/
|
||||||
|
/.env
|
||||||
|
composer.lock.bak
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wartungs-CLI.
|
||||||
|
*
|
||||||
|
* php bin/ekdos migrate Schema anlegen oder fortschreiben
|
||||||
|
* php bin/ekdos user:create Benutzer anlegen (Passwort wird abgefragt)
|
||||||
|
* php bin/ekdos user:list Benutzer auflisten
|
||||||
|
* php bin/ekdos user:password <name> Passwort zuruecksetzen
|
||||||
|
* php bin/ekdos cache:flush n8n-Cache leeren
|
||||||
|
* php bin/ekdos check Postgres, Redis und n8n pruefen
|
||||||
|
*
|
||||||
|
* Der erste Administrator wird mit user:create angelegt. Es gibt bewusst keinen
|
||||||
|
* Bootstrap-Benutzer aus der Umgebung: ein Passwort in einer .env ist ein
|
||||||
|
* Passwort, das niemand mehr aendert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use Ekdos\Auth\AuthService;
|
||||||
|
use Ekdos\Bootstrap\Container;
|
||||||
|
use Ekdos\N8n\Cache;
|
||||||
|
use Ekdos\N8n\Client;
|
||||||
|
use Ekdos\N8n\Endpoints;
|
||||||
|
use Ekdos\Support\Config;
|
||||||
|
use Ekdos\Users\Role;
|
||||||
|
use Ekdos\Users\UserRepository;
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
$projectRoot = dirname(__DIR__);
|
||||||
|
$command = $argv[1] ?? 'help';
|
||||||
|
|
||||||
|
function out(string $line = ''): void
|
||||||
|
{
|
||||||
|
fwrite(STDOUT, $line . PHP_EOL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(string $line): never
|
||||||
|
{
|
||||||
|
fwrite(STDERR, $line . PHP_EOL);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest eine Eingabe ohne Bildschirmausgabe. */
|
||||||
|
function readSecret(string $prompt): string
|
||||||
|
{
|
||||||
|
fwrite(STDERR, $prompt);
|
||||||
|
|
||||||
|
if (DIRECTORY_SEPARATOR === '/' && function_exists('shell_exec')) {
|
||||||
|
shell_exec('stty -echo 2>/dev/null');
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = trim((string) fgets(STDIN));
|
||||||
|
|
||||||
|
if (DIRECTORY_SEPARATOR === '/' && function_exists('shell_exec')) {
|
||||||
|
shell_exec('stty echo 2>/dev/null');
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, PHP_EOL);
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ask(string $prompt, string $fallback = ''): string
|
||||||
|
{
|
||||||
|
fwrite(STDERR, $prompt);
|
||||||
|
$value = trim((string) fgets(STDIN));
|
||||||
|
|
||||||
|
return $value !== '' ? $value : $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
$container = Container::build($projectRoot);
|
||||||
|
|
||||||
|
switch ($command) {
|
||||||
|
case 'migrate':
|
||||||
|
/** @var PDO $db */
|
||||||
|
$db = $container->get(PDO::class);
|
||||||
|
$db->exec('create table if not exists schema_migrations (version text primary key, applied_at timestamptz not null default now())');
|
||||||
|
|
||||||
|
$applied = $db->query('select version from schema_migrations')->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
$files = glob($projectRoot . '/migrations/*.sql') ?: [];
|
||||||
|
sort($files);
|
||||||
|
$ran = 0;
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$version = basename($file, '.sql');
|
||||||
|
|
||||||
|
if (in_array($version, $applied, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = file_get_contents($file);
|
||||||
|
|
||||||
|
if ($sql === false) {
|
||||||
|
fail('Migration nicht lesbar: ' . $file);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db->exec($sql);
|
||||||
|
$statement = $db->prepare('insert into schema_migrations (version) values (:version)');
|
||||||
|
$statement->execute(['version' => $version]);
|
||||||
|
$db->commit();
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$db->rollBack();
|
||||||
|
fail('Migration ' . $version . ' fehlgeschlagen: ' . $exception->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
out(' angewendet: ' . $version);
|
||||||
|
$ran++;
|
||||||
|
}
|
||||||
|
|
||||||
|
out($ran === 0 ? 'Das Schema ist aktuell.' : $ran . ' Migration(en) angewendet.');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'user:create':
|
||||||
|
/** @var UserRepository $users */
|
||||||
|
$users = $container->get(UserRepository::class);
|
||||||
|
|
||||||
|
$username = ask('Benutzername: ');
|
||||||
|
$displayName = ask('Anzeigename: ', $username);
|
||||||
|
$roleInput = ask('Rolle [admin|inhaber|buero] (admin): ', 'admin');
|
||||||
|
$role = Role::tryFrom($roleInput) ?? fail('Unbekannte Rolle: ' . $roleInput);
|
||||||
|
|
||||||
|
if ($username === '') {
|
||||||
|
fail('Der Benutzername darf nicht leer sein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($users->usernameTaken($username)) {
|
||||||
|
fail('Dieser Benutzername ist bereits vergeben.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$password = readSecret('Passwort: ');
|
||||||
|
|
||||||
|
if (($problem = AuthService::rejectWeakPassword($password)) !== null) {
|
||||||
|
fail($problem);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($password !== readSecret('Passwort wiederholen: ')) {
|
||||||
|
fail('Die Passwörter stimmen nicht überein.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $users->create($username, $displayName, $role, AuthService::hash($password));
|
||||||
|
$users->audit(null, 'CLI', 'user.create', $user->id, $user->username, ['role' => $role->value]);
|
||||||
|
out('Angelegt: ' . $user->username . ' (' . $user->role->label() . ')');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'user:list':
|
||||||
|
/** @var UserRepository $users */
|
||||||
|
$users = $container->get(UserRepository::class);
|
||||||
|
$rows = $users->all();
|
||||||
|
|
||||||
|
if ($rows === []) {
|
||||||
|
out('Es ist noch kein Benutzer angelegt. Anlegen mit: php bin/ekdos user:create');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
out(sprintf('%-22s %-24s %-14s %-8s %s', 'BENUTZER', 'NAME', 'ROLLE', 'AKTIV', 'LETZTE ANMELDUNG'));
|
||||||
|
|
||||||
|
foreach ($rows as $user) {
|
||||||
|
out(sprintf(
|
||||||
|
'%-22s %-24s %-14s %-8s %s',
|
||||||
|
$user->username,
|
||||||
|
$user->displayName,
|
||||||
|
$user->role->value,
|
||||||
|
$user->isActive ? 'ja' : 'nein',
|
||||||
|
$user->lastLoginAt ?? '–',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'user:password':
|
||||||
|
/** @var UserRepository $users */
|
||||||
|
$users = $container->get(UserRepository::class);
|
||||||
|
$username = $argv[2] ?? fail('Aufruf: php bin/ekdos user:password <benutzername>');
|
||||||
|
$user = $users->findByUsernameWithHash($username) ?? fail('Unbekannter Benutzer: ' . $username);
|
||||||
|
|
||||||
|
$password = readSecret('Neues Passwort für ' . $user->username . ': ');
|
||||||
|
|
||||||
|
if (($problem = AuthService::rejectWeakPassword($password)) !== null) {
|
||||||
|
fail($problem);
|
||||||
|
}
|
||||||
|
|
||||||
|
$users->update($user->id, ['password_hash' => AuthService::hash($password)]);
|
||||||
|
$container->get(\Ekdos\Auth\SessionStore::class)->destroyAllFor($user->id);
|
||||||
|
$users->audit(null, 'CLI', 'user.password', $user->id, $user->username);
|
||||||
|
out('Passwort geändert. Offene Sitzungen dieses Benutzers wurden beendet.');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cache:flush':
|
||||||
|
/** @var Cache $cache */
|
||||||
|
$cache = $container->get(Cache::class);
|
||||||
|
out($cache->flushAll() . ' zwischengespeicherte n8n-Antworten verworfen.');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'check':
|
||||||
|
/** @var Config $config */
|
||||||
|
$config = $container->get(Config::class);
|
||||||
|
$problems = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$container->get(PDO::class)->query('select 1');
|
||||||
|
out(' Postgres erreichbar');
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
out(' Postgres FEHLER: ' . $exception->getMessage());
|
||||||
|
$problems++;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$container->get(Redis::class)->ping();
|
||||||
|
out(' Redis erreichbar');
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
out(' Redis FEHLER: ' . $exception->getMessage());
|
||||||
|
$problems++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Endpoints $endpoints */
|
||||||
|
$endpoints = $container->get(Endpoints::class);
|
||||||
|
/** @var Client $n8n */
|
||||||
|
$n8n = $container->get(Client::class);
|
||||||
|
$reply = $n8n->get($endpoints->openTickets());
|
||||||
|
|
||||||
|
if ($reply->status === 0) {
|
||||||
|
out(' n8n FEHLER: nicht erreichbar unter ' . $endpoints->openTickets());
|
||||||
|
$problems++;
|
||||||
|
} else {
|
||||||
|
out(' n8n HTTP ' . $reply->status . ' von ' . $endpoints->openTickets());
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['N8N_CUSTOMER_KEY', 'N8N_INVOICE_SYNC_SECRET'] as $secret) {
|
||||||
|
if (!$config->has($secret)) {
|
||||||
|
out(' Hinweis ' . $secret . ' ist nicht gesetzt; betroffene Funktionen antworten mit 503.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exit($problems === 0 ? 0 : 1);
|
||||||
|
|
||||||
|
default:
|
||||||
|
out('EK-DOS-WEB Wartungs-CLI');
|
||||||
|
out();
|
||||||
|
out(' php bin/ekdos migrate Schema anlegen oder fortschreiben');
|
||||||
|
out(' php bin/ekdos user:create Benutzer anlegen');
|
||||||
|
out(' php bin/ekdos user:list Benutzer auflisten');
|
||||||
|
out(' php bin/ekdos user:password <name> Passwort zurücksetzen');
|
||||||
|
out(' php bin/ekdos cache:flush n8n-Cache leeren');
|
||||||
|
out(' php bin/ekdos check Postgres, Redis und n8n prüfen');
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "elektro-krueger/ekdos-backend",
|
||||||
|
"description": "EK-DOS-WEB Backend: BFF-Cookie-Sitzung, Benutzerverwaltung und zwischengespeicherte n8n-Weiterleitung.",
|
||||||
|
"type": "project",
|
||||||
|
"license": "proprietary",
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.5",
|
||||||
|
"ext-json": "*",
|
||||||
|
"ext-pdo": "*",
|
||||||
|
"ext-pdo_pgsql": "*",
|
||||||
|
"ext-redis": "*",
|
||||||
|
"guzzlehttp/guzzle": "^7.9",
|
||||||
|
"php-di/php-di": "^7.0",
|
||||||
|
"slim/psr7": "^1.7",
|
||||||
|
"slim/slim": "^4.14",
|
||||||
|
"vlucas/phpdotenv": "^5.6"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Ekdos\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"optimize-autoloader": true,
|
||||||
|
"sort-packages": true
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"migrate": "php bin/ekdos migrate",
|
||||||
|
"serve": "php -S 127.0.0.1:8080 -t public"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- EK-DOS-WEB: Benutzer, Rollen und Protokoll.
|
||||||
|
-- Sitzungen und n8n-Antworten liegen in Redis, nicht hier.
|
||||||
|
|
||||||
|
create extension if not exists pgcrypto;
|
||||||
|
|
||||||
|
create table if not exists users (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
username text not null,
|
||||||
|
display_name text not null,
|
||||||
|
role text not null default 'buero',
|
||||||
|
password_hash text not null,
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
last_login_at timestamptz,
|
||||||
|
constraint users_role_check check (role in ('admin', 'inhaber', 'buero'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Anmeldename ohne Ruecksicht auf Gross-/Kleinschreibung eindeutig.
|
||||||
|
create unique index if not exists users_username_key on users (lower(username));
|
||||||
|
create index if not exists users_active_idx on users (is_active) where is_active;
|
||||||
|
|
||||||
|
create table if not exists user_audit (
|
||||||
|
id bigserial primary key,
|
||||||
|
actor_id uuid references users (id) on delete set null,
|
||||||
|
actor_name text not null,
|
||||||
|
action text not null,
|
||||||
|
subject_id uuid,
|
||||||
|
subject text not null default '',
|
||||||
|
detail jsonb not null default '{}'::jsonb,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists user_audit_created_at_idx on user_audit (created_at desc);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einziger Einstiegspunkt des Backends.
|
||||||
|
*
|
||||||
|
* nginx reicht ausschliesslich /api/ hierher weiter; alles andere ist die
|
||||||
|
* statisch gebaute Oberflaeche und wird direkt von nginx ausgeliefert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use Ekdos\Bootstrap\Container;
|
||||||
|
use Ekdos\Bootstrap\Routes;
|
||||||
|
use Ekdos\Http\Middleware\ErrorHandler;
|
||||||
|
use Ekdos\Http\Middleware\SessionMiddleware;
|
||||||
|
use Ekdos\Support\Config;
|
||||||
|
use Slim\Factory\AppFactory;
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
$projectRoot = dirname(__DIR__);
|
||||||
|
$container = Container::build($projectRoot);
|
||||||
|
|
||||||
|
/** @var Config $config */
|
||||||
|
$config = $container->get(Config::class);
|
||||||
|
|
||||||
|
// Ausnahmen gehen in den Fehlerkanal von php-fpm, niemals in die Antwort.
|
||||||
|
ini_set('display_errors', '0');
|
||||||
|
ini_set('log_errors', '1');
|
||||||
|
|
||||||
|
AppFactory::setContainer($container);
|
||||||
|
$app = AppFactory::create();
|
||||||
|
|
||||||
|
Routes::register($app);
|
||||||
|
|
||||||
|
// Reihenfolge: zuletzt hinzugefuegt laeuft zuerst.
|
||||||
|
// ErrorHandler -> Session -> Routing -> Body-Parsing -> Route.
|
||||||
|
$app->addBodyParsingMiddleware();
|
||||||
|
$app->addRoutingMiddleware();
|
||||||
|
$app->add($container->get(SessionMiddleware::class));
|
||||||
|
$app->add(new ErrorHandler($config->bool('APP_DEBUG', false)));
|
||||||
|
|
||||||
|
$app->run();
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Auth;
|
||||||
|
|
||||||
|
use Ekdos\Http\Middleware\SessionMiddleware;
|
||||||
|
use Ekdos\Support\Json;
|
||||||
|
use Ekdos\Users\Permission;
|
||||||
|
use Ekdos\Users\UserRepository;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
||||||
|
final readonly class AuthController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private AuthService $auth,
|
||||||
|
private SessionCookie $cookie,
|
||||||
|
private UserRepository $users,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function login(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$body = $request->getParsedBody();
|
||||||
|
$body = is_array($body) ? $body : Json::decode((string) $request->getBody());
|
||||||
|
|
||||||
|
$result = $this->auth->login(
|
||||||
|
username: (string) ($body['username'] ?? $body['account'] ?? ''),
|
||||||
|
password: (string) ($body['password'] ?? ''),
|
||||||
|
clientIp: self::clientIp($request),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result['ok'] === false) {
|
||||||
|
return Json::error($response, $result['error'], $result['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->cookie->attach(Json::write($response, ['user' => self::describe($result['session'])]), $result['session']->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Auth-Sonde der Oberflaeche.
|
||||||
|
*
|
||||||
|
* Bewusst ohne RequireAuth: die SPA fragt hier vor jeder Anzeige an und
|
||||||
|
* entscheidet anhand von 200 oder 401, ob sie die Anmeldemaske zeigt.
|
||||||
|
*/
|
||||||
|
public function me(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$session = SessionMiddleware::of($request);
|
||||||
|
|
||||||
|
if ($session === null) {
|
||||||
|
return Json::write($response, ['user' => null], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Json::write($response, ['user' => self::describe($session)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haelt die Sitzung am Leben, solange jemand am Bildschirm arbeitet.
|
||||||
|
* Die Oberflaeche ruft das alle fuenf Minuten auf.
|
||||||
|
*/
|
||||||
|
public function refresh(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$session = SessionMiddleware::of($request);
|
||||||
|
|
||||||
|
if ($session === null) {
|
||||||
|
return Json::write($response, ['user' => null], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Json::write($response, ['user' => self::describe($session)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$this->auth->logout($this->cookie->read($request));
|
||||||
|
|
||||||
|
return $this->cookie->clear(Json::write($response, ['ok' => true]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenes Passwort aendern. Erlaubt jedem angemeldeten Benutzer. */
|
||||||
|
public function changeOwnPassword(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$session = SessionMiddleware::of($request);
|
||||||
|
|
||||||
|
if ($session === null) {
|
||||||
|
return Json::error($response, 'Bitte erneut anmelden.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $request->getParsedBody();
|
||||||
|
$body = is_array($body) ? $body : Json::decode((string) $request->getBody());
|
||||||
|
$current = (string) ($body['currentPassword'] ?? '');
|
||||||
|
$next = (string) ($body['newPassword'] ?? '');
|
||||||
|
|
||||||
|
$user = $this->users->findByUsernameWithHash($session->username);
|
||||||
|
|
||||||
|
if ($user === null || !password_verify($current, $user->passwordHash)) {
|
||||||
|
return Json::error($response, 'Das aktuelle Passwort ist nicht korrekt.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($problem = AuthService::rejectWeakPassword($next)) !== null) {
|
||||||
|
return Json::error($response, $problem, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->users->update($user->id, ['password_hash' => AuthService::hash($next)]);
|
||||||
|
$this->users->audit($user->id, $user->displayName, 'password.self', $user->id, $user->username);
|
||||||
|
|
||||||
|
// Alle anderen Sitzungen dieses Benutzers verfallen; die eigene bleibt bestehen.
|
||||||
|
return Json::write($response, ['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private static function describe(Session $session): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $session->userId,
|
||||||
|
'username' => $session->username,
|
||||||
|
'name' => $session->displayName,
|
||||||
|
'role' => $session->role->value,
|
||||||
|
'roleLabel' => $session->role->label(),
|
||||||
|
'permissions' => $session->role->permissions(),
|
||||||
|
'canManageUsers' => $session->can(Permission::UsersManage),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hinter nginx steht die echte Adresse in X-Forwarded-For. Es wird nur der
|
||||||
|
* erste Eintrag verwendet und nur, wenn der Header ueberhaupt gesetzt ist --
|
||||||
|
* der Origin ist ausschliesslich ueber den Proxy erreichbar.
|
||||||
|
*/
|
||||||
|
private static function clientIp(Request $request): string
|
||||||
|
{
|
||||||
|
$forwarded = $request->getHeaderLine('X-Forwarded-For');
|
||||||
|
|
||||||
|
if ($forwarded !== '') {
|
||||||
|
return trim(explode(',', $forwarded)[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$server = $request->getServerParams();
|
||||||
|
|
||||||
|
return is_string($server['REMOTE_ADDR'] ?? null) ? $server['REMOTE_ADDR'] : 'unbekannt';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Auth;
|
||||||
|
|
||||||
|
use Ekdos\Users\UserRepository;
|
||||||
|
use Redis;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anmeldung gegen die eigene Benutzertabelle.
|
||||||
|
*
|
||||||
|
* Die Bremse haengt an Benutzername und Quell-IP gemeinsam, damit weder ein
|
||||||
|
* verteilter Angriff auf ein Konto noch ein einzelner Client durchprobieren kann.
|
||||||
|
*/
|
||||||
|
final readonly class AuthService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private UserRepository $users,
|
||||||
|
private SessionStore $sessions,
|
||||||
|
private Redis $redis,
|
||||||
|
private string $prefix,
|
||||||
|
private int $maxAttempts,
|
||||||
|
private int $window,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok: true, session: Session}|array{ok: false, error: string, status: int}
|
||||||
|
*/
|
||||||
|
public function login(string $username, string $password, string $clientIp): array
|
||||||
|
{
|
||||||
|
$username = trim($username);
|
||||||
|
|
||||||
|
if ($username === '' || $password === '') {
|
||||||
|
return ['ok' => false, 'error' => 'Benutzername und Passwort sind erforderlich.', 'status' => 400];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isThrottled($username, $clientIp)) {
|
||||||
|
return ['ok' => false, 'error' => 'Zu viele Anmeldeversuche. Bitte in einigen Minuten erneut versuchen.', 'status' => 429];
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->users->findByUsernameWithHash($username);
|
||||||
|
|
||||||
|
// Auch bei unbekanntem Benutzer wird gehasht, damit die Antwortzeit nichts verraet.
|
||||||
|
$hash = $user?->passwordHash ?? '$argon2id$v=19$m=65536,t=4,p=1$aaaaaaaaaaaaaaaaaaaaaa$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||||
|
$valid = password_verify($password, $hash);
|
||||||
|
|
||||||
|
if ($user === null || !$valid) {
|
||||||
|
$this->recordFailure($username, $clientIp);
|
||||||
|
|
||||||
|
return ['ok' => false, 'error' => 'Benutzer oder Passwort ist nicht korrekt.', 'status' => 401];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$user->isActive) {
|
||||||
|
$this->recordFailure($username, $clientIp);
|
||||||
|
|
||||||
|
return ['ok' => false, 'error' => 'Dieses Konto ist deaktiviert.', 'status' => 403];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->clearFailures($username, $clientIp);
|
||||||
|
|
||||||
|
if (password_needs_rehash($hash, PASSWORD_ARGON2ID)) {
|
||||||
|
$this->users->update($user->id, ['password_hash' => self::hash($password)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->users->touchLogin($user->id);
|
||||||
|
|
||||||
|
return ['ok' => true, 'session' => $this->sessions->create($user)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(string $sessionId): void
|
||||||
|
{
|
||||||
|
if ($sessionId !== '') {
|
||||||
|
$this->sessions->destroy($sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function hash(string $password): string
|
||||||
|
{
|
||||||
|
return password_hash($password, PASSWORD_ARGON2ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mindestanforderung an ein Passwort. Bewusst schlicht: Laenge schlaegt Zeichenklassen. */
|
||||||
|
public static function rejectWeakPassword(string $password): ?string
|
||||||
|
{
|
||||||
|
if (mb_strlen($password) < 12) {
|
||||||
|
return 'Das Passwort muss mindestens 12 Zeichen lang sein.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($password) > 200) {
|
||||||
|
return 'Das Passwort darf höchstens 200 Zeichen lang sein.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isThrottled(string $username, string $clientIp): bool
|
||||||
|
{
|
||||||
|
return (int) ($this->redis->get($this->throttleKey($username, $clientIp)) ?: 0) >= $this->maxAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordFailure(string $username, string $clientIp): void
|
||||||
|
{
|
||||||
|
$key = $this->throttleKey($username, $clientIp);
|
||||||
|
|
||||||
|
if ((int) $this->redis->incr($key) === 1) {
|
||||||
|
$this->redis->expire($key, $this->window);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearFailures(string $username, string $clientIp): void
|
||||||
|
{
|
||||||
|
$this->redis->del($this->throttleKey($username, $clientIp));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function throttleKey(string $username, string $clientIp): string
|
||||||
|
{
|
||||||
|
return $this->prefix . 'login:' . sha1(strtolower($username) . '|' . $clientIp);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Auth;
|
||||||
|
|
||||||
|
use Ekdos\Users\Role;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der serverseitige Teil der Sitzung. Der Browser kennt davon nichts ausser
|
||||||
|
* der undurchsichtigen Kennung im Cookie.
|
||||||
|
*/
|
||||||
|
final readonly class Session
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $id,
|
||||||
|
public string $userId,
|
||||||
|
public string $username,
|
||||||
|
public string $displayName,
|
||||||
|
public Role $role,
|
||||||
|
public int $issuedAt,
|
||||||
|
public int $lastSeenAt,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function fromArray(string $id, array $data): ?self
|
||||||
|
{
|
||||||
|
$role = Role::tryFrom((string) ($data['role'] ?? ''));
|
||||||
|
|
||||||
|
if ($role === null || !isset($data['userId'], $data['username'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
id: $id,
|
||||||
|
userId: (string) $data['userId'],
|
||||||
|
username: (string) $data['username'],
|
||||||
|
displayName: (string) ($data['displayName'] ?? $data['username']),
|
||||||
|
role: $role,
|
||||||
|
issuedAt: (int) ($data['issuedAt'] ?? 0),
|
||||||
|
lastSeenAt: (int) ($data['lastSeenAt'] ?? 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'userId' => $this->userId,
|
||||||
|
'username' => $this->username,
|
||||||
|
'displayName' => $this->displayName,
|
||||||
|
'role' => $this->role->value,
|
||||||
|
'issuedAt' => $this->issuedAt,
|
||||||
|
'lastSeenAt' => $this->lastSeenAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function can(string $permission): bool
|
||||||
|
{
|
||||||
|
return $this->role->allows($permission);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Auth;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Cookie traegt nur die Sitzungskennung.
|
||||||
|
*
|
||||||
|
* Ohne Max-Age bleibt es ein reines Sitzungscookie: schliesst jemand den Browser
|
||||||
|
* vollstaendig, ist beim naechsten Aufruf wieder eine Anmeldung noetig. Das war
|
||||||
|
* schon in der Next.js-Fassung so und bleibt bewusst erhalten.
|
||||||
|
*/
|
||||||
|
final readonly class SessionCookie
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private string $name,
|
||||||
|
private bool $secure,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function read(Request $request): string
|
||||||
|
{
|
||||||
|
$value = $request->getCookieParams()[$this->name] ?? '';
|
||||||
|
|
||||||
|
return is_string($value) ? $value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attach(Response $response, string $id): Response
|
||||||
|
{
|
||||||
|
return $response->withAddedHeader('Set-Cookie', $this->build($id, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clear(Response $response): Response
|
||||||
|
{
|
||||||
|
return $response->withAddedHeader('Set-Cookie', $this->build('', 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function build(string $value, ?int $maxAge): string
|
||||||
|
{
|
||||||
|
$parts = [
|
||||||
|
$this->name . '=' . $value,
|
||||||
|
'Path=/',
|
||||||
|
'HttpOnly',
|
||||||
|
'SameSite=Strict',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->secure) {
|
||||||
|
$parts[] = 'Secure';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($maxAge !== null) {
|
||||||
|
$parts[] = 'Max-Age=' . $maxAge;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode('; ', $parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Auth;
|
||||||
|
|
||||||
|
use Ekdos\Users\User;
|
||||||
|
use Redis;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serverseitiger Sitzungsspeicher (BFF-Ticket-Store) auf Redis.
|
||||||
|
*
|
||||||
|
* Der Browser bekommt ausschliesslich eine zufaellige, undurchsichtige Kennung.
|
||||||
|
* Rollen, Rechte und Anzeigename bleiben auf dem Server: das Cookie waechst
|
||||||
|
* nicht mit den Claims mit und ein Reverse Proxy hat nie zu grosse Header.
|
||||||
|
*
|
||||||
|
* Schluessel:
|
||||||
|
* <prefix>sess:<id> Sitzungsdaten, TTL = Leerlauffenster
|
||||||
|
* <prefix>sess:user:<uid> Menge aller Sitzungen eines Benutzers, fuer Zwangsabmeldung
|
||||||
|
*/
|
||||||
|
final readonly class SessionStore
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Redis $redis,
|
||||||
|
private string $prefix,
|
||||||
|
private int $ttl,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function create(User $user): Session
|
||||||
|
{
|
||||||
|
$now = time();
|
||||||
|
$session = new Session(
|
||||||
|
id: self::newId(),
|
||||||
|
userId: $user->id,
|
||||||
|
username: $user->username,
|
||||||
|
displayName: $user->displayName,
|
||||||
|
role: $user->role,
|
||||||
|
issuedAt: $now,
|
||||||
|
lastSeenAt: $now,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->redis->setex($this->key($session->id), $this->ttl, json_encode($session->toArray(), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||||
|
$this->redis->sAdd($this->userKey($user->id), $session->id);
|
||||||
|
// Der Index darf den laengsten moeglichen Sitzungslauf ueberdauern, nicht laenger.
|
||||||
|
$this->redis->expire($this->userKey($user->id), $this->ttl * 24);
|
||||||
|
|
||||||
|
return $session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function read(string $id): ?Session
|
||||||
|
{
|
||||||
|
if (!self::looksLikeId($id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = $this->redis->get($this->key($id));
|
||||||
|
|
||||||
|
if (!is_string($raw) || $raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
|
||||||
|
return is_array($decoded) ? Session::fromArray($id, $decoded) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gleitendes Leerlauffenster: jede authentifizierte Anfrage schiebt die TTL nach vorn.
|
||||||
|
* Der Rumpf wird nur einmal pro Minute neu geschrieben, sonst reicht ein EXPIRE.
|
||||||
|
*/
|
||||||
|
public function touch(Session $session): void
|
||||||
|
{
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
if ($now - $session->lastSeenAt < 60) {
|
||||||
|
$this->redis->expire($this->key($session->id), $this->ttl);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$refreshed = new Session(
|
||||||
|
id: $session->id,
|
||||||
|
userId: $session->userId,
|
||||||
|
username: $session->username,
|
||||||
|
displayName: $session->displayName,
|
||||||
|
role: $session->role,
|
||||||
|
issuedAt: $session->issuedAt,
|
||||||
|
lastSeenAt: $now,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->redis->setex($this->key($session->id), $this->ttl, json_encode($refreshed->toArray(), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(string $id): void
|
||||||
|
{
|
||||||
|
$session = $this->read($id);
|
||||||
|
|
||||||
|
if ($session !== null) {
|
||||||
|
$this->redis->sRem($this->userKey($session->userId), $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redis->del($this->key($id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meldet einen Benutzer ueberall ab. Wird nach Rollenwechsel, Deaktivierung,
|
||||||
|
* Passwortwechsel und Loeschung aufgerufen, damit eine laufende Sitzung
|
||||||
|
* keine Rechte behaelt, die der Benutzer nicht mehr hat.
|
||||||
|
*/
|
||||||
|
public function destroyAllFor(string $userId): int
|
||||||
|
{
|
||||||
|
$ids = $this->redis->sMembers($this->userKey($userId));
|
||||||
|
|
||||||
|
if (!is_array($ids) || $ids === []) {
|
||||||
|
$this->redis->del($this->userKey($userId));
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redis->del(array_map(fn (string $id): string => $this->key($id), $ids));
|
||||||
|
$this->redis->del($this->userKey($userId));
|
||||||
|
|
||||||
|
return count($ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function newId(): string
|
||||||
|
{
|
||||||
|
return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function looksLikeId(string $id): bool
|
||||||
|
{
|
||||||
|
return $id !== '' && strlen($id) <= 64 && preg_match('/^[A-Za-z0-9_-]+$/', $id) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function key(string $id): string
|
||||||
|
{
|
||||||
|
return $this->prefix . 'sess:' . $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function userKey(string $userId): string
|
||||||
|
{
|
||||||
|
return $this->prefix . 'sess:user:' . $userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Bootstrap;
|
||||||
|
|
||||||
|
use DI\ContainerBuilder;
|
||||||
|
use Ekdos\Auth\AuthService;
|
||||||
|
use Ekdos\Auth\SessionCookie;
|
||||||
|
use Ekdos\Auth\SessionStore;
|
||||||
|
use Ekdos\N8n\Cache;
|
||||||
|
use Ekdos\N8n\Client;
|
||||||
|
use Ekdos\N8n\Endpoints;
|
||||||
|
use Ekdos\Support\Config;
|
||||||
|
use Ekdos\Users\UserRepository;
|
||||||
|
use GuzzleHttp\Client as Guzzle;
|
||||||
|
use PDO;
|
||||||
|
use Psr\Container\ContainerInterface;
|
||||||
|
use Redis;
|
||||||
|
|
||||||
|
use function DI\autowire;
|
||||||
|
use function DI\factory;
|
||||||
|
use function DI\get;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Kompositionswurzel. Alles, was einen Zustand oder eine Verbindung haelt,
|
||||||
|
* wird hier genau einmal beschrieben.
|
||||||
|
*/
|
||||||
|
final class Container
|
||||||
|
{
|
||||||
|
public static function build(string $projectRoot): ContainerInterface
|
||||||
|
{
|
||||||
|
$builder = new ContainerBuilder();
|
||||||
|
$config = self::loadConfig($projectRoot);
|
||||||
|
|
||||||
|
if (!$config->bool('APP_DEBUG', false)) {
|
||||||
|
$builder->enableCompilation($projectRoot . '/var/cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder->addDefinitions([
|
||||||
|
Config::class => $config,
|
||||||
|
|
||||||
|
// ── PostgreSQL ────────────────────────────────────────────────
|
||||||
|
PDO::class => factory(static function (Config $config): PDO {
|
||||||
|
return new PDO(
|
||||||
|
$config->string('DB_DSN', 'pgsql:host=127.0.0.1;port=5432;dbname=ekdos'),
|
||||||
|
$config->string('DB_USER', 'ekdos'),
|
||||||
|
$config->string('DB_PASSWORD'),
|
||||||
|
[
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ── Redis: Sitzungen und n8n-Cache ────────────────────────────
|
||||||
|
Redis::class => factory(static function (Config $config): Redis {
|
||||||
|
$redis = new Redis();
|
||||||
|
$redis->connect($config->string('REDIS_HOST', '127.0.0.1'), $config->int('REDIS_PORT', 6379), 2.0);
|
||||||
|
|
||||||
|
if ($config->has('REDIS_PASSWORD')) {
|
||||||
|
$redis->auth($config->string('REDIS_PASSWORD'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$redis->select($config->int('REDIS_DB', 0));
|
||||||
|
|
||||||
|
return $redis;
|
||||||
|
}),
|
||||||
|
|
||||||
|
SessionStore::class => factory(static function (Redis $redis, Config $config): SessionStore {
|
||||||
|
return new SessionStore($redis, $config->string('REDIS_PREFIX', 'ekdos:'), $config->int('SESSION_TTL', 3600));
|
||||||
|
}),
|
||||||
|
|
||||||
|
SessionCookie::class => factory(static function (Config $config): SessionCookie {
|
||||||
|
return new SessionCookie($config->string('SESSION_COOKIE', 'ekdos_session'), $config->bool('SESSION_COOKIE_SECURE', true));
|
||||||
|
}),
|
||||||
|
|
||||||
|
AuthService::class => factory(static function (UserRepository $users, SessionStore $sessions, Redis $redis, Config $config): AuthService {
|
||||||
|
return new AuthService(
|
||||||
|
users: $users,
|
||||||
|
sessions: $sessions,
|
||||||
|
redis: $redis,
|
||||||
|
prefix: $config->string('REDIS_PREFIX', 'ekdos:'),
|
||||||
|
maxAttempts: $config->int('LOGIN_MAX_ATTEMPTS', 10),
|
||||||
|
window: $config->int('LOGIN_WINDOW', 900),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
Cache::class => factory(static function (Redis $redis, Config $config): Cache {
|
||||||
|
return new Cache($redis, $config->string('REDIS_PREFIX', 'ekdos:'));
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ── n8n ───────────────────────────────────────────────────────
|
||||||
|
Endpoints::class => factory(static fn (Config $config): Endpoints => Endpoints::fromConfig($config)),
|
||||||
|
|
||||||
|
Guzzle::class => factory(static function (Config $config): Guzzle {
|
||||||
|
return new Guzzle([
|
||||||
|
'timeout' => $config->int('N8N_TIMEOUT', 15),
|
||||||
|
'connect_timeout' => $config->int('N8N_CONNECT_TIMEOUT', 5),
|
||||||
|
// n8n antwortet gelegentlich mit einer Weiterleitung auf sich selbst.
|
||||||
|
'allow_redirects' => ['max' => 3],
|
||||||
|
'headers' => ['User-Agent' => 'EK-DOS-WEB/3.0'],
|
||||||
|
]);
|
||||||
|
}),
|
||||||
|
|
||||||
|
Client::class => autowire()->constructorParameter('http', get(Guzzle::class)),
|
||||||
|
|
||||||
|
UserRepository::class => autowire(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $builder->build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest .env, faellt aber auf echte Umgebungsvariablen zurueck (Container, systemd). */
|
||||||
|
public static function loadConfig(string $projectRoot): Config
|
||||||
|
{
|
||||||
|
$values = getenv();
|
||||||
|
|
||||||
|
if (is_readable($projectRoot . '/.env')) {
|
||||||
|
$dotenv = \Dotenv\Dotenv::createArrayBacked($projectRoot);
|
||||||
|
$values = $dotenv->load() + $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Config::fromEnvironment($values);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Bootstrap;
|
||||||
|
|
||||||
|
use Ekdos\Auth\AuthController;
|
||||||
|
use Ekdos\Http\Middleware\RequireAuth;
|
||||||
|
use Ekdos\Http\Middleware\RequirePermission;
|
||||||
|
use Ekdos\Relay\CustomerInvoicesController;
|
||||||
|
use Ekdos\Relay\CustomersController;
|
||||||
|
use Ekdos\Relay\HoursController;
|
||||||
|
use Ekdos\Relay\InternalTasksController;
|
||||||
|
use Ekdos\Relay\InvoicesController;
|
||||||
|
use Ekdos\Relay\OffersController;
|
||||||
|
use Ekdos\Relay\OnlinePurchasesController;
|
||||||
|
use Ekdos\Relay\TicketsController;
|
||||||
|
use Ekdos\Support\Json;
|
||||||
|
use Ekdos\Users\Permission;
|
||||||
|
use Ekdos\Users\UserController;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Slim\App;
|
||||||
|
use Slim\Routing\RouteCollectorProxy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Routen der Anwendung.
|
||||||
|
*
|
||||||
|
* Anonym erreichbar sind nur die Gesundheitspruefung, die Anmeldung, die
|
||||||
|
* Auth-Sonde /api/auth/me und der Cache-Hinweis von n8n. Alles andere haengt
|
||||||
|
* hinter RequireAuth, rechtegebundene Aktionen zusaetzlich hinter RequirePermission.
|
||||||
|
*/
|
||||||
|
final class Routes
|
||||||
|
{
|
||||||
|
public static function register(App $app): void
|
||||||
|
{
|
||||||
|
$app->group('/api', static function (RouteCollectorProxy $api): void {
|
||||||
|
// ── Anonym ────────────────────────────────────────────────────
|
||||||
|
// Die Oberflaeche prueft vor der Anmeldung, ob das Backend lebt.
|
||||||
|
$api->get('/health', static fn (Request $r, Response $w): Response => Json::write($w, ['ok' => true, 'service' => 'ek-dos-web', 'time' => gmdate('c')]));
|
||||||
|
|
||||||
|
$api->post('/auth/login', [AuthController::class, 'login']);
|
||||||
|
$api->get('/auth/me', [AuthController::class, 'me']);
|
||||||
|
$api->post('/auth/logout', [AuthController::class, 'logout']);
|
||||||
|
|
||||||
|
// n8n meldet hierueber eine Ticketerstellung. Traegt entweder eine
|
||||||
|
// Sitzung oder das geteilte Geheimnis; die Pruefung liegt im Controller.
|
||||||
|
$api->post('/tickets/refresh', [TicketsController::class, 'refresh']);
|
||||||
|
|
||||||
|
// ── Angemeldet ────────────────────────────────────────────────
|
||||||
|
$api->group('', static function (RouteCollectorProxy $secure): void {
|
||||||
|
$secure->post('/auth/refresh', [AuthController::class, 'refresh']);
|
||||||
|
$secure->post('/auth/password', [AuthController::class, 'changeOwnPassword']);
|
||||||
|
|
||||||
|
// Tickets
|
||||||
|
$secure->get('/tickets', [TicketsController::class, 'index']);
|
||||||
|
$secure->get('/tickets/digitale-akte', [TicketsController::class, 'digitalFile']);
|
||||||
|
$secure->get('/tickets/servicebericht', [TicketsController::class, 'serviceReport']);
|
||||||
|
$secure->post('/tickets/consultation', [TicketsController::class, 'consultation'])
|
||||||
|
->add(new RequirePermission(Permission::InvoicesProcess));
|
||||||
|
|
||||||
|
// Angebote
|
||||||
|
$secure->get('/offers', [OffersController::class, 'index']);
|
||||||
|
$secure->post('/offers', [OffersController::class, 'update']);
|
||||||
|
$secure->delete('/offers', [OffersController::class, 'delete'])
|
||||||
|
->add(new RequirePermission(Permission::OffersDelete));
|
||||||
|
|
||||||
|
// Kundenstamm
|
||||||
|
$secure->get('/customers', [CustomersController::class, 'index']);
|
||||||
|
$secure->post('/customers', [CustomersController::class, 'create']);
|
||||||
|
$secure->put('/customers', [CustomersController::class, 'update']);
|
||||||
|
$secure->delete('/customers', [CustomersController::class, 'delete'])
|
||||||
|
->add(new RequirePermission(Permission::CustomersDelete));
|
||||||
|
$secure->get('/customers/digitale-akte', [CustomersController::class, 'digitalFile']);
|
||||||
|
|
||||||
|
// Rechnungen eines Kunden und die Gesamtuebersicht
|
||||||
|
$secure->get('/customer-invoices', [CustomerInvoicesController::class, 'index']);
|
||||||
|
$secure->post('/customer-invoices', [CustomerInvoicesController::class, 'assign']);
|
||||||
|
$secure->get('/customer-invoices/pdf', [CustomerInvoicesController::class, 'pdf']);
|
||||||
|
$secure->post('/customer-invoices/sync', [CustomerInvoicesController::class, 'sync']);
|
||||||
|
|
||||||
|
// Rechnungsablauf: anfertigen, pruefen, versenden
|
||||||
|
$secure->get('/invoices-review', [InvoicesController::class, 'reviewList']);
|
||||||
|
$secure->group('', static function (RouteCollectorProxy $invoices): void {
|
||||||
|
$invoices->get('/invoices-create', [InvoicesController::class, 'createList']);
|
||||||
|
$invoices->put('/invoices-create', [InvoicesController::class, 'completeCreateTask']);
|
||||||
|
$invoices->get('/invoices-create/digitale-akte', [InvoicesController::class, 'digitalFile']);
|
||||||
|
$invoices->get('/invoices-send', [InvoicesController::class, 'sendList']);
|
||||||
|
$invoices->post('/invoices-send', [InvoicesController::class, 'confirmSent']);
|
||||||
|
})->add(new RequirePermission(Permission::InvoicesProcess));
|
||||||
|
|
||||||
|
// Interne Aufgaben
|
||||||
|
$secure->get('/internal-tasks', [InternalTasksController::class, 'index']);
|
||||||
|
$secure->post('/internal-tasks', [InternalTasksController::class, 'create']);
|
||||||
|
$secure->put('/internal-tasks', [InternalTasksController::class, 'complete']);
|
||||||
|
$secure->patch('/internal-tasks', [InternalTasksController::class, 'edit']);
|
||||||
|
$secure->delete('/internal-tasks', [InternalTasksController::class, 'delete'])
|
||||||
|
->add(new RequirePermission(Permission::TasksDelete));
|
||||||
|
|
||||||
|
// Online-Kaeufe
|
||||||
|
$secure->get('/online-purchases', [OnlinePurchasesController::class, 'index']);
|
||||||
|
$secure->post('/online-purchases', [OnlinePurchasesController::class, 'create'])
|
||||||
|
->add(new RequirePermission(Permission::PurchasesCreate));
|
||||||
|
$secure->put('/online-purchases', [OnlinePurchasesController::class, 'complete'])
|
||||||
|
->add(new RequirePermission(Permission::PurchasesComplete));
|
||||||
|
$secure->delete('/online-purchases', [OnlinePurchasesController::class, 'delete'])
|
||||||
|
->add(new RequirePermission(Permission::PurchasesDelete));
|
||||||
|
|
||||||
|
// Stundennachweise
|
||||||
|
$secure->get('/hours', [HoursController::class, 'index']);
|
||||||
|
|
||||||
|
// Benutzerverwaltung
|
||||||
|
$secure->group('/users', static function (RouteCollectorProxy $users): void {
|
||||||
|
$users->get('', [UserController::class, 'index']);
|
||||||
|
$users->post('', [UserController::class, 'create']);
|
||||||
|
$users->get('/audit', [UserController::class, 'audit']);
|
||||||
|
$users->patch('/{id}', [UserController::class, 'update']);
|
||||||
|
$users->delete('/{id}', [UserController::class, 'delete']);
|
||||||
|
})->add(new RequirePermission(Permission::UsersManage));
|
||||||
|
})->add(new RequireAuth());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alles unterhalb von /api, was es nicht gibt, antwortet als JSON.
|
||||||
|
// Statische Dateien beantwortet nginx, sie erreichen PHP nie.
|
||||||
|
$app->any('/api/{path:.*}', static fn (Request $r, Response $w): Response => Json::error($w, 'Diese Schnittstelle gibt es nicht.', 404));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Http\Middleware;
|
||||||
|
|
||||||
|
use Ekdos\Support\Json;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface as Handler;
|
||||||
|
use Slim\Exception\HttpMethodNotAllowedException;
|
||||||
|
use Slim\Exception\HttpNotFoundException;
|
||||||
|
use Slim\Psr7\Response as Psr7Response;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ausnahmen werden protokolliert, nicht erzaehlt.
|
||||||
|
*
|
||||||
|
* Es gibt bewusst kein Info-/Debug-Logging: in das Protokoll gehoert nur, was
|
||||||
|
* tatsaechlich schiefgegangen ist. error_log() landet im Fehlerkanal von php-fpm
|
||||||
|
* und damit dort, wo auch nginx hinschreibt.
|
||||||
|
*
|
||||||
|
* Soll spaeter Sentry dazukommen, ist genau eine Zeile noetig -- siehe unten.
|
||||||
|
*/
|
||||||
|
final readonly class ErrorHandler implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
public function __construct(private bool $debug) {}
|
||||||
|
|
||||||
|
public function process(Request $request, Handler $handler): Response
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $handler->handle($request);
|
||||||
|
} catch (HttpMethodNotAllowedException) {
|
||||||
|
return Json::error(new Psr7Response(), 'Diese Methode ist für diese Schnittstelle nicht vorgesehen.', 405);
|
||||||
|
} catch (HttpNotFoundException) {
|
||||||
|
// Kein Fehlerfall, sondern ein Tippfehler in der Adresse. Nicht protokollieren.
|
||||||
|
return Json::error(new Psr7Response(), 'Diese Schnittstelle gibt es nicht.', 404);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
error_log(sprintf(
|
||||||
|
'[ekdos] %s %s -- %s: %s @ %s:%d',
|
||||||
|
$request->getMethod(),
|
||||||
|
(string) $request->getUri()->getPath(),
|
||||||
|
$exception::class,
|
||||||
|
$exception->getMessage(),
|
||||||
|
$exception->getFile(),
|
||||||
|
$exception->getLine(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Sentry-Einstiegspunkt: \Sentry\captureException($exception);
|
||||||
|
|
||||||
|
$message = $this->debug
|
||||||
|
? $exception::class . ': ' . $exception->getMessage()
|
||||||
|
: 'Im Backend ist ein unerwarteter Fehler aufgetreten.';
|
||||||
|
|
||||||
|
return Json::error(new Psr7Response(), $message, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Http\Middleware;
|
||||||
|
|
||||||
|
use Ekdos\Support\Json;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface as Handler;
|
||||||
|
use Slim\Psr7\Response as Psr7Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sauberes 401 statt einer Weiterleitung: die Oberflaeche liest den Status und
|
||||||
|
* zeigt daraufhin die Anmeldemaske. Es gibt hier nichts umzuleiten, weil der
|
||||||
|
* Anmeldedialog Teil der SPA ist.
|
||||||
|
*/
|
||||||
|
final class RequireAuth implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
public function process(Request $request, Handler $handler): Response
|
||||||
|
{
|
||||||
|
if (SessionMiddleware::of($request) === null) {
|
||||||
|
return Json::error(new Psr7Response(), 'Bitte erneut anmelden.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $handler->handle($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Http\Middleware;
|
||||||
|
|
||||||
|
use Ekdos\Support\Json;
|
||||||
|
use Ekdos\Users\Permission;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface as Handler;
|
||||||
|
use Slim\Psr7\Response as Psr7Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ersetzt die frueheren Namensabfragen ("nur Sascha", "nur Svenja") durch ein Recht.
|
||||||
|
* Die Meldung nennt das fehlende Recht, damit im Buero klar ist, wer helfen kann.
|
||||||
|
*/
|
||||||
|
final readonly class RequirePermission implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
public function __construct(private string $permission) {}
|
||||||
|
|
||||||
|
public function process(Request $request, Handler $handler): Response
|
||||||
|
{
|
||||||
|
$session = SessionMiddleware::of($request);
|
||||||
|
|
||||||
|
if ($session === null) {
|
||||||
|
return Json::error(new Psr7Response(), 'Bitte erneut anmelden.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$session->can($this->permission)) {
|
||||||
|
return Json::error(
|
||||||
|
new Psr7Response(),
|
||||||
|
'Dafür fehlt die Berechtigung "' . Permission::label($this->permission) . '".',
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $handler->handle($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Http\Middleware;
|
||||||
|
|
||||||
|
use Ekdos\Auth\Session;
|
||||||
|
use Ekdos\Auth\SessionCookie;
|
||||||
|
use Ekdos\Auth\SessionStore;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface as Handler;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loest das Cookie in eine Sitzung auf und haengt sie an die Anfrage.
|
||||||
|
* Sie weist nichts ab: das uebernehmen RequireAuth und RequirePermission.
|
||||||
|
*/
|
||||||
|
final readonly class SessionMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
public const string ATTRIBUTE = 'session';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private SessionStore $sessions,
|
||||||
|
private SessionCookie $cookie,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(Request $request, Handler $handler): Response
|
||||||
|
{
|
||||||
|
$id = $this->cookie->read($request);
|
||||||
|
$session = $id === '' ? null : $this->sessions->read($id);
|
||||||
|
|
||||||
|
if ($session !== null) {
|
||||||
|
$this->sessions->touch($session);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $handler->handle($request->withAttribute(self::ATTRIBUTE, $session));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function of(Request $request): ?Session
|
||||||
|
{
|
||||||
|
$session = $request->getAttribute(self::ATTRIBUTE);
|
||||||
|
|
||||||
|
return $session instanceof Session ? $session : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\N8n;
|
||||||
|
|
||||||
|
use Redis;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kurzlebiger Redis-Cache vor n8n.
|
||||||
|
*
|
||||||
|
* Das Buero laesst mehrere Ansichten gleichzeitig offen, und die Ticketliste
|
||||||
|
* aktualisiert sich alle 20 Sekunden von selbst. Ohne Cache landet jeder dieser
|
||||||
|
* Takte als eigener Aufruf bei n8n. Drei Mechanismen verhindern das:
|
||||||
|
*
|
||||||
|
* 1. Frischer Eintrag Innerhalb der TTL wird ohne Ruecksprache geantwortet.
|
||||||
|
*
|
||||||
|
* 2. Single-Flight Laeuft der Cache ab, waehrend drei Ansichten gleichzeitig
|
||||||
|
* fragen, holt genau eine die Daten. Die anderen warten
|
||||||
|
* kurz auf deren Ergebnis, statt parallel loszurennen.
|
||||||
|
*
|
||||||
|
* 3. Stale-on-Error Neben dem frischen Eintrag liegt eine deutlich laenger
|
||||||
|
* haltbare Kopie. Ist n8n nicht erreichbar, sieht das Buero
|
||||||
|
* die letzten bekannten Daten statt einer Fehlerseite.
|
||||||
|
*
|
||||||
|
* Schreibende Aufrufe verwerfen ihre Gruppe sofort (invalidate), damit eine
|
||||||
|
* Aenderung nicht bis zum Ablauf der TTL unsichtbar bleibt.
|
||||||
|
*
|
||||||
|
* Schluessel:
|
||||||
|
* <prefix>n8n:fresh:<hash> die kurzlebige Antwort
|
||||||
|
* <prefix>n8n:stale:<hash> die Rueckfallkopie
|
||||||
|
* <prefix>n8n:lock:<hash> Single-Flight-Sperre
|
||||||
|
* <prefix>n8n:group:<name> Menge aller Schluessel einer Gruppe
|
||||||
|
*/
|
||||||
|
final readonly class Cache
|
||||||
|
{
|
||||||
|
/** Wie lange ein wartender Aufrufer auf das Ergebnis des Single-Flight hofft. */
|
||||||
|
private const int WAIT_TOTAL_MS = 400;
|
||||||
|
private const int WAIT_STEP_MS = 25;
|
||||||
|
private const int LOCK_TTL_SECONDS = 15;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private Redis $redis,
|
||||||
|
private string $prefix,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $group Gruppe fuer die gezielte Verwerfung, z. B. "tickets".
|
||||||
|
* @param string $key Eindeutig fuer diese Abfrage inklusive Parameter.
|
||||||
|
* @param int $ttl Sekunden, die die Antwort als frisch gilt.
|
||||||
|
* @param int $staleTtl Sekunden, die die Rueckfallkopie vorgehalten wird.
|
||||||
|
* @param callable():Reply $fetch
|
||||||
|
*/
|
||||||
|
public function remember(string $group, string $key, int $ttl, int $staleTtl, callable $fetch): Reply
|
||||||
|
{
|
||||||
|
$hash = sha1($key);
|
||||||
|
|
||||||
|
if (($hit = $this->readEntry($this->freshKey($hash))) !== null) {
|
||||||
|
return $hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->acquireLock($hash)) {
|
||||||
|
// Jemand anderes holt gerade. Kurz auf dessen Ergebnis warten.
|
||||||
|
if (($shared = $this->waitForFresh($hash)) !== null) {
|
||||||
|
return $shared;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warten hat nichts gebracht: lieber etwas Veraltetes als gar nichts.
|
||||||
|
if (($stale = $this->readEntry($this->staleKey($hash))) !== null) {
|
||||||
|
return $stale->asStale();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$reply = $fetch();
|
||||||
|
|
||||||
|
if ($reply->ok()) {
|
||||||
|
$this->store($group, $hash, $reply, $ttl, $staleTtl);
|
||||||
|
|
||||||
|
return $reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
// n8n antwortet, aber mit einem Fehler. Alte Daten schlagen eine Fehlerseite.
|
||||||
|
return $this->readEntry($this->staleKey($hash))?->asStale() ?? $reply;
|
||||||
|
} finally {
|
||||||
|
$this->releaseLock($hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verwirft alle Eintraege der genannten Gruppen. Nach jedem schreibenden Aufruf. */
|
||||||
|
public function invalidate(string ...$groups): void
|
||||||
|
{
|
||||||
|
foreach ($groups as $group) {
|
||||||
|
$groupKey = $this->groupKey($group);
|
||||||
|
$members = $this->redis->sMembers($groupKey);
|
||||||
|
|
||||||
|
if (is_array($members) && $members !== []) {
|
||||||
|
$this->redis->del($members);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redis->del($groupKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leert alles, was dieser Cache angelegt hat. Nur fuer die Wartungs-CLI. */
|
||||||
|
public function flushAll(): int
|
||||||
|
{
|
||||||
|
$removed = 0;
|
||||||
|
$pattern = $this->prefix . 'n8n:*';
|
||||||
|
$cursor = null;
|
||||||
|
|
||||||
|
do {
|
||||||
|
$keys = $this->redis->scan($cursor, $pattern, 500);
|
||||||
|
|
||||||
|
if (is_array($keys) && $keys !== []) {
|
||||||
|
$removed += (int) $this->redis->del($keys);
|
||||||
|
}
|
||||||
|
} while ($cursor > 0);
|
||||||
|
|
||||||
|
return $removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function store(string $group, string $hash, Reply $reply, int $ttl, int $staleTtl): void
|
||||||
|
{
|
||||||
|
$payload = json_encode(
|
||||||
|
['status' => $reply->status, 'body' => $reply->body, 'contentType' => $reply->contentType],
|
||||||
|
JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->redis->setex($this->freshKey($hash), max(1, $ttl), $payload);
|
||||||
|
$this->redis->setex($this->staleKey($hash), max($ttl, $staleTtl), $payload);
|
||||||
|
$this->redis->sAdd($this->groupKey($group), $this->freshKey($hash), $this->staleKey($hash));
|
||||||
|
$this->redis->expire($this->groupKey($group), max($ttl, $staleTtl) + 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readEntry(string $key): ?Reply
|
||||||
|
{
|
||||||
|
$raw = $this->redis->get($key);
|
||||||
|
|
||||||
|
if (!is_string($raw) || $raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
|
||||||
|
if (!is_array($decoded) || !isset($decoded['status'], $decoded['body'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Reply((int) $decoded['status'], (string) $decoded['body'], (string) ($decoded['contentType'] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function waitForFresh(string $hash): ?Reply
|
||||||
|
{
|
||||||
|
$deadline = microtime(true) + self::WAIT_TOTAL_MS / 1000;
|
||||||
|
|
||||||
|
while (microtime(true) < $deadline) {
|
||||||
|
usleep(self::WAIT_STEP_MS * 1000);
|
||||||
|
|
||||||
|
if (($hit = $this->readEntry($this->freshKey($hash))) !== null) {
|
||||||
|
return $hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function acquireLock(string $hash): bool
|
||||||
|
{
|
||||||
|
return (bool) $this->redis->set($this->lockKey($hash), '1', ['NX', 'EX' => self::LOCK_TTL_SECONDS]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function releaseLock(string $hash): void
|
||||||
|
{
|
||||||
|
$this->redis->del($this->lockKey($hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function freshKey(string $hash): string
|
||||||
|
{
|
||||||
|
return $this->prefix . 'n8n:fresh:' . $hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function staleKey(string $hash): string
|
||||||
|
{
|
||||||
|
return $this->prefix . 'n8n:stale:' . $hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lockKey(string $hash): string
|
||||||
|
{
|
||||||
|
return $this->prefix . 'n8n:lock:' . $hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function groupKey(string $group): string
|
||||||
|
{
|
||||||
|
return $this->prefix . 'n8n:group:' . $group;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\N8n;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie lange welcher Bereich zwischengespeichert wird.
|
||||||
|
*
|
||||||
|
* Die frische TTL orientiert sich am Aktualisierungstakt der Oberflaeche: die
|
||||||
|
* Ticketliste laedt alle 20 Sekunden nach, 15 Sekunden Cache fangen also beide
|
||||||
|
* offenen Arbeitsplaetze ab, ohne dass jemand veraltete Daten sieht.
|
||||||
|
*
|
||||||
|
* Die Rueckfall-TTL ist grosszuegig. Sie greift nur, wenn n8n nicht erreichbar
|
||||||
|
* ist -- dann sind zehn Minuten alte Tickets deutlich besser als eine Fehlermeldung.
|
||||||
|
*
|
||||||
|
* @phpstan-type Policy array{0: string, 1: int, 2: int}
|
||||||
|
*/
|
||||||
|
final class CachePolicy
|
||||||
|
{
|
||||||
|
// Gruppe frisch Rueckfall
|
||||||
|
public const array Tickets = ['tickets', 15, 600];
|
||||||
|
public const array Offers = ['offers', 30, 600];
|
||||||
|
public const array Customers = ['customers', 120, 1800];
|
||||||
|
public const array Tasks = ['tasks', 20, 600];
|
||||||
|
public const array Invoices = ['invoices', 30, 600];
|
||||||
|
public const array InvoicesCreate = ['invoices-create', 30, 600];
|
||||||
|
public const array InvoicesReview = ['invoices-review', 30, 600];
|
||||||
|
public const array InvoicesSend = ['invoices-send', 30, 600];
|
||||||
|
public const array Purchases = ['purchases', 30, 600];
|
||||||
|
|
||||||
|
/** Der Stundennachweis ist ein schwerer Report und aendert sich selten. */
|
||||||
|
public const array Hours = ['hours', 300, 3600];
|
||||||
|
|
||||||
|
/** Alle Gruppen, fuer die Wartungs-CLI und den Sammelaufruf nach dem Abgleich. */
|
||||||
|
public const array ALL_GROUPS = [
|
||||||
|
'tickets', 'offers', 'customers', 'tasks',
|
||||||
|
'invoices', 'invoices-create', 'invoices-review', 'invoices-send',
|
||||||
|
'purchases', 'hours',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\N8n;
|
||||||
|
|
||||||
|
use GuzzleHttp\Client as Guzzle;
|
||||||
|
use GuzzleHttp\Exception\GuzzleException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duenner HTTP-Zugang zu n8n.
|
||||||
|
*
|
||||||
|
* Wirft bei einem HTTP-Fehlerstatus nicht: der Aufrufer entscheidet, was ein
|
||||||
|
* 404 oder 502 aus n8n fuer die Oberflaeche bedeutet. Nur ein echter
|
||||||
|
* Verbindungsfehler ergibt Status 0.
|
||||||
|
*/
|
||||||
|
final readonly class Client
|
||||||
|
{
|
||||||
|
public function __construct(private Guzzle $http) {}
|
||||||
|
|
||||||
|
public function get(string $url, array $headers = []): Reply
|
||||||
|
{
|
||||||
|
return $this->send('GET', $url, $headers, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function post(string $url, ?array $json = null, array $headers = []): Reply
|
||||||
|
{
|
||||||
|
return $this->send('POST', $url, $headers, $json);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function put(string $url, ?array $json = null, array $headers = []): Reply
|
||||||
|
{
|
||||||
|
return $this->send('PUT', $url, $headers, $json);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $url, ?array $json = null, array $headers = []): Reply
|
||||||
|
{
|
||||||
|
return $this->send('DELETE', $url, $headers, $json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function send(string $method, string $url, array $headers, ?array $json): Reply
|
||||||
|
{
|
||||||
|
$options = ['headers' => $headers + ['Accept' => 'application/json'], 'http_errors' => false];
|
||||||
|
|
||||||
|
if ($json !== null) {
|
||||||
|
$options['json'] = $json;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->http->request($method, $url, $options);
|
||||||
|
|
||||||
|
return new Reply(
|
||||||
|
status: $response->getStatusCode(),
|
||||||
|
body: (string) $response->getBody(),
|
||||||
|
contentType: $response->getHeaderLine('Content-Type'),
|
||||||
|
);
|
||||||
|
} catch (GuzzleException) {
|
||||||
|
// n8n nicht erreichbar. Kein Stacktrace ins Protokoll: der Aufrufer
|
||||||
|
// meldet das als 502 und faellt, wo moeglich, auf den Cache zurueck.
|
||||||
|
return new Reply(status: 0, body: '', contentType: '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\N8n;
|
||||||
|
|
||||||
|
use Ekdos\Support\Config;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle n8n-Adressen an einer Stelle.
|
||||||
|
*
|
||||||
|
* Zwei Wege, bewusst getrennt:
|
||||||
|
*
|
||||||
|
* webhook() Der n8n-Dienst auf Port 5678. Er ist ausschliesslich ueber den
|
||||||
|
* CNAME n8n.elektro-krueger.eu erreichbar, deshalb laeuft jeder
|
||||||
|
* ek-dos-web-Webhook darueber.
|
||||||
|
*
|
||||||
|
* internal() Jeder weitere n8n-Dienst, der auf einem anderen Port lauscht,
|
||||||
|
* wird ueber die RFC1918-Adresse angesprochen. Diese Dienste sind
|
||||||
|
* nicht nach aussen veroeffentlicht.
|
||||||
|
*/
|
||||||
|
final readonly class Endpoints
|
||||||
|
{
|
||||||
|
private const string PREFIX = '/webhook/ek-dos-web';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private string $publicBase,
|
||||||
|
private string $internalHost,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function fromConfig(Config $config): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
publicBase: rtrim($config->string('N8N_PUBLIC_BASE', 'https://n8n.elektro-krueger.eu'), '/'),
|
||||||
|
internalHost: $config->string('N8N_INTERNAL_HOST', '10.0.11.131'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Webhook am Hauptdienst (Port 5678, nur ueber den CNAME erreichbar). */
|
||||||
|
public function webhook(string $path = '', array $query = []): string
|
||||||
|
{
|
||||||
|
return $this->publicBase . self::PREFIX . $path . self::query($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beliebiger weiterer n8n-Dienst im internen Netz. */
|
||||||
|
public function internal(int $port, string $path, array $query = []): string
|
||||||
|
{
|
||||||
|
return 'http://' . $this->internalHost . ':' . $port . $path . self::query($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tickets ───────────────────────────────────────────────────────────
|
||||||
|
public function openTickets(): string
|
||||||
|
{
|
||||||
|
return $this->webhook('/offene-tickets');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ticketConsultation(): string
|
||||||
|
{
|
||||||
|
return $this->webhook('/offene-tickets/kundenruecksprache');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ticketDigitalFile(string $ticket): string
|
||||||
|
{
|
||||||
|
return $this->webhook('/tickets/digitale-akte', ['ticket' => $ticket]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ticketServiceReport(string $ticket): string
|
||||||
|
{
|
||||||
|
return $this->webhook('/tickets/servicebericht', ['ticket' => $ticket]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Angebote ──────────────────────────────────────────────────────────
|
||||||
|
public function offers(string $action = ''): string
|
||||||
|
{
|
||||||
|
return $this->webhook('/angebote' . $action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kundenstamm ───────────────────────────────────────────────────────
|
||||||
|
public function customers(): string
|
||||||
|
{
|
||||||
|
return $this->webhook('/kundenstamm');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function customerDigitalFile(string $customer): string
|
||||||
|
{
|
||||||
|
return $this->webhook('/kundenstamm/digitale-akte', ['customer' => $customer]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rechnungen ────────────────────────────────────────────────────────
|
||||||
|
public function invoices(array $query = []): string
|
||||||
|
{
|
||||||
|
return $this->webhook('/rechnungen', $query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function allInvoices(): string
|
||||||
|
{
|
||||||
|
return $this->webhook('/rechnungen/alle');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assignInvoice(): string
|
||||||
|
{
|
||||||
|
return $this->webhook('/rechnungen/zuordnen');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invoicePdf(string $path): string
|
||||||
|
{
|
||||||
|
return $this->webhook('/rechnungen/pdf', ['path' => $path]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invoiceSync(): string
|
||||||
|
{
|
||||||
|
return $this->webhook('/rechnungen/sync');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invoicesCreate(): string
|
||||||
|
{
|
||||||
|
return $this->webhook('/rechnungen-anfertigen');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invoicesCreateComplete(): string
|
||||||
|
{
|
||||||
|
return $this->webhook('/rechnungen-anfertigen/erledigt');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invoicesReview(): string
|
||||||
|
{
|
||||||
|
return $this->webhook('/rechnungen-pruefen');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function invoicesSend(): string
|
||||||
|
{
|
||||||
|
return $this->webhook('/rechnungen-versenden');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function digitalFile(string $taskId): string
|
||||||
|
{
|
||||||
|
return $this->webhook('/digitale-akte', ['taskId' => $taskId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Interne Aufgaben ──────────────────────────────────────────────────
|
||||||
|
public function internalTasks(string $action = ''): string
|
||||||
|
{
|
||||||
|
return $this->webhook('/interne-aufgaben' . $action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Online-Kaeufe ─────────────────────────────────────────────────────
|
||||||
|
public function onlinePurchases(string $action = ''): string
|
||||||
|
{
|
||||||
|
return $this->webhook('/online-kaeufe' . $action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stundennachweise ──────────────────────────────────────────────────
|
||||||
|
public function hours(): string
|
||||||
|
{
|
||||||
|
return $this->webhook('/stundennachweise');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function query(array $query): string
|
||||||
|
{
|
||||||
|
$filtered = array_filter($query, static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||||
|
|
||||||
|
return $filtered === [] ? '' : '?' . http_build_query($filtered);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\N8n;
|
||||||
|
|
||||||
|
use Ekdos\Support\Json;
|
||||||
|
|
||||||
|
/** Eine Antwort von n8n, roh und unbewertet. */
|
||||||
|
final readonly class Reply
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $status,
|
||||||
|
public string $body,
|
||||||
|
public string $contentType = '',
|
||||||
|
public bool $stale = false,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function ok(): bool
|
||||||
|
{
|
||||||
|
return $this->status >= 200 && $this->status < 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Status fuer die Oberflaeche: ein Verbindungsfehler wird zu 502. */
|
||||||
|
public function statusOr(int $fallback = 502): int
|
||||||
|
{
|
||||||
|
return $this->status === 0 ? $fallback : $this->status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function decoded(): array
|
||||||
|
{
|
||||||
|
return Json::decode($this->body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** n8n liefert Listen mal als {"key":[...]}, mal als nacktes Array. */
|
||||||
|
public function items(string $key): array
|
||||||
|
{
|
||||||
|
return Json::listFrom($this->decoded(), $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function asStale(): self
|
||||||
|
{
|
||||||
|
return new self($this->status, $this->body, $this->contentType, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Relay;
|
||||||
|
|
||||||
|
use Ekdos\N8n\CachePolicy;
|
||||||
|
use Ekdos\Support\Json;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechnungen eines Kunden, die Gesamtuebersicht und die Zuordnung offener Belege.
|
||||||
|
*/
|
||||||
|
final readonly class CustomerInvoicesController extends RelayController
|
||||||
|
{
|
||||||
|
/** Die Rechnungsablage. Nur darunter darf ein PDF ausgeliefert werden. */
|
||||||
|
private const string PDF_ROOT = '/files/EK-DOS/40 Rechnungen/';
|
||||||
|
|
||||||
|
public function index(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$mode = $this->query($request, 'mode');
|
||||||
|
$wantsAll = $mode === 'all';
|
||||||
|
$secret = $this->invoiceSyncSecret();
|
||||||
|
|
||||||
|
if ($wantsAll && $secret === '') {
|
||||||
|
return Json::error($response, 'Die geschützte Rechnungsübersicht ist noch nicht eingerichtet.', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $wantsAll ? $this->n8nUrl->allInvoices() : $this->n8nUrl->invoices([
|
||||||
|
'kunde' => $this->query($request, 'kunde'),
|
||||||
|
'aliases' => $this->query($request, 'aliases'),
|
||||||
|
'mode' => $mode,
|
||||||
|
'liegenschaft' => $this->query($request, 'liegenschaft'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$reply = $this->cachedGet(CachePolicy::Invoices, $url, $wantsAll ? ['x-ekdos-invoice-sync' => $secret] : []);
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Rechnungen konnten nicht geladen werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = $reply->decoded();
|
||||||
|
|
||||||
|
return Json::write($response, [
|
||||||
|
'invoices' => is_array($decoded['invoices'] ?? null) ? array_values($decoded['invoices']) : [],
|
||||||
|
'properties' => is_array($decoded['properties'] ?? null) ? array_values($decoded['properties']) : [],
|
||||||
|
'stale' => $reply->stale ?: null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einen offenen Beleg einem Kunden zuordnen. */
|
||||||
|
public function assign(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$body = $this->body($request);
|
||||||
|
$id = self::text($body, 'id');
|
||||||
|
$customer = self::text($body, 'kunde');
|
||||||
|
$secret = $this->invoiceSyncSecret();
|
||||||
|
|
||||||
|
if ($id === '' || $customer === '') {
|
||||||
|
return Json::error($response, 'Rechnung und Kunde müssen ausgewählt sein.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($secret === '') {
|
||||||
|
return Json::error($response, 'Die geschützte Rechnungszuordnung ist noch nicht eingerichtet.', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $this->n8n->post(
|
||||||
|
$this->n8nUrl->assignInvoice(),
|
||||||
|
['id' => $id, 'kunde' => $customer, 'kundennummer' => self::text($body, 'kundennummer')],
|
||||||
|
['x-ekdos-invoice-sync' => $secret],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Rechnung konnte nicht zugeordnet werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cache->invalidate(CachePolicy::Invoices[0]);
|
||||||
|
|
||||||
|
return Json::passthrough($response, $reply->body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechnungs-PDF.
|
||||||
|
*
|
||||||
|
* Der Pfad kommt aus der Oberflaeche, deshalb wird er streng geprueft: er muss
|
||||||
|
* unterhalb der Rechnungsablage liegen, auf .pdf enden und darf kein ".."
|
||||||
|
* enthalten. Ohne diese Pruefung waere die Route ein Dateibrowser fuer die NAS.
|
||||||
|
*/
|
||||||
|
public function pdf(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$path = $this->query($request, 'path');
|
||||||
|
|
||||||
|
if (!str_starts_with($path, self::PDF_ROOT) || !str_ends_with(strtolower($path), '.pdf') || str_contains($path, '..')) {
|
||||||
|
return Json::error($response, 'Ungültiger Rechnungspfad.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $this->n8n->get($this->n8nUrl->invoicePdf($path));
|
||||||
|
|
||||||
|
if (!$reply->ok() || $reply->body === '') {
|
||||||
|
return $this->upstreamError($response, $reply, 'Die Rechnung ist nicht verfügbar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->pdfResponse($response, $reply->body, 'Rechnung.pdf');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stoesst den Abgleich der Rechnungsablage in n8n an. */
|
||||||
|
public function sync(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$secret = $this->invoiceSyncSecret();
|
||||||
|
|
||||||
|
if ($secret === '') {
|
||||||
|
return Json::error($response, 'Der geschützte Rechnungsabgleich ist noch nicht eingerichtet.', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $this->n8n->post($this->n8nUrl->invoiceSync(), null, ['x-ekdos-invoice-sync' => $secret]);
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Der Rechnungsabgleich konnte nicht durchgeführt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach einem Abgleich stimmt keine der Rechnungslisten mehr.
|
||||||
|
$this->cache->invalidate(
|
||||||
|
CachePolicy::Invoices[0],
|
||||||
|
CachePolicy::InvoicesCreate[0],
|
||||||
|
CachePolicy::InvoicesReview[0],
|
||||||
|
CachePolicy::InvoicesSend[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $response->withStatus(204);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Relay;
|
||||||
|
|
||||||
|
use Ekdos\N8n\CachePolicy;
|
||||||
|
use Ekdos\Support\Json;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
||||||
|
final readonly class CustomersController extends RelayController
|
||||||
|
{
|
||||||
|
public function index(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$reply = $this->cachedGet(CachePolicy::Customers, $this->n8nUrl->customers(), $this->readHeaders());
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Der Kundenstamm konnte nicht aus n8n geladen werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->listResponse($response, $reply, 'customers', 'customers');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neuanlage. n8n verlangt dafuer das geteilte Geheimnis, nicht den Lese-Schluessel. */
|
||||||
|
public function create(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$secret = $this->invoiceSyncSecret();
|
||||||
|
|
||||||
|
if ($secret === '') {
|
||||||
|
return Json::error($response, 'Die sichere Kundenanlage ist noch nicht eingerichtet.', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $this->n8n->post($this->n8nUrl->customers(), $this->body($request), ['x-ekdos-invoice-sync' => $secret]);
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Der Kunde konnte nicht im Kundenstamm gespeichert werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cache->invalidate(CachePolicy::Customers[0]);
|
||||||
|
|
||||||
|
return Json::write($response, ['customer' => $reply->decoded()], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$reply = $this->n8n->put($this->n8nUrl->customers(), $this->body($request), $this->readHeaders());
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Der Kunde konnte nicht im Kundenstamm gespeichert werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cache->invalidate(CachePolicy::Customers[0]);
|
||||||
|
|
||||||
|
return Json::write($response, ['customer' => $reply->decoded()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vormals: nur Sascha. */
|
||||||
|
public function delete(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$reply = $this->n8n->delete($this->n8nUrl->customers(), $this->body($request), $this->readHeaders());
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Der Kunde konnte nicht aus dem Kundenstamm gelöscht werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cache->invalidate(CachePolicy::Customers[0]);
|
||||||
|
|
||||||
|
return Json::write($response, ['deleted' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function digitalFile(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$customer = $this->query($request, 'customer');
|
||||||
|
|
||||||
|
if ($customer === '' || mb_strlen($customer) > 160) {
|
||||||
|
return Json::error($response, 'Der Kunde fehlt.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $this->n8n->get($this->n8nUrl->customerDigitalFile($customer));
|
||||||
|
|
||||||
|
if (!$reply->ok() || $reply->body === '') {
|
||||||
|
return $this->upstreamError($response, $reply, 'Die Digitale Akte ist nicht verfügbar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->pdfResponse($response, $reply->body, 'Digitale_Akte.pdf');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Lese-Schluessel des Kundenstamm-Workflows.
|
||||||
|
*
|
||||||
|
* Er stand frueher als Literal im Quelltext; jetzt kommt er aus der Umgebung,
|
||||||
|
* damit er rotiert werden kann, ohne die Anwendung neu zu bauen.
|
||||||
|
*/
|
||||||
|
private function readHeaders(): array
|
||||||
|
{
|
||||||
|
$key = $this->config->string('N8N_CUSTOMER_KEY');
|
||||||
|
|
||||||
|
return $key === '' ? [] : ['X-EK-DOS-Customer-Key' => $key];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Relay;
|
||||||
|
|
||||||
|
use Ekdos\N8n\CachePolicy;
|
||||||
|
use Ekdos\Support\Json;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stundennachweise.
|
||||||
|
*
|
||||||
|
* Ein schwerer Report, den n8n aus mehreren Quellen zusammensetzt. Er wird
|
||||||
|
* deshalb am laengsten zwischengespeichert (5 Minuten frisch, 1 Stunde Rueckfall).
|
||||||
|
*/
|
||||||
|
final readonly class HoursController extends RelayController
|
||||||
|
{
|
||||||
|
public function index(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$reply = $this->cachedGet(CachePolicy::Hours, $this->n8nUrl->hours());
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Stundennachweise konnten nicht aus n8n geladen werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = $reply->decoded();
|
||||||
|
// n8n liefert den Report je nach Workflow-Zweig als Objekt oder als Liste mit einem Element.
|
||||||
|
$source = array_is_list($decoded) ? (is_array($decoded[0] ?? null) ? $decoded[0] : []) : $decoded;
|
||||||
|
|
||||||
|
$employees = [];
|
||||||
|
|
||||||
|
foreach ((is_array($source['employees'] ?? null) ? $source['employees'] : []) as $row) {
|
||||||
|
if (is_array($row) && ($employee = self::mapEmployee($row)) !== null) {
|
||||||
|
$employees[] = $employee;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'month' => self::text($source, 'month'),
|
||||||
|
'year' => self::number($source, 'year'),
|
||||||
|
'generatedAt' => self::text($source, 'generatedAt') !== '' ? self::text($source, 'generatedAt') : gmdate('c'),
|
||||||
|
'employees' => $employees,
|
||||||
|
'statusPriority' => self::text($source, 'statusPriority') !== '' ? self::text($source, 'statusPriority') : null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($reply->stale) {
|
||||||
|
$payload['stale'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Json::write($response, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed>|null */
|
||||||
|
private static function mapEmployee(array $row): ?array
|
||||||
|
{
|
||||||
|
$name = self::text($row, 'name');
|
||||||
|
|
||||||
|
if ($name === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
foreach ((is_array($row['entries'] ?? null) ? $row['entries'] : []) as $entry) {
|
||||||
|
if (is_array($entry) && !array_is_list($entry)) {
|
||||||
|
$entries[] = self::mapEntry($entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $name,
|
||||||
|
'totalHours' => self::number($row, 'totalHours'),
|
||||||
|
'entries' => $entries,
|
||||||
|
'workDaysYear' => self::number($row, 'workDaysYear'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private static function mapEntry(array $entry): array
|
||||||
|
{
|
||||||
|
$date = self::text($entry, 'date');
|
||||||
|
$label = self::text($entry, 'label');
|
||||||
|
$status = self::text($entry, 'status');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'date' => $date,
|
||||||
|
'label' => $label !== '' ? $label : $date,
|
||||||
|
'status' => $status !== '' ? $status : 'Betrieb',
|
||||||
|
'customer' => self::optional($entry, 'customer'),
|
||||||
|
'property' => self::optional($entry, 'property'),
|
||||||
|
'report' => self::optional($entry, 'report'),
|
||||||
|
'hours' => self::number($entry, 'hours'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function number(array $source, string $key): float
|
||||||
|
{
|
||||||
|
$value = $source[$key] ?? null;
|
||||||
|
|
||||||
|
return is_numeric($value) ? (float) $value : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function optional(array $source, string $key): ?string
|
||||||
|
{
|
||||||
|
$value = self::text($source, $key);
|
||||||
|
|
||||||
|
return $value !== '' ? $value : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Relay;
|
||||||
|
|
||||||
|
use Ekdos\N8n\CachePolicy;
|
||||||
|
use Ekdos\Support\Json;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
||||||
|
final readonly class InternalTasksController extends RelayController
|
||||||
|
{
|
||||||
|
private const array RECIPIENTS = ['sascha', 'svenja'];
|
||||||
|
|
||||||
|
private const array CATEGORIES = [
|
||||||
|
'angebote',
|
||||||
|
'steuerberater',
|
||||||
|
'kundenruecksprache',
|
||||||
|
'interne_bueroaufgaben',
|
||||||
|
'heute_erledigen',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function index(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$reply = $this->cachedGet(CachePolicy::Tasks, $this->n8nUrl->internalTasks());
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Interne Aufgaben konnten nicht geladen werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->listResponse($response, $reply, 'tasks', 'tasks');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$body = $this->body($request);
|
||||||
|
$fields = self::validate($body);
|
||||||
|
|
||||||
|
if (is_string($fields)) {
|
||||||
|
return Json::error($response, $fields, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields['erstellt_von'] = $this->session($request)->displayName;
|
||||||
|
$reply = $this->n8n->post($this->n8nUrl->internalTasks(), $fields);
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Aufgabe konnte nicht gespeichert werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cache->invalidate(CachePolicy::Tasks[0]);
|
||||||
|
|
||||||
|
return Json::passthrough($response, $reply->body, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function complete(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$id = self::text($this->body($request), 'id');
|
||||||
|
|
||||||
|
if ($id === '') {
|
||||||
|
return Json::error($response, 'Die Aufgabe fehlt.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $this->n8n->post($this->n8nUrl->internalTasks('/erledigt'), [
|
||||||
|
'id' => $id,
|
||||||
|
'erledigt_von' => $this->session($request)->displayName,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Aufgabe konnte nicht abgeschlossen werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cache->invalidate(CachePolicy::Tasks[0]);
|
||||||
|
|
||||||
|
return Json::passthrough($response, $reply->body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$body = $this->body($request);
|
||||||
|
$id = self::text($body, 'id');
|
||||||
|
$fields = self::validate($body);
|
||||||
|
|
||||||
|
if ($id === '') {
|
||||||
|
return Json::error($response, 'Die Aufgabe fehlt.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($fields)) {
|
||||||
|
return Json::error($response, 'Bitte Aufgabe, Empfänger und Kategorie vollständig angeben.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $this->n8n->post($this->n8nUrl->internalTasks('/bearbeiten'), ['id' => $id] + $fields);
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Aufgabe konnte nicht bearbeitet werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cache->invalidate(CachePolicy::Tasks[0]);
|
||||||
|
|
||||||
|
return Json::passthrough($response, $reply->body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vormals: nur Sascha. */
|
||||||
|
public function delete(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$id = self::text($this->body($request), 'id');
|
||||||
|
|
||||||
|
if ($id === '') {
|
||||||
|
return Json::error($response, 'Die Aufgabe fehlt.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $this->n8n->post($this->n8nUrl->internalTasks('/loeschen'), ['id' => $id]);
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Aufgabe konnte nicht gelöscht werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cache->invalidate(CachePolicy::Tasks[0]);
|
||||||
|
|
||||||
|
return Json::passthrough($response, $reply->body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>|string Felder oder die Fehlermeldung.
|
||||||
|
*/
|
||||||
|
private static function validate(array $body): array|string
|
||||||
|
{
|
||||||
|
$task = self::text($body, 'aufgabe');
|
||||||
|
$recipient = self::text($body, 'empfaenger');
|
||||||
|
$category = self::text($body, 'kategorie');
|
||||||
|
|
||||||
|
if ($task === '') {
|
||||||
|
return 'Bitte Aufgabe eingeben.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($recipient, self::RECIPIENTS, true) || !in_array($category, self::CATEGORIES, true)) {
|
||||||
|
return 'Bitte Empfänger und Kategorie auswählen.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'aufgabe' => $task,
|
||||||
|
'empfaenger' => $recipient,
|
||||||
|
'kategorie' => $category,
|
||||||
|
'kunden_id' => self::text($body, 'kunden_id'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Relay;
|
||||||
|
|
||||||
|
use Ekdos\N8n\CachePolicy;
|
||||||
|
use Ekdos\Support\Json;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die drei Rechnungslisten des Buero-Ablaufs: anfertigen, pruefen, versenden.
|
||||||
|
*
|
||||||
|
* Anfertigen und Versenden waren frueher auf Svenja verdrahtet und haengen jetzt
|
||||||
|
* am Recht "Rechnungen bearbeiten und versenden". Die Pruefliste war in der
|
||||||
|
* Next.js-Fassung versehentlich voellig ungeschuetzt -- sie verlangt jetzt wie
|
||||||
|
* jede andere Route eine angemeldete Sitzung.
|
||||||
|
*/
|
||||||
|
final readonly class InvoicesController extends RelayController
|
||||||
|
{
|
||||||
|
public function createList(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$reply = $this->cachedGet(CachePolicy::InvoicesCreate, $this->n8nUrl->invoicesCreate());
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Die Rechnungsaufgaben-Schnittstelle ist nicht erreichbar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Json::passthrough($response, $reply->body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function completeCreateTask(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$id = self::text($this->body($request), 'id');
|
||||||
|
|
||||||
|
if ($id === '') {
|
||||||
|
return Json::error($response, 'Die Rechnungsaufgabe fehlt.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $this->n8n->post($this->n8nUrl->invoicesCreateComplete(), ['id' => $id]);
|
||||||
|
$decoded = $reply->decoded();
|
||||||
|
|
||||||
|
// n8n meldet einen fachlichen Fehler auch mit HTTP 200 und ok:false.
|
||||||
|
if (!$reply->ok() || ($decoded['ok'] ?? null) === false) {
|
||||||
|
$message = self::text($decoded, 'error');
|
||||||
|
|
||||||
|
return Json::error($response, $message !== '' ? $message : 'Die Rechnungsaufgabe konnte nicht abgeschlossen werden.', 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cache->invalidate(CachePolicy::InvoicesCreate[0], CachePolicy::InvoicesReview[0]);
|
||||||
|
|
||||||
|
return Json::passthrough($response, $reply->body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reviewList(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$reply = $this->cachedGet(CachePolicy::InvoicesReview, $this->n8nUrl->invoicesReview());
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Die Prüflisten-Schnittstelle ist nicht erreichbar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Json::passthrough($response, $reply->body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendList(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$reply = $this->cachedGet(CachePolicy::InvoicesSend, $this->n8nUrl->invoicesSend());
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Die Versandaufgaben-Schnittstelle ist nicht erreichbar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Json::passthrough($response, $reply->body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function confirmSent(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$body = $this->body($request);
|
||||||
|
$id = self::text($body, 'id');
|
||||||
|
|
||||||
|
if ($id === '') {
|
||||||
|
return Json::error($response, 'Die Versandaufgabe fehlt.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $this->n8n->post($this->n8nUrl->invoicesSend(), $body);
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Der Versand konnte nicht bestätigt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cache->invalidate(CachePolicy::InvoicesSend[0], CachePolicy::Invoices[0]);
|
||||||
|
|
||||||
|
return Json::passthrough($response, $reply->body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Digitale Akte zu einer Rechnungsaufgabe.
|
||||||
|
* n8n liefert sie hier als base64 in einem JSON-Feld, nicht als Binaerrumpf.
|
||||||
|
*/
|
||||||
|
public function digitalFile(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$taskId = $this->query($request, 'taskId');
|
||||||
|
|
||||||
|
if ($taskId === '') {
|
||||||
|
return Json::error($response, 'Die Rechnungsaufgabe fehlt.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $this->n8n->get($this->n8nUrl->digitalFile($taskId));
|
||||||
|
$decoded = $reply->decoded();
|
||||||
|
$contentBytes = self::text($decoded, 'contentBytes');
|
||||||
|
|
||||||
|
if (!$reply->ok() || $contentBytes === '') {
|
||||||
|
$message = self::text($decoded, 'error');
|
||||||
|
|
||||||
|
return Json::error(
|
||||||
|
$response,
|
||||||
|
$message !== '' ? $message : 'Die Digitale Akte ist nicht verfügbar (HTTP ' . $reply->statusOr(502) . ').',
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$bytes = base64_decode($contentBytes, true);
|
||||||
|
|
||||||
|
if ($bytes === false) {
|
||||||
|
return Json::error($response, 'Die Digitale Akte konnte nicht gelesen werden.', 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = self::text($decoded, 'name');
|
||||||
|
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $name !== '' ? $name : 'Digitale_Akte.pdf') ?? 'Digitale_Akte.pdf';
|
||||||
|
|
||||||
|
return $this->pdfResponse($response, $bytes, $filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Relay;
|
||||||
|
|
||||||
|
use Ekdos\N8n\CachePolicy;
|
||||||
|
use Ekdos\Support\Json;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
||||||
|
final readonly class OffersController extends RelayController
|
||||||
|
{
|
||||||
|
public function index(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$reply = $this->cachedGet(CachePolicy::Offers, $this->n8nUrl->offers());
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Die Angebote konnten nicht aus n8n geladen werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->listResponse($response, $reply, 'offers', 'offers');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Versandstatus, Beauftragung und Ruecksetzung laufen ueber dieselbe Route. */
|
||||||
|
public function update(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$body = $this->body($request);
|
||||||
|
$id = self::text($body, 'id');
|
||||||
|
$action = self::text($body, 'action');
|
||||||
|
|
||||||
|
if ($id === '') {
|
||||||
|
return Json::error($response, 'Das Angebot fehlt.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($action) {
|
||||||
|
'versendet' => $this->markSent($response, $body, $id),
|
||||||
|
'beauftragt' => $this->markCommissioned($response, $body, $id),
|
||||||
|
'zuruecksetzen' => $this->reset($response, $id),
|
||||||
|
default => Json::error($response, 'Unbekannte Angebotsaktion.', 400),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vormals: nur Sascha. */
|
||||||
|
public function delete(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$id = self::text($this->body($request), 'id');
|
||||||
|
|
||||||
|
if ($id === '') {
|
||||||
|
return Json::error($response, 'Das Angebot fehlt.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $this->n8n->post($this->n8nUrl->offers('/loeschen'), ['id' => $id]);
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Das Angebot konnte nicht gelöscht werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cache->invalidate(CachePolicy::Offers[0]);
|
||||||
|
|
||||||
|
return Json::passthrough($response, $reply->body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function markSent(Response $response, array $body, string $id): Response
|
||||||
|
{
|
||||||
|
$shipping = self::text($body, 'versandart');
|
||||||
|
|
||||||
|
if ($shipping !== 'E-Mail' && $shipping !== 'Post') {
|
||||||
|
return Json::error($response, 'Bitte E-Mail oder Post auswählen.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $this->n8n->post($this->n8nUrl->offers('/versendet'), ['id' => $id, 'versandart' => $shipping]);
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Der Versandstatus konnte nicht gespeichert werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cache->invalidate(CachePolicy::Offers[0]);
|
||||||
|
|
||||||
|
return Json::passthrough($response, $reply->body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function markCommissioned(Response $response, array $body, string $id): Response
|
||||||
|
{
|
||||||
|
$reply = $this->n8n->post($this->n8nUrl->offers('/beauftragt'), [
|
||||||
|
'id' => $id,
|
||||||
|
'angebot_nummer' => self::text($body, 'angebot_nummer'),
|
||||||
|
'kunde' => self::text($body, 'kunde'),
|
||||||
|
'objekt_zusatz' => self::text($body, 'objekt_zusatz'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Die Beauftragung konnte nicht gespeichert werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine Beauftragung erzeugt in n8n eine Rechnungsaufgabe, deshalb faellt
|
||||||
|
// auch der Rechnungs-Cache.
|
||||||
|
$this->cache->invalidate(CachePolicy::Offers[0], CachePolicy::InvoicesCreate[0]);
|
||||||
|
|
||||||
|
return Json::passthrough($response, $reply->body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Geschuetzte Ruecksetzung: n8n verlangt hier das geteilte Geheimnis. */
|
||||||
|
private function reset(Response $response, string $id): Response
|
||||||
|
{
|
||||||
|
$secret = $this->invoiceSyncSecret();
|
||||||
|
|
||||||
|
if ($secret === '') {
|
||||||
|
return Json::error($response, 'Die geschützte Rücksetzung ist noch nicht eingerichtet.', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $this->n8n->post($this->n8nUrl->offers('/zuruecksetzen'), ['id' => $id], ['x-ekdos-invoice-sync' => $secret]);
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Der Versandstatus konnte nicht zurückgesetzt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cache->invalidate(CachePolicy::Offers[0]);
|
||||||
|
|
||||||
|
return Json::passthrough($response, $reply->body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Relay;
|
||||||
|
|
||||||
|
use Ekdos\N8n\CachePolicy;
|
||||||
|
use Ekdos\Support\Json;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
||||||
|
final readonly class OnlinePurchasesController extends RelayController
|
||||||
|
{
|
||||||
|
public function index(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$reply = $this->cachedGet(CachePolicy::Purchases, $this->n8nUrl->onlinePurchases());
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Die Online-Käufe konnten nicht aus n8n geladen werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->listResponse($response, $reply, 'purchases', 'purchases');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vormals: nur Sascha. */
|
||||||
|
public function create(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$body = $this->body($request);
|
||||||
|
$article = self::text($body, 'artikel');
|
||||||
|
$marketplace = self::text($body, 'kaufort');
|
||||||
|
$date = self::text($body, 'kaufdatum');
|
||||||
|
|
||||||
|
if ($article === '' || $marketplace === '' || preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
|
||||||
|
return Json::error($response, 'Artikel, Marktplatz und ein gültiges Kaufdatum sind erforderlich.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $this->n8n->post($this->n8nUrl->onlinePurchases(), ['artikel' => $article, 'kaufort' => $marketplace, 'kaufdatum' => $date]);
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Der Online-Kauf konnte nicht in n8n gespeichert werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cache->invalidate(CachePolicy::Purchases[0]);
|
||||||
|
|
||||||
|
return Json::write($response, ['purchase' => $reply->decoded()], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vormals: nur Svenja. */
|
||||||
|
public function complete(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$id = self::text($this->body($request), 'id');
|
||||||
|
|
||||||
|
if ($id === '') {
|
||||||
|
return Json::error($response, 'Die Kaufposition fehlt.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $this->n8n->post($this->n8nUrl->onlinePurchases('/erledigt'), ['id' => $id]);
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Der Online-Kauf konnte nicht abgeschlossen werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cache->invalidate(CachePolicy::Purchases[0]);
|
||||||
|
|
||||||
|
return Json::write($response, $reply->decoded() + ['erledigt_am' => gmdate('c')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vormals: nur Sascha. */
|
||||||
|
public function delete(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$id = self::text($this->body($request), 'id');
|
||||||
|
|
||||||
|
if ($id === '') {
|
||||||
|
return Json::error($response, 'Die Kaufposition fehlt.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $this->n8n->post($this->n8nUrl->onlinePurchases('/loeschen'), ['id' => $id]);
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Der Online-Kauf konnte nicht gelöscht werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cache->invalidate(CachePolicy::Purchases[0]);
|
||||||
|
|
||||||
|
return Json::passthrough($response, $reply->body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Relay;
|
||||||
|
|
||||||
|
use Ekdos\Auth\Session;
|
||||||
|
use Ekdos\Http\Middleware\SessionMiddleware;
|
||||||
|
use Ekdos\N8n\Cache;
|
||||||
|
use Ekdos\N8n\Client;
|
||||||
|
use Ekdos\N8n\Endpoints;
|
||||||
|
use Ekdos\N8n\Reply;
|
||||||
|
use Ekdos\Support\Config;
|
||||||
|
use Ekdos\Support\Json;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemeinsame Basis aller n8n-Weiterleitungen.
|
||||||
|
*
|
||||||
|
* Jeder abgeleitete Controller bleibt damit auf dem, was ihn ausmacht: welche
|
||||||
|
* Adresse, welche Gruppe, welche Meldung im Fehlerfall.
|
||||||
|
*/
|
||||||
|
abstract readonly class RelayController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected Client $n8n,
|
||||||
|
protected Endpoints $n8nUrl,
|
||||||
|
protected Cache $cache,
|
||||||
|
protected Config $config,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt eine Liste durch den Cache.
|
||||||
|
*
|
||||||
|
* @param array{0: string, 1: int, 2: int} $policy siehe CachePolicy
|
||||||
|
*/
|
||||||
|
protected function cachedGet(array $policy, string $url, array $headers = []): Reply
|
||||||
|
{
|
||||||
|
[$group, $ttl, $stale] = $policy;
|
||||||
|
|
||||||
|
return $this->cache->remember(
|
||||||
|
$group,
|
||||||
|
$url . '|' . json_encode($headers, JSON_THROW_ON_ERROR),
|
||||||
|
$ttl,
|
||||||
|
$stale,
|
||||||
|
fn (): Reply => $this->n8n->get($url, $headers),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function session(Request $request): Session
|
||||||
|
{
|
||||||
|
// RequireAuth laeuft vor jedem Controller, deshalb ist die Sitzung hier gesetzt.
|
||||||
|
return SessionMiddleware::of($request) ?? throw new \LogicException('Route ohne RequireAuth erreicht.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function body(Request $request): array
|
||||||
|
{
|
||||||
|
$parsed = $request->getParsedBody();
|
||||||
|
|
||||||
|
if (is_array($parsed)) {
|
||||||
|
return $parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Json::decode((string) $request->getBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function query(Request $request, string $key, string $fallback = ''): string
|
||||||
|
{
|
||||||
|
$value = $request->getQueryParams()[$key] ?? null;
|
||||||
|
|
||||||
|
return is_string($value) ? trim($value) : $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nimmt Zeichenketten und Zahlen an und liefert immer eine getrimmte Zeichenkette. */
|
||||||
|
protected static function text(array $source, string $key): string
|
||||||
|
{
|
||||||
|
$value = $source[$key] ?? null;
|
||||||
|
|
||||||
|
return is_string($value) || is_int($value) || is_float($value) ? trim((string) $value) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einheitliche Fehlerantwort fuer einen fehlgeschlagenen n8n-Aufruf.
|
||||||
|
* Ein Verbindungsfehler (Status 0) wird zu 502.
|
||||||
|
*/
|
||||||
|
protected function upstreamError(Response $response, Reply $reply, string $message): Response
|
||||||
|
{
|
||||||
|
return Json::error($response, $message, $reply->statusOr(502));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Antwortet mit einer Liste und markiert, wenn sie aus dem Rueckfall-Cache stammt. */
|
||||||
|
protected function listResponse(Response $response, Reply $reply, string $key, string $as): Response
|
||||||
|
{
|
||||||
|
$payload = [$as => $reply->items($key), 'refreshedAt' => gmdate('c')];
|
||||||
|
|
||||||
|
if ($reply->stale) {
|
||||||
|
$payload['stale'] = true;
|
||||||
|
$payload['notice'] = 'n8n ist gerade nicht erreichbar. Angezeigt werden die zuletzt bekannten Daten.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Json::write($response, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reicht eine Binaerantwort (PDF) unveraendert durch. */
|
||||||
|
protected function pdfResponse(Response $response, string $bytes, string $filename): Response
|
||||||
|
{
|
||||||
|
$response->getBody()->write($bytes);
|
||||||
|
|
||||||
|
return $response->withHeader('Content-Type', 'application/pdf')
|
||||||
|
->withHeader('Content-Disposition', 'inline; filename="' . $filename . '"')
|
||||||
|
->withHeader('Cache-Control', 'no-store');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Geteiltes Geheimnis fuer die geschuetzten n8n-Workflows. */
|
||||||
|
protected function invoiceSyncSecret(): string
|
||||||
|
{
|
||||||
|
return $this->config->string('N8N_INVOICE_SYNC_SECRET');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Relay;
|
||||||
|
|
||||||
|
use Ekdos\Http\Middleware\SessionMiddleware;
|
||||||
|
use Ekdos\N8n\CachePolicy;
|
||||||
|
use Ekdos\Support\Json;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
||||||
|
final readonly class TicketsController extends RelayController
|
||||||
|
{
|
||||||
|
/** Ticketnummern haben die Form 2026-021. */
|
||||||
|
private const string TICKET_PATTERN = '/^20\d{2}-\d{3}$/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die offene Ticketliste. n8n liefert deutsche Feldnamen, die Oberflaeche
|
||||||
|
* erwartet camelCase -- diese Abbildung ist die einzige echte Logik im Relay.
|
||||||
|
*/
|
||||||
|
public function index(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$reply = $this->cachedGet(CachePolicy::Tickets, $this->n8nUrl->openTickets());
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Offene Tickets konnten nicht aus n8n geladen werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tickets = [];
|
||||||
|
|
||||||
|
foreach ($reply->items('tickets') as $row) {
|
||||||
|
if (!is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticket = self::mapTicket($row);
|
||||||
|
|
||||||
|
// Zeilen ohne Ticketnummer sind fuer die Ansicht wertlos.
|
||||||
|
if ($ticket['id'] !== '') {
|
||||||
|
$tickets[] = $ticket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = ['tickets' => $tickets, 'refreshedAt' => gmdate('c')];
|
||||||
|
|
||||||
|
if ($reply->stale) {
|
||||||
|
$payload['stale'] = true;
|
||||||
|
$payload['notice'] = 'n8n ist gerade nicht erreichbar. Angezeigt werden die zuletzt bekannten Tickets.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Json::write($response, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wird von Workflow4A nach einer Ticketerstellung aufgerufen.
|
||||||
|
*
|
||||||
|
* Anders als frueher ist das kein Platzhalter mehr: der Aufruf verwirft den
|
||||||
|
* Ticket-Cache, sodass die naechste Abfrage der Oberflaeche garantiert die
|
||||||
|
* neuen Daten sieht statt auf den TTL-Ablauf zu warten.
|
||||||
|
*/
|
||||||
|
public function refresh(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$secret = $this->config->string('N8N_REFRESH_SECRET');
|
||||||
|
|
||||||
|
// Entweder eine angemeldete Sitzung oder das geteilte Geheimnis aus n8n.
|
||||||
|
if (SessionMiddleware::of($request) === null) {
|
||||||
|
$presented = $request->getHeaderLine('x-ekdos-webhook-secret');
|
||||||
|
|
||||||
|
if ($secret === '' || !hash_equals($secret, $presented)) {
|
||||||
|
return Json::error($response, 'Bitte erneut anmelden.', 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cache->invalidate(CachePolicy::Tickets[0]);
|
||||||
|
|
||||||
|
return Json::write($response, ['ok' => true, 'refreshedAt' => gmdate('c')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ergebnis der Kundenruecksprache festhalten. Vormals: nur Svenja. */
|
||||||
|
public function consultation(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$body = $this->body($request);
|
||||||
|
$ticketNumber = self::text($body, 'ticketnummer');
|
||||||
|
$result = self::text($body, 'ergebnis');
|
||||||
|
|
||||||
|
if ($ticketNumber === '' || $result === '') {
|
||||||
|
return Json::error($response, 'Ticketnummer und Ergebnis sind erforderlich.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($result) > 4000) {
|
||||||
|
return Json::error($response, 'Das Ergebnis darf maximal 4.000 Zeichen enthalten.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vorher pruefen, ob schon ein Ergebnis hinterlegt ist. Bewusst ungecacht:
|
||||||
|
// hier zaehlt der aktuelle Stand, nicht ein 15 Sekunden alter.
|
||||||
|
$lookup = $this->n8n->get($this->n8nUrl->openTickets());
|
||||||
|
|
||||||
|
foreach ($lookup->items('tickets') as $row) {
|
||||||
|
if (!is_array($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = self::text($row, 'ticketnummer') !== '' ? self::text($row, 'ticketnummer') : self::text($row, 'id');
|
||||||
|
|
||||||
|
if ($id === $ticketNumber && self::text($row, 'kundenruecksprache_ergebnis') !== '') {
|
||||||
|
return Json::error($response, 'Für dieses Ticket wurde bereits ein Ergebnis gespeichert.', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $this->n8n->post($this->n8nUrl->ticketConsultation(), ['ticketnummer' => $ticketNumber, 'ergebnis' => $result]);
|
||||||
|
|
||||||
|
if (!$reply->ok()) {
|
||||||
|
return $this->upstreamError($response, $reply, 'Das Ergebnis der Kundenrücksprache konnte nicht in n8n gespeichert werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cache->invalidate(CachePolicy::Tickets[0]);
|
||||||
|
|
||||||
|
return Json::write($response, ['ok' => true, 'updatedAt' => gmdate('c')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function digitalFile(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$ticket = $this->query($request, 'ticket');
|
||||||
|
|
||||||
|
if (preg_match(self::TICKET_PATTERN, $ticket) !== 1) {
|
||||||
|
return Json::error($response, 'Die Ticketnummer ist ungültig.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $this->n8n->get($this->n8nUrl->ticketDigitalFile($ticket));
|
||||||
|
|
||||||
|
if (!$reply->ok() || $reply->body === '') {
|
||||||
|
return $this->upstreamError($response, $reply, 'Die Digitale Akte ist nicht verfügbar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->pdfResponse($response, $reply->body, 'Digitale_Akte.pdf');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function serviceReport(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$ticket = $this->query($request, 'ticket');
|
||||||
|
|
||||||
|
if (preg_match(self::TICKET_PATTERN, $ticket) !== 1) {
|
||||||
|
return Json::error($response, 'Die Ticketnummer ist ungültig.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reply = $this->n8n->get($this->n8nUrl->ticketServiceReport($ticket));
|
||||||
|
|
||||||
|
if (!$reply->ok() || $reply->body === '') {
|
||||||
|
return $this->upstreamError($response, $reply, 'Der letzte Servicebericht ist nicht verfügbar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->pdfResponse($response, $reply->body, 'Letzter_Servicebericht.pdf');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private static function mapTicket(array $row): array
|
||||||
|
{
|
||||||
|
$continuation = self::text($row, 'fortsetzung');
|
||||||
|
$decisionCode = self::text($row, 'entscheidung_code');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => self::firstOf($row, ['ticketnummer', 'id'], ''),
|
||||||
|
'status' => self::firstOf($row, ['status'], 'offen'),
|
||||||
|
'reportStatus' => self::optional($row, 'letzter_servicebericht_status'),
|
||||||
|
'customer' => self::firstOf($row, ['kunde', 'customer'], '–'),
|
||||||
|
'property' => self::firstOf($row, ['liegenschaft', 'property'], '–'),
|
||||||
|
'title' => self::firstOf($row, ['title'], ''),
|
||||||
|
'updatedAt' => self::firstOf($row, ['updatedAt', 'updated_at', 'erstellt_am', 'createdAt'], ''),
|
||||||
|
'continuation' => $continuation !== '' ? $continuation : null,
|
||||||
|
'decisionCode' => $decisionCode !== '' ? $decisionCode : null,
|
||||||
|
'requiresCustomerConsultation' => self::needsConsultation($decisionCode, $continuation),
|
||||||
|
'consultationResult' => self::optional($row, 'kundenruecksprache_ergebnis'),
|
||||||
|
'consultationUpdatedAt' => self::optional($row, 'kundenruecksprache_am'),
|
||||||
|
'consultationBy' => self::optional($row, 'kundenruecksprache_von'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Ticket braucht Ruecksprache, wenn n8n es entweder ausdruecklich so
|
||||||
|
* kennzeichnet oder der Fortsetzungstext des Technikers es so formuliert.
|
||||||
|
*/
|
||||||
|
private static function needsConsultation(string $decisionCode, string $continuation): bool
|
||||||
|
{
|
||||||
|
if ($decisionCode === 'TEILERLEDIGUNG_RUECKSPRACHE_KUNDE') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Text kommt aus einem PDF und traegt gelegentlich zusammengesetzte
|
||||||
|
// Umlaute, deshalb vor dem Vergleich nach NFKC normalisieren.
|
||||||
|
$normalized = class_exists(\Normalizer::class)
|
||||||
|
? (\Normalizer::normalize($continuation, \Normalizer::FORM_KC) ?: $continuation)
|
||||||
|
: $continuation;
|
||||||
|
|
||||||
|
return preg_match('/nimmt\s+mit\s+kunden\s+für\s+das\s+weitere\s+vorgehen\s+kontakt\s+auf/iu', $normalized) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erstes gesetztes Feld aus der Liste, sonst der Vorgabewert. */
|
||||||
|
private static function firstOf(array $row, array $keys, string $fallback): string
|
||||||
|
{
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (isset($row[$key]) && (is_string($row[$key]) || is_numeric($row[$key]))) {
|
||||||
|
return (string) $row[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leere Felder werden zu null, damit die Oberflaeche sie ueberspringen kann. */
|
||||||
|
private static function optional(array $row, string $key): ?string
|
||||||
|
{
|
||||||
|
$value = self::text($row, $key);
|
||||||
|
|
||||||
|
return $value !== '' ? $value : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Support;
|
||||||
|
|
||||||
|
/** Die gelesene Umgebung, einmal beim Start eingesammelt. */
|
||||||
|
final readonly class Config
|
||||||
|
{
|
||||||
|
private function __construct(private array $values) {}
|
||||||
|
|
||||||
|
public static function fromEnvironment(array $environment): self
|
||||||
|
{
|
||||||
|
return new self($environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function string(string $key, string $fallback = ''): string
|
||||||
|
{
|
||||||
|
$value = $this->values[$key] ?? null;
|
||||||
|
|
||||||
|
return is_string($value) && trim($value) !== '' ? trim($value) : $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function int(string $key, int $fallback): int
|
||||||
|
{
|
||||||
|
$value = $this->values[$key] ?? null;
|
||||||
|
|
||||||
|
return is_numeric($value) ? (int) $value : $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bool(string $key, bool $fallback): bool
|
||||||
|
{
|
||||||
|
$value = $this->values[$key] ?? null;
|
||||||
|
|
||||||
|
if (is_bool($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match (is_string($value) ? strtolower(trim($value)) : null) {
|
||||||
|
'1', 'true', 'yes', 'on' => true,
|
||||||
|
'0', 'false', 'no', 'off' => false,
|
||||||
|
default => $fallback,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** true, sobald ein Wert (z. B. ein geteiltes Geheimnis) hinterlegt ist. */
|
||||||
|
public function has(string $key): bool
|
||||||
|
{
|
||||||
|
return $this->string($key) !== '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Support;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
|
||||||
|
/** Einheitliche JSON-Antworten. Alle Meldungen sind deutschsprachig und fuer die Oberflaeche gedacht. */
|
||||||
|
final class Json
|
||||||
|
{
|
||||||
|
public static function write(Response $response, mixed $payload, int $status = 200): Response
|
||||||
|
{
|
||||||
|
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||||
|
$response->getBody()->write($body);
|
||||||
|
|
||||||
|
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
->withHeader('Cache-Control', 'no-store')
|
||||||
|
->withStatus($status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function error(Response $response, string $message, int $status): Response
|
||||||
|
{
|
||||||
|
return self::write($response, ['error' => $message], $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schreibt einen bereits von n8n gelieferten JSON-Rumpf unveraendert durch. */
|
||||||
|
public static function passthrough(Response $response, string $body, int $status = 200): Response
|
||||||
|
{
|
||||||
|
$response->getBody()->write($body === '' ? '{}' : $body);
|
||||||
|
|
||||||
|
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
->withHeader('Cache-Control', 'no-store')
|
||||||
|
->withStatus($status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dekodiert einen n8n-Rumpf defensiv: kaputtes JSON wird zu einem leeren Array. */
|
||||||
|
public static function decode(string $body): array
|
||||||
|
{
|
||||||
|
$decoded = json_decode($body, true);
|
||||||
|
|
||||||
|
return is_array($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* n8n liefert Listen mal als {"tickets":[...]}, mal als nacktes Array.
|
||||||
|
* Diese Helferin nimmt beide Formen an.
|
||||||
|
*/
|
||||||
|
public static function listFrom(array $decoded, string $key): array
|
||||||
|
{
|
||||||
|
if (isset($decoded[$key]) && is_array($decoded[$key])) {
|
||||||
|
return array_values($decoded[$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_is_list($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Users;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feingranulare Rechte statt Namensabfragen. Jede Prüfung im alten Code
|
||||||
|
* ("nur Sascha", "nur Svenja") entspricht genau einem Eintrag hier.
|
||||||
|
*/
|
||||||
|
final class Permission
|
||||||
|
{
|
||||||
|
/** Benutzer anlegen, ändern, löschen. */
|
||||||
|
public const string UsersManage = 'users.manage';
|
||||||
|
|
||||||
|
/** Vormals: nur Sascha. */
|
||||||
|
public const string OffersDelete = 'offers.delete';
|
||||||
|
public const string CustomersDelete = 'customers.delete';
|
||||||
|
public const string TasksDelete = 'tasks.delete';
|
||||||
|
public const string PurchasesCreate = 'purchases.create';
|
||||||
|
public const string PurchasesDelete = 'purchases.delete';
|
||||||
|
|
||||||
|
/** Vormals: nur Svenja. */
|
||||||
|
public const string PurchasesComplete = 'purchases.complete';
|
||||||
|
public const string InvoicesProcess = 'invoices.process';
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
public const array ALL = [
|
||||||
|
self::UsersManage,
|
||||||
|
self::OffersDelete,
|
||||||
|
self::CustomersDelete,
|
||||||
|
self::TasksDelete,
|
||||||
|
self::PurchasesCreate,
|
||||||
|
self::PurchasesDelete,
|
||||||
|
self::PurchasesComplete,
|
||||||
|
self::InvoicesProcess,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Menschenlesbare Bezeichnung für die Benutzerverwaltung. */
|
||||||
|
public static function label(string $permission): string
|
||||||
|
{
|
||||||
|
return match ($permission) {
|
||||||
|
self::UsersManage => 'Benutzerverwaltung',
|
||||||
|
self::OffersDelete => 'Angebote löschen',
|
||||||
|
self::CustomersDelete => 'Kunden löschen',
|
||||||
|
self::TasksDelete => 'Aufgaben löschen',
|
||||||
|
self::PurchasesCreate => 'Online-Käufe eintragen',
|
||||||
|
self::PurchasesDelete => 'Online-Käufe löschen',
|
||||||
|
self::PurchasesComplete => 'Online-Käufe abschließen',
|
||||||
|
self::InvoicesProcess => 'Rechnungen bearbeiten und versenden',
|
||||||
|
default => $permission,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Users;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die drei Rollen bilden ab, was vorher fest auf die Namen "sascha" und "svenja"
|
||||||
|
* verdrahtet war. "inhaber" entspricht Saschas Rechten, "buero" denen von Svenja.
|
||||||
|
*/
|
||||||
|
enum Role: string
|
||||||
|
{
|
||||||
|
case Admin = 'admin';
|
||||||
|
case Inhaber = 'inhaber';
|
||||||
|
case Buero = 'buero';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Admin => 'Administration',
|
||||||
|
self::Inhaber => 'Inhaber',
|
||||||
|
self::Buero => 'Büro',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function permissions(): array
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Admin => Permission::ALL,
|
||||||
|
self::Inhaber => [
|
||||||
|
Permission::OffersDelete,
|
||||||
|
Permission::CustomersDelete,
|
||||||
|
Permission::TasksDelete,
|
||||||
|
Permission::PurchasesCreate,
|
||||||
|
Permission::PurchasesDelete,
|
||||||
|
],
|
||||||
|
self::Buero => [
|
||||||
|
Permission::PurchasesComplete,
|
||||||
|
Permission::InvoicesProcess,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function allows(string $permission): bool
|
||||||
|
{
|
||||||
|
return in_array($permission, $this->permissions(), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Users;
|
||||||
|
|
||||||
|
final readonly class User
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $id,
|
||||||
|
public string $username,
|
||||||
|
public string $displayName,
|
||||||
|
public Role $role,
|
||||||
|
public bool $isActive,
|
||||||
|
public string $createdAt,
|
||||||
|
public ?string $lastLoginAt = null,
|
||||||
|
public string $passwordHash = '',
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function fromRow(array $row): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
id: (string) $row['id'],
|
||||||
|
username: (string) $row['username'],
|
||||||
|
displayName: (string) $row['display_name'],
|
||||||
|
role: Role::from((string) $row['role']),
|
||||||
|
isActive: (bool) $row['is_active'],
|
||||||
|
createdAt: (string) $row['created_at'],
|
||||||
|
lastLoginAt: isset($row['last_login_at']) ? (string) $row['last_login_at'] : null,
|
||||||
|
passwordHash: (string) ($row['password_hash'] ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Form, die die Oberflaeche sieht. Der Hash verlaesst den Server nie. */
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'username' => $this->username,
|
||||||
|
'displayName' => $this->displayName,
|
||||||
|
'role' => $this->role->value,
|
||||||
|
'roleLabel' => $this->role->label(),
|
||||||
|
'isActive' => $this->isActive,
|
||||||
|
'createdAt' => $this->createdAt,
|
||||||
|
'lastLoginAt' => $this->lastLoginAt,
|
||||||
|
'permissions' => $this->role->permissions(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function can(string $permission): bool
|
||||||
|
{
|
||||||
|
return $this->role->allows($permission);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Users;
|
||||||
|
|
||||||
|
use Ekdos\Auth\AuthService;
|
||||||
|
use Ekdos\Auth\SessionStore;
|
||||||
|
use Ekdos\Http\Middleware\SessionMiddleware;
|
||||||
|
use Ekdos\Support\Json;
|
||||||
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benutzerverwaltung.
|
||||||
|
*
|
||||||
|
* Alle Routen liegen hinter dem Recht "users.manage" (Rolle Administration).
|
||||||
|
* Vier Regeln schuetzen davor, sich selbst auszusperren:
|
||||||
|
*
|
||||||
|
* 1. Der letzte aktive Administrator kann weder geloescht noch herabgestuft
|
||||||
|
* noch deaktiviert werden.
|
||||||
|
* 2. Niemand kann das eigene Konto loeschen.
|
||||||
|
* 3. Niemand kann die eigene Rolle aendern.
|
||||||
|
* 4. Jede Rechteaenderung meldet den betroffenen Benutzer sofort ab, damit
|
||||||
|
* eine laufende Sitzung keine Rechte behaelt, die sie nicht mehr hat.
|
||||||
|
*/
|
||||||
|
final readonly class UserController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private UserRepository $users,
|
||||||
|
private SessionStore $sessions,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
return Json::write($response, [
|
||||||
|
'users' => array_map(static fn (User $user): array => $user->toArray(), $this->users->all()),
|
||||||
|
'roles' => self::roleCatalogue(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$actor = SessionMiddleware::of($request);
|
||||||
|
$body = self::body($request);
|
||||||
|
|
||||||
|
$username = trim((string) ($body['username'] ?? ''));
|
||||||
|
$displayName = trim((string) ($body['displayName'] ?? ''));
|
||||||
|
$password = (string) ($body['password'] ?? '');
|
||||||
|
$role = Role::tryFrom((string) ($body['role'] ?? ''));
|
||||||
|
|
||||||
|
if (($problem = self::validateName($username, $displayName)) !== null) {
|
||||||
|
return Json::error($response, $problem, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($role === null) {
|
||||||
|
return Json::error($response, 'Bitte eine gültige Rolle auswählen.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($problem = AuthService::rejectWeakPassword($password)) !== null) {
|
||||||
|
return Json::error($response, $problem, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->users->usernameTaken($username)) {
|
||||||
|
return Json::error($response, 'Dieser Benutzername ist bereits vergeben.', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->users->create($username, $displayName, $role, AuthService::hash($password));
|
||||||
|
$this->users->audit($actor?->userId, $actor?->displayName ?? 'System', 'user.create', $user->id, $user->username, ['role' => $role->value]);
|
||||||
|
|
||||||
|
return Json::write($response, ['user' => $user->toArray()], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Response $response, array $args): Response
|
||||||
|
{
|
||||||
|
$actor = SessionMiddleware::of($request);
|
||||||
|
$id = (string) ($args['id'] ?? '');
|
||||||
|
$user = $this->users->find($id);
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
return Json::error($response, 'Dieser Benutzer existiert nicht.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = self::body($request);
|
||||||
|
$fields = [];
|
||||||
|
$changed = [];
|
||||||
|
|
||||||
|
if (array_key_exists('displayName', $body)) {
|
||||||
|
$displayName = trim((string) $body['displayName']);
|
||||||
|
|
||||||
|
if ($displayName === '' || mb_strlen($displayName) > 120) {
|
||||||
|
return Json::error($response, 'Der Anzeigename muss zwischen 1 und 120 Zeichen lang sein.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields['display_name'] = $displayName;
|
||||||
|
$changed[] = 'Anzeigename';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('username', $body)) {
|
||||||
|
$username = trim((string) $body['username']);
|
||||||
|
|
||||||
|
if (($problem = self::validateName($username, 'x')) !== null) {
|
||||||
|
return Json::error($response, $problem, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->users->usernameTaken($username, $user->id)) {
|
||||||
|
return Json::error($response, 'Dieser Benutzername ist bereits vergeben.', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields['username'] = $username;
|
||||||
|
$changed[] = 'Benutzername';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('role', $body)) {
|
||||||
|
$role = Role::tryFrom((string) $body['role']);
|
||||||
|
|
||||||
|
if ($role === null) {
|
||||||
|
return Json::error($response, 'Bitte eine gültige Rolle auswählen.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($actor?->userId === $user->id && $role !== $user->role) {
|
||||||
|
return Json::error($response, 'Die eigene Rolle kann nicht geändert werden.', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->role === Role::Admin && $role !== Role::Admin && $this->users->countAdmins($user->id) === 0) {
|
||||||
|
return Json::error($response, 'Es muss mindestens ein aktiver Administrator bestehen bleiben.', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields['role'] = $role->value;
|
||||||
|
$changed[] = 'Rolle';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('isActive', $body)) {
|
||||||
|
$isActive = (bool) $body['isActive'];
|
||||||
|
|
||||||
|
if (!$isActive && $user->role === Role::Admin && $this->users->countAdmins($user->id) === 0) {
|
||||||
|
return Json::error($response, 'Der letzte aktive Administrator kann nicht deaktiviert werden.', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$isActive && $actor?->userId === $user->id) {
|
||||||
|
return Json::error($response, 'Das eigene Konto kann nicht deaktiviert werden.', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields['is_active'] = $isActive;
|
||||||
|
$changed[] = $isActive ? 'aktiviert' : 'deaktiviert';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('password', $body) && (string) $body['password'] !== '') {
|
||||||
|
$password = (string) $body['password'];
|
||||||
|
|
||||||
|
if (($problem = AuthService::rejectWeakPassword($password)) !== null) {
|
||||||
|
return Json::error($response, $problem, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields['password_hash'] = AuthService::hash($password);
|
||||||
|
$changed[] = 'Passwort';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($fields === []) {
|
||||||
|
return Json::error($response, 'Es wurde nichts geändert.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$updated = $this->users->update($user->id, $fields);
|
||||||
|
$this->users->audit($actor?->userId, $actor?->displayName ?? 'System', 'user.update', $user->id, $user->username, ['changed' => $changed]);
|
||||||
|
|
||||||
|
// Rolle, Aktivierung oder Passwort geaendert: laufende Sitzungen beenden.
|
||||||
|
$forcesLogout = isset($fields['role']) || isset($fields['is_active']) || isset($fields['password_hash']);
|
||||||
|
$endedSessions = $forcesLogout ? $this->sessions->destroyAllFor($user->id) : 0;
|
||||||
|
|
||||||
|
return Json::write($response, [
|
||||||
|
'user' => $updated?->toArray(),
|
||||||
|
'endedSessions' => $endedSessions,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(Request $request, Response $response, array $args): Response
|
||||||
|
{
|
||||||
|
$actor = SessionMiddleware::of($request);
|
||||||
|
$id = (string) ($args['id'] ?? '');
|
||||||
|
$user = $this->users->find($id);
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
return Json::error($response, 'Dieser Benutzer existiert nicht.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($actor?->userId === $user->id) {
|
||||||
|
return Json::error($response, 'Das eigene Konto kann nicht gelöscht werden.', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->role === Role::Admin && $this->users->countAdmins($user->id) === 0) {
|
||||||
|
return Json::error($response, 'Der letzte aktive Administrator kann nicht gelöscht werden.', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erst abmelden, dann loeschen: sonst bliebe eine offene Sitzung ohne Konto zurueck.
|
||||||
|
$this->sessions->destroyAllFor($user->id);
|
||||||
|
$this->users->audit($actor?->userId, $actor?->displayName ?? 'System', 'user.delete', null, $user->username, ['role' => $user->role->value]);
|
||||||
|
$this->users->delete($user->id);
|
||||||
|
|
||||||
|
return Json::write($response, ['deleted' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Das Protokoll der Benutzerverwaltung, jüngste Einträge zuerst. */
|
||||||
|
public function audit(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
return Json::write($response, ['entries' => $this->users->recentAudit(50)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string, mixed>> */
|
||||||
|
private static function roleCatalogue(): array
|
||||||
|
{
|
||||||
|
return array_map(static fn (Role $role): array => [
|
||||||
|
'value' => $role->value,
|
||||||
|
'label' => $role->label(),
|
||||||
|
'permissions' => array_map(Permission::label(...), $role->permissions()),
|
||||||
|
], Role::cases());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function validateName(string $username, string $displayName): ?string
|
||||||
|
{
|
||||||
|
if (preg_match('/^[a-z0-9._-]{3,40}$/i', $username) !== 1) {
|
||||||
|
return 'Der Benutzername darf 3 bis 40 Zeichen lang sein und nur Buchstaben, Ziffern, Punkt, Bindestrich und Unterstrich enthalten.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($displayName === '' || mb_strlen($displayName) > 120) {
|
||||||
|
return 'Der Anzeigename muss zwischen 1 und 120 Zeichen lang sein.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function body(Request $request): array
|
||||||
|
{
|
||||||
|
$parsed = $request->getParsedBody();
|
||||||
|
|
||||||
|
return is_array($parsed) ? $parsed : Json::decode((string) $request->getBody());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Ekdos\Users;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
final readonly class UserRepository
|
||||||
|
{
|
||||||
|
private const string COLUMNS = 'id, username, display_name, role, is_active, created_at, updated_at, last_login_at';
|
||||||
|
|
||||||
|
public function __construct(private PDO $db) {}
|
||||||
|
|
||||||
|
/** @return list<User> */
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
$rows = $this->db->query('select ' . self::COLUMNS . ' from users order by lower(display_name)')->fetchAll();
|
||||||
|
|
||||||
|
return array_map(User::fromRow(...), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find(string $id): ?User
|
||||||
|
{
|
||||||
|
$statement = $this->db->prepare('select ' . self::COLUMNS . ' from users where id = :id');
|
||||||
|
$statement->execute(['id' => $id]);
|
||||||
|
$row = $statement->fetch();
|
||||||
|
|
||||||
|
return $row === false ? null : User::fromRow($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liefert den Hash mit. Wird ausschliesslich fuer die Anmeldung verwendet. */
|
||||||
|
public function findByUsernameWithHash(string $username): ?User
|
||||||
|
{
|
||||||
|
$statement = $this->db->prepare('select ' . self::COLUMNS . ', password_hash from users where lower(username) = lower(:username)');
|
||||||
|
$statement->execute(['username' => $username]);
|
||||||
|
$row = $statement->fetch();
|
||||||
|
|
||||||
|
return $row === false ? null : User::fromRow($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function usernameTaken(string $username, ?string $exceptId = null): bool
|
||||||
|
{
|
||||||
|
$statement = $this->db->prepare('select 1 from users where lower(username) = lower(:username) and (cast(:except as uuid) is null or id <> cast(:except as uuid))');
|
||||||
|
$statement->execute(['username' => $username, 'except' => $exceptId]);
|
||||||
|
|
||||||
|
return $statement->fetchColumn() !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zaehlt aktive Administratoren, optional ohne einen bestimmten Benutzer. */
|
||||||
|
public function countAdmins(?string $exceptId = null): int
|
||||||
|
{
|
||||||
|
$statement = $this->db->prepare("select count(*) from users where role = 'admin' and is_active and (cast(:except as uuid) is null or id <> cast(:except as uuid))");
|
||||||
|
$statement->execute(['except' => $exceptId]);
|
||||||
|
|
||||||
|
return (int) $statement->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(string $username, string $displayName, Role $role, string $passwordHash, bool $isActive = true): User
|
||||||
|
{
|
||||||
|
$statement = $this->db->prepare(
|
||||||
|
'insert into users (username, display_name, role, password_hash, is_active)
|
||||||
|
values (:username, :display_name, :role, :password_hash, :is_active)
|
||||||
|
returning ' . self::COLUMNS
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'username' => $username,
|
||||||
|
'display_name' => $displayName,
|
||||||
|
'role' => $role->value,
|
||||||
|
'password_hash' => $passwordHash,
|
||||||
|
'is_active' => $isActive ? 't' : 'f',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return User::fromRow($statement->fetch());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur uebergebene Felder werden geschrieben, alles andere bleibt unangetastet. */
|
||||||
|
public function update(string $id, array $fields): ?User
|
||||||
|
{
|
||||||
|
$allowed = ['username', 'display_name', 'role', 'password_hash', 'is_active'];
|
||||||
|
$sets = [];
|
||||||
|
$parameters = ['id' => $id];
|
||||||
|
|
||||||
|
foreach ($fields as $column => $value) {
|
||||||
|
if (!in_array($column, $allowed, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sets[] = $column . ' = :' . $column;
|
||||||
|
$parameters[$column] = is_bool($value) ? ($value ? 't' : 'f') : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sets === []) {
|
||||||
|
return $this->find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sets[] = 'updated_at = now()';
|
||||||
|
$statement = $this->db->prepare('update users set ' . implode(', ', $sets) . ' where id = :id returning ' . self::COLUMNS);
|
||||||
|
$statement->execute($parameters);
|
||||||
|
$row = $statement->fetch();
|
||||||
|
|
||||||
|
return $row === false ? null : User::fromRow($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $id): bool
|
||||||
|
{
|
||||||
|
$statement = $this->db->prepare('delete from users where id = :id');
|
||||||
|
$statement->execute(['id' => $id]);
|
||||||
|
|
||||||
|
return $statement->rowCount() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function touchLogin(string $id): void
|
||||||
|
{
|
||||||
|
$statement = $this->db->prepare('update users set last_login_at = now() where id = :id');
|
||||||
|
$statement->execute(['id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function audit(?string $actorId, string $actorName, string $action, ?string $subjectId, string $subject, array $detail = []): void
|
||||||
|
{
|
||||||
|
$statement = $this->db->prepare(
|
||||||
|
'insert into user_audit (actor_id, actor_name, action, subject_id, subject, detail)
|
||||||
|
values (cast(:actor_id as uuid), :actor_name, :action, cast(:subject_id as uuid), :subject, cast(:detail as jsonb))'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'actor_id' => $actorId,
|
||||||
|
'actor_name' => $actorName,
|
||||||
|
'action' => $action,
|
||||||
|
'subject_id' => $subjectId,
|
||||||
|
'subject' => $subject,
|
||||||
|
'detail' => json_encode($detail, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string, mixed>> */
|
||||||
|
public function recentAudit(int $limit = 50): array
|
||||||
|
{
|
||||||
|
$statement = $this->db->prepare('select actor_name, action, subject, detail, created_at from user_audit order by created_at desc limit :limit');
|
||||||
|
$statement->bindValue('limit', $limit, PDO::PARAM_INT);
|
||||||
|
$statement->execute();
|
||||||
|
|
||||||
|
return array_map(static fn (array $row): array => [
|
||||||
|
'actor' => $row['actor_name'],
|
||||||
|
'action' => $row['action'],
|
||||||
|
'subject' => $row['subject'],
|
||||||
|
'detail' => json_decode((string) $row['detail'], true) ?: [],
|
||||||
|
'createdAt' => $row['created_at'],
|
||||||
|
], $statement->fetchAll());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# EK-DOS-WEB
|
||||||
|
#
|
||||||
|
# nginx macht beides: es liefert das statisch gebaute React-Bündel aus und
|
||||||
|
# reicht ausschliesslich /api/ an php-fpm weiter. Auf dem Server läuft kein
|
||||||
|
# Node und kein Anwendungsserver -- nur nginx und php8.5-fpm.
|
||||||
|
#
|
||||||
|
# Ablage: /etc/nginx/sites-available/ekdos.conf -> sites-enabled/
|
||||||
|
# Prüfen: nginx -t && systemctl reload nginx
|
||||||
|
|
||||||
|
upstream ekdos_php {
|
||||||
|
# Unix-Socket statt TCP: kein Netzwerkstack, keine offene Portnummer.
|
||||||
|
server unix:/run/php/ekdos.sock;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name schulung.elektro-krueger.local;
|
||||||
|
|
||||||
|
# Hinter einem TLS-Terminator (Cloudflare, vorgelagertes nginx) diesen Block
|
||||||
|
# entfernen und stattdessen den 443-Block unten verwenden.
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name schulung.elektro-krueger.local;
|
||||||
|
|
||||||
|
ssl_certificate /etc/ssl/ekdos/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/ssl/ekdos/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
|
# Das gebaute Bündel aus dem Gitea-Lauf.
|
||||||
|
root /var/www/ekdos/current;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Die Anwendung ist intern; sie gehört in keinen Suchindex.
|
||||||
|
add_header X-Robots-Tag "noindex, nofollow" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "same-origin" always;
|
||||||
|
|
||||||
|
# Rechnungen und Serviceberichte sind mehrere Megabyte gross.
|
||||||
|
client_max_body_size 32m;
|
||||||
|
|
||||||
|
# ── Statische Oberfläche ──────────────────────────────────────────────
|
||||||
|
# Vite vergibt Inhaltshashes, deshalb dürfen die Bündel dauerhaft im Cache
|
||||||
|
# liegen. Nur index.html darf das nicht, sonst sieht niemand ein Update.
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-store";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Einseitenanwendung: jede unbekannte Adresse bekommt index.html.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Backend ───────────────────────────────────────────────────────────
|
||||||
|
location /api/ {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_pass ekdos_php;
|
||||||
|
|
||||||
|
# Einziger Einstiegspunkt. Es gibt keine weiteren .php-Dateien im Web.
|
||||||
|
fastcgi_param SCRIPT_FILENAME /var/www/ekdos/backend/public/index.php;
|
||||||
|
fastcgi_param SCRIPT_NAME /index.php;
|
||||||
|
fastcgi_param DOCUMENT_ROOT /var/www/ekdos/backend/public;
|
||||||
|
|
||||||
|
# Der Rechnungsabgleich und grosse PDFs brauchen Luft.
|
||||||
|
fastcgi_read_timeout 60s;
|
||||||
|
fastcgi_buffering off;
|
||||||
|
|
||||||
|
# Damit die Anmeldebremse die echte Adresse sieht.
|
||||||
|
fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for;
|
||||||
|
fastcgi_param HTTPS on;
|
||||||
|
}
|
||||||
|
|
||||||
|
# PHP wird nirgendwo sonst ausgeführt.
|
||||||
|
location ~ \.php$ {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Punktdateien bleiben unerreichbar (.env, .git und Ähnliches).
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
|
||||||
|
access_log /var/log/nginx/ekdos.access.log;
|
||||||
|
error_log /var/log/nginx/ekdos.error.log warn;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
; EK-DOS-WEB: eigener php-fpm-Pool.
|
||||||
|
;
|
||||||
|
; Ablage: /etc/php/8.5/fpm/pool.d/ekdos.conf
|
||||||
|
; Prüfen: php-fpm8.5 -t && systemctl restart php8.5-fpm
|
||||||
|
;
|
||||||
|
; Ein eigener Pool statt des mitgelieferten www-Pools: eigener Socket, eigener
|
||||||
|
; Benutzer, eigene Grenzen. Ein anderer Dienst auf demselben Server kann EK-DOS
|
||||||
|
; damit weder aushungern noch dessen Umgebung mitlesen.
|
||||||
|
|
||||||
|
[ekdos]
|
||||||
|
|
||||||
|
user = ekdos
|
||||||
|
group = ekdos
|
||||||
|
|
||||||
|
listen = /run/php/ekdos.sock
|
||||||
|
listen.owner = www-data
|
||||||
|
listen.group = www-data
|
||||||
|
listen.mode = 0660
|
||||||
|
|
||||||
|
; Zwei Personen im Büro, dazu die 20-Sekunden-Abfrage der Ticketliste.
|
||||||
|
; ondemand hält keine ungenutzten Prozesse vor.
|
||||||
|
pm = ondemand
|
||||||
|
pm.max_children = 12
|
||||||
|
pm.process_idle_timeout = 30s
|
||||||
|
pm.max_requests = 500
|
||||||
|
|
||||||
|
; Aufrufe an n8n haben ihr eigenes Zeitlimit (N8N_TIMEOUT). Dieses hier ist die
|
||||||
|
; Notbremse darüber, damit ein hängender Aufruf keinen Prozess dauerhaft bindet.
|
||||||
|
request_terminate_timeout = 60s
|
||||||
|
|
||||||
|
; Fehler gehen in den Fehlerkanal, nicht in die Antwort.
|
||||||
|
php_admin_value[display_errors] = Off
|
||||||
|
php_admin_flag[log_errors] = On
|
||||||
|
php_admin_value[error_log] = /var/log/php/ekdos-error.log
|
||||||
|
php_admin_value[memory_limit] = 256M
|
||||||
|
php_admin_value[upload_max_filesize] = 32M
|
||||||
|
php_admin_value[post_max_size] = 32M
|
||||||
|
|
||||||
|
; Das Backend liest und schreibt nur in seinem eigenen Verzeichnis.
|
||||||
|
php_admin_value[open_basedir] = /var/www/ekdos/backend:/tmp
|
||||||
|
|
||||||
|
; Es werden keine PHP-Sitzungen verwendet -- die Sitzung liegt in Redis.
|
||||||
|
php_admin_value[session.save_handler] = files
|
||||||
|
php_admin_flag[session.auto_start] = Off
|
||||||
|
|
||||||
|
; Opcache: der Quelltext ändert sich nur beim Ausrollen.
|
||||||
|
php_admin_flag[opcache.enable] = On
|
||||||
|
php_admin_value[opcache.validate_timestamps] = 0
|
||||||
|
php_admin_value[opcache.max_accelerated_files] = 10000
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# EK-DOS-WEB betreiben
|
||||||
|
|
||||||
|
Zielsystem: Debian/Ubuntu mit nginx und php8.5-fpm. Postgres und Redis laufen
|
||||||
|
bereits für n8n und werden mitbenutzt (eigene Datenbank, eigene Redis-Nummer).
|
||||||
|
|
||||||
|
## 1. Pakete
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y nginx php8.5-fpm php8.5-cli php8.5-pgsql php8.5-redis \
|
||||||
|
php8.5-mbstring php8.5-curl php8.5-intl php8.5-xml
|
||||||
|
```
|
||||||
|
|
||||||
|
`php8.5-intl` ist optional, verbessert aber die Erkennung der Kundenrücksprache
|
||||||
|
in Ticketberichten (Unicode-Normalisierung). Ohne die Erweiterung greift ein
|
||||||
|
Rückfall ohne Normalisierung.
|
||||||
|
|
||||||
|
## 2. Datenbank und Redis
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u postgres createuser ekdos --pwprompt
|
||||||
|
sudo -u postgres createdb ekdos --owner=ekdos
|
||||||
|
```
|
||||||
|
|
||||||
|
Redis braucht nichts weiter -- EK-DOS legt seine Schlüssel unter dem Präfix
|
||||||
|
`ekdos:` ab und nutzt eine eigene Datenbanknummer (`REDIS_DB`).
|
||||||
|
|
||||||
|
> Redis hält Sitzungen **und** den n8n-Cache. Ist Redis weg, ist niemand mehr
|
||||||
|
> angemeldet und jede Ansicht fragt wieder direkt bei n8n an. Die Anwendung
|
||||||
|
> läuft weiter, aber langsamer und mit erneuter Anmeldung.
|
||||||
|
|
||||||
|
## 3. Verzeichnisse
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /var/www/ekdos/{releases,backend}
|
||||||
|
sudo useradd --system --home /var/www/ekdos --shell /usr/sbin/nologin ekdos
|
||||||
|
sudo chown -R ekdos:ekdos /var/www/ekdos
|
||||||
|
sudo mkdir -p /var/log/php && sudo chown ekdos:ekdos /var/log/php
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Backend einrichten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/ekdos/backend
|
||||||
|
sudo -u ekdos cp .env.example .env
|
||||||
|
sudo -u ekdos editor .env
|
||||||
|
sudo -u ekdos composer install --no-dev --optimize-autoloader
|
||||||
|
sudo -u ekdos php bin/ekdos migrate
|
||||||
|
sudo -u ekdos php bin/ekdos user:create # erster Administrator
|
||||||
|
sudo -u ekdos php bin/ekdos check
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env` gehört dem Benutzer `ekdos` und sollte `chmod 600` sein. Sie wird beim
|
||||||
|
Ausrollen ausdrücklich nicht überschrieben.
|
||||||
|
|
||||||
|
### n8n-Adressen
|
||||||
|
|
||||||
|
| Einstellung | Wert | warum |
|
||||||
|
| ------------------- | --------------------------------- | ----------------------------------------------------------- |
|
||||||
|
| `N8N_PUBLIC_BASE` | `https://n8n.elektro-krueger.eu` | Der Dienst auf Port 5678 ist nur über den CNAME erreichbar |
|
||||||
|
| `N8N_INTERNAL_HOST` | `10.0.11.131` | Jeder weitere n8n-Dienst auf einem anderen Port |
|
||||||
|
|
||||||
|
Alle heutigen `ek-dos-web`-Webhooks laufen über den 5678-Dienst und damit über
|
||||||
|
`N8N_PUBLIC_BASE`. Kommt später ein zweiter n8n-Dienst auf eigenem Port dazu,
|
||||||
|
wird er mit `Endpoints::internal(<port>, '<pfad>')` angesprochen und geht über
|
||||||
|
die RFC1918-Adresse -- er ist nach aussen nicht veröffentlicht.
|
||||||
|
|
||||||
|
## 5. php-fpm
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/php-fpm/ekdos.pool.conf /etc/php/8.5/fpm/pool.d/ekdos.conf
|
||||||
|
sudo php-fpm8.5 -t
|
||||||
|
sudo systemctl restart php8.5-fpm
|
||||||
|
ls -l /run/php/ekdos.sock # muss www-data:www-data 0660 gehören
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Pool setzt `opcache.validate_timestamps = 0`. Neuer Quelltext wird deshalb
|
||||||
|
**erst nach einem `systemctl reload php8.5-fpm`** wirksam -- die Ausrollstrecke
|
||||||
|
macht das selbst.
|
||||||
|
|
||||||
|
## 6. nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/nginx/ekdos.conf /etc/nginx/sites-available/ekdos.conf
|
||||||
|
sudo ln -sf /etc/nginx/sites-available/ekdos.conf /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
Kurzfassung: `fastcgi_pass unix:/run/php/ekdos.sock` für `/api/`, alles andere
|
||||||
|
`try_files $uri $uri/ /index.html`. Kein Anwendungsserver, kein Reverse Proxy
|
||||||
|
auf einen Node-Prozess.
|
||||||
|
|
||||||
|
Läuft der Zugriff nur intern über HTTP, kann `SESSION_COOKIE_SECURE=false`
|
||||||
|
gesetzt und der 443-Block entfernt werden. Sobald TLS anliegt: wieder auf `true`.
|
||||||
|
|
||||||
|
## 7. Ausrollen
|
||||||
|
|
||||||
|
Die Gitea-Strecke (`.gitea/workflows/build.yml`) macht das bei jedem Push auf
|
||||||
|
`main`. Nötig sind:
|
||||||
|
|
||||||
|
- ein SSH-Schlüssel als Gitea-Secret `DEPLOY_SSH_KEY`
|
||||||
|
- ein Konto `deploy` auf dem Server, das nach `/var/www/ekdos` schreiben darf
|
||||||
|
- `sudo systemctl reload php8.5-fpm` ohne Passwort für dieses Konto
|
||||||
|
|
||||||
|
```
|
||||||
|
deploy ALL=(root) NOPASSWD: /bin/systemctl reload php8.5-fpm
|
||||||
|
```
|
||||||
|
|
||||||
|
Jede Veröffentlichung landet unter `releases/<sha>`; sichtbar wird sie erst
|
||||||
|
durch den Symlink-Tausch von `current`. Die letzten fünf bleiben liegen, ein
|
||||||
|
Rückschritt ist damit ein `ln -sfn`.
|
||||||
|
|
||||||
|
### Von Hand
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && pnpm install --frozen-lockfile && pnpm build
|
||||||
|
rsync -az --delete frontend/dist/ server:/var/www/ekdos/releases/manuell/
|
||||||
|
ssh server 'cd /var/www/ekdos && ln -sfn releases/manuell current && sudo systemctl reload php8.5-fpm'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prüfen, wenn etwas klemmt
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php backend/bin/ekdos check # Postgres, Redis, n8n
|
||||||
|
curl -sS https://<host>/api/health # ohne Anmeldung erreichbar
|
||||||
|
tail -f /var/log/php/ekdos-error.log # Ausnahmen aus dem Backend
|
||||||
|
tail -f /var/log/nginx/ekdos.error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
| Symptom | meistens |
|
||||||
|
| ------------------------------------------- | --------------------------------------------------------------------- |
|
||||||
|
| 502 auf `/api/` | Socket fehlt oder falsche Rechte; `systemctl status php8.5-fpm` |
|
||||||
|
| Anmeldung wirkt, danach sofort abgemeldet | Redis nicht erreichbar, oder `SESSION_COOKIE_SECURE=true` ohne TLS |
|
||||||
|
| Listen bleiben leer, Meldung „nicht erreichbar" | `N8N_PUBLIC_BASE` falsch, oder der Webhook ist in n8n nicht aktiv |
|
||||||
|
| Angezeigte Daten sind veraltet | Rückfall-Cache greift, weil n8n gerade nicht antwortet |
|
||||||
|
| 503 „noch nicht eingerichtet" | `N8N_INVOICE_SYNC_SECRET` bzw. `N8N_CUSTOMER_KEY` fehlt in `.env` |
|
||||||
|
| Quelltextänderung wirkt nicht | Opcache; `systemctl reload php8.5-fpm` |
|
||||||
|
|
||||||
|
## Alle Benutzer aussperren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redis-cli --scan --pattern 'ekdos:sess:*' | xargs -r redis-cli del
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach muss sich jeder neu anmelden. Einzelne Konten trifft es gezielt über die
|
||||||
|
Benutzerverwaltung (Deaktivieren beendet die offenen Sitzungen sofort).
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# n8n-Schnittstelle
|
||||||
|
|
||||||
|
Alle Fachdaten von EK-DOS-WEB liegen in n8n. Das Backend hält davon nichts vor
|
||||||
|
ausser einem kurzlebigen Cache.
|
||||||
|
|
||||||
|
## Adressen
|
||||||
|
|
||||||
|
Der n8n-Hauptdienst auf **Port 5678** ist ausschliesslich über
|
||||||
|
`https://n8n.elektro-krueger.eu` erreichbar. Jeder weitere n8n-Dienst auf einem
|
||||||
|
anderen Port ist nicht veröffentlicht und wird über **10.0.11.131** angesprochen.
|
||||||
|
|
||||||
|
In `backend/src/N8n/Endpoints.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$url->webhook('/offene-tickets'); // https://n8n.elektro-krueger.eu/webhook/ek-dos-web/offene-tickets
|
||||||
|
$url->internal(5679, '/webhook/irgendwas'); // http://10.0.11.131:5679/webhook/irgendwas
|
||||||
|
```
|
||||||
|
|
||||||
|
Neue Adressen gehören in diese Klasse, nicht verstreut in die Controller.
|
||||||
|
|
||||||
|
## Endpunkte
|
||||||
|
|
||||||
|
Alle unter `/webhook/ek-dos-web`. Die Spalte *Cache* nennt Gruppe sowie frische
|
||||||
|
und Rückfall-Haltbarkeit in Sekunden.
|
||||||
|
|
||||||
|
| Pfad | Verwendung | Cache |
|
||||||
|
| ----------------------------------- | -------------------------------- | ------------------------ |
|
||||||
|
| `/offene-tickets` | Ticketliste | `tickets` 15 / 600 |
|
||||||
|
| `/offene-tickets/kundenruecksprache`| Ergebnis speichern | schreibend |
|
||||||
|
| `/tickets/digitale-akte` | PDF | – |
|
||||||
|
| `/tickets/servicebericht` | PDF | – |
|
||||||
|
| `/angebote` | Angebotsliste | `offers` 30 / 600 |
|
||||||
|
| `/angebote/versendet` `/beauftragt` `/zuruecksetzen` `/loeschen` | Aktionen | schreibend |
|
||||||
|
| `/kundenstamm` | Kundenliste, Anlage, Änderung | `customers` 120 / 1800 |
|
||||||
|
| `/kundenstamm/digitale-akte` | PDF | – |
|
||||||
|
| `/rechnungen` | Rechnungen eines Kunden | `invoices` 30 / 600 |
|
||||||
|
| `/rechnungen/alle` | Gesamtübersicht (geschützt) | `invoices` 30 / 600 |
|
||||||
|
| `/rechnungen/zuordnen` `/sync` | Zuordnung, Abgleich (geschützt) | schreibend |
|
||||||
|
| `/rechnungen/pdf` | PDF | – |
|
||||||
|
| `/rechnungen-anfertigen` | Aufgabenliste | `invoices-create` 30/600 |
|
||||||
|
| `/rechnungen-anfertigen/erledigt` | Aufgabe abschließen | schreibend |
|
||||||
|
| `/rechnungen-pruefen` | Prüfliste | `invoices-review` 30/600 |
|
||||||
|
| `/rechnungen-versenden` | Versandliste und -bestätigung | `invoices-send` 30/600 |
|
||||||
|
| `/digitale-akte` | PDF als base64 im JSON | – |
|
||||||
|
| `/interne-aufgaben` (+ `/erledigt` `/bearbeiten` `/loeschen`) | Aufgaben | `tasks` 20 / 600 |
|
||||||
|
| `/online-kaeufe` (+ `/erledigt` `/loeschen`) | Online-Käufe | `purchases` 30 / 600 |
|
||||||
|
| `/stundennachweise` | Monatsreport | `hours` 300 / 3600 |
|
||||||
|
|
||||||
|
## Geteilte Geheimnisse
|
||||||
|
|
||||||
|
| Kopfzeile | aus | wofür |
|
||||||
|
| -------------------------- | ------------------------- | ------------------------------------------------------------ |
|
||||||
|
| `x-ekdos-invoice-sync` | `N8N_INVOICE_SYNC_SECRET` | Rechnungsübersicht, Zuordnung, Abgleich, Kundenanlage, Angebot zurücksetzen |
|
||||||
|
| `X-EK-DOS-Customer-Key` | `N8N_CUSTOMER_KEY` | Lesen und Ändern im Kundenstamm |
|
||||||
|
| `x-ekdos-webhook-secret` | `N8N_REFRESH_SECRET` | n8n meldet EK-DOS eine Ticketänderung |
|
||||||
|
|
||||||
|
Fehlt eines davon, antworten die betroffenen Routen mit **503** und einer
|
||||||
|
Meldung, die genau das sagt -- sie schlagen nicht stumm fehl.
|
||||||
|
|
||||||
|
> Der Kundenschlüssel stand in der Next.js-Fassung als Literal im Quelltext
|
||||||
|
> (`app/api/customers/route.ts`). Er liegt jetzt in `.env`. **Er sollte in n8n
|
||||||
|
> gewechselt werden**, weil der alte Wert in der Versionsgeschichte steht.
|
||||||
|
|
||||||
|
## n8n meldet eine Ticketänderung
|
||||||
|
|
||||||
|
Der Workflow *Datenübergabe an EK-DOS-WEB* darf weiter auf seinen Endpunkt
|
||||||
|
zeigen, nur ohne Port:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://schulung.elektro-krueger.local/api/tickets/refresh
|
||||||
|
x-ekdos-webhook-secret: <N8N_REFRESH_SECRET>
|
||||||
|
```
|
||||||
|
|
||||||
|
Anders als früher ist das kein Platzhalter mehr. In der Next.js-Fassung
|
||||||
|
antwortete die Route nur `{ok:true}` und tat sonst nichts; die Oberfläche sah
|
||||||
|
neue Tickets rein zufällig beim nächsten 20-Sekunden-Takt. Jetzt **verwirft der
|
||||||
|
Aufruf den Ticket-Cache**, sodass die nächste Abfrage die neuen Daten garantiert
|
||||||
|
sieht.
|
||||||
|
|
||||||
|
Ohne gesetztes `N8N_REFRESH_SECRET` verlangt die Route eine angemeldete Sitzung
|
||||||
|
und weist den Aufruf aus n8n mit 401 ab.
|
||||||
|
|
||||||
|
## Feldabbildung Tickets
|
||||||
|
|
||||||
|
Die einzige Stelle, an der das Backend n8n-Felder umbenennt
|
||||||
|
(`backend/src/Relay/TicketsController.php`):
|
||||||
|
|
||||||
|
| n8n | Oberfläche |
|
||||||
|
| -------------------------------- | ------------------------------ |
|
||||||
|
| `ticketnummer` / `id` | `id` |
|
||||||
|
| `kunde` / `customer` | `customer` |
|
||||||
|
| `liegenschaft` / `property` | `property` |
|
||||||
|
| `letzter_servicebericht_status` | `reportStatus` |
|
||||||
|
| `fortsetzung` | `continuation` |
|
||||||
|
| `entscheidung_code` | `decisionCode` |
|
||||||
|
| `kundenruecksprache_ergebnis` | `consultationResult` |
|
||||||
|
| `kundenruecksprache_am` / `_von` | `consultationUpdatedAt` / `By` |
|
||||||
|
|
||||||
|
`requiresCustomerConsultation` wird abgeleitet: entweder
|
||||||
|
`entscheidung_code === "TEILERLEDIGUNG_RUECKSPRACHE_KUNDE"`, oder der
|
||||||
|
Fortsetzungstext enthält „nimmt mit Kunden für das weitere Vorgehen Kontakt auf".
|
||||||
|
Zeilen ohne Ticketnummer werden verworfen.
|
||||||
|
|
||||||
|
## Bekannte Kopplung
|
||||||
|
|
||||||
|
Interne Aufgaben kennen in n8n genau zwei Empfänger: `sascha` und `svenja`. Ein
|
||||||
|
drittes EK-DOS-Konto kann sich anmelden und alle Ansichten nutzen, aber noch
|
||||||
|
keine Aufgaben zugewiesen bekommen. Dafür müsste der n8n-Workflow *Interne
|
||||||
|
Aufgaben* einen freien Empfängerschlüssel annehmen.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="EK-DOS WEB – Büroanwendung der Elektro Krüger GmbH." />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<link rel="icon" href="/ek-dos-favicon.svg" />
|
||||||
|
<link rel="shortcut icon" href="/ek-dos-favicon.svg" />
|
||||||
|
<title>EK-DOS</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "ek-dos-web-frontend",
|
||||||
|
"displayName": "EK-DOS WEB",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@10.16.1",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.13.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint src --ext .ts,.tsx",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "19.2.6",
|
||||||
|
"react-dom": "19.2.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.2.14",
|
||||||
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
|
"eslint": "9.39.4",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"vite": "8.0.13"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="64" height="64" rx="13" fill="#12344D"/>
|
||||||
|
<path d="M11 13h25l-3.6 8H20.7l-1.4 5h11.3l-3.4 8H17l-1.6 6h12.8l-3.6 9H8z" fill="#fff"/>
|
||||||
|
<path d="M39 13h13L42 31l12 20H42l-8.4-14-5.5 14H16l12.1-30H39z" fill="#93EC67"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 323 B |
|
After Width: | Height: | Size: 6.9 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="64" height="64" rx="13" fill="#12344D"/>
|
||||||
|
<path d="M11 13h25l-3.6 8H20.7l-1.4 5h11.3l-3.4 8H17l-1.6 6h12.8l-3.6 9H8z" fill="#fff"/>
|
||||||
|
<path d="M39 13h13L42 31l12 20H42l-8.4-14-5.5 14H16l12.1-30H39z" fill="#93EC67"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 323 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 392 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 571 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 386 B |
@@ -0,0 +1,28 @@
|
|||||||
|
import { AuthProvider, useAuth } from '@/auth/AuthProvider';
|
||||||
|
import { LoadingScreen, LoginScreen } from '@/auth/LoginScreen';
|
||||||
|
import Dashboard from '@/views/Dashboard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Auth-Gate.
|
||||||
|
*
|
||||||
|
* Drei Zustände, mehr gibt es nicht: es ist noch nicht entschieden, niemand ist
|
||||||
|
* angemeldet, oder jemand ist angemeldet. Erst im dritten Fall wird überhaupt
|
||||||
|
* eine Fachansicht gerendert -- so kann keine Ansicht in einen Zustand ohne
|
||||||
|
* Benutzer geraten.
|
||||||
|
*/
|
||||||
|
function Gate() {
|
||||||
|
const { user, ready } = useAuth();
|
||||||
|
|
||||||
|
if (!ready) return <LoadingScreen />;
|
||||||
|
if (!user) return <LoginScreen />;
|
||||||
|
|
||||||
|
return <Dashboard />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Gate />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { config } from '@/config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der einzige Weg nach draussen.
|
||||||
|
*
|
||||||
|
* `credentials: 'include'` sorgt dafuer, dass das Sitzungscookie mitgeht. Es gibt
|
||||||
|
* bewusst keinen Authorization-Header und kein Token im Browser: die Sitzung
|
||||||
|
* liegt vollstaendig im Backend, der Browser haelt nur die undurchsichtige Kennung.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status: number,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Sitzung ist abgelaufen oder wurde beendet. */
|
||||||
|
get isUnauthorized(): boolean {
|
||||||
|
return this.status === 401;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Angemeldet, aber ohne das noetige Recht. */
|
||||||
|
get isForbidden(): boolean {
|
||||||
|
return this.status === 403;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestOptions = {
|
||||||
|
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||||
|
body?: unknown;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Wird ausgeloest, sobald das Backend 401 meldet, damit die Anmeldemaske erscheint. */
|
||||||
|
type UnauthorizedListener = () => void;
|
||||||
|
const unauthorizedListeners = new Set<UnauthorizedListener>();
|
||||||
|
|
||||||
|
export function onUnauthorized(listener: UnauthorizedListener): () => void {
|
||||||
|
unauthorizedListeners.add(listener);
|
||||||
|
return () => unauthorizedListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
const { method = 'GET', body, signal } = options;
|
||||||
|
|
||||||
|
const response = await fetch(`${config.api.baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store',
|
||||||
|
signal,
|
||||||
|
headers: body === undefined ? { Accept: 'application/json' } : { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
}).catch(() => {
|
||||||
|
throw new ApiError('Das Backend ist nicht erreichbar.', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 204) return undefined as T;
|
||||||
|
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// 401 auf der Auth-Sonde ist eine Antwort, kein Fehler: dort steht,
|
||||||
|
// dass niemand angemeldet ist. Ueberall sonst heisst es, dass die
|
||||||
|
// Sitzung gerade abgelaufen ist.
|
||||||
|
if (response.status === 401 && path !== '/api/auth/me') {
|
||||||
|
unauthorizedListeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = typeof payload?.error === 'string' ? payload.error : `Das Backend antwortet mit HTTP ${response.status}.`;
|
||||||
|
throw new ApiError(message, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T,>(path: string, signal?: AbortSignal) => request<T>(path, { signal }),
|
||||||
|
post: <T,>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body }),
|
||||||
|
put: <T,>(path: string, body?: unknown) => request<T>(path, { method: 'PUT', body }),
|
||||||
|
patch: <T,>(path: string, body?: unknown) => request<T>(path, { method: 'PATCH', body }),
|
||||||
|
delete: <T,>(path: string, body?: unknown) => request<T>(path, { method: 'DELETE', body }),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Adresse fuer eine PDF-Anzeige im Browser. Die Sitzung traegt ueber das Cookie. */
|
||||||
|
export function fileUrl(path: string): string {
|
||||||
|
return `${config.api.baseUrl}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übergangsbrücke für Dashboard.tsx.
|
||||||
|
*
|
||||||
|
* Die große Dashboard-Komponente ruft noch direkt `fetch("/api/…")` auf, geht
|
||||||
|
* also nicht durch `api` oben. Ohne diesen Aufsatz bliebe eine abgelaufene
|
||||||
|
* Sitzung dort unbemerkt und die Ansichten zeigten Fehlermeldungen, statt zur
|
||||||
|
* Anmeldung zurückzufallen.
|
||||||
|
*
|
||||||
|
* Diese Funktion darf ersatzlos entfallen, sobald alle Ansichten den Client
|
||||||
|
* oben verwenden.
|
||||||
|
*/
|
||||||
|
let intercepted = false;
|
||||||
|
|
||||||
|
export function watchLegacyFetchForExpiry(): void {
|
||||||
|
if (intercepted) return;
|
||||||
|
intercepted = true;
|
||||||
|
|
||||||
|
const original = window.fetch.bind(window);
|
||||||
|
|
||||||
|
window.fetch = async (input, init) => {
|
||||||
|
const response = await original(input, init);
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
|
||||||
|
|
||||||
|
// Nur eigene API-Aufrufe, und die Auth-Sonde bleibt außen vor: dort ist
|
||||||
|
// 401 die reguläre Antwort für "niemand angemeldet".
|
||||||
|
if (response.status === 401 && url.includes('/api/') && !url.includes('/api/auth/me')) {
|
||||||
|
unauthorizedListeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { api, onUnauthorized, watchLegacyFetchForExpiry } from '@/api/client';
|
||||||
|
import { config } from '@/config';
|
||||||
|
import type { CurrentUser, PermissionName } from '@/auth/types';
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
type AuthState = {
|
||||||
|
user: CurrentUser | null;
|
||||||
|
/** Solange false, ist noch nicht entschieden, ob jemand angemeldet ist. */
|
||||||
|
ready: boolean;
|
||||||
|
signIn: (username: string, password: string) => Promise<void>;
|
||||||
|
signOut: () => Promise<void>;
|
||||||
|
can: (permission: PermissionName) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthState | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Auth-Gate der Oberflaeche.
|
||||||
|
*
|
||||||
|
* Es wird nichts geraten: beim Start fragt die Anwendung /api/auth/me. 200
|
||||||
|
* bedeutet angemeldet, 401 bedeutet Anmeldemaske. Danach halten zwei Uhren die
|
||||||
|
* Sitzung im Griff -- eine verlaengert sie, solange jemand arbeitet, die andere
|
||||||
|
* meldet nach einer Stunde ohne Eingabe ab.
|
||||||
|
*/
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<CurrentUser | null>(null);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const lastActivity = useRef(Date.now());
|
||||||
|
|
||||||
|
const probe = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get<{ user: CurrentUser | null }>('/api/auth/me');
|
||||||
|
setUser(data.user);
|
||||||
|
} catch {
|
||||||
|
// 401 und "Backend nicht erreichbar" fuehren beide zur Anmeldemaske.
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setReady(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void probe();
|
||||||
|
}, [probe]);
|
||||||
|
|
||||||
|
// Meldet das Backend irgendwo 401, faellt die Oberflaeche sofort zurueck.
|
||||||
|
// Der Aufsatz erfasst dabei auch die direkten fetch-Aufrufe in Dashboard.tsx.
|
||||||
|
useEffect(() => {
|
||||||
|
watchLegacyFetchForExpiry();
|
||||||
|
|
||||||
|
return onUnauthorized(() => setUser(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Jede Eingabe zaehlt als Aktivitaet.
|
||||||
|
useEffect(() => {
|
||||||
|
const note = () => {
|
||||||
|
lastActivity.current = Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = ['pointerdown', 'keydown', 'wheel', 'touchstart'] as const;
|
||||||
|
events.forEach((event) => window.addEventListener(event, note, { passive: true }));
|
||||||
|
|
||||||
|
return () => events.forEach((event) => window.removeEventListener(event, note));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sitzung verlaengern, solange gearbeitet wird; sonst abmelden.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
if (Date.now() - lastActivity.current > config.session.idleMs) {
|
||||||
|
void api.post('/api/auth/logout').catch(() => undefined);
|
||||||
|
setUser(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void api
|
||||||
|
.post<{ user: CurrentUser | null }>('/api/auth/refresh')
|
||||||
|
.then((data) => setUser(data.user))
|
||||||
|
.catch(() => setUser(null));
|
||||||
|
}, config.session.refreshMs);
|
||||||
|
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const signIn = useCallback(async (username: string, password: string) => {
|
||||||
|
const data = await api.post<{ user: CurrentUser }>('/api/auth/login', { username, password });
|
||||||
|
lastActivity.current = Date.now();
|
||||||
|
setUser(data.user);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const signOut = useCallback(async () => {
|
||||||
|
await api.post('/api/auth/logout').catch(() => undefined);
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<AuthState>(
|
||||||
|
() => ({
|
||||||
|
user,
|
||||||
|
ready,
|
||||||
|
signIn,
|
||||||
|
signOut,
|
||||||
|
can: (permission) => user?.permissions.includes(permission) ?? false,
|
||||||
|
}),
|
||||||
|
[user, ready, signIn, signOut],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthState {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
|
||||||
|
if (context === null) throw new Error('useAuth benötigt einen AuthProvider.');
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useAuth } from '@/auth/AuthProvider';
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anmeldemaske.
|
||||||
|
*
|
||||||
|
* Die beiden fest verdrahteten Schaltflaechen "Svenja" und "Sascha" sind einem
|
||||||
|
* Benutzernamensfeld gewichen -- Konten kommen jetzt aus der eigenen Datenbank
|
||||||
|
* und werden in der Benutzerverwaltung gepflegt. Das uebrige Gestaltungsbild
|
||||||
|
* bleibt unveraendert.
|
||||||
|
*/
|
||||||
|
export function LoginScreen() {
|
||||||
|
const { signIn } = useAuth();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await signIn(username.trim(), password);
|
||||||
|
} catch (problem) {
|
||||||
|
setError(problem instanceof Error ? problem.message : 'Die Anmeldung ist fehlgeschlagen.');
|
||||||
|
setPassword('');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="login-page">
|
||||||
|
<div className="login-panel">
|
||||||
|
<div className="login-brand" aria-label="Elektro Krüger – Wir setzen Sie unter Strom">
|
||||||
|
<strong>EK</strong>
|
||||||
|
<span>
|
||||||
|
<b>Elektro Krüger</b>
|
||||||
|
<small>Wir setzen Sie unter Strom</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="login-kicker">EK-DOS</p>
|
||||||
|
<h1>Willkommen</h1>
|
||||||
|
<p className="login-copy">Bitte melde dich an, um die digitale Schaltzentrale zu öffnen.</p>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<label>
|
||||||
|
Benutzername
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Passwort
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="login-error">{error}</p>}
|
||||||
|
<button type="submit" disabled={busy}>
|
||||||
|
{busy ? 'Anmeldung läuft …' : 'Anmelden →'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div className="login-visual">
|
||||||
|
<img src="/hero-ek-dos.png" alt="" />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingScreen() {
|
||||||
|
return (
|
||||||
|
<main className="login-page">
|
||||||
|
<div className="login-loading">EK-DOS wird geladen …</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export type Role = 'admin' | 'inhaber' | 'buero';
|
||||||
|
|
||||||
|
/** Genau die Rechte, die das Backend in Permission.php fuehrt. */
|
||||||
|
export const Permission = {
|
||||||
|
UsersManage: 'users.manage',
|
||||||
|
OffersDelete: 'offers.delete',
|
||||||
|
CustomersDelete: 'customers.delete',
|
||||||
|
TasksDelete: 'tasks.delete',
|
||||||
|
PurchasesCreate: 'purchases.create',
|
||||||
|
PurchasesDelete: 'purchases.delete',
|
||||||
|
PurchasesComplete: 'purchases.complete',
|
||||||
|
InvoicesProcess: 'invoices.process',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PermissionName = (typeof Permission)[keyof typeof Permission];
|
||||||
|
|
||||||
|
export type CurrentUser = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
/** Anzeigename, z. B. "Svenja". Die Oberflaeche zeigt ausschliesslich diesen. */
|
||||||
|
name: string;
|
||||||
|
role: Role;
|
||||||
|
roleLabel: string;
|
||||||
|
permissions: PermissionName[];
|
||||||
|
canManageUsers: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ManagedUser = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
role: Role;
|
||||||
|
roleLabel: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
permissions: PermissionName[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoleOption = {
|
||||||
|
value: Role;
|
||||||
|
label: string;
|
||||||
|
permissions: string[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Baukonfiguration.
|
||||||
|
*
|
||||||
|
* Im Betrieb liefert dasselbe nginx die Oberflaeche und /api aus, deshalb ist
|
||||||
|
* die Basis leer und alle Aufrufe sind gleichursprünglich (same-origin). Damit
|
||||||
|
* traegt das Sitzungscookie ohne CORS, und es gibt keine Herkunftsliste zu pflegen.
|
||||||
|
*
|
||||||
|
* Sollte die Oberflaeche einmal auf einem eigenen Host liegen, wird hier die
|
||||||
|
* absolute Backend-Adresse eingetragen; dann braucht das Backend zusaetzlich
|
||||||
|
* eine CORS-Freigabe mit Anmeldedaten und beide Hosts muessen dieselbe Site sein.
|
||||||
|
*/
|
||||||
|
type AppConfig = {
|
||||||
|
readonly api: { readonly baseUrl: string };
|
||||||
|
readonly session: {
|
||||||
|
/** Sitzung im Hintergrund am Leben halten. */
|
||||||
|
readonly refreshMs: number;
|
||||||
|
/** Nach dieser Zeit ohne Eingabe wird abgemeldet. */
|
||||||
|
readonly idleMs: number;
|
||||||
|
};
|
||||||
|
readonly polling: {
|
||||||
|
readonly ticketsMs: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const shared = {
|
||||||
|
session: { refreshMs: 5 * 60 * 1000, idleMs: 60 * 60 * 1000 },
|
||||||
|
polling: { ticketsMs: 20_000 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const development: AppConfig = { api: { baseUrl: '' }, ...shared };
|
||||||
|
const production: AppConfig = { api: { baseUrl: '' }, ...shared };
|
||||||
|
|
||||||
|
export const config: AppConfig = import.meta.env.PROD ? production : development;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/** Datums- und Zeitformate, wie sie im Büro gelesen werden. */
|
||||||
|
|
||||||
|
export function formatDateTime(value?: string): string {
|
||||||
|
if (!value) return '–';
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
|
||||||
|
return Number.isNaN(date.getTime())
|
||||||
|
? value
|
||||||
|
: new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(value?: string): string {
|
||||||
|
if (!value) return '–';
|
||||||
|
|
||||||
|
// Mittag statt Mitternacht: so kippt ein reines Datum nicht über die Zeitzone.
|
||||||
|
const date = new Date(`${value.slice(0, 10)}T12:00:00`);
|
||||||
|
|
||||||
|
return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(date);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Bei jedem EK-DOS-WEB-Update hier die sichtbare Versionsnummer erhöhen.
|
||||||
|
export const EKDOS_VERSION = "V3.0";
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { App } from '@/App';
|
||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
|
import '@/styles/globals.css';
|
||||||
|
import '@/styles/dashboard-fixes.css';
|
||||||
|
import '@/styles/users.css';
|
||||||
|
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
|
||||||
|
if (container === null) throw new Error('Das Wurzelelement #root fehlt in index.html.');
|
||||||
|
|
||||||
|
createRoot(container).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/* Dashboard-Korrekturen nach Designabnahme */
|
||||||
|
.app-nav button:nth-of-type(even)>span:first-child{background:#173a57;color:#62b7ff}
|
||||||
|
.helga-card{grid-column:3;grid-row:1}
|
||||||
|
.dashboard-inbox{grid-column:2}
|
||||||
|
.helga-photo{display:block;overflow:hidden;background:#102e49}
|
||||||
|
.helga-photo img{display:block;width:100%;height:100%;object-fit:cover;object-position:center 24%}
|
||||||
|
.helga-card{grid-template-columns:309px minmax(0,1fr);min-height:369px}.helga-photo{width:309px;height:369px}
|
||||||
|
.app-nav button .nav-chevron{margin-left:auto}
|
||||||
|
.app-nav button .nav-chevron+.nav-badge{margin-left:8px}
|
||||||
|
.dashboard-offers{display:grid;grid-column:3;grid-row:2;align-content:start}.dashboard-offers>button{display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:10px;border:0;border-top:1px solid #233748;padding:15px 0;background:transparent;color:inherit;text-align:left;cursor:pointer}.dashboard-offers b{display:block;font-size:13px}.dashboard-offers small{display:block;margin-top:3px;color:#849aaa;font-size:11px}.dashboard-offers em{color:#ffa85b;font-size:10px;font-style:normal;font-weight:800}.dashboard-offers>button:hover b{color:#65bbff}
|
||||||
|
.customer-row-actions{display:flex;justify-content:flex-end;gap:8px;white-space:nowrap}
|
||||||
|
.customers-table .customer-row-actions button{border:1px solid #4a7690;border-radius:7px;padding:7px 9px;background:#183645;color:#d8edf5;font-size:10px;font-weight:900;cursor:pointer}
|
||||||
|
.customers-table .customer-row-actions button:first-child{border-color:#79c96c;background:#19412d;color:#d9ffc7}
|
||||||
|
.customers-table .customer-row-actions button:hover{background:#245168;color:#fff}.customers-table .customer-row-actions button:first-child:hover{background:#256b36}
|
||||||
|
.invoice-modal{width:min(760px,100%)}.invoice-modal-hint{margin:-10px 0 0;color:#a9c1ce;font-size:13px}.invoice-property-list{display:grid;gap:10px}.invoice-property-card{overflow:hidden;border:1px solid #3c6172;border-radius:10px;background:#102633}.invoice-property-card summary{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:12px;padding:15px 16px;cursor:pointer;list-style:none}.invoice-property-card summary::-webkit-details-marker{display:none}.invoice-property-card summary:hover{background:#173442}.invoice-property-card summary span:nth-child(2){display:grid;gap:3px}.invoice-property-card summary b{color:#f0f8fb;font-size:16px}.invoice-property-card summary small{color:#9be97c;font-size:11px;font-weight:800;text-transform:uppercase}.invoice-property-icon{display:grid;place-items:center;width:33px;height:33px;border:1px solid #75d862;border-radius:8px;background:#19412d;color:#a9f78a;font-size:19px}.invoice-property-card summary i{color:#9be97c;font-size:19px;font-style:normal}.invoice-property-card[open] summary{border-bottom:1px solid #3c6172}.invoice-items{display:grid;gap:8px;padding:12px;background:#0c1d27}.invoice-item{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px;border:1px solid #314e5d;border-radius:8px;background:#0d202a}.invoice-item span{display:grid;gap:3px}.invoice-item b{color:#e9f4f8;font-size:13px}.invoice-item small{color:#a9c1ce;font-size:11px}.invoice-item a,.invoice-modal-actions button{display:inline-flex;justify-content:center;align-items:center;border:1px solid #9be97c;border-radius:8px;padding:9px 12px;background:#184b2a;color:#d9ffc7;font-size:12px;font-weight:900;text-decoration:none;cursor:pointer}.invoice-item a:hover{background:#256b36;color:#fff}.invoice-modal-actions{display:flex;justify-content:flex-end;padding-top:2px}.invoice-modal-actions button{border-color:#608091;background:transparent;color:#dcecf3}@media(max-width:700px){.invoice-item{align-items:stretch;flex-direction:column}.invoice-item a{width:100%}}
|
||||||
|
@media(max-width:1270px){.helga-card{grid-column:2;grid-row:auto;grid-template-columns:180px minmax(0,1fr);min-height:216px}.helga-photo{width:180px;height:216px}.dashboard-offers{grid-column:1;grid-row:auto}}
|
||||||
|
@media(max-width:880px){.helga-card,.dashboard-inbox,.dashboard-offers{grid-column:auto;grid-row:auto}}
|
||||||
|
.activity-list .activity-status.orange{color:#ffa254}
|
||||||
|
.activity-list .activity-status.green{color:#62d589;text-shadow:0 0 8px #62d58980}
|
||||||
|
.activity-list .activity-status.blue{color:#4db5ff}
|
||||||
|
.invoices-overview-page{display:grid;gap:18px;padding:30px;min-width:0}.invoices-overview-page>.offers-heading>button{border:1px solid #4a7690;border-radius:8px;padding:10px 14px;background:#183645;color:#d8edf5;font-weight:900;cursor:pointer}.invoices-overview-page>.offers-heading>button:hover{background:#245168;color:#fff}.invoice-overview-tabs{display:flex;gap:9px;border-bottom:1px solid #294353;padding-bottom:12px}.invoice-overview-tabs button{border:1px solid #426778;border-radius:8px;padding:9px 14px;background:#102633;color:#abc2ce;font-size:12px;font-weight:900;cursor:pointer}.invoice-overview-tabs button.active,.invoice-overview-tabs button:hover{border-color:#67b9ff;background:#173a57;color:#e8f6ff}.invoice-overview-list{display:grid;gap:9px}.invoice-overview-item{display:grid;grid-template-columns:.8fr 1.4fr 1.4fr .8fr auto;align-items:center;gap:16px;padding:16px;border:1px solid #315667;border-radius:10px;background:#112b39}.invoice-overview-item>div{display:grid;gap:4px;min-width:0}.invoice-overview-item small{color:#8fb5c7;font-size:10px;font-weight:900;letter-spacing:.08em}.invoice-overview-item b{overflow:hidden;color:#edf8fb;font-size:13px;text-overflow:ellipsis;white-space:nowrap}.invoice-overview-item span{overflow:hidden;color:#b8cbd4;font-size:12px;text-overflow:ellipsis;white-space:nowrap}.invoice-overview-item a{display:inline-flex;align-items:center;justify-content:center;border:1px solid #8fd979;border-radius:8px;padding:9px 12px;background:#19412d;color:#d9ffc7;font-size:12px;font-weight:900;text-decoration:none;white-space:nowrap}.invoice-overview-item a:hover{background:#256b36;color:#fff}@media(max-width:900px){.invoices-overview-page{padding:20px}.invoice-overview-item{grid-template-columns:1fr 1fr}.invoice-overview-item a{grid-column:1/-1;width:100%}}@media(max-width:520px){.invoice-overview-item{grid-template-columns:1fr}.invoice-overview-tabs{flex-direction:column}.invoice-overview-tabs button{width:100%}}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
/* Benutzerverwaltung. Setzt auf den vorhandenen Listenstil der Ticketansicht auf
|
||||||
|
und ergaenzt nur, was es dort noch nicht gibt: Formular, Rollenchips, Protokoll. */
|
||||||
|
|
||||||
|
.users-notice {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border: 1px solid #9fd6a4;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f1fbef;
|
||||||
|
color: #216c2c;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-form {
|
||||||
|
margin: 0 0 22px;
|
||||||
|
padding: 22px 24px;
|
||||||
|
border: 1px solid #c7dce8;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 10px 24px #294f670e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-form h3 {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 14px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
color: #3d596d;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-form input,
|
||||||
|
.users-form select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #b6cfdd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
background: #fbfdff;
|
||||||
|
color: #17384e;
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-form input:focus,
|
||||||
|
.users-form select:focus {
|
||||||
|
border-color: #5fb85f;
|
||||||
|
box-shadow: 0 0 0 3px #9be97c33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-form input:disabled,
|
||||||
|
.users-form select:disabled {
|
||||||
|
background: #eef3f6;
|
||||||
|
color: #6b8091;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-form label small {
|
||||||
|
color: #6a8290;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-form > button,
|
||||||
|
.users-form-actions button {
|
||||||
|
margin-top: 18px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
background: #2f8744;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-form > button:disabled,
|
||||||
|
.users-form-actions button:disabled {
|
||||||
|
opacity: 0.62;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-form-actions .users-secondary {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #9fbedb;
|
||||||
|
color: #1b3047;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-permissions {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f2f9fd;
|
||||||
|
color: #3d596d;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-permissions b {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-permissions ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rollen als farbige Chips, damit die Tabelle auf einen Blick lesbar ist. */
|
||||||
|
.users-role-admin {
|
||||||
|
background: #ffe9c9;
|
||||||
|
color: #8a5411;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-role-inhaber {
|
||||||
|
background: #dbe7ff;
|
||||||
|
color: #24478f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-role-buero {
|
||||||
|
background: #e6f7dd;
|
||||||
|
color: #25753a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-state-active {
|
||||||
|
color: #25753a;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-state-inactive {
|
||||||
|
color: #a82028;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-actions button {
|
||||||
|
border: 1px solid #9fbedb;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: #1b3047;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-actions button:hover:not(:disabled) {
|
||||||
|
border-color: #6cc55a;
|
||||||
|
background: #f1fbf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-actions button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-actions .users-danger {
|
||||||
|
border-color: #e0a9a4;
|
||||||
|
color: #a1332d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-actions .users-danger:hover:not(:disabled) {
|
||||||
|
border-color: #d66b6b;
|
||||||
|
background: #fff4f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-audit {
|
||||||
|
margin-top: 26px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
border: 1px solid #c7dce8;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-audit h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-audit ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-audit li {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: baseline;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #e6eef3;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-audit li:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-audit b {
|
||||||
|
flex: 0 0 150px;
|
||||||
|
color: #3d596d;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-audit span {
|
||||||
|
color: #244258;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.users-audit li {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-audit b {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dunkles Farbschema, passend zum Rest der Anwendung. */
|
||||||
|
:root[data-theme='dark'] .users-form,
|
||||||
|
:root[data-theme='dark'] .users-audit {
|
||||||
|
border-color: #34505f;
|
||||||
|
background: #172a36;
|
||||||
|
color: #e5eff5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .users-form input,
|
||||||
|
:root[data-theme='dark'] .users-form select {
|
||||||
|
border-color: #3a5868;
|
||||||
|
background: #132630;
|
||||||
|
color: #e3eff5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .users-form label,
|
||||||
|
:root[data-theme='dark'] .users-permissions,
|
||||||
|
:root[data-theme='dark'] .users-audit span {
|
||||||
|
color: #b4c5cf;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .users-permissions {
|
||||||
|
background: #1d3544;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .users-notice {
|
||||||
|
border-color: #39704a;
|
||||||
|
background: #163023;
|
||||||
|
color: #d7f3d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .users-actions button {
|
||||||
|
border-color: #3a5868;
|
||||||
|
background: #132630;
|
||||||
|
color: #dceaf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .users-audit li {
|
||||||
|
border-color: #294553;
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
import { api } from '@/api/client';
|
||||||
|
import { useAuth } from '@/auth/AuthProvider';
|
||||||
|
import type { ManagedUser, Role, RoleOption } from '@/auth/types';
|
||||||
|
import { formatDateTime } from '@/lib/format';
|
||||||
|
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||||
|
|
||||||
|
type UsersPayload = { users: ManagedUser[]; roles: RoleOption[] };
|
||||||
|
type AuditEntry = { actor: string; action: string; subject: string; detail: Record<string, unknown>; createdAt: string };
|
||||||
|
|
||||||
|
const emptyDraft = { username: '', displayName: '', role: 'buero' as Role, password: '' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benutzerverwaltung.
|
||||||
|
*
|
||||||
|
* Nur fuer die Rolle Administration sichtbar. Das Backend setzt dieselben Regeln
|
||||||
|
* noch einmal durch -- diese Ansicht macht sie nur sichtbar, sie ersetzt sie nicht.
|
||||||
|
*/
|
||||||
|
export function UsersView() {
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||||
|
const [roles, setRoles] = useState<RoleOption[]>([]);
|
||||||
|
const [audit, setAudit] = useState<AuditEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [notice, setNotice] = useState('');
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(emptyDraft);
|
||||||
|
const [editing, setEditing] = useState<ManagedUser | null>(null);
|
||||||
|
const [editDraft, setEditDraft] = useState({ username: '', displayName: '', role: 'buero' as Role, password: '' });
|
||||||
|
const [deleteConfirming, setDeleteConfirming] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.get<UsersPayload>('/api/users');
|
||||||
|
setUsers(data.users);
|
||||||
|
setRoles(data.roles);
|
||||||
|
} catch (problem) {
|
||||||
|
setError(problem instanceof Error ? problem.message : 'Die Benutzer konnten nicht geladen werden.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshAudit = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get<{ entries: AuditEntry[] }>('/api/users/audit');
|
||||||
|
setAudit(data.entries);
|
||||||
|
} catch {
|
||||||
|
// Das Protokoll ist eine Beigabe. Faellt es aus, bleibt die Verwaltung nutzbar.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
void refreshAudit();
|
||||||
|
}, [refresh, refreshAudit]);
|
||||||
|
|
||||||
|
const run = async (id: string | null, action: () => Promise<string>) => {
|
||||||
|
setBusyId(id ?? 'form');
|
||||||
|
setError('');
|
||||||
|
setNotice('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
setNotice(await action());
|
||||||
|
await refresh();
|
||||||
|
await refreshAudit();
|
||||||
|
} catch (problem) {
|
||||||
|
setError(problem instanceof Error ? problem.message : 'Die Aktion ist fehlgeschlagen.');
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const create = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
void run(null, async () => {
|
||||||
|
await api.post('/api/users', draft);
|
||||||
|
setDraft(emptyDraft);
|
||||||
|
setCreating(false);
|
||||||
|
|
||||||
|
return `Benutzer „${draft.displayName}“ wurde angelegt.`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!editing) return;
|
||||||
|
|
||||||
|
void run(editing.id, async () => {
|
||||||
|
const changes: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (editDraft.displayName !== editing.displayName) changes.displayName = editDraft.displayName;
|
||||||
|
if (editDraft.username !== editing.username) changes.username = editDraft.username;
|
||||||
|
if (editDraft.role !== editing.role) changes.role = editDraft.role;
|
||||||
|
if (editDraft.password !== '') changes.password = editDraft.password;
|
||||||
|
|
||||||
|
if (Object.keys(changes).length === 0) {
|
||||||
|
setEditing(null);
|
||||||
|
return 'Es wurde nichts geändert.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.patch<{ endedSessions: number }>(`/api/users/${editing.id}`, changes);
|
||||||
|
setEditing(null);
|
||||||
|
|
||||||
|
return result.endedSessions > 0
|
||||||
|
? `Gespeichert. ${result.endedSessions} offene Sitzung(en) wurden beendet.`
|
||||||
|
: 'Gespeichert.';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleActive = (user: ManagedUser) =>
|
||||||
|
void run(user.id, async () => {
|
||||||
|
await api.patch(`/api/users/${user.id}`, { isActive: !user.isActive });
|
||||||
|
|
||||||
|
return user.isActive ? `„${user.displayName}“ ist jetzt deaktiviert.` : `„${user.displayName}“ ist wieder aktiv.`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = (user: ManagedUser) =>
|
||||||
|
void run(user.id, async () => {
|
||||||
|
await api.delete(`/api/users/${user.id}`);
|
||||||
|
setDeleteConfirming(null);
|
||||||
|
|
||||||
|
return `Benutzer „${user.displayName}“ wurde gelöscht.`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const startEdit = (user: ManagedUser) => {
|
||||||
|
setEditing(user);
|
||||||
|
setEditDraft({ username: user.username, displayName: user.displayName, role: user.role, password: '' });
|
||||||
|
setCreating(false);
|
||||||
|
setNotice('');
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeAdmins = users.filter((user) => user.role === 'admin' && user.isActive).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="tickets-page users-page">
|
||||||
|
<div className="tickets-heading">
|
||||||
|
<div>
|
||||||
|
<p className="home-kicker">Verwaltung</p>
|
||||||
|
<h2>Benutzer</h2>
|
||||||
|
<p>
|
||||||
|
Konten, Rollen und Rechte für EK-DOS. Jede Änderung an Rolle, Passwort oder Status beendet die offenen
|
||||||
|
Sitzungen des betroffenen Kontos sofort.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="tickets-actions">
|
||||||
|
<span>{users.length} Konten · {activeAdmins} aktive Administration</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setCreating((open) => !open);
|
||||||
|
setEditing(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{creating ? 'Abbrechen' : 'Benutzer anlegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="tickets-error">{error}</p>}
|
||||||
|
{notice && <p className="users-notice">{notice}</p>}
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<form className="users-form" onSubmit={create}>
|
||||||
|
<h3>Neuer Benutzer</h3>
|
||||||
|
<div className="users-form-grid">
|
||||||
|
<label>
|
||||||
|
Benutzername
|
||||||
|
<input
|
||||||
|
value={draft.username}
|
||||||
|
onChange={(event) => setDraft({ ...draft, username: event.target.value })}
|
||||||
|
placeholder="z. B. svenja"
|
||||||
|
autoComplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Anzeigename
|
||||||
|
<input
|
||||||
|
value={draft.displayName}
|
||||||
|
onChange={(event) => setDraft({ ...draft, displayName: event.target.value })}
|
||||||
|
placeholder="z. B. Svenja"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Rolle
|
||||||
|
<select value={draft.role} onChange={(event) => setDraft({ ...draft, role: event.target.value as Role })}>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<option key={role.value} value={role.value}>
|
||||||
|
{role.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Passwort
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={draft.password}
|
||||||
|
onChange={(event) => setDraft({ ...draft, password: event.target.value })}
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={12}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small>Mindestens 12 Zeichen.</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<RolePermissions roles={roles} selected={draft.role} />
|
||||||
|
<button type="submit" disabled={busyId === 'form'}>
|
||||||
|
{busyId === 'form' ? 'Wird angelegt …' : 'Benutzer anlegen'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<form className="users-form" onSubmit={saveEdit}>
|
||||||
|
<h3>{editing.displayName} bearbeiten</h3>
|
||||||
|
<div className="users-form-grid">
|
||||||
|
<label>
|
||||||
|
Benutzername
|
||||||
|
<input
|
||||||
|
value={editDraft.username}
|
||||||
|
onChange={(event) => setEditDraft({ ...editDraft, username: event.target.value })}
|
||||||
|
autoComplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Anzeigename
|
||||||
|
<input
|
||||||
|
value={editDraft.displayName}
|
||||||
|
onChange={(event) => setEditDraft({ ...editDraft, displayName: event.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Rolle
|
||||||
|
<select
|
||||||
|
value={editDraft.role}
|
||||||
|
onChange={(event) => setEditDraft({ ...editDraft, role: event.target.value as Role })}
|
||||||
|
disabled={editing.id === currentUser?.id}
|
||||||
|
>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<option key={role.value} value={role.value}>
|
||||||
|
{role.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{editing.id === currentUser?.id && <small>Die eigene Rolle kann nicht geändert werden.</small>}
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Neues Passwort
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={editDraft.password}
|
||||||
|
onChange={(event) => setEditDraft({ ...editDraft, password: event.target.value })}
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="leer lassen, um es beizubehalten"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<RolePermissions roles={roles} selected={editDraft.role} />
|
||||||
|
<div className="users-form-actions">
|
||||||
|
<button type="submit" disabled={busyId === editing.id}>
|
||||||
|
{busyId === editing.id ? 'Wird gespeichert …' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="users-secondary" onClick={() => setEditing(null)}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="tickets-table-wrap">
|
||||||
|
<table className="tickets-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Benutzer</th>
|
||||||
|
<th>Rolle</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Letzte Anmeldung</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading && users.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5}>Benutzer werden geladen …</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{!loading && users.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5}>Es ist noch kein Benutzer angelegt.</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{users.map((user) => (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<td>
|
||||||
|
<b>{user.displayName}</b>
|
||||||
|
<small>{user.username}{user.id === currentUser?.id ? ' · das bist du' : ''}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`ticket-status users-role-${user.role}`}>{user.roleLabel}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={user.isActive ? 'users-state-active' : 'users-state-inactive'}>
|
||||||
|
{user.isActive ? 'aktiv' : 'deaktiviert'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{user.lastLoginAt ? formatDateTime(user.lastLoginAt) : 'noch nie'}</td>
|
||||||
|
<td className="users-actions">
|
||||||
|
<button onClick={() => startEdit(user)} disabled={busyId !== null}>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleActive(user)}
|
||||||
|
disabled={busyId !== null || user.id === currentUser?.id}
|
||||||
|
title={user.id === currentUser?.id ? 'Das eigene Konto kann nicht deaktiviert werden.' : ''}
|
||||||
|
>
|
||||||
|
{user.isActive ? 'Deaktivieren' : 'Aktivieren'}
|
||||||
|
</button>
|
||||||
|
{deleteConfirming === user.id ? (
|
||||||
|
<>
|
||||||
|
<button className="users-danger" onClick={() => remove(user)} disabled={busyId !== null}>
|
||||||
|
Wirklich löschen
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setDeleteConfirming(null)}>Abbrechen</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="users-danger"
|
||||||
|
onClick={() => setDeleteConfirming(user.id)}
|
||||||
|
disabled={busyId !== null || user.id === currentUser?.id}
|
||||||
|
title={user.id === currentUser?.id ? 'Das eigene Konto kann nicht gelöscht werden.' : ''}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{audit.length > 0 && (
|
||||||
|
<div className="users-audit">
|
||||||
|
<h3>Letzte Änderungen</h3>
|
||||||
|
<ul>
|
||||||
|
{audit.map((entry, index) => (
|
||||||
|
<li key={`${entry.createdAt}-${index}`}>
|
||||||
|
<b>{formatDateTime(entry.createdAt)}</b>
|
||||||
|
<span>
|
||||||
|
{entry.actor} · {describeAction(entry.action)} · {entry.subject}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RolePermissions({ roles, selected }: { roles: RoleOption[]; selected: Role }) {
|
||||||
|
const role = roles.find((candidate) => candidate.value === selected);
|
||||||
|
|
||||||
|
if (!role) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="users-permissions">
|
||||||
|
<b>Diese Rolle darf zusätzlich:</b>
|
||||||
|
{role.permissions.length === 0 ? (
|
||||||
|
<span>nur die allgemeinen Büroansichten</span>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{role.permissions.map((permission) => (
|
||||||
|
<li key={permission}>{permission}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeAction(action: string): string {
|
||||||
|
switch (action) {
|
||||||
|
case 'user.create':
|
||||||
|
return 'angelegt';
|
||||||
|
case 'user.update':
|
||||||
|
return 'geändert';
|
||||||
|
case 'user.delete':
|
||||||
|
return 'gelöscht';
|
||||||
|
case 'user.password':
|
||||||
|
return 'Passwort zurückgesetzt';
|
||||||
|
case 'password.self':
|
||||||
|
return 'eigenes Passwort geändert';
|
||||||
|
default:
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
|
||||||
|
"allowImportingTsExtensions": false,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { fileURLToPath, URL } from 'node:url';
|
||||||
|
import { defineConfig, type UserConfig } from 'vite';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Funktionsform, damit ein Produktionsbau (Gitea-Runner) nichts vom
|
||||||
|
* Entwicklungsserver anfasst. Der Bau erzeugt ausschliesslich statische
|
||||||
|
* Dateien; auf dem Zielsystem laeuft kein Node.
|
||||||
|
*/
|
||||||
|
export default defineConfig(({ command }) => {
|
||||||
|
const config: UserConfig = {
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
// Quellkarten bleiben aus: das Buendel liegt auf einem erreichbaren
|
||||||
|
// nginx und soll den Quelltext nicht mitliefern.
|
||||||
|
sourcemap: false,
|
||||||
|
target: 'es2022',
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
// React getrennt halten, damit ein Oberflaechen-Update nicht
|
||||||
|
// den gesamten Cache der Anwender entwertet.
|
||||||
|
manualChunks: { react: ['react', 'react-dom'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ab hier nur noch Entwicklungsserver. Ein `vite build` erreicht diese
|
||||||
|
// Zeilen nie und braucht deshalb weder Proxy noch laufendes Backend.
|
||||||
|
if (command !== 'serve') return config;
|
||||||
|
|
||||||
|
config.server = {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
proxy: {
|
||||||
|
// Im Betrieb macht das nginx. Lokal zeigt der Proxy auf
|
||||||
|
// `php -S 127.0.0.1:8080 -t backend/public`.
|
||||||
|
'/api': {
|
||||||
|
target: process.env.EKDOS_BACKEND ?? 'http://127.0.0.1:8080',
|
||||||
|
changeOrigin: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return config;
|
||||||
|
});
|
||||||