diff --git a/package.json b/package.json index c454196..e4a9a4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lageplan", - "version": "1.7.1", + "version": "1.7.2", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "private": true, "scripts": { diff --git a/src/app/admin/legal/page.tsx b/src/app/admin/legal/page.tsx new file mode 100644 index 0000000..0111981 --- /dev/null +++ b/src/app/admin/legal/page.tsx @@ -0,0 +1,160 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import { ArrowLeft, Download, Search, Loader2, FileText, AlertTriangle } from 'lucide-react' +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 } + +export default function AdminLegalPage() { + const { user, loading, isServerAdmin } = useAuth() + const router = useRouter() + const [data, setData] = useState<{ documents: Doc[]; totalActiveUsers: number; missing: Miss[] } | null>(null) + const [q, setQ] = useState('') + const [results, setResults] = useState(null) + const [searching, setSearching] = useState(false) + + useEffect(() => { + if (loading) return + if (!user || !isServerAdmin()) { router.replace('/app'); return } + fetch('/api/admin/legal').then(r => r.json()).then(setData).catch(() => setData(null)) + }, [loading, user, isServerAdmin, router]) + + const search = useCallback(async () => { + setSearching(true) + try { + const res = await fetch(`/api/admin/legal/acceptances?q=${encodeURIComponent(q)}`) + const d = await res.json() + setResults(d.acceptances || []) + } catch { setResults([]) } finally { setSearching(false) } + }, [q]) + + if (loading || !data) { + return
+ } + + return ( +
+
+ + Zur Administration + + +

+ Rechtsdokumente & Zustimmungen +

+

+ Aktive Benutzer: {data.totalActiveUsers}. Neue Versionen werden bewusst über src/config/legal.ts{' '} + und die Migration veröffentlicht (löst Re-Consent aus). +

+ + {/* Fehlende Zustimmungen */} + {data.missing.some(m => m.missing > 0) && ( +
+ +
+ Offene Pflicht-Zustimmungen: +
    + {data.missing.filter(m => m.missing > 0).map(m => ( +
  • {m.type} v{m.version}: {m.missing} Benutzer offen
  • + ))} +
+
+
+ )} + + {/* Dokumente */} +
+

Dokumentversionen

+
+ + + + + + + + + + + + + {data.documents.map(d => ( + + + + + + + + + ))} + +
TypVersionVeröffentlichtStatusZustimmungenHash
{d.type}{d.version}{new Date(d.publishedAt).toLocaleDateString('de-CH')} + {d.isActive + ? aktiv + : inaktiv} + {d.acceptanceCount}{d.contentHash.slice(0, 12)}…
+
+
+ + {/* Suche + Export */} +
+
+

Zustimmungshistorie

+ + CSV exportieren + +
+
{ e.preventDefault(); search() }} className="flex gap-2 mb-3"> + setQ(e.target.value)} + placeholder="Nach Benutzer (E-Mail/Name) suchen…" + className="flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm" + /> + +
+ + {results && ( + results.length === 0 + ?

Keine Zustimmungen gefunden.

+ : ( +
+ + + + + + + + + + + {results.map(a => ( + + + + + + + ))} + +
ZeitpunktBenutzerDokumentKontext
{new Date(a.acceptedAt).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' })}{a.name}
{a.email}
{a.documentType} v{a.documentVersion}{a.context}
+
+ ) + )} +
+
+
+ ) +} diff --git a/src/app/api/admin/legal/acceptances/route.ts b/src/app/api/admin/legal/acceptances/route.ts new file mode 100644 index 0000000..512f3e1 --- /dev/null +++ b/src/app/api/admin/legal/acceptances/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { getSession } from '@/lib/auth' + +// GET: Zustimmungshistorie suchen (nach Benutzer-E-Mail/Name oder Organisation). +// Query: ?q=&format=json|csv (nur SERVER_ADMIN) +export async function GET(req: NextRequest) { + try { + const user = await getSession() + if (!user || user.role !== 'SERVER_ADMIN') { + return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }) + } + + const q = (req.nextUrl.searchParams.get('q') || '').trim() + const format = req.nextUrl.searchParams.get('format') || 'json' + + // Passende Benutzer ermitteln (falls Suchbegriff gesetzt) + let userFilter: any = {} + if (q) { + const users = await (prisma as any).user.findMany({ + where: { OR: [{ email: { contains: q, mode: 'insensitive' } }, { name: { contains: q, mode: 'insensitive' } }] }, + select: { id: true }, + take: 500, + }) + userFilter = { userId: { in: users.map((u: any) => u.id) } } + } + + const acceptances = await (prisma as any).legalAcceptance.findMany({ + where: userFilter, + orderBy: { acceptedAt: 'desc' }, + take: 2000, + select: { id: true, userId: true, organizationId: true, documentType: true, documentVersion: true, context: true, acceptedAt: true }, + }) + + // Nur die notwendigen Nachweisdaten anreichern (E-Mail/Name) — keine überflüssigen Personendaten. + const userIds = Array.from(new Set(acceptances.map((a: any) => a.userId))) + const users = await (prisma as any).user.findMany({ where: { id: { in: userIds } }, select: { id: true, email: true, name: true } }) + const uMap = new Map(users.map((u: any) => [u.id, u])) + const rows = acceptances.map((a: any) => ({ + ...a, + email: uMap.get(a.userId)?.email || '', + name: uMap.get(a.userId)?.name || '', + })) + + if (format === 'csv') { + const header = ['acceptedAt', 'email', 'name', 'documentType', 'documentVersion', 'context', '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(',')) + } + return new NextResponse(lines.join('\r\n'), { + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="zustimmungen-${new Date().toISOString().slice(0, 10)}.csv"`, + }, + }) + } + + return NextResponse.json({ acceptances: rows }) + } catch (error) { + console.error('Error searching acceptances:', error) + return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) + } +} diff --git a/src/app/api/admin/legal/route.ts b/src/app/api/admin/legal/route.ts new file mode 100644 index 0000000..242b6cb --- /dev/null +++ b/src/app/api/admin/legal/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { getSession } from '@/lib/auth' + +// GET: Übersicht für Systemadministratoren — aktive Dokumente, Zustimmungszahlen je Version, +// fehlende Zustimmungen (aktive Benutzer ohne aktuelle TERMS/PRIVACY-Zustimmung). +export async function GET() { + try { + const user = await getSession() + if (!user || user.role !== 'SERVER_ADMIN') { + return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 }) + } + + const documents = await (prisma as any).legalDocument.findMany({ + orderBy: [{ type: 'asc' }, { publishedAt: 'desc' }], + }) + + // Zustimmungszahlen je (Typ, Version) + const grouped = await (prisma as any).legalAcceptance.groupBy({ + by: ['documentType', 'documentVersion'], + _count: { _all: true }, + }) + const countMap = new Map() + for (const g of grouped) countMap.set(`${g.documentType}@${g.documentVersion}`, g._count._all) + + const withCounts = documents.map((d: any) => ({ + ...d, + acceptanceCount: countMap.get(`${d.type}@${d.version}`) || 0, + })) + + // Fehlende Pflicht-Zustimmungen: aktive Benutzer, die aktive TERMS/PRIVACY nicht akzeptiert haben + const activeRequired = documents.filter((d: any) => d.isActive && (d.type === 'TERMS' || d.type === 'PRIVACY')) + const totalUsers = await (prisma as any).user.count({ where: { isActive: true } }) + const missing: { type: string; version: string; missing: number }[] = [] + for (const doc of activeRequired) { + const accepted = countMap.get(`${doc.type}@${doc.version}`) || 0 + missing.push({ type: doc.type, version: doc.version, missing: Math.max(0, totalUsers - accepted) }) + } + + return NextResponse.json({ documents: withCounts, totalActiveUsers: totalUsers, missing }) + } catch (error) { + console.error('Error fetching admin legal overview:', error) + return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) + } +} diff --git a/src/app/api/legal/my-acceptances/route.ts b/src/app/api/legal/my-acceptances/route.ts new file mode 100644 index 0000000..fba1528 --- /dev/null +++ b/src/app/api/legal/my-acceptances/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { getSession } from '@/lib/auth' + +// GET: die eigenen protokollierten Zustimmungen des angemeldeten Benutzers (revisionssicher, read-only). +export async function GET() { + try { + const user = await getSession() + if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) + + const acceptances = await (prisma as any).legalAcceptance.findMany({ + where: { userId: user.id }, + orderBy: { acceptedAt: 'desc' }, + select: { documentType: true, documentVersion: true, context: true, acceptedAt: true, organizationId: true }, + }) + + return NextResponse.json({ acceptances }) + } catch (error) { + console.error('Error fetching own acceptances:', error) + return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) + } +} diff --git a/src/app/konto/datenschutz/page.tsx b/src/app/konto/datenschutz/page.tsx new file mode 100644 index 0000000..c36125e --- /dev/null +++ b/src/app/konto/datenschutz/page.tsx @@ -0,0 +1,113 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import { ArrowLeft, Download, Trash2, Mail, ShieldCheck, Loader2 } from 'lucide-react' +import { useAuth } from '@/components/providers/auth-provider' +import { legal } from '@/config/legal' + +interface Acceptance { documentType: string; documentVersion: string; context: string; acceptedAt: string; organizationId: string | null } + +const DOC_URLS: Record = { + TERMS: '/nutzungsbedingungen', + PRIVACY: '/datenschutz', + ORGANIZATION_DECLARATION: '/verantwortungsvolle-nutzung', + RESPONSIBLE_USE: '/verantwortungsvolle-nutzung', + DATA_PROCESSING_AGREEMENT: '/organisationen/datenschutzvereinbarung', +} +const DOC_LABELS: Record = { + TERMS: 'Nutzungsbedingungen', + PRIVACY: 'Datenschutzerklärung', + ORGANIZATION_DECLARATION: 'Organisationsbestätigung', + RESPONSIBLE_USE: 'Verantwortungsvolle Nutzung', + DATA_PROCESSING_AGREEMENT: 'Datenschutzvereinbarung (Org.)', +} + +export default function KontoDatenschutzPage() { + const { user, loading } = useAuth() + const router = useRouter() + const [acceptances, setAcceptances] = useState(null) + + useEffect(() => { + if (loading) return + if (!user) { router.replace('/login?redirect=/konto/datenschutz'); return } + fetch('/api/legal/my-acceptances').then(r => r.json()).then(d => setAcceptances(d.acceptances || [])).catch(() => setAcceptances([])) + }, [loading, user, router]) + + const exportSubject = encodeURIComponent('Datenexport-Anfrage') + const deleteSubject = encodeURIComponent('Kontolöschung-Anfrage') + + return ( +
+
+ + Zur App + + +

+ Konto & Datenschutz +

+ + {/* Zustimmungen */} +
+

Meine Zustimmungen

+ {acceptances === null ? ( +
+ ) : acceptances.length === 0 ? ( +

Noch keine protokollierten Zustimmungen.

+ ) : ( +
    + {acceptances.map((a, i) => ( +
  • +
    + + {DOC_LABELS[a.documentType] || a.documentType} + + · v{a.documentVersion} +
    {a.context}
    +
    + +
  • + ))} +
+ )} +
+ + {/* Rechte ausüben */} +
+

Ihre Rechte ausüben

+ + + Datenexport anfordern
Wir bestätigen den Eingang Ihrer Anfrage.
+
+ + + Konto/Organisation löschen
In den Einstellungen; organisationsrelevante Daten werden nach Berechtigungsprüfung entfernt.
+ + + + Datenschutzkontakt
{legal.privacyContactEmail}
+
+
+ +

+ Hinweis: Produktivdaten werden nach Abschluss des Löschvorgangs entfernt. Daten in Sicherungskopien + können bis zum Ablauf des Backup-Zyklus fortbestehen. Details in der{' '} + Datenschutzerklärung. +

+
+
+ ) +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 07df6c6..3bdf3c6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,7 @@ import { Toaster } from '@/components/ui/toaster' import { AuthProvider } from '@/components/providers/auth-provider' import { ServiceWorkerRegister } from '@/components/providers/sw-register' import { CookieConsent } from '@/components/ui/cookie-consent' +import { LegalConsentGate } from '@/components/legal/legal-consent-gate' const barlow = Barlow({ subsets: ['latin'], @@ -109,6 +110,7 @@ export default function RootLayout({ {children} + diff --git a/src/components/legal/legal-consent-gate.tsx b/src/components/legal/legal-consent-gate.tsx new file mode 100644 index 0000000..c747f73 --- /dev/null +++ b/src/components/legal/legal-consent-gate.tsx @@ -0,0 +1,196 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { usePathname } from 'next/navigation' +import { useAuth } from '@/components/providers/auth-provider' +import { Loader2, ExternalLink } from 'lucide-react' + +interface PendingDoc { type: string; version: string; title: string; url: string | null } + +// Pfade, auf denen der blockierende Dialog NICHT erscheint, damit Dokumente gelesen, +// das Konto verwaltet und abgemeldet werden kann (keine erzwungene Zustimmung durch Weiternutzung). +const ALLOWLIST = [ + '/nutzungsbedingungen', '/datenschutz', '/impressum', '/verantwortungsvolle-nutzung', + '/sicherheit', '/unterstuetzen', '/spenden', '/organisationen', '/login', '/register', + '/konto', '/forgot-password', '/reset-password', +] + +const ORG_CONFIRMATIONS = [ + 'Ich bestätige, dass ich berechtigt bin, für diese Organisation ein Administratorkonto einzurichten oder diese Bestätigung abzugeben.', + 'Unsere Organisation versteht, dass Lageplan ein unterstützendes Werkzeug und kein Alarmierungs-, Leitstellen- oder Einsatzleitsystem ist.', + 'Unsere Organisation prüft Lagepläne, Adressen, Symbole, Distanzen, Berechnungen und sonstige Inhalte vor der operativen Verwendung durch fachkundige Personen.', + 'Unsere Organisation hält unabhängig von Lageplan aktuelle Offline-, Notfall- und Rückfallunterlagen bereit.', + 'Unsere Organisation verwendet Lageplan nicht als einzige Informationsquelle für operative Entscheidungen.', + 'Unsere Organisation speichert ohne separate schriftliche Vereinbarung keine Patienten-, Gesundheits-, Polizei-, Passwort-, Zugangs-, Schlüsselcode- oder anderen besonders schützenswerten Daten in Lageplan.', + 'Ich habe die Nutzungsbedingungen und die Datenschutzerklärung gelesen und akzeptiere die für die Nutzung geltenden Bestimmungen.', +] + +export function LegalConsentGate() { + const { user, loading, logout } = useAuth() + const pathname = usePathname() + const [pending, setPending] = useState(null) + const [checks, setChecks] = useState>({}) + const [orgChecks, setOrgChecks] = useState(Array(ORG_CONFIRMATIONS.length).fill(false)) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState('') + + const onAllowlist = ALLOWLIST.some(p => pathname?.startsWith(p)) || pathname === '/' + + const refresh = useCallback(async () => { + try { + const res = await fetch('/api/legal/status') + if (!res.ok) { setPending([]); return } + const data = await res.json() + setPending(data.needsAcceptance ? data.pending : []) + } catch { + setPending([]) + } + }, []) + + useEffect(() => { + if (loading || !user || onAllowlist) { return } + refresh() + }, [loading, user, onAllowlist, refresh]) + + if (loading || !user || onAllowlist || !pending || pending.length === 0) return null + + const hasOrgDeclaration = pending.some(d => d.type === 'ORGANIZATION_DECLARATION') + const simpleDocs = pending.filter(d => d.type !== 'ORGANIZATION_DECLARATION') + + const allSimpleChecked = simpleDocs.every(d => checks[d.type]) + const allOrgChecked = !hasOrgDeclaration || orgChecks.every(Boolean) + const canSubmit = allSimpleChecked && allOrgChecked && !submitting + + const submit = async () => { + setSubmitting(true); setError('') + try { + const types = pending.map(d => d.type) + const context = hasOrgDeclaration ? 'ADMIN_ROLE_ACCEPTANCE' : 'LOGIN_RECONSENT' + const res = await fetch('/api/legal/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ types, context }), + }) + const data = await res.json() + if (!res.ok) { setError(data.error || 'Speichern fehlgeschlagen.'); return } + if (data.needsAcceptance) { setPending(data.pending); return } + setPending([]) + } catch { + setError('Verbindungsfehler.') + } finally { + setSubmitting(false) + } + } + + return ( +
+
+
+ +
+ +
+ {hasOrgDeclaration && ( +
+

Warum diese Bestätigung notwendig ist

+

+ Lageplan wird mit viel Eigenleistung entwickelt und Feuerwehren kostenlos zur Verfügung gestellt. + Damit das Projekt weiterhin verantwortungsvoll angeboten werden kann und es im Einsatz zu keinen + Missverständnissen kommt, müssen Organisationen einige wichtige Grundlagen bestätigen. +

+

+ Lageplan ist ein unterstützendes Werkzeug. Es ersetzt keine Einsatzleitung, keine Leitstelle, + keine behördlich vorgeschriebenen Unterlagen und keine lokalen Offline- oder Notfallpläne. Pläne, + Adressen, Symbole, Distanzen und Berechnungen müssen vor der operativen Verwendung durch + fachkundige Personen geprüft werden. +

+

+ Diese Bestätigung soll nicht die Verantwortung auf einzelne Benutzer abschieben. Sie stellt sicher, + dass Lageplan innerhalb der Organisation korrekt eingeordnet und sicher verwendet wird. Vielen Dank, + dass ihr mithelft, Lageplan langfristig kostenlos und verantwortungsvoll zu betreiben. +

+
+ )} + + {!hasOrgDeclaration && ( +

+ Die folgenden Bestimmungen wurden aktualisiert. Bitte bestätige die aktuelle Fassung, um Lageplan + weiter zu nutzen. Du kannst dich jederzeit abmelden. +

+ )} + + {/* Dokument-Links */} +
+ {pending.map(d => ( + + {d.title} + + ))} +
+ + {/* Einfache Zustimmungen (Terms/Privacy/Responsible-Use) */} + {simpleDocs.length > 0 && ( +
+ {simpleDocs.map(d => ( + + ))} +
+ )} + + {/* Organisationsbestätigung — 7 einzelne Pflichtbestätigungen */} + {hasOrgDeclaration && ( +
+

Pflichtbestätigungen

+ {ORG_CONFIRMATIONS.map((text, i) => ( + + ))} +
+ )} + + {error &&

{error}

} +
+ +
+ + +
+
+
+ ) +} diff --git a/src/middleware.ts b/src/middleware.ts index 94cd40f..8ce5a8f 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,7 +3,7 @@ import { jwtVerify } from 'jose' import { JWT_SECRET } from './lib/jwt-secret' // Routes that require authentication -const PROTECTED_ROUTES = ['/app', '/settings', '/admin'] +const PROTECTED_ROUTES = ['/app', '/settings', '/admin', '/konto'] // Routes that should redirect to /app if already logged in const AUTH_ROUTES = ['/login', '/register']