9 Commits

Author SHA1 Message Date
Pepe Ziberi
5cdf0e63c9 refactor(wording): "Spende/spenden" -> "Freiwilliger Unterstützungsbeitrag/Beitrag" (v1.7.7)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45m18s
Sichtbare Texte umgestellt (Routen /spenden und Code-Namen unverändert):
- Home, Unterstützungsseite (/spenden), Danke-Seite, Layout-Titel
- Admin-Tab "Unterstützung", Texte + Button "Jetzt unterstützen"
- settings-tab Stripe-/Unterstützungs-Konfiguration
- Nutzungsbedingungen/Unterstützen: "Spendenbescheinigung" -> "Bescheinigung"
- Stripe-Checkout-Position: "Freiwilliger Unterstützungsbeitrag für Lageplan"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:53:11 +02:00
Pepe Ziberi
836b231c42 feat(legal): echte Betreiberangaben eingesetzt, Impressum als Privatperson (v1.7.6)
- Betreiber: Perparim Ziberi (Privatperson), Anglikerstrasse 56, 5612 Villmergen;
  Kontakt app@lageplan.ch, Datenschutz datenschutz@lageplan.ch
- Serverstandort/Hosting: Eigenbetrieb, Schweiz (beim Betreiber) — MinIO/PostgreSQL Region = Schweiz
- Backup-Aufbewahrung: bis zu 30 Tage · Gerichtsstand: Wohnsitz des Betreibers (Schweiz)
- Impressum: Firma "Kingstickers Ziberi" + UID entfernt (auf Wunsch Privatperson), Urheberrecht
  und Copyright auf Perparim Ziberi umgestellt; Home-Footer-Copyright angeglichen
- Alle Legal-Platzhalter damit gefüllt (keine Build-Warnung mehr)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:47:04 +02:00
Pepe Ziberi
a14753c994 feat(legal): Rechtstexte auf finale Fassung (v1.7.5)
Auf Wunsch produktiv statt Entwurf:
- Entwurfs-Kennzeichnung/Warnbanner entfernt; dezenter Standard-Hinweis im Footer der Rechtsseiten
  ("nach bestem Wissen und Gewissen erstellt und laufend gepflegt") — keine Behauptung juristischer Prüfung
- Dokumentversionen 1.0.0-draft -> 1.0.0 (Config + Migration-Seeding)
- AVV: Entwurfs-Sprache entfernt, als zwischen den Parteien wirksame Vereinbarung formuliert
- operatorName Default auf "Pepe Ziberi" (aus Repo bekannt); übrige Betreiberangaben bleiben
  bis zur Bestätigung Platzhalter (Adresse/Rechtsform/Serverstandort etc.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:28:27 +02:00
Pepe Ziberi
9eafe984e1 docs(legal): Abschlussbericht der Compliance-Umsetzung (v1.7.4)
docs/legal/IMPLEMENTATION-REPORT.md — Zusammenfassung, geänderte Dateien, DB, Konfiguration,
manuelle Schritte, offene rechtliche Entscheidungen, Test-/Build-Ergebnisse, Folgeschritte.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:19:59 +02:00
Pepe Ziberi
a943db9c54 docs(legal)+test: Compliance-Dokumentation & Consent-Unit-Tests (v1.7.4)
Teil 5 des Compliance-Updates:

- docs/legal/: data-processing-inventory.md, subprocessors.md,
  technical-organizational-measures.md, retention-and-deletion.md, incident-response.md,
  licenses-and-attributions.md — Ist-Zustand aus der Analyse, offene Punkte als Platzhalter
- Reine Consent-Regeln nach src/lib/legal-rules.ts ausgelagert (kein Prisma) + Texte nach
  src/lib/legal-content.ts (7 Pflichtbestätigungen) — für Testbarkeit
- Vitest eingerichtet (vitest.config.ts, test-Script); 13 Tests grün:
  Pflicht-Dokumenttypen, Re-Consent bei neuer Version, normale Benutzer ≠ Org-Vertreter,
  7 Org-Bestätigungen, Platzhalter-Erkennung, keine erfundenen Subprozessoren

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:19:01 +02:00
Pepe Ziberi
235959101b feat(legal): In-App-Hinweise, Export-Disclaimer & Web-Härtung (v1.7.3)
Teil 4 des Compliance-Updates:

- Export: rechtlicher Prüf-Hinweis in PDF-Footer und eingebrannt in PNG
  ("Angaben vor Verwendung fachlich prüfen · Offline-/Einsatzunterlagen bleiben massgebend")
- Dashboard: dezenter, minimierbarer Hinweisbanner (unterstützendes Werkzeug ohne garantierte
  Verfügbarkeit → /verantwortungsvolle-nutzung); bleibt in den Rechtsseiten erhalten
- Messpanel: expliziter Berechnungs-/Geodaten-Hinweis ("vor operativer Verwendung fachlich prüfen")
- Middleware: private Routen (/app, /admin, /settings, /konto) mit X-Robots-Tag noindex + no-store

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:11:43 +02:00
Pepe Ziberi
6ab2fd9a6b feat(legal): Organisationsbestätigung, Re-Consent-Gate & Admin-Ansicht (v1.7.2)
Teil 3 des Compliance-Updates — Zustimmungs-UI und Nachweis-Verwaltung:

- LegalConsentGate (blockierender Dialog): erkennt offene Pflicht-Zustimmungen nach Login,
  zeigt Organisationsbestätigung mit freundlicher Erklärung + 7 einzeln anzukreuzenden
  Pflichtbestätigungen (keine Vorauswahl) für Org-Admins, sonst Re-Consent für aktualisierte
  Nutzungsbedingungen/Datenschutz. Abmelden/Dokumente-Lesen bleiben möglich (Allowlist);
  keine stillschweigende Zustimmung. Tastatur-/Screenreader-tauglich (role=dialog)
- /konto/datenschutz: eigene Zustimmungen einsehen, Datenexport/Kontolöschung anfordern,
  Datenschutzkontakt (Route via Middleware geschützt)
- /admin/legal (nur SERVER_ADMIN): aktive Dokumentversionen, Zustimmungszahlen, fehlende
  Zustimmungen, Suche nach Benutzer, CSV-Export (minimale Nachweisdaten)
- APIs: /api/legal/my-acceptances, /api/admin/legal, /api/admin/legal/acceptances (+CSV)
- Gate ins Root-Layout eingehängt

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:07:59 +02:00
Pepe Ziberi
8cf1aa4dbe feat(legal): Consent-Datenmodell, Migration & Registrierungs-Zustimmung (v1.7.1)
Teil 2 des Compliance-Updates — revisionssichere Zustimmungsprotokollierung:

- Prisma: Modelle LegalDocument (versioniert, contentHash, isActive, unique[type,version])
  und LegalAcceptance (userId, organizationId?, documentType/-Version, contentHash, context,
  unique gegen Duplikate, User-Relation)
- migrate.js: idempotente Tabellen + Indizes + Seeding/Aktivierung der 5 Rechtsdokumente
  (eine aktive Version je Typ; contentHash aus bewusster Versionsangabe)
- src/lib/legal.ts: getActiveDocuments, getPendingAcceptances, recordAcceptances (skipDuplicates,
  nie überschreiben), Pflichtsets (alle: TERMS+PRIVACY; Org-Admin zusätzlich ORGANIZATION_DECLARATION)
- API: GET /api/legal/status (offene Zustimmungen), POST /api/legal/accept (erfassen, CSRF-Check)
- Registrierung: zwei getrennte, nicht vorausgewählte Zustimmungen (Nutzungsbedingungen akzeptieren
  + Datenschutz-Kenntnisnahme), serverseitig via zod erzwungen, Version protokolliert

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:02:20 +02:00
Pepe Ziberi
872b8927cc feat(legal): Claims-Korrektur, zentrale Legal-Config & öffentliche Rechtsseiten (v1.7.0)
Teil 1 des Compliance-Updates (nach vollständiger Repo-Analyse):

- src/config/legal.ts: zentrale, typisierte Betreiber-/Rechtskonfiguration mit sichtbaren
  Platzhaltern, ENV-Overrides (LEGAL_*), Build-Warnung bei offenen Pflichtangaben, echte
  Subprozessorenliste (aus dem Code abgeleitet)
- Claims-Korrektur (20 Aussagen): 24/7, 100% Swiss Hosted, DSG/DSGVO-konform, 100% der Spende,
  Kein Unternehmen, "bleibt kostenlos", "sicher via Stripe", "keine Weitergabe/keine
  Übermittlung ins Ausland", "verschlüsselt"→"gehasht" — in page.tsx, spenden, impressum,
  datenschutz (Neuaufbau), settings-tab
- Neue Legal-Seiten (druckbar, responsiv, versioniert, als Entwurf gekennzeichnet):
  /nutzungsbedingungen, /verantwortungsvolle-nutzung, /sicherheit, /unterstuetzen,
  /organisationen/datenschutzvereinbarung + wiederverwendbare LegalPage-Shell
- Datenschutz komplett neu aufgebaut: Subprozessoren-Tabelle (#dienstleister), Auslandbekanntgabe,
  gehashte Passwörter, Backups/Löschung, Zahlung (Stripe), Karten/Geodienste, Rechte/EDÖB
- Footer um alle Pflicht-Rechtslinks erweitert; sitemap.ts ergänzt
- docs/legal/website-claims-audit.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:57:39 +02:00
51 changed files with 3703 additions and 282 deletions

View 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).

View 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) | Nachweis Einwilligung/Vertrag | Benutzer | System | PostgreSQL | — | `[SERVERSTANDORT]` | Aufbewahrung zu Nachweiszwecken `[FRIST]` | revisionssicher, nicht überschrieben | minimale Daten (keine roh-IP) |
| 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.

View 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.

View File

@@ -0,0 +1,45 @@
# Lizenzen und Attributionen — Lageplan
Stand: 2026-07-23 · sorgfältiger Entwurf. Eine Quellenangabe ersetzt NICHT automatisch eine
erforderliche Nutzungslizenz. Offene Freigabefragen sind markiert.
## 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 |
|---|---|---|---|---|
| FKS/BABS taktische Zeichen | Einsatzsymbole | `[NUTZUNGSRECHT ZU KLÄREN]` | "Bildquelle Feuerwehr Koordination Schweiz FKS" (vorhanden) | ⚠️ **Nutzungsrecht/Freigabe FKS bestätigen** |
| 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.

View 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.

View 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.

View File

@@ -0,0 +1,59 @@
# 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 für System-/Org-Admins: ❌ nicht vorhanden → **Empfehlung offen**.
- ✅ 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
- ⚠️ Backups: `[VORHANDEN? FREQUENZ? AUFBEWAHRUNG?]` (siehe `docs/backup-und-seeding.md`).
- ❌ Restore-Tests: `[DOKUMENTIEREN]`.
## 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.

View 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.

1138
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "lageplan",
"version": "1.6.9",
"version": "1.7.7",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true,
"scripts": {
@@ -8,6 +8,8 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "vitest run",
"test:watch": "vitest",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
@@ -88,7 +90,8 @@
"sharp": "^0.34.5",
"tailwindcss": "^3.4.1",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
"typescript": "^5.3.3",
"vitest": "^4.1.10"
},
"browserslist": [
"last 2 Chrome versions",

View File

@@ -151,11 +151,73 @@ async function migrate() {
"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")`,
// 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")`,
]
for (const sql of tableMigrations) {
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' },
]
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 ───
console.log(' [5/7] Setting defaults...')
try {

View File

@@ -134,10 +134,56 @@ model User {
iconAssets IconAsset[]
upgradeRequests UpgradeRequest[]
rapports Rapport[]
legalAcceptances LegalAcceptance[]
@@map("users")
}
// ─── 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
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 {
id String @id @default(uuid())
role Role @default(OPERATOR)

View File

@@ -0,0 +1,160 @@
'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 }
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 &amp; 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>
</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></td>
<td className="px-3 py-2 text-xs text-muted-foreground">{a.context}</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
</section>
</div>
</div>
)
}

View File

@@ -100,7 +100,7 @@ export default function AdminPage() {
<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="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>
)}
@@ -169,14 +169,14 @@ export default function AdminPage() {
<h3 className="font-semibold text-lg">Lageplan unterstützen</h3>
<p className="text-sm text-muted-foreground mt-1">
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.
</p>
</div>
</div>
<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">
<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>
@@ -188,7 +188,7 @@ export default function AdminPage() {
<Button asChild>
<a href="/spenden" target="_blank" rel="noopener noreferrer">
<Heart className="w-4 h-4 mr-2" />
Jetzt spenden
Jetzt unterstützen
</a>
</Button>
<Button variant="outline" asChild>

View 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 },
})
// 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', '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.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 })
}
}

View 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 })
}
}

View File

@@ -5,12 +5,16 @@ import { sendEmail } from '@/lib/email'
import { randomBytes } from 'crypto'
import { z } from 'zod'
import { registerLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
import { recordAcceptances } from '@/lib/legal'
const registerSchema = z.object({
organizationName: z.string().min(2, 'Organisationsname zu kurz').max(200),
name: z.string().min(2, 'Name zu kurz').max(200),
email: z.string().email('Ungültige E-Mail-Adresse'),
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) {
@@ -79,8 +83,8 @@ export async function POST(req: NextRequest) {
maxUsers: 5,
maxProjects: 10,
contactEmail: data.email,
privacyAccepted: body.privacyAccepted === true,
privacyAcceptedAt: body.privacyAccepted ? new Date() : null,
privacyAccepted: true,
privacyAcceptedAt: new Date(),
adminAccessAccepted: body.adminAccessAccepted === true,
},
})
@@ -106,6 +110,20 @@ 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',
})
} catch (e) {
console.warn('[register] Zustimmungsprotokollierung fehlgeschlagen (nicht blockierend):', e)
}
// 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'
if (baseUrl && !baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {

View File

@@ -27,8 +27,8 @@ export async function POST(req: NextRequest) {
price_data: {
currency: 'chf',
product_data: {
name: 'Spende für Lageplan',
description: `Freiwillige Spende von CHF ${amount} für die Weiterentwicklung`,
name: 'Freiwilliger Unterstützungsbeitrag für Lageplan',
description: `Freiwilliger Unterstützungsbeitrag von CHF ${amount} für die Weiterentwicklung`,
},
unit_amount: amountInCents,
},

View File

@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { recordAcceptances, getPendingAcceptances, type LegalAcceptanceContext, type LegalDocumentType } from '@/lib/legal'
const VALID_TYPES: LegalDocumentType[] = ['TERMS', 'PRIVACY', 'ORGANIZATION_DECLARATION', 'RESPONSIBLE_USE', 'DATA_PROCESSING_AGREEMENT']
const VALID_CONTEXTS: LegalAcceptanceContext[] = ['REGISTRATION', 'LOGIN_RECONSENT', 'ORGANIZATION_CREATION', 'ADMIN_ROLE_ACCEPTANCE', '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',
})
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 })
}
}

View 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 })
}
}

View 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 })
}
}

View File

@@ -20,6 +20,7 @@ import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { JournalView } from '@/components/journal/journal-view'
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 { OnboardingTour, resetOnboardingTour } from '@/components/onboarding/onboarding-tour'
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'
@@ -1035,6 +1036,8 @@ export default function AppPage() {
editingLoading={editingLoading}
/>
<UsageNoticeBanner />
{/* Kompakte Status-Zeile — eine Zeile mit Chips statt bis zu vier gestapelten Bannern.
Zeigt nur, was gerade zutrifft; im Normalfall (Einsatz, online, verifiziert,
niemand fremd am Zeichnen) erscheint sie gar nicht. */}

View File

@@ -1,187 +1,207 @@
'use client'
import Link from 'next/link'
import { ArrowLeft, Shield, Server, Trash2, Mail } from 'lucide-react'
import { LegalPage } from '@/components/legal/legal-page'
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() {
const d = legal.documents.privacy
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 px-4 py-12">
<div className="max-w-3xl mx-auto">
<Link href="/" className="text-sm text-gray-400 hover:text-gray-300 inline-flex items-center gap-1 mb-8">
<ArrowLeft className="w-3.5 h-3.5" />
Zurück zur Startseite
</Link>
<div className="bg-card rounded-xl shadow-2xl p-8 md:p-12 border border-border">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-red-600 rounded-full flex items-center justify-center">
<Shield className="w-5 h-5 text-white" />
</div>
<h1 className="text-3xl font-bold text-foreground">Datenschutzerklärung</h1>
</div>
<p className="text-muted-foreground mb-8">
Gültig ab 1. Januar 2026 Lageplan.ch, betrieben in der Schweiz.
</p>
{/* 1. Verantwortlicher */}
<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">01</span>
Verantwortlicher
</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
Verantwortlich für die Datenverarbeitung auf lageplan.ch ist der Betreiber der Plattform.
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>.
<LegalPage
title="Datenschutzerklärung"
version={d.version}
publishedAt={d.publishedAt}
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).`}
>
<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>
{/* 2. Welche Daten */}
<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">02</span>
Welche Daten werden erhoben?
</h2>
<div className="space-y-3 text-sm text-muted-foreground leading-relaxed">
<p><strong className="text-foreground">Registrierungsdaten:</strong> Name, E-Mail-Adresse, Organisationsname, Passwort (verschlüsselt gespeichert).</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>
<p><strong className="text-foreground">Technische Daten:</strong> IP-Adresse, Browser-Typ, Zugriffszeitpunkt ausschliesslich für den Betrieb und die Sicherheit der Plattform.</p>
<p><strong className="text-foreground">Hochgeladene Dateien:</strong> Logos, Planbilder und Symbole, die Sie über die Applikation hochladen.</p>
</div>
<section>
<h2>2. Welche Daten werden bearbeitet?</h2>
<ul>
<li><strong>Registrierungs-/Kontodaten:</strong> Name, E-Mail-Adresse, Organisationsname, Rolle. Das Passwort wird mit einem geeigneten Einwegverfahren (bcrypt) gehasht und nie im Klartext gespeichert.</li>
<li><strong>Einsatz-/Inhaltsdaten:</strong> Lagepläne, Journaleinträge, Zeichnungen, Koordinaten, Symbole, hochgeladene Dateien (Logos, Planbilder, Symbole) sowie zugehörige Projekt- und Organisationsdaten.</li>
<li><strong>Protokoll-/Sicherheitsdaten:</strong> technische Zugriffs- und Fehlerprotokolle (z.B. Zeitpunkt, ungefähre technische Angaben) zur Gewährleistung von Betrieb und Sicherheit.</li>
<li><strong>Zustimmungsnachweise:</strong> welche Version der Nutzungsbedingungen/Datenschutzerklärung/Organisationsbestätigung wann akzeptiert wurde.</li>
<li><strong>Unterstützungsbeiträge (optional):</strong> bei freiwilligen Beiträgen der Betrag sowie freiwillig angegebener Name/Nachricht (Zahlungsabwicklung siehe Ziffer 9).</li>
</ul>
</section>
{/* 3. Zweck */}
<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">03</span>
Zweck der Datenverarbeitung
</h2>
<div className="space-y-2 text-sm text-muted-foreground leading-relaxed">
<p>Ihre Daten werden ausschliesslich für folgende Zwecke verwendet:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Bereitstellung und Betrieb der Lageplan-Applikation</li>
<li>Authentifizierung und Benutzerverwaltung</li>
<li>Echtzeit-Synchronisierung von Einsatzdaten zwischen Teammitgliedern</li>
<li>Erstellung von Einsatzrapports und PDF-Exporten</li>
<section>
<h2>3. Zwecke</h2>
<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>
</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">
<section>
<h2>4. Grundlagen der Bearbeitung</h2>
<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:
Die Bearbeitung erfolgt insbesondere zur Erfüllung des Nutzungsverhältnisses (Vertrag), aufgrund
berechtigter Interessen am sicheren und stabilen Betrieb sowie wo erforderlich gestützt auf Ihre
Einwilligung. Zwingende gesetzliche Bestimmungen bleiben vorbehalten.
</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>
</section>
<section id="dienstleister" className="scroll-mt-20">
<h2>5. Empfänger und Dienstleister (Subprozessoren)</h2>
<p>
Für den technischen Betrieb können sorgfältig ausgewählte Dienstleister eingesetzt werden. Nachfolgend
die tatsächlich eingesetzten bzw. optional konfigurierbaren Dienste. Einige Karten-, Geocoding- und
Wetterdienste werden direkt aus Ihrem Browser aufgerufen; dabei kann die aufgerufene Kachel-/Standort-
Anfrage (und damit Ihre IP-Adresse) an den jeweiligen Anbieter übermittelt werden.
</p>
<div className="overflow-x-auto">
<table className="w-full text-xs border-collapse mt-2">
<thead>
<tr className="text-left text-foreground border-b border-border">
<th className="py-1.5 pr-3">Dienst</th>
<th className="py-1.5 pr-3">Zweck</th>
<th className="py-1.5 pr-3">Daten</th>
<th className="py-1.5">Region</th>
</tr>
</thead>
<tbody>
{legal.processors.map(p => (
<tr key={p.name} className="border-b border-border/50 align-top">
<td className="py-1.5 pr-3 text-foreground font-medium">{p.name}{p.optional ? ' *' : ''}</td>
<td className="py-1.5 pr-3">{p.purpose}</td>
<td className="py-1.5 pr-3">{p.data}</td>
<td className="py-1.5">{isPlaceholder(p.region) ? <span className="text-amber-600 dark:text-amber-400">{p.region}</span> : p.region}</td>
</tr>
))}
</tbody>
</table>
</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>
<section>
<h2>6. Bekanntgabe ins Ausland</h2>
<p>
Je nach eingesetztem Dienstleister (z.B. Zahlungsanbieter, konfigurierter E-Mail-Anbieter, globale
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>
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.
Angemeldete Nutzer können Zustimmungen, Datenexport und Kontolöschung unter{' '}
<a href="/konto/datenschutz">Konto &amp; Datenschutz</a> einsehen bzw. anfordern. Anfragen richten Sie an{' '}
<Val value={legal.privacyContactEmail} />; der Eingang wird bestätigt.
</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">
<section>
<h2>14. Sicherheitsvorfälle</h2>
<p>
Alle Daten werden auf Servern in der <strong className="text-foreground">Schweiz</strong> gespeichert.
Es findet keine Übermittlung von Daten ins Ausland statt.
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>
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.
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>
{/* 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 className="text-center mt-6 text-xs text-gray-500">
© {new Date().getFullYear()} Lageplan.ch Alle Rechte vorbehalten.
</div>
</div>
</div>
</LegalPage>
)
}

View File

@@ -31,21 +31,23 @@ export default function ImpressumPage() {
<section>
<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">
<p className="font-semibold text-gray-900">Purepixel</p>
<p>Ein Angebot der Firma Kingstickers Ziberi</p>
<p className="mt-3">Perparim Ziberi</p>
<p>Anglikerstrasse 56</p>
<p className="font-semibold text-gray-900">Perparim Ziberi</p>
<p className="text-gray-500 text-sm">Privatperson</p>
<p className="mt-3">Anglikerstrasse 56</p>
<p>5612 Villmergen</p>
<p>Schweiz</p>
<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>{' '}
<a href="mailto:app@lageplan.ch" className="text-red-600 hover:text-red-500">
app@lageplan.ch
</a>
</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>
</section>
@@ -89,9 +91,9 @@ export default function ImpressumPage() {
<h2 className="text-xl font-semibold text-gray-900 mb-3">Urheberrechte</h2>
<p>
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
genannten Rechtsinhabern. Für die Reproduktion jeglicher Elemente ist die schriftliche
Zustimmung der Urheberrechtsträger im Voraus einzuholen.
dieser Website gehören ausschliesslich Perparim Ziberi oder den speziell genannten
Rechtsinhabern. Für die Reproduktion jeglicher Elemente ist die schriftliche Zustimmung der
Urheberrechtsträger im Voraus einzuholen.
</p>
</section>
@@ -104,17 +106,20 @@ export default function ImpressumPage() {
Privatsphäre sowie auf Schutz vor Missbrauch ihrer persönlichen Daten.
</p>
<p className="mt-2">
Wir halten diese Bestimmungen ein. Persönliche Daten werden streng vertraulich behandelt und
weder an Dritte verkauft noch weitergegeben.
Persönliche Daten werden vertraulich behandelt und nicht an Dritte verkauft. Für den technischen
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 className="mt-2">
Die Nutzung der Applikation erfordert eine Registrierung. Dabei werden folgende Daten
gespeichert: Name, E-Mail-Adresse, Organisationsname. Die Daten werden ausschliesslich für
den Betrieb der Applikation verwendet und auf Servern in der Schweiz gehostet.
Die Nutzung der Applikation erfordert eine Registrierung. Dabei werden insbesondere Name,
E-Mail-Adresse und Organisationsname gespeichert. Passwörter werden mit einem geeigneten
Einwegverfahren (bcrypt) gehasht und nie im Klartext gespeichert.
</p>
<p className="mt-2">
Einsatzdaten (Lagepläne, Journaleinträge, Zeichnungen) werden verschlüsselt gespeichert und
sind nur für berechtigte Benutzer der jeweiligen Organisation zugänglich.
Einsatzdaten (Lagepläne, Journaleinträge, Zeichnungen) sind nach dem Rollen- und
Mandantenkonzept nur für berechtigte Benutzer der jeweiligen Organisation zugänglich. Die
Übertragung erfolgt über HTTPS/TLS.
</p>
</section>
@@ -139,7 +144,7 @@ export default function ImpressumPage() {
</div>
<div className="mt-12 pt-8 border-t border-gray-100 text-center">
<p className="text-sm text-gray-400">&copy; {new Date().getFullYear()} Lageplan Purepixel / Kingstickers Ziberi</p>
<p className="text-sm text-gray-400">&copy; {new Date().getFullYear()} Lageplan Perparim Ziberi</p>
</div>
</main>
</div>

View 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 &amp; 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>
)
}

View File

@@ -5,6 +5,7 @@ import { Toaster } from '@/components/ui/toaster'
import { AuthProvider } from '@/components/providers/auth-provider'
import { ServiceWorkerRegister } from '@/components/providers/sw-register'
import { CookieConsent } from '@/components/ui/cookie-consent'
import { LegalConsentGate } from '@/components/legal/legal-consent-gate'
const barlow = Barlow({
subsets: ['latin'],
@@ -109,6 +110,7 @@ export default function RootLayout({
<AuthProvider>
<ServiceWorkerRegister />
{children}
<LegalConsentGate />
<Toaster />
<CookieConsent />
</AuthProvider>

View File

@@ -0,0 +1,186 @@
'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>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>
)
}

View 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>
)
}

View File

@@ -52,7 +52,7 @@ export default function LandingPage() {
name: 'Was kostet Lageplan?',
acceptedAnswer: {
'@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?',
acceptedAnswer: {
'@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).',
},
},
{
@@ -237,10 +237,10 @@ export default function LandingPage() {
<div className="max-w-5xl mx-auto">
<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: '100%', label: 'Swiss Hosted', sub: 'Server in der Schweiz' },
{ value: '0.—', label: 'Kostenlos', sub: 'Für alle Feuerwehren' },
{ value: '24/7', label: 'Verfügbar', sub: 'Browser-basiert, jedes Gerät' },
{ value: '117', label: 'FKS-Signaturen', sub: 'Taktische Zeichen (Bildquelle FKS)' },
{ value: 'CH', label: 'Swiss Made', sub: 'In der Schweiz entwickelt' },
{ value: '0.—', label: 'Kostenlos', sub: 'Grundnutzung für Feuerwehren' },
{ value: 'Web', label: 'Überall nutzbar', sub: 'Browser, Tablet, Smartphone' },
].map((stat, i) => (
<div key={i} className="text-center">
<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.
Server-Hosting, Domain, SSL-Zertifikate das alles kostet. Ich finanziere das aktuell
komplett aus eigener Tasche, weil mir das Projekt am Herzen liegt.
Wenn du Lageplan nützlich findest, kannst du die Weiterentwicklung mit einer freiwilligen
Spende unterstützen. Jeder Beitrag hilft, neue Features zu bauen und die Server am Laufen zu halten.
Wenn du Lageplan nützlich findest, kannst du die Weiterentwicklung mit einem freiwilligen
Beitrag unterstützen. Jeder Beitrag hilft, neue Features zu bauen und die Server am Laufen zu halten.
</p>
</div>
</div>
@@ -357,8 +357,8 @@ export default function LandingPage() {
{ 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: 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: Lock, title: 'Sicher & Schweiz', desc: 'Rollenbasierte Zugriffskontrolle. Gehostet in der Schweiz — DSG/DSGVO-konform.' },
{ icon: Clock, title: 'Nachvollziehbarkeit', desc: 'Protokollierung von Aktionen mit Zeitstempel für bessere Nachvollziehbarkeit.' },
{ 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.' },
].map((f, i) => (
<div key={i} className="bg-white rounded-xl p-6 border border-gray-100 hover:shadow-lg transition-shadow">
@@ -439,7 +439,7 @@ export default function LandingPage() {
{[
{
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?',
@@ -455,7 +455,7 @@ export default function LandingPage() {
},
{
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?',
@@ -508,7 +508,11 @@ export default function LandingPage() {
</div>
<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">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 className="text-sm text-gray-500">
<p className="font-medium text-gray-700 mb-2">Kontakt</p>
@@ -526,10 +530,15 @@ export default function LandingPage() {
<p className="font-medium text-gray-700 mb-2">Rechtliches</p>
<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="/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="/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 className="border-t border-gray-100 mt-8 pt-6 text-center space-y-1">
<p className="text-sm text-gray-500">&copy; {new Date().getFullYear()} Lageplan Purepixel. Alle Rechte vorbehalten.</p>
<p className="text-sm text-gray-500">&copy; {new Date().getFullYear()} Lageplan Perparim Ziberi. Alle Rechte vorbehalten.</p>
<p className="text-xs text-gray-500">Taktische Symbole: Bildquelle Feuerwehr Koordination Schweiz FKS</p>
<p className="text-xs text-gray-400 mt-1">v{process.env.APP_VERSION}</p>
</div>
@@ -548,8 +557,8 @@ function SupportSection() {
<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>
<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,
freue ich mich über einen freiwilligen Beitrag.
Lageplan wird grundsätzlich kostenlos angeboten. Wenn du die Weiterentwicklung unterstützen
möchtest, freue ich mich über einen freiwilligen Beitrag ohne Anspruch auf eine Gegenleistung.
</p>
</div>
@@ -571,13 +580,13 @@ function SupportSection() {
</div>
<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>
<Link href="/spenden">
<Button className="bg-red-600 hover:bg-red-700 h-12 text-base px-8">
<Heart className="w-4 h-4 mr-2" />
Zur Spendenseite
Zur Unterstützungsseite
</Button>
</Link>
</div>
@@ -585,8 +594,10 @@ function SupportSection() {
{/* Trust note */}
<div className="mt-8 text-center">
<p className="text-sm text-gray-500">
100% deiner Spende fliesst direkt in Serverkosten und Weiterentwicklung.
<br />Kein Unternehmen, keine Investoren nur ein Feuerwehrmann mit einer Idee.
Beiträge helfen, Hosting, Infrastruktur und Weiterentwicklung zu finanzieren.
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>
</div>
</div>

View File

@@ -16,7 +16,8 @@ export default function RegisterPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = 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 [isSuccess, setIsSuccess] = useState(false)
const router = useRouter()
@@ -40,7 +41,7 @@ export default function RegisterPage() {
const res = await fetch('/api/auth/register', {
method: 'POST',
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()
@@ -188,13 +189,28 @@ export default function RegisterPage() {
/>
</div>
{/* Privacy Policy Checkbox */}
<div className="pt-2 border-t border-border">
{/* Zustimmungen — je einzeln aktiv zu setzen, keine Vorauswahl */}
<div className="pt-2 border-t border-border space-y-2.5">
<label className="flex items-start gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={privacyAccepted}
onChange={(e) => setPrivacyAccepted(e.target.checked)}
checked={acceptTerms}
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"
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">
Datenschutzerklärung
</Link>{' '}
gelesen und akzeptiere die Nutzungsbedingungen.
zur Kenntnis genommen.
</span>
</label>
</div>
@@ -211,7 +227,7 @@ export default function RegisterPage() {
<Button
type="submit"
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 ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Wird erstellt...</>

View File

@@ -0,0 +1,59 @@
'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>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>
)
}

View File

@@ -46,5 +46,10 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: 'yearly',
priority: 0.3,
},
{ url: `${baseUrl}/nutzungsbedingungen`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 },
{ url: `${baseUrl}/verantwortungsvolle-nutzung`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 },
{ url: `${baseUrl}/sicherheit`, 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 },
]
}

View File

@@ -30,7 +30,7 @@ export default function DankePage() {
</h1>
<p className="text-xl text-gray-500 mb-2">
Deine Spende ist angekommen.
Dein Beitrag ist angekommen.
</p>
<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.
@@ -40,7 +40,7 @@ export default function DankePage() {
<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" />
<p className="text-gray-700 font-medium">
&laquo;Jede Spende motiviert mich, weiterzumachen. Danke von Herzen!&raquo;
&laquo;Jeder Beitrag motiviert mich, weiterzumachen. Danke von Herzen!&raquo;
</p>
<p className="text-sm text-gray-500 mt-2"> Pepe Ziberi, Entwickler von Lageplan</p>
</div>

View File

@@ -1,8 +1,8 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Spenden',
description: 'Unterstützen Sie die Weiterentwicklung von Lageplan.ch der kostenlosen Krokier-App für Schweizer Feuerwehren.',
title: 'Unterstützen',
description: 'Freiwilliger Unterstützungsbeitrag für die Weiterentwicklung von Lageplan.ch der kostenlosen Krokier-App für Schweizer Feuerwehren.',
robots: { index: true, follow: true },
}

View File

@@ -85,7 +85,7 @@ export default function SpendenPage() {
<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">
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>
</div>
@@ -94,9 +94,9 @@ export default function SpendenPage() {
<div className="flex items-start gap-4">
<Lightbulb className="w-7 h-7 text-red-500 shrink-0 mt-0.5" />
<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">
<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"><Coffee className="w-4 h-4 text-red-500 shrink-0" /> Domain, SSL-Zertifikate und Infrastruktur</li>
</ul>
@@ -206,7 +206,7 @@ export default function SpendenPage() {
{/* CTA */}
{stripeConfigured === false ? (
<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>
) : (
<Button
@@ -217,18 +217,30 @@ export default function SpendenPage() {
{isLoading ? (
<><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>
)}
<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" /> 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" /> Google Pay</span>
</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>
{/* Story */}
@@ -236,21 +248,22 @@ export default function SpendenPage() {
<h3 className="font-bold text-gray-900 mb-3">Die Geschichte hinter Lageplan</h3>
<p className="text-sm text-gray-600 leading-relaxed">
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,
unabhängig vom Budget. Es gibt zwar kommerzielle Alternativen, aber die Idee war immer:
Die App wird <strong>grundsätzlich kostenlos</strong> angeboten weil jede Feuerwehr Zugang zu guten
Werkzeugen haben soll, unabhängig vom Budget. Die Idee war immer:
Ein Werkzeug <em>von der Feuerwehr, für die Feuerwehr</em>.
</p>
<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
Serverkosten und Weiterentwicklung. Danke für deine Unterstützung!
Beiträge helfen, Hosting, Infrastruktur und Weiterentwicklung mitzufinanzieren. Danke für deine
Unterstützung!
</p>
</div>
{/* Trust */}
<div className="mt-8 text-center">
<p className="text-sm text-gray-500">
100% deiner Spende fliesst direkt in Serverkosten und Weiterentwicklung.
<br />Kein Unternehmen, keine Investoren nur ein Feuerwehrmann mit einer Idee.
Beiträge werden nach Abzug allfälliger Zahlungsgebühren für Hosting, Infrastruktur und
Weiterentwicklung eingesetzt.
<br />Ein Projekt aus der Feuerwehr-Praxis entwickelt in der Freizeit.
</p>
<div className="mt-6">
<Link href="/">

View 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>
)
}

View 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>
)
}

View File

@@ -239,7 +239,7 @@ export function SettingsTab({ onNavigateTab }: SettingsTabProps) {
<Mail className="w-5 h-5 text-primary" />
E-Mail / SMTP Konfiguration
</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><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">
@@ -284,10 +284,10 @@ export function SettingsTab({ onNavigateTab }: SettingsTabProps) {
<div className="border rounded-lg p-6 md:col-span-2">
<h3 className="font-semibold text-lg mb-4 flex items-center gap-2">
<CreditCard className="w-5 h-5 text-primary" />
Stripe / Spenden-Konfiguration
Stripe / Unterstützungs-Konfiguration
</h3>
<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>.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">

View 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 gehts 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>
)
}

View File

@@ -0,0 +1,92 @@
'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: '/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>
)
}

View 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>
)
}

View File

@@ -157,7 +157,10 @@ export function MeasurePanel({ measurement, hoseTypes, onClose }: MeasurePanelPr
)}
<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>
</div>
</div>

View 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 genau 5 Rechtsdokumenttypen', () => {
expect(Object.keys(legal.documents).sort()).toEqual(
['dataProcessingAgreement', '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)
})
})

163
src/config/legal.ts Normal file
View File

@@ -0,0 +1,163 @@
/**
* 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'),
/** 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 (Tiles)', purpose: 'Basiskarte', data: 'Kachel-Koordinaten (Kartenausschnitt)', region: '[REGION OSM Foundation, EU/UK]' },
{ name: 'Esri ArcGIS World Imagery', purpose: 'Satellitenkarte', data: 'Kachel-Koordinaten', region: '[REGION]' },
{ name: 'swisstopo (geo.admin.ch)', purpose: 'Schweizer Landeskarte', data: 'Kachel-Koordinaten', region: 'Schweiz' },
{ name: 'Nominatim (OpenStreetMap)', purpose: 'Adress-/Reverse-Geocoding', data: 'Adress-/Ortssuche, GPS-Koordinaten', region: '[REGION OSM Foundation]' },
{ name: 'Open-Meteo', purpose: 'Wind- und Höhendaten', data: 'Koordinaten', region: '[REGION EU]' },
{ name: 'Open-Elevation', purpose: 'Höhendaten (Fallback)', data: 'Koordinaten', region: '[REGION]' },
{ name: 'Stripe', purpose: 'Zahlungsabwicklung freiwilliger Unterstützungsbeiträge', data: 'Betrag, freiwilliger Name/Nachricht, Zahlungsdaten (direkt bei Stripe)', region: '[REGION u.a. USA/EU; Auslandbearbeitung möglich]', optional: true },
{ name: 'SMTP-/E-Mail-Anbieter', purpose: 'System-E-Mails (Verifizierung, Reset, Kontakt, Einladungen)', data: 'E-Mail-Adresse, Nachrichteninhalt', region: '[REGION abhängig vom konfigurierten Anbieter]', optional: true },
{ name: '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[],
// ─── 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' },
} 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

View File

@@ -313,6 +313,19 @@ export function useMapExport({
}
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'
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')
link.download = `${safeName}.png`
link.href = exportCanvas.toDataURL('image/png')
@@ -435,6 +448,14 @@ export function useMapExport({
// ── Footer ──
const footerY = pageH - m - 4
// 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.setFont('helvetica', 'normal')
pdf.setTextColor(156, 163, 175) // gray-400

View 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)
})
})

14
src/lib/legal-content.ts Normal file
View File

@@ -0,0 +1,14 @@
/**
* Reine Textinhalte für den Consent-Flow (ohne UI-Abhängigkeiten, damit testbar).
*/
/** Die 7 einzeln zu bestätigenden Pflichtaussagen der Organisationsbestätigung. */
export const ORG_CONFIRMATIONS: string[] = [
'Ich bestätige, dass ich berechtigt bin, für diese Organisation ein Administratorkonto einzurichten oder diese Bestätigung abzugeben.',
'Unsere Organisation versteht, dass Lageplan ein unterstützendes Werkzeug und kein Alarmierungs-, Leitstellen- oder Einsatzleitsystem ist.',
'Unsere Organisation prüft Lagepläne, Adressen, Symbole, Distanzen, Berechnungen und sonstige Inhalte vor der operativen Verwendung durch fachkundige Personen.',
'Unsere Organisation hält unabhängig von Lageplan aktuelle Offline-, Notfall- und Rückfallunterlagen bereit.',
'Unsere Organisation verwendet Lageplan nicht als einzige Informationsquelle für operative Entscheidungen.',
'Unsere Organisation speichert ohne separate schriftliche Vereinbarung keine Patienten-, Gesundheits-, Polizei-, Passwort-, Zugangs-, Schlüsselcode- oder anderen besonders schützenswerten Daten in Lageplan.',
'Ich habe die Nutzungsbedingungen und die Datenschutzerklärung gelesen und akzeptiere die für die Nutzung geltenden Bestimmungen.',
]

44
src/lib/legal-rules.ts Normal file
View File

@@ -0,0 +1,44 @@
/**
* Reine Consent-Regeln OHNE Laufzeit-Abhängigkeiten (kein Prisma) — damit unit-testbar.
*/
export type LegalDocumentType =
| 'TERMS'
| 'PRIVACY'
| 'ORGANIZATION_DECLARATION'
| 'RESPONSIBLE_USE'
| 'DATA_PROCESSING_AGREEMENT'
export type LegalAcceptanceContext =
| 'REGISTRATION'
| 'LOGIN_RECONSENT'
| 'ORGANIZATION_CREATION'
| 'ADMIN_ROLE_ACCEPTANCE'
| 'DONATION'
/** Für ALLE Benutzer erforderliche Dokumente. */
export const REQUIRED_FOR_ALL: LegalDocumentType[] = ['TERMS', 'PRIVACY']
/** Zusätzlich für Organisationsadministratoren erforderlich. */
export const REQUIRED_FOR_ORG_ADMIN: LegalDocumentType[] = ['ORGANIZATION_DECLARATION']
/** Pflicht-Dokumenttypen für einen Benutzer. */
export function requiredTypesFor(isOrgAdmin: boolean): Set<string> {
return new Set<string>([...REQUIRED_FOR_ALL, ...(isOrgAdmin ? REQUIRED_FOR_ORG_ADMIN : [])])
}
/**
* Kernregel: welche aktiven Pflichtdokumente sind für den Benutzer noch offen?
* @param active aktive Dokumente (eine Version je Typ)
* @param required Menge erforderlicher Dokumenttypen
* @param acceptedKeys bereits akzeptierte Schlüssel `TYPE@version`
*/
export function computePendingDocuments<T extends { type: string; version: string }>(
active: T[],
required: Set<string>,
acceptedKeys: Set<string>,
): T[] {
return active
.filter(d => required.has(d.type))
.filter(d => !acceptedKeys.has(`${d.type}@${d.version}`))
}

95
src/lib/legal.ts Normal file
View File

@@ -0,0 +1,95 @@
import { prisma } from '@/lib/db'
import {
REQUIRED_FOR_ALL, REQUIRED_FOR_ORG_ADMIN, requiredTypesFor, computePendingDocuments,
type LegalDocumentType, type LegalAcceptanceContext,
} from '@/lib/legal-rules'
// Re-Export für bestehende Importe
export {
REQUIRED_FOR_ALL, REQUIRED_FOR_ORG_ADMIN, requiredTypesFor, computePendingDocuments,
}
export type { LegalDocumentType, LegalAcceptanceContext }
export interface ActiveDocument {
type: string
version: string
title: string
url: string | null
contentHash: string
publishedAt: Date
}
/** Alle aktuell aktiven Rechtsdokumente (eine aktive Version je Typ). */
export async function getActiveDocuments(): Promise<ActiveDocument[]> {
return (prisma as any).legalDocument.findMany({
where: { isActive: true },
select: { type: true, version: true, title: true, url: true, contentHash: true, publishedAt: true },
})
}
/** Aktives Dokument eines bestimmten Typs (oder null). */
export async function getActiveDocument(type: LegalDocumentType): Promise<ActiveDocument | null> {
return (prisma as any).legalDocument.findFirst({
where: { type, isActive: true },
select: { type: true, version: true, title: true, url: true, contentHash: true, publishedAt: true },
})
}
/**
* Ermittelt die noch ausstehenden Pflicht-Zustimmungen eines Benutzers.
* Ein Dokument gilt als offen, wenn für die aktive Version keine Zustimmung des Benutzers existiert.
*/
export async function getPendingAcceptances(userId: string, isOrgAdmin: boolean): Promise<ActiveDocument[]> {
const required = requiredTypesFor(isOrgAdmin)
const active = (await getActiveDocuments()).filter(d => required.has(d.type))
if (active.length === 0) return []
const accepted = await (prisma as any).legalAcceptance.findMany({
where: {
userId,
OR: active.map(d => ({ documentType: d.type, documentVersion: d.version })),
},
select: { documentType: true, documentVersion: true },
})
const acceptedKeys = new Set<string>(accepted.map((a: any) => `${a.documentType}@${a.documentVersion}`))
return computePendingDocuments(active, required, acceptedKeys)
}
/** True, wenn der Benutzer alle erforderlichen aktiven Dokumente akzeptiert hat. */
export async function hasAllRequiredAcceptances(userId: string, isOrgAdmin: boolean): Promise<boolean> {
return (await getPendingAcceptances(userId, isOrgAdmin)).length === 0
}
interface RecordInput {
userId: string
organizationId?: string | null
context: LegalAcceptanceContext
locale?: string | null
/** Dokumenttypen, die zugestimmt werden. Es wird jeweils die AKTIVE Version protokolliert. */
types: LegalDocumentType[]
}
/**
* Protokolliert Zustimmungen revisionssicher. Bestehende identische Datensätze werden NICHT
* überschrieben (Unique-Constraint + skipDuplicates). Nur aktive Dokumentversionen werden verbucht.
* Gibt die Anzahl neu erfasster Zustimmungen zurück.
*/
export async function recordAcceptances(input: RecordInput): Promise<number> {
const active = await getActiveDocuments()
const byType = new Map(active.map(d => [d.type, d]))
const rows = input.types
.map(t => byType.get(t))
.filter((d): d is ActiveDocument => !!d)
.map(d => ({
userId: input.userId,
organizationId: input.organizationId ?? null,
documentType: d.type,
documentVersion: d.version,
contentHash: d.contentHash,
context: input.context,
locale: input.locale ?? 'de-CH',
}))
if (rows.length === 0) return 0
const res = await (prisma as any).legalAcceptance.createMany({ data: rows, skipDuplicates: true })
return res?.count ?? 0
}

View File

@@ -3,7 +3,7 @@ import { jwtVerify } from 'jose'
import { JWT_SECRET } from './lib/jwt-secret'
// Routes that require authentication
const PROTECTED_ROUTES = ['/app', '/settings', '/admin']
const PROTECTED_ROUTES = ['/app', '/settings', '/admin', '/konto']
// Routes that should redirect to /app if already logged in
const AUTH_ROUTES = ['/login', '/register']
@@ -95,7 +95,15 @@ export async function middleware(req: NextRequest) {
return new NextResponse(null, { status: 404 })
}
return NextResponse.next()
const res = NextResponse.next()
// Private/authentifizierte Bereiche: nicht indexieren und nicht cachen.
if (PROTECTED_ROUTES.some(r => pathname.startsWith(r))) {
res.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive')
res.headers.set('Cache-Control', 'no-store, max-age=0, must-revalidate')
}
return res
}
export const config = {

14
vitest.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.{test,spec}.{ts,tsx}'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})