feat(legal): Organisationsbestätigung, Re-Consent-Gate & Admin-Ansicht (v1.7.2)
Teil 3 des Compliance-Updates — Zustimmungs-UI und Nachweis-Verwaltung: - LegalConsentGate (blockierender Dialog): erkennt offene Pflicht-Zustimmungen nach Login, zeigt Organisationsbestätigung mit freundlicher Erklärung + 7 einzeln anzukreuzenden Pflichtbestätigungen (keine Vorauswahl) für Org-Admins, sonst Re-Consent für aktualisierte Nutzungsbedingungen/Datenschutz. Abmelden/Dokumente-Lesen bleiben möglich (Allowlist); keine stillschweigende Zustimmung. Tastatur-/Screenreader-tauglich (role=dialog) - /konto/datenschutz: eigene Zustimmungen einsehen, Datenexport/Kontolöschung anfordern, Datenschutzkontakt (Route via Middleware geschützt) - /admin/legal (nur SERVER_ADMIN): aktive Dokumentversionen, Zustimmungszahlen, fehlende Zustimmungen, Suche nach Benutzer, CSV-Export (minimale Nachweisdaten) - APIs: /api/legal/my-acceptances, /api/admin/legal, /api/admin/legal/acceptances (+CSV) - Gate ins Root-Layout eingehängt 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.1",
|
"version": "1.7.2",
|
||||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
160
src/app/admin/legal/page.tsx
Normal file
160
src/app/admin/legal/page.tsx
Normal file
@@ -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<Acc[] | null>(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 <div className="min-h-screen flex items-center justify-center"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background px-4 py-8">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<Link href="/admin" className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-6">
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" /> Zur Administration
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||||
|
<FileText className="w-6 h-6 text-red-600" /> Rechtsdokumente & Zustimmungen
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Aktive Benutzer: {data.totalActiveUsers}. Neue Versionen werden bewusst über <code>src/config/legal.ts</code>{' '}
|
||||||
|
und die Migration veröffentlicht (löst Re-Consent aus).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Fehlende Zustimmungen */}
|
||||||
|
{data.missing.some(m => m.missing > 0) && (
|
||||||
|
<div className="mt-4 rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-950/30 dark:border-amber-800 p-3 text-sm text-amber-800 dark:text-amber-300 flex items-start gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<strong>Offene Pflicht-Zustimmungen:</strong>
|
||||||
|
<ul className="mt-1">
|
||||||
|
{data.missing.filter(m => m.missing > 0).map(m => (
|
||||||
|
<li key={`${m.type}@${m.version}`}>{m.type} v{m.version}: {m.missing} Benutzer offen</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dokumente */}
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="font-semibold text-foreground mb-2">Dokumentversionen</h2>
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/40 text-left text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2">Typ</th>
|
||||||
|
<th className="px-3 py-2">Version</th>
|
||||||
|
<th className="px-3 py-2">Veröffentlicht</th>
|
||||||
|
<th className="px-3 py-2">Status</th>
|
||||||
|
<th className="px-3 py-2">Zustimmungen</th>
|
||||||
|
<th className="px-3 py-2">Hash</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.documents.map(d => (
|
||||||
|
<tr key={d.id} className="border-t border-border">
|
||||||
|
<td className="px-3 py-2 font-medium text-foreground">{d.type}</td>
|
||||||
|
<td className="px-3 py-2">{d.version}</td>
|
||||||
|
<td className="px-3 py-2 text-muted-foreground">{new Date(d.publishedAt).toLocaleDateString('de-CH')}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{d.isActive
|
||||||
|
? <span className="rounded-full bg-green-100 text-green-700 dark:bg-green-950/40 dark:text-green-400 px-2 py-0.5 text-xs">aktiv</span>
|
||||||
|
: <span className="rounded-full bg-muted text-muted-foreground px-2 py-0.5 text-xs">inaktiv</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 tabular-nums">{d.acceptanceCount}</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-[10px] text-muted-foreground">{d.contentHash.slice(0, 12)}…</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Suche + Export */}
|
||||||
|
<section className="mt-8">
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-2">
|
||||||
|
<h2 className="font-semibold text-foreground">Zustimmungshistorie</h2>
|
||||||
|
<a
|
||||||
|
href={`/api/admin/legal/acceptances?format=csv&q=${encodeURIComponent(q)}`}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-red-600 hover:underline"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" /> CSV exportieren
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={e => { e.preventDefault(); search() }} className="flex gap-2 mb-3">
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={searching} className="inline-flex items-center gap-1.5 rounded-lg bg-red-600 hover:bg-red-700 text-white px-4 py-2 text-sm font-medium disabled:opacity-50">
|
||||||
|
{searching ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />} Suchen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{results && (
|
||||||
|
results.length === 0
|
||||||
|
? <p className="text-sm text-muted-foreground">Keine Zustimmungen gefunden.</p>
|
||||||
|
: (
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/40 text-left text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2">Zeitpunkt</th>
|
||||||
|
<th className="px-3 py-2">Benutzer</th>
|
||||||
|
<th className="px-3 py-2">Dokument</th>
|
||||||
|
<th className="px-3 py-2">Kontext</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{results.map(a => (
|
||||||
|
<tr key={a.id} className="border-t border-border">
|
||||||
|
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">{new Date(a.acceptedAt).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' })}</td>
|
||||||
|
<td className="px-3 py-2"><span className="text-foreground">{a.name}</span><br /><span className="text-xs text-muted-foreground">{a.email}</span></td>
|
||||||
|
<td className="px-3 py-2">{a.documentType} <span className="text-muted-foreground">v{a.documentVersion}</span></td>
|
||||||
|
<td className="px-3 py-2 text-xs text-muted-foreground">{a.context}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
src/app/api/admin/legal/acceptances/route.ts
Normal file
65
src/app/api/admin/legal/acceptances/route.ts
Normal file
@@ -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=<suchbegriff>&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<string, any>(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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/app/api/admin/legal/route.ts
Normal file
45
src/app/api/admin/legal/route.ts
Normal file
@@ -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<string, number>()
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app/api/legal/my-acceptances/route.ts
Normal file
22
src/app/api/legal/my-acceptances/route.ts
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/app/konto/datenschutz/page.tsx
Normal file
113
src/app/konto/datenschutz/page.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
TERMS: '/nutzungsbedingungen',
|
||||||
|
PRIVACY: '/datenschutz',
|
||||||
|
ORGANIZATION_DECLARATION: '/verantwortungsvolle-nutzung',
|
||||||
|
RESPONSIBLE_USE: '/verantwortungsvolle-nutzung',
|
||||||
|
DATA_PROCESSING_AGREEMENT: '/organisationen/datenschutzvereinbarung',
|
||||||
|
}
|
||||||
|
const DOC_LABELS: Record<string, string> = {
|
||||||
|
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<Acceptance[] | null>(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 (
|
||||||
|
<div className="min-h-screen bg-background px-4 py-10">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<Link href="/app" className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-6">
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" /> Zur App
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||||
|
<ShieldCheck className="w-6 h-6 text-red-600" /> Konto & Datenschutz
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Zustimmungen */}
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="font-semibold text-foreground mb-2">Meine Zustimmungen</h2>
|
||||||
|
{acceptances === null ? (
|
||||||
|
<div className="py-6 flex justify-center"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>
|
||||||
|
) : acceptances.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Noch keine protokollierten Zustimmungen.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||||
|
{acceptances.map((a, i) => (
|
||||||
|
<li key={i} className="flex items-center justify-between gap-3 px-4 py-2.5 text-sm">
|
||||||
|
<div>
|
||||||
|
<Link href={DOC_URLS[a.documentType] || '#'} target="_blank" className="text-foreground font-medium hover:underline">
|
||||||
|
{DOC_LABELS[a.documentType] || a.documentType}
|
||||||
|
</Link>
|
||||||
|
<span className="text-muted-foreground"> · v{a.documentVersion}</span>
|
||||||
|
<div className="text-xs text-muted-foreground">{a.context}</div>
|
||||||
|
</div>
|
||||||
|
<time className="text-xs text-muted-foreground shrink-0">
|
||||||
|
{new Date(a.acceptedAt).toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}
|
||||||
|
</time>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Rechte ausüben */}
|
||||||
|
<section className="mt-8 space-y-3">
|
||||||
|
<h2 className="font-semibold text-foreground">Ihre Rechte ausüben</h2>
|
||||||
|
<a
|
||||||
|
href={`mailto:${legal.privacyContactEmail}?subject=${exportSubject}`}
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 hover:bg-accent text-sm"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span><strong className="text-foreground">Datenexport anfordern</strong><br /><span className="text-muted-foreground text-xs">Wir bestätigen den Eingang Ihrer Anfrage.</span></span>
|
||||||
|
</a>
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 hover:bg-accent text-sm"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span><strong className="text-foreground">Konto/Organisation löschen</strong><br /><span className="text-muted-foreground text-xs">In den Einstellungen; organisationsrelevante Daten werden nach Berechtigungsprüfung entfernt.</span></span>
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={`mailto:${legal.privacyContactEmail}?subject=${deleteSubject}`}
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 hover:bg-accent text-sm"
|
||||||
|
>
|
||||||
|
<Mail className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span><strong className="text-foreground">Datenschutzkontakt</strong><br /><span className="text-muted-foreground text-xs">{legal.privacyContactEmail}</span></span>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="mt-6 text-xs text-muted-foreground">
|
||||||
|
Hinweis: Produktivdaten werden nach Abschluss des Löschvorgangs entfernt. Daten in Sicherungskopien
|
||||||
|
können bis zum Ablauf des Backup-Zyklus fortbestehen. Details in der{' '}
|
||||||
|
<Link href="/datenschutz" className="underline">Datenschutzerklärung</Link>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Toaster } from '@/components/ui/toaster'
|
|||||||
import { AuthProvider } from '@/components/providers/auth-provider'
|
import { AuthProvider } from '@/components/providers/auth-provider'
|
||||||
import { ServiceWorkerRegister } from '@/components/providers/sw-register'
|
import { ServiceWorkerRegister } from '@/components/providers/sw-register'
|
||||||
import { CookieConsent } from '@/components/ui/cookie-consent'
|
import { CookieConsent } from '@/components/ui/cookie-consent'
|
||||||
|
import { LegalConsentGate } from '@/components/legal/legal-consent-gate'
|
||||||
|
|
||||||
const barlow = Barlow({
|
const barlow = Barlow({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
@@ -109,6 +110,7 @@ export default function RootLayout({
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<ServiceWorkerRegister />
|
<ServiceWorkerRegister />
|
||||||
{children}
|
{children}
|
||||||
|
<LegalConsentGate />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<CookieConsent />
|
<CookieConsent />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
196
src/components/legal/legal-consent-gate.tsx
Normal file
196
src/components/legal/legal-consent-gate.tsx
Normal file
@@ -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<PendingDoc[] | null>(null)
|
||||||
|
const [checks, setChecks] = useState<Record<string, boolean>>({})
|
||||||
|
const [orgChecks, setOrgChecks] = useState<boolean[]>(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 (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="legal-gate-title"
|
||||||
|
className="fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-2xl bg-card rounded-xl border border-border shadow-2xl my-8 max-h-[92vh] flex flex-col">
|
||||||
|
<div className="p-6 border-b border-border">
|
||||||
|
<h2 id="legal-gate-title" className="text-xl font-bold text-foreground">
|
||||||
|
{hasOrgDeclaration ? 'Kurz bestätigen — dann geht’s weiter' : 'Aktualisierte Bestimmungen'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4 overflow-y-auto text-sm text-muted-foreground">
|
||||||
|
{hasOrgDeclaration && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="font-semibold text-foreground">Warum diese Bestätigung notwendig ist</h3>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasOrgDeclaration && (
|
||||||
|
<p>
|
||||||
|
Die folgenden Bestimmungen wurden aktualisiert. Bitte bestätige die aktuelle Fassung, um Lageplan
|
||||||
|
weiter zu nutzen. Du kannst dich jederzeit abmelden.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dokument-Links */}
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{pending.map(d => (
|
||||||
|
<a key={d.type} href={d.url || '#'} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-red-600 underline">
|
||||||
|
{d.title} <ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Einfache Zustimmungen (Terms/Privacy/Responsible-Use) */}
|
||||||
|
{simpleDocs.length > 0 && (
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
{simpleDocs.map(d => (
|
||||||
|
<label key={d.type} className="flex items-start gap-2.5 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!checks[d.type]}
|
||||||
|
onChange={e => setChecks(c => ({ ...c, [d.type]: e.target.checked }))}
|
||||||
|
className="mt-0.5 w-4 h-4 rounded"
|
||||||
|
/>
|
||||||
|
<span>Ich habe die <strong className="text-foreground">{d.title}</strong> (Version {d.version}) gelesen und akzeptiere die geltenden Bestimmungen.</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Organisationsbestätigung — 7 einzelne Pflichtbestätigungen */}
|
||||||
|
{hasOrgDeclaration && (
|
||||||
|
<div className="space-y-2 pt-2 border-t border-border">
|
||||||
|
<h3 className="font-semibold text-foreground pt-2">Pflichtbestätigungen</h3>
|
||||||
|
{ORG_CONFIRMATIONS.map((text, i) => (
|
||||||
|
<label key={i} className="flex items-start gap-2.5 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={orgChecks[i]}
|
||||||
|
onChange={e => setOrgChecks(prev => prev.map((v, j) => j === i ? e.target.checked : v))}
|
||||||
|
className="mt-0.5 w-4 h-4 rounded shrink-0"
|
||||||
|
/>
|
||||||
|
<span>{text}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="text-red-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 border-t border-border flex flex-col sm:flex-row gap-2 sm:justify-between items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => logout()}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground underline order-2 sm:order-1"
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className="order-1 sm:order-2 w-full sm:w-auto inline-flex items-center justify-center gap-2 rounded-lg bg-red-600 hover:bg-red-700 text-white px-6 py-2.5 font-semibold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
{hasOrgDeclaration ? 'Bestätigen und fortfahren' : 'Akzeptieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { jwtVerify } from 'jose'
|
|||||||
import { JWT_SECRET } from './lib/jwt-secret'
|
import { JWT_SECRET } from './lib/jwt-secret'
|
||||||
|
|
||||||
// Routes that require authentication
|
// 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
|
// Routes that should redirect to /app if already logged in
|
||||||
const AUTH_ROUTES = ['/login', '/register']
|
const AUTH_ROUTES = ['/login', '/register']
|
||||||
|
|||||||
Reference in New Issue
Block a user