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:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.7.0",
|
"version": "1.7.1",
|
||||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -151,11 +151,73 @@ async function migrate() {
|
|||||||
"projectId" TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE
|
"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")`,
|
`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) {
|
for (const sql of tableMigrations) {
|
||||||
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ }
|
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 ───
|
// ─── Step 5: Set safe defaults ───
|
||||||
console.log(' [5/7] Setting defaults...')
|
console.log(' [5/7] Setting defaults...')
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -134,10 +134,56 @@ model User {
|
|||||||
iconAssets IconAsset[]
|
iconAssets IconAsset[]
|
||||||
upgradeRequests UpgradeRequest[]
|
upgradeRequests UpgradeRequest[]
|
||||||
rapports Rapport[]
|
rapports Rapport[]
|
||||||
|
legalAcceptances LegalAcceptance[]
|
||||||
|
|
||||||
@@map("users")
|
@@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 {
|
model TenantMembership {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
role Role @default(OPERATOR)
|
role Role @default(OPERATOR)
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ import { sendEmail } from '@/lib/email'
|
|||||||
import { randomBytes } from 'crypto'
|
import { randomBytes } from 'crypto'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { registerLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
import { registerLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
|
||||||
|
import { recordAcceptances } from '@/lib/legal'
|
||||||
|
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
organizationName: z.string().min(2, 'Organisationsname zu kurz').max(200),
|
organizationName: z.string().min(2, 'Organisationsname zu kurz').max(200),
|
||||||
name: z.string().min(2, 'Name zu kurz').max(200),
|
name: z.string().min(2, 'Name zu kurz').max(200),
|
||||||
email: z.string().email('Ungültige E-Mail-Adresse'),
|
email: z.string().email('Ungültige E-Mail-Adresse'),
|
||||||
password: z.string().min(8, 'Passwort muss mindestens 8 Zeichen haben'),
|
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) {
|
export async function POST(req: NextRequest) {
|
||||||
@@ -79,8 +83,8 @@ export async function POST(req: NextRequest) {
|
|||||||
maxUsers: 5,
|
maxUsers: 5,
|
||||||
maxProjects: 10,
|
maxProjects: 10,
|
||||||
contactEmail: data.email,
|
contactEmail: data.email,
|
||||||
privacyAccepted: body.privacyAccepted === true,
|
privacyAccepted: true,
|
||||||
privacyAcceptedAt: body.privacyAccepted ? new Date() : null,
|
privacyAcceptedAt: new Date(),
|
||||||
adminAccessAccepted: body.adminAccessAccepted === true,
|
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
|
// 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'
|
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://')) {
|
if (baseUrl && !baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) {
|
||||||
|
|||||||
51
src/app/api/legal/accept/route.ts
Normal file
51
src/app/api/legal/accept/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/app/api/legal/status/route.ts
Normal file
29
src/app/api/legal/status/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,8 @@ export default function RegisterPage() {
|
|||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [confirmPassword, setConfirmPassword] = 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 [isLoading, setIsLoading] = useState(false)
|
||||||
const [isSuccess, setIsSuccess] = useState(false)
|
const [isSuccess, setIsSuccess] = useState(false)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -40,7 +41,7 @@ export default function RegisterPage() {
|
|||||||
const res = await fetch('/api/auth/register', {
|
const res = await fetch('/api/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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()
|
const data = await res.json()
|
||||||
@@ -188,13 +189,28 @@ export default function RegisterPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Privacy Policy Checkbox */}
|
{/* Zustimmungen — je einzeln aktiv zu setzen, keine Vorauswahl */}
|
||||||
<div className="pt-2 border-t border-border">
|
<div className="pt-2 border-t border-border space-y-2.5">
|
||||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={privacyAccepted}
|
checked={acceptTerms}
|
||||||
onChange={(e) => setPrivacyAccepted(e.target.checked)}
|
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"
|
className="mt-0.5 w-4 h-4 rounded border-gray-300 text-red-600 focus:ring-red-500 cursor-pointer"
|
||||||
disabled={isLoading}
|
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">
|
<Link href="/datenschutz" target="_blank" className="text-red-500 hover:text-red-400 underline font-medium">
|
||||||
Datenschutzerklärung
|
Datenschutzerklärung
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
gelesen und akzeptiere die Nutzungsbedingungen.
|
zur Kenntnis genommen.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +227,7 @@ export default function RegisterPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-red-600 hover:bg-red-700"
|
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 ? (
|
{isLoading ? (
|
||||||
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Wird erstellt...</>
|
<><Loader2 className="w-4 h-4 mr-2 animate-spin" /> Wird erstellt...</>
|
||||||
|
|||||||
110
src/lib/legal.ts
Normal file
110
src/lib/legal.ts
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user