feat(legal): Zustimmungs-Nachweise mit IP, Gerät/Browser & Hash (v1.7.8)
Belastbarer Einwilligungsnachweis: - Schema/Migration: legal_acceptances + ipAddress, userAgent (contentHash bestand bereits) - Erfassung bei Registrierung und jeder Zustimmung (/api/legal/accept) - Admin /admin/legal: neue Spalten IP + Gerät/Browser + Hash; CSV-Export um ipAddress, userAgent, contentHash erweitert - Datenschutzerklärung + Dateninventar: IP/User-Agent als dokumentierter Beweiszweck ergänzt Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
<th className="px-3 py-2">Benutzer</th>
|
||||
<th className="px-3 py-2">Dokument</th>
|
||||
<th className="px-3 py-2">Kontext</th>
|
||||
<th className="px-3 py-2">IP</th>
|
||||
<th className="px-3 py-2">Gerät / Browser</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -144,8 +146,10 @@ export default function AdminLegalPage() {
|
||||
<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">{a.documentType} <span className="text-muted-foreground">v{a.documentVersion}</span>{a.contentHash && <><br /><span className="font-mono text-[10px] text-muted-foreground">{a.contentHash.slice(0, 12)}…</span></>}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{a.context}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground font-mono whitespace-nowrap">{a.ipAddress || '—'}</td>
|
||||
<td className="px-3 py-2 text-[10px] text-muted-foreground max-w-[200px] truncate" title={a.userAgent || ''}>{a.userAgent || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function DatenschutzPage() {
|
||||
<li><strong>Registrierungs-/Kontodaten:</strong> Name, E-Mail-Adresse, Organisationsname, Rolle. Das Passwort wird mit einem geeigneten Einwegverfahren (bcrypt) gehasht und nie im Klartext gespeichert.</li>
|
||||
<li><strong>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>Zustimmungsnachweise:</strong> welche Version der Nutzungsbedingungen/Datenschutzerklärung/Organisationsbestätigung wann akzeptiert wurde — zu Beweiszwecken zusammen mit IP-Adresse und Gerät/Browser (User-Agent) sowie dem Dokument-Prüfwert (Hash).</li>
|
||||
<li><strong>Unterstützungsbeiträge (optional):</strong> bei freiwilligen Beiträgen der Betrag sowie freiwillig angegebener Name/Nachricht (Zahlungsabwicklung siehe Ziffer 9).</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -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<number> {
|
||||
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 })
|
||||
|
||||
Reference in New Issue
Block a user