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
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:
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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?
|
||||
|
||||
95
src/app/api/projects/[id]/share/route.ts
Normal file
95
src/app/api/projects/[id]/share/route.ts
Normal 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 (4–8 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 4–8 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 })
|
||||
}
|
||||
}
|
||||
87
src/app/api/share/[token]/route.ts
Normal file
87
src/app/api/share/[token]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
231
src/app/view/[token]/page.tsx
Normal file
231
src/app/view/[token]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
186
src/components/dialogs/share-dialog.tsx
Normal file
186
src/components/dialogs/share-dialog.tsx
Normal 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 4–8 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 (4–8 Ziffern)' : 'PIN (4–8 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -22,6 +22,7 @@ const PUBLIC_API_PREFIXES = [
|
||||
'/api/donate',
|
||||
'/api/rapports/',
|
||||
'/api/tenants/by-slug/',
|
||||
'/api/share/',
|
||||
]
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
|
||||
Reference in New Issue
Block a user