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