Files
Lageplan/src/app/admin/legal/page.tsx
Pepe Ziberi fa1e911ecd feat(legal): Zustimmungs-Nachweise mit IP, Gerät/Browser & Hash (v1.7.8)
Belastbarer Einwilligungsnachweis:
- Schema/Migration: legal_acceptances + ipAddress, userAgent (contentHash bestand bereits)
- Erfassung bei Registrierung und jeder Zustimmung (/api/legal/accept)
- Admin /admin/legal: neue Spalten IP + Gerät/Browser + Hash; CSV-Export um ipAddress,
  userAgent, contentHash erweitert
- Datenschutzerklärung + Dateninventar: IP/User-Agent als dokumentierter Beweiszweck ergänzt

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 17:55:57 +02:00

165 lines
8.6 KiB
TypeScript

'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; ipAddress: string | null; userAgent: string | null; contentHash: 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>
<th className="px-3 py-2">IP</th>
<th className="px-3 py-2">Gerät / Browser</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>{a.contentHash && <><br /><span className="font-mono text-[10px] text-muted-foreground">{a.contentHash.slice(0, 12)}</span></>}</td>
<td className="px-3 py-2 text-xs text-muted-foreground">{a.context}</td>
<td className="px-3 py-2 text-xs text-muted-foreground font-mono whitespace-nowrap">{a.ipAddress || '—'}</td>
<td className="px-3 py-2 text-[10px] text-muted-foreground max-w-[200px] truncate" title={a.userAgent || ''}>{a.userAgent || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
</section>
</div>
</div>
)
}