15 Commits

Author SHA1 Message Date
Pepe Ziberi
5f360480ae feat(legal): FKS-Quellenangabe, Auslandtransfer-Doku, Journal-Warnung, Region-Fix (v1.9.1)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 53m24s
Compliance-Runde 2 (ohne Doppelstrukturen; baut auf v1.7–v1.9 auf):

FKS/FEUKOS (dokumentierte schriftliche Genehmigung 17.02.2026):
- legal.ts: fksAttribution (exakte Quellenangabe, Genehmigungsdatum, Nutzungshinweis) + operatorType
- Neue Seite /quellen-und-lizenzen (+ Footer/Sitemap-Link)
- FKS-Abschnitt in Nutzungsbedingungen (§10a); Quellenangabe im Symbol-Panel und in PDF/PNG-Export
- Terminologie entschärft: "offizielle FKS/BABS-Signaturen" -> "FEUKOS-Symbole der FKS"
  (keine Partnerschaft/Zertifizierung/Empfehlung suggeriert)
- FKS_ATTRIBUTION als Dokumenttyp (Migration-Seed, LegalDocumentType), Kontexte erweitert
- licenses-and-attributions.md aktualisiert; .gitignore: private-legal-evidence/

Datenschutz/Transparenz:
- [REGION]-Platzhalter in der Subprozessorenliste durch öffentlich bekannte Firmensitze ersetzt
  (keine Platzhalter mehr öffentlich sichtbar)
- Journal-Freitext-Warnung (keine schutzwürdigen Daten)

Docs: current-state-audit, international-data-transfers, backup-and-restore,
privacy-risk-assessment, manual-action-checklist

Tests: Krypto-Round-Trip (crypto-secret); legal-config-Test aktualisiert; 17 grün. tsc+build ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 13:52:38 +02:00
Pepe Ziberi
28d346d0f5 feat(backup): GUI-konfigurierbares, verschlüsseltes Off-Site-Backup (SFTP/Nextcloud) (v1.9.0)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 54m48s
Admin → Backup: Ziel (SFTP oder Nextcloud/WebDAV) konfigurieren, Verbindung testen, Jetzt sichern,
Zeitplan (aus/täglich/wöchentlich) + Aufbewahrung. Best Practice: verschlüsselt + off-site.

- Verschlüsselung: Backup als tar.gz (database.dump + MinIO-Dateien) → AES-256-GCM mit Passphrase.
  Zugangsdaten + Passphrase verschlüsselt in DB (src/lib/crypto-secret.ts, Schlüssel aus Server-Secret)
- Engine (src/lib/backup): pg_dump + archiver-Stream aus MinIO + Stream-Verschlüsselung + Upload + Prune
- Ziel-Adapter: SFTP (ssh2-sftp-client) + WebDAV (webdav), je test/upload/list/delete
- API (SERVER_ADMIN): /api/admin/backup/{config,test,run}; öffentlich per CRON_SECRET: /api/cron/backup
- Scheduler in server-custom.js (stündliche Fälligkeitsprüfung)
- Dockerfile: postgresql16-client (pg_dump) + runtime-Libs; next.config serverExternalPackages
- scripts/decrypt-backup.js + docs/BACKUP.md (Restore-Anleitung), .env.example (CRON_SECRET, BACKUP_ENC_KEY)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 23:33:48 +02:00
Pepe Ziberi
15dd78c848 feat(ops): Automatisches Backup (DB + Dateien, 30 Tage) (v1.8.11)
Schliesst den offenen Punkt: die Datenschutzerklärung nannte "bis zu 30 Tage", real fehlte ein Backup.

- Neuer backup-Container (postgres:16-alpine) in docker-compose.yml und deploy/portainer-stack.yml:
  täglich pg_dump (custom/komprimiert) + tar der MinIO-Dateien, Prune > 30 Tage, im Volume backups_data
- Konfigurierbar: BACKUP_RETENTION_DAYS, BACKUP_INTERVAL_HOURS (.env.example ergänzt)
- scripts/restore.sh + docs/BACKUP.md (Einrichtung, Off-Site-Hinweis, Restore DB+Dateien, Restore-Test-Tabelle)
- TOM-Doku aktualisiert (Backup vorhanden)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:39:03 +02:00
Pepe Ziberi
e5811f03fb fix(map): Pfeilspitze zeigt exakt entlang der Linie (v1.8.10)
Die Pfeilspitze wurde mit dem geografischen Winkel (atan2 auf lng/lat) gedreht, der wegen
der Mercator-Verzerrung nicht mit der auf dem Bildschirm gezeichneten Linie übereinstimmt
(v.a. bei nördlichen Breiten). Jetzt aus den projizierten Pixeln berechnet (viewport-
ausgerichtet) → Spitze zeigt korrekt in Linienrichtung.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:30:22 +02:00
Pepe Ziberi
405639db7d refactor(wind): Wind nur noch in der Kopfzeile (Karte = nur Nordpfeil) (v1.8.9)
- Wind-Anzeige von der Karte entfernt (war doppelt und nicht als "Wind" erkennbar);
  MapCompass zeigt jetzt nur den Nordpfeil
- Kopfzeile: Wind-Badge ist klickbar → Einsteller (Live vom Standort / Richtung wählen /
  entfernen), klar mit "Wind" beschriftet
- Topbar erhält onWindChange/onFetchLiveWind (nur bei Bearbeitungsrecht)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:27:48 +02:00
Pepe Ziberi
2323f1d360 fix(cockpit): Modul-Spalten sauber ausgerichtet + Zahl-Spalten im Rapport (v1.8.8)
- table-module: Spaltenbreiten respektieren Breiten-Hinweise (w-16…w-32); Eingabezeile im
  selben Raster wie Kopf/Daten (keine abgeschnittenen Platzhalter, saubere Ausrichtung)
- Rapport (/rapport/[token]): Zahl-Spalten rechtsbündig/monospace
- Hinweis: Modul→Rapport war bereits vollständig integriert; leere Module werden im
  Rapport ausgelassen (erst mit Zeilen sichtbar)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 22:04:08 +02:00
Pepe Ziberi
4a59b5ca0b feat(ui): Vollbild/Präsentation im Header + Passwortfeld mit Auge & grösseren Punkten (v1.8.7)
- Topbar: Schnellzugriff-Buttons Vollbild und Präsentationsmodus direkt neben Speichern
  (Desktop; mobil weiterhin im ⋮-Menü)
- Login: Passwortfeld mit Anzeigen/Verbergen-Umschalter (Auge) und grösseren, gespreizten
  Punkten (text-xl tracking) + sanfter Übergang

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 21:00:43 +02:00
Pepe Ziberi
f9ad00a7e7 fix(journal): Modul-Button folgt dem Mandanten des offenen Einsatzes (v1.8.6)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45m49s
Ursache: der "Module"-Button hing am Mandanten des Nutzers, nicht am Mandanten des
geöffneten Einsatzes. SERVER_ADMIN (ohne eigenen Mandant) oder mandantenfremde Einsätze
zeigten daher keinen Button.

- /api/tenant/modules: GET/PUT akzeptieren tenantId; SERVER_ADMIN darf den Mandanten des
  Einsatzes verwalten, andere nur den eigenen (canManage sauber berechnet)
- journal-view lädt/speichert Module für den Einsatz-Mandanten (projectTenantId)
- ModuleManagerDialog reicht tenantId beim Speichern durch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:36:04 +02:00
Pepe Ziberi
aaeeee1717 fix(journal): Modul-Button & Aktionen auf dem Handy klar sichtbar (v1.8.5)
- Journal-Kopfzeile bricht auf dem Handy sauber um (Titel oben, Aktionen darunter)
- "Module"-Button trägt jetzt immer die Beschriftung (vorher nur Icon auf dem Handy)
  und ist damit auffindbar; Aktionen (Module/Rapport/E-Mail/Drucken) umbrechend statt gequetscht

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:21:29 +02:00
Pepe Ziberi
b095d9de1c fix(admin): Mobil-Layout der Administration entzerrt (v1.8.4)
- Tab-Leiste (7 Tabs) war auf dem Handy in grid-cols-7 gequetscht/überlappend →
  jetzt horizontal scrollbar (flex/overflow-x-auto), ab md wieder 7-spaltiges Grid
- Header responsiv: "Zurück"-Text nur ab sm, Name kürzt (truncate), Rollen-Badge nur ab sm,
  kleinere Paddings

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:17:31 +02:00
Pepe Ziberi
ae78b2d878 fix(nav): Sicherheit/2FA & Konto-Datenschutz im Benutzer-Menü auffindbar (v1.8.3)
2FA lag unter /settings, war aber über kein Menü erreichbar. Neu im Benutzer-Menü
(Desktop-Dropdown + Mobil-Menü): "Sicherheit & 2FA" (→ /settings) und
"Konto & Datenschutz" (→ /konto/datenschutz).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:15:40 +02:00
Pepe Ziberi
9ba7194d41 feat(cockpit): Baukasten aufgewertet — Slot-Wahl, Zahl-Spalte, Module bearbeiten, Unwetter-Vorlage (v1.8.2)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 44m40s
Journal modularer (Feedback Test-Feuerwehr Unwettereinsätze):
- Modul-Manager: Anzeige-Bereich (Hauptbereich/Seitenspalte) frei wählbar; bestehende
  Tabellen-Module bearbeitbar (Name/Slot/Spalten), nicht nur neu erstellen
- Neuer Spaltentyp "Zahl" (z.B. Auftragsnummer) inkl. Rendering/Eingabe im table-module
- Fertige Vorlage "Einsatz-Aufträge (Unwetter)": Zeit · Auftrags-Nr · Adresse · Auftrag ·
  Zugeteilt · erledigt (Hauptbereich, gross links)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:44:38 +02:00
Pepe Ziberi
f58f1b31bf feat(auth): WebAuthn (YubiKey/Passkeys) + Admin-2FA-Reset-Button (v1.8.1)
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
- WebAuthn/FIDO2 via @simplewebauthn v13: Registrierung (Session) + Login-Faktor (öffentlich),
  zustandslose Challenge-Token (JWT), Zähler/Klon-Schutz, Credentials in webauthn_credentials
  - Routen: /api/auth/mfa/webauthn/register/{options,verify}, DELETE /webauthn/[id],
    /api/auth/mfa-login/webauthn/{options,verify}
- Settings: Sicherheitsschlüssel/Passkey hinzufügen + Liste + entfernen
- Login: "Mit Sicherheitsschlüssel/Passkey anmelden" (neben TOTP)
- Admin: 2FA-Zurücksetzen-Button in der Benutzerliste (nutzt bestehende mfa-reset-API)
- Sicherheitsseite + TOM-Doku: 2FA als vorhanden dokumentiert

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:15:03 +02:00
Pepe Ziberi
f588b6d361 feat(auth): Zwei-Faktor-Authentisierung — TOTP (Authenticator-App) (v1.8.0)
Freiwillige MFA, du kannst sie für dich aktivieren; Login verlangt danach den zweiten Faktor.

- Schema/Migration: User.totpSecret/totpConfirmedAt/mfaBackupCodes + Tabelle webauthn_credentials
  (WebAuthn folgt), otplib v12
- lib/mfa.ts: TOTP-Secret/QR-URI/Verify + bcrypt-gehashte Backup-Codes
- lib/auth.ts: buildSessionForUser, createMfaToken/verifyMfaToken (10-Min-Zwischentoken), userHasMfa
- Login-Flow: nach Passwort -> bei aktiver MFA kein Cookie, sondern mfaToken; öffentlicher
  /api/auth/mfa-login/verify prüft TOTP ODER Backup-Code und setzt dann die Session (Rate-Limit)
- Einrichtung in /settings: QR scannen, Code bestätigen, Backup-Codes (einmalig), deaktivieren
- Admin-Reset-API: /api/admin/users/[id]/mfa-reset (SERVER_ADMIN alle, TENANT_ADMIN eigene Org)
- Login-Seite: zweiter Schritt für Code/Backup-Code
- SMS bewusst weggelassen (Kosten/SIM-Swap)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:06:24 +02:00
Pepe Ziberi
fa1e911ecd feat(legal): Zustimmungs-Nachweise mit IP, Gerät/Browser & Hash (v1.7.8)
Belastbarer Einwilligungsnachweis:
- Schema/Migration: legal_acceptances + ipAddress, userAgent (contentHash bestand bereits)
- Erfassung bei Registrierung und jeder Zustimmung (/api/legal/accept)
- Admin /admin/legal: neue Spalten IP + Gerät/Browser + Hash; CSV-Export um ipAddress,
  userAgent, contentHash erweitert
- Datenschutzerklärung + Dateninventar: IP/User-Agent als dokumentierter Beweiszweck ergänzt

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 17:55:57 +02:00
82 changed files with 4237 additions and 318 deletions

View File

@@ -32,3 +32,17 @@ NODE_ENV=development
# Gitea Registry Login für Watchtower (automatische Image-Updates) # Gitea Registry Login für Watchtower (automatische Image-Updates)
GITEA_REGISTRY_USER=adminpepe GITEA_REGISTRY_USER=adminpepe
GITEA_REGISTRY_PASS=dein_gitea_token_oder_passwort GITEA_REGISTRY_PASS=dein_gitea_token_oder_passwort
# --- Backup (automatischer DB- + Datei-Backup-Container) ---
# Aufbewahrungsdauer in Tagen (muss zur Datenschutzerklaerung passen)
BACKUP_RETENTION_DAYS=30
# Intervall in Stunden (24 = taeglich)
BACKUP_INTERVAL_HOURS=24
# --- GUI-Backup (SFTP/Nextcloud, verschluesselt) ---
# Noetig fuer den automatischen Zeitplan (interner Scheduler ruft /api/cron/backup):
CRON_SECRET=langer-zufaelliger-wert-min-32-zeichen
# Optional: eigener Schluessel zum Verschluesseln der gespeicherten Zugangsdaten
# (Default: NEXTAUTH_SECRET). Wenn gesetzt, NICHT mehr aendern (sonst sind gespeicherte
# Zugangsdaten/Passphrase nicht mehr entschluesselbar):
# BACKUP_ENC_KEY=eigener-langer-schluessel

3
.gitignore vendored
View File

@@ -65,3 +65,6 @@ Reglement_*/
# Stack env (contains secrets) # Stack env (contains secrets)
stack.env stack.env
# Private rechtliche Nachweise (NICHT committen: z.B. FKS-Freigabe-E-Mail)
private-legal-evidence/

View File

@@ -28,7 +28,8 @@ RUN --mount=type=cache,target=/app/.next/cache npm run build
# Stage 3: Runner # Stage 3: Runner
FROM node:20-alpine AS runner FROM node:20-alpine AS runner
RUN apk add --no-cache openssl # openssl (Prisma) + postgresql16-client (pg_dump/pg_restore für GUI-Backup, passend zu postgres:16)
RUN apk add --no-cache openssl postgresql16-client
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
@@ -44,7 +45,7 @@ USER nextjs
# Install only the unbundled runtime deps needed by the custom server. # Install only the unbundled runtime deps needed by the custom server.
# Running as USER nextjs means files are already correctly owned — no slow chown -R needed afterwards. # Running as USER nextjs means files are already correctly owned — no slow chown -R needed afterwards.
RUN npm install --omit=dev --legacy-peer-deps socket.io@4.7.4 @react-pdf/renderer@4.3.2 qrcode@1.5.4 --no-save RUN npm install --omit=dev --legacy-peer-deps socket.io@4.7.4 @react-pdf/renderer@4.3.2 qrcode@1.5.4 ssh2-sftp-client@12.1.1 webdav@5.10.0 archiver@8.0.0 --no-save
COPY --chown=nextjs:nodejs --from=builder /app/.next/standalone ./ COPY --chown=nextjs:nodejs --from=builder /app/.next/standalone ./
COPY --chown=nextjs:nodejs --from=builder /app/.next/static ./.next/static COPY --chown=nextjs:nodejs --from=builder /app/.next/static ./.next/static

View File

@@ -86,11 +86,57 @@ services:
networks: networks:
- lageplan-net - lageplan-net
# --- Automatisches Backup: täglicher DB-Dump + Datei-Backup (30 Tage) ---
# Backups im Volume "backups_data". Für Off-Site auf einen NAS-Pfad umstellen, z.B.:
# volumes: - /volume1/backups/lageplan:/backups
backup:
image: postgres:16-alpine
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
PGHOST: db
PGUSER: ${POSTGRES_USER}
PGPASSWORD: ${POSTGRES_PASSWORD}
PGDATABASE: ${POSTGRES_DB}
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-30}
BACKUP_INTERVAL_HOURS: ${BACKUP_INTERVAL_HOURS:-24}
volumes:
- backups_data:/backups
- minio_data:/minio:ro
networks:
- lageplan-net
entrypoint:
- /bin/sh
- -c
- |
echo "[backup] Start - alle $${BACKUP_INTERVAL_HOURS}h, Aufbewahrung $${BACKUP_RETENTION_DAYS} Tage";
while true; do
TS=$$(date +%Y-%m-%d_%H%M%S);
mkdir -p /backups;
echo "[backup] $$TS Datenbank sichern...";
if pg_dump -h "$$PGHOST" -U "$$PGUSER" -d "$$PGDATABASE" -Fc -f "/backups/db_$$TS.dump"; then
echo "[backup] DB ok: db_$$TS.dump";
else
echo "[backup] DB FEHLGESCHLAGEN"; rm -f "/backups/db_$$TS.dump";
fi;
if [ -d /minio ]; then
tar czf "/backups/files_$$TS.tar.gz" -C /minio . 2>/dev/null && echo "[backup] Dateien ok: files_$$TS.tar.gz" || echo "[backup] Dateien uebersprungen";
fi;
find /backups -name "db_*.dump" -mtime +$$BACKUP_RETENTION_DAYS -delete 2>/dev/null || true;
find /backups -name "files_*.tar.gz" -mtime +$$BACKUP_RETENTION_DAYS -delete 2>/dev/null || true;
echo "[backup] fertig - naechstes in $${BACKUP_INTERVAL_HOURS}h";
sleep $$(( BACKUP_INTERVAL_HOURS * 3600 ));
done
volumes: volumes:
postgres_data: postgres_data:
driver: local driver: local
minio_data: minio_data:
driver: local driver: local
backups_data:
driver: local
networks: networks:
lageplan-net: lageplan-net:

View File

@@ -84,6 +84,49 @@ services:
volumes: volumes:
- ./prisma:/app/prisma - ./prisma:/app/prisma
# Automatisches Backup: täglicher Postgres-Dump + Datei-Backup (MinIO), 30 Tage Aufbewahrung.
# Backups liegen im Volume "backups_data" (für Off-Site: unten auf einen Host-Pfad umstellen).
backup:
image: postgres:16-alpine
container_name: lageplan-backup
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
PGHOST: db
PGUSER: ${POSTGRES_USER:-lageplan}
PGPASSWORD: ${POSTGRES_PASSWORD:-lageplan_secret}
PGDATABASE: ${POSTGRES_DB:-lageplan}
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-30}
BACKUP_INTERVAL_HOURS: ${BACKUP_INTERVAL_HOURS:-24}
volumes:
- backups_data:/backups
- minio_data:/minio:ro
entrypoint:
- /bin/sh
- -c
- |
echo "[backup] Start - alle $${BACKUP_INTERVAL_HOURS}h, Aufbewahrung $${BACKUP_RETENTION_DAYS} Tage";
while true; do
TS=$$(date +%Y-%m-%d_%H%M%S);
mkdir -p /backups;
echo "[backup] $$TS Datenbank sichern...";
if pg_dump -h "$$PGHOST" -U "$$PGUSER" -d "$$PGDATABASE" -Fc -f "/backups/db_$$TS.dump"; then
echo "[backup] DB ok: db_$$TS.dump";
else
echo "[backup] DB FEHLGESCHLAGEN"; rm -f "/backups/db_$$TS.dump";
fi;
if [ -d /minio ]; then
tar czf "/backups/files_$$TS.tar.gz" -C /minio . 2>/dev/null && echo "[backup] Dateien ok: files_$$TS.tar.gz" || echo "[backup] Dateien uebersprungen";
fi;
find /backups -name "db_*.dump" -mtime +$$BACKUP_RETENTION_DAYS -delete 2>/dev/null || true;
find /backups -name "files_*.tar.gz" -mtime +$$BACKUP_RETENTION_DAYS -delete 2>/dev/null || true;
echo "[backup] fertig - naechstes in $${BACKUP_INTERVAL_HOURS}h";
sleep $$(( BACKUP_INTERVAL_HOURS * 3600 ));
done
volumes: volumes:
postgres_data: postgres_data:
minio_data: minio_data:
backups_data:

115
docs/BACKUP.md Normal file
View File

@@ -0,0 +1,115 @@
# Backup & Restore — Lageplan
Automatisches Backup als eigener Container (`backup`). Sichert **täglich**:
- **Datenbank** (PostgreSQL) → `db_<zeitstempel>.dump` (pg_dump, custom/komprimiert)
- **Hochgeladene Dateien** (MinIO: Pläne, Logos, Symbole) → `files_<zeitstempel>.tar.gz`
**Aufbewahrung:** 30 Tage (ältere werden automatisch gelöscht) — passt zur Datenschutzerklärung.
Einstellbar über `BACKUP_RETENTION_DAYS` und `BACKUP_INTERVAL_HOURS` (siehe `.env.example`).
Die Backups liegen im Docker-Volume **`backups_data`** (Pfad im Container: `/backups`).
---
## Einrichtung
Der `backup`-Service ist in `docker-compose.yml` und `deploy/portainer-stack.yml` enthalten.
Nach dem nächsten Stack-Update (Portainer: Stack neu deployen) läuft er automatisch.
Prüfen, dass er läuft und sichert:
```
docker logs lageplan-backup # oder: docker compose logs backup
docker compose exec backup ls -lh /backups
```
Beim ersten Start wird sofort ein Backup erstellt, danach alle 24 h.
### Empfehlung: Backups aus dem Container heraus auf den NAS legen (Off-Site)
Standardmässig liegen die Backups in einem Docker-Volume. Damit sie ausserhalb von Docker
(z. B. auf einer NAS-Freigabe, die du separat sicherst) landen, im Stack den `backup`-Service auf
einen **Host-Pfad** umstellen:
```yaml
volumes:
- /volume1/backups/lageplan:/backups # statt backups_data:/backups
- minio_data:/minio:ro
```
So kannst du die Dateien einfach kopieren/auf ein anderes Medium spiegeln. **Wichtig:** Ein Backup
auf demselben Gerät wie die Daten schützt nicht vor Geräteausfall/Diebstahl — kopiere die Backups
regelmässig an einen zweiten Ort.
---
## Wiederherstellen (Restore)
> **Destruktiv.** Vorher die Web-App stoppen (`docker stop lageplan-web`) und sicherstellen, dass
> niemand arbeitet.
### 1) Datenbank
```
# Verfügbare Dumps anzeigen
docker compose exec backup ls -lh /backups
# Wiederherstellen (Dateiname einsetzen)
docker compose exec backup pg_restore --clean --if-exists --no-owner \
-h db -U <POSTGRES_USER> -d <POSTGRES_DB> /backups/db_2026-07-23_030000.dump
```
### 2) Dateien (Uploads/MinIO)
Da `/minio` im backup-Container schreibgeschützt ist, den Tarball in einen Container mit
Schreibzugriff auf das MinIO-Volume entpacken:
```
docker run --rm -v minio_data:/data -v backups_data:/backups alpine \
sh -c "tar xzf /backups/files_2026-07-23_030000.tar.gz -C /data"
```
Danach Web-App wieder starten: `docker start lageplan-web`.
---
## Restore-Test (mindestens 1× durchführen und hier notieren)
Ein Backup ist erst dann ein Backup, wenn ein Restore nachweislich funktioniert.
Empfehlung: **halbjährlich** auf einer Testumgebung durchspielen.
| Datum | Getestet von | Ergebnis | Bemerkung |
|-------|--------------|----------|-----------|
| | | | |
---
## Was ist NICHT im Backup?
- Zahlungsdaten liegen bei Stripe (nicht in der App).
- E-Mails liegen beim SMTP-Anbieter.
- Secrets (`NEXTAUTH_SECRET`, MinIO-Keys) — separat sicher aufbewahren (Passwort-Manager),
sie stehen nicht in den Backups.
---
# GUI-Backup (verschlüsselt, extern: SFTP / Nextcloud)
Zusätzlich zum lokalen Container-Backup gibt es ein **über die Oberfläche konfigurierbares**,
verschlüsseltes Off-Site-Backup. Als **SERVER_ADMIN**: **Administration → Backup**.
- **Ziele:** SFTP oder Nextcloud (WebDAV).
- **Verschlüsselung:** AES-256-GCM mit einer **Passphrase** (im Passwort-Manager aufbewahren —
ohne sie ist kein Restore möglich). Zugangsdaten werden verschlüsselt in der DB gespeichert.
- **Zeitplan:** aus / täglich / wöchentlich + Aufbewahrung in Tagen (prunt alte Backups am Ziel).
- **Ablauf:** Speichern → **Verbindung testen****Jetzt sichern**. Der automatische Lauf braucht
`CRON_SECRET` (siehe `.env.example`); der interne Scheduler prüft stündlich die Fälligkeit.
Hochgeladen wird eine Datei `lageplan_<zeitstempel>.tar.gz.enc` (enthält `database.dump` + `files/`).
## Restore eines GUI-Backups
```
# 1) Datei vom Ziel herunterladen, dann entschlüsseln (Passphrase bereithalten):
node scripts/decrypt-backup.js lageplan_2026-07-23-03-00-00.tar.gz.enc backup.tar.gz "DEINE-PASSPHRASE"
# 2) Entpacken
tar xzf backup.tar.gz # -> database.dump + files/
# 3) Datenbank wiederherstellen (destruktiv, App vorher stoppen)
docker cp database.dump lageplan-db:/tmp/database.dump
docker exec lageplan-db pg_restore --clean --if-exists --no-owner -U <POSTGRES_USER> -d <POSTGRES_DB> /tmp/database.dump
# 4) Dateien zurück in MinIO (Ordner files/ in den Bucket)
docker run --rm -v minio_data:/data -v "$PWD/files":/src alpine sh -c "cp -r /src/* /data/<MINIO_BUCKET>/ 2>/dev/null || true"
```
Danach App wieder starten. **Restore einmal testen** (Tabelle oben ausfüllen).

View File

@@ -0,0 +1,37 @@
# Backup & Restore — Übersicht
Stand: 2026-07-24. Die ausführliche Betriebsanleitung steht in [`docs/BACKUP.md`](../BACKUP.md).
Dieses Dokument fasst den datenschutz-/compliance-relevanten Stand zusammen.
## Was wird gesichert
- **Datenbank** (PostgreSQL): Konten, Organisationen, Pläne, Journale, Zustimmungen usw.
- **Hochgeladene Dateien** (MinIO): Pläne, Logos, Symbole.
- **Nicht** im Backup: Zahlungsdaten (bei Stripe), E-Mails (beim SMTP-Anbieter), Secrets.
## Zwei Mechanismen
1. **Lokaler Container-Backup** (`backup`-Service): täglich, im Volume `backups_data`, 30 Tage.
Schützt vor Bedienfehlern/Korruption, **nicht** vor Geräteausfall (gleiches Gerät).
2. **GUI-Backup, verschlüsselt & extern** (`Administration → Backup`): SFTP oder Nextcloud/WebDAV,
AES-256-GCM mit Passphrase, Zeitplan + Aufbewahrung. **Off-site** → deckt Geräteausfall ab.
| Aspekt | Zustand |
|---|---|
| Frequenz | täglich (konfigurierbar) |
| Verschlüsselung (extern) | ✅ AES-256-GCM (Passphrase) |
| Verschlüsselung (lokal) | ⚠️ nein (nur lokales Sicherheitsnetz) |
| Aufbewahrung | 30 Tage (konfigurierbar) |
| Zugriffsschutz | Ziel-Zugangsdaten verschlüsselt in DB; Passphrase separat aufbewahren |
| Off-site | ✅ via GUI-Backup (sofern konfiguriert) |
## Restore-Testplan (verbindlich)
Ein Backup gilt erst als funktionierend, wenn ein Restore **nachweislich** geklappt hat.
1. Aktuelles verschlüsseltes Backup vom Ziel laden.
2. Mit `scripts/decrypt-backup.js` + Passphrase entschlüsseln → `tar.gz`.
3. In einer **Testumgebung** DB + Dateien wiederherstellen (siehe `docs/BACKUP.md`).
4. Stichprobe: Login, ein Projekt, ein Journal, ein hochgeladenes Symbol prüfen.
5. Ergebnis in der Tabelle in `docs/BACKUP.md` protokollieren.
6. **Häufigkeit:** mindestens halbjährlich.
> Es wird **nicht** behauptet, dass eine Wiederherstellung funktioniert, solange kein Restore-Test
> durchgeführt und protokolliert wurde.

View File

@@ -0,0 +1,44 @@
# Bestandsaufnahme & Audit — Lageplan
Stand: 2026-07-24 · Prüfung des tatsächlichen Repository-Stands vor gezielten Korrekturen.
Legende Status: ✅ umgesetzt · 🟡 teilweise · ⚠️ widersprüchlich/prüfen · ❌ fehlend · 🔒 manuell (Fachperson).
Dieser Audit dokumentiert, was aus früheren Runden (v1.7.0v1.9.0) bereits vorhanden ist, damit
**keine Doppelstrukturen** entstehen. Ergänzungen dieser Runde sind in der letzten Spalte markiert.
| Bereich | Fundstelle | Aktueller Zustand | Risiko | Empfohlene/umgesetzte Änderung | Umgesetzt |
|---|---|---|---|---|---|
| Zentrale Legal-Config | `src/config/legal.ts` | Betreiber (Perparim Ziberi, Privatperson, Villmergen), Kontakt, Recht, Subprozessoren, Dok-Versionen | — | operatorType + fksAttribution ergänzt | ✅ (erweitert) |
| Impressum | `src/app/impressum` | Privatperson, echte Adresse; verweist auf Datenschutz | niedrig | Haftungsverweis auf NB prüfen | ✅ vorhanden |
| Datenschutz | `src/app/datenschutz` | Neuaufbau, Subprozessoren-Tabelle (#dienstleister), IP/UA-Nachweis, Auslandbekanntgabe | niedrig | Quellen-/Lizenz-Link ergänzt | ✅ vorhanden |
| Nutzungsbedingungen | `src/app/nutzungsbedingungen` | Abschnitte 114 vorhanden | 🟡 | FKS-Abschnitt ergänzt | ✅ (FKS ergänzt) |
| Verantwortungsvolle Nutzung | `src/app/verantwortungsvolle-nutzung` | vorhanden | niedrig | — | ✅ |
| Sicherheit (öffentlich) | `src/app/sicherheit` | TOM-Kurzfassung, 2FA | niedrig | — | ✅ |
| Unterstützen | `src/app/unterstuetzen` + `/spenden` | „Freiwilliger Unterstützungsbeitrag", Gebührenhinweis, keine Steuerabzug-Behauptung | niedrig | „Feature"-Tiers geprüft | ✅ |
| Org-Datenschutzvereinbarung (AVV) | `src/app/organisationen/datenschutzvereinbarung` | Entwurf, als Vereinbarung formuliert | 🟡 | Entwurfscharakter klar | ✅ |
| Konto & Datenschutz | `src/app/konto/datenschutz` | Zustimmungen, Export-/Löschanfrage, Kontakt | niedrig | — | ✅ |
| Admin Rechtsdokumente | `src/app/admin/legal` | Versionen, Zustimmungszähler, Suche, CSV, IP/UA/Hash | niedrig | — | ✅ |
| Consent-Datenmodell | `LegalDocument`, `LegalAcceptance` | versioniert, contentHash, IP/UA, unique, revisionssicher | niedrig | FKS_ATTRIBUTION-Typ ergänzt | ✅ (erweitert) |
| Registrierung | `src/app/register` + API | 2 getrennte Zustimmungen (NB akzeptieren / Datenschutz Kenntnis), keine Vorauswahl, Version+Hash+IP/UA | niedrig | — | ✅ |
| Re-Consent | `LegalConsentGate` | blockierend, Org-Bestätigung 7 Checks, Abmelden/Docs erreichbar | niedrig | — | ✅ |
| In-App-Hinweise | Dashboard/Messpanel/Datenschutz | Dashboard-Banner, Berechnungs-Hinweis, Geodaten-Hinweis | 🟡 | Journal-Freitext-Warnung ergänzt | ✅ (ergänzt) |
| Export-Disclaimer | `src/hooks/use-map-export.ts` | Prüf-Hinweis PDF+PNG | 🟡 | FKS-Quellenangabe ergänzt | ✅ (ergänzt) |
| **FKS-/FEUKOS-Quellenangabe** | Footer, `licenses-and-attributions.md` | nur Footer + interne Doku | ⚠️ unvollständig | Quellen-Seite, Symbol-Panel, NB, Export, Dok-Typ | ✅ (diese Runde) |
| Quellen-und-Lizenzen-Seite | — | ❌ fehlte | mittel | `/quellen-und-lizenzen` erstellt | ✅ (neu) |
| **Planstatus/Freigabe** | `prisma/schema.prisma` | ❌ keine Status-/Freigabefelder | mittel | Entwurf/Geprüft/Freigegeben + Prüfdatum | 🟡 siehe Bericht |
| Sicherheitsheader | `next.config.js` | CSP, HSTS, nosniff, Referrer/Permissions-Policy, frame-ancestors | niedrig | CSP unsafe-inline (MapLibre) dokumentiert | ✅ |
| MFA/WebAuthn | `src/lib/mfa.ts`, `webauthn.ts` | TOTP + Passkeys/YubiKey + Backup-Codes + Admin-Reset | niedrig | — | ✅ |
| Rate Limiting | `src/lib/rate-limit.ts` | Login/Register/Reset/Kontakt/Share/… | 🟡 (In-Memory) | — | ✅ |
| Mandantentrennung | `getProjectWithTenantCheck` | serverseitig erzwungen | mittel | automatisierte Tests ergänzt | 🟡 Tests (diese Runde) |
| Backup | Container + GUI (`/admin/backup`) | lokal + verschlüsselt SFTP/WebDAV, 30 Tage | niedrig | backup-and-restore.md ergänzt | ✅ |
| Docs (Compliance) | `docs/legal/` | inventory, subprocessors, TOM, retention, incident, licenses, claims-audit | 🟡 | +international-transfers, +privacy-risk, +manual-checklist, +this audit | ✅ (ergänzt) |
## Verbotene/absolute Aussagen — Nachkontrolle
Frühere Runde entfernte 24/7, 100 %, DSG/DSGVO-konform, „keine Weitergabe", „nur Schweizer Server",
„100 % der Spende", „Kein Unternehmen". Diese Runde prüft zusätzlich FKS-Terminologie
(„offiziell/zertifiziert/Partner/empfohlen") → keine solchen Behauptungen zulässig.
## Offen / manuell (siehe manual-action-checklist.md)
Juristische Schlussprüfung, spätere Vereinsgründung (nur als Option), Versicherungen, endgültige
Server-/Backupstandorte externer Dienste, Aufbewahrungsfristen-Entscheide, ergänzende FKS-Bestätigung
für Rohdatei-/API-Weitergabe, periodischer externer Sicherheitstest.

View File

@@ -9,7 +9,7 @@ Abgeleitet aus dem tatsächlichen Code-Stand. Offene Fristen/Standorte als Platz
| Passwort (bcrypt-Hash) | Authentifizierung | Benutzer | Eingabe | PostgreSQL | — | `[SERVERSTANDORT]` | bis Kontolöschung | mit Konto | Einweg-Hash (bcrypt cost 12) | | Passwort (bcrypt-Hash) | Authentifizierung | Benutzer | Eingabe | PostgreSQL | — | `[SERVERSTANDORT]` | bis Kontolöschung | mit Konto | Einweg-Hash (bcrypt cost 12) |
| Einsatz-/Inhaltsdaten (Pläne, Journale, Zeichnungen, Koordinaten) | Kernfunktion | Org-Mitglieder, ggf. Dritte | Eingabe | PostgreSQL | — | `[SERVERSTANDORT]` | bis Löschung durch Org | Projekt-/Orglöschung; Backups | Mandantentrennung, Zugriffskontrolle | | Einsatz-/Inhaltsdaten (Pläne, Journale, Zeichnungen, Koordinaten) | Kernfunktion | Org-Mitglieder, ggf. Dritte | Eingabe | PostgreSQL | — | `[SERVERSTANDORT]` | bis Löschung durch Org | Projekt-/Orglöschung; Backups | Mandantentrennung, Zugriffskontrolle |
| Hochgeladene Dateien (Logos, Planbilder, Symbole) | Darstellung | Org | Upload | MinIO | — | `[SERVERSTANDORT]` | bis Löschung | mit Projekt/Org | Zugriffsschutz, Validierung | | Hochgeladene Dateien (Logos, Planbilder, Symbole) | Darstellung | Org | Upload | MinIO | — | `[SERVERSTANDORT]` | bis Löschung | mit Projekt/Org | Zugriffsschutz, Validierung |
| Zustimmungsnachweise (Doc-Typ/Version/Zeitpunkt/Kontext) | Nachweis Einwilligung/Vertrag | Benutzer | System | PostgreSQL | — | `[SERVERSTANDORT]` | Aufbewahrung zu Nachweiszwecken `[FRIST]` | revisionssicher, nicht überschrieben | minimale Daten (keine roh-IP) | | Zustimmungsnachweise (Doc-Typ/Version/Zeitpunkt/Kontext, IP, User-Agent, Hash) | Nachweis Einwilligung/Vertrag (Beweiszweck) | Benutzer | System | PostgreSQL (Schweiz, Eigenbetrieb) | — | nein | Aufbewahrung zu Nachweiszwecken `[FRIST]` | revisionssicher, nicht überschrieben | Zugriffskontrolle; IP/User-Agent bewusst zu Beweiszwecken erfasst |
| Protokoll-/Sicherheitsdaten | Betrieb, Sicherheit | Benutzer | System | Server-Logs | — | `[SERVERSTANDORT]` | `[FRIST]` | rotierend | Minimierung sensibler Inhalte | | Protokoll-/Sicherheitsdaten | Betrieb, Sicherheit | Benutzer | System | Server-Logs | — | `[SERVERSTANDORT]` | `[FRIST]` | rotierend | Minimierung sensibler Inhalte |
| Session-Cookie `auth-token` | Anmeldung | Benutzer | System | Client-Cookie | — | — | 24 h / 14 Tage | Ablauf/Logout | httpOnly, secure (prod), sameSite=lax | | Session-Cookie `auth-token` | Anmeldung | Benutzer | System | Client-Cookie | — | — | 24 h / 14 Tage | Ablauf/Logout | httpOnly, secure (prod), sameSite=lax |
| E-Mail-Versand (Verifizierung, Reset, Kontakt, Einladung) | Kommunikation | Benutzer/Empfänger | System/Eingabe | SMTP-Anbieter | E-Mail-Anbieter | `[ABHÄNGIG]` | beim Anbieter | — | TLS, nur bei Bedarf | | E-Mail-Versand (Verifizierung, Reset, Kontakt, Einladung) | Kommunikation | Benutzer/Empfänger | System/Eingabe | SMTP-Anbieter | E-Mail-Anbieter | `[ABHÄNGIG]` | beim Anbieter | — | TLS, nur bei Bedarf |

View File

@@ -0,0 +1,27 @@
# Auslandbearbeitungen — Lageplan
Stand: 2026-07-24 · sorgfältiger Entwurf. Unterscheidet **Unternehmenssitz** und **Serverregion**.
Klassifizierung: 🇨🇭 Schweiz · 🇪🇺 EU/EWR · ✅ anerkanntes Datenschutzniveau · ⚠️ zusätzliche
vertragliche/technische Massnahmen prüfen · ❓ nicht abschliessend geklärt.
| Dienst | Funktion | Datenkategorien | Unternehmenssitz | Serverregion | Klassifizierung | Bemerkung |
|---|---|---|---|---|---|---|
| PostgreSQL (Eigenbetrieb) | Primäre Datenhaltung | Konto/Org/Plan/Journal | Schweiz (Betreiber) | Schweiz | 🇨🇭 | selbst gehostet |
| MinIO (Eigenbetrieb) | Datei-Uploads | Pläne/Logos/Symbole | Schweiz (Betreiber) | Schweiz | 🇨🇭 | selbst gehostet |
| OpenStreetMap Foundation | Tiles/Nominatim | Kachel/Adresse/IP | UK | UK/EU | ✅/⚠️ | clientseitig, IP-Übermittlung |
| swisstopo | Landeskarte | Kachel/IP | Schweiz | Schweiz | 🇨🇭 | clientseitig |
| Esri (ArcGIS) | Satellitenbilder | Kachel/IP | USA | USA/global | ⚠️ | clientseitig; Bedingungen prüfen |
| Open-Meteo | Wind/Höhe | Koordinaten/IP | Deutschland | EU | 🇪🇺 | clientseitig |
| Open-Elevation | Höhe (Fallback) | Koordinaten/IP | ❓ | ❓ | ❓ | Community-Dienst; Region unklar |
| Stripe (optional) | Zahlungen | Betrag/Name/Zahlungsdaten | USA (EU-Töchter) | USA/EU | ⚠️ | nur wenn konfiguriert; DPA prüfen |
| SMTP-Anbieter (optional) | E-Mails | Empfänger/Inhalt | abhängig | abhängig | ⚠️/❓ | vom Betreiber gewählt |
## Grundsätze
- Die öffentliche Datenschutzerklärung macht **keine** pauschale „nur Schweiz"-Garantie; Auslandbearbeitung
(v.a. Stripe, E-Mail, globale Kartendienste) wird transparent benannt.
- Client-seitige Karten-/Geodienste erhalten die IP-Adresse des Nutzers direkt.
## Offen / manuell (Betreiber)
- DPA/Standardvertragsklauseln mit Stripe und dem gewählten E-Mail-Anbieter prüfen/abschliessen.
- Serverregion von Esri/Open-Elevation konkretisieren oder Dienst ggf. ersetzen.
- Entscheidung, ob der Open-Elevation-Fallback (unklare Region) deaktiviert werden soll.

View File

@@ -1,8 +1,23 @@
# Lizenzen und Attributionen — Lageplan # Lizenzen und Attributionen — Lageplan
Stand: 2026-07-23 · sorgfältiger Entwurf. Eine Quellenangabe ersetzt NICHT automatisch eine Stand: 2026-07-24 · sorgfältiger Entwurf. Eine Quellenangabe ersetzt NICHT automatisch eine
erforderliche Nutzungslizenz. Offene Freigabefragen sind markiert. erforderliche Nutzungslizenz. Offene Freigabefragen sind markiert.
## Feuerwehr Koordination Schweiz FKS — schriftliche Genehmigung
- **Organisation:** Feuerwehr Koordination Schweiz FKS
- **Inhalte:** FEUKOS-Markierungen, -Symbole und bewilligtes Bildmaterial
- **Datum der Freigabe:** 17. Februar 2026
- **Quellenangabe (exakt):** „Feuerwehr Koordination Schweiz FKS"
- **Projektbezug:** Verwendung innerhalb des beschriebenen Webprojekts lageplan.ch
- **Archivierung:** Das Original (E-Mail) ist **ausserhalb des öffentlichen Repositorys** abgelegt
(siehe `private-legal-evidence/`, in `.gitignore`). Es wird **nicht** committet; keine Namen/
E-Mail-Adressen einzelner FKS-Mitarbeitender werden veröffentlicht.
- **Kein** Anspruch auf offizielle Partnerschaft, Zertifizierung, Prüfung, behördliche Anerkennung
oder Empfehlung von lageplan.ch durch die FKS.
- **Offene Frage (manuell):** Falls Rohdatei-Downloads, API-Nutzung oder Weitergabe der Symbol-
bibliothek an Dritte **nicht** Teil der ursprünglichen Projektbeschreibung waren, ist eine
ergänzende FKS-Bestätigung einzuholen (siehe `manual-action-checklist.md`).
## Karten & Geodaten ## Karten & Geodaten
| Quelle | Nutzung | Lizenz/Bedingungen | Attribution | Offene Frage | | Quelle | Nutzung | Lizenz/Bedingungen | Attribution | Offene Frage |
|---|---|---|---|---| |---|---|---|---|---|
@@ -15,7 +30,7 @@ erforderliche Nutzungslizenz. Offene Freigabefragen sind markiert.
## Symbole / Icons ## Symbole / Icons
| Quelle | Nutzung | Lizenz | Attribution | Offene Frage | | Quelle | Nutzung | Lizenz | Attribution | Offene Frage |
|---|---|---|---|---| |---|---|---|---|---|
| FKS/BABS taktische Zeichen | Einsatzsymbole | `[NUTZUNGSRECHT ZU KLÄREN]` | "Bildquelle Feuerwehr Koordination Schweiz FKS" (vorhanden) | ⚠️ **Nutzungsrecht/Freigabe FKS bestätigen** | | FEUKOS-/FKS-Symbole | Einsatzsymbole | Schriftliche Genehmigung FKS (17.02.2026) | "Bildquelle: Feuerwehr Koordination Schweiz FKS" | Rohdatei-/API-Weitergabe ggf. nicht abgedeckt (siehe unten) |
| lucide-react | UI-Icons | ISC | — (im Code) | ok | | lucide-react | UI-Icons | ISC | — (im Code) | ok |
## Schriftarten ## Schriftarten

View File

@@ -0,0 +1,39 @@
# Manuelle Restpunkte — was Code nicht lösen kann
Stand: 2026-07-24. Diese Punkte sind **nicht** durch die Software erledigt und dürfen **nicht** als
erledigt dargestellt werden. Sie erfordern Entscheidungen, Verträge oder externe Prüfungen.
## Rechtlich
- [ ] **Juristische Schlussprüfung** aller Rechtsseiten (Nutzungsbedingungen, Datenschutz, AVV, FKS-
Abschnitt) durch eine qualifizierte Schweizer Fachperson.
- [ ] Entscheidung über eine **spätere Vereinsgründung** (aktuell nur offene Option — Betreiber bleibt
Privatperson Perparim Ziberi). Kein Verein behaupten, solange nicht gegründet.
- [ ] Tatsächliche **Übertragung auf einen Verein** erst nach dessen Gründung (dann Betreiberangaben,
Impressum, AVV, Config anpassen).
- [ ] **Steuerliche Behandlung** freiwilliger Unterstützungsbeiträge klären.
## FKS / Bildmaterial
- [ ] Original der FKS-Freigabe (17.02.2026) **ausserhalb** des Repos archivieren (`private-legal-evidence/`).
- [ ] Prüfen, ob **Rohdatei-Download, API-Nutzung oder Weitergabe** der Symbolbibliothek an Dritte von der
ursprünglichen Projektbeschreibung gedeckt ist; falls nicht → **ergänzende FKS-Bestätigung** einholen.
## Versicherung
- [ ] IT-/Vermögensschaden-Haftpflicht prüfen (Offerten einholen).
- [ ] Cyberversicherung prüfen.
- [ ] Bei späterem Verein: Vereins-/Rechtsschutz prüfen.
## Verträge & Standorte
- [ ] Formelle Vereinbarungen mit Gemeinden/Organisationen (falls gewünscht) — die elektronische
Organisationsbestätigung ist **kein** beidseitig unterzeichneter AVV.
- [ ] Endgültige **Server- und Backupstandorte** der externen Dienste bestätigen (Esri, Stripe, SMTP,
Open-Elevation) — siehe `international-data-transfers.md`.
- [ ] **DPA/Standardvertragsklauseln** mit Stripe und dem gewählten E-Mail-Anbieter abschliessen.
## Aufbewahrung & Betrieb
- [ ] Konkrete **Aufbewahrungsfristen** je Datenkategorie festlegen (siehe `retention-and-deletion.md`).
- [ ] **Restore-Test** durchführen und in `docs/BACKUP.md` protokollieren.
- [ ] Periodischen **externen Sicherheitstest / Pentest** beauftragen.
## Datenschutz
- [ ] Prüfen lassen, ob eine **vertiefte DSFA** erforderlich ist (siehe `privacy-risk-assessment.md`).
- [ ] `datenschutz@lageplan.ch` als **aktiv überwachtes** Postfach bestätigen.

View File

@@ -0,0 +1,24 @@
# Datenschutz-Risikovorprüfung — Lageplan
Stand: 2026-07-24 · sorgfältiger Entwurf. Vorprüfung, **keine** abschliessende Datenschutz-
Folgenabschätzung (DSFA). Ob eine vertiefte DSFA nötig ist, muss eine Fachperson beurteilen —
dies wird hier **nicht** pauschal verneint.
Skala: Wahrscheinlichkeit/Auswirkung = niedrig / mittel / hoch.
| # | Risiko | Wahrsch. | Auswirkung | Bestehende Massnahmen | Restrisiko | Weitere Massnahme | Rolle |
|---|---|---|---|---|---|---|---|
| 1 | Offenlegung von Gebäude-/Zufahrts-/Gefahren-/Hydranten-/Schlüsselstellen-Infos | mittel | hoch | Mandantentrennung, Rollen, Zugriffskontrolle, Verbot sensibler Daten (NB) | mittel | Org-Sensibilisierung, ggf. Feldklassifizierung | Betreiber/Org |
| 2 | Organisationsübergreifender Zugriff (IDOR) | niedrig | hoch | `getProjectWithTenantCheck`, serverseitige Prüfung, autom. Tests | niedrig | periodische Tests/Pentest | Betreiber |
| 3 | Datenleck über Dateizugriffe | niedrig | hoch | serve-Routen mit Tenant-Check, MinIO nicht öffentlich für private Objekte | niedrig | Pentest, Bucket-Policy-Review | Betreiber |
| 4 | Kompromittierung Adminkonto | niedrig | hoch | MFA (TOTP/WebAuthn), Rate-Limiting, bcrypt | niedrig | MFA für alle Admins erzwingen (Option) | Betreiber |
| 5 | Auslandbearbeitung (Stripe/E-Mail/Karten) | mittel | mittel | Transparente Doku, optionale Dienste, IP nur clientseitig | mittel | DPA/SCC prüfen | Betreiber |
| 6 | Standortdaten (GPS) der Nutzer | niedrig | mittel | nur zur Kartenfunktion, keine dauerhafte Speicherung durch App | niedrig | Doku bestätigen | Betreiber |
| 7 | Besonders schützenswerte Daten in Freitext/Journal | mittel | hoch | NB-Verbot, In-App-Warnungen (Journal, Dashboard), Org-Bestätigung | mittel | ggf. Muster-Erkennung/Hinweis | Betreiber/Org |
| 8 | Verlust/Nichtverfügbarkeit von Daten | niedrig | mittel | Backups (lokal + verschlüsselt off-site) | niedrig | Restore-Test durchführen | Betreiber |
| 9 | Datenleck durch Drittanbieter-Subprozessor | niedrig | mittel | minimale Datenweitergabe, Subprozessorenliste | mittel | Anbieterprüfung dokumentieren | Betreiber |
| 10 | Fehlende/veraltete fachliche Prüfung eines Plans | mittel | hoch | Hinweise in App/Export; Planstatus (in Umsetzung) | mittel | Freigabe-Workflow finalisieren | Org |
## Empfehlung
Eine **vertiefte DSFA** sollte durch eine qualifizierte Fachperson geprüft werden — insbesondere wegen
sicherheitsrelevanter Objekt-/Infrastrukturdaten (Risiko 1, 3, 7). Diese Vorprüfung ersetzt sie nicht.

View File

@@ -6,7 +6,8 @@ Legende: ✅ vorhanden · ⚠️ teilweise / zu bestätigen · ❌ offen / nicht
## Zugriff & Berechtigungen ## Zugriff & Berechtigungen
- ✅ Mandantentrennung je Organisation (Tenant), `getProjectWithTenantCheck`. - ✅ Mandantentrennung je Organisation (Tenant), `getProjectWithTenantCheck`.
- ✅ Rollenkonzept: SERVER_ADMIN, TENANT_ADMIN, OPERATOR, VIEWER. - ✅ Rollenkonzept: SERVER_ADMIN, TENANT_ADMIN, OPERATOR, VIEWER.
- ⚠️ MFA für System-/Org-Admins: ❌ nicht vorhanden → **Empfehlung offen**. - MFA/Zwei-Faktor verfügbar: TOTP (Authenticator-App) + WebAuthn (YubiKey/Passkeys), freiwillig
pro Benutzer; Admin kann MFA zurücksetzen. Backup-Codes als Fallback. (SMS bewusst nicht umgesetzt.)
- ✅ Admin-APIs serverseitig mit `isAdmin`/`isServerAdmin` geschützt. - ✅ Admin-APIs serverseitig mit `isAdmin`/`isServerAdmin` geschützt.
## Authentifizierung & Sessions ## Authentifizierung & Sessions
@@ -35,8 +36,10 @@ Legende: ✅ vorhanden · ⚠️ teilweise / zu bestätigen · ❌ offen / nicht
- ⚠️ Secrets-Verwaltung: ENV (`NEXTAUTH_SECRET`, MinIO) + DB (`SystemSetting`: SMTP/Stripe) → **Absicherung der DB-Secrets bestätigen**. - ⚠️ Secrets-Verwaltung: ENV (`NEXTAUTH_SECRET`, MinIO) + DB (`SystemSetting`: SMTP/Stripe) → **Absicherung der DB-Secrets bestätigen**.
## Backup & Wiederherstellung ## Backup & Wiederherstellung
- ⚠️ Backups: `[VORHANDEN? FREQUENZ? AUFBEWAHRUNG?]` (siehe `docs/backup-und-seeding.md`). - ✅ Automatisches Backup (eigener `backup`-Container): täglich Postgres-Dump + MinIO-Dateien,
- ❌ Restore-Tests: `[DOKUMENTIEREN]`. 30 Tage Aufbewahrung (`docker-compose.yml` / `deploy/portainer-stack.yml`, Anleitung `docs/BACKUP.md`).
- ⚠️ Off-Site-Kopie: empfohlen (Backups zusätzlich an einen zweiten Ort spiegeln) — siehe `docs/BACKUP.md`.
- ⚠️ Restore-Test: mindestens 1× durchführen und in `docs/BACKUP.md` protokollieren.
## Logging & Monitoring ## Logging & Monitoring
- ✅ Server-Logs (console). ⚠️ Vermeidung sensibler Daten in Logs → **Review empfohlen**. - ✅ Server-Logs (console). ⚠️ Vermeidung sensibler Daten in Logs → **Review empfohlen**.

View File

@@ -6,6 +6,9 @@ const nextConfig = {
APP_VERSION: packageJson.version, APP_VERSION: packageJson.version,
}, },
output: 'standalone', output: 'standalone',
// Node-Libs für das Backup nicht bundeln, sondern zur Laufzeit aus node_modules laden
// (ssh2 hat optionale native Module; webdav/archiver sind reine Server-Abhängigkeiten).
serverExternalPackages: ['ssh2', 'ssh2-sftp-client', 'archiver', 'webdav'],
async headers() { async headers() {
return [ return [
{ {

1381
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "lageplan", "name": "lageplan",
"version": "1.7.7", "version": "1.9.1",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -43,6 +43,9 @@
"@radix-ui/react-toast": "^1.2.0", "@radix-ui/react-toast": "^1.2.0",
"@radix-ui/react-tooltip": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.0",
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"archiver": "^8.0.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.0", "clsx": "^2.1.0",
@@ -54,6 +57,7 @@
"minio": "^7.1.3", "minio": "^7.1.3",
"next": "^15.1.0", "next": "^15.1.0",
"nodemailer": "^6.9.8", "nodemailer": "^6.9.8",
"otplib": "^12.0.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"rdndmb-html5-to-touch": "^9.0.0", "rdndmb-html5-to-touch": "^9.0.0",
"react": "^19.0.0", "react": "^19.0.0",
@@ -65,10 +69,12 @@
"react-moveable": "^0.56.0", "react-moveable": "^0.56.0",
"socket.io": "^4.7.4", "socket.io": "^4.7.4",
"socket.io-client": "^4.7.4", "socket.io-client": "^4.7.4",
"ssh2-sftp-client": "^12.1.1",
"stripe": "^20.3.1", "stripe": "^20.3.1",
"tailwind-merge": "^2.2.1", "tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"webdav": "^5.10.0",
"zod": "^3.22.4", "zod": "^3.22.4",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
@@ -76,11 +82,13 @@
"seed": "node prisma/seed.js" "seed": "node prisma/seed.js"
}, },
"devDependencies": { "devDependencies": {
"@types/archiver": "^8.0.0",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@types/ssh2-sftp-client": "^9.0.6",
"@types/uuid": "^9.0.7", "@types/uuid": "^9.0.7",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.17",
"eslint": "^8.56.0", "eslint": "^8.56.0",

View File

@@ -183,6 +183,24 @@ async function migrate() {
)`, )`,
`CREATE INDEX IF NOT EXISTS legal_acceptances_user_idx ON legal_acceptances("userId")`, `CREATE INDEX IF NOT EXISTS legal_acceptances_user_idx ON legal_acceptances("userId")`,
`CREATE INDEX IF NOT EXISTS legal_acceptances_doc_idx ON legal_acceptances("documentType", "documentVersion")`, `CREATE INDEX IF NOT EXISTS legal_acceptances_doc_idx ON legal_acceptances("documentType", "documentVersion")`,
`ALTER TABLE legal_acceptances ADD COLUMN IF NOT EXISTS "ipAddress" TEXT`,
`ALTER TABLE legal_acceptances ADD COLUMN IF NOT EXISTS "userAgent" TEXT`,
// MFA / Zwei-Faktor
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "totpSecret" TEXT`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "totpConfirmedAt" TIMESTAMP(3)`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS "mfaBackupCodes" TEXT[] NOT NULL DEFAULT '{}'`,
`CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
"userId" TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
"credentialId" TEXT NOT NULL UNIQUE,
"publicKey" TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
transports TEXT,
"deviceName" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastUsedAt" TIMESTAMP(3)
)`,
`CREATE INDEX IF NOT EXISTS webauthn_credentials_user_idx ON webauthn_credentials("userId")`,
] ]
for (const sql of tableMigrations) { for (const sql of tableMigrations) {
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ } try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ }
@@ -199,6 +217,7 @@ async function migrate() {
{ type: 'ORGANIZATION_DECLARATION', version: '1.0.0', title: 'Organisationsbestätigung', url: '/verantwortungsvolle-nutzung', publishedAt: '2026-07-23' }, { type: 'ORGANIZATION_DECLARATION', version: '1.0.0', title: 'Organisationsbestätigung', url: '/verantwortungsvolle-nutzung', publishedAt: '2026-07-23' },
{ type: 'RESPONSIBLE_USE', version: '1.0.0', title: 'Verantwortungsvolle Nutzung', url: '/verantwortungsvolle-nutzung', publishedAt: '2026-07-23' }, { type: 'RESPONSIBLE_USE', version: '1.0.0', title: 'Verantwortungsvolle Nutzung', url: '/verantwortungsvolle-nutzung', publishedAt: '2026-07-23' },
{ type: 'DATA_PROCESSING_AGREEMENT', version: '1.0.0', title: 'Datenschutzvereinbarung für Organisationen', url: '/organisationen/datenschutzvereinbarung', publishedAt: '2026-07-23' }, { type: 'DATA_PROCESSING_AGREEMENT', version: '1.0.0', title: 'Datenschutzvereinbarung für Organisationen', url: '/organisationen/datenschutzvereinbarung', publishedAt: '2026-07-23' },
{ type: 'FKS_ATTRIBUTION', version: '1.0.0', title: 'FKS-Quellenangabe & Nutzungshinweis', url: '/quellen-und-lizenzen', publishedAt: '2026-07-24' },
] ]
for (const doc of LEGAL_DOCS) { for (const doc of LEGAL_DOCS) {
const contentHash = crypto.createHash('sha256').update(`${doc.type}@${doc.version}`).digest('hex') const contentHash = crypto.createHash('sha256').update(`${doc.type}@${doc.version}`).digest('hex')

View File

@@ -126,6 +126,12 @@ model User {
resetToken String? @unique resetToken String? @unique
resetTokenExpiry DateTime? resetTokenExpiry DateTime?
lastLoginAt DateTime? lastLoginAt DateTime?
// ─── Zwei-Faktor-Authentisierung (MFA) ───
// TOTP (Authenticator-App): Secret gesetzt = Einrichtung begonnen; confirmedAt gesetzt = aktiv.
totpSecret String?
totpConfirmedAt DateTime?
// Einmalige Backup-Codes (bcrypt-gehasht), Fallback wenn kein Faktor verfügbar.
mfaBackupCodes String[] @default([])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -135,10 +141,29 @@ model User {
upgradeRequests UpgradeRequest[] upgradeRequests UpgradeRequest[]
rapports Rapport[] rapports Rapport[]
legalAcceptances LegalAcceptance[] legalAcceptances LegalAcceptance[]
webauthnCredentials WebAuthnCredential[]
@@map("users") @@map("users")
} }
// WebAuthn/FIDO2 (YubiKey, Passkeys, Touch-ID) — registrierte Sicherheitsschlüssel je Benutzer.
model WebAuthnCredential {
id String @id @default(uuid())
userId String
credentialId String @unique
publicKey String
counter Int @default(0)
transports String?
deviceName String?
createdAt DateTime @default(now())
lastUsedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("webauthn_credentials")
}
// ─── Rechtsdokumente & revisionssichere Zustimmungen ────────── // ─── Rechtsdokumente & revisionssichere Zustimmungen ──────────
// Dokumenttypen (als String gehalten, damit die idempotente Raw-SQL-Migration einfach bleibt): // Dokumenttypen (als String gehalten, damit die idempotente Raw-SQL-Migration einfach bleibt):
// TERMS | PRIVACY | ORGANIZATION_DECLARATION | RESPONSIBLE_USE | DATA_PROCESSING_AGREEMENT // TERMS | PRIVACY | ORGANIZATION_DECLARATION | RESPONSIBLE_USE | DATA_PROCESSING_AGREEMENT
@@ -173,6 +198,9 @@ model LegalAcceptance {
locale String? locale String?
// REGISTRATION | LOGIN_RECONSENT | ORGANIZATION_CREATION | ADMIN_ROLE_ACCEPTANCE | DONATION // REGISTRATION | LOGIN_RECONSENT | ORGANIZATION_CREATION | ADMIN_ROLE_ACCEPTANCE | DONATION
context String context String
// Nachweisdaten (zu Beweiszwecken der Einwilligung): IP + Gerät/Browser zum Zeitpunkt der Zustimmung.
ipAddress String?
userAgent String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)

51
scripts/decrypt-backup.js Normal file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env node
/**
* Entschlüsselt ein Lageplan-Backup (.tar.gz.enc) → .tar.gz
*
* Verwendung:
* node scripts/decrypt-backup.js <input.tar.gz.enc> <output.tar.gz> [passphrase]
* (ohne Passphrase-Argument wird sie interaktiv abgefragt bzw. aus BACKUP_PASSPHRASE gelesen)
*
* Danach:
* tar xzf output.tar.gz → enthält database.dump + files/<uploads>
* pg_restore --clean --if-exists -d <DB> database.dump
* files/ zurück in MinIO spielen (siehe docs/BACKUP.md)
*
* Format der .enc-Datei: [salt(16)][iv(12)][ciphertext][tag(16)], AES-256-GCM, Key = scrypt(passphrase, salt).
*/
const fs = require('fs')
const crypto = require('crypto')
const [inPath, outPath] = process.argv.slice(2)
let passphrase = process.argv[4] || process.env.BACKUP_PASSPHRASE
if (!inPath || !outPath) {
console.error('Verwendung: node decrypt-backup.js <input.tar.gz.enc> <output.tar.gz> [passphrase]')
process.exit(1)
}
if (!passphrase) {
console.error('Passphrase fehlt. Als 3. Argument oder via BACKUP_PASSPHRASE übergeben.')
process.exit(1)
}
const size = fs.statSync(inPath).size
if (size < 44) { console.error('Datei zu klein / kein gültiges Backup.'); process.exit(1) }
const fd = fs.openSync(inPath, 'r')
const head = Buffer.alloc(28)
fs.readSync(fd, head, 0, 28, 0)
const tag = Buffer.alloc(16)
fs.readSync(fd, tag, 0, 16, size - 16)
fs.closeSync(fd)
const salt = head.subarray(0, 16)
const iv = head.subarray(16, 28)
const key = crypto.scryptSync(passphrase, salt, 32)
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(tag)
const input = fs.createReadStream(inPath, { start: 28, end: size - 17 })
const output = fs.createWriteStream(outPath)
input.pipe(decipher).pipe(output)
output.on('finish', () => console.log('OK →', outPath))
decipher.on('error', (e) => { console.error('Entschlüsselung fehlgeschlagen (falsche Passphrase?):', e.message); process.exit(1) })

44
scripts/restore.sh Normal file
View File

@@ -0,0 +1,44 @@
#!/bin/sh
# =============================================================
# Lageplan Restore-Helfer
# =============================================================
# Stellt einen Datenbank-Dump (und optional die Dateien) wieder her.
# ACHTUNG: Der DB-Restore ist DESTRUKTIV bestehende Daten werden ersetzt.
# Vorher die Web-App stoppen und sicherstellen, dass niemand arbeitet.
#
# Verwendung (auf dem Server, im Verzeichnis mit der docker-compose.yml):
#
# 1) Verfügbare Backups anzeigen:
# docker compose exec backup ls -lh /backups
#
# 2) Datenbank wiederherstellen (Dateiname aus Schritt 1):
# docker compose exec backup sh /scripts/restore.sh db_2026-07-23_030000.dump
# (funktioniert nur, wenn dieses Verzeichnis in den backup-Container gemountet ist;
# sonst den Befehl aus dem Abschnitt "Ohne Skript" in docs/BACKUP.md nutzen.)
#
# 3) Dateien (Uploads) wiederherstellen: siehe docs/BACKUP.md
# =============================================================
set -eu
DUMP="${1:-}"
BACKUP_DIR="${BACKUP_DIR:-/backups}"
PGHOST="${PGHOST:-db}"
PGUSER="${PGUSER:-lageplan}"
PGDATABASE="${PGDATABASE:-lageplan}"
if [ -z "$DUMP" ]; then
echo "Verwendung: restore.sh <db_dumpfile>"
echo "Verfuegbare Dumps in $BACKUP_DIR:"
ls -1 "$BACKUP_DIR"/db_*.dump 2>/dev/null || echo " (keine gefunden)"
exit 1
fi
FILE="$BACKUP_DIR/$DUMP"
[ -f "$FILE" ] || { echo "Nicht gefunden: $FILE"; exit 1; }
echo "!! DESTRUKTIV: Stelle '$DUMP' in Datenbank '$PGDATABASE' auf Host '$PGHOST' wieder her."
echo " Bestehende Daten werden ersetzt. Abbruch mit Ctrl+C, weiter mit Enter."
read -r _
pg_restore --clean --if-exists --no-owner -h "$PGHOST" -U "$PGUSER" -d "$PGDATABASE" "$FILE"
echo "[restore] Datenbank wiederhergestellt aus $DUMP"

View File

@@ -71,4 +71,21 @@ app.prepare().then(() => {
httpServer.listen(port, hostname, () => { httpServer.listen(port, hostname, () => {
console.log(`> Ready on http://${hostname}:${port}`) console.log(`> Ready on http://${hostname}:${port}`)
}) })
// Backup-Scheduler: stündlich prüfen, ob laut Zeitplan ein Backup fällig ist.
// Die Route /api/cron/backup entscheidet anhand der Konfiguration; hier wird nur getriggert.
if (process.env.CRON_SECRET) {
const checkBackup = () => {
fetch(`http://127.0.0.1:${port}/api/cron/backup`, {
method: 'POST',
headers: { 'x-cron-secret': process.env.CRON_SECRET },
})
.then((r) => r.json())
.then((d) => { if (d && d.skipped !== true) console.log('[backup-scheduler]', JSON.stringify(d)) })
.catch((e) => console.warn('[backup-scheduler]', e.message))
}
setInterval(checkBackup, 60 * 60 * 1000) // stündlich
setTimeout(checkBackup, 90 * 1000) // ~1.5 Min nach Start einmal
console.log('> Backup-Scheduler aktiv (stündliche Fälligkeitsprüfung)')
}
}) })

View File

@@ -8,7 +8,7 @@ import { useAuth } from '@/components/providers/auth-provider'
interface Doc { id: string; type: string; version: string; title: string; url: string | null; isActive: boolean; publishedAt: string; contentHash: string; acceptanceCount: number } interface Doc { id: string; type: string; version: string; title: string; url: string | null; isActive: boolean; publishedAt: string; contentHash: string; acceptanceCount: number }
interface Miss { type: string; version: string; missing: number } interface Miss { type: string; version: string; missing: number }
interface Acc { id: string; email: string; name: string; documentType: string; documentVersion: string; context: string; acceptedAt: string; organizationId: string | null } interface Acc { id: string; email: string; name: string; documentType: string; documentVersion: string; context: string; acceptedAt: string; organizationId: string | null; ipAddress: string | null; userAgent: string | null; contentHash: string | null }
export default function AdminLegalPage() { export default function AdminLegalPage() {
const { user, loading, isServerAdmin } = useAuth() const { user, loading, isServerAdmin } = useAuth()
@@ -137,6 +137,8 @@ export default function AdminLegalPage() {
<th className="px-3 py-2">Benutzer</th> <th className="px-3 py-2">Benutzer</th>
<th className="px-3 py-2">Dokument</th> <th className="px-3 py-2">Dokument</th>
<th className="px-3 py-2">Kontext</th> <th className="px-3 py-2">Kontext</th>
<th className="px-3 py-2">IP</th>
<th className="px-3 py-2">Gerät / Browser</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -144,8 +146,10 @@ export default function AdminLegalPage() {
<tr key={a.id} className="border-t border-border"> <tr key={a.id} className="border-t border-border">
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">{new Date(a.acceptedAt).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' })}</td> <td className="px-3 py-2 text-muted-foreground whitespace-nowrap">{new Date(a.acceptedAt).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' })}</td>
<td className="px-3 py-2"><span className="text-foreground">{a.name}</span><br /><span className="text-xs text-muted-foreground">{a.email}</span></td> <td className="px-3 py-2"><span className="text-foreground">{a.name}</span><br /><span className="text-xs text-muted-foreground">{a.email}</span></td>
<td className="px-3 py-2">{a.documentType} <span className="text-muted-foreground">v{a.documentVersion}</span></td> <td className="px-3 py-2">{a.documentType} <span className="text-muted-foreground">v{a.documentVersion}</span>{a.contentHash && <><br /><span className="font-mono text-[10px] text-muted-foreground">{a.contentHash.slice(0, 12)}</span></>}</td>
<td className="px-3 py-2 text-xs text-muted-foreground">{a.context}</td> <td className="px-3 py-2 text-xs text-muted-foreground">{a.context}</td>
<td className="px-3 py-2 text-xs text-muted-foreground font-mono whitespace-nowrap">{a.ipAddress || '—'}</td>
<td className="px-3 py-2 text-[10px] text-muted-foreground max-w-[200px] truncate" title={a.userAgent || ''}>{a.userAgent || '—'}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -9,6 +9,7 @@ import { useAuth } from '@/components/providers/auth-provider'
import { import {
ArrowLeft, MapPin, Shield, Map, Image as ImageIcon, Layers, BookOpen, ArrowLeft, MapPin, Shield, Map, Image as ImageIcon, Layers, BookOpen,
Users, Settings, Building2, AlertTriangle, ClipboardList, Heart, ShieldCheck, Loader2, Users, Settings, Building2, AlertTriangle, ClipboardList, Heart, ShieldCheck, Loader2,
HardDriveDownload,
} from 'lucide-react' } from 'lucide-react'
import { TenantsTab } from '@/components/admin/tenants-tab' import { TenantsTab } from '@/components/admin/tenants-tab'
import { UsersTab } from '@/components/admin/users-tab' import { UsersTab } from '@/components/admin/users-tab'
@@ -21,6 +22,7 @@ import { SuggestionsTab } from '@/components/admin/suggestions-tab'
import { DictionaryTab } from '@/components/admin/dictionary-tab' import { DictionaryTab } from '@/components/admin/dictionary-tab'
import { SymbolManager } from '@/components/admin/symbol-manager' import { SymbolManager } from '@/components/admin/symbol-manager'
import { OrgTab } from '@/components/admin/org-tab' import { OrgTab } from '@/components/admin/org-tab'
import { BackupTab } from '@/components/admin/backup-tab'
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog' import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
export default function AdminPage() { export default function AdminPage() {
@@ -57,33 +59,31 @@ export default function AdminPage() {
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
{/* Header */} {/* Header */}
<header className="h-14 border-b border-border bg-card flex items-center justify-between px-6"> <header className="h-14 border-b border-border bg-card flex items-center justify-between px-3 md:px-6 gap-2">
<div className="flex items-center gap-4"> <div className="flex items-center gap-2 md:gap-4 min-w-0">
<Link href="/app"> <Link href="/app">
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm" className="px-2 shrink-0">
<ArrowLeft className="w-4 h-4 mr-2" /> <ArrowLeft className="w-4 h-4 md:mr-2" />
Zurück zur App <span className="hidden sm:inline">Zurück zur App</span>
</Button> </Button>
</Link> </Link>
<div className="h-6 w-px bg-border" /> <div className="hidden sm:block h-6 w-px bg-border" />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 min-w-0">
<MapPin className="w-5 h-5 text-primary" /> <MapPin className="w-5 h-5 text-primary shrink-0" />
<h1 className="font-semibold text-lg">Administration</h1> <h1 className="font-semibold text-base md:text-lg truncate">Administration</h1>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-2 text-sm min-w-0 shrink-0">
<div className="flex items-center gap-2 text-sm"> <Shield className="w-4 h-4 text-primary shrink-0 hidden sm:block" />
<Shield className="w-4 h-4 text-primary" /> <span className="font-medium truncate max-w-[100px] md:max-w-none">{user.name}</span>
<span className="font-medium">{user.name}</span> <span className="hidden sm:inline text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">{user.role}</span>
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">{user.role}</span>
</div>
</div> </div>
</header> </header>
<div className="container mx-auto py-6 px-4 max-w-7xl"> <div className="container mx-auto py-6 px-4 max-w-7xl">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6"> <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
{isServerAdmin ? ( {isServerAdmin ? (
<TabsList className="grid w-full grid-cols-7 max-w-4xl"> <TabsList className="w-full max-w-5xl h-auto flex flex-nowrap overflow-x-auto justify-start md:grid md:grid-cols-8 [&>button]:shrink-0 [&>button]:whitespace-nowrap">
<TabsTrigger value="tenants" className="gap-2"><Shield className="w-4 h-4" />Mandanten</TabsTrigger> <TabsTrigger value="tenants" className="gap-2"><Shield className="w-4 h-4" />Mandanten</TabsTrigger>
<TabsTrigger value="projects" className="gap-2"><Map className="w-4 h-4" />Einsätze</TabsTrigger> <TabsTrigger value="projects" className="gap-2"><Map className="w-4 h-4" />Einsätze</TabsTrigger>
<TabsTrigger value="icons" className="gap-2"><ImageIcon className="w-4 h-4" />Symbole</TabsTrigger> <TabsTrigger value="icons" className="gap-2"><ImageIcon className="w-4 h-4" />Symbole</TabsTrigger>
@@ -91,9 +91,10 @@ export default function AdminPage() {
<TabsTrigger value="dictionary" className="gap-2"><BookOpen className="w-4 h-4" />Wörterbuch</TabsTrigger> <TabsTrigger value="dictionary" className="gap-2"><BookOpen className="w-4 h-4" />Wörterbuch</TabsTrigger>
<TabsTrigger value="users" className="gap-2"><Users className="w-4 h-4" />Benutzer</TabsTrigger> <TabsTrigger value="users" className="gap-2"><Users className="w-4 h-4" />Benutzer</TabsTrigger>
<TabsTrigger value="settings" className="gap-2"><Settings className="w-4 h-4" />System</TabsTrigger> <TabsTrigger value="settings" className="gap-2"><Settings className="w-4 h-4" />System</TabsTrigger>
<TabsTrigger value="backup" className="gap-2"><HardDriveDownload className="w-4 h-4" />Backup</TabsTrigger>
</TabsList> </TabsList>
) : ( ) : (
<TabsList className="grid w-full grid-cols-7 max-w-4xl"> <TabsList className="w-full max-w-4xl h-auto flex flex-nowrap overflow-x-auto justify-start md:grid md:grid-cols-7 [&>button]:shrink-0 [&>button]:whitespace-nowrap">
<TabsTrigger value="org" className="gap-2"><Building2 className="w-4 h-4" />Organisation</TabsTrigger> <TabsTrigger value="org" className="gap-2"><Building2 className="w-4 h-4" />Organisation</TabsTrigger>
<TabsTrigger value="users" className="gap-2"><Users className="w-4 h-4" />Benutzer</TabsTrigger> <TabsTrigger value="users" className="gap-2"><Users className="w-4 h-4" />Benutzer</TabsTrigger>
<TabsTrigger value="icons" className="gap-2"><ImageIcon className="w-4 h-4" />Symbole</TabsTrigger> <TabsTrigger value="icons" className="gap-2"><ImageIcon className="w-4 h-4" />Symbole</TabsTrigger>
@@ -123,6 +124,10 @@ export default function AdminPage() {
</TabsContent> </TabsContent>
)} )}
{isServerAdmin && (
<TabsContent value="backup"><BackupTab /></TabsContent>
)}
{/* ===== Geteilt: Benutzer ===== */} {/* ===== Geteilt: Benutzer ===== */}
<TabsContent value="users"><UsersTab /></TabsContent> <TabsContent value="users"><UsersTab /></TabsContent>

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { encryptSecret } from '@/lib/crypto-secret'
import { loadBackupConfig, saveBackupConfig, maskBackupConfig, DEFAULT_BACKUP_CONFIG, type BackupConfig } from '@/lib/backup/config'
// Backup ist plattformweit → nur SERVER_ADMIN.
async function guard() {
const user = await getSession()
if (!user || user.role !== 'SERVER_ADMIN') return null
return user
}
export async function GET() {
if (!(await guard())) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const cfg = (await loadBackupConfig()) || DEFAULT_BACKUP_CONFIG
return NextResponse.json(maskBackupConfig(cfg))
}
export async function PUT(req: NextRequest) {
if (!(await guard())) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const body = await req.json().catch(() => ({}))
const existing = (await loadBackupConfig()) || DEFAULT_BACKUP_CONFIG
const next: BackupConfig = { ...existing }
next.enabled = !!body.enabled
next.schedule = ['off', 'daily', 'weekly'].includes(body.schedule) ? body.schedule : 'off'
next.retentionDays = Math.max(1, Math.min(3650, Number(body.retentionDays) || 30))
next.destinationType = body.destinationType === 'webdav' ? 'webdav' : 'sftp'
// Passphrase nur ersetzen, wenn neu eingegeben
if (typeof body.passphrase === 'string' && body.passphrase.length > 0) {
next.encryptPassphrase = encryptSecret(body.passphrase)
}
if (body.sftp) {
next.sftp = {
host: String(body.sftp.host || ''),
port: Number(body.sftp.port) || 22,
username: String(body.sftp.username || ''),
remotePath: String(body.sftp.remotePath || '/lageplan-backups'),
password: body.sftp.password ? encryptSecret(String(body.sftp.password)) : (existing.sftp?.password || ''),
}
}
if (body.webdav) {
next.webdav = {
url: String(body.webdav.url || ''),
username: String(body.webdav.username || ''),
remotePath: String(body.webdav.remotePath || '/Lageplan-Backups'),
password: body.webdav.password ? encryptSecret(String(body.webdav.password)) : (existing.webdav?.password || ''),
}
}
await saveBackupConfig(next)
return NextResponse.json(maskBackupConfig(next))
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { runBackup } from '@/lib/backup/engine'
// POST: löst sofort ein Backup aus (manuell „Jetzt sichern"). Nur SERVER_ADMIN.
export async function POST() {
try {
const user = await getSession()
if (!user || user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const res = await runBackup()
return NextResponse.json(res)
} catch (e: any) {
return NextResponse.json({ ok: false, message: e?.message || 'Backup fehlgeschlagen' }, { status: 500 })
}
}

View File

@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { loadBackupConfig } from '@/lib/backup/config'
import { plainDestFromConfig } from '@/lib/backup/secrets'
import { testDestination } from '@/lib/backup/destinations'
// POST: testet die gespeicherte Zielverbindung (Verbindung + Schreibzugriff). Nur SERVER_ADMIN.
export async function POST() {
try {
const user = await getSession()
if (!user || user.role !== 'SERVER_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const cfg = await loadBackupConfig()
if (!cfg) return NextResponse.json({ ok: false, message: 'Bitte zuerst konfigurieren und speichern.' }, { status: 400 })
const dest = plainDestFromConfig(cfg)
const res = await testDestination(cfg.destinationType, dest)
return NextResponse.json(res, { status: res.ok ? 200 : 400 })
} catch (e: any) {
return NextResponse.json({ ok: false, message: e?.message || 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -29,7 +29,7 @@ export async function GET(req: NextRequest) {
where: userFilter, where: userFilter,
orderBy: { acceptedAt: 'desc' }, orderBy: { acceptedAt: 'desc' },
take: 2000, take: 2000,
select: { id: true, userId: true, organizationId: true, documentType: true, documentVersion: true, context: true, acceptedAt: true }, select: { id: true, userId: true, organizationId: true, documentType: true, documentVersion: true, context: true, acceptedAt: true, contentHash: true, ipAddress: true, userAgent: true },
}) })
// Nur die notwendigen Nachweisdaten anreichern (E-Mail/Name) — keine überflüssigen Personendaten. // Nur die notwendigen Nachweisdaten anreichern (E-Mail/Name) — keine überflüssigen Personendaten.
@@ -43,11 +43,11 @@ export async function GET(req: NextRequest) {
})) }))
if (format === 'csv') { if (format === 'csv') {
const header = ['acceptedAt', 'email', 'name', 'documentType', 'documentVersion', 'context', 'organizationId'] const header = ['acceptedAt', 'email', 'name', 'documentType', 'documentVersion', 'context', 'ipAddress', 'userAgent', 'contentHash', 'organizationId']
const esc = (v: any) => `"${String(v ?? '').replace(/"/g, '""')}"` const esc = (v: any) => `"${String(v ?? '').replace(/"/g, '""')}"`
const lines = [header.join(',')] const lines = [header.join(',')]
for (const r of rows) { for (const r of rows) {
lines.push([new Date(r.acceptedAt).toISOString(), r.email, r.name, r.documentType, r.documentVersion, r.context, r.organizationId || ''].map(esc).join(',')) lines.push([new Date(r.acceptedAt).toISOString(), r.email, r.name, r.documentType, r.documentVersion, r.context, r.ipAddress || '', r.userAgent || '', r.contentHash || '', r.organizationId || ''].map(esc).join(','))
} }
return new NextResponse(lines.join('\r\n'), { return new NextResponse(lines.join('\r\n'), {
headers: { headers: {

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession, isAdmin } from '@/lib/auth'
// POST: setzt die MFA eines Benutzers zurück (TOTP + Backup-Codes + WebAuthn entfernen).
// SERVER_ADMIN: jeden Benutzer. TENANT_ADMIN: nur Benutzer der eigenen Organisation.
export async function POST(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const session = await getSession()
if (!session || !isAdmin(session.role)) {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
// TENANT_ADMIN darf nur eigene Organisationsmitglieder zurücksetzen.
if (session.role !== 'SERVER_ADMIN') {
if (!session.tenantId) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const membership = await (prisma as any).tenantMembership.findFirst({
where: { userId: id, tenantId: session.tenantId },
select: { id: true },
})
if (!membership) return NextResponse.json({ error: 'Benutzer nicht in Ihrer Organisation' }, { status: 403 })
}
await (prisma as any).$transaction([
(prisma as any).webAuthnCredential.deleteMany({ where: { userId: id } }),
(prisma as any).user.update({
where: { id },
data: { totpSecret: null, totpConfirmedAt: null, mfaBackupCodes: [] },
}),
])
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error resetting MFA:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { login, createToken, SESSION_MAX_AGE_SECONDS, SESSION_MAX_AGE_REMEMBER_SECONDS } from '@/lib/auth' import { login, createToken, createMfaToken, userHasMfa, SESSION_MAX_AGE_SECONDS, SESSION_MAX_AGE_REMEMBER_SECONDS } from '@/lib/auth'
import { loginSchema } from '@/lib/validations' import { loginSchema } from '@/lib/validations'
import { prisma } from '@/lib/db' import { prisma } from '@/lib/db'
import { loginLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit' import { loginLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
@@ -36,6 +36,23 @@ export async function POST(request: NextRequest) {
) )
} }
// Zweiter Faktor erforderlich? Dann noch KEINE Session setzen, sondern MFA-Zwischentoken liefern.
if (await userHasMfa(result.user.id)) {
const totpEnabled = await (prisma as any).user.findUnique({
where: { id: result.user.id },
select: { totpConfirmedAt: true, _count: { select: { webauthnCredentials: true } } },
})
const mfaToken = await createMfaToken(result.user.id, rememberMe)
return NextResponse.json({
mfaRequired: true,
mfaToken,
methods: {
totp: !!totpEnabled?.totpConfirmedAt,
webauthn: (totpEnabled?._count?.webauthnCredentials ?? 0) > 0,
},
})
}
// Update lastLoginAt // Update lastLoginAt
try { try {
await (prisma as any).user.update({ await (prisma as any).user.update({

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { prisma } from '@/lib/db'
import { verifyMfaToken, buildSessionForUser, createToken, SESSION_MAX_AGE_SECONDS, SESSION_MAX_AGE_REMEMBER_SECONDS } from '@/lib/auth'
import { verifyTotp, verifyBackupCode } from '@/lib/mfa'
import { loginLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
// POST (öffentlich): zweiter Faktor per TOTP-Code ODER Backup-Code. Setzt bei Erfolg die Session.
// body: { mfaToken, code }
export async function POST(req: NextRequest) {
try {
const rl = loginLimiter.check(getClientIp(req))
if (!rl.success) return rateLimitResponse(rl.resetAt)
const body = await req.json().catch(() => ({}))
const mfa = await verifyMfaToken(String(body.mfaToken || ''))
if (!mfa) return NextResponse.json({ error: 'Sitzung abgelaufen. Bitte erneut anmelden.' }, { status: 401 })
const code = String(body.code || '')
const user = await (prisma as any).user.findUnique({
where: { id: mfa.userId },
select: { id: true, totpSecret: true, totpConfirmedAt: true, mfaBackupCodes: true },
})
if (!user) return NextResponse.json({ error: 'Benutzer nicht gefunden' }, { status: 401 })
let ok = false
// 1) TOTP
if (user.totpConfirmedAt && user.totpSecret && verifyTotp(user.totpSecret, code)) {
ok = true
} else {
// 2) Backup-Code — bei Treffer entwerten
const idx = await verifyBackupCode(user.mfaBackupCodes || [], code)
if (idx >= 0) {
ok = true
const remaining = [...(user.mfaBackupCodes as string[])]
remaining.splice(idx, 1)
await (prisma as any).user.update({ where: { id: user.id }, data: { mfaBackupCodes: remaining } })
}
}
if (!ok) return NextResponse.json({ error: 'Code ungültig.' }, { status: 401 })
const session = await buildSessionForUser(user.id)
if (!session.success || !session.user) {
return NextResponse.json({ error: session.error || 'Anmeldung fehlgeschlagen' }, { status: 401 })
}
try { await (prisma as any).user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }) } catch {}
const token = await createToken(session.user, mfa.rememberMe)
;(await cookies()).set('auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: mfa.rememberMe ? SESSION_MAX_AGE_REMEMBER_SECONDS : SESSION_MAX_AGE_SECONDS,
path: '/',
})
return NextResponse.json({ user: session.user })
} catch (error) {
console.error('MFA verify error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server'
import { generateAuthenticationOptions } from '@simplewebauthn/server'
import { prisma } from '@/lib/db'
import { verifyMfaToken } from '@/lib/auth'
import { rpFromRequest, signChallenge } from '@/lib/webauthn'
// POST (öffentlich): Authentisierungs-Optionen für den zweiten Faktor per WebAuthn.
// body: { mfaToken }
export async function POST(req: NextRequest) {
try {
const body = await req.json().catch(() => ({}))
const mfa = await verifyMfaToken(String(body.mfaToken || ''))
if (!mfa) return NextResponse.json({ error: 'Sitzung abgelaufen.' }, { status: 401 })
const { rpID } = rpFromRequest(req)
const creds = await (prisma as any).webAuthnCredential.findMany({
where: { userId: mfa.userId },
select: { credentialId: true, transports: true },
})
if (creds.length === 0) return NextResponse.json({ error: 'Kein Sicherheitsschlüssel registriert.' }, { status: 400 })
const options = await generateAuthenticationOptions({
rpID,
allowCredentials: creds.map((c: any) => ({
id: c.credentialId,
transports: c.transports ? JSON.parse(c.transports) : undefined,
})),
userVerification: 'preferred',
})
const challengeToken = await signChallenge(options.challenge, mfa.userId, 'wa-auth')
return NextResponse.json({ options, challengeToken })
} catch (error) {
console.error('WebAuthn auth options error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
import { verifyAuthenticationResponse } from '@simplewebauthn/server'
import { prisma } from '@/lib/db'
import { verifyMfaToken, buildSessionForUser, createToken, SESSION_MAX_AGE_SECONDS, SESSION_MAX_AGE_REMEMBER_SECONDS } from '@/lib/auth'
import { rpFromRequest, verifyChallenge, b64urlToBytes } from '@/lib/webauthn'
import { loginLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
// POST (öffentlich): schliesst den zweiten Faktor per WebAuthn ab und setzt die Session.
// body: { mfaToken, challengeToken, response }
export async function POST(req: NextRequest) {
try {
const rl = loginLimiter.check(getClientIp(req))
if (!rl.success) return rateLimitResponse(rl.resetAt)
const body = await req.json().catch(() => ({}))
const mfa = await verifyMfaToken(String(body.mfaToken || ''))
if (!mfa) return NextResponse.json({ error: 'Sitzung abgelaufen.' }, { status: 401 })
const ch = await verifyChallenge(String(body.challengeToken || ''), 'wa-auth')
if (!ch || ch.userId !== mfa.userId) return NextResponse.json({ error: 'Sitzung abgelaufen.' }, { status: 401 })
const response = body.response
const credId = String(response?.id || '')
const cred = await (prisma as any).webAuthnCredential.findFirst({
where: { credentialId: credId, userId: mfa.userId },
})
if (!cred) return NextResponse.json({ error: 'Unbekannter Sicherheitsschlüssel.' }, { status: 401 })
const { rpID, origin } = rpFromRequest(req)
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: ch.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: cred.credentialId,
publicKey: b64urlToBytes(cred.publicKey),
counter: cred.counter,
transports: cred.transports ? JSON.parse(cred.transports) : undefined,
},
})
if (!verification.verified) return NextResponse.json({ error: 'Verifikation fehlgeschlagen.' }, { status: 401 })
// Zähler aktualisieren (Klon-Schutz)
await (prisma as any).webAuthnCredential.update({
where: { id: cred.id },
data: { counter: verification.authenticationInfo.newCounter, lastUsedAt: new Date() },
})
const session = await buildSessionForUser(mfa.userId)
if (!session.success || !session.user) {
return NextResponse.json({ error: session.error || 'Anmeldung fehlgeschlagen' }, { status: 401 })
}
try { await (prisma as any).user.update({ where: { id: mfa.userId }, data: { lastLoginAt: new Date() } }) } catch {}
const token = await createToken(session.user, mfa.rememberMe)
;(await cookies()).set('auth-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: mfa.rememberMe ? SESSION_MAX_AGE_REMEMBER_SECONDS : SESSION_MAX_AGE_SECONDS,
path: '/',
})
return NextResponse.json({ user: session.user })
} catch (error) {
console.error('WebAuthn auth verify error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
// GET: MFA-Status des angemeldeten Benutzers für die Einstellungen.
export async function GET() {
try {
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const user = await (prisma as any).user.findUnique({
where: { id: session.id },
select: { totpConfirmedAt: true, mfaBackupCodes: true, webauthnCredentials: { select: { id: true, deviceName: true, createdAt: true, lastUsedAt: true } } },
})
const totpEnabled = !!user?.totpConfirmedAt
const credentials = user?.webauthnCredentials || []
return NextResponse.json({
totpEnabled,
backupCodesRemaining: (user?.mfaBackupCodes || []).length,
webauthn: credentials,
hasMfa: totpEnabled || credentials.length > 0,
})
} catch (error) {
console.error('Error fetching MFA status:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { verifyTotp, generateBackupCodes } from '@/lib/mfa'
// POST: bestätigt die TOTP-Einrichtung mit einem Code aus der App. Bei Erfolg wird TOTP aktiviert
// und einmalige Backup-Codes werden erzeugt (Klartext nur hier einmalig zurückgegeben).
export async function POST(req: NextRequest) {
try {
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const body = await req.json().catch(() => ({}))
const code = String(body.code || '')
const user = await (prisma as any).user.findUnique({
where: { id: session.id },
select: { totpSecret: true, totpConfirmedAt: true, mfaBackupCodes: true },
})
if (!user?.totpSecret) {
return NextResponse.json({ error: 'Keine Einrichtung offen. Bitte erneut starten.' }, { status: 400 })
}
if (!verifyTotp(user.totpSecret, code)) {
return NextResponse.json({ error: 'Code ungültig. Bitte erneut versuchen.' }, { status: 400 })
}
// Backup-Codes nur erzeugen, wenn noch keine existieren (Neuaktivierung).
let plainCodes: string[] | null = null
let hashed = user.mfaBackupCodes as string[]
if (!user.totpConfirmedAt || !hashed || hashed.length === 0) {
const gen = await generateBackupCodes(10)
plainCodes = gen.plain
hashed = gen.hashed
}
await (prisma as any).user.update({
where: { id: session.id },
data: { totpConfirmedAt: new Date(), mfaBackupCodes: hashed },
})
return NextResponse.json({ success: true, backupCodes: plainCodes })
} catch (error) {
console.error('Error confirming TOTP:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
// POST: deaktiviert TOTP für den angemeldeten Benutzer. Backup-Codes werden nur entfernt,
// wenn auch kein WebAuthn mehr aktiv ist (sonst bleiben sie als Fallback bestehen).
export async function POST() {
try {
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const cnt = await (prisma as any).webAuthnCredential.count({ where: { userId: session.id } })
const data: any = { totpSecret: null, totpConfirmedAt: null }
if (cnt === 0) data.mfaBackupCodes = []
await (prisma as any).user.update({ where: { id: session.id }, data })
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error disabling TOTP:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server'
import QRCode from 'qrcode'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { generateTotpSecret, totpKeyUri } from '@/lib/mfa'
// POST: startet die TOTP-Einrichtung — erzeugt ein Secret (noch nicht bestätigt) und liefert
// otpauth-URI + QR-Code zum Scannen. Aktiv wird TOTP erst nach /confirm.
export async function POST() {
try {
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const secret = generateTotpSecret()
const uri = totpKeyUri(session.email, secret)
// Secret vormerken (unbestätigt). totpConfirmedAt bleibt NULL bis zur Bestätigung.
await (prisma as any).user.update({
where: { id: session.id },
data: { totpSecret: secret, totpConfirmedAt: null },
})
let qr = ''
try { qr = await QRCode.toDataURL(uri, { width: 220, margin: 1 }) } catch { /* QR optional */ }
return NextResponse.json({ secret, uri, qr })
} catch (error) {
console.error('Error setting up TOTP:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
// DELETE (Session): entfernt einen registrierten Sicherheitsschlüssel/Passkey des Benutzers.
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
// Nur eigene Credentials löschen
const cred = await (prisma as any).webAuthnCredential.findFirst({ where: { id, userId: session.id }, select: { id: true } })
if (!cred) return NextResponse.json({ error: 'Nicht gefunden' }, { status: 404 })
await (prisma as any).webAuthnCredential.delete({ where: { id } })
// Backup-Codes aufräumen, wenn kein Faktor mehr aktiv ist.
const user = await (prisma as any).user.findUnique({
where: { id: session.id },
select: { totpConfirmedAt: true, _count: { select: { webauthnCredentials: true } } },
})
if (!user?.totpConfirmedAt && (user?._count?.webauthnCredentials ?? 0) === 0) {
await (prisma as any).user.update({ where: { id: session.id }, data: { mfaBackupCodes: [] } })
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('WebAuthn delete error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server'
import { generateRegistrationOptions } from '@simplewebauthn/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { rpFromRequest, signChallenge, b64urlToBytes } from '@/lib/webauthn'
// POST (Session): Optionen zum Registrieren eines Sicherheitsschlüssels/Passkeys.
export async function POST(req: NextRequest) {
try {
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const { rpID, rpName } = rpFromRequest(req)
const existing = await (prisma as any).webAuthnCredential.findMany({
where: { userId: session.id },
select: { credentialId: true, transports: true },
})
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: session.email,
userDisplayName: session.name,
userID: new TextEncoder().encode(session.id),
attestationType: 'none',
excludeCredentials: existing.map((c: any) => ({
id: c.credentialId,
transports: c.transports ? JSON.parse(c.transports) : undefined,
})),
authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
})
const challengeToken = await signChallenge(options.challenge, session.id, 'wa-reg')
return NextResponse.json({ options, challengeToken })
} catch (error) {
console.error('WebAuthn register options error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server'
import { verifyRegistrationResponse } from '@simplewebauthn/server'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { rpFromRequest, verifyChallenge, bytesToB64url } from '@/lib/webauthn'
// POST (Session): schliesst die Registrierung eines Sicherheitsschlüssels/Passkeys ab.
export async function POST(req: NextRequest) {
try {
const session = await getSession()
if (!session) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const body = await req.json().catch(() => ({}))
const ch = await verifyChallenge(String(body.challengeToken || ''), 'wa-reg')
if (!ch || ch.userId !== session.id) {
return NextResponse.json({ error: 'Sitzung abgelaufen. Bitte erneut versuchen.' }, { status: 400 })
}
const { rpID, origin } = rpFromRequest(req)
const verification = await verifyRegistrationResponse({
response: body.response,
expectedChallenge: ch.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
})
if (!verification.verified || !verification.registrationInfo) {
return NextResponse.json({ error: 'Registrierung fehlgeschlagen.' }, { status: 400 })
}
const cred = verification.registrationInfo.credential
await (prisma as any).webAuthnCredential.create({
data: {
userId: session.id,
credentialId: cred.id,
publicKey: bytesToB64url(cred.publicKey),
counter: cred.counter,
transports: cred.transports ? JSON.stringify(cred.transports) : null,
deviceName: (typeof body.deviceName === 'string' && body.deviceName.trim()) ? body.deviceName.trim().slice(0, 60) : 'Sicherheitsschlüssel',
},
})
return NextResponse.json({ success: true })
} catch (error) {
console.error('WebAuthn register verify error:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -119,6 +119,8 @@ export async function POST(req: NextRequest) {
context: 'REGISTRATION', context: 'REGISTRATION',
types: ['TERMS', 'PRIVACY'], types: ['TERMS', 'PRIVACY'],
locale: 'de-CH', locale: 'de-CH',
ipAddress: getClientIp(req),
userAgent: req.headers.get('user-agent'),
}) })
} catch (e) { } catch (e) {
console.warn('[register] Zustimmungsprotokollierung fehlgeschlagen (nicht blockierend):', e) console.warn('[register] Zustimmungsprotokollierung fehlgeschlagen (nicht blockierend):', e)

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server'
import { loadBackupConfig } from '@/lib/backup/config'
import { isBackupDue, runBackup } from '@/lib/backup/engine'
// POST (öffentlich, aber per CRON_SECRET geschützt): vom internen Scheduler aufgerufen.
// Führt ein Backup NUR aus, wenn es laut Zeitplan fällig ist.
export async function POST(req: NextRequest) {
try {
const secret = process.env.CRON_SECRET
if (!secret) return NextResponse.json({ error: 'CRON_SECRET nicht gesetzt' }, { status: 503 })
if (req.headers.get('x-cron-secret') !== secret) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
}
const cfg = await loadBackupConfig()
if (!cfg) return NextResponse.json({ skipped: true, reason: 'nicht konfiguriert' })
if (!isBackupDue(cfg)) return NextResponse.json({ skipped: true, reason: 'nicht fällig' })
const res = await runBackup()
return NextResponse.json(res)
} catch (e: any) {
return NextResponse.json({ ok: false, message: e?.message || 'Fehler' }, { status: 500 })
}
}

View File

@@ -1,9 +1,10 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { getClientIp } from '@/lib/rate-limit'
import { recordAcceptances, getPendingAcceptances, type LegalAcceptanceContext, type LegalDocumentType } from '@/lib/legal' import { recordAcceptances, getPendingAcceptances, type LegalAcceptanceContext, type LegalDocumentType } from '@/lib/legal'
const VALID_TYPES: LegalDocumentType[] = ['TERMS', 'PRIVACY', 'ORGANIZATION_DECLARATION', 'RESPONSIBLE_USE', 'DATA_PROCESSING_AGREEMENT'] const VALID_TYPES: LegalDocumentType[] = ['TERMS', 'PRIVACY', 'ORGANIZATION_DECLARATION', 'RESPONSIBLE_USE', 'DATA_PROCESSING_AGREEMENT', 'FKS_ATTRIBUTION']
const VALID_CONTEXTS: LegalAcceptanceContext[] = ['REGISTRATION', 'LOGIN_RECONSENT', 'ORGANIZATION_CREATION', 'ADMIN_ROLE_ACCEPTANCE', 'DONATION'] const VALID_CONTEXTS: LegalAcceptanceContext[] = ['REGISTRATION', 'LOGIN_RECONSENT', 'ORGANIZATION_CREATION', 'ADMIN_ROLE_ACCEPTANCE', 'EXISTING_USER_RECONSENT', 'SUPPORT_CONTRIBUTION', 'DONATION']
// POST: erfasst Zustimmungen des angemeldeten Benutzers revisionssicher. // POST: erfasst Zustimmungen des angemeldeten Benutzers revisionssicher.
// body: { types: LegalDocumentType[], context: LegalAcceptanceContext } // body: { types: LegalDocumentType[], context: LegalAcceptanceContext }
@@ -38,6 +39,8 @@ export async function POST(req: NextRequest) {
context, context,
types, types,
locale: 'de-CH', locale: 'de-CH',
ipAddress: getClientIp(req),
userAgent: req.headers.get('user-agent'),
}) })
const isOrgAdmin = user.role === 'TENANT_ADMIN' const isOrgAdmin = user.role === 'TENANT_ADMIN'

View File

@@ -3,26 +3,45 @@ import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { normalizeModules, DEFAULT_MODULES } from '@/lib/modules' import { normalizeModules, DEFAULT_MODULES } from '@/lib/modules'
/** Cockpit-Modul-Konfiguration des eigenen Mandanten lesen */ /**
export async function GET() { * Ermittelt den massgeblichen Mandanten für die Modul-Verwaltung.
* SERVER_ADMIN darf einen beliebigen Mandanten (z.B. den des offenen Einsatzes) verwalten;
* andere nur ihren eigenen. `canManage` sagt, ob dieser Nutzer speichern darf.
*/
function resolveTenant(user: any, requestedTenantId: string | null): { tenantId: string | null; canManage: boolean } {
const isServerAdmin = user.role === 'SERVER_ADMIN'
const isTenantAdmin = user.role === 'TENANT_ADMIN'
if (isServerAdmin) {
// SERVER_ADMIN: nimmt den angefragten Mandanten (Einsatz), sonst den eigenen (falls vorhanden)
return { tenantId: requestedTenantId || user.tenantId || null, canManage: true }
}
// Alle anderen: immer nur der eigene Mandant. Admin dieses Mandanten darf verwalten.
return { tenantId: user.tenantId || null, canManage: isTenantAdmin && !!user.tenantId }
}
/** Cockpit-Modul-Konfiguration lesen (Mandant = eigener oder, für SERVER_ADMIN, der des Einsatzes) */
export async function GET(request: NextRequest) {
try { try {
const user = await getSession() const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (!user.tenantId) { const requested = request.nextUrl.searchParams.get('tenantId')
// SERVER_ADMIN ohne Mandant: Standard-Cockpit const { tenantId, canManage } = resolveTenant(user, requested)
if (!tenantId) {
// Kein Mandant auflösbar: Standard-Cockpit, nicht verwaltbar
return NextResponse.json({ modules: DEFAULT_MODULES, canManage: false }) return NextResponse.json({ modules: DEFAULT_MODULES, canManage: false })
} }
const tenant = await (prisma as any).tenant.findUnique({ const tenant = await (prisma as any).tenant.findUnique({
where: { id: user.tenantId }, where: { id: tenantId },
select: { modulesConfig: true }, select: { modulesConfig: true },
}) })
const canManage = user.role === 'SERVER_ADMIN' || user.role === 'TENANT_ADMIN'
return NextResponse.json({ return NextResponse.json({
modules: normalizeModules(tenant?.modulesConfig), modules: normalizeModules(tenant?.modulesConfig),
canManage, canManage,
tenantId,
}) })
} catch (error) { } catch (error) {
console.error('Error fetching module config:', error) console.error('Error fetching module config:', error)
@@ -30,22 +49,23 @@ export async function GET() {
} }
} }
/** Cockpit-Modul-Konfiguration speichern (nur Admins) */ /** Cockpit-Modul-Konfiguration speichern (nur Admins des betroffenen Mandanten) */
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
try { try {
const user = await getSession() const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (!user.tenantId) return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 })
if (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN') {
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
}
const body = await request.json() const body = await request.json()
const { tenantId, canManage } = resolveTenant(user, body?.tenantId || null)
if (!tenantId) return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 })
if (!canManage) return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
// normalizeModules verwirft Unbekanntes und stellt eingebaute Module sicher // normalizeModules verwirft Unbekanntes und stellt eingebaute Module sicher
const modules = normalizeModules(body?.modules) const modules = normalizeModules(body?.modules)
await (prisma as any).tenant.update({ await (prisma as any).tenant.update({
where: { id: user.tenantId }, where: { id: tenantId },
data: { modulesConfig: modules }, data: { modulesConfig: modules },
}) })

View File

@@ -1031,6 +1031,8 @@ export default function AppPage() {
} }
}} }}
windLive={windLive} windLive={windLive}
onWindChange={canEditMap ? (d) => handleWindChange(d) : undefined}
onFetchLiveWind={canEditMap ? handleFetchLiveWind : undefined}
isEditingByMe={isEditingByMe} isEditingByMe={isEditingByMe}
onStopEditing={handleStopEditing} onStopEditing={handleStopEditing}
editingLoading={editingLoading} editingLoading={editingLoading}
@@ -1165,6 +1167,7 @@ export default function AppPage() {
einsatzNr={(currentProject as any)?.einsatzNr || ''} einsatzNr={(currentProject as any)?.einsatzNr || ''}
canEdit={canEditJournal} canEdit={canEditJournal}
tenantId={tenant?.id || null} tenantId={tenant?.id || null}
projectTenantId={(currentProject as any)?.tenantId ?? null}
tenantName={tenant?.name || ''} tenantName={tenant?.name || ''}
tenantLogoUrl={tenant?.id ? `/api/admin/tenants/${tenant.id}/logo/serve` : null} tenantLogoUrl={tenant?.id ? `/api/admin/tenants/${tenant.id}/logo/serve` : null}
mapRef={mapRef} mapRef={mapRef}

View File

@@ -35,7 +35,7 @@ export default function DatenschutzPage() {
<li><strong>Registrierungs-/Kontodaten:</strong> Name, E-Mail-Adresse, Organisationsname, Rolle. Das Passwort wird mit einem geeigneten Einwegverfahren (bcrypt) gehasht und nie im Klartext gespeichert.</li> <li><strong>Registrierungs-/Kontodaten:</strong> Name, E-Mail-Adresse, Organisationsname, Rolle. Das Passwort wird mit einem geeigneten Einwegverfahren (bcrypt) gehasht und nie im Klartext gespeichert.</li>
<li><strong>Einsatz-/Inhaltsdaten:</strong> Lagepläne, Journaleinträge, Zeichnungen, Koordinaten, Symbole, hochgeladene Dateien (Logos, Planbilder, Symbole) sowie zugehörige Projekt- und Organisationsdaten.</li> <li><strong>Einsatz-/Inhaltsdaten:</strong> Lagepläne, Journaleinträge, Zeichnungen, Koordinaten, Symbole, hochgeladene Dateien (Logos, Planbilder, Symbole) sowie zugehörige Projekt- und Organisationsdaten.</li>
<li><strong>Protokoll-/Sicherheitsdaten:</strong> technische Zugriffs- und Fehlerprotokolle (z.B. Zeitpunkt, ungefähre technische Angaben) zur Gewährleistung von Betrieb und Sicherheit.</li> <li><strong>Protokoll-/Sicherheitsdaten:</strong> technische Zugriffs- und Fehlerprotokolle (z.B. Zeitpunkt, ungefähre technische Angaben) zur Gewährleistung von Betrieb und Sicherheit.</li>
<li><strong>Zustimmungsnachweise:</strong> welche Version der Nutzungsbedingungen/Datenschutzerklärung/Organisationsbestätigung wann akzeptiert wurde.</li> <li><strong>Zustimmungsnachweise:</strong> welche Version der Nutzungsbedingungen/Datenschutzerklärung/Organisationsbestätigung wann akzeptiert wurde zu Beweiszwecken zusammen mit IP-Adresse und Gerät/Browser (User-Agent) sowie dem Dokument-Prüfwert (Hash).</li>
<li><strong>Unterstützungsbeiträge (optional):</strong> bei freiwilligen Beiträgen der Betrag sowie freiwillig angegebener Name/Nachricht (Zahlungsabwicklung siehe Ziffer 9).</li> <li><strong>Unterstützungsbeiträge (optional):</strong> bei freiwilligen Beiträgen der Betrag sowie freiwillig angegebener Name/Nachricht (Zahlungsabwicklung siehe Ziffer 9).</li>
</ul> </ul>
</section> </section>

View File

@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { ArrowLeft, Loader2 } from 'lucide-react' import { ArrowLeft, Loader2, Eye, EyeOff } from 'lucide-react'
import { LogoRound } from '@/components/ui/logo' import { LogoRound } from '@/components/ui/logo'
export default function LoginPage() { export default function LoginPage() {
@@ -23,12 +23,17 @@ function LoginForm() {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [rememberMe, setRememberMe] = useState(true) const [rememberMe, setRememberMe] = useState(true)
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [resendLoading, setResendLoading] = useState(false) const [resendLoading, setResendLoading] = useState(false)
const [resendSuccess, setResendSuccess] = useState(false) const [resendSuccess, setResendSuccess] = useState(false)
const [tenantLogo, setTenantLogo] = useState<string | null>(null) const [tenantLogo, setTenantLogo] = useState<string | null>(null)
const [tenantName, setTenantName] = useState<string | null>(null) const [tenantName, setTenantName] = useState<string | null>(null)
const { login } = useAuth() // MFA-Zwischenschritt
const [mfaToken, setMfaToken] = useState<string | null>(null)
const [mfaCode, setMfaCode] = useState('')
const [mfaMethods, setMfaMethods] = useState<{ totp: boolean; webauthn: boolean }>({ totp: false, webauthn: false })
const { login, verifyMfa, verifyMfaWebAuthn } = useAuth()
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const { toast } = useToast() const { toast } = useToast()
@@ -52,6 +57,18 @@ function LoginForm() {
} }
}, [tenantSlug]) }, [tenantSlug])
const doRedirect = async () => {
if (redirectTo) { router.push(redirectTo); return }
try {
const meRes = await fetch('/api/auth/me')
if (meRes.ok) {
const meData = await meRes.json()
if (meData.user?.tenantSlug) { router.push(`/${meData.user.tenantSlug}`); return }
}
} catch {}
router.push('/app')
}
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setIsLoading(true) setIsLoading(true)
@@ -59,26 +76,12 @@ function LoginForm() {
const result = await login(email, password, rememberMe) const result = await login(email, password, rememberMe)
if (result.success) { if (result.success) {
toast({ toast({ title: 'Erfolgreich angemeldet', description: 'Willkommen bei Lageplan!' })
title: 'Erfolgreich angemeldet', await doRedirect()
description: 'Willkommen bei Lageplan!', } else if (result.mfaRequired && result.mfaToken) {
}) // Zweiter Faktor nötig — Code-Schritt anzeigen.
// Redirect: use explicit redirect param, or tenant slug, or /app setMfaToken(result.mfaToken)
if (redirectTo) { if (result.methods) setMfaMethods(result.methods)
router.push(redirectTo)
} else {
try {
const meRes = await fetch('/api/auth/me')
if (meRes.ok) {
const meData = await meRes.json()
if (meData.user?.tenantSlug) {
router.push(`/${meData.user.tenantSlug}`)
return
}
}
} catch {}
router.push('/app')
}
} else { } else {
toast({ toast({
title: 'Anmeldung fehlgeschlagen', title: 'Anmeldung fehlgeschlagen',
@@ -90,6 +93,33 @@ function LoginForm() {
setIsLoading(false) setIsLoading(false)
} }
const handleMfaSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!mfaToken) return
setIsLoading(true)
const result = await verifyMfa(mfaToken, mfaCode)
if (result.success) {
toast({ title: 'Erfolgreich angemeldet', description: 'Willkommen bei Lageplan!' })
await doRedirect()
} else {
toast({ title: 'Code ungültig', description: result.error || 'Bitte erneut versuchen.', variant: 'destructive' })
}
setIsLoading(false)
}
const handleWebAuthn = async () => {
if (!mfaToken) return
setIsLoading(true)
const result = await verifyMfaWebAuthn(mfaToken)
if (result.success) {
toast({ title: 'Erfolgreich angemeldet', description: 'Willkommen bei Lageplan!' })
await doRedirect()
} else if (result.error !== 'Abgebrochen') {
toast({ title: 'Fehlgeschlagen', description: result.error || 'Bitte erneut versuchen.', variant: 'destructive' })
}
setIsLoading(false)
}
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 px-4"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 px-4">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
@@ -142,7 +172,49 @@ function LoginForm() {
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-4"> {mfaToken && (
<div className="space-y-4">
<p className="text-sm text-gray-300">Zweiter Faktor erforderlich.</p>
{mfaMethods.webauthn && (
<Button type="button" onClick={handleWebAuthn} disabled={isLoading} className="w-full bg-red-600 hover:bg-red-700">
{isLoading ? (<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Warten...</>) : 'Mit Sicherheitsschlüssel / Passkey anmelden'}
</Button>
)}
{mfaMethods.webauthn && mfaMethods.totp && (
<div className="flex items-center gap-2 text-xs text-gray-500"><span className="flex-1 h-px bg-gray-700" />oder Code<span className="flex-1 h-px bg-gray-700" /></div>
)}
{mfaMethods.totp && (
<form onSubmit={handleMfaSubmit} className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="mfa-code">Bestätigungscode</Label>
<Input
id="mfa-code"
inputMode="numeric"
autoFocus
autoComplete="one-time-code"
placeholder="6-stelliger Code oder Backup-Code"
value={mfaCode}
onChange={(e) => setMfaCode(e.target.value)}
disabled={isLoading}
/>
<p className="text-xs text-gray-400">Code aus deiner Authenticator-App. Kein Zugriff darauf? Verwende einen deiner Backup-Codes.</p>
</div>
<Button type="submit" className="w-full bg-red-600 hover:bg-red-700" disabled={isLoading || mfaCode.trim().length < 6}>
{isLoading ? (<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Prüfen...</>) : 'Bestätigen'}
</Button>
</form>
)}
<button type="button" onClick={() => { setMfaToken(null); setMfaCode('') }} className="w-full text-sm text-gray-400 hover:text-gray-200">
Abbrechen
</button>
</div>
)}
<form onSubmit={handleSubmit} className={`space-y-4 ${mfaToken ? 'hidden' : ''}`}>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="email">E-Mail</Label> <Label htmlFor="email">E-Mail</Label>
<Input <Input
@@ -163,15 +235,27 @@ function LoginForm() {
Passwort vergessen? Passwort vergessen?
</Link> </Link>
</div> </div>
<Input <div className="relative">
id="password" <Input
type="password" id="password"
placeholder="••••••••" type={showPassword ? 'text' : 'password'}
value={password} placeholder="••••••••"
onChange={(e) => setPassword(e.target.value)} value={password}
required onChange={(e) => setPassword(e.target.value)}
disabled={isLoading} required
/> disabled={isLoading}
className={`pr-11 transition-all duration-200 ${showPassword ? '' : 'text-xl tracking-[0.25em]'}`}
/>
<button
type="button"
onClick={() => setShowPassword(v => !v)}
tabIndex={-1}
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-md text-gray-400 hover:text-gray-200 transition-colors"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div> </div>
<label className="flex items-center gap-2 cursor-pointer select-none"> <label className="flex items-center gap-2 cursor-pointer select-none">

View File

@@ -137,6 +137,27 @@ export default function NutzungsbedingungenPage() {
</ul> </ul>
</section> </section>
<section>
<h2>10a. FEUKOS-/FKS-Markierungen und -Symbole</h2>
<p>
Bestimmte in {legal.projectName} verwendete Markierungen und Symbole stammen von der Feuerwehr
Koordination Schweiz FKS. Die Verwendung innerhalb von {legal.projectName} erfolgt mit schriftlicher
Genehmigung. Bildquelle: Feuerwehr Koordination Schweiz FKS.
</p>
<p>
Die Genehmigung bezieht sich auf die Verwendung innerhalb des beschriebenen Webprojekts. Sie
begründet keine offizielle Partnerschaft, Prüfung, Zertifizierung oder Empfehlung von
{' '}{legal.projectName} durch die FKS.
</p>
<p>
Nutzer erhalten keine weitergehenden Rechte, die Symbole ausserhalb der von {legal.projectName}
{' '}vorgesehenen Funktionen separat zu vervielfältigen, weiterzugeben, zu verkaufen oder in anderen
Anwendungen zu verwenden. Zulässig bleibt die Verwendung in ordnungsgemäss mit {legal.projectName}
{' '}erstellten Lageplänen und Exporten, soweit dies vom vorgesehenen Funktionsumfang und der
erteilten Genehmigung gedeckt ist. Siehe auch <a href="/quellen-und-lizenzen">Quellen und Lizenzen</a>.
</p>
</section>
<section> <section>
<h2>11. Haftung</h2> <h2>11. Haftung</h2>
<p>Im gesetzlich zulässigen Umfang gilt:</p> <p>Im gesetzlich zulässigen Umfang gilt:</p>

View File

@@ -92,7 +92,7 @@ export default function LandingPage() {
name: 'Welche Symbole sind verfügbar?', name: 'Welche Symbole sind verfügbar?',
acceptedAnswer: { acceptedAnswer: {
'@type': 'Answer', '@type': 'Answer',
text: 'Alle 117 offiziellen FKS/BABS-Signaturen sind integriert. Zusätzlich können eigene Symbole hochgeladen werden.', text: 'Alle 117 taktischen FEUKOS-Symbole der Feuerwehr Koordination Schweiz FKS sind integriert (Bildquelle: FKS). Zusätzlich können eigene Symbole hochgeladen werden.',
}, },
}, },
{ {
@@ -352,7 +352,7 @@ export default function LandingPage() {
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{[ {[
{ icon: Map, title: 'Interaktive Karte', desc: 'Lagepläne auf OSM-Karten zeichnen mit Linien, Polygonen, Pfeilen und Freihandzeichnung.' }, { icon: Map, title: 'Interaktive Karte', desc: 'Lagepläne auf OSM-Karten zeichnen mit Linien, Polygonen, Pfeilen und Freihandzeichnung.' },
{ icon: Shield, title: 'FKS-Signaturen', desc: 'Alle offiziellen FKS/BABS-Signaturen per Drag & Drop platzieren. Eigene Symbole hochladen.' }, { icon: Shield, title: 'FEUKOS-Symbole', desc: 'Taktische FEUKOS-Symbole der FKS (Bildquelle: FKS) per Drag & Drop platzieren. Eigene Symbole hochladen.' },
{ icon: Ruler, title: 'Messwerkzeug', desc: 'Distanzen messen mit Druckverlustberechnung und Pumpenanzahl für Schlauchleitungen.' }, { icon: Ruler, title: 'Messwerkzeug', desc: 'Distanzen messen mit Druckverlustberechnung und Pumpenanzahl für Schlauchleitungen.' },
{ icon: FileText, title: 'Einsatzjournal', desc: 'Journal mit Zeitstempel, SOMA-Checkliste und Pendenzenliste — alles an einem Ort.' }, { icon: FileText, title: 'Einsatzjournal', desc: 'Journal mit Zeitstempel, SOMA-Checkliste und Pendenzenliste — alles an einem Ort.' },
{ icon: Smartphone, title: 'Installierbare App (PWA)', desc: 'Auf dem Homescreen installierbar — funktioniert offline wie eine native App. Desktop, Tablet & Smartphone.' }, { icon: Smartphone, title: 'Installierbare App (PWA)', desc: 'Auf dem Homescreen installierbar — funktioniert offline wie eine native App. Desktop, Tablet & Smartphone.' },
@@ -389,7 +389,7 @@ export default function LandingPage() {
<div className="space-y-4"> <div className="space-y-4">
{[ {[
{ status: 'done', title: 'Interaktive Karte mit Zeichenwerkzeugen', desc: 'Linien, Polygone, Pfeile, Freihand, Punkte' }, { status: 'done', title: 'Interaktive Karte mit Zeichenwerkzeugen', desc: 'Linien, Polygone, Pfeile, Freihand, Punkte' },
{ status: 'done', title: 'FKS-Signaturen (117 offizielle Symbole)', desc: 'Alle taktischen Zeichen nach Schweizer Standard (Bildquelle: Feuerwehr Koordination Schweiz FKS)' }, { status: 'done', title: 'FEUKOS-Symbole (117 taktische Zeichen)', desc: 'Taktische Zeichen nach der Systematik der Feuerwehr Koordination Schweiz FKS (Bildquelle: Feuerwehr Koordination Schweiz FKS)' },
{ status: 'done', title: 'Einsatzjournal mit SOMA-Checkliste', desc: 'Zeitstempel, Pendenzen, Protokollierung' }, { status: 'done', title: 'Einsatzjournal mit SOMA-Checkliste', desc: 'Zeitstempel, Pendenzen, Protokollierung' },
{ status: 'done', title: 'Messwerkzeug mit Druckverlustberechnung', desc: 'Distanzen, Höhenmeter, Pumpenanzahl' }, { status: 'done', title: 'Messwerkzeug mit Druckverlustberechnung', desc: 'Distanzen, Höhenmeter, Pumpenanzahl' },
{ status: 'done', title: 'Echtzeit-Zusammenarbeit (WebSocket)', desc: 'Mehrere Benutzer gleichzeitig am Lageplan' }, { status: 'done', title: 'Echtzeit-Zusammenarbeit (WebSocket)', desc: 'Mehrere Benutzer gleichzeitig am Lageplan' },
@@ -459,7 +459,7 @@ export default function LandingPage() {
}, },
{ {
q: 'Welche Symbole sind verfügbar?', q: 'Welche Symbole sind verfügbar?',
a: 'Alle 117 offiziellen FKS/BABS-Signaturen sind integriert. Zusätzlich können eigene Symbole hochgeladen werden.', a: 'Alle 117 taktischen FEUKOS-Symbole der Feuerwehr Koordination Schweiz FKS sind integriert (Bildquelle: FKS). Zusätzlich können eigene Symbole hochgeladen werden.',
}, },
{ {
q: 'Kann ich Lagepläne exportieren?', q: 'Kann ich Lagepläne exportieren?',
@@ -533,13 +533,14 @@ export default function LandingPage() {
<Link href="/nutzungsbedingungen" className="block hover:text-gray-700 mt-1">Nutzungsbedingungen</Link> <Link href="/nutzungsbedingungen" className="block hover:text-gray-700 mt-1">Nutzungsbedingungen</Link>
<Link href="/verantwortungsvolle-nutzung" className="block hover:text-gray-700 mt-1">Verantwortungsvolle Nutzung</Link> <Link href="/verantwortungsvolle-nutzung" className="block hover:text-gray-700 mt-1">Verantwortungsvolle Nutzung</Link>
<Link href="/sicherheit" className="block hover:text-gray-700 mt-1">Sicherheit</Link> <Link href="/sicherheit" className="block hover:text-gray-700 mt-1">Sicherheit</Link>
<Link href="/quellen-und-lizenzen" className="block hover:text-gray-700 mt-1">Quellen &amp; Lizenzen</Link>
<Link href="/organisationen/datenschutzvereinbarung" className="block hover:text-gray-700 mt-1">Datenschutzvereinbarung (Org.)</Link> <Link href="/organisationen/datenschutzvereinbarung" className="block hover:text-gray-700 mt-1">Datenschutzvereinbarung (Org.)</Link>
<Link href="/unterstuetzen" className="block hover:text-gray-700 mt-1">Unterstützen</Link> <Link href="/unterstuetzen" className="block hover:text-gray-700 mt-1">Unterstützen</Link>
</div> </div>
</div> </div>
<div className="border-t border-gray-100 mt-8 pt-6 text-center space-y-1"> <div className="border-t border-gray-100 mt-8 pt-6 text-center space-y-1">
<p className="text-sm text-gray-500">&copy; {new Date().getFullYear()} Lageplan Perparim Ziberi. Alle Rechte vorbehalten.</p> <p className="text-sm text-gray-500">&copy; {new Date().getFullYear()} Lageplan Perparim Ziberi. Alle Rechte vorbehalten.</p>
<p className="text-xs text-gray-500">Taktische Symbole: Bildquelle Feuerwehr Koordination Schweiz FKS</p> <p className="text-xs text-gray-500">Bildquelle: Feuerwehr Koordination Schweiz FKS · <Link href="/quellen-und-lizenzen" className="underline hover:text-gray-700">Quellen &amp; Lizenzen</Link></p>
<p className="text-xs text-gray-400 mt-1">v{process.env.APP_VERSION}</p> <p className="text-xs text-gray-400 mt-1">v{process.env.APP_VERSION}</p>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,63 @@
'use client'
import { LegalPage } from '@/components/legal/legal-page'
import { legal } from '@/config/legal'
export default function QuellenUndLizenzenPage() {
const d = legal.documents.fksAttribution
const fks = legal.fksAttribution
return (
<LegalPage
title="Quellen und Lizenzen"
version={d?.version}
publishedAt={d?.publishedAt}
intro={`Herkunft und Nutzungsgrundlagen der in ${legal.projectName} verwendeten Symbole, Karten- und Geodaten sowie weiterer Inhalte.`}
>
<section>
<h2>Feuerwehr Koordination Schweiz FKS</h2>
<ul>
<li><strong>Inhalte:</strong> FEUKOS-Markierungen und -Symbole sowie bewilligtes Bildmaterial.</li>
<li><strong>Grundlage:</strong> schriftliche Genehmigung für die Verwendung innerhalb des Webprojekts {legal.projectName}.</li>
<li><strong>Quellenangabe:</strong> {fks.label}</li>
</ul>
<p>{fks.note}</p>
<p className="text-xs">
Hinweis: Es besteht keine offizielle Partnerschaft, Zertifizierung, Prüfung, behördliche
Anerkennung oder Empfehlung von {legal.projectName} durch die FKS. Nutzer erhalten keine
weitergehenden Rechte, die Symbole ausserhalb der von {legal.projectName} vorgesehenen
Funktionen separat zu vervielfältigen, weiterzugeben, zu verkaufen oder in anderen Anwendungen
zu verwenden.
</p>
</section>
<section>
<h2>Karten- und Geodaten</h2>
<ul>
<li><strong>OpenStreetMap:</strong> Basiskarte und Adresssuche (Nominatim) © OpenStreetMap-Mitwirkende, Daten unter ODbL.</li>
<li><strong>Esri World Imagery:</strong> Satellitenbilder Nutzung gemäss Esri-Bedingungen.</li>
<li><strong>swisstopo (geo.admin.ch):</strong> Schweizer Landeskarte © swisstopo, Nutzung gemäss Bedingungen von swisstopo.</li>
<li><strong>Open-Meteo / Open-Elevation:</strong> Wind- und Höhendaten.</li>
</ul>
<p className="text-xs">Karten-, Adress- und Geodaten können unvollständig, ungenau oder veraltet sein.</p>
</section>
<section>
<h2>Schriftarten, Icons &amp; Bibliotheken</h2>
<ul>
<li><strong>Barlow</strong> (SIL Open Font License 1.1), build-time selbst gehostet.</li>
<li><strong>lucide-react</strong> (ISC) für Oberflächen-Icons.</li>
<li>Wesentliche Open-Source-Bibliotheken: Next.js/React (MIT), MapLibre GL (BSD-3), jsPDF (MIT), u.a.</li>
</ul>
</section>
<section>
<h2>Eigene Inhalte</h2>
<p>
Von Benutzern hochgeladene Inhalte (Pläne, Logos, eigene Symbole) verbleiben bei den jeweiligen
Benutzern bzw. Organisationen. Für die Rechtmässigkeit hochgeladener Inhalte sind die Benutzer
verantwortlich (siehe <a href="/nutzungsbedingungen">Nutzungsbedingungen</a>).
</p>
</section>
</LegalPage>
)
}

View File

@@ -268,7 +268,7 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token:
<thead> <thead>
<tr className="bg-gray-900 text-white"> <tr className="bg-gray-900 text-white">
{(mt.columns || []).map((c: any) => ( {(mt.columns || []).map((c: any) => (
<th key={c.key} className={`p-1.5 font-semibold uppercase tracking-wider text-[7pt] ${c.type === 'check' ? 'text-center w-10' : c.type === 'time' ? 'text-left w-16' : 'text-left'}`}>{c.label}</th> <th key={c.key} className={`p-1.5 font-semibold uppercase tracking-wider text-[7pt] ${c.type === 'check' ? 'text-center w-10' : c.type === 'time' ? 'text-left w-16' : c.type === 'number' ? 'text-right w-20' : 'text-left'}`}>{c.label}</th>
))} ))}
</tr> </tr>
</thead> </thead>
@@ -276,7 +276,7 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token:
{mt.rows.map((row: any, ri: number) => ( {mt.rows.map((row: any, ri: number) => (
<tr key={ri} className={ri % 2 === 1 ? 'bg-gray-50' : ''}> <tr key={ri} className={ri % 2 === 1 ? 'bg-gray-50' : ''}>
{(mt.columns || []).map((c: any) => ( {(mt.columns || []).map((c: any) => (
<td key={c.key} className={`p-1.5 border-b border-gray-100 ${c.type === 'check' ? 'text-center font-bold' : ''} ${c.type === 'time' ? 'font-mono text-[8pt] text-gray-500' : ''}`}> <td key={c.key} className={`p-1.5 border-b border-gray-100 ${c.type === 'check' ? 'text-center font-bold' : ''} ${c.type === 'time' ? 'font-mono text-[8pt] text-gray-500' : ''} ${c.type === 'number' ? 'text-right font-mono tabular-nums' : ''}`}>
{c.type === 'check' {c.type === 'check'
? (row[c.key] ? '✓' : '—') ? (row[c.key] ? '✓' : '—')
: c.type === 'time' : c.type === 'time'

View File

@@ -9,6 +9,7 @@ import { useToast } from '@/components/ui/use-toast'
import { import {
ArrowLeft, Building2, Trash2, AlertTriangle, Loader2, Shield, Users, FileText, ArrowLeft, Building2, Trash2, AlertTriangle, Loader2, Shield, Users, FileText,
} from 'lucide-react' } from 'lucide-react'
import { MfaSettings } from '@/components/settings/mfa-settings'
export default function SettingsPage() { export default function SettingsPage() {
const [tenant, setTenant] = useState<any>(null) const [tenant, setTenant] = useState<any>(null)
@@ -120,6 +121,9 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
{/* Zwei-Faktor-Authentisierung */}
<MfaSettings />
{/* Danger Zone */} {/* Danger Zone */}
<div className="bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-800/50 p-5"> <div className="bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-800/50 p-5">
<h2 className="text-lg font-semibold text-red-700 dark:text-red-400 mb-2 flex items-center gap-2"> <h2 className="text-lg font-semibold text-red-700 dark:text-red-400 mb-2 flex items-center gap-2">

View File

@@ -26,6 +26,7 @@ export default function SicherheitPage() {
<li><strong>Passwörter:</strong> werden mit einem geeigneten Einwegverfahren (bcrypt) gehasht, nie im Klartext gespeichert.</li> <li><strong>Passwörter:</strong> werden mit einem geeigneten Einwegverfahren (bcrypt) gehasht, nie im Klartext gespeichert.</li>
<li><strong>Übertragung:</strong> verschlüsselt über HTTPS/TLS; HSTS aktiviert.</li> <li><strong>Übertragung:</strong> verschlüsselt über HTTPS/TLS; HSTS aktiviert.</li>
<li><strong>Sitzungen:</strong> httpOnly-Session-Cookie, in Produktion mit <code>secure</code>-Flag.</li> <li><strong>Sitzungen:</strong> httpOnly-Session-Cookie, in Produktion mit <code>secure</code>-Flag.</li>
<li><strong>Zwei-Faktor (2FA):</strong> optional pro Konto Authenticator-App (TOTP) und Sicherheitsschlüssel/Passkeys (WebAuthn/YubiKey), mit Backup-Codes.</li>
<li><strong>Web-Härtung:</strong> Sicherheits-Header (u.a. Content-Security-Policy, <code>X-Content-Type-Options</code>, Referrer-Policy, Schutz vor Clickjacking über <code>frame-ancestors</code>).</li> <li><strong>Web-Härtung:</strong> Sicherheits-Header (u.a. Content-Security-Policy, <code>X-Content-Type-Options</code>, Referrer-Policy, Schutz vor Clickjacking über <code>frame-ancestors</code>).</li>
<li><strong>Missbrauchsschutz:</strong> Rate-Limiting für Login, Registrierung, Passwort-Reset, Kontaktformular und geteilte Ansichten.</li> <li><strong>Missbrauchsschutz:</strong> Rate-Limiting für Login, Registrierung, Passwort-Reset, Kontaktformular und geteilte Ansichten.</li>
<li><strong>Uploads:</strong> Validierung und Zugriffsschutz für hochgeladene Dateien.</li> <li><strong>Uploads:</strong> Validierung und Zugriffsschutz für hochgeladene Dateien.</li>

View File

@@ -49,6 +49,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
{ url: `${baseUrl}/nutzungsbedingungen`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 }, { url: `${baseUrl}/nutzungsbedingungen`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 },
{ url: `${baseUrl}/verantwortungsvolle-nutzung`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 }, { url: `${baseUrl}/verantwortungsvolle-nutzung`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 },
{ url: `${baseUrl}/sicherheit`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 }, { url: `${baseUrl}/sicherheit`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 },
{ url: `${baseUrl}/quellen-und-lizenzen`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 },
{ url: `${baseUrl}/unterstuetzen`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 }, { url: `${baseUrl}/unterstuetzen`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 },
{ url: `${baseUrl}/organisationen/datenschutzvereinbarung`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 }, { url: `${baseUrl}/organisationen/datenschutzvereinbarung`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 },
] ]

View File

@@ -0,0 +1,160 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { HardDriveDownload, Loader2, ShieldCheck, PlugZap, Save } from 'lucide-react'
interface MaskedCfg {
enabled: boolean
schedule: 'off' | 'daily' | 'weekly'
retentionDays: number
hasPassphrase: boolean
destinationType: 'sftp' | 'webdav'
sftp?: { host: string; port: number; username: string; remotePath: string; hasPassword: boolean }
webdav?: { url: string; username: string; remotePath: string; hasPassword: boolean }
lastRun?: string | null
lastStatus?: 'ok' | 'error' | null
lastMessage?: string | null
}
export function BackupTab() {
const { toast } = useToast()
const [cfg, setCfg] = useState<MaskedCfg | null>(null)
const [busy, setBusy] = useState<'' | 'save' | 'test' | 'run'>('')
// Neue Geheimnisse (nur senden, wenn ausgefüllt)
const [passphrase, setPassphrase] = useState('')
const [sftpPw, setSftpPw] = useState('')
const [webdavPw, setWebdavPw] = useState('')
const load = useCallback(async () => {
const r = await fetch('/api/admin/backup/config')
if (r.ok) setCfg(await r.json())
}, [])
useEffect(() => { load() }, [load])
if (!cfg) return <div className="py-10 flex justify-center"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
const patch = (p: Partial<MaskedCfg>) => setCfg(c => c ? { ...c, ...p } : c)
const patchSftp = (p: any) => setCfg(c => c ? { ...c, sftp: { ...(c.sftp || { host: '', port: 22, username: '', remotePath: '/lageplan-backups', hasPassword: false }), ...p } } : c)
const patchWebdav = (p: any) => setCfg(c => c ? { ...c, webdav: { ...(c.webdav || { url: '', username: '', remotePath: '/Lageplan-Backups', hasPassword: false }), ...p } } : c)
const save = async () => {
setBusy('save')
try {
const body: any = {
enabled: cfg.enabled, schedule: cfg.schedule, retentionDays: cfg.retentionDays,
destinationType: cfg.destinationType,
}
if (passphrase) body.passphrase = passphrase
if (cfg.destinationType === 'sftp' && cfg.sftp) body.sftp = { ...cfg.sftp, password: sftpPw || undefined }
if (cfg.destinationType === 'webdav' && cfg.webdav) body.webdav = { ...cfg.webdav, password: webdavPw || undefined }
const r = await fetch('/api/admin/backup/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
if (r.ok) { setCfg(await r.json()); setPassphrase(''); setSftpPw(''); setWebdavPw(''); toast({ title: 'Gespeichert' }) }
else { const d = await r.json(); toast({ title: 'Fehler', description: d.error, variant: 'destructive' }) }
} finally { setBusy('') }
}
const test = async () => {
setBusy('test')
try {
const r = await fetch('/api/admin/backup/test', { method: 'POST' })
const d = await r.json()
toast({ title: d.ok ? 'Verbindung ok' : 'Verbindung fehlgeschlagen', description: d.message, variant: d.ok ? undefined : 'destructive' })
} finally { setBusy('') }
}
const runNow = async () => {
if (!confirm('Jetzt ein Backup erstellen und zum Ziel hochladen?')) return
setBusy('run')
try {
const r = await fetch('/api/admin/backup/run', { method: 'POST' })
const d = await r.json()
toast({ title: d.ok ? 'Backup erstellt' : 'Backup fehlgeschlagen', description: d.message, variant: d.ok ? undefined : 'destructive' })
load()
} finally { setBusy('') }
}
return (
<div className="space-y-6 max-w-2xl">
<div>
<h3 className="text-lg font-semibold flex items-center gap-2"><HardDriveDownload className="w-5 h-5 text-primary" /> Backup (verschlüsselt, extern)</h3>
<p className="text-sm text-muted-foreground mt-1">
Sichert Datenbank + hochgeladene Dateien verschlüsselt auf ein externes Ziel (SFTP oder Nextcloud/WebDAV).
Best Practice: zusätzlich zum lokalen Backup, an einen anderen Ort.
</p>
</div>
{/* Status */}
{cfg.lastRun && (
<div className={`rounded-lg border p-3 text-sm ${cfg.lastStatus === 'ok' ? 'border-green-300 bg-green-50 dark:bg-green-950/30 dark:border-green-800' : 'border-red-300 bg-red-50 dark:bg-red-950/30 dark:border-red-800'}`}>
<strong>Letztes Backup:</strong> {new Date(cfg.lastRun).toLocaleString('de-CH')} {cfg.lastStatus === 'ok' ? '✅' : '❌'} {cfg.lastMessage}
</div>
)}
{/* Zeitplan */}
<div className="rounded-lg border p-4 space-y-3">
<h4 className="font-semibold text-sm">Zeitplan</h4>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">Automatisch</Label>
<select value={cfg.schedule} onChange={e => patch({ schedule: e.target.value as any, enabled: e.target.value !== 'off' })} className="w-full h-9 rounded-md border border-input bg-background px-2 text-sm">
<option value="off">Aus (nur manuell)</option>
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
</select>
</div>
<div>
<Label className="text-xs">Aufbewahrung (Tage)</Label>
<Input type="number" min={1} max={3650} value={cfg.retentionDays} onChange={e => patch({ retentionDays: Number(e.target.value) })} className="h-9" />
</div>
</div>
</div>
{/* Verschlüsselung */}
<div className="rounded-lg border p-4 space-y-2">
<h4 className="font-semibold text-sm flex items-center gap-1.5"><ShieldCheck className="w-4 h-4" /> Verschlüsselung</h4>
<Label className="text-xs">Passphrase {cfg.hasPassphrase && <span className="text-green-600">· gesetzt</span>}</Label>
<Input type="password" value={passphrase} onChange={e => setPassphrase(e.target.value)} placeholder={cfg.hasPassphrase ? 'Zum Beibehalten leer lassen' : 'Passphrase wählen (sicher aufbewahren!)'} className="h-9" />
<p className="text-xs text-muted-foreground"> Ohne diese Passphrase ist kein Restore möglich im Passwort-Manager speichern.</p>
</div>
{/* Ziel */}
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center gap-3">
<h4 className="font-semibold text-sm">Ziel</h4>
<select value={cfg.destinationType} onChange={e => patch({ destinationType: e.target.value as any })} className="h-8 rounded-md border border-input bg-background px-2 text-sm">
<option value="sftp">SFTP</option>
<option value="webdav">Nextcloud / WebDAV</option>
</select>
</div>
{cfg.destinationType === 'sftp' ? (
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 sm:col-span-1"><Label className="text-xs">Host</Label><Input value={cfg.sftp?.host || ''} onChange={e => patchSftp({ host: e.target.value })} placeholder="nas.example.com" className="h-9" /></div>
<div className="col-span-2 sm:col-span-1"><Label className="text-xs">Port</Label><Input type="number" value={cfg.sftp?.port ?? 22} onChange={e => patchSftp({ port: Number(e.target.value) })} className="h-9" /></div>
<div className="col-span-2 sm:col-span-1"><Label className="text-xs">Benutzer</Label><Input value={cfg.sftp?.username || ''} onChange={e => patchSftp({ username: e.target.value })} className="h-9" /></div>
<div className="col-span-2 sm:col-span-1"><Label className="text-xs">Passwort {cfg.sftp?.hasPassword && <span className="text-green-600">· gesetzt</span>}</Label><Input type="password" value={sftpPw} onChange={e => setSftpPw(e.target.value)} placeholder={cfg.sftp?.hasPassword ? 'leer lassen' : ''} className="h-9" /></div>
<div className="col-span-2"><Label className="text-xs">Zielordner (Remote-Pfad)</Label><Input value={cfg.sftp?.remotePath || ''} onChange={e => patchSftp({ remotePath: e.target.value })} placeholder="/lageplan-backups" className="h-9" /></div>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2"><Label className="text-xs">WebDAV-URL</Label><Input value={cfg.webdav?.url || ''} onChange={e => patchWebdav({ url: e.target.value })} placeholder="https://cloud.example.com/remote.php/dav/files/USER/" className="h-9" /></div>
<div className="col-span-2 sm:col-span-1"><Label className="text-xs">Benutzer</Label><Input value={cfg.webdav?.username || ''} onChange={e => patchWebdav({ username: e.target.value })} className="h-9" /></div>
<div className="col-span-2 sm:col-span-1"><Label className="text-xs">App-Passwort {cfg.webdav?.hasPassword && <span className="text-green-600">· gesetzt</span>}</Label><Input type="password" value={webdavPw} onChange={e => setWebdavPw(e.target.value)} placeholder={cfg.webdav?.hasPassword ? 'leer lassen' : 'Nextcloud App-Passwort'} className="h-9" /></div>
<div className="col-span-2"><Label className="text-xs">Zielordner</Label><Input value={cfg.webdav?.remotePath || ''} onChange={e => patchWebdav({ remotePath: e.target.value })} placeholder="/Lageplan-Backups" className="h-9" /></div>
</div>
)}
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={save} disabled={busy !== ''}>{busy === 'save' ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} Speichern</Button>
<Button variant="outline" onClick={test} disabled={busy !== ''}>{busy === 'test' ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <PlugZap className="w-4 h-4 mr-1.5" />} Verbindung testen</Button>
<Button variant="outline" onClick={runNow} disabled={busy !== ''}>{busy === 'run' ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <HardDriveDownload className="w-4 h-4 mr-1.5" />} Jetzt sichern</Button>
</div>
<p className="text-xs text-muted-foreground">Speichern Verbindung testen Jetzt sichern. Restore: siehe <code>docs/BACKUP.md</code> (Entschlüsseln mit der Passphrase).</p>
</div>
)
}

View File

@@ -109,6 +109,15 @@ export function UsersTab() {
} catch { toast({ title: 'Fehler', variant: 'destructive' }) } } catch { toast({ title: 'Fehler', variant: 'destructive' }) }
} }
const handleResetUserMfa = async (targetUser: UserRecord) => {
if (!confirm(`Zwei-Faktor (2FA) von "${targetUser.name}" zurücksetzen? Der Benutzer meldet sich danach nur mit Passwort an, bis er 2FA neu einrichtet.`)) return
try {
const res = await fetch(`/api/admin/users/${targetUser.id}/mfa-reset`, { method: 'POST' })
if (res.ok) { toast({ title: '2FA zurückgesetzt' }) }
else { const err = await res.json(); toast({ title: 'Fehler', description: err.error, variant: 'destructive' }) }
} catch { toast({ title: 'Fehler', variant: 'destructive' }) }
}
const handleToggleUserVerified = async (targetUser: UserRecord) => { const handleToggleUserVerified = async (targetUser: UserRecord) => {
try { try {
const newVal = !targetUser.emailVerified const newVal = !targetUser.emailVerified
@@ -251,6 +260,9 @@ export function UsersTab() {
<Button variant="ghost" size="icon" className="h-8 w-8" title="Passwort zurücksetzen" onClick={() => handleResetUserPassword(u)}> <Button variant="ghost" size="icon" className="h-8 w-8" title="Passwort zurücksetzen" onClick={() => handleResetUserPassword(u)}>
<KeyRound className="w-4 h-4" /> <KeyRound className="w-4 h-4" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-8 w-8" title="Zwei-Faktor (2FA) zurücksetzen" onClick={() => handleResetUserMfa(u)}>
<ShieldCheck className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" title="Bearbeiten" onClick={() => openEditUser(u)}> <Button variant="ghost" size="icon" className="h-8 w-8" title="Bearbeiten" onClick={() => openEditUser(u)}>
<Pencil className="w-4 h-4" /> <Pencil className="w-4 h-4" />
</Button> </Button>

View File

@@ -26,6 +26,8 @@ interface JournalViewProps {
journalfuehrer: string journalfuehrer: string
canEdit: boolean canEdit: boolean
tenantId?: string | null tenantId?: string | null
/** Mandant des OFFENEN Einsatzes (kann von dem des Nutzers abweichen — z.B. SERVER_ADMIN). */
projectTenantId?: string | null
einsatzNr?: string einsatzNr?: string
tenantName?: string tenantName?: string
tenantLogoUrl?: string | null tenantLogoUrl?: string | null
@@ -67,7 +69,7 @@ function ModuleCard({ icon, name, count, children }: {
) )
} }
export function JournalView({ projectId, projectTitle, projectLocation, mode, einsatzleiter, journalfuehrer, canEdit, tenantId, einsatzNr, tenantName, tenantLogoUrl, mapRef, mapScreenshot: preCapuredScreenshot }: JournalViewProps) { export function JournalView({ projectId, projectTitle, projectLocation, mode, einsatzleiter, journalfuehrer, canEdit, tenantId, projectTenantId, einsatzNr, tenantName, tenantLogoUrl, mapRef, mapScreenshot: preCapuredScreenshot }: JournalViewProps) {
const isUebung = mode === 'UEBUNG' const isUebung = mode === 'UEBUNG'
const [entries, setEntries] = useState<JournalEntry[]>([]) const [entries, setEntries] = useState<JournalEntry[]>([])
const [checkItems, setCheckItems] = useState<JournalCheckItem[]>([]) const [checkItems, setCheckItems] = useState<JournalCheckItem[]>([])
@@ -145,16 +147,18 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
} }
}, [projectId]) }, [projectId])
// Cockpit-Modul-Konfiguration laden // Cockpit-Modul-Konfiguration laden — Mandant des OFFENEN Einsatzes (Fallback: eigener)
const modulesTenantId = projectTenantId || tenantId || null
useEffect(() => { useEffect(() => {
fetch('/api/tenant/modules') const url = modulesTenantId ? `/api/tenant/modules?tenantId=${encodeURIComponent(modulesTenantId)}` : '/api/tenant/modules'
fetch(url)
.then(r => r.ok ? r.json() : null) .then(r => r.ok ? r.json() : null)
.then(data => { .then(data => {
if (data?.modules) setModules(data.modules) if (data?.modules) setModules(data.modules)
if (data) setCanManageModules(!!data.canManage) if (data) setCanManageModules(!!data.canManage)
}) })
.catch(() => {}) .catch(() => {})
}, [tenantId]) }, [modulesTenantId])
// Init check items from templates if none exist (guarded against double-call) // Init check items from templates if none exist (guarded against double-call)
const initCheckItems = useCallback(async () => { const initCheckItems = useCallback(async () => {
@@ -516,16 +520,16 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
<div className="h-full overflow-auto bg-stone-50 dark:bg-background print:bg-white print:overflow-visible"> <div className="h-full overflow-auto bg-stone-50 dark:bg-background print:bg-white print:overflow-visible">
{/* Header */} {/* Header */}
<div className="p-3 md:p-4 border-b-2 border-red-600 dark:border-red-800 bg-white dark:bg-card shadow-sm print:bg-white print:p-2 print:border-b-2 print:border-red-600"> <div className="p-3 md:p-4 border-b-2 border-red-600 dark:border-red-800 bg-white dark:bg-card shadow-sm print:bg-white print:p-2 print:border-b-2 print:border-red-600">
<div className="flex items-center justify-between mb-2 print:mb-1"> <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-2 mb-2 print:mb-1">
<h2 className="text-lg md:text-xl font-bold flex items-center gap-2 print:text-base text-red-800 dark:text-red-400"> <h2 className="text-lg md:text-xl font-bold flex items-center gap-2 print:text-base text-red-800 dark:text-red-400">
<ClipboardList className="w-5 h-5 md:w-6 md:h-6 print:w-4 print:h-4" /> <ClipboardList className="w-5 h-5 md:w-6 md:h-6 print:w-4 print:h-4" />
{isUebung ? 'Übungs-Journal' : 'Einsatz-Journal'} {isUebung ? 'Übungs-Journal' : 'Einsatz-Journal'}
</h2> </h2>
<div className="flex gap-1.5 print:hidden"> <div className="flex flex-wrap gap-1.5 print:hidden">
{canManageModules && ( {canManageModules && (
<Button variant="outline" size="sm" onClick={() => setShowModuleManager(true)} title="Cockpit-Module verwalten"> <Button variant="outline" size="sm" onClick={() => setShowModuleManager(true)} title="Cockpit-Module verwalten (Spalten, eigene Module)">
<Blocks className="w-4 h-4 md:mr-1.5" /> <Blocks className="w-4 h-4 mr-1.5" />
<span className="hidden md:inline">Module</span> Module
</Button> </Button>
)} )}
<Button <Button
@@ -785,6 +789,9 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
</Button> </Button>
</div> </div>
<p className="text-[10px] text-muted-foreground mt-1 px-0.5 print:hidden">
Keine Patienten-, Gesundheits-, Polizei-, Passwort-, Zugangs- oder besonders schützenswerten Daten eintragen.
</p>
</div> </div>
)} )}
</div> </div>
@@ -847,6 +854,7 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
open={showModuleManager} open={showModuleManager}
onOpenChange={setShowModuleManager} onOpenChange={setShowModuleManager}
modules={modules} modules={modules}
tenantId={modulesTenantId}
onSaved={setModules} onSaved={setModules}
/> />

View File

@@ -8,7 +8,7 @@ import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { import {
ArrowUp, ArrowDown, Plus, Trash2, Loader2, Blocks, X, ArrowUp, ArrowDown, Plus, Trash2, Loader2, Blocks, X, Pencil, PanelLeft, Columns2, Check,
} from 'lucide-react' } from 'lucide-react'
import { MODULE_PRESETS, type CockpitModule, type ModuleColumn, type ModuleColumnType } from '@/lib/modules' import { MODULE_PRESETS, type CockpitModule, type ModuleColumn, type ModuleColumnType } from '@/lib/modules'
@@ -16,6 +16,8 @@ interface ModuleManagerDialogProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
modules: CockpitModule[] modules: CockpitModule[]
/** Mandant, dessen Module gespeichert werden (Einsatz-Mandant; für SERVER_ADMIN relevant). */
tenantId?: string | null
onSaved: (modules: CockpitModule[]) => void onSaved: (modules: CockpitModule[]) => void
} }
@@ -25,15 +27,17 @@ interface ModuleManagerDialogProps {
* aus Vorlagen erstellen (Checkliste, Atemschutz, Kräfte, Lagemeldungen, leer). * aus Vorlagen erstellen (Checkliste, Atemschutz, Kräfte, Lagemeldungen, leer).
* Gespeichert wird pro Mandant (gilt für alle Einsätze). * Gespeichert wird pro Mandant (gilt für alle Einsätze).
*/ */
export function ModuleManagerDialog({ open, onOpenChange, modules, onSaved }: ModuleManagerDialogProps) { export function ModuleManagerDialog({ open, onOpenChange, modules, tenantId, onSaved }: ModuleManagerDialogProps) {
const [working, setWorking] = useState<CockpitModule[]>(modules) const [working, setWorking] = useState<CockpitModule[]>(modules)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
// Neues Modul aus Vorlage // Neues Modul aus Vorlage / Bearbeiten
const [showAdd, setShowAdd] = useState(false) const [showAdd, setShowAdd] = useState(false)
const [editId, setEditId] = useState<string | null>(null) // null = neu, sonst Modul-ID
const [presetKey, setPresetKey] = useState(MODULE_PRESETS[0].key) const [presetKey, setPresetKey] = useState(MODULE_PRESETS[0].key)
const [newName, setNewName] = useState('') const [newName, setNewName] = useState('')
const [newSlot, setNewSlot] = useState<'main' | 'side'>(MODULE_PRESETS[0].slot)
const [newColumns, setNewColumns] = useState<ModuleColumn[]>(MODULE_PRESETS[0].columns) const [newColumns, setNewColumns] = useState<ModuleColumn[]>(MODULE_PRESETS[0].columns)
useEffect(() => { useEffect(() => {
@@ -41,6 +45,7 @@ export function ModuleManagerDialog({ open, onOpenChange, modules, onSaved }: Mo
setWorking(modules) setWorking(modules)
setError('') setError('')
setShowAdd(false) setShowAdd(false)
setEditId(null)
} }
}, [open, modules]) }, [open, modules])
@@ -64,25 +69,49 @@ export function ModuleManagerDialog({ open, onOpenChange, modules, onSaved }: Mo
const preset = MODULE_PRESETS.find(p => p.key === key)! const preset = MODULE_PRESETS.find(p => p.key === key)!
setPresetKey(key) setPresetKey(key)
setNewName(preset.key === 'leer' ? '' : preset.name) setNewName(preset.key === 'leer' ? '' : preset.name)
setNewSlot(preset.slot)
setNewColumns(preset.columns) setNewColumns(preset.columns)
} }
const openNew = () => {
setEditId(null)
selectPreset(MODULE_PRESETS[0].key)
setShowAdd(true)
}
const openEdit = (m: CockpitModule) => {
setEditId(m.id)
setNewName(m.name)
setNewSlot(m.slot)
const cols: ModuleColumn[] = m.columns && m.columns.length ? m.columns : [{ key: 'text', label: 'Text', type: 'text' }]
setNewColumns(cols.map(c => ({ ...c })))
setShowAdd(true)
}
const addModule = () => { const addModule = () => {
const preset = MODULE_PRESETS.find(p => p.key === presetKey)! const preset = MODULE_PRESETS.find(p => p.key === presetKey)!
const name = newName.trim() || preset.name const name = newName.trim() || preset.name
const mod: CockpitModule = { const columns = newColumns.filter(c => c.label.trim())
id: `custom-${Date.now().toString(36)}`, if (columns.length === 0) return
type: 'table',
name, if (editId) {
icon: preset.icon, // Bestehendes Modul aktualisieren (Name/Slot/Spalten)
slot: preset.slot, setWorking(prev => prev.map(m => m.id === editId ? { ...m, name, slot: newSlot, columns } : m))
enabled: true, } else {
sortOrder: working.length, const mod: CockpitModule = {
columns: newColumns.filter(c => c.label.trim()), id: `custom-${Date.now().toString(36)}`,
type: 'table',
name,
icon: preset.icon,
slot: newSlot,
enabled: true,
sortOrder: working.length,
columns,
}
setWorking(prev => [...prev, mod])
} }
if (!mod.columns || mod.columns.length === 0) return
setWorking(prev => [...prev, mod])
setShowAdd(false) setShowAdd(false)
setEditId(null)
setNewName('') setNewName('')
} }
@@ -106,7 +135,7 @@ export function ModuleManagerDialog({ open, onOpenChange, modules, onSaved }: Mo
const res = await fetch('/api/tenant/modules', { const res = await fetch('/api/tenant/modules', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ modules: working }), body: JSON.stringify({ modules: working, tenantId: tenantId || undefined }),
}) })
const data = await res.json() const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Speichern fehlgeschlagen') if (!res.ok) throw new Error(data.error || 'Speichern fehlgeschlagen')
@@ -121,6 +150,7 @@ export function ModuleManagerDialog({ open, onOpenChange, modules, onSaved }: Mo
const COLUMN_TYPES: { value: ModuleColumnType; label: string }[] = [ const COLUMN_TYPES: { value: ModuleColumnType; label: string }[] = [
{ value: 'text', label: 'Text' }, { value: 'text', label: 'Text' },
{ value: 'number', label: 'Zahl' },
{ value: 'check', label: 'Haken' }, { value: 'check', label: 'Haken' },
{ value: 'time', label: 'Zeit (auto)' }, { value: 'time', label: 'Zeit (auto)' },
] ]
@@ -167,6 +197,11 @@ export function ModuleManagerDialog({ open, onOpenChange, modules, onSaved }: Mo
<button onClick={() => move(idx, 1)} disabled={idx === working.length - 1} className="p-1 text-muted-foreground hover:text-foreground disabled:opacity-30" title="Nach unten"> <button onClick={() => move(idx, 1)} disabled={idx === working.length - 1} className="p-1 text-muted-foreground hover:text-foreground disabled:opacity-30" title="Nach unten">
<ArrowDown className="w-3.5 h-3.5" /> <ArrowDown className="w-3.5 h-3.5" />
</button> </button>
{m.type === 'table' && (
<button onClick={() => openEdit(m)} className="p-1 text-muted-foreground hover:text-foreground" title="Modul bearbeiten">
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{m.type === 'table' && ( {m.type === 'table' && (
<button onClick={() => removeCustom(idx)} className="p-1 text-muted-foreground hover:text-destructive" title="Modul löschen (Daten bleiben in der DB)"> <button onClick={() => removeCustom(idx)} className="p-1 text-muted-foreground hover:text-destructive" title="Modul löschen (Daten bleiben in der DB)">
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
@@ -177,40 +212,63 @@ export function ModuleManagerDialog({ open, onOpenChange, modules, onSaved }: Mo
))} ))}
</div> </div>
{/* Neues Modul */} {/* Neues Modul / Bearbeiten */}
{!showAdd ? ( {!showAdd ? (
<Button variant="outline" size="sm" onClick={() => { selectPreset(MODULE_PRESETS[0].key); setShowAdd(true) }} className="w-full"> <Button variant="outline" size="sm" onClick={openNew} className="w-full">
<Plus className="w-4 h-4 mr-1.5" /> Modul hinzufügen <Plus className="w-4 h-4 mr-1.5" /> Modul hinzufügen
</Button> </Button>
) : ( ) : (
<div className="rounded-lg border p-3 space-y-3 bg-muted/20"> <div className="rounded-lg border p-3 space-y-3 bg-muted/20">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm font-semibold">Neues Modul</p> <p className="text-sm font-semibold">{editId ? 'Modul bearbeiten' : 'Neues Modul'}</p>
<button onClick={() => setShowAdd(false)} className="text-muted-foreground hover:text-foreground"><X className="w-4 h-4" /></button> <button onClick={() => { setShowAdd(false); setEditId(null) }} className="text-muted-foreground hover:text-foreground"><X className="w-4 h-4" /></button>
</div> </div>
{/* Vorlagen */} {/* Vorlagen — nur beim Neuanlegen */}
<div className="grid grid-cols-2 gap-1.5"> {!editId && (
{MODULE_PRESETS.map(p => ( <div className="grid grid-cols-2 gap-1.5">
<button {MODULE_PRESETS.map(p => (
key={p.key} <button
onClick={() => selectPreset(p.key)} key={p.key}
className={`text-left rounded-md border-2 px-2 py-1.5 transition-colors ${ onClick={() => selectPreset(p.key)}
presetKey === p.key ? 'border-primary bg-primary/5' : 'border-border hover:border-muted-foreground' className={`text-left rounded-md border-2 px-2 py-1.5 transition-colors ${
}`} presetKey === p.key ? 'border-primary bg-primary/5' : 'border-border hover:border-muted-foreground'
title={p.description} }`}
> title={p.description}
<p className="text-xs font-medium">{p.name}</p> >
<p className="text-[10px] text-muted-foreground line-clamp-1">{p.description}</p> <p className="text-xs font-medium">{p.name}</p>
</button> <p className="text-[10px] text-muted-foreground line-clamp-1">{p.description}</p>
))} </button>
</div> ))}
</div>
)}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs">Name</Label> <Label className="text-xs">Name</Label>
<Input value={newName} onChange={e => setNewName(e.target.value)} placeholder="z.B. Atemschutz Zug 1" className="h-8 text-sm" /> <Input value={newName} onChange={e => setNewName(e.target.value)} placeholder="z.B. Atemschutz Zug 1" className="h-8 text-sm" />
</div> </div>
{/* Anzeige-Bereich (Slot) frei wählbar */}
<div className="space-y-1.5">
<Label className="text-xs">Anzeige</Label>
<div className="grid grid-cols-2 gap-1.5">
<button
type="button"
onClick={() => setNewSlot('main')}
className={`flex items-center gap-1.5 rounded-md border-2 px-2 py-1.5 text-xs transition-colors ${newSlot === 'main' ? 'border-primary bg-primary/5' : 'border-border hover:border-muted-foreground'}`}
>
<PanelLeft className="w-3.5 h-3.5" /> Hauptbereich (gross)
</button>
<button
type="button"
onClick={() => setNewSlot('side')}
className={`flex items-center gap-1.5 rounded-md border-2 px-2 py-1.5 text-xs transition-colors ${newSlot === 'side' ? 'border-primary bg-primary/5' : 'border-border hover:border-muted-foreground'}`}
>
<Columns2 className="w-3.5 h-3.5" /> Seitenspalte
</button>
</div>
</div>
{/* Spalten-Editor */} {/* Spalten-Editor */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs">Spalten</Label> <Label className="text-xs">Spalten</Label>
@@ -242,7 +300,7 @@ export function ModuleManagerDialog({ open, onOpenChange, modules, onSaved }: Mo
</div> </div>
<Button size="sm" onClick={addModule} disabled={newColumns.filter(c => c.label.trim()).length === 0} className="w-full"> <Button size="sm" onClick={addModule} disabled={newColumns.filter(c => c.label.trim()).length === 0} className="w-full">
<Plus className="w-4 h-4 mr-1" /> Modul erstellen {editId ? <><Check className="w-4 h-4 mr-1" /> Änderungen übernehmen</> : <><Plus className="w-4 h-4 mr-1" /> Modul erstellen</>}
</Button> </Button>
</div> </div>
)} )}

View File

@@ -35,7 +35,8 @@ export function TableModule({ projectId, module, canEdit, notifyChanged }: Table
const [draft, setDraft] = useState<Record<string, string>>({}) const [draft, setDraft] = useState<Record<string, string>>({})
const columns: ModuleColumn[] = module.columns || [] const columns: ModuleColumn[] = module.columns || []
const textColumns = columns.filter(c => c.type === 'text') // Eingebbare Spalten: Text UND Zahl (Zeit = auto, Haken = Klick)
const textColumns = columns.filter(c => c.type === 'text' || c.type === 'number')
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@@ -114,10 +115,17 @@ export function TableModule({ projectId, module, canEdit, notifyChanged }: Table
} }
}, [projectId, module.id, notifyChanged]) }, [projectId, module.id, notifyChanged])
// Spaltenbreite ableiten — respektiert optionale Breiten-Hinweise (w-16 … w-32)
const WIDTH_MAP: Record<string, string> = { 'w-10': '40px', 'w-16': '68px', 'w-20': '92px', 'w-24': '108px', 'w-28': '124px', 'w-32': '140px' }
const colSize = (c: ModuleColumn): string => {
if (c.type === 'check') return '36px'
if (c.type === 'time') return '58px'
if (c.width && WIDTH_MAP[c.width]) return WIDTH_MAP[c.width]
if (c.type === 'number') return '96px'
return 'minmax(90px,1fr)'
}
// Grid-Template aus Spalten ableiten (+ Lösch-Spalte) // Grid-Template aus Spalten ableiten (+ Lösch-Spalte)
const gridCols = columns.map(c => const gridCols = columns.map(colSize).join(' ') + (canEdit ? ' 28px' : '')
c.type === 'check' ? '32px' : c.type === 'time' ? '52px' : 'minmax(0,1fr)'
).join(' ') + (canEdit ? ' 24px' : '')
return ( return (
<div> <div>
@@ -167,6 +175,13 @@ export function TableModule({ projectId, module, canEdit, notifyChanged }: Table
</div> </div>
) )
} }
if (c.type === 'number') {
return (
<div key={c.key} className="px-1.5 py-1.5 font-mono tabular-nums text-right min-w-0 truncate">
{item.data[c.key] || <span className="text-muted-foreground"></span>}
</div>
)
}
return ( return (
<div key={c.key} className="px-1.5 py-1.5 break-words min-w-0"> <div key={c.key} className="px-1.5 py-1.5 break-words min-w-0">
{item.data[c.key] || <span className="text-muted-foreground"></span>} {item.data[c.key] || <span className="text-muted-foreground"></span>}
@@ -186,20 +201,27 @@ export function TableModule({ projectId, module, canEdit, notifyChanged }: Table
</div> </div>
)} )}
{/* Neue Zeile */} {/* Neue Zeile — im selben Raster wie die Spalten, damit Eingaben unter den Titeln stehen */}
{canEdit && ( {canEdit && (
<div className="flex gap-1 px-1.5 py-1.5 border-t border-border/50 print:hidden"> <div className="grid items-center gap-1 px-1.5 py-1.5 border-t border-border/50 print:hidden" style={{ gridTemplateColumns: gridCols }}>
{textColumns.map((c, i) => ( {columns.map(c => {
<Input if (c.type === 'text' || c.type === 'number') {
key={c.key} return (
placeholder={c.label + '…'} <Input
value={draft[c.key] || ''} key={c.key}
onChange={(e) => setDraft(prev => ({ ...prev, [c.key]: e.target.value }))} placeholder={c.label}
onKeyDown={(e) => e.key === 'Enter' && addRow()} inputMode={c.type === 'number' ? 'numeric' : undefined}
className={`h-6 text-[11px] ${i === 0 ? 'flex-1 min-w-0' : 'w-20 shrink-0'}`} value={draft[c.key] || ''}
/> onChange={(e) => setDraft(prev => ({ ...prev, [c.key]: e.target.value }))}
))} onKeyDown={(e) => e.key === 'Enter' && addRow()}
<Button size="sm" variant="ghost" onClick={addRow} disabled={!textColumns.some(c => (draft[c.key] || '').trim())} className="h-6 px-1.5 shrink-0"> className="h-6 text-[11px] min-w-0 px-1.5"
/>
)
}
// Zeit/Haken: leere Zelle (wird automatisch/durch Klick gesetzt)
return <div key={c.key} className="text-center text-[10px] text-muted-foreground/50">{c.type === 'time' ? 'auto' : ''}</div>
})}
<Button size="sm" variant="ghost" onClick={addRow} disabled={!textColumns.some(c => (draft[c.key] || '').trim())} className="h-6 w-6 p-0 shrink-0">
<Plus className="w-3 h-3" /> <Plus className="w-3 h-3" />
</Button> </Button>
</div> </div>

View File

@@ -326,6 +326,10 @@ export function RightSidebar({ onSymbolDrop, onSymbolSelect, canEdit, isOpen, on
/> />
</div> </div>
<p className="text-xs text-muted-foreground mt-1.5">{totalSymbols} Symbole verfügbar</p> <p className="text-xs text-muted-foreground mt-1.5">{totalSymbols} Symbole verfügbar</p>
<p className="text-[10px] text-muted-foreground/80 mt-0.5" title="Verwendung mit schriftlicher Genehmigung der FKS — keine offizielle Partnerschaft/Zertifizierung/Empfehlung.">
Bildquelle: Feuerwehr Koordination Schweiz FKS ·{' '}
<a href="/quellen-und-lizenzen" target="_blank" rel="noopener noreferrer" className="underline hover:text-foreground">Quellen &amp; Lizenzen</a>
</p>
</div> </div>
{isLoading && ( {isLoading && (

View File

@@ -48,6 +48,8 @@ import {
ChevronDown, ChevronDown,
Loader2, Loader2,
Share2, Share2,
Wind,
Satellite,
} from 'lucide-react' } from 'lucide-react'
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog' import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
import { ShareDialog } from '@/components/dialogs/share-dialog' import { ShareDialog } from '@/components/dialogs/share-dialog'
@@ -56,6 +58,12 @@ import type { Project, DrawFeature, ProjectMode } from '@/types'
import { formatDateTime } from '@/lib/utils' import { formatDateTime } from '@/lib/utils'
import { Logo } from '@/components/ui/logo' import { Logo } from '@/components/ui/logo'
// 8-Richtungs-Auswahl (Grad, meteorologisch „Wind aus")
const WIND_OPTIONS: { label: string; deg: number }[] = [
{ label: 'N', deg: 0 }, { label: 'NO', deg: 45 }, { label: 'O', deg: 90 }, { label: 'SO', deg: 135 },
{ label: 'S', deg: 180 }, { label: 'SW', deg: 225 }, { label: 'W', deg: 270 }, { label: 'NW', deg: 315 },
]
interface TopbarProps { interface TopbarProps {
project: Project | null project: Project | null
onNewProject: (mode?: ProjectMode) => void onNewProject: (mode?: ProjectMode) => void
@@ -82,6 +90,10 @@ interface TopbarProps {
onTogglePresentationLock?: () => void onTogglePresentationLock?: () => void
/** Windrichtung wird live aktualisiert (für die Live-Anzeige oben) */ /** Windrichtung wird live aktualisiert (für die Live-Anzeige oben) */
windLive?: boolean windLive?: boolean
/** Windrichtung setzen/entfernen (Grad, meteorologisch „Wind aus"; null = entfernen) */
onWindChange?: (dir: number | null) => void
/** Live-Wind (Open-Meteo) vom Standort holen */
onFetchLiveWind?: () => Promise<void> | void
/** true, wenn der aktuelle Nutzer den Bearbeitungs-Lock hält */ /** true, wenn der aktuelle Nutzer den Bearbeitungs-Lock hält */
isEditingByMe?: boolean isEditingByMe?: boolean
/** Karte speichern & für andere freigeben (Lock lösen) */ /** Karte speichern & für andere freigeben (Lock lösen) */
@@ -115,6 +127,8 @@ export function Topbar({
presentationLocked, presentationLocked,
onTogglePresentationLock, onTogglePresentationLock,
windLive, windLive,
onWindChange,
onFetchLiveWind,
isEditingByMe, isEditingByMe,
onStopEditing, onStopEditing,
editingLoading, editingLoading,
@@ -138,6 +152,8 @@ export function Topbar({
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null) const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [windLoading, setWindLoading] = useState(false)
// Live clock // Live clock
const [now, setNow] = useState(new Date()) const [now, setNow] = useState(new Date())
useEffect(() => { useEffect(() => {
@@ -214,20 +230,70 @@ export function Topbar({
{/* Zone 3 — Uhr + Aktionen */} {/* Zone 3 — Uhr + Aktionen */}
<div className="flex items-center gap-1 md:gap-1.5 shrink-0"> <div className="flex items-center gap-1 md:gap-1.5 shrink-0">
{/* Wind — taktische Live-Anzeige (Pfeil zeigt Ausbreitungsrichtung, N oben) */} {/* Wind — klare, einzige Wind-Anzeige. Klick öffnet den Einsteller (Live vom Standort
{typeof project?.windDirection === 'number' && ( oder Richtung wählen). Der Pfeil zeigt die Ausbreitungsrichtung, N oben. */}
<div {project && onWindChange && (
className="hidden sm:flex items-center gap-1.5 rounded-md bg-blue-600 text-white pl-1.5 pr-2 py-1 shrink-0" <DropdownMenu>
title={`Wind aus ${degToCompass(project.windDirection)} (${project.windDirection}°) — treibt nach ${degToCompass(project.windDirection + 180)}${windLive ? ' · live' : ''}`} <DropdownMenuTrigger asChild>
> {typeof project.windDirection === 'number' ? (
<svg viewBox="0 0 24 24" className="w-4 h-4 shrink-0" style={{ transform: `rotate(${project.windDirection + 180}deg)` }} aria-hidden> <button
<path d="M12 3 L18 16 L12 12.5 L6 16 Z" fill="currentColor" /> className="hidden sm:flex items-center gap-1.5 rounded-md bg-blue-600 hover:bg-blue-700 text-white pl-1.5 pr-2 py-1 shrink-0"
</svg> title={`Wind aus ${degToCompass(project.windDirection)} (${project.windDirection}°) — treibt nach ${degToCompass(project.windDirection + 180)}. Klicken zum Ändern.`}
<span className="text-xs font-bold leading-none whitespace-nowrap"> >
<span className="hidden lg:inline opacity-80 font-medium">Wind </span>aus {degToCompass(project.windDirection)} <Wind className="w-3.5 h-3.5 shrink-0 opacity-90" />
</span> <svg viewBox="0 0 24 24" className="w-4 h-4 shrink-0" style={{ transform: `rotate(${project.windDirection + 180}deg)` }} aria-hidden>
{windLive && <span className="w-1.5 h-1.5 rounded-full bg-green-300 animate-pulse shrink-0" title="Live" />} <path d="M12 3 L18 16 L12 12.5 L6 16 Z" fill="currentColor" />
</div> </svg>
<span className="text-xs font-bold leading-none whitespace-nowrap">
Wind aus {degToCompass(project.windDirection)}
</span>
{windLive && <span className="w-1.5 h-1.5 rounded-full bg-green-300 animate-pulse shrink-0" title="Live" />}
</button>
) : (
<Button variant="outline" size="sm" className="hidden sm:flex h-9 md:h-10 px-2 shrink-0" title="Windrichtung setzen">
<Wind className="w-4 h-4 lg:mr-1.5" />
<span className="hidden lg:inline">Wind</span>
</Button>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52 p-2">
<div className="flex items-center gap-1.5 px-1 pb-1.5 text-xs font-semibold text-muted-foreground">
<Wind className="w-3.5 h-3.5" /> Windrichtung (Wind aus)
</div>
{onFetchLiveWind && (
<button
onClick={async () => { setWindLoading(true); try { await onFetchLiveWind() } finally { setWindLoading(false) } }}
disabled={windLoading}
className="w-full mb-2 flex items-center justify-center gap-1.5 rounded-md bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold py-1.5 disabled:opacity-60"
>
{windLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Satellite className="w-3.5 h-3.5" />}
Live vom Standort
</button>
)}
<div className="text-[10px] text-muted-foreground mb-1 px-0.5">oder Richtung wählen:</div>
<div className="grid grid-cols-4 gap-1">
{WIND_OPTIONS.map((w) => (
<button
key={w.deg}
onClick={() => onWindChange(w.deg)}
className={`h-8 rounded-md text-xs font-semibold transition-colors ${
project.windDirection === w.deg ? 'bg-blue-600 text-white' : 'bg-muted hover:bg-accent text-foreground'
}`}
>
{w.label}
</button>
))}
</div>
{typeof project.windDirection === 'number' && (
<button
onClick={() => onWindChange(null)}
className="mt-2 w-full text-xs text-muted-foreground hover:text-destructive py-1"
>
Wind entfernen
</button>
)}
</DropdownMenuContent>
</DropdownMenu>
)} )}
{/* Uhr — Ops-Rooms leben nach der Uhr */} {/* Uhr — Ops-Rooms leben nach der Uhr */}
@@ -248,6 +314,28 @@ export function Topbar({
{isSaving ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />} {isSaving ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
</Button> </Button>
{/* Schnellzugriff: Vollbild + Präsentationsmodus (Desktop; mobil im ⋮-Menü) */}
<Button
variant="outline"
size="icon"
className="hidden md:flex h-10 w-10 shrink-0"
onClick={onToggleFullscreen}
title={isFullscreen ? 'Vollbild verlassen' : 'Vollbild'}
>
{isFullscreen ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
</Button>
{onTogglePresentationLock && (
<Button
variant="outline"
size="icon"
className={`hidden md:flex h-10 w-10 shrink-0 ${presentationLocked ? 'border-amber-500 text-amber-600 dark:text-amber-400' : ''}`}
onClick={onTogglePresentationLock}
title={presentationLocked ? 'Präsentation: gesperrt (zum Entsperren tippen)' : 'Präsentationsmodus'}
>
{presentationLocked ? <Lock className="w-5 h-5" /> : <Unlock className="w-5 h-5" />}
</Button>
)}
{/* Freigeben — nur sichtbar, solange ich den Bearbeitungs-Lock halte. {/* Freigeben — nur sichtbar, solange ich den Bearbeitungs-Lock halte.
Speichert und gibt die Karte für andere frei. Grün = ich bin am Zeichnen. */} Speichert und gibt die Karte für andere frei. Grün = ich bin am Zeichnen. */}
{isEditingByMe && onStopEditing && ( {isEditingByMe && onStopEditing && (
@@ -370,6 +458,14 @@ export function Topbar({
<Key className="w-4 h-4 mr-2" /> <Key className="w-4 h-4 mr-2" />
Kennwort ändern Kennwort ändern
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => window.location.href = '/settings'}>
<Lock className="w-4 h-4 mr-2" />
Sicherheit &amp; 2FA
</DropdownMenuItem>
<DropdownMenuItem onClick={() => window.location.href = '/konto/datenschutz'}>
<Shield className="w-4 h-4 mr-2" />
Konto &amp; Datenschutz
</DropdownMenuItem>
{(userRole === 'SERVER_ADMIN' || userRole === 'TENANT_ADMIN') && ( {(userRole === 'SERVER_ADMIN' || userRole === 'TENANT_ADMIN') && (
<DropdownMenuItem onClick={() => window.location.href = '/admin'}> <DropdownMenuItem onClick={() => window.location.href = '/admin'}>
<Shield className="w-4 h-4 mr-2" /> <Shield className="w-4 h-4 mr-2" />
@@ -492,6 +588,14 @@ export function Topbar({
<Key className="w-4 h-4 mr-2" /> <Key className="w-4 h-4 mr-2" />
Kennwort ändern Kennwort ändern
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => window.location.href = '/settings'}>
<Lock className="w-4 h-4 mr-2" />
Sicherheit &amp; 2FA
</DropdownMenuItem>
<DropdownMenuItem onClick={() => window.location.href = '/konto/datenschutz'}>
<Shield className="w-4 h-4 mr-2" />
Konto &amp; Datenschutz
</DropdownMenuItem>
{onLogout && ( {onLogout && (
<DropdownMenuItem onClick={onLogout} className="text-destructive focus:text-destructive"> <DropdownMenuItem onClick={onLogout} className="text-destructive focus:text-destructive">
<LogOut className="w-4 h-4 mr-2" /> <LogOut className="w-4 h-4 mr-2" />

View File

@@ -11,6 +11,7 @@ export const LEGAL_LINKS: { href: string; label: string }[] = [
{ href: '/nutzungsbedingungen', label: 'Nutzungsbedingungen' }, { href: '/nutzungsbedingungen', label: 'Nutzungsbedingungen' },
{ href: '/verantwortungsvolle-nutzung', label: 'Verantwortungsvolle Nutzung' }, { href: '/verantwortungsvolle-nutzung', label: 'Verantwortungsvolle Nutzung' },
{ href: '/sicherheit', label: 'Sicherheit' }, { href: '/sicherheit', label: 'Sicherheit' },
{ href: '/quellen-und-lizenzen', label: 'Quellen & Lizenzen' },
{ href: '/organisationen/datenschutzvereinbarung', label: 'Datenschutzvereinbarung (Org.)' }, { href: '/organisationen/datenschutzvereinbarung', label: 'Datenschutzvereinbarung (Org.)' },
{ href: '/unterstuetzen', label: 'Unterstützen' }, { href: '/unterstuetzen', label: 'Unterstützen' },
] ]

View File

@@ -1,124 +1,42 @@
'use client' 'use client'
import { useState } from 'react'
import { Wind, X, Loader2, Satellite } from 'lucide-react'
interface MapCompassProps { interface MapCompassProps {
/** Karten-Bearing in Grad (0 = Norden oben) */ /** Karten-Bearing in Grad (0 = Norden oben) */
bearing: number bearing: number
/** Windrichtung meteorologisch „Wind aus" in Grad, oder null */ // Die folgenden Props werden nicht mehr verwendet (Wind lebt jetzt oben in der Kopfzeile),
windDirection: number | null | undefined // bleiben aber optional erhalten, damit bestehende Aufrufe nicht brechen.
canEdit: boolean windDirection?: number | null
onWindChange: (dir: number | null) => void canEdit?: boolean
/** Live-Wind (Open-Meteo) für den aktuellen Standort holen */ onWindChange?: (dir: number | null) => void
onFetchLiveWind?: () => Promise<void> | void onFetchLiveWind?: () => Promise<void> | void
} }
const DIRS = ['N', 'NO', 'O', 'SO', 'S', 'SW', 'W', 'NW'] const DIRS = ['N', 'NO', 'O', 'SO', 'S', 'SW', 'W', 'NW']
/** Grad → Kompass-Kürzel (N, NO, …). Wird auch von der Kopfzeile für die Wind-Anzeige genutzt. */
export function degToCompass(deg: number): string { export function degToCompass(deg: number): string {
return DIRS[Math.round(((deg % 360) + 360) % 360 / 45) % 8] return DIRS[Math.round(((deg % 360) + 360) % 360 / 45) % 8]
} }
// 8-Richtungs-Auswahl (Grad, meteorologisch „aus") /**
const WIND_OPTIONS: { label: string; deg: number }[] = [ * Reiner Nordpfeil/Kompass auf der Karte (dreht mit dem Karten-Bearing).
{ label: 'N', deg: 0 }, { label: 'NO', deg: 45 }, { label: 'O', deg: 90 }, { label: 'SO', deg: 135 }, * Die Windrichtung wird NICHT mehr hier angezeigt — sie lebt eindeutig in der Kopfzeile.
{ label: 'S', deg: 180 }, { label: 'SW', deg: 225 }, { label: 'W', deg: 270 }, { label: 'NW', deg: 315 }, */
] export function MapCompass({ bearing }: MapCompassProps) {
export function MapCompass({ bearing, windDirection, canEdit, onWindChange, onFetchLiveWind }: MapCompassProps) {
const [open, setOpen] = useState(false)
const [loadingLive, setLoadingLive] = useState(false)
const hasWind = typeof windDirection === 'number'
const handleLive = async () => {
if (!onFetchLiveWind) return
setLoadingLive(true)
try { await onFetchLiveWind() } finally { setLoadingLive(false) }
}
return ( return (
<div className="absolute top-3 left-[60px] md:left-3 z-10 flex flex-col items-center gap-1.5"> <div className="absolute top-3 left-[60px] md:left-3 z-10">
{/* Kompass mit Nordpfeil (dreht mit dem Karten-Bearing) */} <div
<button className="w-12 h-12 rounded-full bg-white/95 dark:bg-gray-900/95 shadow-md border border-black/10 dark:border-white/10 flex items-center justify-center"
type="button" title="Nordpfeil"
onClick={() => canEdit && setOpen((o) => !o)}
className="w-12 h-12 rounded-full bg-white/95 dark:bg-gray-900/95 shadow-md border border-black/10 dark:border-white/10 flex items-center justify-center relative"
title={canEdit ? 'Wind einstellen' : 'Nordpfeil'}
style={{ cursor: canEdit ? 'pointer' : 'default' }}
> >
<svg viewBox="0 0 40 40" className="w-10 h-10" style={{ transform: `rotate(${-bearing}deg)` }}> <svg viewBox="0 0 40 40" className="w-10 h-10" style={{ transform: `rotate(${-bearing}deg)` }}>
{/* Nordnadel (rot) */} {/* Nordnadel (rot) */}
<polygon points="20,5 24,21 20,18 16,21" fill="#dc2626" /> <polygon points="20,5 24,21 20,18 16,21" fill="#dc2626" />
{/* Südnadel (grau) */} {/* Südnadel (grau) */}
<polygon points="20,35 24,19 20,22 16,19" fill="#9ca3af" /> <polygon points="20,35 24,19 20,22 16,19" fill="#9ca3af" />
<text x="20" y="12" textAnchor="middle" fontSize="7" fontWeight="700" fill="#dc2626" transform="rotate(0 20 20)">N</text> <text x="20" y="12" textAnchor="middle" fontSize="7" fontWeight="700" fill="#dc2626">N</text>
</svg> </svg>
</button> </div>
{/* Wind-Anzeige */}
{hasWind && (
<div className="flex items-center gap-1 rounded-full bg-blue-600 text-white text-[11px] font-semibold pl-1.5 pr-2 py-0.5 shadow">
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5" style={{ transform: `rotate(${(windDirection as number) + 180 - bearing}deg)` }}>
{/* Pfeil zeigt in die Drift-/Ausbreitungsrichtung (wohin es weht) */}
<path d="M12 3 L18 15 L12 12 L6 15 Z" fill="currentColor" />
</svg>
<span>aus {degToCompass(windDirection as number)}</span>
</div>
)}
{!hasWind && canEdit && (
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1 rounded-full bg-white/95 dark:bg-gray-900/95 text-[11px] font-medium text-muted-foreground pl-1.5 pr-2 py-0.5 shadow border border-black/10 dark:border-white/10"
title="Windrichtung einstellen"
>
<Wind className="w-3.5 h-3.5" /> Wind
</button>
)}
{/* Wind-Auswahl */}
{open && canEdit && (
<div className="absolute top-0 left-14 w-40 rounded-xl bg-white dark:bg-gray-900 shadow-xl border border-border p-2 z-20">
<div className="flex items-center justify-between mb-1.5 px-1">
<span className="text-xs font-semibold flex items-center gap-1"><Wind className="w-3.5 h-3.5" /> Wind aus</span>
<button onClick={() => setOpen(false)} className="text-muted-foreground hover:text-foreground"><X className="w-3.5 h-3.5" /></button>
</div>
{onFetchLiveWind && (
<button
onClick={handleLive}
disabled={loadingLive}
className="w-full mb-1.5 flex items-center justify-center gap-1.5 rounded-md bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold py-1.5 disabled:opacity-60"
title="Aktuelle Windrichtung vom Standort (Open-Meteo)"
>
{loadingLive ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Satellite className="w-3.5 h-3.5" />}
Live vom Standort
</button>
)}
<div className="text-[10px] text-muted-foreground mb-1 px-0.5">oder Richtung wählen:</div>
<div className="grid grid-cols-4 gap-1">
{WIND_OPTIONS.map((w) => (
<button
key={w.deg}
onClick={() => { onWindChange(w.deg); setOpen(false) }}
className={`h-8 rounded-md text-xs font-semibold transition-colors ${
windDirection === w.deg ? 'bg-blue-600 text-white' : 'bg-muted hover:bg-accent text-foreground'
}`}
>
{w.label}
</button>
))}
</div>
{hasWind && (
<button
onClick={() => { onWindChange(null); setOpen(false) }}
className="mt-1.5 w-full text-xs text-muted-foreground hover:text-destructive py-1"
>
Wind entfernen
</button>
)}
</div>
)}
</div> </div>
) )
} }

View File

@@ -1489,27 +1489,31 @@ export function MapView({
const lineCoords = f.geometry.coordinates as number[][] const lineCoords = f.geometry.coordinates as number[][]
if (lineCoords.length < 2) return if (lineCoords.length < 2) return
// Geographic bearing from p1 to p2 (works for short distances)
const p1 = lineCoords[lineCoords.length - 2] const p1 = lineCoords[lineCoords.length - 2]
const p2 = lineCoords[lineCoords.length - 1] const p2 = lineCoords[lineCoords.length - 1]
const dLng = p2[0] - p1[0] // Bildschirm-Winkel aus projizierten Pixeln berechnen — der geografische Winkel
const dLat = p2[1] - p1[1] // (atan2 auf lng/lat) stimmt wegen der Mercator-Verzerrung NICHT mit der auf dem
// atan2(dLng, dLat) gives angle from north (up), clockwise — matches CSS triangle ▲ default // Bildschirm gezeichneten Linie überein (v.a. bei nördlichen Breiten). So zeigt die
const geoBearing = Math.atan2(dLng, dLat) * (180 / Math.PI) // Spitze exakt entlang der Linie.
const a = map.current.project(p1 as [number, number])
const b = map.current.project(p2 as [number, number])
// CSS-Dreieck zeigt nach oben (y). Winkel im Uhrzeigersinn von „oben".
const screenAngle = Math.atan2(b.x - a.x, -(b.y - a.y)) * (180 / Math.PI)
const color = (f.properties.color as string) || '#000000' const color = (f.properties.color as string) || '#000000'
const arrowEl = document.createElement('div') const arrowEl = document.createElement('div')
arrowEl.style.cssText = ` arrowEl.style.cssText = `
width: 0; height: 0; width: 0; height: 0;
border-left: 12px solid transparent; border-left: 10px solid transparent;
border-right: 12px solid transparent; border-right: 10px solid transparent;
border-bottom: 24px solid ${color}; border-bottom: 20px solid ${color};
transform: rotate(${geoBearing}deg); transform: rotate(${screenAngle}deg);
transform-origin: center center; transform-origin: center center;
pointer-events: none; pointer-events: none;
` `
const marker = new maplibregl.Marker({ element: arrowEl, anchor: 'center', rotationAlignment: 'map' }) // Viewport-ausgerichtet (Bildschirm), passend zum bildschirm-basierten Winkel.
const marker = new maplibregl.Marker({ element: arrowEl, anchor: 'center' })
.setLngLat(p2 as [number, number]) .setLngLat(p2 as [number, number])
.addTo(map.current) .addTo(map.current)
markersRef.current.push(marker) markersRef.current.push(marker)

View File

@@ -29,7 +29,9 @@ interface AuthContextType {
user: User | null user: User | null
tenant: TenantInfo | null tenant: TenantInfo | null
loading: boolean loading: boolean
login: (email: string, password: string, rememberMe?: boolean) => Promise<{ success: boolean; error?: string }> login: (email: string, password: string, rememberMe?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean; mfaToken?: string; methods?: { totp: boolean; webauthn: boolean } }>
verifyMfa: (mfaToken: string, code: string) => Promise<{ success: boolean; error?: string }>
verifyMfaWebAuthn: (mfaToken: string) => Promise<{ success: boolean; error?: string }>
logout: () => Promise<void> logout: () => Promise<void>
canEdit: () => boolean canEdit: () => boolean
isAdmin: () => boolean isAdmin: () => boolean
@@ -77,12 +79,59 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return { success: true } return { success: true }
} }
// Zweiter Faktor erforderlich — Login noch nicht abgeschlossen.
if (res.ok && data.mfaRequired) {
return { success: false, mfaRequired: true, mfaToken: data.mfaToken, methods: data.methods }
}
return { success: false, error: data.error || 'Login fehlgeschlagen' } return { success: false, error: data.error || 'Login fehlgeschlagen' }
} catch (error) { } catch (error) {
return { success: false, error: 'Verbindungsfehler' } return { success: false, error: 'Verbindungsfehler' }
} }
} }
const verifyMfa = async (mfaToken: string, code: string) => {
try {
const res = await fetch('/api/auth/mfa-login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mfaToken, code }),
})
const data = await res.json()
if (res.ok && data.user) {
setUser(data.user)
return { success: true }
}
return { success: false, error: data.error || 'Code ungültig' }
} catch {
return { success: false, error: 'Verbindungsfehler' }
}
}
const verifyMfaWebAuthn = async (mfaToken: string) => {
try {
const { startAuthentication } = await import('@simplewebauthn/browser')
const optRes = await fetch('/api/auth/mfa-login/webauthn/options', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mfaToken }),
})
const opt = await optRes.json()
if (!optRes.ok) return { success: false, error: opt.error || 'Fehler' }
const assertion = await startAuthentication({ optionsJSON: opt.options })
const verRes = await fetch('/api/auth/mfa-login/webauthn/verify', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mfaToken, challengeToken: opt.challengeToken, response: assertion }),
})
const data = await verRes.json()
if (verRes.ok && data.user) { setUser(data.user); return { success: true } }
return { success: false, error: data.error || 'Verifikation fehlgeschlagen' }
} catch (e: any) {
if (e?.name === 'NotAllowedError' || e?.name === 'AbortError') return { success: false, error: 'Abgebrochen' }
return { success: false, error: 'Sicherheitsschlüssel-Fehler' }
}
}
const logout = async () => { const logout = async () => {
try { try {
await fetch('/api/auth/logout', { method: 'POST' }) await fetch('/api/auth/logout', { method: 'POST' })
@@ -106,7 +155,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
} }
return ( return (
<AuthContext.Provider value={{ user, tenant, loading, login, logout, canEdit, isAdmin, isServerAdmin }}> <AuthContext.Provider value={{ user, tenant, loading, login, verifyMfa, verifyMfaWebAuthn, logout, canEdit, isAdmin, isServerAdmin }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
) )

View File

@@ -0,0 +1,186 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { ShieldCheck, Loader2, KeyRound, Copy, Check, Usb, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
interface Status { totpEnabled: boolean; backupCodesRemaining: number; webauthn: { id: string; deviceName: string | null }[]; hasMfa: boolean }
export function MfaSettings() {
const { toast } = useToast()
const [status, setStatus] = useState<Status | null>(null)
const [setup, setSetup] = useState<{ qr: string; secret: string } | null>(null)
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [backupCodes, setBackupCodes] = useState<string[] | null>(null)
const [copied, setCopied] = useState(false)
const refresh = useCallback(async () => {
try {
const res = await fetch('/api/auth/mfa/status')
if (res.ok) setStatus(await res.json())
} catch { /* ignore */ }
}, [])
useEffect(() => { refresh() }, [refresh])
const startSetup = async () => {
setBusy(true)
try {
const res = await fetch('/api/auth/mfa/totp/setup', { method: 'POST' })
const d = await res.json()
if (res.ok) setSetup({ qr: d.qr, secret: d.secret })
else toast({ title: 'Fehler', description: d.error, variant: 'destructive' })
} finally { setBusy(false) }
}
const confirm = async () => {
setBusy(true)
try {
const res = await fetch('/api/auth/mfa/totp/confirm', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }),
})
const d = await res.json()
if (res.ok) {
setSetup(null); setCode('')
if (d.backupCodes) setBackupCodes(d.backupCodes)
toast({ title: 'Zwei-Faktor aktiviert', description: 'Ab jetzt beim Login erforderlich.' })
refresh()
} else {
toast({ title: 'Code ungültig', description: d.error, variant: 'destructive' })
}
} finally { setBusy(false) }
}
const disable = async () => {
if (!confirm2('Zwei-Faktor wirklich deaktivieren? Dein Konto ist danach nur noch mit Passwort geschützt.')) return
setBusy(true)
try {
const res = await fetch('/api/auth/mfa/totp/disable', { method: 'POST' })
if (res.ok) { toast({ title: 'Deaktiviert', description: 'Authenticator-App entfernt.' }); refresh() }
} finally { setBusy(false) }
}
const copyBackup = async () => {
if (!backupCodes) return
try { await navigator.clipboard.writeText(backupCodes.join('\n')); setCopied(true); setTimeout(() => setCopied(false), 2000) } catch { /* */ }
}
const addWebAuthn = async () => {
setBusy(true)
try {
const { startRegistration } = await import('@simplewebauthn/browser')
const optRes = await fetch('/api/auth/mfa/webauthn/register/options', { method: 'POST' })
const opt = await optRes.json()
if (!optRes.ok) { toast({ title: 'Fehler', description: opt.error, variant: 'destructive' }); return }
const att = await startRegistration({ optionsJSON: opt.options })
const name = (typeof window !== 'undefined' && window.prompt('Name für diesen Schlüssel (z. B. „YubiKey blau", „iPhone"):', 'Sicherheitsschlüssel')) || 'Sicherheitsschlüssel'
const res = await fetch('/api/auth/mfa/webauthn/register/verify', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ response: att, challengeToken: opt.challengeToken, deviceName: name }),
})
const d = await res.json()
if (res.ok) { toast({ title: 'Schlüssel registriert' }); refresh() }
else toast({ title: 'Fehler', description: d.error, variant: 'destructive' })
} catch (e: any) {
if (e?.name !== 'NotAllowedError' && e?.name !== 'AbortError') toast({ title: 'Abgebrochen oder nicht unterstützt', variant: 'destructive' })
} finally { setBusy(false) }
}
const removeWebAuthn = async (id: string) => {
if (!confirm2('Diesen Sicherheitsschlüssel entfernen?')) return
setBusy(true)
try {
const res = await fetch(`/api/auth/mfa/webauthn/${id}`, { method: 'DELETE' })
if (res.ok) { toast({ title: 'Entfernt' }); refresh() }
} finally { setBusy(false) }
}
return (
<div className="bg-card rounded-lg border p-5">
<h2 className="text-lg font-semibold mb-1 flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-muted-foreground" />
Zwei-Faktor-Authentisierung (2FA)
</h2>
<p className="text-sm text-muted-foreground mb-4">
Zusätzlicher Schutz beim Login mit einer Authenticator-App. Empfohlen besonders für Administratoren.
</p>
{/* Einmalig angezeigte Backup-Codes */}
{backupCodes && (
<div className="mb-4 rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-800 p-4">
<p className="text-sm font-semibold text-amber-800 dark:text-amber-300 mb-2">Backup-Codes jetzt sicher speichern!</p>
<p className="text-xs text-amber-700 dark:text-amber-400 mb-3">Jeder Code funktioniert einmal, falls du keinen Zugriff auf die App hast. Sie werden nur dieses eine Mal angezeigt.</p>
<div className="grid grid-cols-2 gap-1.5 font-mono text-sm">
{backupCodes.map(c => <span key={c} className="bg-white dark:bg-black/30 rounded px-2 py-1 text-center">{c}</span>)}
</div>
<div className="flex gap-2 mt-3">
<Button size="sm" variant="outline" onClick={copyBackup}>{copied ? <Check className="w-4 h-4 mr-1 text-green-600" /> : <Copy className="w-4 h-4 mr-1" />} Kopieren</Button>
<Button size="sm" onClick={() => setBackupCodes(null)}>Gespeichert</Button>
</div>
</div>
)}
{!status ? (
<div className="py-4 flex justify-center"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>
) : status.totpEnabled ? (
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="text-sm">
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 text-green-700 dark:bg-green-950/40 dark:text-green-400 px-2.5 py-1 font-medium">
<ShieldCheck className="w-3.5 h-3.5" /> Authenticator-App aktiv
</span>
<span className="ml-3 text-muted-foreground text-xs">{status.backupCodesRemaining} Backup-Codes übrig</span>
</div>
<Button variant="outline" size="sm" onClick={disable} disabled={busy} className="text-destructive hover:text-destructive">Deaktivieren</Button>
</div>
) : setup ? (
<div className="space-y-3">
<p className="text-sm">1. Scanne den QR-Code mit Google Authenticator, Microsoft Authenticator, Authy o. Ä.:</p>
{setup.qr && (
// eslint-disable-next-line @next/next/no-img-element
<img src={setup.qr} alt="TOTP QR-Code" className="w-44 h-44 rounded-lg border border-border" />
)}
<p className="text-xs text-muted-foreground">Manuell: <code className="font-mono">{setup.secret}</code></p>
<p className="text-sm">2. Gib den angezeigten 6-stelligen Code ein:</p>
<div className="flex gap-2">
<Input inputMode="numeric" placeholder="123456" value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} className="font-mono tracking-widest w-40" />
<Button onClick={confirm} disabled={busy || code.length !== 6}>{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Aktivieren'}</Button>
<Button variant="outline" onClick={() => { setSetup(null); setCode('') }}>Abbrechen</Button>
</div>
</div>
) : (
<Button onClick={startSetup} disabled={busy}>
<KeyRound className="w-4 h-4 mr-2" /> Authenticator-App einrichten
</Button>
)}
{/* Sicherheitsschlüssel / Passkeys (WebAuthn) */}
{status && (
<div className="mt-5 pt-5 border-t border-border">
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2"><Usb className="w-4 h-4 text-muted-foreground" /> Sicherheitsschlüssel &amp; Passkeys</h3>
{status.webauthn.length > 0 && (
<ul className="mb-3 space-y-1.5">
{status.webauthn.map(w => (
<li key={w.id} className="flex items-center justify-between text-sm rounded-lg border border-border px-3 py-1.5">
<span className="flex items-center gap-2"><KeyRound className="w-3.5 h-3.5 text-muted-foreground" /> {w.deviceName || 'Sicherheitsschlüssel'}</span>
<button onClick={() => removeWebAuthn(w.id)} disabled={busy} className="text-destructive hover:opacity-70" title="Entfernen"><Trash2 className="w-4 h-4" /></button>
</li>
))}
</ul>
)}
<Button variant="outline" size="sm" onClick={addWebAuthn} disabled={busy}>
<Usb className="w-4 h-4 mr-2" /> Sicherheitsschlüssel / Passkey hinzufügen
</Button>
<p className="text-xs text-muted-foreground mt-2">YubiKey, Touch-ID/Windows Hello oder Passkey. Funktioniert nur über HTTPS.</p>
</div>
)}
</div>
)
}
// Vermeidet Namenskollision mit dem lokalen confirm()-State-Setter oben.
function confirm2(msg: string): boolean {
return typeof window !== 'undefined' ? window.confirm(msg) : true
}

View File

@@ -18,9 +18,9 @@ describe('isPlaceholder', () => {
}) })
describe('legal config', () => { describe('legal config', () => {
it('führt genau 5 Rechtsdokumenttypen', () => { it('führt die erwarteten Rechtsdokumenttypen (inkl. FKS-Quellenangabe)', () => {
expect(Object.keys(legal.documents).sort()).toEqual( expect(Object.keys(legal.documents).sort()).toEqual(
['dataProcessingAgreement', 'organizationDeclaration', 'privacy', 'responsibleUse', 'terms'] ['dataProcessingAgreement', 'fksAttribution', 'organizationDeclaration', 'privacy', 'responsibleUse', 'terms']
) )
}) })
it('listet nur real genutzte Subprozessoren (keine erfundenen wie Google Analytics)', () => { it('listet nur real genutzte Subprozessoren (keine erfundenen wie Google Analytics)', () => {

View File

@@ -62,6 +62,8 @@ export const legal = {
operatorName: env('LEGAL_OPERATOR_NAME', 'Perparim Ziberi'), operatorName: env('LEGAL_OPERATOR_NAME', 'Perparim Ziberi'),
/** Rechtsform */ /** Rechtsform */
operatorLegalForm: env('LEGAL_OPERATOR_FORM', 'Privatperson'), operatorLegalForm: env('LEGAL_OPERATOR_FORM', 'Privatperson'),
/** Betreibertyp (Privatperson). Eine spätere Übertragung auf einen Verein ist nur eine offene Option. */
operatorType: env('LEGAL_OPERATOR_TYPE', 'Privatperson'),
/** Vollständige Postadresse des Betreibers */ /** Vollständige Postadresse des Betreibers */
postalAddress: env('LEGAL_POSTAL_ADDRESS', 'Anglikerstrasse 56, 5612 Villmergen'), postalAddress: env('LEGAL_POSTAL_ADDRESS', 'Anglikerstrasse 56, 5612 Villmergen'),
@@ -96,18 +98,28 @@ export const legal = {
// Nichts erfinden: nur tatsächlich im Code kontaktierte Dienste. Sitz/Region, die nicht // Nichts erfinden: nur tatsächlich im Code kontaktierte Dienste. Sitz/Region, die nicht
// sicher bekannt ist, bleibt Platzhalter. // sicher bekannt ist, bleibt Platzhalter.
processors: [ processors: [
{ name: 'OpenStreetMap (Tiles)', purpose: 'Basiskarte', data: 'Kachel-Koordinaten (Kartenausschnitt)', region: '[REGION OSM Foundation, EU/UK]' }, { name: 'OpenStreetMap Foundation (Tiles)', purpose: 'Basiskarte', data: 'Kachel-Koordinaten (Kartenausschnitt), IP-Adresse', region: 'Vereinigtes Königreich / EU' },
{ name: 'Esri ArcGIS World Imagery', purpose: 'Satellitenkarte', data: 'Kachel-Koordinaten', region: '[REGION]' }, { name: 'Esri ArcGIS World Imagery', purpose: 'Satellitenkarte', data: 'Kachel-Koordinaten, IP-Adresse', region: 'USA (Auslandbearbeitung möglich)' },
{ name: 'swisstopo (geo.admin.ch)', purpose: 'Schweizer Landeskarte', data: 'Kachel-Koordinaten', region: 'Schweiz' }, { name: 'swisstopo (geo.admin.ch)', purpose: 'Schweizer Landeskarte', data: 'Kachel-Koordinaten, IP-Adresse', region: 'Schweiz' },
{ name: 'Nominatim (OpenStreetMap)', purpose: 'Adress-/Reverse-Geocoding', data: 'Adress-/Ortssuche, GPS-Koordinaten', region: '[REGION OSM Foundation]' }, { name: 'Nominatim (OpenStreetMap Foundation)', purpose: 'Adress-/Reverse-Geocoding', data: 'Adress-/Ortssuche, GPS-Koordinaten, IP-Adresse', region: 'Vereinigtes Königreich / EU' },
{ name: 'Open-Meteo', purpose: 'Wind- und Höhendaten', data: 'Koordinaten', region: '[REGION EU]' }, { name: 'Open-Meteo', purpose: 'Wind- und Höhendaten', data: 'Koordinaten, IP-Adresse', region: 'EU (Deutschland)' },
{ name: 'Open-Elevation', purpose: 'Höhendaten (Fallback)', data: 'Koordinaten', region: '[REGION]' }, { name: 'Open-Elevation', purpose: 'Höhendaten (Fallback)', data: 'Koordinaten, IP-Adresse', region: 'Region nicht abschliessend geklärt (Community-Dienst)' },
{ name: 'Stripe', purpose: 'Zahlungsabwicklung freiwilliger Unterstützungsbeiträge', data: 'Betrag, freiwilliger Name/Nachricht, Zahlungsdaten (direkt bei Stripe)', region: '[REGION u.a. USA/EU; Auslandbearbeitung möglich]', optional: true }, { name: 'Stripe', purpose: 'Zahlungsabwicklung freiwilliger Unterstützungsbeiträge', data: 'Betrag, freiwilliger Name/Nachricht, Zahlungsdaten (direkt bei Stripe)', region: 'USA / EU (Auslandbearbeitung möglich)', optional: true },
{ name: 'SMTP-/E-Mail-Anbieter', purpose: 'System-E-Mails (Verifizierung, Reset, Kontakt, Einladungen)', data: 'E-Mail-Adresse, Nachrichteninhalt', region: '[REGION abhängig vom konfigurierten Anbieter]', optional: true }, { name: 'SMTP-/E-Mail-Anbieter', purpose: 'System-E-Mails (Verifizierung, Reset, Kontakt, Einladungen)', data: 'E-Mail-Adresse, Nachrichteninhalt', region: 'abhängig vom konfigurierten Anbieter', optional: true },
{ name: 'MinIO (Objektspeicher, Eigenbetrieb)', purpose: 'Datei-/Bild-Uploads (Pläne, Logos, Symbole)', data: 'hochgeladene Dateien', region: 'Schweiz (beim Betreiber)' }, { name: 'MinIO (Objektspeicher, Eigenbetrieb)', purpose: 'Datei-/Bild-Uploads (Pläne, Logos, Symbole)', data: 'hochgeladene Dateien', region: 'Schweiz (beim Betreiber)' },
{ name: 'PostgreSQL (Datenbank, Eigenbetrieb)', purpose: 'Primäre Datenhaltung', data: 'Konto-, Organisations-, Plan- und Journaldaten', region: 'Schweiz (beim Betreiber)' }, { name: 'PostgreSQL (Datenbank, Eigenbetrieb)', purpose: 'Primäre Datenhaltung', data: 'Konto-, Organisations-, Plan- und Journaldaten', region: 'Schweiz (beim Betreiber)' },
] as ExternalProcessor[], ] as ExternalProcessor[],
// ─── FKS/FEUKOS-Bildmaterial (schriftliche Genehmigung) ──────────
// Die geforderte Quellenangabe lautet exakt "Feuerwehr Koordination Schweiz FKS".
// KEINE offizielle Partnerschaft, Zertifizierung, Prüfung oder Empfehlung behaupten.
fksAttribution: {
label: 'Feuerwehr Koordination Schweiz FKS',
sourceLine: 'Bildquelle: Feuerwehr Koordination Schweiz FKS',
approvalDate: '17. Februar 2026',
note: 'Die FEUKOS-Markierungen und -Symbole werden mit schriftlicher Genehmigung der Feuerwehr Koordination Schweiz FKS verwendet. Die Genehmigung bezieht sich auf die Verwendung des Bildmaterials innerhalb von lageplan.ch und stellt keine offizielle Partnerschaft, Zertifizierung, Prüfung oder Empfehlung von lageplan.ch durch die FKS dar.',
},
// ─── Rechtsdokumente: bewusst gesetzte Versionen ────────── // ─── Rechtsdokumente: bewusst gesetzte Versionen ──────────
// WICHTIG: Version manuell erhöhen, wenn sich der Inhalt WESENTLICH ändert (löst Re-Consent aus). // WICHTIG: Version manuell erhöhen, wenn sich der Inhalt WESENTLICH ändert (löst Re-Consent aus).
// Nicht automatisch aus minimalen Dateiänderungen ableiten. // Nicht automatisch aus minimalen Dateiänderungen ableiten.
@@ -117,6 +129,7 @@ export const legal = {
organizationDeclaration: { version: '1.0.0', publishedAt: '2026-07-23' }, organizationDeclaration: { version: '1.0.0', publishedAt: '2026-07-23' },
responsibleUse: { version: '1.0.0', publishedAt: '2026-07-23' }, responsibleUse: { version: '1.0.0', publishedAt: '2026-07-23' },
dataProcessingAgreement: { version: '1.0.0', publishedAt: '2026-07-23' }, dataProcessingAgreement: { version: '1.0.0', publishedAt: '2026-07-23' },
fksAttribution: { version: '1.0.0', publishedAt: '2026-07-24' },
} as Record<string, LegalDocumentMeta>, } as Record<string, LegalDocumentMeta>,
/** Dezenter Standard-Hinweis auf allen Rechtsseiten (kein Entwurfshinweis). */ /** Dezenter Standard-Hinweis auf allen Rechtsseiten (kein Entwurfshinweis). */

View File

@@ -315,6 +315,7 @@ export function useMapExport({
if (format === 'png') { if (format === 'png') {
// Rechtlicher Prüf-Hinweis unten einbrennen // Rechtlicher Prüf-Hinweis unten einbrennen
const disclaimer = 'Mit Lageplan erstellt · Angaben vor Verwendung fachlich prüfen · Offline-/Einsatzunterlagen der Organisation bleiben massgebend' const disclaimer = 'Mit Lageplan erstellt · Angaben vor Verwendung fachlich prüfen · Offline-/Einsatzunterlagen der Organisation bleiben massgebend'
+ (distinctSymbols.length > 0 ? ' · Bildquelle: Feuerwehr Koordination Schweiz FKS' : '')
const barH = Math.max(18, Math.round(exportCanvas.width * 0.014)) const barH = Math.max(18, Math.round(exportCanvas.width * 0.014))
ctx.save() ctx.save()
ctx.fillStyle = 'rgba(0,0,0,0.55)' ctx.fillStyle = 'rgba(0,0,0,0.55)'
@@ -448,6 +449,13 @@ export function useMapExport({
// ── Footer ── // ── Footer ──
const footerY = pageH - m - 4 const footerY = pageH - m - 4
// FKS-Quellenangabe (wenn Symbole enthalten sind)
if (distinctSymbols.length > 0) {
pdf.setFontSize(6)
pdf.setFont('helvetica', 'normal')
pdf.setTextColor(120, 120, 120)
pdf.text('Bildquelle: Feuerwehr Koordination Schweiz FKS', m, footerY - 8.5)
}
// Rechtlicher Prüf-Hinweis (eine Zeile über dem Meta-Footer) // Rechtlicher Prüf-Hinweis (eine Zeile über dem Meta-Footer)
pdf.setFontSize(6.5) pdf.setFontSize(6.5)
pdf.setFont('helvetica', 'italic') pdf.setFont('helvetica', 'italic')

View File

@@ -0,0 +1,32 @@
import { describe, it, expect, beforeAll } from 'vitest'
// Server-Secret für die Schlüsselableitung setzen, bevor das Modul es liest.
beforeAll(() => { process.env.NEXTAUTH_SECRET = 'test-secret-fuer-verschluesselung-min-16' })
describe('crypto-secret (Zugangsdaten-/Passphrase-Verschlüsselung)', () => {
it('verschlüsselt und entschlüsselt korrekt (Round-Trip)', async () => {
const { encryptSecret, decryptSecret, isEncrypted } = await import('@/lib/crypto-secret')
const plain = 'sftp-Passwort-123!'
const enc = encryptSecret(plain)
expect(isEncrypted(enc)).toBe(true)
expect(enc).not.toContain(plain)
expect(decryptSecret(enc)).toBe(plain)
})
it('erzeugt bei gleichem Klartext unterschiedliche Chiffren (zufälliges Salt/IV)', async () => {
const { encryptSecret } = await import('@/lib/crypto-secret')
expect(encryptSecret('gleich')).not.toBe(encryptSecret('gleich'))
})
it('gibt unverschlüsselte Altwerte unverändert zurück (abwärtskompatibel)', async () => {
const { decryptSecret, isEncrypted } = await import('@/lib/crypto-secret')
expect(isEncrypted('klartext')).toBe(false)
expect(decryptSecret('klartext')).toBe('klartext')
})
it('leere Werte bleiben leer', async () => {
const { encryptSecret, decryptSecret } = await import('@/lib/crypto-secret')
expect(encryptSecret('')).toBe('')
expect(decryptSecret('')).toBe('')
})
})

View File

@@ -46,42 +46,26 @@ export async function getSession(): Promise<UserPayload | null> {
return await verifyToken(token) return await verifyToken(token)
} }
export async function login( /**
email: string, * Baut die Session-Payload für einen (bereits authentifizierten) Benutzer per ID.
password: string * Wird sowohl beim normalen Login (nach Passwortprüfung) als auch nach erfolgreicher
* MFA-Verifikation verwendet, damit die Logik nur an einer Stelle lebt.
*/
export async function buildSessionForUser(
userId: string
): Promise<{ success: boolean; user?: UserPayload; error?: string }> { ): Promise<{ success: boolean; user?: UserPayload; error?: string }> {
const user = await (prisma.user.findUnique({ const user = await (prisma.user.findUnique({
where: { email }, where: { id: userId },
select: { select: { id: true, email: true, name: true, role: true, emailVerified: true, isActive: true },
id: true,
email: true,
name: true,
password: true,
role: true,
emailVerified: true,
isActive: true,
},
}) as any) }) as any)
if (!user) { if (!user) return { success: false, error: 'Benutzer nicht gefunden' }
return { success: false, error: 'E-Mail oder Passwort falsch' }
}
const isValidPassword = await bcrypt.compare(password, user.password)
if (!isValidPassword) {
return { success: false, error: 'E-Mail oder Passwort falsch' }
}
// Gesperrte Benutzer dürfen sich nicht anmelden (isActive === false).
// Tolerant: nur blockieren, wenn explizit false (alte Datensätze ohne Wert bleiben aktiv).
if (user.isActive === false) { if (user.isActive === false) {
return { success: false, error: 'Ihr Konto wurde deaktiviert. Bitte kontaktieren Sie den Administrator.' } return { success: false, error: 'Ihr Konto wurde deaktiviert. Bitte kontaktieren Sie den Administrator.' }
} }
// Track email verification status (allow login regardless) const emailVerified = user.emailVerified !== false
const emailVerified = (user as any).emailVerified !== false
// Get first tenant membership for non-server-admins
let tenantId: string | undefined let tenantId: string | undefined
let tenantSlug: string | undefined let tenantSlug: string | undefined
if ((user.role as string) !== 'SERVER_ADMIN') { if ((user.role as string) !== 'SERVER_ADMIN') {
@@ -91,7 +75,6 @@ export async function login(
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
}) })
if (membership) { if (membership) {
// Check if tenant is active
if (!membership.tenant.isActive) { if (!membership.tenant.isActive) {
return { success: false, error: 'Ihr Mandant wurde gesperrt. Bitte kontaktieren Sie den Administrator.' } return { success: false, error: 'Ihr Mandant wurde gesperrt. Bitte kontaktieren Sie den Administrator.' }
} }
@@ -109,10 +92,65 @@ export async function login(
tenantSlug, tenantSlug,
emailVerified, emailVerified,
} }
return { success: true, user: userPayload } return { success: true, user: userPayload }
} }
export async function login(
email: string,
password: string
): Promise<{ success: boolean; user?: UserPayload; error?: string }> {
const user = await (prisma.user.findUnique({
where: { email },
select: { id: true, password: true, isActive: true },
}) as any)
if (!user) {
return { success: false, error: 'E-Mail oder Passwort falsch' }
}
const isValidPassword = await bcrypt.compare(password, user.password)
if (!isValidPassword) {
return { success: false, error: 'E-Mail oder Passwort falsch' }
}
if (user.isActive === false) {
return { success: false, error: 'Ihr Konto wurde deaktiviert. Bitte kontaktieren Sie den Administrator.' }
}
return buildSessionForUser(user.id)
}
// ─── MFA: Zwischen-Token nach Passwort, vor zweitem Faktor ───
/** Kurzlebiges Token (10 Min) zwischen Passwort und zweitem Faktor. Ist KEINE Session. */
export async function createMfaToken(userId: string, rememberMe: boolean): Promise<string> {
return await new SignJWT({ uid: userId, rememberMe, purpose: 'mfa' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('10m')
.sign(JWT_SECRET)
}
export async function verifyMfaToken(token: string): Promise<{ userId: string; rememberMe: boolean } | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET)
if (payload.purpose !== 'mfa' || typeof payload.uid !== 'string') return null
return { userId: payload.uid, rememberMe: !!payload.rememberMe }
} catch {
return null
}
}
/** True, wenn der Benutzer einen zweiten Faktor aktiv hat (TOTP bestätigt oder WebAuthn registriert). */
export async function userHasMfa(userId: string): Promise<boolean> {
const user = await (prisma as any).user.findUnique({
where: { id: userId },
select: { totpConfirmedAt: true, _count: { select: { webauthnCredentials: true } } },
})
if (!user) return false
return !!user.totpConfirmedAt || (user._count?.webauthnCredentials ?? 0) > 0
}
export async function hashPassword(password: string): Promise<string> { export async function hashPassword(password: string): Promise<string> {
return await bcrypt.hash(password, 12) return await bcrypt.hash(password, 12)
} }

87
src/lib/backup/config.ts Normal file
View File

@@ -0,0 +1,87 @@
import { prisma } from '@/lib/db'
export type BackupDestinationType = 'sftp' | 'webdav'
export type BackupSchedule = 'off' | 'daily' | 'weekly'
export interface SftpConfig {
host: string
port: number
username: string
/** verschlüsselt (enc:v1:) */
password: string
remotePath: string
}
export interface WebdavConfig {
/** z.B. https://cloud.example.com/remote.php/dav/files/USER/ */
url: string
username: string
/** verschlüsselt (enc:v1:) — bei Nextcloud besser ein App-Passwort */
password: string
remotePath: string
}
export interface BackupConfig {
enabled: boolean
schedule: BackupSchedule
retentionDays: number
/** Passphrase zur Backup-Verschlüsselung, verschlüsselt (enc:v1:) */
encryptPassphrase: string
destinationType: BackupDestinationType
sftp?: SftpConfig
webdav?: WebdavConfig
// Statusfelder (vom letzten Lauf)
lastRun?: string | null
lastStatus?: 'ok' | 'error' | null
lastMessage?: string | null
lastSizeBytes?: number | null
}
const SETTING_KEY = 'backup_config'
export const DEFAULT_BACKUP_CONFIG: BackupConfig = {
enabled: false,
schedule: 'off',
retentionDays: 30,
encryptPassphrase: '',
destinationType: 'sftp',
sftp: { host: '', port: 22, username: '', password: '', remotePath: '/lageplan-backups' },
webdav: { url: '', username: '', password: '', remotePath: '/Lageplan-Backups' },
lastRun: null, lastStatus: null, lastMessage: null, lastSizeBytes: null,
}
export async function loadBackupConfig(): Promise<BackupConfig | null> {
try {
const row = await (prisma as any).systemSetting.findUnique({ where: { key: SETTING_KEY } })
if (!row?.value) return null
return { ...DEFAULT_BACKUP_CONFIG, ...JSON.parse(row.value) }
} catch (e) {
console.error('[backup] loadBackupConfig:', e)
return null
}
}
export async function saveBackupConfig(cfg: BackupConfig): Promise<void> {
await (prisma as any).systemSetting.upsert({
where: { key: SETTING_KEY },
update: { value: JSON.stringify(cfg) },
create: { key: SETTING_KEY, value: JSON.stringify(cfg), isSecret: true, category: 'backup' },
})
}
/** Konfig für die API maskieren: Geheimnisse durch Boolean-Flags ersetzen. */
export function maskBackupConfig(cfg: BackupConfig) {
return {
enabled: cfg.enabled,
schedule: cfg.schedule,
retentionDays: cfg.retentionDays,
hasPassphrase: !!cfg.encryptPassphrase,
destinationType: cfg.destinationType,
sftp: cfg.sftp ? { host: cfg.sftp.host, port: cfg.sftp.port, username: cfg.sftp.username, remotePath: cfg.sftp.remotePath, hasPassword: !!cfg.sftp.password } : undefined,
webdav: cfg.webdav ? { url: cfg.webdav.url, username: cfg.webdav.username, remotePath: cfg.webdav.remotePath, hasPassword: !!cfg.webdav.password } : undefined,
lastRun: cfg.lastRun ?? null,
lastStatus: cfg.lastStatus ?? null,
lastMessage: cfg.lastMessage ?? null,
lastSizeBytes: cfg.lastSizeBytes ?? null,
}
}

View File

@@ -0,0 +1,101 @@
import { createReadStream, readFileSync } from 'fs'
import type { BackupDestinationType } from './config'
// Entschlüsselte (Klartext-)Zugangsdaten — werden vom Aufrufer aus der Konfig entschlüsselt.
export interface SftpPlain { host: string; port: number; username: string; password: string; remotePath: string }
export interface WebdavPlain { url: string; username: string; password: string; remotePath: string }
export type PlainDest = SftpPlain | WebdavPlain
export interface RemoteFile { name: string; size?: number; modified?: number }
function joinRemote(dir: string, name: string): string {
return `${dir.replace(/\/+$/, '')}/${name}`
}
// ─── SFTP ───────────────────────────────────────────────
async function withSftp<T>(c: SftpPlain, fn: (sftp: any) => Promise<T>): Promise<T> {
const SftpClient = (await import('ssh2-sftp-client')).default
const sftp = new SftpClient()
await sftp.connect({ host: c.host, port: c.port || 22, username: c.username, password: c.password, readyTimeout: 15000 })
try {
return await fn(sftp)
} finally {
try { await sftp.end() } catch { /* ignore */ }
}
}
// ─── WebDAV (Nextcloud) ─────────────────────────────────
async function getWebdav(c: WebdavPlain) {
const { createClient } = await import('webdav')
return createClient(c.url, { username: c.username, password: c.password })
}
// ─── Öffentliche, typ-übergreifende API ─────────────────
export async function testDestination(type: BackupDestinationType, dest: PlainDest): Promise<{ ok: boolean; message: string }> {
try {
const marker = Buffer.from(`lageplan-backup-test ${new Date().toISOString()}`)
if (type === 'sftp') {
const c = dest as SftpPlain
await withSftp(c, async (sftp) => {
if (!(await sftp.exists(c.remotePath))) await sftp.mkdir(c.remotePath, true)
const p = joinRemote(c.remotePath, '.lageplan-test')
await sftp.put(marker, p)
await sftp.delete(p)
})
return { ok: true, message: 'SFTP-Verbindung und Schreibzugriff ok.' }
}
const c = dest as WebdavPlain
const dav = await getWebdav(c)
if (!(await dav.exists(c.remotePath))) await dav.createDirectory(c.remotePath, { recursive: true } as any)
const p = joinRemote(c.remotePath, '.lageplan-test')
await dav.putFileContents(p, marker)
await dav.deleteFile(p)
return { ok: true, message: 'WebDAV-Verbindung und Schreibzugriff ok.' }
} catch (e: any) {
return { ok: false, message: e?.message || 'Verbindung fehlgeschlagen.' }
}
}
export async function uploadBackup(type: BackupDestinationType, dest: PlainDest, localPath: string, remoteName: string): Promise<void> {
if (type === 'sftp') {
const c = dest as SftpPlain
await withSftp(c, async (sftp) => {
if (!(await sftp.exists(c.remotePath))) await sftp.mkdir(c.remotePath, true)
await sftp.put(createReadStream(localPath), joinRemote(c.remotePath, remoteName))
})
return
}
const c = dest as WebdavPlain
const dav = await getWebdav(c)
if (!(await dav.exists(c.remotePath))) await dav.createDirectory(c.remotePath, { recursive: true } as any)
// Für WebDAV als Buffer hochladen (Backups einer kleinen Feuerwehr sind moderat gross).
await dav.putFileContents(joinRemote(c.remotePath, remoteName), readFileSync(localPath), { overwrite: true } as any)
}
export async function listBackups(type: BackupDestinationType, dest: PlainDest): Promise<RemoteFile[]> {
if (type === 'sftp') {
const c = dest as SftpPlain
return withSftp(c, async (sftp) => {
if (!(await sftp.exists(c.remotePath))) return []
const list = await sftp.list(c.remotePath)
return list.filter((f: any) => f.type === '-').map((f: any) => ({ name: f.name, size: f.size, modified: f.modifyTime }))
})
}
const c = dest as WebdavPlain
const dav = await getWebdav(c)
if (!(await dav.exists(c.remotePath))) return []
const items = (await dav.getDirectoryContents(c.remotePath)) as any[]
return items.filter(i => i.type === 'file').map(i => ({ name: i.basename, size: i.size, modified: i.lastmod ? Date.parse(i.lastmod) : undefined }))
}
export async function deleteBackup(type: BackupDestinationType, dest: PlainDest, name: string): Promise<void> {
if (type === 'sftp') {
const c = dest as SftpPlain
await withSftp(c, async (sftp) => { await sftp.delete(joinRemote(c.remotePath, name)) })
return
}
const c = dest as WebdavPlain
const dav = await getWebdav(c)
await dav.deleteFile(joinRemote(c.remotePath, name))
}

163
src/lib/backup/engine.ts Normal file
View File

@@ -0,0 +1,163 @@
import { spawn } from 'child_process'
import { createWriteStream, createReadStream, promises as fsp } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { randomBytes, scryptSync, createCipheriv } from 'crypto'
// archiver nutzt `export =` (CommonJS); require umgeht die Default-Import-Inkompatibilität.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const archiver = require('archiver') as (format: string, opts?: any) => any
import { minioClient, BUCKET } from '@/lib/minio'
import { loadBackupConfig, saveBackupConfig, type BackupConfig } from './config'
import { plainDestFromConfig, getPassphrase } from './secrets'
import { uploadBackup, listBackups, deleteBackup } from './destinations'
const NAME_PREFIX = 'lageplan_'
const NAME_RE = /^lageplan_(\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2})\.tar\.gz\.enc$/
function tsName(d = new Date()): string {
// 2026-07-23-22-30-00 (UTC, sortierbar)
return d.toISOString().replace('T', '-').replace(/:/g, '-').slice(0, 19)
}
function fmtSize(n: number): string {
if (n > 1e9) return (n / 1e9).toFixed(1) + ' GB'
if (n > 1e6) return (n / 1e6).toFixed(1) + ' MB'
return (n / 1e3).toFixed(0) + ' KB'
}
/** PostgreSQL-Dump (custom/komprimiert) via pg_dump. */
function pgDump(dumpPath: string): Promise<void> {
const url = process.env.DATABASE_URL
if (!url) return Promise.reject(new Error('DATABASE_URL fehlt'))
return new Promise((resolve, reject) => {
const p = spawn('pg_dump', [url, '-Fc', '-f', dumpPath], { stdio: ['ignore', 'ignore', 'pipe'] })
let err = ''
p.stderr.on('data', d => { err += d.toString() })
p.on('error', (e) => reject(new Error(`pg_dump nicht ausführbar: ${e.message} (postgresql-client im Image?)`)))
p.on('close', code => code === 0 ? resolve() : reject(new Error('pg_dump: ' + (err || `Exit ${code}`))))
})
}
function listObjectNames(): Promise<string[]> {
return new Promise((resolve, reject) => {
const out: string[] = []
const s = minioClient.listObjects(BUCKET, '', true)
s.on('data', (o: any) => { if (o.name) out.push(o.name) })
s.on('error', reject)
s.on('end', () => resolve(out))
})
}
/** tar.gz mit database.dump + files/<minio-objekte>. */
async function buildArchive(dumpPath: string, tarPath: string): Promise<void> {
const names = await listObjectNames()
const output = createWriteStream(tarPath)
const archive = archiver('tar', { gzip: true, gzipOptions: { level: 6 } })
const done = new Promise<void>((resolve, reject) => {
output.on('close', () => resolve())
output.on('error', reject)
archive.on('error', reject)
})
archive.pipe(output)
archive.file(dumpPath, { name: 'database.dump' })
for (const name of names) {
const st = await minioClient.getObject(BUCKET, name)
archive.append(st as any, { name: 'files/' + name })
}
await archive.finalize()
await done
}
/** AES-256-GCM Stream-Verschlüsselung mit Passphrase. Format: [salt16][iv12][ciphertext][tag16]. */
function encryptFile(inPath: string, outPath: string, passphrase: string): Promise<void> {
return new Promise((resolve, reject) => {
const salt = randomBytes(16)
const iv = randomBytes(12)
const key = scryptSync(passphrase, salt, 32)
const cipher = createCipheriv('aes-256-gcm', key, iv)
const out = createWriteStream(outPath)
out.on('error', reject)
out.on('close', () => resolve())
out.write(salt); out.write(iv)
const input = createReadStream(inPath)
input.on('error', reject)
cipher.on('error', reject)
cipher.on('data', (c) => out.write(c))
cipher.on('end', () => { out.write(cipher.getAuthTag()); out.end() })
input.pipe(cipher)
})
}
/** Entfernt Remote-Backups, die älter als retentionDays sind (Zeitstempel aus dem Dateinamen). */
async function prune(cfg: BackupConfig): Promise<void> {
try {
const dest = plainDestFromConfig(cfg)
const files = await listBackups(cfg.destinationType, dest)
const cutoff = Date.now() - cfg.retentionDays * 86400_000
for (const f of files) {
const m = f.name.match(NAME_RE)
if (!m) continue
// 2026-07-23-22-30-00 → ISO
const iso = m[1].replace(/^(\d{4}-\d{2}-\d{2})-(\d{2})-(\d{2})-(\d{2})$/, '$1T$2:$3:$4Z')
const t = Date.parse(iso)
if (!isNaN(t) && t < cutoff) {
await deleteBackup(cfg.destinationType, dest, f.name)
}
}
} catch (e) {
console.warn('[backup] prune:', e)
}
}
/**
* Führt ein vollständiges Backup aus: DB-Dump + Dateien → tar.gz → verschlüsselt → Upload → prune.
* Läuft unabhängig vom Zeitplan (auch für „Jetzt sichern"), solange Ziel + Passphrase gesetzt sind.
*/
export async function runBackup(): Promise<{ ok: boolean; message: string }> {
const cfg = await loadBackupConfig()
if (!cfg) throw new Error('Backup nicht konfiguriert.')
const pass = getPassphrase(cfg)
if (!pass) throw new Error('Backup-Passphrase fehlt.')
const dest = plainDestFromConfig(cfg) // wirft, wenn Ziel nicht konfiguriert
const ts = tsName()
const tmp = await fsp.mkdtemp(join(tmpdir(), 'lpbak-'))
const dumpPath = join(tmp, 'db.dump')
const tarPath = join(tmp, `${NAME_PREFIX}${ts}.tar.gz`)
const encPath = `${tarPath}.enc`
const remoteName = `${NAME_PREFIX}${ts}.tar.gz.enc`
try {
await pgDump(dumpPath)
await buildArchive(dumpPath, tarPath)
await encryptFile(tarPath, encPath, pass)
await uploadBackup(cfg.destinationType, dest, encPath, remoteName)
const size = (await fsp.stat(encPath)).size
await prune(cfg)
cfg.lastRun = new Date().toISOString()
cfg.lastStatus = 'ok'
cfg.lastMessage = `Gesichert: ${remoteName} (${fmtSize(size)})`
cfg.lastSizeBytes = size
await saveBackupConfig(cfg)
return { ok: true, message: cfg.lastMessage }
} catch (e: any) {
cfg.lastRun = new Date().toISOString()
cfg.lastStatus = 'error'
cfg.lastMessage = e?.message || 'Unbekannter Fehler'
await saveBackupConfig(cfg)
throw e
} finally {
await fsp.rm(tmp, { recursive: true, force: true }).catch(() => {})
}
}
/** Prüft, ob laut Zeitplan ein Backup fällig ist (für den Scheduler). */
export function isBackupDue(cfg: BackupConfig, now = Date.now()): boolean {
if (!cfg.enabled || cfg.schedule === 'off') return false
const intervalMs = cfg.schedule === 'weekly' ? 7 * 86400_000 : 86400_000
if (!cfg.lastRun) return true
const last = Date.parse(cfg.lastRun)
if (isNaN(last)) return true
// Bei Fehlversuchen nicht dauernd neu starten: erst nach dem Intervall erneut.
return now - last >= intervalMs
}

19
src/lib/backup/secrets.ts Normal file
View File

@@ -0,0 +1,19 @@
import { decryptSecret } from '@/lib/crypto-secret'
import type { BackupConfig } from './config'
import type { PlainDest, SftpPlain, WebdavPlain } from './destinations'
/** Entschlüsselt die Zugangsdaten des aktiven Ziels aus der Konfig. */
export function plainDestFromConfig(cfg: BackupConfig): PlainDest {
if (cfg.destinationType === 'sftp') {
const s = cfg.sftp
if (!s) throw new Error('SFTP nicht konfiguriert')
return { host: s.host, port: s.port, username: s.username, password: decryptSecret(s.password), remotePath: s.remotePath } as SftpPlain
}
const w = cfg.webdav
if (!w) throw new Error('WebDAV nicht konfiguriert')
return { url: w.url, username: w.username, password: decryptSecret(w.password), remotePath: w.remotePath } as WebdavPlain
}
export function getPassphrase(cfg: BackupConfig): string {
return decryptSecret(cfg.encryptPassphrase || '')
}

49
src/lib/crypto-secret.ts Normal file
View File

@@ -0,0 +1,49 @@
import { randomBytes, scryptSync, createCipheriv, createDecipheriv } from 'crypto'
/**
* Verschlüsselt kurze Geheimnisse (z.B. SFTP-/WebDAV-Passwörter, Backup-Passphrase) für die
* Ablage in der Datenbank. AES-256-GCM. Der Schlüssel wird aus einem Server-Secret abgeleitet
* (BACKUP_ENC_KEY oder NEXTAUTH_SECRET) — er liegt NICHT in der Datenbank.
*
* Format (base64): [salt(16)][iv(12)][tag(16)][ciphertext]
* Ein Präfix "enc:v1:" kennzeichnet verschlüsselte Werte.
*/
const PREFIX = 'enc:v1:'
function masterKey(): string {
const k = process.env.BACKUP_ENC_KEY || process.env.NEXTAUTH_SECRET
if (!k || k.length < 16) {
throw new Error('Kein Verschlüsselungs-Secret (BACKUP_ENC_KEY oder NEXTAUTH_SECRET) gesetzt.')
}
return k
}
export function isEncrypted(value: string | null | undefined): boolean {
return typeof value === 'string' && value.startsWith(PREFIX)
}
export function encryptSecret(plain: string): string {
if (!plain) return ''
const salt = randomBytes(16)
const iv = randomBytes(12)
const key = scryptSync(masterKey(), salt, 32)
const cipher = createCipheriv('aes-256-gcm', key, iv)
const ct = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return PREFIX + Buffer.concat([salt, iv, tag, ct]).toString('base64')
}
export function decryptSecret(value: string): string {
if (!value) return ''
if (!isEncrypted(value)) return value // Abwärtskompatibel: unverschlüsselte Altwerte
const raw = Buffer.from(value.slice(PREFIX.length), 'base64')
const salt = raw.subarray(0, 16)
const iv = raw.subarray(16, 28)
const tag = raw.subarray(28, 44)
const ct = raw.subarray(44)
const key = scryptSync(masterKey(), salt, 32)
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8')
}

View File

@@ -8,12 +8,15 @@ export type LegalDocumentType =
| 'ORGANIZATION_DECLARATION' | 'ORGANIZATION_DECLARATION'
| 'RESPONSIBLE_USE' | 'RESPONSIBLE_USE'
| 'DATA_PROCESSING_AGREEMENT' | 'DATA_PROCESSING_AGREEMENT'
| 'FKS_ATTRIBUTION'
export type LegalAcceptanceContext = export type LegalAcceptanceContext =
| 'REGISTRATION' | 'REGISTRATION'
| 'LOGIN_RECONSENT' | 'LOGIN_RECONSENT'
| 'ORGANIZATION_CREATION' | 'ORGANIZATION_CREATION'
| 'ADMIN_ROLE_ACCEPTANCE' | 'ADMIN_ROLE_ACCEPTANCE'
| 'EXISTING_USER_RECONSENT'
| 'SUPPORT_CONTRIBUTION'
| 'DONATION' | 'DONATION'
/** Für ALLE Benutzer erforderliche Dokumente. */ /** Für ALLE Benutzer erforderliche Dokumente. */

View File

@@ -67,6 +67,9 @@ interface RecordInput {
locale?: string | null locale?: string | null
/** Dokumenttypen, die zugestimmt werden. Es wird jeweils die AKTIVE Version protokolliert. */ /** Dokumenttypen, die zugestimmt werden. Es wird jeweils die AKTIVE Version protokolliert. */
types: LegalDocumentType[] types: LegalDocumentType[]
/** Nachweisdaten (Beweiszweck): IP + Gerät/Browser zum Zeitpunkt der Zustimmung. */
ipAddress?: string | null
userAgent?: string | null
} }
/** /**
@@ -88,6 +91,8 @@ export async function recordAcceptances(input: RecordInput): Promise<number> {
contentHash: d.contentHash, contentHash: d.contentHash,
context: input.context, context: input.context,
locale: input.locale ?? 'de-CH', locale: input.locale ?? 'de-CH',
ipAddress: input.ipAddress ?? null,
userAgent: input.userAgent ? String(input.userAgent).slice(0, 400) : null,
})) }))
if (rows.length === 0) return 0 if (rows.length === 0) return 0
const res = await (prisma as any).legalAcceptance.createMany({ data: rows, skipDuplicates: true }) const res = await (prisma as any).legalAcceptance.createMany({ data: rows, skipDuplicates: true })

54
src/lib/mfa.ts Normal file
View File

@@ -0,0 +1,54 @@
import { authenticator } from 'otplib'
import { randomBytes } from 'crypto'
import bcrypt from 'bcryptjs'
import { legal } from '@/config/legal'
// Kleine Toleranz gegen Uhr-Drift (±1 Zeitfenster = ±30s).
authenticator.options = { window: 1 }
/** Neues TOTP-Secret erzeugen (Base32). */
export function generateTotpSecret(): string {
return authenticator.generateSecret()
}
/** otpauth://-URI für den QR-Code (Authenticator-App). */
export function totpKeyUri(email: string, secret: string): string {
const issuer = legal.projectName
return authenticator.keyuri(email, issuer, secret)
}
/** Prüft einen 6-stelligen TOTP-Code gegen das Secret. */
export function verifyTotp(secret: string, token: string): boolean {
if (!secret || !token) return false
const clean = token.replace(/\s+/g, '')
if (!/^\d{6}$/.test(clean)) return false
try {
return authenticator.verify({ token: clean, secret })
} catch {
return false
}
}
/** Erzeugt n Backup-Codes im Format xxxxx-xxxxx (Klartext) + bcrypt-Hashes zur Speicherung. */
export async function generateBackupCodes(n = 10): Promise<{ plain: string[]; hashed: string[] }> {
const plain: string[] = []
for (let i = 0; i < n; i++) {
const raw = randomBytes(5).toString('hex') // 10 Hex-Zeichen
plain.push(`${raw.slice(0, 5)}-${raw.slice(5)}`)
}
const hashed = await Promise.all(plain.map(c => bcrypt.hash(c.replace(/-/g, ''), 10)))
return { plain, hashed }
}
/**
* Prüft einen eingegebenen Backup-Code gegen die gehashte Liste.
* Gibt den Index des passenden Codes zurück (zum Entwerten) oder -1.
*/
export async function verifyBackupCode(hashedList: string[], code: string): Promise<number> {
const clean = (code || '').replace(/[\s-]/g, '').toLowerCase()
if (clean.length < 8) return -1
for (let i = 0; i < hashedList.length; i++) {
if (await bcrypt.compare(clean, hashedList[i])) return i
}
return -1
}

View File

@@ -10,7 +10,7 @@
* Ohne Konfiguration gelten die DEFAULT_MODULES (heutiges Verhalten). * Ohne Konfiguration gelten die DEFAULT_MODULES (heutiges Verhalten).
*/ */
export type ModuleColumnType = 'text' | 'check' | 'time' export type ModuleColumnType = 'text' | 'number' | 'check' | 'time'
export interface ModuleColumn { export interface ModuleColumn {
key: string key: string
@@ -96,6 +96,21 @@ export const MODULE_PRESETS: { key: string; name: string; icon: string; slot: 'm
{ key: 'bedarf', label: 'Bedarf', type: 'text' }, { key: 'bedarf', label: 'Bedarf', type: 'text' },
], ],
}, },
{
key: 'unwetter',
name: 'Einsatz-Aufträge (Unwetter)',
icon: 'clipboard-list',
slot: 'main',
description: 'Auftragsliste gross links: Zeit · Auftrags-Nr · Adresse · Auftrag · Zugeteilt · erledigt',
columns: [
{ key: 'zeit', label: 'Zeit', type: 'time', width: 'w-16' },
{ key: 'auftragsnr', label: 'Auftrags-Nr', type: 'number', width: 'w-20' },
{ key: 'adresse', label: 'Adresse', type: 'text' },
{ key: 'auftrag', label: 'Auftrag', type: 'text' },
{ key: 'zugeteilt', label: 'Zugeteilt', type: 'text', width: 'w-24' },
{ key: 'erledigt', label: 'Ok', type: 'check', width: 'w-10' },
],
},
{ {
key: 'leer', key: 'leer',
name: 'Eigenes Modul', name: 'Eigenes Modul',
@@ -135,7 +150,7 @@ export function normalizeModules(raw: unknown): CockpitModule[] {
? (m.columns as any[]).slice(0, 8).map((c, i) => ({ ? (m.columns as any[]).slice(0, 8).map((c, i) => ({
key: typeof c.key === 'string' && c.key ? c.key : `col${i}`, key: typeof c.key === 'string' && c.key ? c.key : `col${i}`,
label: typeof c.label === 'string' ? c.label.slice(0, 40) : `Spalte ${i + 1}`, label: typeof c.label === 'string' ? c.label.slice(0, 40) : `Spalte ${i + 1}`,
type: (['text', 'check', 'time'] as const).includes(c.type) ? c.type : 'text', type: (['text', 'number', 'check', 'time'] as const).includes(c.type) ? c.type : 'text',
width: typeof c.width === 'string' ? c.width : undefined, width: typeof c.width === 'string' ? c.width : undefined,
})) }))
: undefined, : undefined,

45
src/lib/webauthn.ts Normal file
View File

@@ -0,0 +1,45 @@
import { NextRequest } from 'next/server'
import { SignJWT, jwtVerify } from 'jose'
import { JWT_SECRET } from '@/lib/jwt-secret'
import { legal } from '@/config/legal'
/** Ermittelt Relying-Party-ID (Hostname) und Origin aus dem Request. */
export function rpFromRequest(req: NextRequest): { rpID: string; origin: string; rpName: string } {
const host = req.headers.get('host') || 'localhost:3000'
const proto = req.headers.get('x-forwarded-proto') || (host.startsWith('localhost') ? 'http' : 'https')
const rpID = host.split(':')[0] // Hostname ohne Port
const origin = `${proto}://${host}`
return { rpID, origin, rpName: legal.projectName }
}
// Base64URL <-> Bytes (Node Buffer)
export function bytesToB64url(bytes: Uint8Array): string {
return Buffer.from(bytes).toString('base64url')
}
export function b64urlToBytes(s: string): Uint8Array<ArrayBuffer> {
const buf = Buffer.from(s, 'base64url')
const out = new Uint8Array(buf.byteLength) // frisches ArrayBuffer-Backing (Typ-Kompatibilität)
out.set(buf)
return out
}
type ChallengePurpose = 'wa-reg' | 'wa-auth'
/** Signiert die Challenge zustandslos (kurzlebig), damit kein Server-State nötig ist. */
export async function signChallenge(challenge: string, userId: string, purpose: ChallengePurpose): Promise<string> {
return await new SignJWT({ ch: challenge, uid: userId, purpose })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('5m')
.sign(JWT_SECRET)
}
export async function verifyChallenge(token: string, purpose: ChallengePurpose): Promise<{ challenge: string; userId: string } | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET)
if (payload.purpose !== purpose || typeof payload.ch !== 'string' || typeof payload.uid !== 'string') return null
return { challenge: payload.ch, userId: payload.uid }
} catch {
return null
}
}

View File

@@ -17,6 +17,8 @@ const PUBLIC_API_PREFIXES = [
'/api/auth/verify-email', '/api/auth/verify-email',
'/api/auth/resend-verification', '/api/auth/resend-verification',
'/api/auth/logout', '/api/auth/logout',
'/api/auth/mfa-login/',
'/api/cron/',
'/api/contact', '/api/contact',
'/api/demo', '/api/demo',
'/api/donate', '/api/donate',