feat: 6-digit pair code login + responsive layout for narrow car browsers

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.
This commit is contained in:
2026-06-01 10:10:30 +01:00
parent 10235aaa32
commit 90a4459e63
3 changed files with 309 additions and 58 deletions
+22
View File
@@ -38,6 +38,28 @@ export async function logoutOwner(): Promise<void> {
await fetch('/api/auth/owner/logout', { method: 'POST' });
}
export interface OwnerPairCode {
code: string;
expiresInS: number;
}
export async function mintOwnerPairCode(): Promise<OwnerPairCode | null> {
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<boolean> {
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;
+217 -58
View File
@@ -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<typeof useTesla>['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 (
<div
className="flex items-center gap-4 px-5 flex-shrink-0"
style={{ height: 62, background: 'var(--gd-bg)', borderBottom: '1px solid var(--gd-border)' }}
className="flex items-center flex-wrap gap-x-3 gap-y-2 px-4 py-2 flex-shrink-0"
style={{ minHeight: 62, background: 'var(--gd-bg)', borderBottom: '1px solid var(--gd-border)' }}
>
{/* Brand */}
<div className="flex items-center gap-2.5">
@@ -1479,7 +1480,7 @@ function TopBar({
) : (
<div
className="flex items-center rounded-[10px] overflow-hidden"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', height: 38, minWidth: 380 }}
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', height: 38, minWidth: 280 }}
>
<div className="px-3.5 flex items-center gap-2 h-full flex-1" style={{ borderRight: '1px solid var(--gd-border)' }}>
<div className="w-1.5 h-1.5 rounded-full" style={{ background: 'var(--gd-text-2)' }} />
@@ -1522,12 +1523,12 @@ function TopBar({
</div>
)}
<div className="flex-1" />
<div className="flex-1 hidden md:block" />
{/* Chat composer with chips */}
<div
className="flex items-center pl-3 rounded-[10px]"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', height: 38, minWidth: 360 }}
className="flex items-center pl-3 rounded-[10px] flex-1 min-w-[240px]"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', height: 38 }}
>
<Sparkles className="w-3.5 h-3.5" style={{ color: 'var(--gd-red)' }} />
{chips.length > 0 && (
@@ -1602,22 +1603,34 @@ function TopBar({
{teslaStatus?.available && (
teslaStatus.connected ? (
<button
onClick={onDisconnectTesla}
title="Disconnect Tesla"
className="h-[38px] px-3 inline-flex items-center gap-2 rounded-[10px] cursor-pointer"
<div
className="h-[38px] inline-flex items-center rounded-[10px] overflow-hidden"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}
>
<Car className="w-3.5 h-3.5" style={{ color: 'var(--gd-red)' }} />
<div className="flex flex-col items-start leading-[1.15]">
<div className="text-[11.5px] font-medium">
{teslaState?.battery != null ? `${teslaState.battery}%` : 'Connected'}
<button
onClick={onDisconnectTesla}
title="Disconnect Tesla"
className="h-full px-3 inline-flex items-center gap-2 cursor-pointer"
>
<Car className="w-3.5 h-3.5" style={{ color: 'var(--gd-red)' }} />
<div className="flex flex-col items-start leading-[1.15]">
<div className="text-[11.5px] font-medium">
{teslaState?.battery != null ? `${teslaState.battery}%` : 'Connected'}
</div>
<div className="text-[9.5px] num" style={{ color: 'var(--gd-text-3)' }}>
{teslaState?.rangeKm != null ? `${teslaState.rangeKm} km` : (teslaState?.asleep ? 'asleep' : '')}
</div>
</div>
<div className="text-[9.5px] num" style={{ color: 'var(--gd-text-3)' }}>
{teslaState?.rangeKm != null ? `${teslaState.rangeKm} km` : (teslaState?.asleep ? 'asleep' : '')}
</div>
</div>
</button>
</button>
<button
onClick={onOpenPairCode}
title="Pair another device with a 6-digit code"
className="h-full px-2 grid place-items-center cursor-pointer"
style={{ borderLeft: '1px solid var(--gd-border)' }}
>
<Smartphone className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
</button>
</div>
) : (
<button
onClick={onConnectTesla}
@@ -1875,6 +1888,7 @@ export default function TeslaTripPlanner() {
const [datePickerOpen, setDatePickerOpen] = useState(false);
const [dateAnchor, setDateAnchor] = useState<DOMRect | null>(null);
const [ownerLoginOpen, setOwnerLoginOpen] = useState(false);
const [pairCodeOpen, setPairCodeOpen] = useState(false);
// After successful owner login, fire this pending action.
const ownerLoginThenRef = React.useRef<null | (() => void)>(null);
const lastODSent = React.useRef<{ from: string; to: string } | null>(null);
@@ -2454,6 +2468,7 @@ export default function TeslaTripPlanner() {
await tesla.refreshStatus();
toast.success('Tesla disconnected');
}}
onOpenPairCode={() => setPairCodeOpen(true)}
inCar={inCar}
/>
@@ -2469,10 +2484,12 @@ export default function TeslaTripPlanner() {
/>
)}
{/* Body: map left, rail right */}
<div className="flex flex-1 overflow-hidden">
{/* Map */}
<div className="flex-1 relative" style={{ background: '#0a0f1a' }}>
{/* Body: map left + rail right on wide screens, map top + rail below
on narrow ones (tablets, Tesla 3/Y portrait, smaller car browsers). */}
<div className="flex flex-col lg:flex-row flex-1 overflow-hidden">
{/* Map — fills width on narrow; ~50vh tall on narrow so the rail
gets meaningful space below. */}
<div className="relative flex-1 min-h-[40vh] lg:min-h-0" style={{ background: '#0a0f1a' }}>
<MapContainer
center={[51.5, 2.0]} zoom={5}
style={{ height: '100%', width: '100%', background: '#0a0f1a' }}
@@ -2649,10 +2666,11 @@ export default function TeslaTripPlanner() {
)}
</div>
{/* Stops rail */}
{/* Stops rail — full width below the map on narrow screens, 540px
fixed right column on wide screens. */}
<aside
className="flex flex-col overflow-hidden flex-shrink-0"
style={{ width: 540, background: 'var(--gd-bg-2)', borderLeft: '1px solid var(--gd-border)' }}
className="flex flex-col overflow-hidden flex-shrink-0 w-full lg:w-[540px]"
style={{ background: 'var(--gd-bg-2)' }}
>
{/* Trip header */}
<div className="px-5 py-3.5 flex items-center gap-2.5" style={{ borderBottom: '1px solid var(--gd-border)' }}>
@@ -2949,6 +2967,11 @@ export default function TeslaTripPlanner() {
if (fn) fn();
}}
/>
<OwnerPairCodeDisplay
open={pairCodeOpen}
onClose={() => setPairCodeOpen(false)}
/>
</ErrorBoundary>
);
}
@@ -3104,31 +3127,40 @@ function DrivingMode({ tesla, nextStop, vehicle, onSendToNav, onExit }: {
function OwnerLoginModal({ open, onClose, onSuccess }: {
open: boolean; onClose: () => void; onSuccess: () => void;
}) {
const [mode, setMode] = React.useState<'pair' | 'secret'>('pair');
const [secret, setSecret] = React.useState('');
const [pin, setPin] = React.useState('');
const [pending, setPending] = React.useState(false);
React.useEffect(() => {
if (!open) return;
setPin('');
setSecret('');
setMode('pair');
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
const submit = async () => {
const submitSecret = async () => {
if (!secret) return;
setPending(true);
try {
const ok = await loginOwner(secret);
if (ok) {
toast.success('Logged in as owner');
setSecret('');
onSuccess();
} else {
toast.error('Invalid owner secret');
}
} finally {
setPending(false);
}
if (ok) { toast.success('Logged in as owner'); setSecret(''); onSuccess(); }
else toast.error('Invalid owner secret');
} finally { setPending(false); }
};
const submitPin = async () => {
if (pin.length !== 6) return;
setPending(true);
try {
const ok = await redeemOwnerPairCode(pin);
if (ok) { toast.success('Device paired'); setPin(''); onSuccess(); }
else toast.error('Invalid or expired code');
} finally { setPending(false); }
};
return (
<div
onClick={onClose}
@@ -3137,7 +3169,7 @@ function OwnerLoginModal({ open, onClose, onSuccess }: {
>
<div
onClick={(e) => e.stopPropagation()}
className="w-[440px] max-w-full overflow-hidden"
className="w-[480px] max-w-full overflow-hidden"
style={{
background: 'var(--gd-bg-2)',
border: '1px solid var(--gd-border-2)',
@@ -3148,36 +3180,163 @@ function OwnerLoginModal({ open, onClose, onSuccess }: {
<div className="px-5 py-4" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<div className="text-[14px] font-semibold">Owner login required</div>
<div className="text-[11.5px] mt-0.5" style={{ color: 'var(--gd-text-3)' }}>
The Tesla integration is restricted to the deploying user. Enter the OWNER_SECRET set in the deploy environment.
{mode === 'pair'
? 'Type the 6-digit code from a logged-in device. Get one by clicking your Tesla chip on the desktop and choosing "Pair another device".'
: 'Or enter the long OWNER_SECRET set in the deploy environment.'}
</div>
</div>
<div className="px-5 py-4">
<input
type="password"
autoFocus
value={secret}
onChange={(e) => setSecret(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
placeholder="Owner secret"
className="w-full text-[13.5px] px-3 py-2.5 rounded-lg outline-none"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)' }}
/>
<div className="px-5 pt-4 pb-1 flex gap-1.5">
{(['pair', 'secret'] as const).map(m => (
<button
key={m}
onClick={() => setMode(m)}
className="flex-1 h-9 text-[12px] rounded-lg transition"
style={{
background: mode === m ? 'var(--gd-red-soft)' : 'transparent',
border: `1px solid ${mode === m ? 'var(--gd-red-line)' : 'var(--gd-border)'}`,
color: mode === m ? 'var(--gd-red)' : 'var(--gd-text-2)',
fontWeight: mode === m ? 600 : 400,
}}
>
{m === 'pair' ? 'Use 6-digit code' : 'Use owner secret'}
</button>
))}
</div>
<div className="px-5 py-4">
{mode === 'pair' ? (
<input
type="text"
inputMode="numeric"
pattern="\d*"
autoFocus
value={pin}
maxLength={6}
onChange={(e) => 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,
}}
/>
) : (
<input
type="password"
autoFocus
value={secret}
onChange={(e) => 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)' }}
/>
)}
</div>
<div className="px-5 py-4 flex items-center gap-2" style={{ borderTop: '1px solid var(--gd-border)' }}>
<button
onClick={onClose}
className="flex-1 h-10 text-[12.5px] rounded-lg"
className="flex-1 h-12 text-[14px] rounded-lg"
style={{ background: 'transparent', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
>
Cancel
</button>
<button
onClick={submit}
disabled={!secret || pending}
className="flex-[2] h-10 text-[12.5px] rounded-lg disabled:opacity-40"
onClick={mode === 'pair' ? submitPin : submitSecret}
disabled={pending || (mode === 'pair' ? pin.length !== 6 : !secret)}
className="flex-[2] h-12 text-[14px] rounded-lg disabled:opacity-40 font-semibold"
style={{ background: 'var(--gd-red)', color: '#fff' }}
>
{pending ? 'Logging in' : 'Log in'}
{pending ? (mode === 'pair' ? 'Pairing' : 'Logging in') : (mode === 'pair' ? 'Pair this device' : 'Log in')}
</button>
</div>
</div>
</div>
);
}
// 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<string | null>(null);
const [secondsLeft, setSecondsLeft] = React.useState(0);
const [error, setError] = React.useState<string | null>(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 (
<div
onClick={onClose}
className="fixed inset-0 grid place-items-center p-6"
style={{ zIndex: 9999, background: 'rgba(5,5,8,0.72)', backdropFilter: 'blur(8px)' }}
>
<div
onClick={(e) => 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)',
}}
>
<div className="px-6 py-5" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<div className="text-[13px] font-semibold mb-1">Pair another device</div>
<div className="text-[11.5px]" style={{ color: 'var(--gd-text-3)' }}>
On the new device (e.g. your Tesla browser) open https://roadtrip.tony.codes and click <b>Connect Tesla</b> → <b>Use 6-digit code</b>, then enter:
</div>
</div>
<div className="px-6 py-8">
{error ? (
<div className="text-[14px]" style={{ color: 'var(--gd-red)' }}>{error}</div>
) : code ? (
<>
<div
className="font-semibold tracking-[0.45em] num"
style={{ fontSize: 56, color: 'var(--gd-red)' }}
>
{code}
</div>
<div className="text-[12px] mt-3" style={{ color: 'var(--gd-text-3)' }}>
Expires in {mins}:{String(secs).padStart(2, '0')} · single-use
</div>
</>
) : (
<div className="text-[14px]" style={{ color: 'var(--gd-text-3)' }}>Generating code…</div>
)}
</div>
<div className="px-6 py-4" style={{ borderTop: '1px solid var(--gd-border)' }}>
<button
onClick={onClose}
className="w-full h-11 text-[13px] rounded-lg"
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
>
Done
</button>
</div>
</div>
+70
View File
@@ -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<string, { expiresAt: number }>();
// IP → recent redeem attempt timestamps (oldest first).
const redeemAttempts = new Map<string, number[]>();
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;