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

@@ -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://')) {