diff --git a/package.json b/package.json index f2cad5c..c454196 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/prisma/migrate.js b/prisma/migrate.js index ccba9ef..125ac7c 100644 --- a/prisma/migrate.js +++ b/prisma/migrate.js @@ -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 { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 669e9c9..c4109be 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index a3a366f..0925b2d 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -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://')) { diff --git a/src/app/api/legal/accept/route.ts b/src/app/api/legal/accept/route.ts new file mode 100644 index 0000000..41d2ffb --- /dev/null +++ b/src/app/api/legal/accept/route.ts @@ -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 }) + } +} diff --git a/src/app/api/legal/status/route.ts b/src/app/api/legal/status/route.ts new file mode 100644 index 0000000..0bad9b6 --- /dev/null +++ b/src/app/api/legal/status/route.ts @@ -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 }) + } +} diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index e090186..d0ad926 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -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() { /> - {/* Privacy Policy Checkbox */} -
+ {/* Zustimmungen — je einzeln aktiv zu setzen, keine Vorauswahl */} +
+
@@ -211,7 +227,7 @@ export default function RegisterPage() {