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:
Pepe Ziberi
2026-07-23 12:07:59 +02:00
parent 8cf1aa4dbe
commit 6ab2fd9a6b
9 changed files with 605 additions and 2 deletions

View File

@@ -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": {

View 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 &amp; 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>
)
}

View 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 })
}
}

View 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 })
}
}

View 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 })
}
}

View 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 &amp; 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>
)
}

View File

@@ -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({
<AuthProvider>
<ServiceWorkerRegister />
{children}
<LegalConsentGate />
<Toaster />
<CookieConsent />
</AuthProvider>

View 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 gehts 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>
)
}

View File

@@ -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']