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' }); 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 { export interface TeslaActiveRoute {
destination: string; destination: string;
lat: number | null; lat: number | null;
+217 -58
View File
@@ -1,7 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap } from 'react-leaflet'; 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 { isMockEnabled, getMockScenario, setMockScenario, resetMockDrive } from '../lib/teslaMock';
import { detectInCar } from '../lib/incar'; import { detectInCar } from '../lib/incar';
import { computeBatteryPlan, type BatteryAtStop, type BatteryRisk } from '../lib/batteryPlan'; import { computeBatteryPlan, type BatteryAtStop, type BatteryRisk } from '../lib/batteryPlan';
@@ -12,7 +12,7 @@ import {
Zap, Bed, Flag, Home as HomeIcon, Coffee, Utensils, Camera, Zap, Bed, Flag, Home as HomeIcon, Coffee, Utensils, Camera,
ShoppingBag, Footprints, Wifi, MapPin, Route, Clock, TreePine, Euro, ShoppingBag, Footprints, Wifi, MapPin, Route, Clock, TreePine, Euro,
CalendarDays, Ship, Users, ExternalLink, Crosshair, ArrowUp, ArrowDown, CalendarDays, Ship, Users, ExternalLink, Crosshair, ArrowUp, ArrowDown,
Car, Battery, Navigation, Car, Battery, Navigation, Smartphone,
} from 'lucide-react'; } from 'lucide-react';
// Fix Leaflet default icons (we still need pins for non-active stops) // Fix Leaflet default icons (we still need pins for non-active stops)
@@ -1411,7 +1411,7 @@ function TopBar({
chatInput, setChatInput, onChatSubmit, chips, onRemoveChip, chatInput, setChatInput, onChatSubmit, chips, onRemoveChip,
vehicle, onOpenVehiclePanel, grokStatus, onOpenGpx, vehicle, onOpenVehiclePanel, grokStatus, onOpenGpx,
travelDates, onOpenDates, onUseMyLocation, travelDates, onOpenDates, onUseMyLocation,
teslaStatus, teslaState, onConnectTesla, onDisconnectTesla, inCar, teslaStatus, teslaState, onConnectTesla, onDisconnectTesla, onOpenPairCode, inCar,
}: { }: {
origin: string; destination: string; origin: string; destination: string;
onOriginChange: (v: string) => void; onOriginChange: (v: string) => void;
@@ -1430,6 +1430,7 @@ function TopBar({
teslaState: ReturnType<typeof useTesla>['state']; teslaState: ReturnType<typeof useTesla>['state'];
onConnectTesla: () => void; onConnectTesla: () => void;
onDisconnectTesla: () => void; onDisconnectTesla: () => void;
onOpenPairCode: () => void;
inCar: boolean; inCar: boolean;
}) { }) {
// Tesla connected → we know the car (and where it is), so the vehicle picker // Tesla connected → we know the car (and where it is), so the vehicle picker
@@ -1448,8 +1449,8 @@ function TopBar({
const datesEmpty = !travelDates.outbound; const datesEmpty = !travelDates.outbound;
return ( return (
<div <div
className="flex items-center gap-4 px-5 flex-shrink-0" className="flex items-center flex-wrap gap-x-3 gap-y-2 px-4 py-2 flex-shrink-0"
style={{ height: 62, background: 'var(--gd-bg)', borderBottom: '1px solid var(--gd-border)' }} style={{ minHeight: 62, background: 'var(--gd-bg)', borderBottom: '1px solid var(--gd-border)' }}
> >
{/* Brand */} {/* Brand */}
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
@@ -1479,7 +1480,7 @@ function TopBar({
) : ( ) : (
<div <div
className="flex items-center rounded-[10px] overflow-hidden" 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="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)' }} /> <div className="w-1.5 h-1.5 rounded-full" style={{ background: 'var(--gd-text-2)' }} />
@@ -1522,12 +1523,12 @@ function TopBar({
</div> </div>
)} )}
<div className="flex-1" /> <div className="flex-1 hidden md:block" />
{/* Chat composer with chips */} {/* Chat composer with chips */}
<div <div
className="flex items-center pl-3 rounded-[10px]" 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, minWidth: 360 }} 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)' }} /> <Sparkles className="w-3.5 h-3.5" style={{ color: 'var(--gd-red)' }} />
{chips.length > 0 && ( {chips.length > 0 && (
@@ -1602,22 +1603,34 @@ function TopBar({
{teslaStatus?.available && ( {teslaStatus?.available && (
teslaStatus.connected ? ( teslaStatus.connected ? (
<button <div
onClick={onDisconnectTesla} className="h-[38px] inline-flex items-center rounded-[10px] overflow-hidden"
title="Disconnect Tesla"
className="h-[38px] px-3 inline-flex items-center gap-2 rounded-[10px] cursor-pointer"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }} style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}
> >
<Car className="w-3.5 h-3.5" style={{ color: 'var(--gd-red)' }} /> <button
<div className="flex flex-col items-start leading-[1.15]"> onClick={onDisconnectTesla}
<div className="text-[11.5px] font-medium"> title="Disconnect Tesla"
{teslaState?.battery != null ? `${teslaState.battery}%` : 'Connected'} 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>
<div className="text-[9.5px] num" style={{ color: 'var(--gd-text-3)' }}> </button>
{teslaState?.rangeKm != null ? `${teslaState.rangeKm} km` : (teslaState?.asleep ? 'asleep' : '')} <button
</div> onClick={onOpenPairCode}
</div> title="Pair another device with a 6-digit code"
</button> 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 <button
onClick={onConnectTesla} onClick={onConnectTesla}
@@ -1875,6 +1888,7 @@ export default function TeslaTripPlanner() {
const [datePickerOpen, setDatePickerOpen] = useState(false); const [datePickerOpen, setDatePickerOpen] = useState(false);
const [dateAnchor, setDateAnchor] = useState<DOMRect | null>(null); const [dateAnchor, setDateAnchor] = useState<DOMRect | null>(null);
const [ownerLoginOpen, setOwnerLoginOpen] = useState(false); const [ownerLoginOpen, setOwnerLoginOpen] = useState(false);
const [pairCodeOpen, setPairCodeOpen] = useState(false);
// After successful owner login, fire this pending action. // After successful owner login, fire this pending action.
const ownerLoginThenRef = React.useRef<null | (() => void)>(null); const ownerLoginThenRef = React.useRef<null | (() => void)>(null);
const lastODSent = React.useRef<{ from: string; to: string } | null>(null); const lastODSent = React.useRef<{ from: string; to: string } | null>(null);
@@ -2454,6 +2468,7 @@ export default function TeslaTripPlanner() {
await tesla.refreshStatus(); await tesla.refreshStatus();
toast.success('Tesla disconnected'); toast.success('Tesla disconnected');
}} }}
onOpenPairCode={() => setPairCodeOpen(true)}
inCar={inCar} inCar={inCar}
/> />
@@ -2469,10 +2484,12 @@ export default function TeslaTripPlanner() {
/> />
)} )}
{/* Body: map left, rail right */} {/* Body: map left + rail right on wide screens, map top + rail below
<div className="flex flex-1 overflow-hidden"> on narrow ones (tablets, Tesla 3/Y portrait, smaller car browsers). */}
{/* Map */} <div className="flex flex-col lg:flex-row flex-1 overflow-hidden">
<div className="flex-1 relative" style={{ background: '#0a0f1a' }}> {/* 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 <MapContainer
center={[51.5, 2.0]} zoom={5} center={[51.5, 2.0]} zoom={5}
style={{ height: '100%', width: '100%', background: '#0a0f1a' }} style={{ height: '100%', width: '100%', background: '#0a0f1a' }}
@@ -2649,10 +2666,11 @@ export default function TeslaTripPlanner() {
)} )}
</div> </div>
{/* Stops rail */} {/* Stops rail — full width below the map on narrow screens, 540px
fixed right column on wide screens. */}
<aside <aside
className="flex flex-col overflow-hidden flex-shrink-0" className="flex flex-col overflow-hidden flex-shrink-0 w-full lg:w-[540px]"
style={{ width: 540, background: 'var(--gd-bg-2)', borderLeft: '1px solid var(--gd-border)' }} style={{ background: 'var(--gd-bg-2)' }}
> >
{/* Trip header */} {/* Trip header */}
<div className="px-5 py-3.5 flex items-center gap-2.5" style={{ borderBottom: '1px solid var(--gd-border)' }}> <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(); if (fn) fn();
}} }}
/> />
<OwnerPairCodeDisplay
open={pairCodeOpen}
onClose={() => setPairCodeOpen(false)}
/>
</ErrorBoundary> </ErrorBoundary>
); );
} }
@@ -3104,31 +3127,40 @@ function DrivingMode({ tesla, nextStop, vehicle, onSendToNav, onExit }: {
function OwnerLoginModal({ open, onClose, onSuccess }: { function OwnerLoginModal({ open, onClose, onSuccess }: {
open: boolean; onClose: () => void; onSuccess: () => void; open: boolean; onClose: () => void; onSuccess: () => void;
}) { }) {
const [mode, setMode] = React.useState<'pair' | 'secret'>('pair');
const [secret, setSecret] = React.useState(''); const [secret, setSecret] = React.useState('');
const [pin, setPin] = React.useState('');
const [pending, setPending] = React.useState(false); const [pending, setPending] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
if (!open) return; if (!open) return;
setPin('');
setSecret('');
setMode('pair');
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]); }, [open, onClose]);
if (!open) return null; if (!open) return null;
const submit = async () => {
const submitSecret = async () => {
if (!secret) return; if (!secret) return;
setPending(true); setPending(true);
try { try {
const ok = await loginOwner(secret); const ok = await loginOwner(secret);
if (ok) { if (ok) { toast.success('Logged in as owner'); setSecret(''); onSuccess(); }
toast.success('Logged in as owner'); else toast.error('Invalid owner secret');
setSecret(''); } finally { setPending(false); }
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 ( return (
<div <div
onClick={onClose} onClick={onClose}
@@ -3137,7 +3169,7 @@ function OwnerLoginModal({ open, onClose, onSuccess }: {
> >
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="w-[440px] max-w-full overflow-hidden" className="w-[480px] max-w-full overflow-hidden"
style={{ style={{
background: 'var(--gd-bg-2)', background: 'var(--gd-bg-2)',
border: '1px solid var(--gd-border-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="px-5 py-4" style={{ borderBottom: '1px solid var(--gd-border)' }}>
<div className="text-[14px] font-semibold">Owner login required</div> <div className="text-[14px] font-semibold">Owner login required</div>
<div className="text-[11.5px] mt-0.5" style={{ color: 'var(--gd-text-3)' }}> <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> </div>
<div className="px-5 py-4">
<input <div className="px-5 pt-4 pb-1 flex gap-1.5">
type="password" {(['pair', 'secret'] as const).map(m => (
autoFocus <button
value={secret} key={m}
onChange={(e) => setSecret(e.target.value)} onClick={() => setMode(m)}
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }} className="flex-1 h-9 text-[12px] rounded-lg transition"
placeholder="Owner secret" style={{
className="w-full text-[13.5px] px-3 py-2.5 rounded-lg outline-none" background: mode === m ? 'var(--gd-red-soft)' : 'transparent',
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)' }} 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>
<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)' }}> <div className="px-5 py-4 flex items-center gap-2" style={{ borderTop: '1px solid var(--gd-border)' }}>
<button <button
onClick={onClose} 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)' }} style={{ background: 'transparent', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
> >
Cancel Cancel
</button> </button>
<button <button
onClick={submit} onClick={mode === 'pair' ? submitPin : submitSecret}
disabled={!secret || pending} disabled={pending || (mode === 'pair' ? pin.length !== 6 : !secret)}
className="flex-[2] h-10 text-[12.5px] rounded-lg disabled:opacity-40" className="flex-[2] h-12 text-[14px] rounded-lg disabled:opacity-40 font-semibold"
style={{ background: 'var(--gd-red)', color: '#fff' }} 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> </button>
</div> </div>
</div> </div>
+70
View File
@@ -1,5 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import crypto from 'node:crypto';
import { env } from '../config/env.js'; import { env } from '../config/env.js';
import { createLogger } from '../lib/logger.js';
import { import {
clearOwnerCookie, clearOwnerCookie,
isOwnerAuthenticated, isOwnerAuthenticated,
@@ -7,8 +9,32 @@ import {
verifyOwnerSecret, verifyOwnerSecret,
} from '../lib/ownerAuth.js'; } from '../lib/ownerAuth.js';
const log = createLogger('owner');
const router = Router(); 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) => { router.get('/api/auth/owner/status', (req, res) => {
res.json({ res.json({
authenticated: isOwnerAuthenticated(req), authenticated: isOwnerAuthenticated(req),
@@ -35,4 +61,48 @@ router.post('/api/auth/owner/logout', (_req, res) => {
res.json({ ok: true }); 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; export default router;