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
+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;