diff --git a/package.json b/package.json index 9019c27..51eb412 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lageplan", - "version": "1.0.9", + "version": "1.1.0", "description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation", "private": true, "scripts": { diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 5e37086..f5fcc96 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -58,6 +58,7 @@ import { } from 'lucide-react' import Link from 'next/link' import { TenantDetailDialog } from '@/components/admin/tenant-detail-dialog' +import { HoseSettingsDialog } from '@/components/dialogs/hose-settings-dialog' // --- Types --- interface IconCategory { @@ -214,6 +215,14 @@ export default function AdminPage() { const [symbolScaleLoading, setSymbolScaleLoading] = useState(false) const [symbolScaleStatus, setSymbolScaleStatus] = useState(null) + // Admin Projects (SERVER_ADMIN) + const [adminProjects, setAdminProjects] = useState([]) + const [adminProjectsLoading, setAdminProjectsLoading] = useState(false) + const [adminProjectTenantFilter, setAdminProjectTenantFilter] = useState('all') + + // Hose Settings (Tenant Admin) + const [isHoseSettingsOpen, setIsHoseSettingsOpen] = useState(false) + // Redirect to login if not authenticated, or to app if not admin useEffect(() => { if (authLoading) return @@ -251,6 +260,25 @@ export default function AdminPage() { if (user?.role === 'SERVER_ADMIN') fetchGlobalDict() }, [user?.role]) + // Fetch admin projects (SERVER_ADMIN) + const fetchAdminProjects = async (tenantFilter?: string) => { + setAdminProjectsLoading(true) + try { + const url = tenantFilter && tenantFilter !== 'all' + ? `/api/admin/projects?tenantId=${tenantFilter}` + : '/api/admin/projects' + const res = await fetch(url) + if (res.ok) { + const data = await res.json() + setAdminProjects(data.projects || []) + } + } catch {} + setAdminProjectsLoading(false) + } + useEffect(() => { + if (user?.role === 'SERVER_ADMIN') fetchAdminProjects() + }, [user?.role]) + const fetchData = async () => { setIsLoading(true) try { @@ -683,11 +711,15 @@ export default function AdminPage() {
{user?.role === 'SERVER_ADMIN' ? ( - + Mandanten + + + Einsätze + Symbole @@ -710,7 +742,7 @@ export default function AdminPage() { ) : user?.role === 'TENANT_ADMIN' ? ( - + Benutzer @@ -719,6 +751,10 @@ export default function AdminPage() { Wörterliste + + + Schläuche + Spenden @@ -960,6 +996,97 @@ export default function AdminPage() { )} + {/* ===== PROJECTS TAB (SERVER_ADMIN — Einsätze verwalten) ===== */} + {user?.role === 'SERVER_ADMIN' && ( + +
+

+ {adminProjects.length} Einsatz/Einsätze +

+
+ Feuerwehr: + +
+
+ + {adminProjectsLoading ? ( +
+ +
+ ) : adminProjects.length === 0 ? ( +

Keine Einsätze gefunden.

+ ) : ( +
+ + + + + + + + + + + + + + + {adminProjects.map((p: any) => ( + + + + + + + + + + + ))} + +
Einsatz-NrTitelOrtErstellt vonFeuerwehrElementeGeändertAktion
{p.einsatzNr || '—'}{p.title}{p.location || '—'} + {p.owner?.name || p.owner?.email || '—'} + + {p.tenant?.name || '—'} + {p._count?.features || 0}{new Date(p.updatedAt).toLocaleString('de-CH')} + +
+
+ )} +
+ )} + + {/* ===== HOSE TYPES TAB (Schlauchtypen) ===== */} + +
+

+ + Schlauchtypen verwalten +

+

+ Konfiguriere die Schlauchtypen für die Druckberechnung im Messwerkzeug. Der Standard-Schlauch wird automatisch für neue Berechnungen verwendet. +

+ +
+ +
+ {/* ===== SUGGESTIONS TAB (Word Library) ===== */}
diff --git a/src/app/api/admin/projects/route.ts b/src/app/api/admin/projects/route.ts new file mode 100644 index 0000000..689ef4d --- /dev/null +++ b/src/app/api/admin/projects/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { prisma } from '@/lib/db' + +export async function GET(request: NextRequest) { + try { + const user = await getSession() + if (!user || user.role !== 'SERVER_ADMIN') { + return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const tenantId = searchParams.get('tenantId') + + const where: any = {} + if (tenantId) where.tenantId = tenantId + + const projects = await (prisma as any).project.findMany({ + where, + orderBy: { updatedAt: 'desc' }, + include: { + owner: { + select: { id: true, name: true, email: true }, + }, + tenant: { + select: { id: true, name: true }, + }, + _count: { + select: { features: true }, + }, + }, + }) + + return NextResponse.json({ projects }) + } catch (error) { + console.error('Error fetching admin projects:', error) + return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }) + } +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index a26bcca..338bb7c 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -22,11 +22,16 @@ export async function POST(request: NextRequest) { } const { email, password } = validated.data + const rememberMe = body.rememberMe === true const result = await login(email, password) if (!result.success || !result.user) { + const remaining = rl.remaining + const warningText = remaining <= 3 && remaining > 0 + ? ` (Noch ${remaining} Versuch${remaining === 1 ? '' : 'e'})` + : '' return NextResponse.json( - { error: result.error || 'Login fehlgeschlagen' }, + { error: (result.error || 'Login fehlgeschlagen') + warningText, remaining }, { status: 401 } ) } @@ -39,13 +44,13 @@ export async function POST(request: NextRequest) { }) } catch {} - const token = await createToken(result.user) + const token = await createToken(result.user, rememberMe) ;(await cookies()).set('auth-token', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: 60 * 60 * 24, // 24 hours + maxAge: rememberMe ? 60 * 60 * 24 * 30 : 60 * 60 * 24, // 30 days or 24 hours path: '/', }) diff --git a/src/app/api/projects/[id]/features/route.ts b/src/app/api/projects/[id]/features/route.ts index d59b3ee..d890b0e 100644 --- a/src/app/api/projects/[id]/features/route.ts +++ b/src/app/api/projects/[id]/features/route.ts @@ -113,7 +113,19 @@ export async function PUT( } const body = await request.json() - const { features } = body as { features: Array<{ id?: string; type: string; geometry: object; properties?: object }> } + const { features, mapCenter, mapZoom } = body as { + features: Array<{ id?: string; type: string; geometry: object; properties?: object }> + mapCenter?: { lng: number; lat: number } + mapZoom?: number + } + + // Persist map viewport alongside features (if provided) + if (mapCenter && mapZoom !== undefined) { + await (prisma as any).project.update({ + where: { id }, + data: { mapCenter, mapZoom }, + }) + } await (prisma as any).feature.deleteMany({ where: { projectId: id }, diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx index 5536a3e..2a396dc 100644 --- a/src/app/app/page.tsx +++ b/src/app/app/page.tsx @@ -22,6 +22,7 @@ import { jsPDF } from 'jspdf' import { Lock, Unlock, Eye, AlertTriangle, WifiOff } from 'lucide-react' import { getSocket, setSocketRoom } from '@/lib/socket' import { CustomDragLayer } from '@/components/map/custom-drag-layer' +import { OnboardingTour, resetOnboardingTour } from '@/components/onboarding/onboarding-tour' import { addToSyncQueue, flushSyncQueue, getSyncQueue, isOnline as checkOnline } from '@/lib/offline-sync' export interface Project { @@ -92,6 +93,9 @@ export default function AppPage() { const [lastMapScreenshot, setLastMapScreenshot] = useState('') const [defaultSymbolScale, setDefaultSymbolScale] = useState(1.5) + // Onboarding tour + const [showTour, setShowTour] = useState(false) + // Live editing lock state const [editingBy, setEditingBy] = useState<{ id: string; name: string; since: string } | null>(null) const [isEditingByMe, setIsEditingByMe] = useState(false) @@ -698,7 +702,13 @@ export default function AppPage() { const saveFeaturesToApi = useCallback(async () => { if (!currentProject?.id) return const url = `/api/projects/${currentProject.id}/features` - const body = { features: featuresRef.current } + const mapInstance = mapRef.current + const body: any = { features: featuresRef.current } + if (mapInstance) { + const c = mapInstance.getCenter() + body.mapCenter = { lng: c.lng, lat: c.lat } + body.mapZoom = mapInstance.getZoom() + } // If offline, queue the save for later sync if (!navigator.onLine) { @@ -885,10 +895,16 @@ export default function AppPage() { setIsSaving(true) try { + const saveBody: any = { features } + if (mapRef.current) { + const c = mapRef.current.getCenter() + saveBody.mapCenter = { lng: c.lng, lat: c.lat } + saveBody.mapZoom = mapRef.current.getZoom() + } let res = await fetch(`/api/projects/${currentProject.id}/features`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ features }), + body: JSON.stringify(saveBody), }) // If project doesn't exist in DB (404), re-create it first then retry @@ -1048,6 +1064,59 @@ export default function AppPage() { } }, []) + // Keyboard shortcuts for tools + const [isShortcutHelpOpen, setIsShortcutHelpOpen] = useState(false) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore when typing in inputs/textareas + const tag = (e.target as HTMLElement)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target as HTMLElement)?.isContentEditable) return + + // ? or F1 → help + if (e.key === '?' || e.key === 'F1') { e.preventDefault(); setIsShortcutHelpOpen(true); return } + + // DEL / Backspace → delete selected feature(s) + if (e.key === 'Delete' || e.key === 'Backspace') { + e.preventDefault() + // Remove all selected features + const current = featuresRef.current + const selected = current.filter(f => f.properties?._selected) + if (selected.length > 0) { + handleFeaturesChange(current.filter(f => !f.properties?._selected)) + } + return + } + + // Ctrl/Cmd shortcuts (CH keyboard: Z and Y are swapped) + if (e.ctrlKey || e.metaKey) { + if (e.key === 'z') { e.preventDefault(); handleRedo(); return } + if (e.key === 'y') { e.preventDefault(); handleUndo(); return } + if (e.key === 's') { e.preventDefault(); handleSaveProject(); return } + return + } + + // Tool shortcuts (single key, no modifier) + const shortcuts: Record = { + 'v': 'select', 's': 'select', + 'p': 'point', + 'l': 'linestring', + 'g': 'polygon', + 'r': 'rectangle', + 'c': 'circle', + 'f': 'freehand', + 'a': 'arrow', + 't': 'text', + 'e': 'eraser', + 'm': 'measure', + 'd': 'dangerzone', + } + const mode = shortcuts[e.key.toLowerCase()] + if (mode) { e.preventDefault(); setDrawMode(mode); return } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [handleUndo, handleRedo, handleSaveProject, setDrawMode, handleFeaturesChange]) + const handlePlanUpload = useCallback(() => { if (!currentProject) return const input = document.createElement('input') @@ -1455,6 +1524,7 @@ export default function AppPage() { userName={user?.name} userRole={user?.role} onLogout={logout} + onStartTour={() => { resetOnboardingTour(); setShowTour(true) }} /> {/* Offline banner */} @@ -1530,7 +1600,7 @@ export default function AppPage() {
{/* Map view — always mounted, hidden via CSS to preserve state */} -
+
+ {/* Keyboard Shortcuts Help Dialog */} + + + + Tastenkürzel + +
+
Werkzeuge
+ {[ + ['V', 'Auswählen'], ['P', 'Punkt'], ['L', 'Linie'], ['G', 'Polygon'], + ['R', 'Rechteck'], ['C', 'Kreis'], ['F', 'Freihand'], ['A', 'Pfeil / Route'], + ['T', 'Text'], ['E', 'Radiergummi'], ['M', 'Messen'], ['D', 'Gefahrenzone'], + ].map(([key, label]) => ( +
+ {label} + {key} +
+ ))} +
Aktionen
+ {[ + ['Ctrl+Y', 'Rückgängig'], ['Ctrl+Z', 'Wiederholen'], + ['Ctrl+S', 'Speichern'], ['Del', 'Auswahl löschen'], + ['Esc', 'Abbrechen'], ['?', 'Diese Hilfe'], + ].map(([key, label]) => ( +
+ {label} + {key} +
+ ))} +
+
+
+ {/* Delete All Confirmation Dialog */} @@ -1635,6 +1739,12 @@ export default function AppPage() {
+ + {/* Onboarding Tour */} + setShowTour(false)} + />
) diff --git a/src/app/globals.css b/src/app/globals.css index 5fe11ed..bbfcfe3 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -56,6 +56,9 @@ } body { @apply bg-background text-foreground; + font-size: 15px; + line-height: 1.6; + letter-spacing: 0.01em; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f1916bd..07df6c6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,12 +1,12 @@ import type { Metadata, Viewport } from 'next' -import { Inter } from 'next/font/google' +import { Barlow } from 'next/font/google' import './globals.css' 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' -const inter = Inter({ +const barlow = Barlow({ subsets: ['latin'], weight: ['400', '500', '600', '700'], display: 'swap', @@ -105,7 +105,7 @@ export default function RootLayout({ - + {children} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 5338b51..745314b 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -22,6 +22,7 @@ export default function LoginPage() { function LoginForm() { const [email, setEmail] = useState('') const [password, setPassword] = useState('') + const [rememberMe, setRememberMe] = useState(true) const [isLoading, setIsLoading] = useState(false) const [resendLoading, setResendLoading] = useState(false) const [resendSuccess, setResendSuccess] = useState(false) @@ -55,7 +56,7 @@ function LoginForm() { e.preventDefault() setIsLoading(true) - const result = await login(email, password) + const result = await login(email, password, rememberMe) if (result.success) { toast({ @@ -173,6 +174,16 @@ function LoginForm() { />
+ + diff --git a/src/components/map/map-view.tsx b/src/components/map/map-view.tsx index 4da9a19..3b8ce59 100644 --- a/src/components/map/map-view.tsx +++ b/src/components/map/map-view.tsx @@ -106,7 +106,7 @@ export function MapView({ const measureMarkersRef = useRef([]) const measureCoordsRef = useRef([]) const [isMapLoaded, setIsMapLoaded] = useState(false) - const [activeBaseLayer, setActiveBaseLayer] = useState<'osm' | 'satellite' | 'swisstopo' | 'swissimage'>('osm') + const [activeBaseLayer, setActiveBaseLayer] = useState<'osm' | 'satellite' | 'swisstopo'>('osm') const [layerDropdownOpen, setLayerDropdownOpen] = useState(false) const [measurePointCount, setMeasurePointCount] = useState(0) const [measureFinished, setMeasureFinished] = useState(false) @@ -699,15 +699,6 @@ export function MapView({ attribution: '© swisstopo', maxzoom: 17, }, - 'swissimage': { - type: 'raster', - tiles: [ - 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage/default/current/3857/{z}/{x}/{y}.jpeg', - ], - tileSize: 256, - attribution: '© swisstopo SWISSIMAGE', - maxzoom: 18, - }, }, layers: [ { @@ -727,12 +718,6 @@ export function MapView({ source: 'swisstopo', layout: { visibility: 'none' }, }, - { - id: 'swissimage', - type: 'raster', - source: 'swissimage', - layout: { visibility: 'none' }, - }, ], }, center: [initialCenter.lng, initialCenter.lat], @@ -2168,7 +2153,7 @@ export function MapView({ }} > - {{ osm: 'OpenStreetMap', satellite: 'Satellit', swisstopo: 'Swisstopo', swissimage: 'Luftbild CH' }[activeBaseLayer]} + {{ osm: 'OpenStreetMap', satellite: 'Satellit', swisstopo: 'Swisstopo' }[activeBaseLayer]} {layerDropdownOpen && ( @@ -2180,13 +2165,12 @@ export function MapView({ { key: 'osm', label: 'OpenStreetMap' }, { key: 'satellite', label: 'Satellit (Esri)' }, { key: 'swisstopo', label: 'Swisstopo Karte' }, - { key: 'swissimage', label: 'Luftbild CH' }, ] as const).map(({ key, label }) => (
)} + {/* Credit link */} + + mit ♥ von Pepe + + {/* Cursor-following tooltip — always mounted for stable ref */}
void +} + +export function OnboardingTour({ forceShow = false, onComplete }: OnboardingTourProps) { + const [isVisible, setIsVisible] = useState(false) + const [currentStep, setCurrentStep] = useState(0) + const [highlightRect, setHighlightRect] = useState(null) + + useEffect(() => { + if (forceShow) { + setIsVisible(true) + setCurrentStep(0) + return + } + const completed = localStorage.getItem(TOUR_STORAGE_KEY) + if (!completed) { + // Small delay so the app renders first + const timer = setTimeout(() => setIsVisible(true), 1500) + return () => clearTimeout(timer) + } + }, [forceShow]) + + const updateHighlight = useCallback(() => { + const step = TOUR_STEPS[currentStep] + if (step.targetSelector) { + const el = document.querySelector(step.targetSelector) + if (el) { + setHighlightRect(el.getBoundingClientRect()) + return + } + } + setHighlightRect(null) + }, [currentStep]) + + useEffect(() => { + if (!isVisible) return + updateHighlight() + window.addEventListener('resize', updateHighlight) + return () => window.removeEventListener('resize', updateHighlight) + }, [isVisible, currentStep, updateHighlight]) + + const completeTour = useCallback(() => { + localStorage.setItem(TOUR_STORAGE_KEY, 'true') + setIsVisible(false) + onComplete?.() + }, [onComplete]) + + const nextStep = () => { + if (currentStep < TOUR_STEPS.length - 1) { + setCurrentStep(currentStep + 1) + } else { + completeTour() + } + } + + const prevStep = () => { + if (currentStep > 0) setCurrentStep(currentStep - 1) + } + + if (!isVisible) return null + + const step = TOUR_STEPS[currentStep] + const isFirst = currentStep === 0 + const isLast = currentStep === TOUR_STEPS.length - 1 + + // Calculate tooltip position based on highlight + const getTooltipStyle = (): React.CSSProperties => { + if (!highlightRect) { + // Center on screen + return { + position: 'fixed', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + } + } + const pos = step.position || 'bottom' + const gap = 12 + switch (pos) { + case 'bottom': + return { + position: 'fixed', + top: highlightRect.bottom + gap, + left: Math.max(16, Math.min(highlightRect.left, window.innerWidth - 360)), + } + case 'top': + return { + position: 'fixed', + bottom: window.innerHeight - highlightRect.top + gap, + left: Math.max(16, Math.min(highlightRect.left, window.innerWidth - 360)), + } + case 'right': + return { + position: 'fixed', + top: Math.max(16, highlightRect.top), + left: highlightRect.right + gap, + } + case 'left': + return { + position: 'fixed', + top: Math.max(16, highlightRect.top), + right: window.innerWidth - highlightRect.left + gap, + } + default: + return { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' } + } + } + + return ( + <> + {/* Backdrop overlay */} +
+ + {/* Highlight cutout */} + {highlightRect && ( +
+ )} + + {/* Tooltip card */} +
+
+

{step.title}

+ +
+

+ {step.description} +

+ + {/* Progress dots */} +
+
+ {TOUR_STEPS.map((_, i) => ( +
+ ))} +
+ +
+ {!isFirst && ( + + )} + {isFirst && ( + + )} + +
+
+
+ + ) +} + +/** Reset the onboarding tour so it shows again next time */ +export function resetOnboardingTour() { + localStorage.removeItem(TOUR_STORAGE_KEY) +} diff --git a/src/components/providers/auth-provider.tsx b/src/components/providers/auth-provider.tsx index ba43d46..047c01e 100644 --- a/src/components/providers/auth-provider.tsx +++ b/src/components/providers/auth-provider.tsx @@ -29,7 +29,7 @@ interface AuthContextType { user: User | null tenant: TenantInfo | null loading: boolean - login: (email: string, password: string) => Promise<{ success: boolean; error?: string }> + login: (email: string, password: string, rememberMe?: boolean) => Promise<{ success: boolean; error?: string }> logout: () => Promise canEdit: () => boolean isAdmin: () => boolean @@ -62,12 +62,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { } } - const login = async (email: string, password: string) => { + const login = async (email: string, password: string, rememberMe = false) => { try { const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), + body: JSON.stringify({ email, password, rememberMe }), }) const data = await res.json() diff --git a/src/lib/auth.ts b/src/lib/auth.ts index b68771c..ae23245 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -21,11 +21,11 @@ export interface UserPayload { emailVerified?: boolean } -export async function createToken(user: UserPayload): Promise { +export async function createToken(user: UserPayload, rememberMe = false): Promise { return await new SignJWT({ user }) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() - .setExpirationTime('24h') + .setExpirationTime(rememberMe ? '30d' : '24h') .sign(JWT_SECRET) } diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 149a4d2..0d0949f 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -72,7 +72,7 @@ export function rateLimit(config: RateLimitConfig) { } // Pre-configured limiters for different endpoints -export const loginLimiter = rateLimit({ id: 'login', max: 5, windowSeconds: 60 * 15 }) // 5 attempts per 15 min +export const loginLimiter = rateLimit({ id: 'login', max: 10, windowSeconds: 60 * 5 }) // 10 attempts per 5 min export const registerLimiter = rateLimit({ id: 'register', max: 3, windowSeconds: 60 * 60 }) // 3 per hour export const forgotPasswordLimiter = rateLimit({ id: 'forgot-pw', max: 3, windowSeconds: 60 * 15 }) // 3 per 15 min export const resendVerificationLimiter = rateLimit({ id: 'resend-verify', max: 3, windowSeconds: 60 * 15 }) @@ -94,8 +94,12 @@ export function getClientIp(req: Request): string { /** Helper: create a 429 response with retry-after header */ export function rateLimitResponse(resetAt: number) { const retryAfter = Math.ceil((resetAt - Date.now()) / 1000) + const minutes = Math.ceil(retryAfter / 60) return new Response( - JSON.stringify({ error: 'Zu viele Anfragen. Bitte versuchen Sie es später erneut.' }), + JSON.stringify({ + error: `Zu viele Versuche. Bitte warten Sie ${minutes > 1 ? `${minutes} Minuten` : `${retryAfter} Sekunden`} und versuchen es erneut.`, + retryAfter, + }), { status: 429, headers: {