From 90a4459e63363b73295aeb264ba771b4f4c2196d Mon Sep 17 00:00:00 2001 From: Tony James Date: Mon, 1 Jun 2026 10:10:30 +0100 Subject: [PATCH] feat: 6-digit pair code login + responsive layout for narrow car browsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pair code: - POST /api/auth/owner/pair-code (owner-gated) mints a single-use, 5-minute, 6-digit code held in memory. - POST /api/auth/owner/pair-redeem swaps the code for an owner cookie, with basic IP-based rate limiting (max 8 attempts/min). - OwnerLoginModal gains a tab: "Use 6-digit code" (default) vs the long secret. The PIN input is big, monospaced, numeric-only. - Small smartphone icon next to the connected Tesla chip opens OwnerPairCodeDisplay, which shows the code in 56px type with a live countdown so it's tappable on the touchscreen of the other device while you stare at it. Responsive layout: - TopBar wraps when narrower than the chip row. minWidth on OD strip drops 380→280 and chat composer drops 360→240 so they stack cleanly. - Main body switches to flex-col below the lg breakpoint (Tailwind 1024px), so the map sits on top of the stops rail on Tesla 3/Y portrait and any window not maximised. Map gets 40vh min-height so it never disappears. --- client/src/lib/tesla.ts | 22 +++ client/src/pages/TeslaTripPlanner.tsx | 275 ++++++++++++++++++++------ server/routes/owner.ts | 70 +++++++ 3 files changed, 309 insertions(+), 58 deletions(-) diff --git a/client/src/lib/tesla.ts b/client/src/lib/tesla.ts index d3bd3e7..6d78a47 100644 --- a/client/src/lib/tesla.ts +++ b/client/src/lib/tesla.ts @@ -38,6 +38,28 @@ export async function logoutOwner(): Promise { await fetch('/api/auth/owner/logout', { method: 'POST' }); } +export interface OwnerPairCode { + code: string; + expiresInS: number; +} + +export async function mintOwnerPairCode(): Promise { + const res = await fetch('/api/auth/owner/pair-code', { method: 'POST' }); + if (!res.ok) return null; + const data = await res.json(); + if (!data?.code) return null; + return { code: data.code, expiresInS: data.expiresInS ?? 300 }; +} + +export async function redeemOwnerPairCode(code: string): Promise { + const res = await fetch('/api/auth/owner/pair-redeem', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }); + return res.ok; +} + export interface TeslaActiveRoute { destination: string; lat: number | null; diff --git a/client/src/pages/TeslaTripPlanner.tsx b/client/src/pages/TeslaTripPlanner.tsx index 1e6978d..8ed4982 100644 --- a/client/src/pages/TeslaTripPlanner.tsx +++ b/client/src/pages/TeslaTripPlanner.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { toast } from 'sonner'; import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap } from 'react-leaflet'; -import { useTesla, startTeslaConnect, disconnectTesla, sendToTeslaNav, wakeTesla, loginOwner, type TeslaActiveRoute } from '../lib/tesla'; +import { useTesla, startTeslaConnect, disconnectTesla, sendToTeslaNav, wakeTesla, loginOwner, mintOwnerPairCode, redeemOwnerPairCode, type TeslaActiveRoute } from '../lib/tesla'; import { isMockEnabled, getMockScenario, setMockScenario, resetMockDrive } from '../lib/teslaMock'; import { detectInCar } from '../lib/incar'; import { computeBatteryPlan, type BatteryAtStop, type BatteryRisk } from '../lib/batteryPlan'; @@ -12,7 +12,7 @@ import { Zap, Bed, Flag, Home as HomeIcon, Coffee, Utensils, Camera, ShoppingBag, Footprints, Wifi, MapPin, Route, Clock, TreePine, Euro, CalendarDays, Ship, Users, ExternalLink, Crosshair, ArrowUp, ArrowDown, - Car, Battery, Navigation, + Car, Battery, Navigation, Smartphone, } from 'lucide-react'; // Fix Leaflet default icons (we still need pins for non-active stops) @@ -1411,7 +1411,7 @@ function TopBar({ chatInput, setChatInput, onChatSubmit, chips, onRemoveChip, vehicle, onOpenVehiclePanel, grokStatus, onOpenGpx, travelDates, onOpenDates, onUseMyLocation, - teslaStatus, teslaState, onConnectTesla, onDisconnectTesla, inCar, + teslaStatus, teslaState, onConnectTesla, onDisconnectTesla, onOpenPairCode, inCar, }: { origin: string; destination: string; onOriginChange: (v: string) => void; @@ -1430,6 +1430,7 @@ function TopBar({ teslaState: ReturnType['state']; onConnectTesla: () => void; onDisconnectTesla: () => void; + onOpenPairCode: () => void; inCar: boolean; }) { // Tesla connected → we know the car (and where it is), so the vehicle picker @@ -1448,8 +1449,8 @@ function TopBar({ const datesEmpty = !travelDates.outbound; return (
{/* Brand */}
@@ -1479,7 +1480,7 @@ function TopBar({ ) : (
@@ -1522,12 +1523,12 @@ function TopBar({
)} -
+
{/* Chat composer with chips */}
{chips.length > 0 && ( @@ -1602,22 +1603,34 @@ function TopBar({ {teslaStatus?.available && ( teslaStatus.connected ? ( -
- + + +
) : ( + ))}
+ +
+ {mode === 'pair' ? ( + setPin(e.target.value.replace(/\D/g, '').slice(0, 6))} + onKeyDown={(e) => { if (e.key === 'Enter') submitPin(); }} + placeholder="••••••" + className="w-full text-center font-semibold tracking-[0.4em] outline-none num" + style={{ + fontSize: 36, + padding: '14px 10px', + background: 'var(--gd-panel)', + border: '1px solid var(--gd-border)', + color: 'var(--gd-text)', + borderRadius: 12, + }} + /> + ) : ( + setSecret(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') submitSecret(); }} + placeholder="Owner secret" + className="w-full text-[13.5px] px-3 py-3 rounded-lg outline-none" + style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)' }} + /> + )} +
+
+
+
+
+ ); +} + +// Modal shown on an already-logged-in device to mint a fresh 6-digit code +// the user can type into a touchscreen device (e.g. their Tesla browser). +function OwnerPairCodeDisplay({ open, onClose }: { open: boolean; onClose: () => void }) { + const [code, setCode] = React.useState(null); + const [secondsLeft, setSecondsLeft] = React.useState(0); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + if (!open) return; + setCode(null); setError(null); setSecondsLeft(0); + (async () => { + const c = await mintOwnerPairCode(); + if (!c) { setError('Could not mint a pair code'); return; } + setCode(c.code); + setSecondsLeft(c.expiresInS); + })(); + }, [open]); + + React.useEffect(() => { + if (!open || !code || secondsLeft <= 0) return; + const t = setInterval(() => setSecondsLeft(s => Math.max(0, s - 1)), 1000); + return () => clearInterval(t); + }, [open, code, secondsLeft]); + + if (!open) return null; + const mins = Math.floor(secondsLeft / 60); + const secs = secondsLeft % 60; + return ( +
+
e.stopPropagation()} + className="w-[480px] max-w-full overflow-hidden text-center" + style={{ + background: 'var(--gd-bg-2)', + border: '1px solid var(--gd-border-2)', + borderRadius: 16, + boxShadow: '0 24px 60px rgba(0,0,0,0.55)', + }} + > +
+
Pair another device
+
+ On the new device (e.g. your Tesla browser) open https://roadtrip.tony.codes and click Connect TeslaUse 6-digit code, then enter: +
+
+
+ {error ? ( +
{error}
+ ) : code ? ( + <> +
+ {code} +
+
+ Expires in {mins}:{String(secs).padStart(2, '0')} · single-use +
+ + ) : ( +
Generating code…
+ )} +
+
+
diff --git a/server/routes/owner.ts b/server/routes/owner.ts index c5d8efa..8f158ab 100644 --- a/server/routes/owner.ts +++ b/server/routes/owner.ts @@ -1,5 +1,7 @@ import { Router } from 'express'; +import crypto from 'node:crypto'; import { env } from '../config/env.js'; +import { createLogger } from '../lib/logger.js'; import { clearOwnerCookie, isOwnerAuthenticated, @@ -7,8 +9,32 @@ import { verifyOwnerSecret, } from '../lib/ownerAuth.js'; +const log = createLogger('owner'); const router = Router(); +// ─── Device pair-code store (in-memory) ───────────────────────────────────── +// 6-digit short codes minted by an already-logged-in owner session so a +// touchscreen device (the Tesla in-car browser) can log in without retyping +// the long OWNER_SECRET. 5-minute TTL, single use, rate-limited. +const PAIR_CODE_TTL_MS = 5 * 60 * 1000; +const pairCodes = new Map(); +// IP → recent redeem attempt timestamps (oldest first). +const redeemAttempts = new Map(); +const MAX_REDEEM_PER_MIN = 8; + +function cleanPairCodes(): void { + const now = Date.now(); + for (const [k, v] of pairCodes) if (v.expiresAt < now) pairCodes.delete(k); +} + +function rateLimited(ip: string): boolean { + const now = Date.now(); + const arr = (redeemAttempts.get(ip) || []).filter(t => now - t < 60_000); + arr.push(now); + redeemAttempts.set(ip, arr); + return arr.length > MAX_REDEEM_PER_MIN; +} + router.get('/api/auth/owner/status', (req, res) => { res.json({ authenticated: isOwnerAuthenticated(req), @@ -35,4 +61,48 @@ router.post('/api/auth/owner/logout', (_req, res) => { res.json({ ok: true }); }); +// ─── Pair code: mint one (requires owner session) ────────────────────────── +router.post('/api/auth/owner/pair-code', (req, res) => { + if (!isOwnerAuthenticated(req)) { + res.status(401).json({ ok: false, reason: 'auth_required' }); + return; + } + cleanPairCodes(); + // Cap concurrent codes per host to a sane number. + if (pairCodes.size > 32) { + res.status(429).json({ ok: false, reason: 'too_many_codes' }); + return; + } + let code: string; + do { + code = String(crypto.randomInt(100_000, 1_000_000)); + } while (pairCodes.has(code)); + pairCodes.set(code, { expiresAt: Date.now() + PAIR_CODE_TTL_MS }); + res.json({ ok: true, code, expiresInS: Math.floor(PAIR_CODE_TTL_MS / 1000) }); +}); + +// ─── Pair code: redeem on a new device ────────────────────────────────────── +router.post('/api/auth/owner/pair-redeem', (req, res) => { + const ip = (req.ip || (req.headers['x-forwarded-for'] as string) || 'unknown').toString(); + if (rateLimited(ip)) { + res.status(429).json({ ok: false, reason: 'rate_limited' }); + return; + } + const { code } = (req.body || {}) as { code?: unknown }; + if (typeof code !== 'string' || !/^\d{6}$/.test(code)) { + res.status(400).json({ ok: false, reason: 'bad_code' }); + return; + } + cleanPairCodes(); + const entry = pairCodes.get(code); + if (!entry) { + res.status(401).json({ ok: false, reason: 'invalid_or_expired' }); + return; + } + pairCodes.delete(code); + setOwnerCookie(res); + log.info({ ip }, 'Device paired via owner pair code'); + res.json({ ok: true }); +}); + export default router;