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:
@@ -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;
|
||||||
|
|||||||
@@ -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,11 +1603,14 @@ function TopBar({
|
|||||||
|
|
||||||
{teslaStatus?.available && (
|
{teslaStatus?.available && (
|
||||||
teslaStatus.connected ? (
|
teslaStatus.connected ? (
|
||||||
|
<div
|
||||||
|
className="h-[38px] inline-flex items-center rounded-[10px] overflow-hidden"
|
||||||
|
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={onDisconnectTesla}
|
onClick={onDisconnectTesla}
|
||||||
title="Disconnect Tesla"
|
title="Disconnect Tesla"
|
||||||
className="h-[38px] px-3 inline-flex items-center gap-2 rounded-[10px] cursor-pointer"
|
className="h-full px-3 inline-flex items-center gap-2 cursor-pointer"
|
||||||
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}
|
|
||||||
>
|
>
|
||||||
<Car className="w-3.5 h-3.5" style={{ color: 'var(--gd-red)' }} />
|
<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="flex flex-col items-start leading-[1.15]">
|
||||||
@@ -1618,6 +1622,15 @@ function TopBar({
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<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 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">
|
<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
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
autoFocus
|
autoFocus
|
||||||
value={secret}
|
value={secret}
|
||||||
onChange={(e) => setSecret(e.target.value)}
|
onChange={(e) => setSecret(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') submitSecret(); }}
|
||||||
placeholder="Owner secret"
|
placeholder="Owner secret"
|
||||||
className="w-full text-[13.5px] px-3 py-2.5 rounded-lg outline-none"
|
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)' }}
|
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)', color: 'var(--gd-text)' }}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user