diff --git a/package.json b/package.json index b82c672..4a4d623 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/prisma/migrate.js b/prisma/migrate.js index 7bb82da..ccba9ef 100644 --- a/prisma/migrate.js +++ b/prisma/migrate.js @@ -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) { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bafe32c..669e9c9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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? diff --git a/src/app/api/projects/[id]/share/route.ts b/src/app/api/projects/[id]/share/route.ts new file mode 100644 index 0000000..0b4ce1d --- /dev/null +++ b/src/app/api/projects/[id]/share/route.ts @@ -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 }) + } +} diff --git a/src/app/api/share/[token]/route.ts b/src/app/api/share/[token]/route.ts new file mode 100644 index 0000000..6ec5f96 --- /dev/null +++ b/src/app/api/share/[token]/route.ts @@ -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 }) + } +} diff --git a/src/app/view/[token]/page.tsx b/src/app/view/[token]/page.tsx new file mode 100644 index 0000000..c4d2950 --- /dev/null +++ b/src/app/view/[token]/page.tsx @@ -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('loading') + const [meta, setMeta] = useState(null) + const [project, setProject] = useState(null) + const [features, setFeatures] = useState([]) + const [pin, setPin] = useState('') + const [error, setError] = useState('') + const [submitting, setSubmitting] = useState(false) + const projectIdRef = useRef(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 ( +
+
{ 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" + > +
+ Geschützte Ansicht +
+ {meta?.title &&

{meta.einsatzNr ? `${meta.einsatzNr} · ` : ''}{meta.title}

} +

Bitte PIN eingeben, um die Lage anzusehen.

+ 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 &&

{error}

} + +
+
+ ) + } + + // ─── Fehler ─── + if (phase === 'error') { + return ( +
+
+
Nicht verfügbar
+

{error || 'Dieser Teilen-Link ist ungültig oder wurde deaktiviert.'}

+
+
+ ) + } + + // ─── Laden ─── + if (phase === 'loading' || !project) { + return ( +
+ +
+ ) + } + + // ─── Karte (read-only) ─── + return ( +
+ {/* Schlanke Kopfzeile für Ansichts-Modus */} +
+ + Nur Ansicht · live + +
+ + {(project as any).einsatzNr ? `${(project as any).einsatzNr} · ` : ''}{(project as any).title} + + {(project as any).location && ( + + {(project as any).location} + + )} +
+ {typeof project.windDirection === 'number' && ( + + Wind aus {degToCompass(project.windDirection)} + + )} +
+ +
+ + {}} + onSymbolDrop={() => {}} + onTextPlace={() => {}} + canEdit={false} + /> + +
+
+ ) +} diff --git a/src/components/dialogs/share-dialog.tsx b/src/components/dialogs/share-dialog.tsx new file mode 100644 index 0000000..cc046a9 --- /dev/null +++ b/src/components/dialogs/share-dialog.tsx @@ -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(null) + const [hasPin, setHasPin] = useState(false) + const [usePin, setUsePin] = useState(false) + const [pinInput, setPinInput] = useState('') + const [copied, setCopied] = useState(false) + const [qr, setQr] = useState('') + 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 ( + + + + + Lage teilen (Nur-Ansicht) + + + Erzeugt einen Link, über den andere die Lage live nur ansehen können — + ohne Login. Nichts kann darüber verändert werden. + + + + {loading ? ( +
+ ) : ( +
+ {!enabled ? ( + + ) : ( + <> + {/* Link + Kopieren */} +
+ +
+ e.target.select()} /> + +
+
+ + {/* QR-Code zum Scannen vor Ort */} + {qr && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + QR-Code zum Teilen-Link +
+ )} + + {/* PIN-Schutz */} +
+ + {usePin && ( +
+ 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" + /> + +
+ )} + {!usePin && hasPin && ( + + )} +
+ + {/* Deaktivieren */} + + + )} + + {error &&

{error}

} +
+ )} +
+
+ ) +} diff --git a/src/components/layout/topbar.tsx b/src/components/layout/topbar.tsx index 6c6584d..86f7242 100644 --- a/src/components/layout/topbar.tsx +++ b/src/components/layout/topbar.tsx @@ -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({ Einsätze verwalten + {project && ( + setIsShareOpen(true)}> + + Lage teilen (Nur-Ansicht) + + )} handleExport('png')}> @@ -417,6 +426,12 @@ export function Topbar({ Einsätze verwalten + {project && ( + setIsShareOpen(true)}> + + Lage teilen (Nur-Ansicht) + + )} @@ -641,6 +656,8 @@ export function Topbar({ + + {/* Password Change Dialog */} { setShowPasswordDialog(open) diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 0d0949f..7aafc23 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -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 { diff --git a/src/middleware.ts b/src/middleware.ts index 2b68389..94cd40f 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -22,6 +22,7 @@ const PUBLIC_API_PREFIXES = [ '/api/donate', '/api/rapports/', '/api/tenants/by-slug/', + '/api/share/', ] export async function middleware(req: NextRequest) {