diff --git a/docs/legal/data-processing-inventory.md b/docs/legal/data-processing-inventory.md index 46c55c9..d5c9671 100644 --- a/docs/legal/data-processing-inventory.md +++ b/docs/legal/data-processing-inventory.md @@ -9,7 +9,7 @@ Abgeleitet aus dem tatsächlichen Code-Stand. Offene Fristen/Standorte als Platz | Passwort (bcrypt-Hash) | Authentifizierung | Benutzer | Eingabe | PostgreSQL | — | `[SERVERSTANDORT]` | bis Kontolöschung | mit Konto | Einweg-Hash (bcrypt cost 12) | | 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) | +| Zustimmungsnachweise (Doc-Typ/Version/Zeitpunkt/Kontext, IP, User-Agent, Hash) | Nachweis Einwilligung/Vertrag (Beweiszweck) | Benutzer | System | PostgreSQL (Schweiz, Eigenbetrieb) | — | nein | Aufbewahrung zu Nachweiszwecken `[FRIST]` | revisionssicher, nicht überschrieben | Zugriffskontrolle; IP/User-Agent bewusst zu Beweiszwecken erfasst | | Protokoll-/Sicherheitsdaten | Betrieb, Sicherheit | Benutzer | System | Server-Logs | — | `[SERVERSTANDORT]` | `[FRIST]` | rotierend | Minimierung sensibler Inhalte | | Session-Cookie `auth-token` | Anmeldung | Benutzer | System | Client-Cookie | — | — | 24 h / 14 Tage | Ablauf/Logout | httpOnly, secure (prod), sameSite=lax | | E-Mail-Versand (Verifizierung, Reset, Kontakt, Einladung) | Kommunikation | Benutzer/Empfänger | System/Eingabe | SMTP-Anbieter | E-Mail-Anbieter | `[ABHÄNGIG]` | beim Anbieter | — | TLS, nur bei Bedarf | diff --git a/package.json b/package.json index a0a0d59..0e1ed82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lageplan", - "version": "1.7.7", + "version": "1.7.8", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "private": true, "scripts": { diff --git a/prisma/migrate.js b/prisma/migrate.js index 4ed86e8..3298549 100644 --- a/prisma/migrate.js +++ b/prisma/migrate.js @@ -183,6 +183,8 @@ async function migrate() { )`, `CREATE INDEX IF NOT EXISTS legal_acceptances_user_idx ON legal_acceptances("userId")`, `CREATE INDEX IF NOT EXISTS legal_acceptances_doc_idx ON legal_acceptances("documentType", "documentVersion")`, + `ALTER TABLE legal_acceptances ADD COLUMN IF NOT EXISTS "ipAddress" TEXT`, + `ALTER TABLE legal_acceptances ADD COLUMN IF NOT EXISTS "userAgent" TEXT`, ] for (const sql of tableMigrations) { try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c4109be..ea5fe8f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -173,6 +173,9 @@ model LegalAcceptance { locale String? // REGISTRATION | LOGIN_RECONSENT | ORGANIZATION_CREATION | ADMIN_ROLE_ACCEPTANCE | DONATION context String + // Nachweisdaten (zu Beweiszwecken der Einwilligung): IP + Gerät/Browser zum Zeitpunkt der Zustimmung. + ipAddress String? + userAgent String? createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/app/admin/legal/page.tsx b/src/app/admin/legal/page.tsx index 0111981..c61daf3 100644 --- a/src/app/admin/legal/page.tsx +++ b/src/app/admin/legal/page.tsx @@ -8,7 +8,7 @@ import { useAuth } from '@/components/providers/auth-provider' interface Doc { id: string; type: string; version: string; title: string; url: string | null; isActive: boolean; publishedAt: string; contentHash: string; acceptanceCount: number } interface Miss { type: string; version: string; missing: number } -interface Acc { id: string; email: string; name: string; documentType: string; documentVersion: string; context: string; acceptedAt: string; organizationId: string | null } +interface Acc { id: string; email: string; name: string; documentType: string; documentVersion: string; context: string; acceptedAt: string; organizationId: string | null; ipAddress: string | null; userAgent: string | null; contentHash: string | null } export default function AdminLegalPage() { const { user, loading, isServerAdmin } = useAuth() @@ -137,6 +137,8 @@ export default function AdminLegalPage() { Benutzer Dokument Kontext + IP + Gerät / Browser @@ -144,8 +146,10 @@ export default function AdminLegalPage() { {new Date(a.acceptedAt).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' })} {a.name}
{a.email} - {a.documentType} v{a.documentVersion} + {a.documentType} v{a.documentVersion}{a.contentHash && <>
{a.contentHash.slice(0, 12)}…} {a.context} + {a.ipAddress || '—'} + {a.userAgent || '—'} ))} diff --git a/src/app/api/admin/legal/acceptances/route.ts b/src/app/api/admin/legal/acceptances/route.ts index 512f3e1..d479100 100644 --- a/src/app/api/admin/legal/acceptances/route.ts +++ b/src/app/api/admin/legal/acceptances/route.ts @@ -29,7 +29,7 @@ export async function GET(req: NextRequest) { where: userFilter, orderBy: { acceptedAt: 'desc' }, take: 2000, - select: { id: true, userId: true, organizationId: true, documentType: true, documentVersion: true, context: true, acceptedAt: true }, + select: { id: true, userId: true, organizationId: true, documentType: true, documentVersion: true, context: true, acceptedAt: true, contentHash: true, ipAddress: true, userAgent: true }, }) // Nur die notwendigen Nachweisdaten anreichern (E-Mail/Name) — keine überflüssigen Personendaten. @@ -43,11 +43,11 @@ export async function GET(req: NextRequest) { })) if (format === 'csv') { - const header = ['acceptedAt', 'email', 'name', 'documentType', 'documentVersion', 'context', 'organizationId'] + const header = ['acceptedAt', 'email', 'name', 'documentType', 'documentVersion', 'context', 'ipAddress', 'userAgent', 'contentHash', 'organizationId'] const esc = (v: any) => `"${String(v ?? '').replace(/"/g, '""')}"` const 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(',')) + lines.push([new Date(r.acceptedAt).toISOString(), r.email, r.name, r.documentType, r.documentVersion, r.context, r.ipAddress || '', r.userAgent || '', r.contentHash || '', r.organizationId || ''].map(esc).join(',')) } return new NextResponse(lines.join('\r\n'), { headers: { diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 0925b2d..d56ae91 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -119,6 +119,8 @@ export async function POST(req: NextRequest) { context: 'REGISTRATION', types: ['TERMS', 'PRIVACY'], locale: 'de-CH', + ipAddress: getClientIp(req), + userAgent: req.headers.get('user-agent'), }) } catch (e) { console.warn('[register] Zustimmungsprotokollierung fehlgeschlagen (nicht blockierend):', e) diff --git a/src/app/api/legal/accept/route.ts b/src/app/api/legal/accept/route.ts index 41d2ffb..94739f1 100644 --- a/src/app/api/legal/accept/route.ts +++ b/src/app/api/legal/accept/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { getClientIp } from '@/lib/rate-limit' import { recordAcceptances, getPendingAcceptances, type LegalAcceptanceContext, type LegalDocumentType } from '@/lib/legal' const VALID_TYPES: LegalDocumentType[] = ['TERMS', 'PRIVACY', 'ORGANIZATION_DECLARATION', 'RESPONSIBLE_USE', 'DATA_PROCESSING_AGREEMENT'] @@ -38,6 +39,8 @@ export async function POST(req: NextRequest) { context, types, locale: 'de-CH', + ipAddress: getClientIp(req), + userAgent: req.headers.get('user-agent'), }) const isOrgAdmin = user.role === 'TENANT_ADMIN' diff --git a/src/app/datenschutz/page.tsx b/src/app/datenschutz/page.tsx index fcfaa54..432226f 100644 --- a/src/app/datenschutz/page.tsx +++ b/src/app/datenschutz/page.tsx @@ -35,7 +35,7 @@ export default function DatenschutzPage() {
  • Registrierungs-/Kontodaten: Name, E-Mail-Adresse, Organisationsname, Rolle. Das Passwort wird mit einem geeigneten Einwegverfahren (bcrypt) gehasht und nie im Klartext gespeichert.
  • Einsatz-/Inhaltsdaten: Lagepläne, Journaleinträge, Zeichnungen, Koordinaten, Symbole, hochgeladene Dateien (Logos, Planbilder, Symbole) sowie zugehörige Projekt- und Organisationsdaten.
  • Protokoll-/Sicherheitsdaten: technische Zugriffs- und Fehlerprotokolle (z.B. Zeitpunkt, ungefähre technische Angaben) zur Gewährleistung von Betrieb und Sicherheit.
  • -
  • Zustimmungsnachweise: welche Version der Nutzungsbedingungen/Datenschutzerklärung/Organisationsbestätigung wann akzeptiert wurde.
  • +
  • Zustimmungsnachweise: welche Version der Nutzungsbedingungen/Datenschutzerklärung/Organisationsbestätigung wann akzeptiert wurde — zu Beweiszwecken zusammen mit IP-Adresse und Gerät/Browser (User-Agent) sowie dem Dokument-Prüfwert (Hash).
  • Unterstützungsbeiträge (optional): bei freiwilligen Beiträgen der Betrag sowie freiwillig angegebener Name/Nachricht (Zahlungsabwicklung siehe Ziffer 9).
  • diff --git a/src/lib/legal.ts b/src/lib/legal.ts index 23a2900..1ac335f 100644 --- a/src/lib/legal.ts +++ b/src/lib/legal.ts @@ -67,6 +67,9 @@ interface RecordInput { locale?: string | null /** Dokumenttypen, die zugestimmt werden. Es wird jeweils die AKTIVE Version protokolliert. */ types: LegalDocumentType[] + /** Nachweisdaten (Beweiszweck): IP + Gerät/Browser zum Zeitpunkt der Zustimmung. */ + ipAddress?: string | null + userAgent?: string | null } /** @@ -88,6 +91,8 @@ export async function recordAcceptances(input: RecordInput): Promise { contentHash: d.contentHash, context: input.context, locale: input.locale ?? 'de-CH', + ipAddress: input.ipAddress ?? null, + userAgent: input.userAgent ? String(input.userAgent).slice(0, 400) : null, })) if (rows.length === 0) return 0 const res = await (prisma as any).legalAcceptance.createMany({ data: rows, skipDuplicates: true })