Compare commits
24 Commits
6aa70e1504
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f360480ae | ||
|
|
28d346d0f5 | ||
|
|
15dd78c848 | ||
|
|
e5811f03fb | ||
|
|
405639db7d | ||
|
|
2323f1d360 | ||
|
|
4a59b5ca0b | ||
|
|
f9ad00a7e7 | ||
|
|
aaeeee1717 | ||
|
|
b095d9de1c | ||
|
|
ae78b2d878 | ||
|
|
9ba7194d41 | ||
|
|
f58f1b31bf | ||
|
|
f588b6d361 | ||
|
|
fa1e911ecd | ||
|
|
5cdf0e63c9 | ||
|
|
836b231c42 | ||
|
|
a14753c994 | ||
|
|
9eafe984e1 | ||
|
|
a943db9c54 | ||
|
|
235959101b | ||
|
|
6ab2fd9a6b | ||
|
|
8cf1aa4dbe | ||
|
|
872b8927cc |
14
.env.example
14
.env.example
@@ -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
3
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
115
docs/BACKUP.md
Normal 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).
|
||||||
83
docs/legal/IMPLEMENTATION-REPORT.md
Normal file
83
docs/legal/IMPLEMENTATION-REPORT.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Compliance-Umsetzung — Abschlussbericht
|
||||||
|
|
||||||
|
Stand: 2026-07-23 · Versionen 1.7.0 – 1.7.4 · sorgfältiger Entwurf, ersetzt keine juristische Prüfung.
|
||||||
|
|
||||||
|
## A. Zusammenfassung
|
||||||
|
- **Analyse**: 4 parallele Analysen (Legal/Claims, Dienstleister, Auth/Modelle/Admin, Export/Security/Cookies).
|
||||||
|
- **Claims**: 20 werbliche/absolute Aussagen korrigiert (24/7, 100 %, DSG/DSGVO-konform, „keine Weitergabe",
|
||||||
|
„keine Übermittlung ins Ausland", „verschlüsselt"→„gehasht", 100 % der Spende, „Kein Unternehmen").
|
||||||
|
- **Transparenz**: zentrale Legal-Config, neue Rechtsseiten, faktenbasierte Datenschutzerklärung mit
|
||||||
|
echter Subprozessorenliste.
|
||||||
|
- **Consent**: revisionssichere Zustimmungsprotokollierung (LegalDocument/LegalAcceptance), Registrierung
|
||||||
|
mit getrennten Zustimmungen, blockierender Re-Consent-Dialog, Organisationsbestätigung (7 Pflicht-Checks),
|
||||||
|
Admin-Übersicht mit CSV-Export, Konto-Datenschutzseite.
|
||||||
|
- **Risikoreduktion**: Export-Prüfhinweise (PDF/PNG), Dashboard-Hinweis, Berechnungs-/Geodaten-Hinweis,
|
||||||
|
private Routen `noindex`/`no-store`.
|
||||||
|
|
||||||
|
## B. Geänderte/neue Dateien (Auszug)
|
||||||
|
| Datei | Zweck | Änderung |
|
||||||
|
|---|---|---|
|
||||||
|
| src/config/legal.ts | Zentrale Rechtskonfiguration | NEU |
|
||||||
|
| src/components/legal/legal-page.tsx | Shell für Rechtsseiten + Footer-Links | NEU |
|
||||||
|
| src/app/nutzungsbedingungen/… | Nutzungsbedingungen | NEU |
|
||||||
|
| src/app/verantwortungsvolle-nutzung/… | Verantwortungsvolle Nutzung | NEU |
|
||||||
|
| src/app/sicherheit/… | Sicherheit | NEU |
|
||||||
|
| src/app/unterstuetzen/… | Freiwilliger Unterstützungsbeitrag | NEU |
|
||||||
|
| src/app/organisationen/datenschutzvereinbarung/… | AVV-Entwurf | NEU |
|
||||||
|
| src/app/datenschutz/page.tsx | Datenschutzerklärung | NEUAUFBAU |
|
||||||
|
| src/app/impressum/page.tsx | Impressum | Claims korrigiert |
|
||||||
|
| src/app/page.tsx, spenden/…, admin/settings-tab | Landing/Spenden/Admin | Claims korrigiert |
|
||||||
|
| prisma/schema.prisma, prisma/migrate.js | LegalDocument/LegalAcceptance + Seeding | Erweitert |
|
||||||
|
| src/lib/legal.ts, legal-rules.ts, legal-content.ts | Consent-Logik | NEU |
|
||||||
|
| src/app/api/legal/*, api/admin/legal/* | Consent-/Admin-APIs | NEU |
|
||||||
|
| src/components/legal/legal-consent-gate.tsx | Re-Consent/Org-Bestätigung | NEU |
|
||||||
|
| src/app/konto/datenschutz/… , src/app/admin/legal/… | Konto- & Admin-Ansicht | NEU |
|
||||||
|
| src/app/register/page.tsx, api/auth/register | Zustimmung bei Registrierung | Erweitert |
|
||||||
|
| src/hooks/use-map-export.ts | Export-Prüfhinweis | Erweitert |
|
||||||
|
| src/components/legal/usage-notice-banner.tsx | Dashboard-Hinweis | NEU |
|
||||||
|
| src/components/map/measure-panel.tsx | Berechnungs-Hinweis | Erweitert |
|
||||||
|
| src/middleware.ts | noindex/no-store, /konto, /api/share | Erweitert |
|
||||||
|
| src/app/sitemap.ts | neue Seiten | Erweitert |
|
||||||
|
| docs/legal/*.md | Audit, Inventar, Subprozessoren, TOM, Retention, Incident, Lizenzen | NEU |
|
||||||
|
| vitest.config.ts, src/**/__tests__ | Unit-Tests | NEU |
|
||||||
|
|
||||||
|
## C. Datenbank
|
||||||
|
- **Neue Tabellen**: `legal_documents`, `legal_acceptances` (idempotent in `prisma/migrate.js`).
|
||||||
|
- **Neue Felder**: keine an bestehenden Tabellen (Consent liegt in eigenen Tabellen; Tenant-Felder
|
||||||
|
`privacyAccepted` etc. bestanden bereits).
|
||||||
|
- **Migration**: läuft idempotent beim Container-Start (bestehendes Muster). Dokumente werden geseedet/aktiviert.
|
||||||
|
- **Auswirkungen auf Bestand**: keine destruktiven Änderungen; bestehende Daten unberührt.
|
||||||
|
|
||||||
|
## D. Konfiguration (neue ENV, optional — Defaults sind sichtbare Platzhalter)
|
||||||
|
`LEGAL_OPERATOR_NAME`, `LEGAL_OPERATOR_FORM`, `LEGAL_POSTAL_ADDRESS`, `LEGAL_SUPPORT_EMAIL`,
|
||||||
|
`LEGAL_PRIVACY_EMAIL`, `LEGAL_SITE_DOMAIN`, `LEGAL_APP_DOMAIN`, `LEGAL_JURISDICTION`,
|
||||||
|
`LEGAL_HOSTING_PROVIDER`, `LEGAL_SERVER_LOCATION`, `LEGAL_BACKUP_RETENTION`.
|
||||||
|
|
||||||
|
## E. Manuelle Schritte nach Deployment
|
||||||
|
1. `LEGAL_*`-Angaben setzen (oder direkt in `src/config/legal.ts`).
|
||||||
|
2. `datenschutz@lageplan.ch` als überwachtes Postfach bestätigen.
|
||||||
|
3. Migration läuft automatisch; prüfen, dass die 5 Dokumente aktiv sind (`/admin/legal`).
|
||||||
|
4. Bestehende Org-Admins sehen beim nächsten Login die Organisationsbestätigung.
|
||||||
|
5. Stripe-/SMTP-DPA prüfen; Subprozessoren-Regionen ergänzen.
|
||||||
|
6. `/admin/legal` ggf. im Admin-Menü verlinken (aktuell direkte URL).
|
||||||
|
|
||||||
|
## F. Offene rechtliche Entscheidungen
|
||||||
|
Betreiberform/-adresse, Datenschutzkontakt, Serverstandorte (inkl. Backups/Logs), Unterauftragnehmer-
|
||||||
|
Regionen & DPAs, Aufbewahrungsfristen, Nutzungsrecht FKS/BABS-Symbole, Versicherung, AVV mit Feuerwehren,
|
||||||
|
juristische Schlussprüfung.
|
||||||
|
|
||||||
|
## G. Tests & Prüfung
|
||||||
|
- `npm test` (Vitest): **13 Tests grün** (Consent-Regeln, Re-Consent, 7 Org-Bestätigungen, Platzhalter,
|
||||||
|
keine erfundenen Subprozessoren).
|
||||||
|
- `tsc --noEmit`: nur vorbestehender `nodemailer`-Typhinweis (unabhängig).
|
||||||
|
- `next build`: erfolgreich (66 Seiten).
|
||||||
|
- Manuell zu prüfen (kein E2E-Framework, unverhältnismässig): Registrierung ohne Zustimmung blockiert,
|
||||||
|
Re-Consent-Dialog erscheint, Checkboxen einzeln/nicht vorausgewählt, Admin-Seiten nicht öffentlich.
|
||||||
|
|
||||||
|
## H. Nicht umgesetzt / bewusst als Folgeschritt
|
||||||
|
- **Plan-Freigabe-Workflow** (Entwurf/geprüft/freigegeben/archiviert, geprüft-von/-am, Prüf-Erinnerungen):
|
||||||
|
grosser neuer DB-+UI-Umbau; stattdessen Export-/Dashboard-/Berechnungs-Hinweise umgesetzt. Empfehlung als
|
||||||
|
eigenes Projekt.
|
||||||
|
- **MFA für Admins**, CSP verschärfen, Socket.io-CORS einschränken, geteilter Rate-Limit-Store: in
|
||||||
|
`technical-organizational-measures.md` als empfohlene Massnahmen dokumentiert.
|
||||||
|
- Cookie-Banner: unverändert (nur technisch notwendige Cookies; in Datenschutz dokumentiert).
|
||||||
37
docs/legal/backup-and-restore.md
Normal file
37
docs/legal/backup-and-restore.md
Normal 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.
|
||||||
44
docs/legal/current-state-audit.md
Normal file
44
docs/legal/current-state-audit.md
Normal 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.0–v1.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 1–14 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.
|
||||||
23
docs/legal/data-processing-inventory.md
Normal file
23
docs/legal/data-processing-inventory.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Dateninventar / Verzeichnis der Bearbeitungstätigkeiten — Lageplan
|
||||||
|
|
||||||
|
Stand: 2026-07-23 · sorgfältiger Entwurf, ersetzt keine juristische Prüfung.
|
||||||
|
Abgeleitet aus dem tatsächlichen Code-Stand. Offene Fristen/Standorte als Platzhalter `[…]`.
|
||||||
|
|
||||||
|
| Datenkategorie | Zweck | Betroffene | Herkunft | Speicherort | Empfänger/Drittanbieter | Auslandbekanntgabe | Aufbewahrung | Löschprozess | Schutzmassnahmen |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Kontodaten (Name, E-Mail, Rolle) | Authentifizierung, Verwaltung | Benutzer | Eingabe bei Registrierung | PostgreSQL | — | `[SERVERSTANDORT]` | bis Kontolöschung | Selfservice/Antrag; Backups bis Zyklusende | Rollen/Mandanten, TLS, Zugriffskontrolle |
|
||||||
|
| 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 |
|
||||||
|
| Hochgeladene Dateien (Logos, Planbilder, Symbole) | Darstellung | Org | Upload | MinIO | — | `[SERVERSTANDORT]` | bis Löschung | mit Projekt/Org | Zugriffsschutz, Validierung |
|
||||||
|
| 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 |
|
||||||
|
| 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 |
|
||||||
|
| Unterstützungsbeiträge (Betrag, Name/Nachricht, payment_intent) | Zahlungsabwicklung | Spender | Eingabe/Stripe | Stripe + `SystemSetting` | Stripe | ja (möglich) | `[FRIST]` | `[PROZESS]` | Zahlungsdaten nur bei Stripe |
|
||||||
|
| Karten-/Geo-Anfragen | Karten/Adresse/Wetter | Benutzer | Client | (extern) | OSM/Esri/swisstopo/Nominatim/Open-Meteo | möglich | keine Speicherung durch App | — | nur Kachel/Koordinaten |
|
||||||
|
| localStorage (Zeichnungen, Sync-Queue, Theme, Onboarding, letztes Projekt, Cookie-Hinweis) | Funktion/Offline | Benutzer | Client | Gerät des Nutzers | — | — | bis lokal gelöscht | Browser | verbleibt lokal |
|
||||||
|
|
||||||
|
## Offene Entscheidungen
|
||||||
|
|
||||||
|
- Konkrete Aufbewahrungsfristen (Logs, Zustimmungen, Zahlungsdaten) → siehe `retention-and-deletion.md`.
|
||||||
|
- Serverstandorte bestätigen.
|
||||||
44
docs/legal/incident-response.md
Normal file
44
docs/legal/incident-response.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Incident-Response — Lageplan
|
||||||
|
|
||||||
|
Stand: 2026-07-23 · sorgfältiger Entwurf. **Manueller Entscheidungsprozess** — es erfolgt KEINE
|
||||||
|
automatische Meldung an Behörden. Meldepflichten werden im Einzelfall geprüft.
|
||||||
|
|
||||||
|
## Kontakte (auszufüllen)
|
||||||
|
- Verantwortlich (Betreiber): `[NAME]` · `[KONTAKT]`
|
||||||
|
- Datenschutzkontakt: siehe `src/config/legal.ts` (`privacyContactEmail`)
|
||||||
|
- Technischer Notfallkontakt: `[NAME/TELEFON]`
|
||||||
|
- Zuständige Aufsichtsbehörde (CH): EDÖB — `[LINK/KONTAKT]`
|
||||||
|
|
||||||
|
## Ablauf
|
||||||
|
|
||||||
|
1. **Erkennung** — Quelle (Monitoring, Meldung, Nutzerhinweis), Zeitpunkt und Symptome festhalten.
|
||||||
|
2. **Interne Eskalation** — Verantwortliche informieren; Vorfall im Vorfallprotokoll eröffnen.
|
||||||
|
3. **Eindämmung** — betroffene Systeme/Zugänge isolieren, ggf. Dienste temporär sperren.
|
||||||
|
4. **Beweissicherung** — Logs/Artefakte sichern, bevor Änderungen erfolgen.
|
||||||
|
5. **Bewertung betroffener Daten** — welche Datenkategorien/Personen sind betroffen?
|
||||||
|
6. **Risikobewertung** — Eintritts-/Schadenswahrscheinlichkeit für Betroffene.
|
||||||
|
7. **Information betroffener Organisationen** — ohne unangemessene Verzögerung, sachlich.
|
||||||
|
8. **Prüfung Behördenmeldung** — Meldepflicht (z.B. EDÖB) im Einzelfall prüfen und **manuell** entscheiden/dokumentieren.
|
||||||
|
9. **Passwort-/Schlüsselrotation** — Secrets, Tokens, ggf. Nutzer-Passwörter rotieren.
|
||||||
|
10. **Wiederherstellung** — aus sauberem Stand/Backup; Integrität prüfen.
|
||||||
|
11. **Nachbearbeitung** — Ursachenanalyse, Massnahmen, Lessons Learned.
|
||||||
|
|
||||||
|
## Vorfallprotokoll (Vorlage)
|
||||||
|
| Feld | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| Vorfall-ID / Datum | |
|
||||||
|
| Entdeckt durch / wann | |
|
||||||
|
| Beschreibung | |
|
||||||
|
| Betroffene Daten/Personen | |
|
||||||
|
| Sofortmassnahmen | |
|
||||||
|
| Risikobewertung | |
|
||||||
|
| Information an Organisationen (wann/wie) | |
|
||||||
|
| Behördenmeldung (ja/nein, Begründung) | |
|
||||||
|
| Rotation durchgeführt | |
|
||||||
|
| Wiederherstellung | |
|
||||||
|
| Abschluss / Massnahmen | |
|
||||||
|
|
||||||
|
## Vom Betreiber zu treffende Entscheidungen
|
||||||
|
- Verbindliche Kontaktliste und Erreichbarkeit.
|
||||||
|
- Melde-Schwellen und interne Fristen.
|
||||||
|
- Aufbewahrung des Vorfallprotokolls.
|
||||||
27
docs/legal/international-data-transfers.md
Normal file
27
docs/legal/international-data-transfers.md
Normal 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.
|
||||||
60
docs/legal/licenses-and-attributions.md
Normal file
60
docs/legal/licenses-and-attributions.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Lizenzen und Attributionen — Lageplan
|
||||||
|
|
||||||
|
Stand: 2026-07-24 · sorgfältiger Entwurf. Eine Quellenangabe ersetzt NICHT automatisch eine
|
||||||
|
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
|
||||||
|
| Quelle | Nutzung | Lizenz/Bedingungen | Attribution | Offene Frage |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| OpenStreetMap | Basiskarte, Nominatim | ODbL (Daten) | "© OpenStreetMap-Mitwirkende" (vorhanden im Impressum/Karte) | Tile-Usage-Policy einhalten (Fair Use) |
|
||||||
|
| Esri World Imagery | Satellitenlayer | Esri-Nutzungsbedingungen | "Esri World Imagery" | ⚠️ kommerzielle/Nutzungsbedingungen prüfen |
|
||||||
|
| swisstopo (geo.admin.ch) | Landeskarte | swisstopo-Bedingungen | "© swisstopo" empfohlen | ⚠️ Attribution/Bedingungen bestätigen |
|
||||||
|
| Open-Meteo | Wind/Höhe | Open-Meteo Terms (frei, Attribution empfohlen) | "Open-Meteo" | Attribution ergänzen? |
|
||||||
|
| Open-Elevation | Höhe (Fallback) | offen/community | — | ⚠️ Bedingungen prüfen |
|
||||||
|
|
||||||
|
## Symbole / Icons
|
||||||
|
| Quelle | Nutzung | Lizenz | Attribution | Offene Frage |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
## Schriftarten
|
||||||
|
| Quelle | Lizenz | Hinweis |
|
||||||
|
|---|---|---|
|
||||||
|
| Barlow (Google Fonts, via next/font) | SIL Open Font License 1.1 | build-time self-hosted, kein Laufzeit-Request zu Google |
|
||||||
|
|
||||||
|
## Wesentliche Bibliotheken (Auszug)
|
||||||
|
| Paket | Lizenz |
|
||||||
|
|---|---|
|
||||||
|
| next / react | MIT |
|
||||||
|
| maplibre-gl | BSD-3-Clause |
|
||||||
|
| jspdf | MIT (+ Teile Apache-2.0) |
|
||||||
|
| qrcode | MIT |
|
||||||
|
| stripe (SDK) | MIT |
|
||||||
|
| bcryptjs, jose, zod, socket.io | MIT |
|
||||||
|
| @prisma/client, prisma | Apache-2.0 |
|
||||||
|
|
||||||
|
## Bereits umgesetzte sichtbare Attributionen
|
||||||
|
- Karten-Attribution "© OpenStreetMap contributors | MapLibre" auf der Karte.
|
||||||
|
- "Taktische Symbole: Bildquelle Feuerwehr Koordination Schweiz FKS" im Footer.
|
||||||
|
- Kartendaten-Hinweis im Impressum (OpenStreetMap, Nominatim).
|
||||||
|
|
||||||
|
## Offene Freigabefragen (Betreiber)
|
||||||
|
1. **FKS/BABS-Symbole**: Nutzungsrecht/Freigabe für die konkrete Verwendung schriftlich bestätigen.
|
||||||
|
2. Esri/swisstopo-Nutzungsbedingungen für den vorgesehenen (auch nicht-kommerziellen) Einsatz prüfen.
|
||||||
|
3. Vollständige Lizenzliste (z.B. `license-checker`) generieren und Drittlizenzen bündeln, falls verteilt.
|
||||||
39
docs/legal/manual-action-checklist.md
Normal file
39
docs/legal/manual-action-checklist.md
Normal 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.
|
||||||
24
docs/legal/privacy-risk-assessment.md
Normal file
24
docs/legal/privacy-risk-assessment.md
Normal 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.
|
||||||
37
docs/legal/retention-and-deletion.md
Normal file
37
docs/legal/retention-and-deletion.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Aufbewahrungs- und Löschkonzept — Lageplan
|
||||||
|
|
||||||
|
Stand: 2026-07-23 · sorgfältiger Entwurf. **Keine Fristen erfunden** — offene Fristen als `[FRIST — zu
|
||||||
|
entscheiden]`. Der Betreiber muss die markierten Entscheidungen treffen.
|
||||||
|
|
||||||
|
| Datenkategorie | Aufbewahrung | Löschung / Prozess |
|
||||||
|
|---|---|---|
|
||||||
|
| Aktive Benutzerkonten | solange Konto besteht | Selfservice/Antrag; kaskadierende Löschung zugehöriger Daten |
|
||||||
|
| Deaktivierte Konten (`isActive=false`) | `[FRIST]` | `[PROZESS — automatische Löschung nach X?]` |
|
||||||
|
| Organisationen (Tenant) | solange aktiv | Löschung durch Org-Admin (Einstellungen) nach Berechtigungsprüfung |
|
||||||
|
| Pläne / Projekte | bis Löschung durch Org | mit Projekt-/Orglöschung (Cascade) |
|
||||||
|
| Dateiuploads (MinIO) | bis Löschung des Bezugsobjekts | mit Projekt/Org/Icon |
|
||||||
|
| Audit-/Aktionslogs (falls geführt) | `[FRIST]` | `[PROZESS]` |
|
||||||
|
| Sicherheits-/Server-Logs | `[FRIST]` | Rotation `[KONFIG]` |
|
||||||
|
| Einladungen | `[FRIST]` | nach Annahme/Ablauf |
|
||||||
|
| Passwort-Reset-Tokens | Ablauf (`resetTokenExpiry`) | mit Verwendung/Ablauf |
|
||||||
|
| E-Mail-Verifizierungs-Tokens | bis Verifizierung | mit Verifizierung |
|
||||||
|
| E-Mails (beim SMTP-Anbieter) | beim Anbieter | `[ABHÄNGIG]` |
|
||||||
|
| Zahlungsinformationen (Stripe + `SystemSetting/donations`) | `[FRIST]` (ggf. gesetzliche Aufbewahrung) | `[PROZESS]`; Zahlungsdaten liegen bei Stripe |
|
||||||
|
| Supportanfragen (Kontaktformular) | `[FRIST]` | `[PROZESS]` |
|
||||||
|
| Backups | `[BACKUP-AUFBEWAHRUNGSFRIST]` | mit Ablauf des Backup-Zyklus |
|
||||||
|
| Zustimmungshistorie (LegalAcceptance) | zu Nachweiszwecken `[FRIST]` | revisionssicher, i.d.R. nicht gelöscht solange Konto besteht |
|
||||||
|
|
||||||
|
## Grundsätze
|
||||||
|
- Produktivdaten werden nach Abschluss des Löschvorgangs entfernt.
|
||||||
|
- Daten in Sicherungskopien können bis zum Ablauf des regulären Backup-Zyklus fortbestehen.
|
||||||
|
- Organisationsrelevante Daten werden nicht ohne Berechtigungsprüfung sofort gelöscht.
|
||||||
|
- Betroffenenrechte (Auskunft, Berichtigung, Löschung, Export) über `/konto/datenschutz` bzw.
|
||||||
|
Datenschutzkontakt; Eingang wird bestätigt.
|
||||||
|
|
||||||
|
## Vom Betreiber zu treffende Entscheidungen
|
||||||
|
1. Aufbewahrungsfrist deaktivierter Konten und automatischer Löschlauf?
|
||||||
|
2. Log-Aufbewahrung (Server-/Sicherheitslogs) und Rotation?
|
||||||
|
3. Aufbewahrung Zahlungs-/Spendendaten (gesetzliche Pflichten?).
|
||||||
|
4. Backup-Aufbewahrungsfrist (`LEGAL_BACKUP_RETENTION`).
|
||||||
|
5. Aufbewahrung Zustimmungsnachweise nach Kontolöschung.
|
||||||
|
6. Support-/Kontaktanfragen-Frist.
|
||||||
33
docs/legal/subprocessors.md
Normal file
33
docs/legal/subprocessors.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Unterauftragnehmer / Subprozessoren — Lageplan
|
||||||
|
|
||||||
|
Stand: 2026-07-23 · sorgfältiger Entwurf, ersetzt keine juristische Prüfung.
|
||||||
|
|
||||||
|
Nur tatsächlich im Code verwendete bzw. optional konfigurierbare Dienste. Nicht verwendete Dienste
|
||||||
|
sind NICHT aufgeführt. Regionen/Verträge, die nicht sicher bekannt sind, tragen Platzhalter `[…]`.
|
||||||
|
|
||||||
|
| Anbieter | Zweck | Verarbeitete Daten | Sitz / Region | Datenschutzhinweis | Auslandbearbeitung | Vertragsstatus |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| OpenStreetMap Foundation (Tiles) | Basiskarte | Kachel-Koordinaten, IP (Client) | `[REGION — UK/EU]` | osmfoundation.org/wiki/Privacy_Policy | möglich | `[ZU PRÜFEN]` |
|
||||||
|
| Esri (ArcGIS World Imagery) | Satellitenbilder | Kachel-Koordinaten, IP (Client) | `[REGION — USA/global]` | esri.com/legal | wahrscheinlich | `[ZU PRÜFEN]` |
|
||||||
|
| swisstopo (geo.admin.ch) | Schweizer Landeskarte | Kachel-Koordinaten, IP (Client) | Schweiz | geo.admin.ch | nein | `[ZU PRÜFEN]` |
|
||||||
|
| Nominatim (OSM) | Adress-/Reverse-Geocoding | Suchbegriff/GPS, IP (Client) | `[REGION — OSMF]` | osmfoundation.org | möglich | `[ZU PRÜFEN]` |
|
||||||
|
| Open-Meteo | Wind-/Höhendaten | Koordinaten, IP (Client) | `[REGION — EU/DE]` | open-meteo.com/en/terms | möglich | `[ZU PRÜFEN]` |
|
||||||
|
| Open-Elevation | Höhendaten (Fallback) | Koordinaten, IP (Client) | `[REGION]` | open-elevation.com | möglich | `[ZU PRÜFEN]` |
|
||||||
|
| Stripe | Zahlungsabwicklung (freiwillige Beiträge) | Betrag, freiwilliger Name/Nachricht, Zahlungsdaten (direkt bei Stripe) | `[REGION — USA/EU]` | stripe.com/privacy | ja (USA möglich) | `[DPA PRÜFEN]` · nur wenn konfiguriert |
|
||||||
|
| SMTP-/E-Mail-Anbieter (konfigurierbar) | System-E-Mails | Empfängeradresse, Mailinhalt | `[ABHÄNGIG VOM ANBIETER]` | `[ANBIETER]` | abhängig | `[ZU PRÜFEN]` · nur wenn konfiguriert |
|
||||||
|
| MinIO (Objektspeicher, selbst gehostet) | Datei-/Bild-Uploads | Pläne, Logos, Symbole | `[SERVERSTANDORT]` | — (Eigenbetrieb) | `[ZU BESTÄTIGEN]` | Eigenbetrieb |
|
||||||
|
| PostgreSQL (Datenbank, selbst gehostet) | Primäre Datenhaltung | Konto-, Org-, Plan-, Journaldaten | `[SERVERSTANDORT]` | — (Eigenbetrieb) | `[ZU BESTÄTIGEN]` | Eigenbetrieb |
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- **Client-seitige Dienste** (Tiles, Nominatim, Open-Meteo/Elevation): werden direkt aus dem Browser
|
||||||
|
des Nutzers aufgerufen; dabei kann die IP-Adresse an den Anbieter gelangen.
|
||||||
|
- **Stripe** und **SMTP** sind nur aktiv, wenn in den Systemeinstellungen (DB `SystemSetting`) konfiguriert.
|
||||||
|
- **MapTiler / Mapbox / Google Maps**: NICHT genutzt (MapTiler nur in der CSP-Allowlist vorbereitet).
|
||||||
|
- **Analytics / Error-Tracking (Sentry, GA, Plausible, PostHog)**: NICHT vorhanden.
|
||||||
|
|
||||||
|
## Offene Entscheidungen des Betreibers
|
||||||
|
|
||||||
|
- Serverstandort für PostgreSQL, MinIO, Backups und Logs abschliessend bestätigen.
|
||||||
|
- DPA/AVV mit Stripe und dem gewählten E-Mail-Anbieter abschliessen und Status hier eintragen.
|
||||||
|
- Verarbeitungsregionen der Kartendienste konkretisieren.
|
||||||
62
docs/legal/technical-organizational-measures.md
Normal file
62
docs/legal/technical-organizational-measures.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Technische und organisatorische Massnahmen (TOM) — Lageplan
|
||||||
|
|
||||||
|
Stand: 2026-07-23 · sorgfältiger Entwurf. Ist-Zustand aus dem Code; offene Punkte klar markiert.
|
||||||
|
Legende: ✅ vorhanden · ⚠️ teilweise / zu bestätigen · ❌ offen / nicht umgesetzt.
|
||||||
|
|
||||||
|
## Zugriff & Berechtigungen
|
||||||
|
- ✅ Mandantentrennung je Organisation (Tenant), `getProjectWithTenantCheck`.
|
||||||
|
- ✅ Rollenkonzept: SERVER_ADMIN, TENANT_ADMIN, OPERATOR, VIEWER.
|
||||||
|
- ✅ 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.
|
||||||
|
|
||||||
|
## Authentifizierung & Sessions
|
||||||
|
- ✅ Passwort-Hashing: bcrypt (cost 12), nie Klartext.
|
||||||
|
- ✅ JWT-Session (jose, HS256), Cookie `auth-token` httpOnly, secure (prod), sameSite=lax, 24 h/14 Tage.
|
||||||
|
- ✅ Passwort-Reset mit kryptographischem Token (32 Bytes), Ablauf.
|
||||||
|
- ✅ E-Mail-Verifizierung.
|
||||||
|
|
||||||
|
## Transport & Web-Härtung
|
||||||
|
- ✅ HTTPS/TLS; HSTS (max-age 2 Jahre, preload).
|
||||||
|
- ✅ Sicherheits-Header: CSP (mit `frame-ancestors 'self'`), X-Content-Type-Options nosniff,
|
||||||
|
Referrer-Policy, Permissions-Policy, COOP, X-XSS-Protection.
|
||||||
|
- ⚠️ CSP enthält `unsafe-inline`/`unsafe-eval` (für MapLibre gelockert) → langfristig verschärfen.
|
||||||
|
- ✅ Private Routen (/app,/admin,/settings,/konto): `X-Robots-Tag: noindex` + `Cache-Control: no-store` (Middleware).
|
||||||
|
- ⚠️ Socket.io-Server (`server-custom.js`): CORS `origin:'*'` → **einschränken empfohlen**.
|
||||||
|
|
||||||
|
## Eingaben & Uploads
|
||||||
|
- ✅ Rate-Limiting: Login, Registrierung, Passwort-Reset, Verifizierung, Kontakt, Konto-Löschung, Share-PIN.
|
||||||
|
- ⚠️ Rate-Limiter ist In-Memory (pro Instanz) → bei Mehr-Instanz-Betrieb geteilten Store nutzen.
|
||||||
|
- ⚠️ Upload-Validierung (MIME/Grösse): vorhanden für Icons/Logos/Planbilder → **Umfang bestätigen**.
|
||||||
|
- ✅ Zugriffsschutz auf Dateien (serve-Routen mit Tenant-Check).
|
||||||
|
|
||||||
|
## Speicherung & Verschlüsselung
|
||||||
|
- ✅ Übertragung verschlüsselt (TLS).
|
||||||
|
- ⚠️ Verschlüsselung ruhender Daten (DB/MinIO/Backups): `[ZU BESTÄTIGEN]`.
|
||||||
|
- ⚠️ Secrets-Verwaltung: ENV (`NEXTAUTH_SECRET`, MinIO) + DB (`SystemSetting`: SMTP/Stripe) → **Absicherung der DB-Secrets bestätigen**.
|
||||||
|
|
||||||
|
## Backup & Wiederherstellung
|
||||||
|
- ✅ Automatisches Backup (eigener `backup`-Container): täglich Postgres-Dump + MinIO-Dateien,
|
||||||
|
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
|
||||||
|
- ✅ Server-Logs (console). ⚠️ Vermeidung sensibler Daten in Logs → **Review empfohlen**.
|
||||||
|
- ❌ Zentrales Monitoring/Alerting: `[OFFEN]`.
|
||||||
|
|
||||||
|
## Betrieb & Prozesse
|
||||||
|
- ✅ Idempotente Migrationen (`prisma/migrate.js`) beim Container-Start.
|
||||||
|
- ⚠️ Dependency-/Patch-Management: `[PROZESS DOKUMENTIEREN]`.
|
||||||
|
- ⚠️ Trennung Entwicklung/Test/Produktion: `[BESTÄTIGEN]`; Umgang mit Testdaten `[BESTÄTIGEN]`.
|
||||||
|
- ✅ Löschkonzept/Datenexport: siehe `retention-and-deletion.md`; Betroffenenrechte via `/konto/datenschutz`.
|
||||||
|
- ✅ Berechtigungsentzug: Benutzer sperren (`isActive`), aus Organisation entfernen.
|
||||||
|
|
||||||
|
## Direkt umgesetzte risikoarme Verbesserungen (dieses Update)
|
||||||
|
- Private Routen auf `noindex`/`no-store` gesetzt.
|
||||||
|
- Rechtliche Prüf-Hinweise in Exporten und Berechnungen.
|
||||||
|
- Revisionssichere Zustimmungsprotokollierung ohne roh-IP.
|
||||||
|
|
||||||
|
## Empfohlene, noch offene Massnahmen
|
||||||
|
- MFA für Admins; CSP verschärfen; Socket.io-CORS einschränken; geteilter Rate-Limit-Store;
|
||||||
|
Verschlüsselung ruhender Daten + Backups bestätigen; Restore-Tests; Monitoring.
|
||||||
46
docs/legal/website-claims-audit.md
Normal file
46
docs/legal/website-claims-audit.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Website Claims Audit — Lageplan
|
||||||
|
|
||||||
|
Stand: 2026-07-23 · Version 1.0 (sorgfältiger Entwurf, ersetzt keine juristische Prüfung)
|
||||||
|
|
||||||
|
Prüfung aller werblichen / absoluten Aussagen im Repository und deren Korrektur zu sachlichen,
|
||||||
|
nachweisbaren Formulierungen. Fundstellen beziehen sich auf den Stand vor der Korrektur.
|
||||||
|
|
||||||
|
| # | Bisherige Aussage | Fundstelle (vorher) | Risiko / Widerspruch | Neue Formulierung | Geänderte Datei |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | „100% · Swiss Hosted · Server in der Schweiz" | `src/app/page.tsx:241` | Serverstandort nicht für alle Subprozessoren (Stripe, SMTP, globale Kartendienste) belegt; absolutes 100% | „CH · Swiss Made · In der Schweiz entwickelt" | `src/app/page.tsx` |
|
||||||
|
| 2 | „24/7 · Verfügbar" | `src/app/page.tsx:243` | Verfügbarkeitszusage ohne SLA | „Web · Überall nutzbar · Browser, Tablet, Smartphone" | `src/app/page.tsx` |
|
||||||
|
| 3 | „Die Applikation ist DSG- und DSGVO-konform." (FAQ + JSON-LD) | `src/app/page.tsx:87,458` | Unbelegte pauschale Konformitätszusage | „Die eingesetzten Dienstleister und Speicherorte sind in der Datenschutzerklärung transparent aufgeführt. Lageplan orientiert sich am Schweizer Datenschutzgesetz (DSG)." | `src/app/page.tsx` |
|
||||||
|
| 4 | „Sicher & Schweiz … DSG/DSGVO-konform." (Feature-Karte) | `src/app/page.tsx:361` | Absolute Sicherheits-/Konformitätszusage | „Sicherheit & Datenschutz … Angemessene technische und organisatorische Schutzmassnahmen — Details in der Datenschutzerklärung." | `src/app/page.tsx` |
|
||||||
|
| 5 | „Lückenlose Protokollierung aller Aktionen" | `src/app/page.tsx:360` | „lückenlos" = absolute Zusage | „Protokollierung von Aktionen mit Zeitstempel für bessere Nachvollziehbarkeit." | `src/app/page.tsx` |
|
||||||
|
| 6 | „Nichts. Lageplan ist kostenlos …" (FAQ + JSON-LD) | `src/app/page.tsx:55,442` | Impliziert dauerhafte Kostenlosigkeit | „… grundsätzlich kostenlos … Ein Anspruch darauf, dass alle Funktionen dauerhaft kostenlos bleiben, besteht nicht." | `src/app/page.tsx` |
|
||||||
|
| 7 | „100% deiner Spende fliesst direkt …" | `src/app/page.tsx:588`, `src/app/spenden/page.tsx:252` | Ignoriert Stripe-Gebühren; absolutes 100% | „Beiträge werden nach Abzug allfälliger Zahlungsgebühren für Hosting, Infrastruktur und Weiterentwicklung eingesetzt." | `src/app/page.tsx`, `src/app/spenden/page.tsx` |
|
||||||
|
| 8 | „Kein Unternehmen, keine Investoren …" | `src/app/page.tsx:589`, `src/app/spenden/page.tsx:253` | Betreiberform ist rechtlich noch festzulegen; potenziell irreführend | „Ein Projekt aus der Feuerwehr-Praxis — entwickelt in der Freizeit." | `src/app/page.tsx`, `src/app/spenden/page.tsx` |
|
||||||
|
| 9 | „Lageplan ist und bleibt kostenlos." | `src/app/page.tsx:551`, `src/app/spenden/page.tsx:239` | „bleibt" = dauerhafte Zusage | „Lageplan wird grundsätzlich kostenlos angeboten." | `src/app/page.tsx`, `src/app/spenden/page.tsx` |
|
||||||
|
| 10 | „Zahlung sicher via Stripe" | `src/app/page.tsx:574`, `src/app/spenden/page.tsx:226` | „sicher" = absolute Zusage | „Zahlung über Stripe" | `src/app/page.tsx`, `src/app/spenden/page.tsx` |
|
||||||
|
| 11 | „Server-Hosting in der Schweiz (monatliche Kosten)" | `src/app/spenden/page.tsx:99` | Serverstandort nicht abschliessend belegt | „Server-Hosting und Infrastruktur (monatliche Kosten)" | `src/app/spenden/page.tsx` |
|
||||||
|
| 12 | „Jeder Franken fliesst direkt in Serverkosten …" | `src/app/spenden/page.tsx:244` | Ignoriert Gebühren | „Beiträge helfen, Hosting, Infrastruktur und Weiterentwicklung mitzufinanzieren." | `src/app/spenden/page.tsx` |
|
||||||
|
| 13 | „weder an Dritte verkauft noch weitergegeben" | `src/app/impressum/page.tsx:108` | Absolute „keine Weitergabe"; widerspricht Einsatz von Dienstleistern | „… nicht an Dritte verkauft. Für den technischen Betrieb können sorgfältig ausgewählte … Dienstleister eingesetzt werden. Einzelheiten enthält die Datenschutzerklärung." | `src/app/impressum/page.tsx` |
|
||||||
|
| 14 | „Einsatzdaten … werden verschlüsselt gespeichert" | `src/app/impressum/page.tsx:116` | „verschlüsselt gespeichert" technisch nicht belegt (Ruhedaten) | Zugriffs-/Mandantenkonzept + „Übertragung erfolgt über HTTPS/TLS"; Passwörter „gehasht (bcrypt)" | `src/app/impressum/page.tsx` |
|
||||||
|
| 15 | „Passwort (verschlüsselt gespeichert)" | `src/app/datenschutz/page.tsx:47` | Falsch: Passwörter werden gehasht, nicht verschlüsselt | „Passwort wird mit einem geeigneten Einwegverfahren (bcrypt) gehasht" | `src/app/datenschutz/page.tsx` (Neuaufbau) |
|
||||||
|
| 16 | „Alle Daten … auf Servern in der Schweiz … keine Übermittlung ins Ausland" | `src/app/datenschutz/page.tsx:105-106` | Widerspricht Stripe/SMTP/Kartendiensten; absolute Auslandsaussage | Ziffer 6 „Bekanntgabe ins Ausland": Auslandbearbeitung je nach Dienstleister möglich | `src/app/datenschutz/page.tsx` (Neuaufbau) |
|
||||||
|
| 17 | „Personenbezogene Daten werden nicht an Dritte weitergegeben" | `src/app/datenschutz/page.tsx:91,164` | Absolute „keine Weitergabe" | Dienstleisterliste (Subprozessoren) + „nicht zu Werbezwecken verkauft" | `src/app/datenschutz/page.tsx` (Neuaufbau) |
|
||||||
|
| 18 | „Gemäss DSG und DSGVO haben Sie folgende Rechte" | `src/app/datenschutz/page.tsx:144` | Pauschale DSGVO-Anwendbarkeit unbelegt | „Im Rahmen des anwendbaren Rechts …" + Beschwerde bei EDÖB | `src/app/datenschutz/page.tsx` (Neuaufbau) |
|
||||||
|
| 19 | „Bei der Löschung werden sämtliche Daten unwiderruflich entfernt" | `src/app/datenschutz/page.tsx:128` | Ignoriert Backups | „Produktivdaten werden nach Abschluss des Löschvorgangs entfernt. Daten in Sicherungskopien können bis zum Ablauf des Backup-Zyklus fortbestehen." | `src/app/datenschutz/page.tsx` (Neuaufbau) |
|
||||||
|
| 20 | „Passwörter werden verschlüsselt in der Datenbank gespeichert." | `src/components/admin/settings-tab.tsx:242` | Falsch: gehasht, nicht verschlüsselt | „… mit einem Einwegverfahren (bcrypt) gehasht …" | `src/components/admin/settings-tab.tsx` |
|
||||||
|
|
||||||
|
## Nicht beanstandet (Kontext geprüft)
|
||||||
|
|
||||||
|
- Zahlreiche `100%` in `rapport-pdf.tsx`, `measure-panel.tsx`, `map-view.tsx`, E-Mail-Templates → CSS-Breitenangaben, keine Werbeaussage.
|
||||||
|
- „sicher" in `README.md`, `docs/*`, `jwt-secret.ts`, `modules.ts` → technische Kommentare, nicht öffentlich.
|
||||||
|
- „SSL-Zertifikate" (Substantiv) → keine „zertifiziert"-Behauptung.
|
||||||
|
|
||||||
|
## Nicht gefunden (im gesamten Repo)
|
||||||
|
|
||||||
|
`garantiert`, `fehlerfrei`, `gemeinnützig`, `steuerlich`, `rechtssicher`, `zertifiziert`,
|
||||||
|
„immer verfügbar", „kostenlos für immer" — keine Treffer.
|
||||||
|
|
||||||
|
## Noch offen (Betreiber-Entscheid)
|
||||||
|
|
||||||
|
- Formulierung „In der Schweiz entwickelt": Sobald der Serverstandort für alle Subprozessoren/Backups
|
||||||
|
bestätigt ist, kann ggf. eine präzisere Hosting-Aussage getroffen werden.
|
||||||
|
- „Purepixel / Kingstickers Ziberi" (Impressum-Copyright): rechtsverbindliche Betreiberbezeichnung bestätigen.
|
||||||
@@ -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 [
|
||||||
{
|
{
|
||||||
|
|||||||
2515
package-lock.json
generated
2515
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.6.9",
|
"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": {
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
@@ -41,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",
|
||||||
@@ -52,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",
|
||||||
@@ -63,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"
|
||||||
},
|
},
|
||||||
@@ -74,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",
|
||||||
@@ -88,7 +98,8 @@
|
|||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"last 2 Chrome versions",
|
"last 2 Chrome versions",
|
||||||
|
|||||||
@@ -151,11 +151,92 @@ async function migrate() {
|
|||||||
"projectId" TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE
|
"projectId" TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS module_items_project_module_idx ON module_items("projectId", "moduleId")`,
|
`CREATE INDEX IF NOT EXISTS module_items_project_module_idx ON module_items("projectId", "moduleId")`,
|
||||||
|
// Rechtsdokumente (versioniert, bewusst gesetzte Version)
|
||||||
|
`CREATE TABLE IF NOT EXISTS legal_documents (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
version TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
"publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"effectiveAt" TIMESTAMP(3),
|
||||||
|
"contentHash" TEXT NOT NULL,
|
||||||
|
url TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(type, version)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS legal_documents_type_active_idx ON legal_documents(type, "isActive")`,
|
||||||
|
// Revisionssichere Zustimmungen — nie überschreiben, neue Version = neuer Datensatz
|
||||||
|
`CREATE TABLE IF NOT EXISTS legal_acceptances (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
"userId" TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
"organizationId" TEXT,
|
||||||
|
"documentType" TEXT NOT NULL,
|
||||||
|
"documentVersion" TEXT NOT NULL,
|
||||||
|
"contentHash" TEXT NOT NULL,
|
||||||
|
"acceptedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
locale TEXT,
|
||||||
|
context TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE("userId", "organizationId", "documentType", "documentVersion", context)
|
||||||
|
)`,
|
||||||
|
`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")`,
|
||||||
|
`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 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Step 4b: Rechtsdokumente seeden/aktivieren (idempotent) ───
|
||||||
|
// WICHTIG: Diese Metadaten müssen mit src/config/legal.ts übereinstimmen. Version bewusst
|
||||||
|
// erhöhen, wenn sich der Inhalt WESENTLICH ändert (löst Re-Consent aus).
|
||||||
|
console.log(' [4b] Seeding legal documents...')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const LEGAL_DOCS = [
|
||||||
|
{ type: 'TERMS', version: '1.0.0', title: 'Nutzungsbedingungen', url: '/nutzungsbedingungen', publishedAt: '2026-07-23' },
|
||||||
|
{ type: 'PRIVACY', version: '1.0.0', title: 'Datenschutzerklärung', url: '/datenschutz', 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: '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) {
|
||||||
|
const contentHash = crypto.createHash('sha256').update(`${doc.type}@${doc.version}`).digest('hex')
|
||||||
|
try {
|
||||||
|
// Neue Version anlegen (falls noch nicht vorhanden), als aktiv markieren
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`INSERT INTO legal_documents (type, version, title, url, "contentHash", "publishedAt", "effectiveAt", "isActive")
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$6,true)
|
||||||
|
ON CONFLICT (type, version) DO UPDATE SET title = EXCLUDED.title, url = EXCLUDED.url, "isActive" = true`,
|
||||||
|
doc.type, doc.version, doc.title, doc.url, contentHash, new Date(doc.publishedAt)
|
||||||
|
)
|
||||||
|
// Andere Versionen desselben Typs deaktivieren (nur eine aktive je Typ)
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`UPDATE legal_documents SET "isActive" = false WHERE type = $1 AND version <> $2`,
|
||||||
|
doc.type, doc.version
|
||||||
|
)
|
||||||
|
} catch (e) { /* table might not exist yet on very first run */ }
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Step 5: Set safe defaults ───
|
// ─── Step 5: Set safe defaults ───
|
||||||
console.log(' [5/7] Setting defaults...')
|
console.log(' [5/7] Setting defaults...')
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -134,10 +140,78 @@ model User {
|
|||||||
iconAssets IconAsset[]
|
iconAssets IconAsset[]
|
||||||
upgradeRequests UpgradeRequest[]
|
upgradeRequests UpgradeRequest[]
|
||||||
rapports Rapport[]
|
rapports Rapport[]
|
||||||
|
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 ──────────
|
||||||
|
// Dokumenttypen (als String gehalten, damit die idempotente Raw-SQL-Migration einfach bleibt):
|
||||||
|
// TERMS | PRIVACY | ORGANIZATION_DECLARATION | RESPONSIBLE_USE | DATA_PROCESSING_AGREEMENT
|
||||||
|
|
||||||
|
model LegalDocument {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
type String
|
||||||
|
version String
|
||||||
|
title String
|
||||||
|
publishedAt DateTime @default(now())
|
||||||
|
effectiveAt DateTime?
|
||||||
|
contentHash String
|
||||||
|
url String?
|
||||||
|
isActive Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([type, version])
|
||||||
|
@@index([type, isActive])
|
||||||
|
@@map("legal_documents")
|
||||||
|
}
|
||||||
|
|
||||||
|
model LegalAcceptance {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
// NULL = persönliche Zustimmung (nicht organisationsbezogen)
|
||||||
|
organizationId String?
|
||||||
|
documentType String
|
||||||
|
documentVersion String
|
||||||
|
contentHash String
|
||||||
|
acceptedAt DateTime @default(now())
|
||||||
|
locale String?
|
||||||
|
// REGISTRATION | LOGIN_RECONSENT | ORGANIZATION_CREATION | ADMIN_ROLE_ACCEPTANCE | DONATION
|
||||||
|
context String
|
||||||
|
// Nachweisdaten (zu Beweiszwecken der Einwilligung): IP + Gerät/Browser zum Zeitpunkt der Zustimmung.
|
||||||
|
ipAddress String?
|
||||||
|
userAgent String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
// Verhindert doppelte identische Zustimmungen; neue Version erzeugt neuen Datensatz.
|
||||||
|
@@unique([userId, organizationId, documentType, documentVersion, context])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([documentType, documentVersion])
|
||||||
|
@@map("legal_acceptances")
|
||||||
|
}
|
||||||
|
|
||||||
model TenantMembership {
|
model TenantMembership {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
role Role @default(OPERATOR)
|
role Role @default(OPERATOR)
|
||||||
|
|||||||
51
scripts/decrypt-backup.js
Normal file
51
scripts/decrypt-backup.js
Normal 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
44
scripts/restore.sh
Normal 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"
|
||||||
@@ -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)')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
164
src/app/admin/legal/page.tsx
Normal file
164
src/app/admin/legal/page.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { ArrowLeft, Download, Search, Loader2, FileText, AlertTriangle } from 'lucide-react'
|
||||||
|
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 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; ipAddress: string | null; userAgent: string | null; contentHash: string | null }
|
||||||
|
|
||||||
|
export default function AdminLegalPage() {
|
||||||
|
const { user, loading, isServerAdmin } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
const [data, setData] = useState<{ documents: Doc[]; totalActiveUsers: number; missing: Miss[] } | null>(null)
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [results, setResults] = useState<Acc[] | null>(null)
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return
|
||||||
|
if (!user || !isServerAdmin()) { router.replace('/app'); return }
|
||||||
|
fetch('/api/admin/legal').then(r => r.json()).then(setData).catch(() => setData(null))
|
||||||
|
}, [loading, user, isServerAdmin, router])
|
||||||
|
|
||||||
|
const search = useCallback(async () => {
|
||||||
|
setSearching(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/legal/acceptances?q=${encodeURIComponent(q)}`)
|
||||||
|
const d = await res.json()
|
||||||
|
setResults(d.acceptances || [])
|
||||||
|
} catch { setResults([]) } finally { setSearching(false) }
|
||||||
|
}, [q])
|
||||||
|
|
||||||
|
if (loading || !data) {
|
||||||
|
return <div className="min-h-screen flex items-center justify-center"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background px-4 py-8">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<Link href="/admin" className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-6">
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" /> Zur Administration
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||||
|
<FileText className="w-6 h-6 text-red-600" /> Rechtsdokumente & Zustimmungen
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Aktive Benutzer: {data.totalActiveUsers}. Neue Versionen werden bewusst über <code>src/config/legal.ts</code>{' '}
|
||||||
|
und die Migration veröffentlicht (löst Re-Consent aus).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Fehlende Zustimmungen */}
|
||||||
|
{data.missing.some(m => m.missing > 0) && (
|
||||||
|
<div className="mt-4 rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-800 p-3 text-sm text-amber-800 dark:text-amber-300 flex items-start gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<strong>Offene Pflicht-Zustimmungen:</strong>
|
||||||
|
<ul className="mt-1">
|
||||||
|
{data.missing.filter(m => m.missing > 0).map(m => (
|
||||||
|
<li key={`${m.type}@${m.version}`}>{m.type} v{m.version}: {m.missing} Benutzer offen</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dokumente */}
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="font-semibold text-foreground mb-2">Dokumentversionen</h2>
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/40 text-left text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2">Typ</th>
|
||||||
|
<th className="px-3 py-2">Version</th>
|
||||||
|
<th className="px-3 py-2">Veröffentlicht</th>
|
||||||
|
<th className="px-3 py-2">Status</th>
|
||||||
|
<th className="px-3 py-2">Zustimmungen</th>
|
||||||
|
<th className="px-3 py-2">Hash</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.documents.map(d => (
|
||||||
|
<tr key={d.id} className="border-t border-border">
|
||||||
|
<td className="px-3 py-2 font-medium text-foreground">{d.type}</td>
|
||||||
|
<td className="px-3 py-2">{d.version}</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground">{new Date(d.publishedAt).toLocaleDateString('de-CH')}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{d.isActive
|
||||||
|
? <span className="rounded-full bg-green-100 text-green-700 dark:bg-green-950/40 dark:text-green-400 px-2 py-0.5 text-xs">aktiv</span>
|
||||||
|
: <span className="rounded-full bg-muted text-muted-foreground px-2 py-0.5 text-xs">inaktiv</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 tabular-nums">{d.acceptanceCount}</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-[10px] text-muted-foreground">{d.contentHash.slice(0, 12)}…</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Suche + Export */}
|
||||||
|
<section className="mt-8">
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-2">
|
||||||
|
<h2 className="font-semibold text-foreground">Zustimmungshistorie</h2>
|
||||||
|
<a
|
||||||
|
href={`/api/admin/legal/acceptances?format=csv&q=${encodeURIComponent(q)}`}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-red-600 hover:underline"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" /> CSV exportieren
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={e => { e.preventDefault(); search() }} className="flex gap-2 mb-3">
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={e => setQ(e.target.value)}
|
||||||
|
placeholder="Nach Benutzer (E-Mail/Name) suchen…"
|
||||||
|
className="flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={searching} className="inline-flex items-center gap-1.5 rounded-lg bg-red-600 hover:bg-red-700 text-white px-4 py-2 text-sm font-medium disabled:opacity-50">
|
||||||
|
{searching ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />} Suchen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{results && (
|
||||||
|
results.length === 0
|
||||||
|
? <p className="text-sm text-muted-foreground">Keine Zustimmungen gefunden.</p>
|
||||||
|
: (
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/40 text-left text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2">Zeitpunkt</th>
|
||||||
|
<th className="px-3 py-2">Benutzer</th>
|
||||||
|
<th className="px-3 py-2">Dokument</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>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{results.map(a => (
|
||||||
|
<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"><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>{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 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>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,16 +91,17 @@ 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>
|
||||||
<TabsTrigger value="soma" className="gap-2"><AlertTriangle className="w-4 h-4" />SOMA</TabsTrigger>
|
<TabsTrigger value="soma" className="gap-2"><AlertTriangle className="w-4 h-4" />SOMA</TabsTrigger>
|
||||||
<TabsTrigger value="suggestions" className="gap-2"><ClipboardList className="w-4 h-4" />Wörterliste</TabsTrigger>
|
<TabsTrigger value="suggestions" className="gap-2"><ClipboardList className="w-4 h-4" />Wörterliste</TabsTrigger>
|
||||||
<TabsTrigger value="hose-types" className="gap-2"><Settings className="w-4 h-4" />Schläuche</TabsTrigger>
|
<TabsTrigger value="hose-types" className="gap-2"><Settings className="w-4 h-4" />Schläuche</TabsTrigger>
|
||||||
<TabsTrigger value="donate" className="gap-2"><Heart className="w-4 h-4" />Spenden</TabsTrigger>
|
<TabsTrigger value="donate" className="gap-2"><Heart className="w-4 h-4" />Unterstützung</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -169,14 +174,14 @@ export default function AdminPage() {
|
|||||||
<h3 className="font-semibold text-lg">Lageplan unterstützen</h3>
|
<h3 className="font-semibold text-lg">Lageplan unterstützen</h3>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Lageplan ist ein kostenloses Projekt — entwickelt von einem aktiven Feuerwehrmann
|
Lageplan ist ein kostenloses Projekt — entwickelt von einem aktiven Feuerwehrmann
|
||||||
in seiner Freizeit. Ohne Firma, ohne Investoren. Deine Spende hilft, den Betrieb und die
|
in seiner Freizeit. Ein freiwilliger Beitrag hilft, den Betrieb und die
|
||||||
Weiterentwicklung zu finanzieren.
|
Weiterentwicklung zu finanzieren.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-muted/50 rounded-lg p-4 mb-6">
|
<div className="bg-muted/50 rounded-lg p-4 mb-6">
|
||||||
<p className="text-sm font-medium mb-2">Wohin fliesst deine Spende?</p>
|
<p className="text-sm font-medium mb-2">Wohin fliesst dein Beitrag?</p>
|
||||||
<ul className="text-sm text-muted-foreground space-y-1">
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
<li className="flex items-center gap-2"><Shield className="w-3.5 h-3.5 text-primary shrink-0" /> Server-Hosting in der Schweiz (monatliche Kosten)</li>
|
<li className="flex items-center gap-2"><Shield className="w-3.5 h-3.5 text-primary shrink-0" /> Server-Hosting in der Schweiz (monatliche Kosten)</li>
|
||||||
<li className="flex items-center gap-2"><Settings className="w-3.5 h-3.5 text-primary shrink-0" /> Entwicklung neuer Features und Bugfixes</li>
|
<li className="flex items-center gap-2"><Settings className="w-3.5 h-3.5 text-primary shrink-0" /> Entwicklung neuer Features und Bugfixes</li>
|
||||||
@@ -188,7 +193,7 @@ export default function AdminPage() {
|
|||||||
<Button asChild>
|
<Button asChild>
|
||||||
<a href="/spenden" target="_blank" rel="noopener noreferrer">
|
<a href="/spenden" target="_blank" rel="noopener noreferrer">
|
||||||
<Heart className="w-4 h-4 mr-2" />
|
<Heart className="w-4 h-4 mr-2" />
|
||||||
Jetzt spenden
|
Jetzt unterstützen
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" asChild>
|
<Button variant="outline" asChild>
|
||||||
|
|||||||
55
src/app/api/admin/backup/config/route.ts
Normal file
55
src/app/api/admin/backup/config/route.ts
Normal 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))
|
||||||
|
}
|
||||||
17
src/app/api/admin/backup/run/route.ts
Normal file
17
src/app/api/admin/backup/run/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/app/api/admin/backup/test/route.ts
Normal file
23
src/app/api/admin/backup/test/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/app/api/admin/legal/acceptances/route.ts
Normal file
65
src/app/api/admin/legal/acceptances/route.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/db'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
|
||||||
|
// GET: Zustimmungshistorie suchen (nach Benutzer-E-Mail/Name oder Organisation).
|
||||||
|
// Query: ?q=<suchbegriff>&format=json|csv (nur SERVER_ADMIN)
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSession()
|
||||||
|
if (!user || user.role !== 'SERVER_ADMIN') {
|
||||||
|
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = (req.nextUrl.searchParams.get('q') || '').trim()
|
||||||
|
const format = req.nextUrl.searchParams.get('format') || 'json'
|
||||||
|
|
||||||
|
// Passende Benutzer ermitteln (falls Suchbegriff gesetzt)
|
||||||
|
let userFilter: any = {}
|
||||||
|
if (q) {
|
||||||
|
const users = await (prisma as any).user.findMany({
|
||||||
|
where: { OR: [{ email: { contains: q, mode: 'insensitive' } }, { name: { contains: q, mode: 'insensitive' } }] },
|
||||||
|
select: { id: true },
|
||||||
|
take: 500,
|
||||||
|
})
|
||||||
|
userFilter = { userId: { in: users.map((u: any) => u.id) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptances = await (prisma as any).legalAcceptance.findMany({
|
||||||
|
where: userFilter,
|
||||||
|
orderBy: { acceptedAt: 'desc' },
|
||||||
|
take: 2000,
|
||||||
|
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.
|
||||||
|
const userIds = Array.from(new Set(acceptances.map((a: any) => a.userId)))
|
||||||
|
const users = await (prisma as any).user.findMany({ where: { id: { in: userIds } }, select: { id: true, email: true, name: true } })
|
||||||
|
const uMap = new Map<string, any>(users.map((u: any) => [u.id, u]))
|
||||||
|
const rows = acceptances.map((a: any) => ({
|
||||||
|
...a,
|
||||||
|
email: uMap.get(a.userId)?.email || '',
|
||||||
|
name: uMap.get(a.userId)?.name || '',
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (format === 'csv') {
|
||||||
|
const header = ['acceptedAt', 'email', 'name', 'documentType', 'documentVersion', 'context', 'ipAddress', 'userAgent', 'contentHash', 'organizationId']
|
||||||
|
const esc = (v: any) => `"${String(v ?? '').replace(/"/g, '""')}"`
|
||||||
|
const lines = [header.join(',')]
|
||||||
|
for (const r of rows) {
|
||||||
|
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'), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/csv; charset=utf-8',
|
||||||
|
'Content-Disposition': `attachment; filename="zustimmungen-${new Date().toISOString().slice(0, 10)}.csv"`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ acceptances: rows })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching acceptances:', error)
|
||||||
|
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/app/api/admin/legal/route.ts
Normal file
45
src/app/api/admin/legal/route.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/db'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
|
||||||
|
// GET: Übersicht für Systemadministratoren — aktive Dokumente, Zustimmungszahlen je Version,
|
||||||
|
// fehlende Zustimmungen (aktive Benutzer ohne aktuelle TERMS/PRIVACY-Zustimmung).
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSession()
|
||||||
|
if (!user || user.role !== 'SERVER_ADMIN') {
|
||||||
|
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const documents = await (prisma as any).legalDocument.findMany({
|
||||||
|
orderBy: [{ type: 'asc' }, { publishedAt: 'desc' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Zustimmungszahlen je (Typ, Version)
|
||||||
|
const grouped = await (prisma as any).legalAcceptance.groupBy({
|
||||||
|
by: ['documentType', 'documentVersion'],
|
||||||
|
_count: { _all: true },
|
||||||
|
})
|
||||||
|
const countMap = new Map<string, number>()
|
||||||
|
for (const g of grouped) countMap.set(`${g.documentType}@${g.documentVersion}`, g._count._all)
|
||||||
|
|
||||||
|
const withCounts = documents.map((d: any) => ({
|
||||||
|
...d,
|
||||||
|
acceptanceCount: countMap.get(`${d.type}@${d.version}`) || 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Fehlende Pflicht-Zustimmungen: aktive Benutzer, die aktive TERMS/PRIVACY nicht akzeptiert haben
|
||||||
|
const activeRequired = documents.filter((d: any) => d.isActive && (d.type === 'TERMS' || d.type === 'PRIVACY'))
|
||||||
|
const totalUsers = await (prisma as any).user.count({ where: { isActive: true } })
|
||||||
|
const missing: { type: string; version: string; missing: number }[] = []
|
||||||
|
for (const doc of activeRequired) {
|
||||||
|
const accepted = countMap.get(`${doc.type}@${doc.version}`) || 0
|
||||||
|
missing.push({ type: doc.type, version: doc.version, missing: Math.max(0, totalUsers - accepted) })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ documents: withCounts, totalActiveUsers: totalUsers, missing })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching admin legal overview:', error)
|
||||||
|
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/app/api/admin/users/[id]/mfa-reset/route.ts
Normal file
38
src/app/api/admin/users/[id]/mfa-reset/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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({
|
||||||
|
|||||||
64
src/app/api/auth/mfa-login/verify/route.ts
Normal file
64
src/app/api/auth/mfa-login/verify/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/api/auth/mfa-login/webauthn/options/route.ts
Normal file
37
src/app/api/auth/mfa-login/webauthn/options/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/app/api/auth/mfa-login/webauthn/verify/route.ts
Normal file
72
src/app/api/auth/mfa-login/webauthn/verify/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/app/api/auth/mfa/status/route.ts
Normal file
28
src/app/api/auth/mfa/status/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/app/api/auth/mfa/totp/confirm/route.ts
Normal file
46
src/app/api/auth/mfa/totp/confirm/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app/api/auth/mfa/totp/disable/route.ts
Normal file
22
src/app/api/auth/mfa/totp/disable/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/app/api/auth/mfa/totp/setup/route.ts
Normal file
31
src/app/api/auth/mfa/totp/setup/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/app/api/auth/mfa/webauthn/[id]/route.ts
Normal file
32
src/app/api/auth/mfa/webauthn/[id]/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/app/api/auth/mfa/webauthn/register/options/route.ts
Normal file
39
src/app/api/auth/mfa/webauthn/register/options/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/app/api/auth/mfa/webauthn/register/verify/route.ts
Normal file
48
src/app/api/auth/mfa/webauthn/register/verify/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,16 @@ import { sendEmail } from '@/lib/email'
|
|||||||
import { randomBytes } from 'crypto'
|
import { randomBytes } from 'crypto'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { registerLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
import { registerLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
||||||
|
import { recordAcceptances } from '@/lib/legal'
|
||||||
|
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
organizationName: z.string().min(2, 'Organisationsname zu kurz').max(200),
|
organizationName: z.string().min(2, 'Organisationsname zu kurz').max(200),
|
||||||
name: z.string().min(2, 'Name zu kurz').max(200),
|
name: z.string().min(2, 'Name zu kurz').max(200),
|
||||||
email: z.string().email('Ungültige E-Mail-Adresse'),
|
email: z.string().email('Ungültige E-Mail-Adresse'),
|
||||||
password: z.string().min(8, 'Passwort muss mindestens 8 Zeichen haben'),
|
password: z.string().min(8, 'Passwort muss mindestens 8 Zeichen haben'),
|
||||||
|
// Aktive Zustimmung erforderlich (keine vorausgewählte Checkbox im UI).
|
||||||
|
acceptTerms: z.literal(true, { errorMap: () => ({ message: 'Bitte die Nutzungsbedingungen akzeptieren.' }) }),
|
||||||
|
acceptPrivacy: z.literal(true, { errorMap: () => ({ message: 'Bitte die Kenntnisnahme der Datenschutzerklärung bestätigen.' }) }),
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@@ -79,8 +83,8 @@ export async function POST(req: NextRequest) {
|
|||||||
maxUsers: 5,
|
maxUsers: 5,
|
||||||
maxProjects: 10,
|
maxProjects: 10,
|
||||||
contactEmail: data.email,
|
contactEmail: data.email,
|
||||||
privacyAccepted: body.privacyAccepted === true,
|
privacyAccepted: true,
|
||||||
privacyAcceptedAt: body.privacyAccepted ? new Date() : null,
|
privacyAcceptedAt: new Date(),
|
||||||
adminAccessAccepted: body.adminAccessAccepted === true,
|
adminAccessAccepted: body.adminAccessAccepted === true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -106,6 +110,22 @@ export async function POST(req: NextRequest) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Zustimmungen revisionssicher protokollieren (aktive Dokumentversion).
|
||||||
|
// Die Organisationsbestätigung wird separat nach dem ersten Login abgefragt (Re-Consent).
|
||||||
|
try {
|
||||||
|
await recordAcceptances({
|
||||||
|
userId: user.id,
|
||||||
|
organizationId: tenant.id,
|
||||||
|
context: 'REGISTRATION',
|
||||||
|
types: ['TERMS', 'PRIVACY'],
|
||||||
|
locale: 'de-CH',
|
||||||
|
ipAddress: getClientIp(req),
|
||||||
|
userAgent: req.headers.get('user-agent'),
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[register] Zustimmungsprotokollierung fehlgeschlagen (nicht blockierend):', e)
|
||||||
|
}
|
||||||
|
|
||||||
// Send verification email
|
// Send verification email
|
||||||
let baseUrl = process.env.NEXTAUTH_URL || req.headers.get('origin') || `${req.headers.get('x-forwarded-proto') || 'https'}://${req.headers.get('host')}` || 'http://localhost:3000'
|
let baseUrl = process.env.NEXTAUTH_URL || req.headers.get('origin') || `${req.headers.get('x-forwarded-proto') || 'https'}://${req.headers.get('host')}` || 'http://localhost:3000'
|
||||||
if (baseUrl && !baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
if (baseUrl && !baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||||
|
|||||||
24
src/app/api/cron/backup/route.ts
Normal file
24
src/app/api/cron/backup/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,8 +27,8 @@ export async function POST(req: NextRequest) {
|
|||||||
price_data: {
|
price_data: {
|
||||||
currency: 'chf',
|
currency: 'chf',
|
||||||
product_data: {
|
product_data: {
|
||||||
name: 'Spende für Lageplan',
|
name: 'Freiwilliger Unterstützungsbeitrag für Lageplan',
|
||||||
description: `Freiwillige Spende von CHF ${amount} für die Weiterentwicklung`,
|
description: `Freiwilliger Unterstützungsbeitrag von CHF ${amount} für die Weiterentwicklung`,
|
||||||
},
|
},
|
||||||
unit_amount: amountInCents,
|
unit_amount: amountInCents,
|
||||||
},
|
},
|
||||||
|
|||||||
54
src/app/api/legal/accept/route.ts
Normal file
54
src/app/api/legal/accept/route.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { getClientIp } from '@/lib/rate-limit'
|
||||||
|
import { recordAcceptances, getPendingAcceptances, type LegalAcceptanceContext, type LegalDocumentType } from '@/lib/legal'
|
||||||
|
|
||||||
|
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', 'EXISTING_USER_RECONSENT', 'SUPPORT_CONTRIBUTION', 'DONATION']
|
||||||
|
|
||||||
|
// POST: erfasst Zustimmungen des angemeldeten Benutzers revisionssicher.
|
||||||
|
// body: { types: LegalDocumentType[], context: LegalAcceptanceContext }
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSession()
|
||||||
|
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||||
|
|
||||||
|
// CSRF-Schutz: nur same-origin JSON-Requests (Cookie ist sameSite=lax).
|
||||||
|
const contentType = req.headers.get('content-type') || ''
|
||||||
|
if (!contentType.includes('application/json')) {
|
||||||
|
return NextResponse.json({ error: 'Ungültiger Request' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({}))
|
||||||
|
const types: LegalDocumentType[] = Array.isArray(body.types)
|
||||||
|
? body.types.filter((t: any) => VALID_TYPES.includes(t))
|
||||||
|
: []
|
||||||
|
const context: LegalAcceptanceContext = VALID_CONTEXTS.includes(body.context) ? body.context : 'LOGIN_RECONSENT'
|
||||||
|
|
||||||
|
if (types.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Keine gültigen Dokumenttypen' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organisationsbezogene Zustimmung (Org-Bestätigung) an den Tenant binden.
|
||||||
|
const orgBound = context === 'ORGANIZATION_CREATION' || context === 'ADMIN_ROLE_ACCEPTANCE' || types.includes('ORGANIZATION_DECLARATION')
|
||||||
|
const organizationId = orgBound ? (user.tenantId ?? null) : null
|
||||||
|
|
||||||
|
const count = await recordAcceptances({
|
||||||
|
userId: user.id,
|
||||||
|
organizationId,
|
||||||
|
context,
|
||||||
|
types,
|
||||||
|
locale: 'de-CH',
|
||||||
|
ipAddress: getClientIp(req),
|
||||||
|
userAgent: req.headers.get('user-agent'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const isOrgAdmin = user.role === 'TENANT_ADMIN'
|
||||||
|
const pending = await getPendingAcceptances(user.id, isOrgAdmin)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, recorded: count, needsAcceptance: pending.length > 0, pending })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error recording legal acceptance:', error)
|
||||||
|
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app/api/legal/my-acceptances/route.ts
Normal file
22
src/app/api/legal/my-acceptances/route.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/db'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
|
||||||
|
// GET: die eigenen protokollierten Zustimmungen des angemeldeten Benutzers (revisionssicher, read-only).
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSession()
|
||||||
|
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||||
|
|
||||||
|
const acceptances = await (prisma as any).legalAcceptance.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
orderBy: { acceptedAt: 'desc' },
|
||||||
|
select: { documentType: true, documentVersion: true, context: true, acceptedAt: true, organizationId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ acceptances })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching own acceptances:', error)
|
||||||
|
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/app/api/legal/status/route.ts
Normal file
29
src/app/api/legal/status/route.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { getActiveDocuments, getPendingAcceptances } from '@/lib/legal'
|
||||||
|
|
||||||
|
// GET: aktive Rechtsdokumente + offene Pflicht-Zustimmungen des aktuellen Benutzers.
|
||||||
|
// Grundlage für den blockierenden Re-Consent-Dialog.
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSession()
|
||||||
|
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||||
|
|
||||||
|
const isOrgAdmin = user.role === 'TENANT_ADMIN'
|
||||||
|
const [active, pending] = await Promise.all([
|
||||||
|
getActiveDocuments(),
|
||||||
|
getPendingAcceptances(user.id, isOrgAdmin),
|
||||||
|
])
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
isOrgAdmin,
|
||||||
|
organizationId: user.tenantId ?? null,
|
||||||
|
documents: active,
|
||||||
|
pending, // leer = alles akzeptiert
|
||||||
|
needsAcceptance: pending.length > 0,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching legal status:', error)
|
||||||
|
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
import { JournalView } from '@/components/journal/journal-view'
|
import { JournalView } from '@/components/journal/journal-view'
|
||||||
import { Lock, Eye, Pencil, AlertTriangle, WifiOff, GraduationCap, Map as MapIcon, ClipboardList, LayoutGrid } from 'lucide-react'
|
import { Lock, Eye, Pencil, AlertTriangle, WifiOff, GraduationCap, Map as MapIcon, ClipboardList, LayoutGrid } from 'lucide-react'
|
||||||
|
import { UsageNoticeBanner } from '@/components/legal/usage-notice-banner'
|
||||||
import { CustomDragLayer } from '@/components/map/custom-drag-layer'
|
import { CustomDragLayer } from '@/components/map/custom-drag-layer'
|
||||||
import { OnboardingTour, resetOnboardingTour } from '@/components/onboarding/onboarding-tour'
|
import { OnboardingTour, resetOnboardingTour } from '@/components/onboarding/onboarding-tour'
|
||||||
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'
|
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'
|
||||||
@@ -1030,11 +1031,15 @@ 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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<UsageNoticeBanner />
|
||||||
|
|
||||||
{/* Kompakte Status-Zeile — eine Zeile mit Chips statt bis zu vier gestapelten Bannern.
|
{/* Kompakte Status-Zeile — eine Zeile mit Chips statt bis zu vier gestapelten Bannern.
|
||||||
Zeigt nur, was gerade zutrifft; im Normalfall (Einsatz, online, verifiziert,
|
Zeigt nur, was gerade zutrifft; im Normalfall (Einsatz, online, verifiziert,
|
||||||
niemand fremd am Zeichnen) erscheint sie gar nicht. */}
|
niemand fremd am Zeichnen) erscheint sie gar nicht. */}
|
||||||
@@ -1162,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}
|
||||||
|
|||||||
@@ -1,187 +1,207 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import { LegalPage } from '@/components/legal/legal-page'
|
||||||
import { ArrowLeft, Shield, Server, Trash2, Mail } from 'lucide-react'
|
import { legal, isPlaceholder } from '@/config/legal'
|
||||||
|
|
||||||
|
function Val({ value }: { value: string }) {
|
||||||
|
if (isPlaceholder(value)) {
|
||||||
|
return <span className="text-amber-600 dark:text-amber-400 font-medium">{value}</span>
|
||||||
|
}
|
||||||
|
return <span className="text-foreground font-medium">{value}</span>
|
||||||
|
}
|
||||||
|
|
||||||
export default function DatenschutzPage() {
|
export default function DatenschutzPage() {
|
||||||
|
const d = legal.documents.privacy
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 px-4 py-12">
|
<LegalPage
|
||||||
<div className="max-w-3xl mx-auto">
|
title="Datenschutzerklärung"
|
||||||
<Link href="/" className="text-sm text-gray-400 hover:text-gray-300 inline-flex items-center gap-1 mb-8">
|
version={d.version}
|
||||||
<ArrowLeft className="w-3.5 h-3.5" />
|
publishedAt={d.publishedAt}
|
||||||
Zurück zur Startseite
|
intro={`Diese Erklärung informiert transparent darüber, welche Personendaten ${legal.projectName} bearbeitet, zu welchem Zweck und wer daran beteiligt ist. Sie orientiert sich am Schweizer Datenschutzgesetz (DSG).`}
|
||||||
</Link>
|
>
|
||||||
|
<section>
|
||||||
|
<h2>1. Verantwortlicher</h2>
|
||||||
|
<p>
|
||||||
|
Verantwortlich für die Datenbearbeitung ist der Betreiber der Plattform{' '}
|
||||||
|
<Val value={legal.operatorName} /> (<Val value={legal.operatorLegalForm} />),{' '}
|
||||||
|
<Val value={legal.postalAddress} />.
|
||||||
|
</p>
|
||||||
|
<p><strong>Datenschutzkontakt:</strong> <Val value={legal.privacyContactEmail} /></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="bg-card rounded-xl shadow-2xl p-8 md:p-12 border border-border">
|
<section>
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<h2>2. Welche Daten werden bearbeitet?</h2>
|
||||||
<div className="w-10 h-10 bg-red-600 rounded-full flex items-center justify-center">
|
<ul>
|
||||||
<Shield className="w-5 h-5 text-white" />
|
<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>
|
||||||
</div>
|
<li><strong>Einsatz-/Inhaltsdaten:</strong> Lagepläne, Journaleinträge, Zeichnungen, Koordinaten, Symbole, hochgeladene Dateien (Logos, Planbilder, Symbole) sowie zugehörige Projekt- und Organisationsdaten.</li>
|
||||||
<h1 className="text-3xl font-bold text-foreground">Datenschutzerklärung</h1>
|
<li><strong>Protokoll-/Sicherheitsdaten:</strong> technische Zugriffs- und Fehlerprotokolle (z.B. Zeitpunkt, ungefähre technische Angaben) zur Gewährleistung von Betrieb und Sicherheit.</li>
|
||||||
</div>
|
<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>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p className="text-muted-foreground mb-8">
|
<section>
|
||||||
Gültig ab 1. Januar 2026 — Lageplan.ch, betrieben in der Schweiz.
|
<h2>3. Zwecke</h2>
|
||||||
</p>
|
<ul>
|
||||||
|
<li>Bereitstellung und Betrieb der Applikation, Authentifizierung und Benutzer-/Organisationsverwaltung</li>
|
||||||
|
<li>Echtzeit-Synchronisierung von Einsatzdaten zwischen berechtigten Teammitgliedern</li>
|
||||||
|
<li>Erstellung von Rapporten und PDF-/PNG-Exporten</li>
|
||||||
|
<li>Gewährleistung von Sicherheit, Stabilität und Missbrauchsprävention</li>
|
||||||
|
<li>Technischer Support und Fehlerbehebung</li>
|
||||||
|
<li>Erfüllung rechtlicher Pflichten (soweit anwendbar)</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* 1. Verantwortlicher */}
|
<section>
|
||||||
<section className="mb-8">
|
<h2>4. Grundlagen der Bearbeitung</h2>
|
||||||
<h2 className="text-xl font-semibold text-foreground mb-3 flex items-center gap-2">
|
<p>
|
||||||
<span className="text-red-500 font-mono text-sm">01</span>
|
Die Bearbeitung erfolgt insbesondere zur Erfüllung des Nutzungsverhältnisses (Vertrag), aufgrund
|
||||||
Verantwortlicher
|
berechtigter Interessen am sicheren und stabilen Betrieb sowie – wo erforderlich – gestützt auf Ihre
|
||||||
</h2>
|
Einwilligung. Zwingende gesetzliche Bestimmungen bleiben vorbehalten.
|
||||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
</p>
|
||||||
Verantwortlich für die Datenverarbeitung auf lageplan.ch ist der Betreiber der Plattform.
|
</section>
|
||||||
Bei Fragen zum Datenschutz wenden Sie sich bitte an die im Impressum angegebene Kontaktadresse
|
|
||||||
oder per E-Mail an <span className="text-foreground font-medium">datenschutz@lageplan.ch</span>.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 2. Welche Daten */}
|
<section id="dienstleister" className="scroll-mt-20">
|
||||||
<section className="mb-8">
|
<h2>5. Empfänger und Dienstleister (Subprozessoren)</h2>
|
||||||
<h2 className="text-xl font-semibold text-foreground mb-3 flex items-center gap-2">
|
<p>
|
||||||
<span className="text-red-500 font-mono text-sm">02</span>
|
Für den technischen Betrieb können sorgfältig ausgewählte Dienstleister eingesetzt werden. Nachfolgend
|
||||||
Welche Daten werden erhoben?
|
die tatsächlich eingesetzten bzw. optional konfigurierbaren Dienste. Einige Karten-, Geocoding- und
|
||||||
</h2>
|
Wetterdienste werden direkt aus Ihrem Browser aufgerufen; dabei kann die aufgerufene Kachel-/Standort-
|
||||||
<div className="space-y-3 text-sm text-muted-foreground leading-relaxed">
|
Anfrage (und damit Ihre IP-Adresse) an den jeweiligen Anbieter übermittelt werden.
|
||||||
<p><strong className="text-foreground">Registrierungsdaten:</strong> Name, E-Mail-Adresse, Organisationsname, Passwort (verschlüsselt gespeichert).</p>
|
</p>
|
||||||
<p><strong className="text-foreground">Einsatzdaten:</strong> Lagepläne, Journal-Einträge, Zeichnungen, Koordinaten, Symbole und zugehörige Projektdaten, die Sie in der Applikation erstellen.</p>
|
<div className="overflow-x-auto">
|
||||||
<p><strong className="text-foreground">Technische Daten:</strong> IP-Adresse, Browser-Typ, Zugriffszeitpunkt — ausschliesslich für den Betrieb und die Sicherheit der Plattform.</p>
|
<table className="w-full text-xs border-collapse mt-2">
|
||||||
<p><strong className="text-foreground">Hochgeladene Dateien:</strong> Logos, Planbilder und Symbole, die Sie über die Applikation hochladen.</p>
|
<thead>
|
||||||
</div>
|
<tr className="text-left text-foreground border-b border-border">
|
||||||
</section>
|
<th className="py-1.5 pr-3">Dienst</th>
|
||||||
|
<th className="py-1.5 pr-3">Zweck</th>
|
||||||
{/* 3. Zweck */}
|
<th className="py-1.5 pr-3">Daten</th>
|
||||||
<section className="mb-8">
|
<th className="py-1.5">Region</th>
|
||||||
<h2 className="text-xl font-semibold text-foreground mb-3 flex items-center gap-2">
|
</tr>
|
||||||
<span className="text-red-500 font-mono text-sm">03</span>
|
</thead>
|
||||||
Zweck der Datenverarbeitung
|
<tbody>
|
||||||
</h2>
|
{legal.processors.map(p => (
|
||||||
<div className="space-y-2 text-sm text-muted-foreground leading-relaxed">
|
<tr key={p.name} className="border-b border-border/50 align-top">
|
||||||
<p>Ihre Daten werden ausschliesslich für folgende Zwecke verwendet:</p>
|
<td className="py-1.5 pr-3 text-foreground font-medium">{p.name}{p.optional ? ' *' : ''}</td>
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
<td className="py-1.5 pr-3">{p.purpose}</td>
|
||||||
<li>Bereitstellung und Betrieb der Lageplan-Applikation</li>
|
<td className="py-1.5 pr-3">{p.data}</td>
|
||||||
<li>Authentifizierung und Benutzerverwaltung</li>
|
<td className="py-1.5">{isPlaceholder(p.region) ? <span className="text-amber-600 dark:text-amber-400">{p.region}</span> : p.region}</td>
|
||||||
<li>Echtzeit-Synchronisierung von Einsatzdaten zwischen Teammitgliedern</li>
|
</tr>
|
||||||
<li>Erstellung von Einsatzrapports und PDF-Exporten</li>
|
))}
|
||||||
<li>Technischer Support und Fehlerbehebung</li>
|
</tbody>
|
||||||
</ul>
|
</table>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 4. Betrieb und Wartung */}
|
|
||||||
<section className="mb-8">
|
|
||||||
<h2 className="text-xl font-semibold text-foreground mb-3 flex items-center gap-2">
|
|
||||||
<span className="text-red-500 font-mono text-sm">04</span>
|
|
||||||
Betrieb, Wartung und Support
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-3 text-sm text-muted-foreground leading-relaxed">
|
|
||||||
<p>
|
|
||||||
Für den sicheren und zuverlässigen Betrieb der Plattform können autorisierte Mitarbeitende
|
|
||||||
des Betreibers im Rahmen ihrer Aufgaben auf Systemdaten zugreifen. Dies geschieht ausschliesslich zu folgenden Zwecken:
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
|
||||||
<li>Sicherstellung der Systemverfügbarkeit und -stabilität</li>
|
|
||||||
<li>Fehlerbehebung und technischer Support auf Anfrage</li>
|
|
||||||
<li>Durchführung von Wartungs- und Aktualisierungsarbeiten</li>
|
|
||||||
<li>Gewährleistung der Datensicherheit und Missbrauchsprävention</li>
|
|
||||||
</ul>
|
|
||||||
<p>
|
|
||||||
Der Zugriff erfolgt unter Einhaltung strikter Vertraulichkeitspflichten und wird auf das
|
|
||||||
betrieblich notwendige Minimum beschränkt. Personenbezogene Daten werden nicht an Dritte weitergegeben
|
|
||||||
und ausschliesslich im Rahmen der oben genannten Zwecke verarbeitet.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 5. Hosting & Speicherung */}
|
|
||||||
<section className="mb-8">
|
|
||||||
<h2 className="text-xl font-semibold text-foreground mb-3 flex items-center gap-2">
|
|
||||||
<Server className="w-4 h-4 text-red-500" />
|
|
||||||
<span><span className="text-red-500 font-mono text-sm mr-2">05</span>Hosting und Speicherung</span>
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-2 text-sm text-muted-foreground leading-relaxed">
|
|
||||||
<p>
|
|
||||||
Alle Daten werden auf Servern in der <strong className="text-foreground">Schweiz</strong> gespeichert.
|
|
||||||
Es findet keine Übermittlung von Daten ins Ausland statt.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Die Daten werden in einer PostgreSQL-Datenbank gespeichert. Dateien (Logos, Bilder) werden in einem
|
|
||||||
S3-kompatiblen Objektspeicher (MinIO) abgelegt. Die Übertragung erfolgt verschlüsselt via HTTPS/TLS.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 6. Datenlöschung */}
|
|
||||||
<section className="mb-8">
|
|
||||||
<h2 className="text-xl font-semibold text-foreground mb-3 flex items-center gap-2">
|
|
||||||
<Trash2 className="w-4 h-4 text-red-500" />
|
|
||||||
<span><span className="text-red-500 font-mono text-sm mr-2">06</span>Datenlöschung und Konto-Auflösung</span>
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-2 text-sm text-muted-foreground leading-relaxed">
|
|
||||||
<p>
|
|
||||||
Sie haben jederzeit das Recht, Ihr Konto und Ihre Organisation inklusive aller zugehörigen Daten
|
|
||||||
vollständig zu löschen. Dies können Sie selbstständig über die Applikation durchführen
|
|
||||||
(Einstellungen → Organisation löschen).
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Bei der Löschung werden sämtliche Daten unwiderruflich entfernt: Benutzerkonten, Projekte,
|
|
||||||
Lagepläne, Journal-Einträge, Rapports, hochgeladene Dateien und alle zugehörigen Metadaten.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Alternativ können Sie die Löschung per E-Mail an <span className="text-foreground font-medium">datenschutz@lageplan.ch</span> beantragen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 7. Rechte */}
|
|
||||||
<section className="mb-8">
|
|
||||||
<h2 className="text-xl font-semibold text-foreground mb-3 flex items-center gap-2">
|
|
||||||
<span className="text-red-500 font-mono text-sm">07</span>
|
|
||||||
Ihre Rechte
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-2 text-sm text-muted-foreground leading-relaxed">
|
|
||||||
<p>Gemäss dem Schweizer Datenschutzgesetz (DSG) und der DSGVO haben Sie folgende Rechte:</p>
|
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
|
||||||
<li><strong className="text-foreground">Auskunftsrecht:</strong> Sie können Auskunft über Ihre gespeicherten Daten verlangen.</li>
|
|
||||||
<li><strong className="text-foreground">Berichtigungsrecht:</strong> Sie können die Korrektur unrichtiger Daten verlangen.</li>
|
|
||||||
<li><strong className="text-foreground">Löschungsrecht:</strong> Sie können die Löschung Ihrer Daten verlangen.</li>
|
|
||||||
<li><strong className="text-foreground">Datenportabilität:</strong> Sie können Ihre Daten in einem gängigen Format anfordern.</li>
|
|
||||||
<li><strong className="text-foreground">Widerspruchsrecht:</strong> Sie können der Verarbeitung Ihrer Daten widersprechen.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 8. Cookies */}
|
|
||||||
<section className="mb-8">
|
|
||||||
<h2 className="text-xl font-semibold text-foreground mb-3 flex items-center gap-2">
|
|
||||||
<span className="text-red-500 font-mono text-sm">08</span>
|
|
||||||
Cookies
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
|
||||||
Die Applikation verwendet ausschliesslich <strong className="text-foreground">technisch notwendige Cookies</strong> für
|
|
||||||
die Authentifizierung (Session-Token). Es werden keine Tracking-, Analyse- oder Werbe-Cookies eingesetzt.
|
|
||||||
Es werden keine Daten an Dritte weitergegeben.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 9. Kontakt */}
|
|
||||||
<section className="mb-4">
|
|
||||||
<h2 className="text-xl font-semibold text-foreground mb-3 flex items-center gap-2">
|
|
||||||
<Mail className="w-4 h-4 text-red-500" />
|
|
||||||
<span><span className="text-red-500 font-mono text-sm mr-2">09</span>Kontakt</span>
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
|
||||||
Bei Fragen oder Anliegen zum Datenschutz kontaktieren Sie uns unter:{' '}
|
|
||||||
<span className="text-foreground font-medium">datenschutz@lageplan.ch</span>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs">* nur aktiv, sofern konfiguriert/genutzt (z.B. Zahlungsabwicklung, E-Mail-Versand).</p>
|
||||||
|
<p>Personendaten werden nicht zu Werbezwecken an Dritte verkauft.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="text-center mt-6 text-xs text-gray-500">
|
<section>
|
||||||
© {new Date().getFullYear()} Lageplan.ch — Alle Rechte vorbehalten.
|
<h2>6. Bekanntgabe ins Ausland</h2>
|
||||||
</div>
|
<p>
|
||||||
</div>
|
Je nach eingesetztem Dienstleister (z.B. Zahlungsanbieter, konfigurierter E-Mail-Anbieter, globale
|
||||||
</div>
|
Kartendienste) kann eine Bearbeitung ausserhalb der Schweiz erfolgen. Der Betreiber wählt Dienstleister
|
||||||
|
sorgfältig aus. Eine ausschliessliche Speicherung in der Schweiz wird nicht pauschal zugesichert; die
|
||||||
|
konkreten Verarbeitungsregionen sind – soweit bekannt – in der Tabelle unter Ziffer 5 bzw. in{' '}
|
||||||
|
<code>docs/legal/subprocessors.md</code> aufgeführt. Offene Punkte sind mit Platzhaltern gekennzeichnet.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>7. Sicherheit</h2>
|
||||||
|
<p>
|
||||||
|
Es werden angemessene technische und organisatorische Massnahmen eingesetzt (u.a. Rollen- und
|
||||||
|
Mandantentrennung, Passwort-Hashing, verschlüsselte Übertragung via HTTPS/TLS, Zugriffskontrollen,
|
||||||
|
Rate-Limiting). Ein vollständiger Schutz vor allen Risiken kann jedoch nicht garantiert werden.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>8. Aufbewahrung und Löschung</h2>
|
||||||
|
<p>
|
||||||
|
Personendaten werden so lange aufbewahrt, wie es für die genannten Zwecke oder gesetzliche Pflichten
|
||||||
|
erforderlich ist. Produktivdaten werden nach Abschluss des Löschvorgangs entfernt. Daten in
|
||||||
|
Sicherungskopien (Backups) können bis zum Ablauf des regulären Backup-Zyklus fortbestehen
|
||||||
|
(Aufbewahrungsfrist der Backups: <Val value={legal.backupRetention} />). Die detaillierten Fristen sind in{' '}
|
||||||
|
<code>docs/legal/retention-and-deletion.md</code> dokumentiert.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>9. Zahlungsabwicklung (freiwillige Beiträge)</h2>
|
||||||
|
<p>
|
||||||
|
Freiwillige Unterstützungsbeiträge werden über <strong>Stripe</strong> abgewickelt. Zahlungsdaten
|
||||||
|
(z.B. Kreditkarte, Twint) werden direkt bei Stripe erfasst und nicht in der Applikation gespeichert.
|
||||||
|
An Stripe werden der Betrag sowie ein freiwillig angegebener Name/Nachricht übermittelt. Es werden keine
|
||||||
|
wiederkehrenden Zahlungen eingerichtet. Details siehe{' '}
|
||||||
|
<a href="/unterstuetzen">Unterstützungsseite</a>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>10. Karten- und Geodienste</h2>
|
||||||
|
<p>
|
||||||
|
Für Karten, Adresssuche und Wetter-/Höhendaten werden Dienste von OpenStreetMap (Tiles/Nominatim),
|
||||||
|
Esri (Satellitenbilder), swisstopo (Landeskarte) sowie Open-Meteo/Open-Elevation genutzt. Diese Aufrufe
|
||||||
|
erfolgen aus Ihrem Browser; dabei kann Ihre IP-Adresse an die jeweiligen Anbieter übermittelt werden.
|
||||||
|
Karten-, Adress- und Geodaten können unvollständig, ungenau oder veraltet sein.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>11. Cookies und lokale Speichertechnologien</h2>
|
||||||
|
<p>
|
||||||
|
Es wird ein einziges, technisch notwendiges Cookie gesetzt: <code>auth-token</code> (Session/Anmeldung,
|
||||||
|
httpOnly). Es werden keine Tracking-, Analyse- oder Werbe-Cookies eingesetzt.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Im lokalen Speicher (localStorage) Ihres Geräts werden zur Funktion technisch notwendige Angaben
|
||||||
|
abgelegt, u.a.: zwischengespeicherte Zeichnungen/Offline-Warteschlange, Cache-Zeitstempel für
|
||||||
|
Offline-Karten, Theme-Einstellung, zuletzt geöffnetes Projekt, Onboarding-Status und der Zustand des
|
||||||
|
Cookie-Hinweises. Diese Daten verbleiben auf Ihrem Gerät.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>12. Protokolldaten</h2>
|
||||||
|
<p>
|
||||||
|
Zum Betrieb und zur Sicherheit können technische Protokolle anfallen. Diese werden auf das notwendige
|
||||||
|
Mass beschränkt; sensible Inhalte werden nach Möglichkeit von Protokollen ferngehalten.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>13. Ihre Rechte</h2>
|
||||||
|
<p>Im Rahmen des anwendbaren Rechts stehen Ihnen insbesondere folgende Rechte zu:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Auskunft</strong> über die bearbeiteten Daten</li>
|
||||||
|
<li><strong>Berichtigung</strong> unrichtiger Daten</li>
|
||||||
|
<li><strong>Löschung</strong> (im Rahmen gesetzlicher Pflichten)</li>
|
||||||
|
<li><strong>Datenherausgabe/-export</strong> in einem gängigen Format</li>
|
||||||
|
<li><strong>Widerspruch</strong> gegen bestimmte Bearbeitungen</li>
|
||||||
|
<li><strong>Beschwerde</strong> bei der zuständigen Datenschutzbehörde (in der Schweiz: EDÖB)</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Angemeldete Nutzer können Zustimmungen, Datenexport und Kontolöschung unter{' '}
|
||||||
|
<a href="/konto/datenschutz">Konto & Datenschutz</a> einsehen bzw. anfordern. Anfragen richten Sie an{' '}
|
||||||
|
<Val value={legal.privacyContactEmail} />; der Eingang wird bestätigt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>14. Sicherheitsvorfälle</h2>
|
||||||
|
<p>
|
||||||
|
Bei einem Sicherheitsvorfall mit Risiko für betroffene Personen prüft der Betreiber die erforderlichen
|
||||||
|
Massnahmen und Informationspflichten nach einem definierten internen Prozess.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>15. Änderungen dieser Datenschutzerklärung</h2>
|
||||||
|
<p>
|
||||||
|
Diese Datenschutzerklärung kann angepasst werden. Massgebend ist die jeweils veröffentlichte Version
|
||||||
|
(siehe Kopf dieser Seite). Bei wesentlichen Änderungen wird angemessen informiert.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</LegalPage>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,21 +31,23 @@ export default function ImpressumPage() {
|
|||||||
<section>
|
<section>
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-3">Betreiber der Webseite</h2>
|
<h2 className="text-xl font-semibold text-gray-900 mb-3">Betreiber der Webseite</h2>
|
||||||
<div className="bg-gray-50 rounded-xl p-6 space-y-1">
|
<div className="bg-gray-50 rounded-xl p-6 space-y-1">
|
||||||
<p className="font-semibold text-gray-900">Purepixel</p>
|
<p className="font-semibold text-gray-900">Perparim Ziberi</p>
|
||||||
<p>Ein Angebot der Firma Kingstickers Ziberi</p>
|
<p className="text-gray-500 text-sm">Privatperson</p>
|
||||||
<p className="mt-3">Perparim Ziberi</p>
|
<p className="mt-3">Anglikerstrasse 56</p>
|
||||||
<p>Anglikerstrasse 56</p>
|
|
||||||
<p>5612 Villmergen</p>
|
<p>5612 Villmergen</p>
|
||||||
<p>Schweiz</p>
|
<p>Schweiz</p>
|
||||||
<p className="mt-3">
|
<p className="mt-3">
|
||||||
<span className="text-gray-500">UID:</span> CHE-353.865.879
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span className="text-gray-500">E-Mail:</span>{' '}
|
<span className="text-gray-500">E-Mail:</span>{' '}
|
||||||
<a href="mailto:app@lageplan.ch" className="text-red-600 hover:text-red-500">
|
<a href="mailto:app@lageplan.ch" className="text-red-600 hover:text-red-500">
|
||||||
app@lageplan.ch
|
app@lageplan.ch
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-gray-500">Datenschutz:</span>{' '}
|
||||||
|
<a href="mailto:datenschutz@lageplan.ch" className="text-red-600 hover:text-red-500">
|
||||||
|
datenschutz@lageplan.ch
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -89,9 +91,9 @@ export default function ImpressumPage() {
|
|||||||
<h2 className="text-xl font-semibold text-gray-900 mb-3">Urheberrechte</h2>
|
<h2 className="text-xl font-semibold text-gray-900 mb-3">Urheberrechte</h2>
|
||||||
<p>
|
<p>
|
||||||
Die Urheber- und alle anderen Rechte an Inhalten, Bildern, Fotos oder anderen Dateien auf
|
Die Urheber- und alle anderen Rechte an Inhalten, Bildern, Fotos oder anderen Dateien auf
|
||||||
dieser Website gehören ausschliesslich der Firma Kingstickers Ziberi oder den speziell
|
dieser Website gehören ausschliesslich Perparim Ziberi oder den speziell genannten
|
||||||
genannten Rechtsinhabern. Für die Reproduktion jeglicher Elemente ist die schriftliche
|
Rechtsinhabern. Für die Reproduktion jeglicher Elemente ist die schriftliche Zustimmung der
|
||||||
Zustimmung der Urheberrechtsträger im Voraus einzuholen.
|
Urheberrechtsträger im Voraus einzuholen.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -104,17 +106,20 @@ export default function ImpressumPage() {
|
|||||||
Privatsphäre sowie auf Schutz vor Missbrauch ihrer persönlichen Daten.
|
Privatsphäre sowie auf Schutz vor Missbrauch ihrer persönlichen Daten.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2">
|
<p className="mt-2">
|
||||||
Wir halten diese Bestimmungen ein. Persönliche Daten werden streng vertraulich behandelt und
|
Persönliche Daten werden vertraulich behandelt und nicht an Dritte verkauft. Für den technischen
|
||||||
weder an Dritte verkauft noch weitergegeben.
|
Betrieb können sorgfältig ausgewählte Hosting-, Karten-, E-Mail-, Zahlungs- und
|
||||||
|
Infrastruktur-Dienstleister eingesetzt werden. Einzelheiten enthält die{' '}
|
||||||
|
<a href="/datenschutz" className="text-red-600 hover:text-red-500">Datenschutzerklärung</a>.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2">
|
<p className="mt-2">
|
||||||
Die Nutzung der Applikation erfordert eine Registrierung. Dabei werden folgende Daten
|
Die Nutzung der Applikation erfordert eine Registrierung. Dabei werden insbesondere Name,
|
||||||
gespeichert: Name, E-Mail-Adresse, Organisationsname. Die Daten werden ausschliesslich für
|
E-Mail-Adresse und Organisationsname gespeichert. Passwörter werden mit einem geeigneten
|
||||||
den Betrieb der Applikation verwendet und auf Servern in der Schweiz gehostet.
|
Einwegverfahren (bcrypt) gehasht und nie im Klartext gespeichert.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2">
|
<p className="mt-2">
|
||||||
Einsatzdaten (Lagepläne, Journaleinträge, Zeichnungen) werden verschlüsselt gespeichert und
|
Einsatzdaten (Lagepläne, Journaleinträge, Zeichnungen) sind nach dem Rollen- und
|
||||||
sind nur für berechtigte Benutzer der jeweiligen Organisation zugänglich.
|
Mandantenkonzept nur für berechtigte Benutzer der jeweiligen Organisation zugänglich. Die
|
||||||
|
Übertragung erfolgt über HTTPS/TLS.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -139,7 +144,7 @@ export default function ImpressumPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 pt-8 border-t border-gray-100 text-center">
|
<div className="mt-12 pt-8 border-t border-gray-100 text-center">
|
||||||
<p className="text-sm text-gray-400">© {new Date().getFullYear()} Lageplan — Purepixel / Kingstickers Ziberi</p>
|
<p className="text-sm text-gray-400">© {new Date().getFullYear()} Lageplan — Perparim Ziberi</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
113
src/app/konto/datenschutz/page.tsx
Normal file
113
src/app/konto/datenschutz/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { ArrowLeft, Download, Trash2, Mail, ShieldCheck, Loader2 } from 'lucide-react'
|
||||||
|
import { useAuth } from '@/components/providers/auth-provider'
|
||||||
|
import { legal } from '@/config/legal'
|
||||||
|
|
||||||
|
interface Acceptance { documentType: string; documentVersion: string; context: string; acceptedAt: string; organizationId: string | null }
|
||||||
|
|
||||||
|
const DOC_URLS: Record<string, string> = {
|
||||||
|
TERMS: '/nutzungsbedingungen',
|
||||||
|
PRIVACY: '/datenschutz',
|
||||||
|
ORGANIZATION_DECLARATION: '/verantwortungsvolle-nutzung',
|
||||||
|
RESPONSIBLE_USE: '/verantwortungsvolle-nutzung',
|
||||||
|
DATA_PROCESSING_AGREEMENT: '/organisationen/datenschutzvereinbarung',
|
||||||
|
}
|
||||||
|
const DOC_LABELS: Record<string, string> = {
|
||||||
|
TERMS: 'Nutzungsbedingungen',
|
||||||
|
PRIVACY: 'Datenschutzerklärung',
|
||||||
|
ORGANIZATION_DECLARATION: 'Organisationsbestätigung',
|
||||||
|
RESPONSIBLE_USE: 'Verantwortungsvolle Nutzung',
|
||||||
|
DATA_PROCESSING_AGREEMENT: 'Datenschutzvereinbarung (Org.)',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KontoDatenschutzPage() {
|
||||||
|
const { user, loading } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
const [acceptances, setAcceptances] = useState<Acceptance[] | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return
|
||||||
|
if (!user) { router.replace('/login?redirect=/konto/datenschutz'); return }
|
||||||
|
fetch('/api/legal/my-acceptances').then(r => r.json()).then(d => setAcceptances(d.acceptances || [])).catch(() => setAcceptances([]))
|
||||||
|
}, [loading, user, router])
|
||||||
|
|
||||||
|
const exportSubject = encodeURIComponent('Datenexport-Anfrage')
|
||||||
|
const deleteSubject = encodeURIComponent('Kontolöschung-Anfrage')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background px-4 py-10">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<Link href="/app" className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-6">
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" /> Zur App
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||||
|
<ShieldCheck className="w-6 h-6 text-red-600" /> Konto & Datenschutz
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Zustimmungen */}
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="font-semibold text-foreground mb-2">Meine Zustimmungen</h2>
|
||||||
|
{acceptances === null ? (
|
||||||
|
<div className="py-6 flex justify-center"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>
|
||||||
|
) : acceptances.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Noch keine protokollierten Zustimmungen.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||||
|
{acceptances.map((a, i) => (
|
||||||
|
<li key={i} className="flex items-center justify-between gap-3 px-4 py-2.5 text-sm">
|
||||||
|
<div>
|
||||||
|
<Link href={DOC_URLS[a.documentType] || '#'} target="_blank" className="text-foreground font-medium hover:underline">
|
||||||
|
{DOC_LABELS[a.documentType] || a.documentType}
|
||||||
|
</Link>
|
||||||
|
<span className="text-muted-foreground"> · v{a.documentVersion}</span>
|
||||||
|
<div className="text-xs text-muted-foreground">{a.context}</div>
|
||||||
|
</div>
|
||||||
|
<time className="text-xs text-muted-foreground shrink-0">
|
||||||
|
{new Date(a.acceptedAt).toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}
|
||||||
|
</time>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Rechte ausüben */}
|
||||||
|
<section className="mt-8 space-y-3">
|
||||||
|
<h2 className="font-semibold text-foreground">Ihre Rechte ausüben</h2>
|
||||||
|
<a
|
||||||
|
href={`mailto:${legal.privacyContactEmail}?subject=${exportSubject}`}
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 hover:bg-accent text-sm"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span><strong className="text-foreground">Datenexport anfordern</strong><br /><span className="text-muted-foreground text-xs">Wir bestätigen den Eingang Ihrer Anfrage.</span></span>
|
||||||
|
</a>
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 hover:bg-accent text-sm"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span><strong className="text-foreground">Konto/Organisation löschen</strong><br /><span className="text-muted-foreground text-xs">In den Einstellungen; organisationsrelevante Daten werden nach Berechtigungsprüfung entfernt.</span></span>
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={`mailto:${legal.privacyContactEmail}?subject=${deleteSubject}`}
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 hover:bg-accent text-sm"
|
||||||
|
>
|
||||||
|
<Mail className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span><strong className="text-foreground">Datenschutzkontakt</strong><br /><span className="text-muted-foreground text-xs">{legal.privacyContactEmail}</span></span>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="mt-6 text-xs text-muted-foreground">
|
||||||
|
Hinweis: Produktivdaten werden nach Abschluss des Löschvorgangs entfernt. Daten in Sicherungskopien
|
||||||
|
können bis zum Ablauf des Backup-Zyklus fortbestehen. Details in der{' '}
|
||||||
|
<Link href="/datenschutz" className="underline">Datenschutzerklärung</Link>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Toaster } from '@/components/ui/toaster'
|
|||||||
import { AuthProvider } from '@/components/providers/auth-provider'
|
import { AuthProvider } from '@/components/providers/auth-provider'
|
||||||
import { ServiceWorkerRegister } from '@/components/providers/sw-register'
|
import { ServiceWorkerRegister } from '@/components/providers/sw-register'
|
||||||
import { CookieConsent } from '@/components/ui/cookie-consent'
|
import { CookieConsent } from '@/components/ui/cookie-consent'
|
||||||
|
import { LegalConsentGate } from '@/components/legal/legal-consent-gate'
|
||||||
|
|
||||||
const barlow = Barlow({
|
const barlow = Barlow({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
@@ -109,6 +110,7 @@ export default function RootLayout({
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<ServiceWorkerRegister />
|
<ServiceWorkerRegister />
|
||||||
{children}
|
{children}
|
||||||
|
<LegalConsentGate />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<CookieConsent />
|
<CookieConsent />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
207
src/app/nutzungsbedingungen/page.tsx
Normal file
207
src/app/nutzungsbedingungen/page.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { LegalPage } from '@/components/legal/legal-page'
|
||||||
|
import { legal } from '@/config/legal'
|
||||||
|
|
||||||
|
export default function NutzungsbedingungenPage() {
|
||||||
|
const d = legal.documents.terms
|
||||||
|
return (
|
||||||
|
<LegalPage
|
||||||
|
title="Nutzungsbedingungen"
|
||||||
|
version={d.version}
|
||||||
|
publishedAt={d.publishedAt}
|
||||||
|
intro={`Diese Nutzungsbedingungen regeln die Nutzung von ${legal.projectName} (Website, Anwendung, Benutzerkonten, Organisationen, Exporte und unterstützende Funktionen).`}
|
||||||
|
>
|
||||||
|
<section>
|
||||||
|
<h2>1. Geltungsbereich</h2>
|
||||||
|
<p>
|
||||||
|
Diese Bedingungen gelten für die Website, die Anwendung, Benutzerkonten, Organisationen, Exporte und
|
||||||
|
unterstützende Funktionen von {legal.projectName}. <strong>Betreiber</strong> ist der Anbieter der
|
||||||
|
Plattform; <strong>Benutzer</strong> ist jede natürliche Person mit Konto; <strong>Organisation</strong>
|
||||||
|
{' '}ist eine Feuerwehr oder Einheit mit eigenem Mandanten; <strong>Plattform</strong> bezeichnet die
|
||||||
|
Gesamtheit der bereitgestellten Dienste.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>2. Zweck von {legal.projectName}</h2>
|
||||||
|
<p>{legal.projectName} ist ein unterstützendes Werkzeug für:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Erstellung und Verwaltung von Lage- und Objektplänen</li>
|
||||||
|
<li>Dokumentation und Visualisierung</li>
|
||||||
|
<li>Zusammenarbeit</li>
|
||||||
|
<li>Export von Unterlagen</li>
|
||||||
|
<li>unterstützende Berechnungen</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>3. Kein Einsatzleitsystem</h2>
|
||||||
|
<ul>
|
||||||
|
<li>{legal.projectName} ist <strong>kein Alarmierungssystem</strong>.</li>
|
||||||
|
<li>{legal.projectName} ist <strong>kein Einsatzleitsystem</strong> und ersetzt keine Einsatzleitung.</li>
|
||||||
|
<li>{legal.projectName} ersetzt keine Leitstelle.</li>
|
||||||
|
<li>{legal.projectName} ersetzt keine gesetzlich oder organisatorisch vorgeschriebenen Unterlagen.</li>
|
||||||
|
<li>{legal.projectName} darf <strong>nicht als einzige Informationsquelle</strong> verwendet werden.</li>
|
||||||
|
<li>Die Organisation muss unabhängig aktuelle <strong>Offline- und Notfallunterlagen</strong> bereithalten.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>4. Fachliche Prüfung</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Pläne müssen von fachkundigen Personen geprüft und freigegeben werden.</li>
|
||||||
|
<li>Adressen, Distanzen, Symbole, Massstäbe, Hydranten, Zufahrten und Berechnungen können fehlerhaft oder veraltet sein.</li>
|
||||||
|
<li>Automatisch erzeugte oder berechnete Angaben sind Hilfsmittel.</li>
|
||||||
|
<li>Für operative Entscheidungen bleibt die zuständige Feuerwehr bzw. Organisation verantwortlich.</li>
|
||||||
|
<li>Änderungen an Gebäuden, Zufahrten, Wasserbezugsstellen oder Gefahren müssen durch die Organisation aktualisiert werden.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>5. Benutzerkonten und Organisationen</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Benutzer müssen richtige Angaben machen.</li>
|
||||||
|
<li>Zugangsdaten dürfen nicht weitergegeben werden.</li>
|
||||||
|
<li>Organisationen müssen Berechtigungen regelmässig kontrollieren.</li>
|
||||||
|
<li>Ausgetretene Benutzer müssen entfernt werden.</li>
|
||||||
|
<li>Organisationsadministratoren müssen zur Vertretung ihrer Organisation berechtigt sein.</li>
|
||||||
|
<li>Aktivitäten innerhalb eines Organisationskontos können protokolliert werden.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>6. Verbotene Inhalte</h2>
|
||||||
|
<p>Ohne separate schriftliche Vereinbarung ist insbesondere untersagt, folgende Inhalte zu speichern:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Patientennamen, Gesundheitsdaten, medizinische Befunde</li>
|
||||||
|
<li>Fotos identifizierbarer verletzter oder verstorbener Personen</li>
|
||||||
|
<li>polizeiliche Ermittlungsinformationen</li>
|
||||||
|
<li>Passwörter, Alarmcodes, Schlüsselcodes, Tresorcodes</li>
|
||||||
|
<li>private Telefonnummern ohne rechtliche Grundlage</li>
|
||||||
|
<li>Ausweiskopien und weitere besonders schützenswerte Personendaten</li>
|
||||||
|
<li>rechtswidrige oder urheberrechtlich unzulässige Inhalte</li>
|
||||||
|
<li>Informationen, welche die Sicherheit von Personen oder kritischer Infrastruktur unangemessen gefährden</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Auch reine Objektinformationen können vertraulich oder sicherheitsrelevant sein und dürfen nur
|
||||||
|
berechtigten Personen zugänglich gemacht werden.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>7. Verfügbarkeit</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Es besteht kein Anspruch auf ununterbrochene Verfügbarkeit.</li>
|
||||||
|
<li>Es besteht kein Service-Level-Agreement (SLA).</li>
|
||||||
|
<li>Wartungen und Störungen sind möglich.</li>
|
||||||
|
<li>Funktionen dürfen weiterentwickelt, eingeschränkt oder eingestellt werden.</li>
|
||||||
|
<li>Exporte und Offline-Kopien liegen in der Verantwortung der Organisation.</li>
|
||||||
|
<li>Es besteht keine Garantie auf dauerhafte Speicherung ohne eigene Sicherung.</li>
|
||||||
|
</ul>
|
||||||
|
<p>{legal.projectName} wird ohne garantierte Verfügbarkeit und ohne SLA bereitgestellt.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>8. Kostenloser Betrieb</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Die Plattform wird grundsätzlich kostenlos angeboten.</li>
|
||||||
|
<li>Es besteht kein Anspruch darauf, dass alle Funktionen dauerhaft kostenlos bleiben.</li>
|
||||||
|
<li>Wesentliche Änderungen werden angemessen kommuniziert.</li>
|
||||||
|
<li>Eine spätere kostenpflichtige Leistung entsteht nur nach ausdrücklicher separater Zustimmung.</li>
|
||||||
|
<li>Es gibt keine automatische kostenpflichtige Verlängerung.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>9. Unterstützungsbeiträge</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Beiträge sind freiwillig und erfolgen ohne Anspruch auf eine Gegenleistung.</li>
|
||||||
|
<li>Beiträge begründen keinen Anspruch auf Funktionen, Support, Verfügbarkeit oder bevorzugte Behandlung.</li>
|
||||||
|
<li>Zahlungsanbieter können Gebühren abziehen.</li>
|
||||||
|
<li>Es wird keine steuerlich abzugsfähige Bescheinigung versprochen.</li>
|
||||||
|
<li>Bei versehentlicher Zahlung besteht eine Kontaktmöglichkeit zur Prüfung einer Rückerstattung ({legal.supportEmail}).</li>
|
||||||
|
<li>Es wird keine Gemeinnützigkeit oder Steuerbefreiung behauptet, solange diese nicht schriftlich bestätigt ist.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>10. Geistiges Eigentum</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Die Rechte an der Software verbleiben beim Betreiber bzw. den jeweiligen Rechteinhabern.</li>
|
||||||
|
<li>Rechte an eigenen Inhalten verbleiben bei den Benutzern bzw. Organisationen.</li>
|
||||||
|
<li>Der Betreiber erhält die für den technischen Betrieb notwendige, einfache Nutzungslizenz an hochgeladenen Inhalten.</li>
|
||||||
|
<li>Benutzer sind für die hochgeladenen Inhalte und deren Rechtmässigkeit verantwortlich.</li>
|
||||||
|
<li>Karten-, Symbol-, Icon- und Drittanbieter-Lizenzen sind zu beachten (siehe <a href="/impressum">Impressum</a> und Lizenzdokumentation).</li>
|
||||||
|
<li>Eine Quellenangabe ersetzt nicht automatisch eine erforderliche Nutzungslizenz.</li>
|
||||||
|
</ul>
|
||||||
|
</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>
|
||||||
|
<h2>11. Haftung</h2>
|
||||||
|
<p>Im gesetzlich zulässigen Umfang gilt:</p>
|
||||||
|
<ul>
|
||||||
|
<li>keine Garantie für Vollständigkeit, Aktualität oder Fehlerfreiheit der Inhalte;</li>
|
||||||
|
<li>keine Garantie für Karten- oder Drittanbieterdaten;</li>
|
||||||
|
<li>keine Garantie für Berechnungsergebnisse;</li>
|
||||||
|
<li>die Verantwortung für fachliche Kontrolle und operative Entscheidungen liegt bei der Organisation;</li>
|
||||||
|
<li>indirekte Schäden, Datenverlust und Betriebsunterbruch werden nur im gesetzlich zulässigen Umfang begrenzt.</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
<strong>Zwingende gesetzliche Haftungsbestimmungen bleiben vorbehalten. Insbesondere wird die Haftung
|
||||||
|
für rechtswidrige Absicht oder grobe Fahrlässigkeit nicht im Voraus ausgeschlossen.</strong>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>12. Sperrung und Beendigung</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Sperrung bei Missbrauch oder Sicherheitsrisiken ist möglich.</li>
|
||||||
|
<li>Eine Exportmöglichkeit wird bereitgestellt, soweit technisch und rechtlich möglich.</li>
|
||||||
|
<li>Löschung erfolgt nach definierten Fristen; Daten in Backups können bis zum Ablauf des Backup-Zyklus fortbestehen.</li>
|
||||||
|
<li>Bei Einstellung des Projekts erfolgt eine angemessene Vorankündigung, soweit möglich.</li>
|
||||||
|
<li>Bei akuten Sicherheitsproblemen können sofortige Massnahmen ergriffen werden.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>13. Änderungen der Bedingungen</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Die Bedingungen werden versioniert.</li>
|
||||||
|
<li>Über wesentliche Änderungen wird informiert.</li>
|
||||||
|
<li>Bei wesentlichen Änderungen wird eine erneute ausdrückliche Zustimmung verlangt.</li>
|
||||||
|
<li>Frühere Versionen bleiben intern nachvollziehbar.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>14. Anwendbares Recht</h2>
|
||||||
|
<p>
|
||||||
|
Es gilt {legal.governingLaw}. Gerichtsstand, soweit zulässig und sinnvoll: {legal.jurisdiction}.
|
||||||
|
Zwingende Bestimmungen zum Schutz von Konsumentinnen und Konsumenten bleiben vorbehalten.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</LegalPage>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
src/app/organisationen/datenschutzvereinbarung/page.tsx
Normal file
142
src/app/organisationen/datenschutzvereinbarung/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { LegalPage } from '@/components/legal/legal-page'
|
||||||
|
import { legal, isPlaceholder } from '@/config/legal'
|
||||||
|
|
||||||
|
function Val({ value }: { value: string }) {
|
||||||
|
return isPlaceholder(value)
|
||||||
|
? <span className="text-amber-600 dark:text-amber-400 font-medium">{value}</span>
|
||||||
|
: <span className="text-foreground font-medium">{value}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DatenschutzvereinbarungPage() {
|
||||||
|
const d = legal.documents.dataProcessingAgreement
|
||||||
|
return (
|
||||||
|
<LegalPage
|
||||||
|
title="Datenschutzvereinbarung für Organisationen"
|
||||||
|
version={d.version}
|
||||||
|
publishedAt={d.publishedAt}
|
||||||
|
intro="Vereinbarung zur Auftragsbearbeitung zwischen dem Betreiber und der nutzenden Organisation. Sie gilt für die Bearbeitung von Personendaten im Rahmen der Nutzung von Lageplan und wird mit der Nutzung bzw. gesonderter Bestätigung wirksam."
|
||||||
|
>
|
||||||
|
<section>
|
||||||
|
<h2>1. Parteien</h2>
|
||||||
|
<p>
|
||||||
|
<strong>Auftragsbearbeiter (Betreiber):</strong> <Val value={legal.operatorName} /> (<Val value={legal.operatorLegalForm} />),{' '}
|
||||||
|
<Val value={legal.postalAddress} />, Kontakt <Val value={legal.privacyContactEmail} />.
|
||||||
|
</p>
|
||||||
|
<p><strong>Verantwortliche (Organisation):</strong> die jeweilige Feuerwehr/Organisation als Mandant.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>2. Gegenstand und Dauer</h2>
|
||||||
|
<p>
|
||||||
|
Gegenstand ist die Bearbeitung von Personendaten im Auftrag der Organisation im Rahmen der Nutzung von
|
||||||
|
{' '}{legal.projectName}. Die Vereinbarung gilt für die Dauer des Nutzungsverhältnisses.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>3. Zweck</h2>
|
||||||
|
<p>Bereitstellung, Betrieb und Support der Plattform gemäss Weisung der Organisation.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>4. Kategorien betroffener Personen</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Mitglieder und Funktionsträger der Organisation (Benutzer)</li>
|
||||||
|
<li>ggf. weitere Personen, soweit von der Organisation im Rahmen der Nutzung erfasst</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>5. Datenkategorien</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Konto-/Kontaktdaten (Name, E-Mail, Rolle)</li>
|
||||||
|
<li>Inhalts-/Einsatzdaten (Pläne, Journale, Zeichnungen, Uploads)</li>
|
||||||
|
<li>Protokoll-/Nutzungsdaten</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Besonders schützenswerte Personendaten dürfen ohne gesonderte schriftliche Vereinbarung nicht bearbeitet
|
||||||
|
werden (siehe <a href="/nutzungsbedingungen">Nutzungsbedingungen</a>).
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>6. Weisungen der Organisation</h2>
|
||||||
|
<p>Der Betreiber bearbeitet Personendaten nur gemäss den dokumentierten Weisungen der Organisation und dieser Vereinbarung.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>7. Vertraulichkeit</h2>
|
||||||
|
<p>Zum Zugriff berechtigte Personen sind zur Vertraulichkeit verpflichtet.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>8. Technische und organisatorische Massnahmen (TOM)</h2>
|
||||||
|
<p>
|
||||||
|
Es gelten die unter <a href="/sicherheit">Sicherheit</a> beschriebenen Massnahmen; der detaillierte
|
||||||
|
Stand ist in <code>docs/legal/technical-organizational-measures.md</code> dokumentiert (Anhang A).
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>9. Unterauftragnehmer</h2>
|
||||||
|
<p>
|
||||||
|
Der Betreiber setzt die in der <a href="/datenschutz#dienstleister">Dienstleisterliste</a> genannten
|
||||||
|
Unterauftragnehmer ein (Anhang B). Über Änderungen wird angemessen informiert.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>10. Datenstandorte</h2>
|
||||||
|
<p>Verarbeitungsregionen gemäss Dienstleisterliste; offene Punkte: <Val value={legal.serverLocation} />.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>11. Unterstützung bei Betroffenenrechten</h2>
|
||||||
|
<p>Der Betreiber unterstützt die Organisation angemessen bei der Erfüllung von Auskunfts-, Berichtigungs-, Lösch- und Exportbegehren.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>12. Sicherheitsvorfälle</h2>
|
||||||
|
<p>Der Betreiber informiert die Organisation ohne unangemessene Verzögerung über Sicherheitsvorfälle mit Risiko für betroffene Personen.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>13. Löschung und Rückgabe</h2>
|
||||||
|
<p>
|
||||||
|
Nach Vertragsende werden Personendaten gelöscht oder zurückgegeben, soweit keine gesetzliche
|
||||||
|
Aufbewahrungspflicht besteht. Daten in Backups können bis zum Ablauf des Backup-Zyklus fortbestehen
|
||||||
|
(<Val value={legal.backupRetention} />).
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>14. Datenexport</h2>
|
||||||
|
<p>Die Organisation kann ihre Daten in einem gängigen Format exportieren, soweit technisch möglich.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>15. Kontrolle und Nachweis</h2>
|
||||||
|
<p>Der Betreiber stellt der Organisation auf Anfrage angemessene Nachweise zur Einhaltung dieser Vereinbarung zur Verfügung.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>16. Kontaktstellen</h2>
|
||||||
|
<p>Betreiber: <Val value={legal.privacyContactEmail} />. Organisation: durch die Organisation zu benennen.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Anhänge</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Anhang A – TOM:</strong> <a href="/sicherheit">Sicherheit</a> / interne TOM-Dokumentation</li>
|
||||||
|
<li><strong>Anhang B – Unterauftragnehmer:</strong> <a href="/datenschutz#dienstleister">Dienstleisterliste</a></li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-xs">
|
||||||
|
Massgebend sind die konkreten Angaben in dieser Vereinbarung sowie die Anhänge A (TOM) und B
|
||||||
|
(Unterauftragnehmer). Individuelle Zusätze können auf Anfrage vereinbart werden.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</LegalPage>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ export default function LandingPage() {
|
|||||||
name: 'Was kostet Lageplan?',
|
name: 'Was kostet Lageplan?',
|
||||||
acceptedAnswer: {
|
acceptedAnswer: {
|
||||||
'@type': 'Answer',
|
'@type': 'Answer',
|
||||||
text: 'Nichts. Lageplan ist kostenlos für alle Feuerwehren in der Schweiz. Die Entwicklung wird durch freiwillige Spenden finanziert.',
|
text: 'Lageplan wird Feuerwehren grundsätzlich kostenlos zur Verfügung gestellt. Der Betrieb wird durch freiwillige Unterstützungsbeiträge mitfinanziert. Ein Anspruch darauf, dass alle Funktionen dauerhaft kostenlos bleiben, besteht nicht.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -84,7 +84,7 @@ export default function LandingPage() {
|
|||||||
name: 'Wo werden meine Daten gespeichert?',
|
name: 'Wo werden meine Daten gespeichert?',
|
||||||
acceptedAnswer: {
|
acceptedAnswer: {
|
||||||
'@type': 'Answer',
|
'@type': 'Answer',
|
||||||
text: 'Alle Daten werden auf Servern in der Schweiz gespeichert. Die Applikation ist DSG- und DSGVO-konform.',
|
text: 'Kontodaten werden in einer Datenbank gespeichert. Die eingesetzten Dienstleister und Speicherorte sind in der Datenschutzerklärung transparent aufgeführt. Lageplan orientiert sich am Schweizer Datenschutzgesetz (DSG).',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -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.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -237,10 +237,10 @@ export default function LandingPage() {
|
|||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 md:gap-8">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 md:gap-8">
|
||||||
{[
|
{[
|
||||||
{ value: '117', label: 'FKS-Signaturen', sub: 'Offizielle taktische Zeichen' },
|
{ value: '117', label: 'FKS-Signaturen', sub: 'Taktische Zeichen (Bildquelle FKS)' },
|
||||||
{ value: '100%', label: 'Swiss Hosted', sub: 'Server in der Schweiz' },
|
{ value: 'CH', label: 'Swiss Made', sub: 'In der Schweiz entwickelt' },
|
||||||
{ value: '0.—', label: 'Kostenlos', sub: 'Für alle Feuerwehren' },
|
{ value: '0.—', label: 'Kostenlos', sub: 'Grundnutzung für Feuerwehren' },
|
||||||
{ value: '24/7', label: 'Verfügbar', sub: 'Browser-basiert, jedes Gerät' },
|
{ value: 'Web', label: 'Überall nutzbar', sub: 'Browser, Tablet, Smartphone' },
|
||||||
].map((stat, i) => (
|
].map((stat, i) => (
|
||||||
<div key={i} className="text-center">
|
<div key={i} className="text-center">
|
||||||
<p className="text-3xl sm:text-4xl font-extrabold text-red-600">{stat.value}</p>
|
<p className="text-3xl sm:text-4xl font-extrabold text-red-600">{stat.value}</p>
|
||||||
@@ -333,8 +333,8 @@ export default function LandingPage() {
|
|||||||
Hinter jeder Funktion stecken Stunden an Entwicklung, Testing und Feinschliff.
|
Hinter jeder Funktion stecken Stunden an Entwicklung, Testing und Feinschliff.
|
||||||
Server-Hosting, Domain, SSL-Zertifikate — das alles kostet. Ich finanziere das aktuell
|
Server-Hosting, Domain, SSL-Zertifikate — das alles kostet. Ich finanziere das aktuell
|
||||||
komplett aus eigener Tasche, weil mir das Projekt am Herzen liegt.
|
komplett aus eigener Tasche, weil mir das Projekt am Herzen liegt.
|
||||||
Wenn du Lageplan nützlich findest, kannst du die Weiterentwicklung mit einer freiwilligen
|
Wenn du Lageplan nützlich findest, kannst du die Weiterentwicklung mit einem freiwilligen
|
||||||
Spende unterstützen. Jeder Beitrag hilft, neue Features zu bauen und die Server am Laufen zu halten.
|
Beitrag unterstützen. Jeder Beitrag hilft, neue Features zu bauen und die Server am Laufen zu halten.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -352,13 +352,13 @@ 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.' },
|
||||||
{ icon: Users, title: 'Echtzeit-Zusammenarbeit', desc: 'Mehrere Benutzer können gleichzeitig am selben Lageplan arbeiten.' },
|
{ icon: Users, title: 'Echtzeit-Zusammenarbeit', desc: 'Mehrere Benutzer können gleichzeitig am selben Lageplan arbeiten.' },
|
||||||
{ icon: Clock, title: 'Audit Trail', desc: 'Lückenlose Protokollierung aller Aktionen mit Zeitstempel für Nachvollziehbarkeit.' },
|
{ icon: Clock, title: 'Nachvollziehbarkeit', desc: 'Protokollierung von Aktionen mit Zeitstempel für bessere Nachvollziehbarkeit.' },
|
||||||
{ icon: Lock, title: 'Sicher & Schweiz', desc: 'Rollenbasierte Zugriffskontrolle. Gehostet in der Schweiz — DSG/DSGVO-konform.' },
|
{ icon: Lock, title: 'Sicherheit & Datenschutz', desc: 'Rollenbasierte Zugriffskontrolle und Mandantentrennung. Angemessene technische und organisatorische Schutzmassnahmen — Details in der Datenschutzerklärung.' },
|
||||||
{ icon: FileText, title: 'PDF-Export', desc: 'Lagepläne als PDF exportieren mit Metadaten, Datum und Einsatzinformationen.' },
|
{ icon: FileText, title: 'PDF-Export', desc: 'Lagepläne als PDF exportieren mit Metadaten, Datum und Einsatzinformationen.' },
|
||||||
].map((f, i) => (
|
].map((f, i) => (
|
||||||
<div key={i} className="bg-white rounded-xl p-6 border border-gray-100 hover:shadow-lg transition-shadow">
|
<div key={i} className="bg-white rounded-xl p-6 border border-gray-100 hover:shadow-lg transition-shadow">
|
||||||
@@ -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' },
|
||||||
@@ -439,7 +439,7 @@ export default function LandingPage() {
|
|||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
q: 'Was kostet Lageplan?',
|
q: 'Was kostet Lageplan?',
|
||||||
a: 'Nichts. Lageplan ist kostenlos für alle Feuerwehren in der Schweiz. Die Entwicklung wird durch freiwillige Spenden finanziert.',
|
a: 'Lageplan wird Feuerwehren grundsätzlich kostenlos zur Verfügung gestellt. Der Betrieb wird durch freiwillige Unterstützungsbeiträge mitfinanziert. Ein Anspruch darauf, dass alle Funktionen dauerhaft kostenlos bleiben, besteht nicht.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'Brauche ich eine Installation?',
|
q: 'Brauche ich eine Installation?',
|
||||||
@@ -455,11 +455,11 @@ export default function LandingPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: 'Wo werden meine Daten gespeichert?',
|
q: 'Wo werden meine Daten gespeichert?',
|
||||||
a: 'Alle Daten werden auf Servern in der Schweiz gespeichert. Die Applikation ist DSG- und DSGVO-konform.',
|
a: 'Kontodaten werden in einer Datenbank gespeichert. Die eingesetzten Dienstleister und Speicherorte sind in der Datenschutzerklärung transparent aufgeführt. Lageplan orientiert sich am Schweizer Datenschutzgesetz (DSG).',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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?',
|
||||||
@@ -508,7 +508,11 @@ export default function LandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500">Digitale Lagepläne für die Schweizer Feuerwehr</p>
|
<p className="text-sm text-gray-500">Digitale Lagepläne für die Schweizer Feuerwehr</p>
|
||||||
<p className="text-sm text-gray-500 mt-1">Ein Projekt von Pepe Ziberi</p>
|
<p className="text-sm text-gray-500 mt-1">Ein Projekt von Pepe Ziberi</p>
|
||||||
<p className="text-sm text-gray-500">Gehostet in der Schweiz — Swiss Made 🇨🇭</p>
|
<p className="text-sm text-gray-500">In der Schweiz entwickelt — Swiss Made 🇨🇭</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-2 max-w-xs">
|
||||||
|
Unterstützendes Werkzeug ohne garantierte Verfügbarkeit. Kein Einsatzleit- oder
|
||||||
|
Alarmierungssystem. Angaben vor Verwendung fachlich prüfen.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
<p className="font-medium text-gray-700 mb-2">Kontakt</p>
|
<p className="font-medium text-gray-700 mb-2">Kontakt</p>
|
||||||
@@ -526,11 +530,17 @@ export default function LandingPage() {
|
|||||||
<p className="font-medium text-gray-700 mb-2">Rechtliches</p>
|
<p className="font-medium text-gray-700 mb-2">Rechtliches</p>
|
||||||
<Link href="/impressum" className="block hover:text-gray-700">Impressum</Link>
|
<Link href="/impressum" className="block hover:text-gray-700">Impressum</Link>
|
||||||
<Link href="/datenschutz" className="block hover:text-gray-700 mt-1">Datenschutz</Link>
|
<Link href="/datenschutz" className="block hover:text-gray-700 mt-1">Datenschutz</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="/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 & Lizenzen</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>
|
||||||
</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">© {new Date().getFullYear()} Lageplan — Purepixel. Alle Rechte vorbehalten.</p>
|
<p className="text-sm text-gray-500">© {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 & 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>
|
||||||
@@ -548,8 +558,8 @@ function SupportSection() {
|
|||||||
<Coffee className="w-10 h-10 text-red-600 mx-auto mb-4" />
|
<Coffee className="w-10 h-10 text-red-600 mx-auto mb-4" />
|
||||||
<h2 className="text-3xl font-bold text-gray-900">Projekt unterstützen</h2>
|
<h2 className="text-3xl font-bold text-gray-900">Projekt unterstützen</h2>
|
||||||
<p className="mt-3 text-lg text-gray-500 max-w-xl mx-auto">
|
<p className="mt-3 text-lg text-gray-500 max-w-xl mx-auto">
|
||||||
Lageplan ist und bleibt kostenlos. Wenn du die Weiterentwicklung unterstützen möchtest,
|
Lageplan wird grundsätzlich kostenlos angeboten. Wenn du die Weiterentwicklung unterstützen
|
||||||
freue ich mich über einen freiwilligen Beitrag.
|
möchtest, freue ich mich über einen freiwilligen Beitrag — ohne Anspruch auf eine Gegenleistung.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -571,13 +581,13 @@ function SupportSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-500 mb-6">
|
<p className="text-gray-500 mb-6">
|
||||||
Wähle einen Betrag auf unserer Spendenseite — Zahlung sicher via Stripe (Kreditkarte, Twint, Apple Pay, Google Pay).
|
Wähle einen Betrag auf der Unterstützungsseite — Zahlung über Stripe (Kreditkarte, Twint, Apple Pay, Google Pay).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Link href="/spenden">
|
<Link href="/spenden">
|
||||||
<Button className="bg-red-600 hover:bg-red-700 h-12 text-base px-8">
|
<Button className="bg-red-600 hover:bg-red-700 h-12 text-base px-8">
|
||||||
<Heart className="w-4 h-4 mr-2" />
|
<Heart className="w-4 h-4 mr-2" />
|
||||||
Zur Spendenseite
|
Zur Unterstützungsseite
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -585,8 +595,10 @@ function SupportSection() {
|
|||||||
{/* Trust note */}
|
{/* Trust note */}
|
||||||
<div className="mt-8 text-center">
|
<div className="mt-8 text-center">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
100% deiner Spende fliesst direkt in Serverkosten und Weiterentwicklung.
|
Beiträge helfen, Hosting, Infrastruktur und Weiterentwicklung zu finanzieren.
|
||||||
<br />Kein Unternehmen, keine Investoren — nur ein Feuerwehrmann mit einer Idee.
|
Ein Beitrag ist freiwillig und erfolgt ohne Anspruch auf eine Gegenleistung; Gebühren des
|
||||||
|
Zahlungsanbieters können abgezogen werden.
|
||||||
|
<br />Ein Projekt aus der Feuerwehr-Praxis — entwickelt in der Freizeit.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
63
src/app/quellen-und-lizenzen/page.tsx
Normal file
63
src/app/quellen-und-lizenzen/page.tsx
Normal 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 & 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ export default function RegisterPage() {
|
|||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [confirmPassword, setConfirmPassword] = useState('')
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
const [privacyAccepted, setPrivacyAccepted] = useState(false)
|
const [acceptTerms, setAcceptTerms] = useState(false)
|
||||||
|
const [acceptPrivacy, setAcceptPrivacy] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [isSuccess, setIsSuccess] = useState(false)
|
const [isSuccess, setIsSuccess] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -40,7 +41,7 @@ export default function RegisterPage() {
|
|||||||
const res = await fetch('/api/auth/register', {
|
const res = await fetch('/api/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ organizationName, name, email, password, privacyAccepted }),
|
body: JSON.stringify({ organizationName, name, email, password, acceptTerms, acceptPrivacy }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -188,13 +189,28 @@ export default function RegisterPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Privacy Policy Checkbox */}
|
{/* Zustimmungen — je einzeln aktiv zu setzen, keine Vorauswahl */}
|
||||||
<div className="pt-2 border-t border-border">
|
<div className="pt-2 border-t border-border space-y-2.5">
|
||||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={privacyAccepted}
|
checked={acceptTerms}
|
||||||
onChange={(e) => setPrivacyAccepted(e.target.checked)}
|
onChange={(e) => setAcceptTerms(e.target.checked)}
|
||||||
|
className="mt-0.5 w-4 h-4 rounded border-gray-300 text-red-600 focus:ring-red-500 cursor-pointer"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
Ich akzeptiere die{' '}
|
||||||
|
<Link href="/nutzungsbedingungen" target="_blank" className="text-red-500 hover:text-red-400 underline font-medium">
|
||||||
|
Nutzungsbedingungen
|
||||||
|
</Link>.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={acceptPrivacy}
|
||||||
|
onChange={(e) => setAcceptPrivacy(e.target.checked)}
|
||||||
className="mt-0.5 w-4 h-4 rounded border-gray-300 text-red-600 focus:ring-red-500 cursor-pointer"
|
className="mt-0.5 w-4 h-4 rounded border-gray-300 text-red-600 focus:ring-red-500 cursor-pointer"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
@@ -203,7 +219,7 @@ export default function RegisterPage() {
|
|||||||
<Link href="/datenschutz" target="_blank" className="text-red-500 hover:text-red-400 underline font-medium">
|
<Link href="/datenschutz" target="_blank" className="text-red-500 hover:text-red-400 underline font-medium">
|
||||||
Datenschutzerklärung
|
Datenschutzerklärung
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
gelesen und akzeptiere die Nutzungsbedingungen.
|
zur Kenntnis genommen.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +227,7 @@ export default function RegisterPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-red-600 hover:bg-red-700"
|
className="w-full bg-red-600 hover:bg-red-700"
|
||||||
disabled={isLoading || !organizationName || !name || !email || !password || !confirmPassword || !privacyAccepted}
|
disabled={isLoading || !organizationName || !name || !email || !password || !confirmPassword || !acceptTerms || !acceptPrivacy}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Wird erstellt...</>
|
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Wird erstellt...</>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
60
src/app/sicherheit/page.tsx
Normal file
60
src/app/sicherheit/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { LegalPage } from '@/components/legal/legal-page'
|
||||||
|
import { legal } from '@/config/legal'
|
||||||
|
|
||||||
|
export default function SicherheitPage() {
|
||||||
|
return (
|
||||||
|
<LegalPage
|
||||||
|
title="Sicherheit"
|
||||||
|
version={legal.documents.privacy.version}
|
||||||
|
publishedAt={legal.documents.privacy.publishedAt}
|
||||||
|
intro={`Übersicht über die technischen und organisatorischen Sicherheitsmassnahmen von ${legal.projectName}. Diese Darstellung ist allgemein gehalten; ein vollständiger Schutz vor allen Risiken kann nicht garantiert werden.`}
|
||||||
|
>
|
||||||
|
<section>
|
||||||
|
<h2>Grundsatz</h2>
|
||||||
|
<p>
|
||||||
|
Wir setzen angemessene technische und organisatorische Sicherheitsmassnahmen ein. Ein vollständiger
|
||||||
|
Schutz vor allen Risiken kann jedoch nicht garantiert werden. Sicherheit ist ein fortlaufender Prozess.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Technische Massnahmen (Auszug)</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Zugriffstrennung:</strong> Mandantentrennung je Organisation sowie ein Rollen- und Rechtekonzept (Server-Admin, Organisations-Admin, Bearbeiter, Betrachter).</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>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>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>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Organisatorische Massnahmen</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Zugriff auf Systemdaten nur im betrieblich notwendigen Umfang.</li>
|
||||||
|
<li>Trennung von Entwicklungs-/Test- und Produktionsumgebung.</li>
|
||||||
|
<li>Regelmässige Aktualisierung von Abhängigkeiten.</li>
|
||||||
|
<li>Definierter Prozess für Sicherheitsvorfälle (siehe interne Dokumentation).</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-xs">
|
||||||
|
Der detaillierte, laufend gepflegte Ist-Zustand inkl. offener Punkte ist intern in{' '}
|
||||||
|
<code>docs/legal/technical-organizational-measures.md</code> dokumentiert.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Sicherheitslücke melden</h2>
|
||||||
|
<p>
|
||||||
|
Wenn Sie eine mögliche Sicherheitslücke entdecken, melden Sie diese bitte vertraulich an{' '}
|
||||||
|
<strong>{legal.supportEmail}</strong>. Bitte nutzen Sie gefundene Schwachstellen nicht aus und geben Sie
|
||||||
|
uns angemessene Zeit zur Behebung.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</LegalPage>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -46,5 +46,11 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||||||
changeFrequency: 'yearly',
|
changeFrequency: 'yearly',
|
||||||
priority: 0.3,
|
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}/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}/organisationen/datenschutzvereinbarung`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function DankePage() {
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-xl text-gray-500 mb-2">
|
<p className="text-xl text-gray-500 mb-2">
|
||||||
Deine Spende ist angekommen.
|
Dein Beitrag ist angekommen.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-400 mb-10 max-w-md mx-auto">
|
<p className="text-gray-400 mb-10 max-w-md mx-auto">
|
||||||
Dein Beitrag hilft direkt dabei, Lageplan weiterzuentwickeln und die Server am Laufen zu halten.
|
Dein Beitrag hilft direkt dabei, Lageplan weiterzuentwickeln und die Server am Laufen zu halten.
|
||||||
@@ -40,7 +40,7 @@ export default function DankePage() {
|
|||||||
<div className="bg-red-50 rounded-2xl p-8 border border-red-100 mb-10">
|
<div className="bg-red-50 rounded-2xl p-8 border border-red-100 mb-10">
|
||||||
<Heart className="w-8 h-8 text-red-500 mx-auto mb-3" />
|
<Heart className="w-8 h-8 text-red-500 mx-auto mb-3" />
|
||||||
<p className="text-gray-700 font-medium">
|
<p className="text-gray-700 font-medium">
|
||||||
«Jede Spende motiviert mich, weiterzumachen. Danke von Herzen!»
|
«Jeder Beitrag motiviert mich, weiterzumachen. Danke von Herzen!»
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 mt-2">— Pepe Ziberi, Entwickler von Lageplan</p>
|
<p className="text-sm text-gray-500 mt-2">— Pepe Ziberi, Entwickler von Lageplan</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Spenden',
|
title: 'Unterstützen',
|
||||||
description: 'Unterstützen Sie die Weiterentwicklung von Lageplan.ch – der kostenlosen Krokier-App für Schweizer Feuerwehren.',
|
description: 'Freiwilliger Unterstützungsbeitrag für die Weiterentwicklung von Lageplan.ch – der kostenlosen Krokier-App für Schweizer Feuerwehren.',
|
||||||
robots: { index: true, follow: true },
|
robots: { index: true, follow: true },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export default function SpendenPage() {
|
|||||||
<h1 className="text-4xl font-extrabold text-gray-900 mb-4">Lageplan unterstützen</h1>
|
<h1 className="text-4xl font-extrabold text-gray-900 mb-4">Lageplan unterstützen</h1>
|
||||||
<p className="text-lg text-gray-500 max-w-xl mx-auto">
|
<p className="text-lg text-gray-500 max-w-xl mx-auto">
|
||||||
Lageplan ist ein kostenloses Herzensprojekt von einem Feuerwehrmann für Feuerwehrleute.
|
Lageplan ist ein kostenloses Herzensprojekt von einem Feuerwehrmann für Feuerwehrleute.
|
||||||
Mit deiner Spende hilfst du, die Weiterentwicklung und den Betrieb zu finanzieren.
|
Mit deinem freiwilligen Beitrag hilfst du, die Weiterentwicklung und den Betrieb zu finanzieren.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -94,9 +94,9 @@ export default function SpendenPage() {
|
|||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<Lightbulb className="w-7 h-7 text-red-500 shrink-0 mt-0.5" />
|
<Lightbulb className="w-7 h-7 text-red-500 shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold text-gray-900 mb-2">Wohin fliesst deine Spende?</h3>
|
<h3 className="font-bold text-gray-900 mb-2">Wohin fliesst dein Beitrag?</h3>
|
||||||
<ul className="space-y-2 text-sm text-gray-600">
|
<ul className="space-y-2 text-sm text-gray-600">
|
||||||
<li className="flex items-center gap-2"><Shield className="w-4 h-4 text-red-500 shrink-0" /> Server-Hosting in der Schweiz (monatliche Kosten)</li>
|
<li className="flex items-center gap-2"><Shield className="w-4 h-4 text-red-500 shrink-0" /> Server-Hosting und Infrastruktur (monatliche Kosten)</li>
|
||||||
<li className="flex items-center gap-2"><Sparkles className="w-4 h-4 text-red-500 shrink-0" /> Entwicklung neuer Features (PWA, Vorlagen, Einsatzpläne)</li>
|
<li className="flex items-center gap-2"><Sparkles className="w-4 h-4 text-red-500 shrink-0" /> Entwicklung neuer Features (PWA, Vorlagen, Einsatzpläne)</li>
|
||||||
<li className="flex items-center gap-2"><Coffee className="w-4 h-4 text-red-500 shrink-0" /> Domain, SSL-Zertifikate und Infrastruktur</li>
|
<li className="flex items-center gap-2"><Coffee className="w-4 h-4 text-red-500 shrink-0" /> Domain, SSL-Zertifikate und Infrastruktur</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -206,7 +206,7 @@ export default function SpendenPage() {
|
|||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
{stripeConfigured === false ? (
|
{stripeConfigured === false ? (
|
||||||
<div className="bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-lg p-4 text-sm text-center">
|
<div className="bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-lg p-4 text-sm text-center">
|
||||||
Spenden sind aktuell nicht möglich. Die Zahlungsabwicklung wird gerade eingerichtet.
|
Beiträge sind aktuell nicht möglich. Die Zahlungsabwicklung wird gerade eingerichtet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@@ -217,18 +217,30 @@ export default function SpendenPage() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Weiterleitung zu Stripe...</>
|
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Weiterleitung zu Stripe...</>
|
||||||
) : (
|
) : (
|
||||||
<><Heart className="w-4 h-4 mr-2" /> CHF {amount} spenden</>
|
<><Heart className="w-4 h-4 mr-2" /> CHF {amount} beitragen</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-3 text-xs text-gray-400">
|
<div className="mt-4 flex flex-wrap items-center justify-center gap-3 text-xs text-gray-400">
|
||||||
<span className="flex items-center gap-1"><Shield className="w-3.5 h-3.5" /> Sichere Zahlung via Stripe</span>
|
<span className="flex items-center gap-1"><Shield className="w-3.5 h-3.5" /> Zahlung über Stripe</span>
|
||||||
<span className="flex items-center gap-1"><Check className="w-3.5 h-3.5" /> Kreditkarte</span>
|
<span className="flex items-center gap-1"><Check className="w-3.5 h-3.5" /> Kreditkarte</span>
|
||||||
<span className="flex items-center gap-1"><Check className="w-3.5 h-3.5" /> Twint</span>
|
<span className="flex items-center gap-1"><Check className="w-3.5 h-3.5" /> Twint</span>
|
||||||
<span className="flex items-center gap-1"><Check className="w-3.5 h-3.5" /> Apple Pay</span>
|
<span className="flex items-center gap-1"><Check className="w-3.5 h-3.5" /> Apple Pay</span>
|
||||||
<span className="flex items-center gap-1"><Check className="w-3.5 h-3.5" /> Google Pay</span>
|
<span className="flex items-center gap-1"><Check className="w-3.5 h-3.5" /> Google Pay</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Rechtlicher Hinweis zum freiwilligen Beitrag */}
|
||||||
|
<div className="mt-5 rounded-lg bg-gray-50 border border-gray-100 p-4 text-xs text-gray-500 leading-relaxed">
|
||||||
|
<strong className="text-gray-700">Freiwilliger Unterstützungsbeitrag.</strong> Ein Beitrag ist
|
||||||
|
freiwillig und erfolgt ohne Anspruch auf eine Gegenleistung. Daraus entsteht insbesondere kein
|
||||||
|
Anspruch auf Funktionen, Support, Verfügbarkeit, bevorzugte Behandlung oder eine bestimmte
|
||||||
|
Weiterentwicklung. Zahlungs- und Transaktionsgebühren des Zahlungsanbieters (Stripe) können vom
|
||||||
|
Beitrag abgezogen werden. Es wird keine steuerlich abzugsfähige Bescheinigung ausgestellt,
|
||||||
|
solange keine entsprechende behördliche Anerkennung besteht. Es handelt sich um eine einmalige
|
||||||
|
Zahlung; es wird keine wiederkehrende Zahlung eingerichtet. Details zur Zahlungsabwicklung siehe{' '}
|
||||||
|
<Link href="/datenschutz" className="text-red-600 underline">Datenschutzerklärung</Link>.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Story */}
|
{/* Story */}
|
||||||
@@ -236,21 +248,22 @@ export default function SpendenPage() {
|
|||||||
<h3 className="font-bold text-gray-900 mb-3">Die Geschichte hinter Lageplan</h3>
|
<h3 className="font-bold text-gray-900 mb-3">Die Geschichte hinter Lageplan</h3>
|
||||||
<p className="text-sm text-gray-600 leading-relaxed">
|
<p className="text-sm text-gray-600 leading-relaxed">
|
||||||
Lageplan wird seit über 2 Jahren von einem aktiven Feuerwehrmann in seiner Freizeit entwickelt.
|
Lageplan wird seit über 2 Jahren von einem aktiven Feuerwehrmann in seiner Freizeit entwickelt.
|
||||||
Die App ist und bleibt <strong>kostenlos</strong> — weil jede Feuerwehr Zugang zu guten Werkzeugen haben soll,
|
Die App wird <strong>grundsätzlich kostenlos</strong> angeboten — weil jede Feuerwehr Zugang zu guten
|
||||||
unabhängig vom Budget. Es gibt zwar kommerzielle Alternativen, aber die Idee war immer:
|
Werkzeugen haben soll, unabhängig vom Budget. Die Idee war immer:
|
||||||
Ein Werkzeug <em>von der Feuerwehr, für die Feuerwehr</em>.
|
Ein Werkzeug <em>von der Feuerwehr, für die Feuerwehr</em>.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-600 leading-relaxed mt-3">
|
<p className="text-sm text-gray-600 leading-relaxed mt-3">
|
||||||
Mit deiner Spende hilfst du, dass das so bleibt. Jeder Franken fliesst direkt in
|
Beiträge helfen, Hosting, Infrastruktur und Weiterentwicklung mitzufinanzieren. Danke für deine
|
||||||
Serverkosten und Weiterentwicklung. Danke für deine Unterstützung!
|
Unterstützung!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Trust */}
|
{/* Trust */}
|
||||||
<div className="mt-8 text-center">
|
<div className="mt-8 text-center">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
100% deiner Spende fliesst direkt in Serverkosten und Weiterentwicklung.
|
Beiträge werden nach Abzug allfälliger Zahlungsgebühren für Hosting, Infrastruktur und
|
||||||
<br />Kein Unternehmen, keine Investoren — nur ein Feuerwehrmann mit einer Idee.
|
Weiterentwicklung eingesetzt.
|
||||||
|
<br />Ein Projekt aus der Feuerwehr-Praxis — entwickelt in der Freizeit.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
|
|||||||
55
src/app/unterstuetzen/page.tsx
Normal file
55
src/app/unterstuetzen/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Heart } from 'lucide-react'
|
||||||
|
import { LegalPage } from '@/components/legal/legal-page'
|
||||||
|
import { legal } from '@/config/legal'
|
||||||
|
|
||||||
|
export default function UnterstuetzenPage() {
|
||||||
|
return (
|
||||||
|
<LegalPage
|
||||||
|
title="Freiwilliger Unterstützungsbeitrag"
|
||||||
|
version={legal.documents.terms.version}
|
||||||
|
publishedAt={legal.documents.terms.publishedAt}
|
||||||
|
draft={false}
|
||||||
|
intro={`${legal.projectName} wird grundsätzlich kostenlos angeboten. Freiwillige Beiträge helfen bei der Finanzierung von Hosting, Infrastruktur und Weiterentwicklung.`}
|
||||||
|
>
|
||||||
|
<section>
|
||||||
|
<p>
|
||||||
|
Ein Beitrag ist <strong>freiwillig</strong> und erfolgt ohne Anspruch auf eine bestimmte Gegenleistung.
|
||||||
|
Daraus entsteht insbesondere kein Anspruch auf Funktionen, Support, Verfügbarkeit, bevorzugte Behandlung
|
||||||
|
oder eine bestimmte Weiterentwicklung.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Zahlungs- und Transaktionsgebühren des eingesetzten Zahlungsanbieters (Stripe) können vom Beitrag
|
||||||
|
abgezogen werden. {legal.projectName} stellt keine steuerlich abzugsfähige Bescheinigung aus,
|
||||||
|
solange keine entsprechende behördliche Anerkennung besteht.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Es handelt sich um eine <strong>einmalige Zahlung</strong>; es wird keine automatisch wiederkehrende
|
||||||
|
Zahlung eingerichtet. Angaben zur Zahlungsabwicklung enthält die{' '}
|
||||||
|
<a href="/datenschutz">Datenschutzerklärung</a>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Beitrag leisten</h2>
|
||||||
|
<p>Auf der Zahlungsseite kannst du einen Betrag wählen. Die Zahlung erfolgt über Stripe.</p>
|
||||||
|
<Link
|
||||||
|
href="/spenden"
|
||||||
|
className="not-prose inline-flex items-center gap-2 rounded-lg bg-red-600 hover:bg-red-700 text-white px-5 py-2.5 font-semibold no-underline print:hidden"
|
||||||
|
>
|
||||||
|
<Heart className="w-4 h-4" /> Zur Zahlungsseite
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Versehentliche Zahlung</h2>
|
||||||
|
<p>
|
||||||
|
Falls eine Zahlung versehentlich erfolgt ist, melde dich bitte unter <strong>{legal.supportEmail}</strong>{' '}
|
||||||
|
zur Prüfung einer Rückerstattung.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</LegalPage>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
src/app/verantwortungsvolle-nutzung/page.tsx
Normal file
66
src/app/verantwortungsvolle-nutzung/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { LegalPage } from '@/components/legal/legal-page'
|
||||||
|
import { legal } from '@/config/legal'
|
||||||
|
|
||||||
|
export default function VerantwortungsvolleNutzungPage() {
|
||||||
|
const d = legal.documents.responsibleUse
|
||||||
|
return (
|
||||||
|
<LegalPage
|
||||||
|
title="Verantwortungsvolle Nutzung"
|
||||||
|
version={d.version}
|
||||||
|
publishedAt={d.publishedAt}
|
||||||
|
intro={`${legal.projectName} ist ein unterstützendes Werkzeug. Diese Seite erklärt, wie es sicher und verantwortungsvoll eingesetzt wird — damit es im Einsatz zu keinen Missverständnissen kommt.`}
|
||||||
|
>
|
||||||
|
<section>
|
||||||
|
<h2>Was {legal.projectName} ist – und was nicht</h2>
|
||||||
|
<p>
|
||||||
|
{legal.projectName} hilft beim Erstellen, Verwalten, Visualisieren und Exportieren von Lage- und
|
||||||
|
Objektplänen sowie bei unterstützenden Berechnungen. Es ist <strong>kein Alarmierungssystem, keine
|
||||||
|
Leitstelle und kein Einsatzleitsystem</strong> und ersetzt keine behördlich vorgeschriebenen Unterlagen.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Grundregeln für den Einsatz</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Haltet unabhängig von {legal.projectName} <strong>aktuelle Offline- und Notfallunterlagen</strong> bereit.</li>
|
||||||
|
<li>Verwendet {legal.projectName} <strong>nicht als einzige Informationsquelle</strong> für operative Entscheidungen.</li>
|
||||||
|
<li>Lasst Pläne, Adressen, Symbole, Distanzen und Berechnungen vor der operativen Verwendung durch <strong>fachkundige Personen prüfen</strong>.</li>
|
||||||
|
<li>Rechnet mit möglichen Störungen oder Nichtverfügbarkeit — es besteht keine garantierte Verfügbarkeit und kein SLA.</li>
|
||||||
|
<li>Erstellt regelmässig <strong>Exporte</strong> wichtiger Pläne als Rückfallebene.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Umgang mit sensiblen Daten</h2>
|
||||||
|
<p>
|
||||||
|
Speichert ohne separate schriftliche Vereinbarung <strong>keine besonders schützenswerten Daten</strong>
|
||||||
|
{' '}(z.B. Patienten-, Gesundheits-, Polizei-, Passwort-, Zugangs- oder Schlüsselcode-Daten). Auch reine
|
||||||
|
Objektinformationen können sicherheitsrelevant sein und dürfen nur berechtigten Personen zugänglich
|
||||||
|
gemacht werden. Details siehe <a href="/nutzungsbedingungen">Nutzungsbedingungen</a> und{' '}
|
||||||
|
<a href="/datenschutz">Datenschutzerklärung</a>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Aktualität von Karten und Berechnungen</h2>
|
||||||
|
<p>
|
||||||
|
Karten-, Adress- und Geodaten können unvollständig, ungenau oder veraltet sein. Berechnungen (z.B.
|
||||||
|
Distanzen, Druckverlust, Höhen) sind <strong>Richtwerte</strong> und ersetzen keine Lagebeurteilung.
|
||||||
|
Änderungen an Gebäuden, Zufahrten, Wasserbezugsstellen oder Gefahren müssen durch die Organisation
|
||||||
|
gepflegt werden.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Warum das wichtig ist</h2>
|
||||||
|
<p>
|
||||||
|
{legal.projectName} wird mit viel Eigenleistung entwickelt und Feuerwehren kostenlos zur Verfügung
|
||||||
|
gestellt. Eine verantwortungsvolle Nutzung sorgt dafür, dass das Projekt langfristig sicher und
|
||||||
|
kostenlos angeboten werden kann — und dass im Ernstfall keine falschen Erwartungen entstehen.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</LegalPage>
|
||||||
|
)
|
||||||
|
}
|
||||||
160
src/components/admin/backup-tab.tsx
Normal file
160
src/components/admin/backup-tab.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -239,7 +239,7 @@ export function SettingsTab({ onNavigateTab }: SettingsTabProps) {
|
|||||||
<Mail className="w-5 h-5 text-primary" />
|
<Mail className="w-5 h-5 text-primary" />
|
||||||
E-Mail / SMTP Konfiguration
|
E-Mail / SMTP Konfiguration
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground mb-4">SMTP-Server für den E-Mail-Versand konfigurieren. Empfohlen: TLS auf Port 587. Passwörter werden verschlüsselt in der Datenbank gespeichert.</p>
|
<p className="text-sm text-muted-foreground mb-4">SMTP-Server für den E-Mail-Versand konfigurieren. Empfohlen: TLS auf Port 587. Zugangsdaten werden serverseitig gespeichert und im Interface maskiert.</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div><Label>SMTP Host</Label><Input value={smtpHost} onChange={e => setSmtpHost(e.target.value)} placeholder="smtp.gmail.com" /></div>
|
<div><Label>SMTP Host</Label><Input value={smtpHost} onChange={e => setSmtpHost(e.target.value)} placeholder="smtp.gmail.com" /></div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -284,10 +284,10 @@ export function SettingsTab({ onNavigateTab }: SettingsTabProps) {
|
|||||||
<div className="border rounded-lg p-6 md:col-span-2">
|
<div className="border rounded-lg p-6 md:col-span-2">
|
||||||
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
|
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
|
||||||
<CreditCard className="w-5 h-5 text-primary" />
|
<CreditCard className="w-5 h-5 text-primary" />
|
||||||
Stripe / Spenden-Konfiguration
|
Stripe / Unterstützungs-Konfiguration
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
Stripe API-Keys für die Spendenseite konfigurieren. Unterstützt Kreditkarte, Twint und weitere Zahlungsmethoden.
|
Stripe API-Keys für die Unterstützungsseite konfigurieren. Unterstützt Kreditkarte, Twint und weitere Zahlungsmethoden.
|
||||||
Keys findest du im <a href="https://dashboard.stripe.com/apikeys" target="_blank" rel="noopener noreferrer" className="text-primary underline">Stripe Dashboard</a>.
|
Keys findest du im <a href="https://dashboard.stripe.com/apikeys" target="_blank" rel="noopener noreferrer" className="text-primary underline">Stripe Dashboard</a>.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 & Lizenzen</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
|
|||||||
@@ -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 & 2FA
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => window.location.href = '/konto/datenschutz'}>
|
||||||
|
<Shield className="w-4 h-4 mr-2" />
|
||||||
|
Konto & 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 & 2FA
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => window.location.href = '/konto/datenschutz'}>
|
||||||
|
<Shield className="w-4 h-4 mr-2" />
|
||||||
|
Konto & 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" />
|
||||||
|
|||||||
187
src/components/legal/legal-consent-gate.tsx
Normal file
187
src/components/legal/legal-consent-gate.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import { useAuth } from '@/components/providers/auth-provider'
|
||||||
|
import { Loader2, ExternalLink } from 'lucide-react'
|
||||||
|
import { ORG_CONFIRMATIONS } from '@/lib/legal-content'
|
||||||
|
|
||||||
|
interface PendingDoc { type: string; version: string; title: string; url: string | null }
|
||||||
|
|
||||||
|
// Pfade, auf denen der blockierende Dialog NICHT erscheint, damit Dokumente gelesen,
|
||||||
|
// das Konto verwaltet und abgemeldet werden kann (keine erzwungene Zustimmung durch Weiternutzung).
|
||||||
|
const ALLOWLIST = [
|
||||||
|
'/nutzungsbedingungen', '/datenschutz', '/impressum', '/verantwortungsvolle-nutzung',
|
||||||
|
'/sicherheit', '/unterstuetzen', '/spenden', '/organisationen', '/login', '/register',
|
||||||
|
'/konto', '/forgot-password', '/reset-password',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function LegalConsentGate() {
|
||||||
|
const { user, loading, logout } = useAuth()
|
||||||
|
const pathname = usePathname()
|
||||||
|
const [pending, setPending] = useState<PendingDoc[] | null>(null)
|
||||||
|
const [checks, setChecks] = useState<Record<string, boolean>>({})
|
||||||
|
const [orgChecks, setOrgChecks] = useState<boolean[]>(Array(ORG_CONFIRMATIONS.length).fill(false))
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const onAllowlist = ALLOWLIST.some(p => pathname?.startsWith(p)) || pathname === '/'
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/legal/status')
|
||||||
|
if (!res.ok) { setPending([]); return }
|
||||||
|
const data = await res.json()
|
||||||
|
setPending(data.needsAcceptance ? data.pending : [])
|
||||||
|
} catch {
|
||||||
|
setPending([])
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || !user || onAllowlist) { return }
|
||||||
|
refresh()
|
||||||
|
}, [loading, user, onAllowlist, refresh])
|
||||||
|
|
||||||
|
if (loading || !user || onAllowlist || !pending || pending.length === 0) return null
|
||||||
|
|
||||||
|
const hasOrgDeclaration = pending.some(d => d.type === 'ORGANIZATION_DECLARATION')
|
||||||
|
const simpleDocs = pending.filter(d => d.type !== 'ORGANIZATION_DECLARATION')
|
||||||
|
|
||||||
|
const allSimpleChecked = simpleDocs.every(d => checks[d.type])
|
||||||
|
const allOrgChecked = !hasOrgDeclaration || orgChecks.every(Boolean)
|
||||||
|
const canSubmit = allSimpleChecked && allOrgChecked && !submitting
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setSubmitting(true); setError('')
|
||||||
|
try {
|
||||||
|
const types = pending.map(d => d.type)
|
||||||
|
const context = hasOrgDeclaration ? 'ADMIN_ROLE_ACCEPTANCE' : 'LOGIN_RECONSENT'
|
||||||
|
const res = await fetch('/api/legal/accept', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ types, context }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { setError(data.error || 'Speichern fehlgeschlagen.'); return }
|
||||||
|
if (data.needsAcceptance) { setPending(data.pending); return }
|
||||||
|
setPending([])
|
||||||
|
} catch {
|
||||||
|
setError('Verbindungsfehler.')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="legal-gate-title"
|
||||||
|
className="fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-2xl bg-card rounded-xl border border-border shadow-2xl my-8 max-h-[92vh] flex flex-col">
|
||||||
|
<div className="p-6 border-b border-border">
|
||||||
|
<h2 id="legal-gate-title" className="text-xl font-bold text-foreground">
|
||||||
|
{hasOrgDeclaration ? 'Kurz bestätigen — dann geht’s weiter' : 'Aktualisierte Bestimmungen'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4 overflow-y-auto text-sm text-muted-foreground">
|
||||||
|
{hasOrgDeclaration && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="font-semibold text-foreground">Warum diese Bestätigung notwendig ist</h3>
|
||||||
|
<p>
|
||||||
|
Lageplan wird mit viel Eigenleistung entwickelt und Feuerwehren kostenlos zur Verfügung gestellt.
|
||||||
|
Damit das Projekt weiterhin verantwortungsvoll angeboten werden kann und es im Einsatz zu keinen
|
||||||
|
Missverständnissen kommt, müssen Organisationen einige wichtige Grundlagen bestätigen.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Lageplan ist ein unterstützendes Werkzeug. Es ersetzt keine Einsatzleitung, keine Leitstelle,
|
||||||
|
keine behördlich vorgeschriebenen Unterlagen und keine lokalen Offline- oder Notfallpläne. Pläne,
|
||||||
|
Adressen, Symbole, Distanzen und Berechnungen müssen vor der operativen Verwendung durch
|
||||||
|
fachkundige Personen geprüft werden.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Diese Bestätigung soll nicht die Verantwortung auf einzelne Benutzer abschieben. Sie stellt sicher,
|
||||||
|
dass Lageplan innerhalb der Organisation korrekt eingeordnet und sicher verwendet wird. Vielen Dank,
|
||||||
|
dass ihr mithelft, Lageplan langfristig kostenlos und verantwortungsvoll zu betreiben.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasOrgDeclaration && (
|
||||||
|
<p>
|
||||||
|
Die folgenden Bestimmungen wurden aktualisiert. Bitte bestätige die aktuelle Fassung, um Lageplan
|
||||||
|
weiter zu nutzen. Du kannst dich jederzeit abmelden.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dokument-Links */}
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{pending.map(d => (
|
||||||
|
<a key={d.type} href={d.url || '#'} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-red-600 underline">
|
||||||
|
{d.title} <ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Einfache Zustimmungen (Terms/Privacy/Responsible-Use) */}
|
||||||
|
{simpleDocs.length > 0 && (
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
{simpleDocs.map(d => (
|
||||||
|
<label key={d.type} className="flex items-start gap-2.5 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!checks[d.type]}
|
||||||
|
onChange={e => setChecks(c => ({ ...c, [d.type]: e.target.checked }))}
|
||||||
|
className="mt-0.5 w-4 h-4 rounded"
|
||||||
|
/>
|
||||||
|
<span>Ich habe die <strong className="text-foreground">{d.title}</strong> (Version {d.version}) gelesen und akzeptiere die geltenden Bestimmungen.</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Organisationsbestätigung — 7 einzelne Pflichtbestätigungen */}
|
||||||
|
{hasOrgDeclaration && (
|
||||||
|
<div className="space-y-2 pt-2 border-t border-border">
|
||||||
|
<h3 className="font-semibold text-foreground pt-2">Pflichtbestätigungen</h3>
|
||||||
|
{ORG_CONFIRMATIONS.map((text, i) => (
|
||||||
|
<label key={i} className="flex items-start gap-2.5 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={orgChecks[i]}
|
||||||
|
onChange={e => setOrgChecks(prev => prev.map((v, j) => j === i ? e.target.checked : v))}
|
||||||
|
className="mt-0.5 w-4 h-4 rounded shrink-0"
|
||||||
|
/>
|
||||||
|
<span>{text}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="text-red-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 border-t border-border flex flex-col sm:flex-row gap-2 sm:justify-between items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => logout()}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground underline order-2 sm:order-1"
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className="order-1 sm:order-2 w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg bg-red-600 hover:bg-red-700 text-white px-6 py-2.5 font-semibold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
{hasOrgDeclaration ? 'Bestätigen und fortfahren' : 'Akzeptieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
93
src/components/legal/legal-page.tsx
Normal file
93
src/components/legal/legal-page.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { ArrowLeft, Printer } from 'lucide-react'
|
||||||
|
import { legal } from '@/config/legal'
|
||||||
|
|
||||||
|
/** Pflicht-Links, die im Footer jeder öffentlichen (Rechts-)Seite erscheinen. */
|
||||||
|
export const LEGAL_LINKS: { href: string; label: string }[] = [
|
||||||
|
{ href: '/impressum', label: 'Impressum' },
|
||||||
|
{ href: '/datenschutz', label: 'Datenschutz' },
|
||||||
|
{ href: '/nutzungsbedingungen', label: 'Nutzungsbedingungen' },
|
||||||
|
{ href: '/verantwortungsvolle-nutzung', label: 'Verantwortungsvolle Nutzung' },
|
||||||
|
{ href: '/sicherheit', label: 'Sicherheit' },
|
||||||
|
{ href: '/quellen-und-lizenzen', label: 'Quellen & Lizenzen' },
|
||||||
|
{ href: '/organisationen/datenschutzvereinbarung', label: 'Datenschutzvereinbarung (Org.)' },
|
||||||
|
{ href: '/unterstuetzen', label: 'Unterstützen' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function LegalFooterLinks() {
|
||||||
|
return (
|
||||||
|
<nav aria-label="Rechtliche Seiten" className="mt-10 border-t border-border pt-6 print:hidden">
|
||||||
|
<ul className="flex flex-wrap gap-x-4 gap-y-2 text-sm text-muted-foreground">
|
||||||
|
{LEGAL_LINKS.map(l => (
|
||||||
|
<li key={l.href}>
|
||||||
|
<Link href={l.href} className="hover:text-foreground underline underline-offset-2">{l.label}</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LegalPageProps {
|
||||||
|
title: string
|
||||||
|
/** Versionsangabe, z.B. legal.documents.terms.version */
|
||||||
|
version?: string
|
||||||
|
/** Veröffentlichungsdatum ISO */
|
||||||
|
publishedAt?: string
|
||||||
|
/** Optionaler Warnhinweis (Standard: aus — Texte sind final). */
|
||||||
|
draft?: boolean
|
||||||
|
/** Kurzer Einleitungssatz unter dem Titel */
|
||||||
|
intro?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einheitliche, responsive, druckbare Hülle für alle öffentlichen Rechtsseiten.
|
||||||
|
* Enthält Zurück-Link, Drucken-Button, Version/Datum, Entwurfshinweis und die Pflicht-Footer-Links.
|
||||||
|
*/
|
||||||
|
export function LegalPage({ title, version, publishedAt, draft = false, intro, children }: LegalPageProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background px-4 py-10 print:py-0 print:bg-white">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<div className="flex items-center justify-between mb-6 print:hidden">
|
||||||
|
<Link href="/" className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" /> Zur Startseite
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => window.print()}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Printer className="w-3.5 h-3.5" /> Drucken
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article className="bg-card rounded-xl border border-border shadow-sm p-6 md:p-10 print:border-0 print:shadow-none print:p-0">
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">{title}</h1>
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
{version && <span>Version {version}</span>}
|
||||||
|
{publishedAt && <span>· Stand: {publishedAt}</span>}
|
||||||
|
<span>· {legal.projectName} ({legal.siteDomain})</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{intro && <p className="mt-4 text-sm text-muted-foreground leading-relaxed">{intro}</p>}
|
||||||
|
|
||||||
|
{draft && (
|
||||||
|
<div className="mt-4 rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-800 px-4 py-3 text-sm text-amber-800 dark:text-amber-300">
|
||||||
|
{legal.draftNotice}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="legal-prose mt-6 space-y-6 text-sm leading-relaxed text-muted-foreground [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-foreground [&_h2]:mb-2 [&_h2]:mt-2 [&_h3]:font-semibold [&_h3]:text-foreground [&_strong]:text-foreground [&_a]:text-red-600 [&_a]:underline [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:space-y-1 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:space-y-1">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LegalFooterLinks />
|
||||||
|
<p className="mt-4 text-xs text-muted-foreground/70">{legal.draftNotice}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
src/components/legal/usage-notice-banner.tsx
Normal file
40
src/components/legal/usage-notice-banner.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Info, X } from 'lucide-react'
|
||||||
|
|
||||||
|
const KEY = 'lageplan-usage-notice-dismissed'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dezenter, minimierbarer Hinweis im Dashboard/App-Bereich.
|
||||||
|
* Nach Kenntnisnahme minimiert (localStorage) — verschwindet aber nicht aus den Rechtsseiten
|
||||||
|
* (/verantwortungsvolle-nutzung, Footer).
|
||||||
|
*/
|
||||||
|
export function UsageNoticeBanner() {
|
||||||
|
const [dismissed, setDismissed] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try { setDismissed(localStorage.getItem(KEY) === '1') } catch { setDismissed(false) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (dismissed) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 px-3 py-2 bg-amber-50 dark:bg-amber-950/30 border-b border-amber-200 dark:border-amber-900 text-xs text-amber-800 dark:text-amber-300 print:hidden">
|
||||||
|
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||||
|
<p className="flex-1 leading-relaxed">
|
||||||
|
Lageplan ist ein unterstützendes Werkzeug ohne garantierte Verfügbarkeit. Bitte haltet aktuelle
|
||||||
|
Offline- und Notfallunterlagen bereit.{' '}
|
||||||
|
<Link href="/verantwortungsvolle-nutzung" className="underline font-medium">Mehr erfahren</Link>.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => { try { localStorage.setItem(KEY, '1') } catch {}; setDismissed(true) }}
|
||||||
|
aria-label="Hinweis minimieren"
|
||||||
|
className="shrink-0 rounded p-0.5 hover:bg-amber-100 dark:hover:bg-amber-900/50"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -157,7 +157,10 @@ export function MeasurePanel({ measurement, hoseTypes, onClose }: MeasurePanelPr
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-[10px] text-muted-foreground leading-snug">
|
<p className="text-[10px] text-muted-foreground leading-snug">
|
||||||
Länge inkl. 10% Verlegereserve · Höhendaten: {measurement.elevationSource === 'open-meteo' ? 'Open-Meteo DEM (~90 m Raster)' : measurement.elevationSource === 'open-elevation' ? 'Open-Elevation' : 'keine'} · Richtwerte, ersetzt keine Lagebeurteilung.
|
Länge inkl. 10% Verlegereserve · Höhendaten: {measurement.elevationSource === 'open-meteo' ? 'Open-Meteo DEM (~90 m Raster)' : measurement.elevationSource === 'open-elevation' ? 'Open-Elevation' : 'keine'}.
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-amber-600 dark:text-amber-400 leading-snug font-medium">
|
||||||
|
Berechnungshilfe: Ergebnis vor der operativen Verwendung fachlich prüfen. Karten-/Geodaten können ungenau sein.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
186
src/components/settings/mfa-settings.tsx
Normal file
186
src/components/settings/mfa-settings.tsx
Normal 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 & 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
|
||||||
|
}
|
||||||
32
src/config/__tests__/legal-config.test.ts
Normal file
32
src/config/__tests__/legal-config.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { isPlaceholder, legal } from '@/config/legal'
|
||||||
|
|
||||||
|
describe('isPlaceholder', () => {
|
||||||
|
it('erkennt Platzhalter im Format [NAME]', () => {
|
||||||
|
expect(isPlaceholder('[POSTADRESSE]')).toBe(true)
|
||||||
|
expect(isPlaceholder('[RECHTLICHER NAME DES BETREIBERS]')).toBe(true)
|
||||||
|
})
|
||||||
|
it('erkennt echte Werte als Nicht-Platzhalter', () => {
|
||||||
|
expect(isPlaceholder('Musterstrasse 1, 8000 Zürich')).toBe(false)
|
||||||
|
expect(isPlaceholder('app@lageplan.ch')).toBe(false)
|
||||||
|
})
|
||||||
|
it('leere Werte gelten als Platzhalter (fehlend)', () => {
|
||||||
|
expect(isPlaceholder('')).toBe(true)
|
||||||
|
expect(isPlaceholder(null)).toBe(true)
|
||||||
|
expect(isPlaceholder(undefined)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('legal config', () => {
|
||||||
|
it('führt die erwarteten Rechtsdokumenttypen (inkl. FKS-Quellenangabe)', () => {
|
||||||
|
expect(Object.keys(legal.documents).sort()).toEqual(
|
||||||
|
['dataProcessingAgreement', 'fksAttribution', 'organizationDeclaration', 'privacy', 'responsibleUse', 'terms']
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it('listet nur real genutzte Subprozessoren (keine erfundenen wie Google Analytics)', () => {
|
||||||
|
const names = legal.processors.map(p => p.name.toLowerCase())
|
||||||
|
expect(names.some(n => n.includes('stripe'))).toBe(true)
|
||||||
|
expect(names.some(n => n.includes('analytics'))).toBe(false)
|
||||||
|
expect(names.some(n => n.includes('mapbox'))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
176
src/config/legal.ts
Normal file
176
src/config/legal.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Zentrale, typisierte Betreiber- und Rechtskonfiguration für Lageplan.
|
||||||
|
*
|
||||||
|
* WARUM: Rechtliche Angaben (Betreiber, Kontakt, Serverstandort, Dokumentversionen)
|
||||||
|
* dürfen NICHT an mehreren Stellen widersprüchlich hartcodiert werden. Alle Legal-Seiten,
|
||||||
|
* das Impressum, die Datenschutzerklärung, der Consent-Flow und die Export-Footer lesen
|
||||||
|
* ihre Angaben aus dieser einen Quelle.
|
||||||
|
*
|
||||||
|
* PLATZHALTER: Felder, die der Betreiber rechtlich verbindlich festlegen muss, tragen als
|
||||||
|
* Default einen deutlich sichtbaren Platzhalter im Format `[NAME]`. Diese Platzhalter dürfen
|
||||||
|
* NICHT stillschweigend durch erfundene Werte ersetzt werden. Setze die echten Werte über
|
||||||
|
* Umgebungsvariablen (siehe unten) oder trage sie hier direkt ein, sobald sie feststehen.
|
||||||
|
*
|
||||||
|
* VALIDIERUNG: `assertLegalConfig()` warnt in Development und beim Build, wenn Pflichtangaben
|
||||||
|
* noch Platzhalter sind. In Produktion werden Platzhalter in der UI als solche kenntlich
|
||||||
|
* gemacht (siehe `isPlaceholder()`), damit nie ein erfundener Wert als echt erscheint.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Präfix, an dem ein noch nicht ausgefüllter Pflichtwert erkannt wird. */
|
||||||
|
const PLACEHOLDER_PREFIX = '['
|
||||||
|
|
||||||
|
/** True, wenn der Wert noch ein unausgefüllter Platzhalter ist. */
|
||||||
|
export function isPlaceholder(value: string | null | undefined): boolean {
|
||||||
|
return !value || (value.trim().startsWith(PLACEHOLDER_PREFIX) && value.trim().endsWith(']'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest eine Umgebungsvariable, fällt sonst auf den Platzhalter/Default zurück. */
|
||||||
|
function env(name: string, fallback: string): string {
|
||||||
|
const v = process.env[name]
|
||||||
|
return v && v.trim().length > 0 ? v.trim() : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalProcessor {
|
||||||
|
/** Anzeigename des Dienstleisters */
|
||||||
|
name: string
|
||||||
|
/** Zweck der Bearbeitung */
|
||||||
|
purpose: string
|
||||||
|
/** Verarbeitete Datenarten (grob) */
|
||||||
|
data: string
|
||||||
|
/** Sitz / relevante Verarbeitungsregion, soweit bekannt; sonst Platzhalter */
|
||||||
|
region: string
|
||||||
|
/** true, wenn der Dienst nur bei Bedarf/optionaler Konfiguration aktiv ist */
|
||||||
|
optional?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegalDocumentMeta {
|
||||||
|
/** Interne, bewusst gesetzte Versionsangabe (nicht automatisch aus Dateiänderung ableiten!) */
|
||||||
|
version: string
|
||||||
|
/** Veröffentlichungsdatum ISO (YYYY-MM-DD) */
|
||||||
|
publishedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const legal = {
|
||||||
|
// ─── Betreiber ─────────────────────────────────────────────
|
||||||
|
/** Anzeigename des Projekts */
|
||||||
|
projectName: 'Lageplan',
|
||||||
|
/**
|
||||||
|
* Rechtlich verbindlicher Name des Betreibers. Im Repository wird das Projekt öffentlich
|
||||||
|
* "Pepe Ziberi / Purepixel" zugeschrieben; die rechtsverbindliche Bezeichnung inkl.
|
||||||
|
* Rechtsform muss der Betreiber bestätigen.
|
||||||
|
*/
|
||||||
|
operatorName: env('LEGAL_OPERATOR_NAME', 'Perparim Ziberi'),
|
||||||
|
/** Rechtsform */
|
||||||
|
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 */
|
||||||
|
postalAddress: env('LEGAL_POSTAL_ADDRESS', 'Anglikerstrasse 56, 5612 Villmergen'),
|
||||||
|
|
||||||
|
// ─── Kontakt ───────────────────────────────────────────────
|
||||||
|
/** Allgemeine Support-/Kontaktadresse (im Repo öffentlich verwendet) */
|
||||||
|
supportEmail: env('LEGAL_SUPPORT_EMAIL', 'app@lageplan.ch'),
|
||||||
|
/**
|
||||||
|
* Datenschutz-Kontakt. Default = im Repository bereits verwendete Adresse
|
||||||
|
* (nicht erfunden). Der Betreiber muss bestätigen, dass dieses Postfach aktiv überwacht wird.
|
||||||
|
*/
|
||||||
|
privacyContactEmail: env('LEGAL_PRIVACY_EMAIL', 'datenschutz@lageplan.ch'),
|
||||||
|
|
||||||
|
// ─── Domains ───────────────────────────────────────────────
|
||||||
|
siteDomain: env('LEGAL_SITE_DOMAIN', 'lageplan.ch'),
|
||||||
|
appDomain: env('LEGAL_APP_DOMAIN', 'app.lageplan.ch'),
|
||||||
|
|
||||||
|
// ─── Recht & Gerichtsstand ────────────────────────────────
|
||||||
|
country: 'Schweiz',
|
||||||
|
governingLaw: 'Schweizer Recht',
|
||||||
|
/** Gerichtsstand: gesetzlicher Standard am Wohnsitz des Betreibers; zwingende Gerichtsstände vorbehalten. */
|
||||||
|
jurisdiction: env('LEGAL_JURISDICTION', 'Wohnsitz des Betreibers in der Schweiz'),
|
||||||
|
|
||||||
|
// ─── Infrastruktur ────────────────────────────────────────
|
||||||
|
/** Hostinganbieter: Eigenbetrieb (selbst gehosteter Server beim Betreiber). */
|
||||||
|
hostingProvider: env('LEGAL_HOSTING_PROVIDER', 'Eigenbetrieb (selbst gehosteter Server beim Betreiber)'),
|
||||||
|
/** Serverstandort der primären Daten (DB/Objektspeicher). */
|
||||||
|
serverLocation: env('LEGAL_SERVER_LOCATION', 'Schweiz (beim Betreiber, Villmergen AG)'),
|
||||||
|
/** Aufbewahrungsfrist von Sicherungskopien. */
|
||||||
|
backupRetention: env('LEGAL_BACKUP_RETENTION', 'bis zu 30 Tage'),
|
||||||
|
|
||||||
|
// ─── Externe Dienstleister (Ist-Zustand aus dem Code abgeleitet) ──
|
||||||
|
// Nichts erfinden: nur tatsächlich im Code kontaktierte Dienste. Sitz/Region, die nicht
|
||||||
|
// sicher bekannt ist, bleibt Platzhalter.
|
||||||
|
processors: [
|
||||||
|
{ 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, IP-Adresse', region: 'USA (Auslandbearbeitung möglich)' },
|
||||||
|
{ name: 'swisstopo (geo.admin.ch)', purpose: 'Schweizer Landeskarte', data: 'Kachel-Koordinaten, IP-Adresse', region: 'Schweiz' },
|
||||||
|
{ 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, IP-Adresse', region: 'EU (Deutschland)' },
|
||||||
|
{ 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: '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: '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: 'PostgreSQL (Datenbank, Eigenbetrieb)', purpose: 'Primäre Datenhaltung', data: 'Konto-, Organisations-, Plan- und Journaldaten', region: 'Schweiz (beim Betreiber)' },
|
||||||
|
] 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 ──────────
|
||||||
|
// WICHTIG: Version manuell erhöhen, wenn sich der Inhalt WESENTLICH ändert (löst Re-Consent aus).
|
||||||
|
// Nicht automatisch aus minimalen Dateiänderungen ableiten.
|
||||||
|
documents: {
|
||||||
|
terms: { version: '1.0.0', publishedAt: '2026-07-23' },
|
||||||
|
privacy: { 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' },
|
||||||
|
dataProcessingAgreement: { version: '1.0.0', publishedAt: '2026-07-23' },
|
||||||
|
fksAttribution: { version: '1.0.0', publishedAt: '2026-07-24' },
|
||||||
|
} as Record<string, LegalDocumentMeta>,
|
||||||
|
|
||||||
|
/** Dezenter Standard-Hinweis auf allen Rechtsseiten (kein Entwurfshinweis). */
|
||||||
|
draftNotice:
|
||||||
|
'Diese Unterlagen wurden nach bestem Wissen und Gewissen erstellt und werden laufend gepflegt.',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** Pflichtfelder, die vor Produktivbetrieb ausgefüllt sein müssen. */
|
||||||
|
const REQUIRED_FIELDS: { key: string; value: string }[] = [
|
||||||
|
{ key: 'operatorName', value: legal.operatorName },
|
||||||
|
{ key: 'operatorLegalForm', value: legal.operatorLegalForm },
|
||||||
|
{ key: 'postalAddress', value: legal.postalAddress },
|
||||||
|
{ key: 'privacyContactEmail', value: legal.privacyContactEmail },
|
||||||
|
{ key: 'hostingProvider', value: legal.hostingProvider },
|
||||||
|
{ key: 'serverLocation', value: legal.serverLocation },
|
||||||
|
{ key: 'backupRetention', value: legal.backupRetention },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Liste der noch offenen Pflichtangaben (Platzhalter). */
|
||||||
|
export function missingLegalFields(): string[] {
|
||||||
|
return REQUIRED_FIELDS.filter(f => isPlaceholder(f.value)).map(f => f.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warnt beim Build/Start, wenn Pflichtangaben noch Platzhalter sind.
|
||||||
|
* Bricht den Build NICHT ab (damit Entwicklung möglich bleibt), macht das Risiko aber sichtbar.
|
||||||
|
*/
|
||||||
|
export function assertLegalConfig(): void {
|
||||||
|
const missing = missingLegalFields()
|
||||||
|
if (missing.length > 0) {
|
||||||
|
const msg = `⚠️ [legal.ts] Offene rechtliche Pflichtangaben (noch Platzhalter): ${missing.join(', ')}. ` +
|
||||||
|
`Bitte in src/config/legal.ts bzw. via Umgebungsvariablen (LEGAL_*) setzen, bevor produktiv gegangen wird.`
|
||||||
|
// In Produktion als Fehler-Log, in Dev als Warnung — nie stillschweigend.
|
||||||
|
if (process.env.NODE_ENV === 'production') console.error(msg)
|
||||||
|
else console.warn(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beim Laden der Konfiguration einmalig prüfen (serverseitig).
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
assertLegalConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LegalConfig = typeof legal
|
||||||
@@ -313,6 +313,20 @@ export function useMapExport({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (format === 'png') {
|
if (format === 'png') {
|
||||||
|
// Rechtlicher Prüf-Hinweis unten einbrennen
|
||||||
|
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))
|
||||||
|
ctx.save()
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.55)'
|
||||||
|
ctx.fillRect(0, exportCanvas.height - barH, exportCanvas.width, barH)
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.font = `${Math.round(barH * 0.55)}px sans-serif`
|
||||||
|
ctx.textBaseline = 'middle'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText(disclaimer, exportCanvas.width / 2, exportCanvas.height - barH / 2, exportCanvas.width - 20)
|
||||||
|
ctx.restore()
|
||||||
|
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.download = `${safeName}.png`
|
link.download = `${safeName}.png`
|
||||||
link.href = exportCanvas.toDataURL('image/png')
|
link.href = exportCanvas.toDataURL('image/png')
|
||||||
@@ -435,6 +449,21 @@ 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)
|
||||||
|
pdf.setFontSize(6.5)
|
||||||
|
pdf.setFont('helvetica', 'italic')
|
||||||
|
pdf.setTextColor(120, 120, 120)
|
||||||
|
pdf.text(
|
||||||
|
'Mit Lageplan erstellt. Angaben vor Verwendung fachlich prüfen. Die aktuellen Offline- und Einsatzunterlagen der zuständigen Organisation bleiben massgebend.',
|
||||||
|
m, footerY - 4
|
||||||
|
)
|
||||||
pdf.setFontSize(7)
|
pdf.setFontSize(7)
|
||||||
pdf.setFont('helvetica', 'normal')
|
pdf.setFont('helvetica', 'normal')
|
||||||
pdf.setTextColor(156, 163, 175) // gray-400
|
pdf.setTextColor(156, 163, 175) // gray-400
|
||||||
|
|||||||
32
src/lib/__tests__/crypto-secret.test.ts
Normal file
32
src/lib/__tests__/crypto-secret.test.ts
Normal 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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
63
src/lib/__tests__/legal-rules.test.ts
Normal file
63
src/lib/__tests__/legal-rules.test.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { requiredTypesFor, computePendingDocuments, REQUIRED_FOR_ALL, REQUIRED_FOR_ORG_ADMIN } from '@/lib/legal-rules'
|
||||||
|
import { ORG_CONFIRMATIONS } from '@/lib/legal-content'
|
||||||
|
|
||||||
|
const active = [
|
||||||
|
{ type: 'TERMS', version: '1.0.0' },
|
||||||
|
{ type: 'PRIVACY', version: '1.0.0' },
|
||||||
|
{ type: 'ORGANIZATION_DECLARATION', version: '1.0.0' },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('requiredTypesFor', () => {
|
||||||
|
it('verlangt für alle Benutzer TERMS + PRIVACY', () => {
|
||||||
|
const req = requiredTypesFor(false)
|
||||||
|
expect(req.has('TERMS')).toBe(true)
|
||||||
|
expect(req.has('PRIVACY')).toBe(true)
|
||||||
|
expect(req.has('ORGANIZATION_DECLARATION')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verlangt für Org-Admins zusätzlich die Organisationsbestätigung', () => {
|
||||||
|
const req = requiredTypesFor(true)
|
||||||
|
expect(req.has('ORGANIZATION_DECLARATION')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computePendingDocuments', () => {
|
||||||
|
it('meldet alle Pflichtdokumente offen, wenn nichts akzeptiert wurde', () => {
|
||||||
|
const pending = computePendingDocuments(active, requiredTypesFor(true), new Set())
|
||||||
|
expect(pending.map(d => d.type).sort()).toEqual(['ORGANIZATION_DECLARATION', 'PRIVACY', 'TERMS'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('meldet nichts offen, wenn aktive Versionen akzeptiert wurden', () => {
|
||||||
|
const accepted = new Set(['TERMS@1.0.0', 'PRIVACY@1.0.0', 'ORGANIZATION_DECLARATION@1.0.0'])
|
||||||
|
const pending = computePendingDocuments(active, requiredTypesFor(true), accepted)
|
||||||
|
expect(pending).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('löst Re-Consent aus, wenn eine neue Version veröffentlicht wird', () => {
|
||||||
|
// Nutzer hat 1.0.0 akzeptiert, aktiv ist jetzt 2.0.0
|
||||||
|
const activeV2 = [{ type: 'TERMS', version: '2.0.0' }, { type: 'PRIVACY', version: '1.0.0' }]
|
||||||
|
const accepted = new Set(['TERMS@1.0.0', 'PRIVACY@1.0.0'])
|
||||||
|
const pending = computePendingDocuments(activeV2, requiredTypesFor(false), accepted)
|
||||||
|
expect(pending.map(d => d.type)).toEqual(['TERMS'])
|
||||||
|
expect(pending[0].version).toBe('2.0.0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normale Benutzer werden nicht als Org-Vertreter behandelt (keine Org-Bestätigung verlangt)', () => {
|
||||||
|
const accepted = new Set(['TERMS@1.0.0', 'PRIVACY@1.0.0'])
|
||||||
|
const pending = computePendingDocuments(active, requiredTypesFor(false), accepted)
|
||||||
|
expect(pending).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Konstanten & Inhalte', () => {
|
||||||
|
it('REQUIRED-Mengen sind korrekt', () => {
|
||||||
|
expect(REQUIRED_FOR_ALL).toEqual(['TERMS', 'PRIVACY'])
|
||||||
|
expect(REQUIRED_FOR_ORG_ADMIN).toEqual(['ORGANIZATION_DECLARATION'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Organisationsbestätigung hat genau 7 Pflichtaussagen', () => {
|
||||||
|
expect(ORG_CONFIRMATIONS).toHaveLength(7)
|
||||||
|
expect(ORG_CONFIRMATIONS.every(s => s.trim().length > 0)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
87
src/lib/backup/config.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/lib/backup/destinations.ts
Normal file
101
src/lib/backup/destinations.ts
Normal 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
163
src/lib/backup/engine.ts
Normal 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
19
src/lib/backup/secrets.ts
Normal 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
49
src/lib/crypto-secret.ts
Normal 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')
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user