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>
This commit is contained in:
Pepe Ziberi
2026-07-23 12:02:20 +02:00
parent 872b8927cc
commit 8cf1aa4dbe
8 changed files with 343 additions and 11 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "lageplan",
"version": "1.7.0",
"version": "1.7.1",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true,
"scripts": {

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-draft', title: 'Nutzungsbedingungen', url: '/nutzungsbedingungen', publishedAt: '2026-07-23' },
{ type: 'PRIVACY', version: '1.0.0-draft', title: 'Datenschutzerklärung', url: '/datenschutz', publishedAt: '2026-07-23' },
{ type: 'ORGANIZATION_DECLARATION', version: '1.0.0-draft', title: 'Organisationsbestätigung', url: '/verantwortungsvolle-nutzung', publishedAt: '2026-07-23' },
{ type: 'RESPONSIBLE_USE', version: '1.0.0-draft', title: 'Verantwortungsvolle Nutzung', url: '/verantwortungsvolle-nutzung', publishedAt: '2026-07-23' },
{ type: 'DATA_PROCESSING_AGREEMENT', version: '1.0.0-draft', 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

@@ -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

@@ -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,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

@@ -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...</>

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

@@ -0,0 +1,110 @@
import { prisma } from '@/lib/db'
/** Dokumenttypen (mit Prisma-String-Feldern konsistent). */
export type LegalDocumentType =
| 'TERMS'
| 'PRIVACY'
| 'ORGANIZATION_DECLARATION'
| 'RESPONSIBLE_USE'
| 'DATA_PROCESSING_AGREEMENT'
/** Zustimmungskontexte. */
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']
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 = new Set<string>([
...REQUIRED_FOR_ALL,
...(isOrgAdmin ? REQUIRED_FOR_ORG_ADMIN : []),
])
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(accepted.map((a: any) => `${a.documentType}@${a.documentVersion}`))
return active.filter(d => !acceptedKeys.has(`${d.type}@${d.version}`))
}
/** 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
}