feat(share): Nur-Ansicht-Teilen-Link (live, optional PIN) + QR (v1.6.9)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 31m39s

Neuer öffentlicher Teilen-Link für die aktuelle Lage — ohne Login nur zum
Ansehen, live aktualisiert. Optional mit 4–8-stelliger PIN geschützt.

- DB: Project.shareToken (unique) / sharePin (bcrypt) / shareEnabled + Migration
- API: POST/GET /api/projects/[id]/share (Verwaltung, Mandanten-Check, VIEWER blockiert),
  GET/POST /api/share/[token] (öffentlich, PIN-Prüfung, Rate-Limit gegen Brute-Force)
- Öffentliche Seite /view/[token]: PIN-Gate, read-only MapView, Socket-Live + 30s-Poll
- Teilen-Dialog im Werkzeuge-/Mobil-Menü: Link kopieren, QR-Code, PIN setzen/entfernen,
  Teilen beenden (widerruft den Link)
- Middleware: /api/share/ öffentlich freigeschaltet

Hinweis: hochgeladene Custom-Symbole zeigen im anonymen Viewer den Platzhalter
(Icon-Endpunkte bleiben auth-geschützt); eingebaute taktische Zeichen erscheinen normal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pepe Ziberi
2026-07-22 23:30:47 +02:00
parent eb5da2f0c3
commit 6aa70e1504
10 changed files with 630 additions and 1 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "lageplan",
"version": "1.6.8",
"version": "1.6.9",
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
"private": true,
"scripts": {

View File

@@ -86,6 +86,11 @@ async function migrate() {
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "modulesConfig" JSONB`,
// Windrichtung fürs Lagebild
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "windDirection" INTEGER`,
// Öffentlicher Nur-Ansicht-Teilen-Link (live, optional PIN)
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "shareToken" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "sharePin" TEXT`,
`ALTER TABLE projects ADD COLUMN IF NOT EXISTS "shareEnabled" BOOLEAN NOT NULL DEFAULT false`,
`CREATE UNIQUE INDEX IF NOT EXISTS "projects_shareToken_key" ON projects ("shareToken")`,
]
let added = 0
for (const sql of columnMigrations) {

View File

@@ -175,6 +175,12 @@ model Project {
// neuere Änderungen stillschweigend überschreibt.
featuresVersion Int @default(0)
// Öffentlicher Nur-Ansicht-Teilen-Link (live). shareToken = zufälliger Slug im Link,
// sharePin = bcrypt-Hash einer optionalen PIN (NULL = keine PIN), shareEnabled = aktiv/aus.
shareToken String? @unique
sharePin String?
shareEnabled Boolean @default(false)
// Live editing lock (session-based for same-account multi-device)
editingById String?
editingUserName String?

View File

@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from 'next/server'
import { randomBytes } from 'crypto'
import bcrypt from 'bcryptjs'
import { prisma } from '@/lib/db'
import { getSession } from '@/lib/auth'
import { getProjectWithTenantCheck } from '@/lib/tenant'
function shareUrl(req: NextRequest, token: string) {
const baseUrl = process.env.NEXTAUTH_URL || req.headers.get('origin') ||
`${req.headers.get('x-forwarded-proto') || 'https'}://${req.headers.get('host')}` || ''
return `${baseUrl}/view/${token}`
}
// GET: aktuellen Teilen-Status abrufen
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
const project = await getProjectWithTenantCheck(id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
return NextResponse.json({
enabled: !!project.shareEnabled && !!project.shareToken,
url: project.shareToken ? shareUrl(req, project.shareToken) : null,
hasPin: !!project.sharePin,
})
} catch (error) {
console.error('Error fetching share status:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
// POST: Teilen aktivieren/aktualisieren/deaktivieren
// body: { enabled: boolean, pin?: string | null }
// pin === undefined → PIN unverändert lassen
// pin === null oder '' → PIN entfernen
// pin === "1234" → PIN setzen (48 Ziffern)
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const user = await getSession()
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
const project = await getProjectWithTenantCheck(id, user)
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
const body = await req.json().catch(() => ({}))
const enabled = !!body.enabled
if (!enabled) {
// Deaktivieren = Link widerrufen (Token + PIN löschen, alter Link wird ungültig)
await (prisma as any).project.update({
where: { id },
data: { shareEnabled: false, shareToken: null, sharePin: null },
})
return NextResponse.json({ enabled: false, url: null, hasPin: false })
}
const data: any = { shareEnabled: true }
// Token erzeugen, falls noch keiner existiert
let token = project.shareToken as string | null
if (!token) {
token = randomBytes(9).toString('base64url') // ~12 Zeichen, URL-sicher
data.shareToken = token
}
// PIN-Handling
if (body.pin !== undefined) {
const pin = body.pin === null ? '' : String(body.pin).trim()
if (pin === '') {
data.sharePin = null
} else {
if (!/^\d{4,8}$/.test(pin)) {
return NextResponse.json({ error: 'PIN muss 48 Ziffern haben' }, { status: 400 })
}
data.sharePin = await bcrypt.hash(pin, 10)
}
}
await (prisma as any).project.update({ where: { id }, data })
const hasPin = body.pin !== undefined
? (body.pin !== null && String(body.pin).trim() !== '')
: !!project.sharePin
return NextResponse.json({ enabled: true, url: shareUrl(req, token), hasPin })
} catch (error) {
console.error('Error updating share status:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import { prisma } from '@/lib/db'
import { sharePinLimiter, getClientIp, rateLimitResponse } from '@/lib/rate-limit'
// Öffentliche Nur-Ansicht-Route für einen geteilten Einsatz.
// Kein Login nötig. Bei gesetzter PIN werden Kartendaten erst nach korrekter PIN geliefert.
async function loadShared(token: string) {
if (!token || token.length > 64) return null
const project = await (prisma as any).project.findUnique({
where: { shareToken: token },
select: {
id: true, title: true, location: true, einsatzNr: true, mode: true,
mapCenter: true, mapZoom: true, windDirection: true,
shareEnabled: true, sharePin: true,
},
})
if (!project || !project.shareEnabled) return null
return project
}
// GET: Meta-Infos (Titel + ob PIN nötig) — ohne Kartendaten
export async function GET(_req: NextRequest, { params }: { params: Promise<{ token: string }> }) {
try {
const { token } = await params
const project = await loadShared(token)
if (!project) return NextResponse.json({ error: 'Link ungültig oder deaktiviert' }, { status: 404 })
return NextResponse.json({
ok: true,
requiresPin: !!project.sharePin,
title: project.title,
einsatzNr: project.einsatzNr,
location: project.location,
})
} catch (error) {
console.error('Error resolving share link:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}
// POST: liefert den Live-Snapshot (Projekt-Kern + Features), nach PIN-Prüfung
// body: { pin?: string }
export async function POST(req: NextRequest, { params }: { params: Promise<{ token: string }> }) {
try {
const { token } = await params
const project = await loadShared(token)
if (!project) return NextResponse.json({ error: 'Link ungültig oder deaktiviert' }, { status: 404 })
if (project.sharePin) {
// Brute-Force-Schutz nur, wenn eine PIN gesetzt ist
const rl = sharePinLimiter.check(getClientIp(req))
if (!rl.success) return rateLimitResponse(rl.resetAt)
const body = await req.json().catch(() => ({}))
const pin = String(body.pin || '').trim()
if (!pin || !(await bcrypt.compare(pin, project.sharePin))) {
return NextResponse.json({ error: 'Falsche PIN', requiresPin: true }, { status: 401 })
}
}
const features = await (prisma as any).feature.findMany({
where: { projectId: project.id },
select: { id: true, type: true, geometry: true, properties: true },
})
return NextResponse.json({
ok: true,
projectId: project.id, // für Live-Updates (Socket-Raum)
project: {
id: project.id,
title: project.title,
location: project.location,
einsatzNr: project.einsatzNr,
mode: project.mode,
mapCenter: project.mapCenter,
mapZoom: project.mapZoom,
windDirection: project.windDirection,
},
features,
})
} catch (error) {
console.error('Error serving shared snapshot:', error)
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
}
}

View File

@@ -0,0 +1,231 @@
'use client'
import { useEffect, useRef, useState, useCallback } from 'react'
import { useParams } from 'next/navigation'
import dynamic from 'next/dynamic'
import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
import { Eye, Lock, Loader2, MapPin, Wind } from 'lucide-react'
import { getSocket, setSocketRoom } from '@/lib/socket'
import { degToCompass } from '@/components/map/map-compass'
import type { DrawFeature, Project } from '@/types'
// MapLibre nur clientseitig laden (kein SSR)
const MapView = dynamic(() => import('@/components/map/map-view').then(m => m.MapView), { ssr: false })
type Phase = 'loading' | 'pin' | 'ready' | 'error'
interface ShareMeta {
title: string
einsatzNr?: string | null
location?: string | null
}
export default function SharedViewPage() {
const params = useParams()
const token = String(params?.token || '')
const [phase, setPhase] = useState<Phase>('loading')
const [meta, setMeta] = useState<ShareMeta | null>(null)
const [project, setProject] = useState<Project | null>(null)
const [features, setFeatures] = useState<DrawFeature[]>([])
const [pin, setPin] = useState('')
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
const projectIdRef = useRef<string | null>(null)
const pinRef = useRef('')
// Snapshot laden (mit optionaler PIN)
const loadSnapshot = useCallback(async (pinValue?: string) => {
setSubmitting(true)
setError('')
try {
const res = await fetch(`/api/share/${token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin: pinValue || undefined }),
})
const data = await res.json()
if (res.status === 401) {
setPhase('pin')
setError(pinValue ? 'Falsche PIN — bitte erneut versuchen.' : '')
return false
}
if (!res.ok) {
setError(data.error || 'Link ungültig.')
setPhase('error')
return false
}
projectIdRef.current = data.projectId
pinRef.current = pinValue || ''
setProject(data.project as Project)
setFeatures(data.features || [])
setPhase('ready')
return true
} catch {
setError('Verbindungsfehler.')
setPhase('error')
return false
} finally {
setSubmitting(false)
}
}, [token])
// Beim Laden: Meta holen, dann ggf. direkt Snapshot (ohne PIN) laden
useEffect(() => {
if (!token) return
let cancelled = false
;(async () => {
try {
const res = await fetch(`/api/share/${token}`)
const data = await res.json()
if (cancelled) return
if (!res.ok) { setError(data.error || 'Link ungültig oder deaktiviert.'); setPhase('error'); return }
setMeta({ title: data.title, einsatzNr: data.einsatzNr, location: data.location })
if (data.requiresPin) {
setPhase('pin')
} else {
await loadSnapshot()
}
} catch {
if (!cancelled) { setError('Verbindungsfehler.'); setPhase('error') }
}
})()
return () => { cancelled = true }
}, [token, loadSnapshot])
// Live-Updates: Socket-Raum beitreten + auf Feature-Änderungen hören.
// Zusätzlich alle 30s als Fallback neu laden (falls ein Socket-Event verloren geht).
useEffect(() => {
if (phase !== 'ready' || !projectIdRef.current) return
const pid = projectIdRef.current
const socket = getSocket()
setSocketRoom(pid)
socket.emit('join-project', pid)
const onFeatures = (data: { features?: DrawFeature[] }) => {
if (Array.isArray(data?.features)) setFeatures(data.features)
}
socket.on('features-changed', onFeatures)
const poll = setInterval(async () => {
try {
const res = await fetch(`/api/share/${token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin: pinRef.current || undefined }),
})
if (res.ok) {
const data = await res.json()
if (Array.isArray(data.features)) setFeatures(data.features)
}
} catch { /* offline — beim nächsten Tick erneut */ }
}, 30000)
return () => {
socket.off('features-changed', onFeatures)
socket.emit('leave-project', pid)
setSocketRoom(null)
clearInterval(poll)
}
}, [phase, token])
// ─── PIN-Eingabe ───
if (phase === 'pin') {
return (
<div className="min-h-screen flex items-center justify-center bg-muted/30 p-4">
<form
onSubmit={(e) => { e.preventDefault(); if (pin.length >= 4) loadSnapshot(pin) }}
className="w-full max-w-sm bg-card border border-border rounded-xl shadow-lg p-6 space-y-4"
>
<div className="flex items-center gap-2 text-lg font-bold">
<Lock className="w-5 h-5 text-primary" /> Geschützte Ansicht
</div>
{meta?.title && <p className="text-sm text-muted-foreground">{meta.einsatzNr ? `${meta.einsatzNr} · ` : ''}{meta.title}</p>}
<p className="text-sm">Bitte PIN eingeben, um die Lage anzusehen.</p>
<input
type="text"
inputMode="numeric"
autoFocus
value={pin}
onChange={(e) => setPin(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder="PIN"
className="w-full text-center text-2xl tracking-[0.4em] font-mono border border-border rounded-lg py-2.5 bg-background"
/>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={pin.length < 4 || submitting}
className="w-full bg-primary text-primary-foreground rounded-lg py-2.5 font-semibold disabled:opacity-50 flex items-center justify-center gap-2"
>
{submitting && <Loader2 className="w-4 h-4 animate-spin" />} Ansehen
</button>
</form>
</div>
)
}
// ─── Fehler ───
if (phase === 'error') {
return (
<div className="min-h-screen flex items-center justify-center bg-muted/30 p-4">
<div className="w-full max-w-sm bg-card border border-border rounded-xl shadow-lg p-6 text-center space-y-2">
<div className="text-lg font-bold">Nicht verfügbar</div>
<p className="text-sm text-muted-foreground">{error || 'Dieser Teilen-Link ist ungültig oder wurde deaktiviert.'}</p>
</div>
</div>
)
}
// ─── Laden ───
if (phase === 'loading' || !project) {
return (
<div className="min-h-screen flex items-center justify-center bg-muted/30">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
)
}
// ─── Karte (read-only) ───
return (
<div className="h-screen w-screen flex flex-col">
{/* Schlanke Kopfzeile für Ansichts-Modus */}
<header className="h-12 shrink-0 border-b border-border bg-card flex items-center gap-3 px-3 md:px-4">
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-600 text-white text-xs font-semibold px-2.5 py-1">
<Eye className="w-3.5 h-3.5" /> Nur Ansicht · live
</span>
<div className="flex items-center gap-2 min-w-0">
<span className="font-semibold text-sm truncate">
{(project as any).einsatzNr ? `${(project as any).einsatzNr} · ` : ''}{(project as any).title}
</span>
{(project as any).location && (
<span className="hidden sm:inline-flex items-center gap-1 text-xs text-muted-foreground truncate">
<MapPin className="w-3.5 h-3.5" /> {(project as any).location}
</span>
)}
</div>
{typeof project.windDirection === 'number' && (
<span className="ml-auto hidden sm:inline-flex items-center gap-1 rounded-full bg-blue-50 dark:bg-blue-950/40 text-blue-700 dark:text-blue-300 text-xs font-medium px-2 py-1">
<Wind className="w-3.5 h-3.5" /> Wind aus {degToCompass(project.windDirection)}
</span>
)}
</header>
<div className="flex-1 relative overflow-hidden">
<DndProvider backend={HTML5Backend}>
<MapView
project={project}
features={features}
drawMode="select"
selectedColor="#ef4444"
selectedWidth={4}
onFeaturesChange={() => {}}
onSymbolDrop={() => {}}
onTextPlace={() => {}}
canEdit={false}
/>
</DndProvider>
</div>
</div>
)
}

View File

@@ -0,0 +1,186 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import QRCode from 'qrcode'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { Loader2, Copy, Check, Eye, Link2, Trash2 } from 'lucide-react'
interface ShareDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
projectId: string | null
projectTitle?: string
}
export function ShareDialog({ open, onOpenChange, projectId, projectTitle }: ShareDialogProps) {
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [enabled, setEnabled] = useState(false)
const [url, setUrl] = useState<string | null>(null)
const [hasPin, setHasPin] = useState(false)
const [usePin, setUsePin] = useState(false)
const [pinInput, setPinInput] = useState('')
const [copied, setCopied] = useState(false)
const [qr, setQr] = useState<string>('')
const [error, setError] = useState('')
// Status beim Öffnen laden
useEffect(() => {
if (!open || !projectId) return
setLoading(true)
setError('')
setPinInput('')
setCopied(false)
fetch(`/api/projects/${projectId}/share`)
.then(r => r.json())
.then(d => {
setEnabled(!!d.enabled)
setUrl(d.url || null)
setHasPin(!!d.hasPin)
setUsePin(!!d.hasPin)
})
.catch(() => setError('Konnte Teilen-Status nicht laden.'))
.finally(() => setLoading(false))
}, [open, projectId])
// QR-Code erzeugen, wenn ein Link existiert
useEffect(() => {
if (url) {
QRCode.toDataURL(url, { width: 220, margin: 1 }).then(setQr).catch(() => setQr(''))
} else {
setQr('')
}
}, [url])
const post = useCallback(async (body: any) => {
if (!projectId) return
setSaving(true)
setError('')
try {
const res = await fetch(`/api/projects/${projectId}/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const d = await res.json()
if (!res.ok) { setError(d.error || 'Fehler beim Speichern.'); return }
setEnabled(!!d.enabled)
setUrl(d.url || null)
setHasPin(!!d.hasPin)
} catch {
setError('Verbindungsfehler.')
} finally {
setSaving(false)
}
}, [projectId])
const handleEnable = () => post({ enabled: true })
const handleDisable = () => { setUsePin(false); post({ enabled: false }) }
const handleSavePin = () => {
if (usePin) {
if (!/^\d{4,8}$/.test(pinInput)) { setError('PIN muss 48 Ziffern haben.'); return }
post({ enabled: true, pin: pinInput }).then(() => setPinInput(''))
} else {
post({ enabled: true, pin: null })
}
}
const handleCopy = async () => {
if (!url) return
try {
await navigator.clipboard.writeText(url)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch { /* Clipboard evtl. blockiert */ }
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Link2 className="w-5 h-5" /> Lage teilen (Nur-Ansicht)
</DialogTitle>
<DialogDescription>
Erzeugt einen Link, über den andere die Lage <strong>live nur ansehen</strong> können
ohne Login. Nichts kann darüber verändert werden.
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="py-8 flex justify-center"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
) : (
<div className="space-y-4 py-2">
{!enabled ? (
<Button onClick={handleEnable} disabled={saving} className="w-full">
{saving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Eye className="w-4 h-4 mr-2" />}
Teilen aktivieren
</Button>
) : (
<>
{/* Link + Kopieren */}
<div className="space-y-1.5">
<Label>Teilen-Link</Label>
<div className="flex gap-2">
<Input readOnly value={url || ''} className="font-mono text-xs" onFocus={(e) => e.target.select()} />
<Button variant="outline" size="icon" onClick={handleCopy} title="Link kopieren">
{copied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
</div>
{/* QR-Code zum Scannen vor Ort */}
{qr && (
<div className="flex justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={qr} alt="QR-Code zum Teilen-Link" className="w-40 h-40 rounded-lg border border-border" />
</div>
)}
{/* PIN-Schutz */}
<div className="rounded-lg border border-border p-3 space-y-2">
<label className="flex items-center gap-2 text-sm font-medium cursor-pointer">
<input type="checkbox" checked={usePin} onChange={(e) => setUsePin(e.target.checked)} className="w-4 h-4" />
Mit PIN schützen {hasPin && <span className="text-xs text-green-600 font-normal">· aktiv</span>}
</label>
{usePin && (
<div className="flex gap-2">
<Input
inputMode="numeric"
value={pinInput}
onChange={(e) => setPinInput(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder={hasPin ? 'Neue PIN (48 Ziffern)' : 'PIN (48 Ziffern)'}
className="font-mono tracking-widest"
/>
<Button variant="outline" onClick={handleSavePin} disabled={saving}>Speichern</Button>
</div>
)}
{!usePin && hasPin && (
<Button variant="outline" size="sm" onClick={handleSavePin} disabled={saving}>PIN entfernen</Button>
)}
</div>
{/* Deaktivieren */}
<Button variant="outline" onClick={handleDisable} disabled={saving} className="w-full text-destructive hover:text-destructive">
<Trash2 className="w-4 h-4 mr-2" /> Teilen beenden (Link ungültig machen)
</Button>
</>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -47,8 +47,10 @@ import {
Plus,
ChevronDown,
Loader2,
Share2,
} from 'lucide-react'
import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog'
import { ShareDialog } from '@/components/dialogs/share-dialog'
import { degToCompass } from '@/components/map/map-compass'
import type { Project, DrawFeature, ProjectMode } from '@/types'
import { formatDateTime } from '@/lib/utils'
@@ -119,6 +121,7 @@ export function Topbar({
}: TopbarProps) {
const [isLoadDialogOpen, setIsLoadDialogOpen] = useState(false)
const [isHoseSettingsOpen, setIsHoseSettingsOpen] = useState(false)
const [isShareOpen, setIsShareOpen] = useState(false)
const [showPasswordDialog, setShowPasswordDialog] = useState(false)
const [showDeleteAccountDialog, setShowDeleteAccountDialog] = useState(false)
const [deleteAccountPw, setDeleteAccountPw] = useState('')
@@ -293,6 +296,12 @@ export function Topbar({
<List className="w-4 h-4 mr-2" />
Einsätze verwalten
</DropdownMenuItem>
{project && (
<DropdownMenuItem onClick={() => setIsShareOpen(true)}>
<Share2 className="w-4 h-4 mr-2" />
Lage teilen (Nur-Ansicht)
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleExport('png')}>
<Download className="w-4 h-4 mr-2" />
@@ -417,6 +426,12 @@ export function Topbar({
<List className="w-4 h-4 mr-2" />
Einsätze verwalten
</DropdownMenuItem>
{project && (
<DropdownMenuItem onClick={() => setIsShareOpen(true)}>
<Share2 className="w-4 h-4 mr-2" />
Lage teilen (Nur-Ansicht)
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
@@ -641,6 +656,8 @@ export function Topbar({
<HoseSettingsDialog open={isHoseSettingsOpen} onOpenChange={setIsHoseSettingsOpen} />
<ShareDialog open={isShareOpen} onOpenChange={setIsShareOpen} projectId={project?.id ?? null} projectTitle={project?.title} />
{/* Password Change Dialog */}
<Dialog open={showPasswordDialog} onOpenChange={(open) => {
setShowPasswordDialog(open)

View File

@@ -79,6 +79,7 @@ export const resendVerificationLimiter = rateLimit({ id: 'resend-verify', max: 3
export const contactLimiter = rateLimit({ id: 'contact', max: 5, windowSeconds: 60 * 60 }) // 5 per hour
export const deleteAccountLimiter = rateLimit({ id: 'delete-acct', max: 3, windowSeconds: 60 * 15 })
export const resetPasswordLimiter = rateLimit({ id: 'reset-pw', max: 5, windowSeconds: 60 * 15 })
export const sharePinLimiter = rateLimit({ id: 'share-pin', max: 15, windowSeconds: 60 * 10 }) // 15 PIN-Versuche / 10 min
/** Extract client IP from request headers */
export function getClientIp(req: Request): string {

View File

@@ -22,6 +22,7 @@ const PUBLIC_API_PREFIXES = [
'/api/donate',
'/api/rapports/',
'/api/tenants/by-slug/',
'/api/share/',
]
export async function middleware(req: NextRequest) {