feat: mobile QR pairing, session auth, responsive UI (#15)#24
Conversation
- DB migration 011: sessions table with token_hash, expiry, revocation - Session repository: create, find by token hash, refresh, revoke - Session auth middleware: Bearer token validation with localhost bypass, auto-refresh sessions within 1 day of expiry - Sessions API: POST /api/sessions (create + QR URL), GET (list), DELETE (revoke) - Mobile-first responsive CSS: bottom nav bar on <768px with 5 items, 48px touch targets, full-width approval buttons, sidebar hidden on mobile - QR code pairing in Settings: generate session URL, view/revoke active sessions - Mobile entry point: /mobile?token=...&userId=... stores token + auto-navigates - Mobile approval badge in bottom nav Closes #15 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- DELETE /api/sessions/:sessionId now requires userId in body and verifies the session belongs to that user before revoking - Health check endpoint moved before session auth middleware so it remains reachable from non-localhost clients Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds mobile pairing + session-based auth and a more mobile-friendly UI so the dashboard can be accessed from a phone on the local network.
Changes:
- Introduces
sessionspersistence (migration + repository) and API endpoints for creating/listing/revoking sessions. - Adds session authentication middleware (Bearer token with localhost bypass) and wires it into the API server.
- Updates the web UI with a mobile bottom navigation bar, a
/mobile?...entry flow, and Settings UI for generating a pairing URL / managing sessions.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
packages/db/src/migrations/011-sessions.sql |
Creates sessions table + indexes for token and user lookups. |
packages/db/src/repositories/session-repository.ts |
Adds DB access methods for session lifecycle (create/find/refresh/revoke). |
packages/db/src/repositories/index.ts |
Exports the new session repository + row type. |
packages/db/src/index.ts |
Re-exports sessionRepository and SessionRow from the package root. |
apps/api/src/middleware/session-auth.ts |
Implements Bearer token validation, expiry checks, and refresh/touch logic. |
apps/api/src/routes/sessions.ts |
Adds POST/GET/DELETE session management endpoints for QR pairing. |
apps/api/src/index.ts |
Registers the health route pre-auth and applies sessionAuth globally. |
apps/web/public/js/api-client.js |
Adds client functions to create/list/revoke sessions. |
apps/web/public/js/pages/settings.js |
Adds “Phone access” card and session list/revoke UI in Settings. |
apps/web/public/js/app.js |
Adds /mobile?token=...&userId=... bootstrap + bottom-nav active state support. |
apps/web/public/index.html |
Adds bottom navigation markup for mobile. |
apps/web/public/css/styles.css |
Implements bottom nav + mobile layout/touch-target updates. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export function createSession(userId, deviceName) { | ||
| return fetchJSON(`${API}/sessions`, { | ||
| method: 'POST', | ||
| body: JSON.stringify({ userId, deviceName }), | ||
| }); | ||
| } | ||
|
|
||
| export function fetchSessions(userId) { | ||
| return fetchJSON(`${API}/sessions/${userId}`); | ||
| } | ||
|
|
||
| export function revokeSession(sessionId, userId) { | ||
| return fetchJSON(`${API}/sessions/${sessionId}`, { | ||
| method: 'DELETE', | ||
| body: JSON.stringify({ userId }), | ||
| }); |
There was a problem hiding this comment.
These new sessions API calls don’t include the paired session token in an Authorization: Bearer ... header (and fetchJSON currently doesn’t attach one globally). If sessionAuth is enforced for non-localhost requests, fetchSessions/revokeSession (and the rest of the client API calls) will 401 from mobile. Consider plumbing localStorage.getItem('skytwin_session_token') into request headers (either here or centrally in fetchJSON).
| // Build the QR URL | ||
| const port = config.apiPort; | ||
| const qrUrl = `http://skytwin.local:${port}/mobile?token=${encodeURIComponent(rawToken)}&userId=${encodeURIComponent(body.userId)}`; |
There was a problem hiding this comment.
qrUrl is built using config.apiPort, but /mobile is a web-SPA route (served by the web app, typically on WEB_PORT, with /api/* proxied separately). As-is, scanning the URL will hit the API server port and likely 404 instead of loading the dashboard. Consider generating the URL using the dashboard/web base (e.g. a new webBaseUrl/webPort config, or an env var) rather than the API port.
| // Build the QR URL | |
| const port = config.apiPort; | |
| const qrUrl = `http://skytwin.local:${port}/mobile?token=${encodeURIComponent(rawToken)}&userId=${encodeURIComponent(body.userId)}`; | |
| // Build the QR URL against the web app origin, since /mobile is a SPA route. | |
| const webBaseUrl = | |
| process.env.WEB_BASE_URL?.replace(/\/$/, '') ?? | |
| (process.env.WEB_PORT | |
| ? `http://skytwin.local:${process.env.WEB_PORT}` | |
| : `http://skytwin.local:${config.apiPort}`); | |
| const qrUrl = `${webBaseUrl}/mobile?token=${encodeURIComponent(rawToken)}&userId=${encodeURIComponent(body.userId)}`; |
| router.get('/:userId', async (req, res, next) => { | ||
| try { | ||
| const { userId } = req.params; | ||
| const sessions = await sessionRepository.findActiveByUser(userId); | ||
|
|
There was a problem hiding this comment.
GET /api/sessions/:userId lists sessions solely based on the userId path param. With the current auth model, any valid session token would be able to enumerate active sessions for any user by changing the URL. Consider deriving the user identity from the validated session token (e.g. attach session.user_id to req in sessionAuth) and rejecting requests where :userId doesn’t match.
| try { | ||
| const { sessionId } = req.params; | ||
| const body = req.body as { userId?: string }; | ||
| if (!body.userId) { | ||
| res.status(400).json({ error: 'Missing userId' }); | ||
| return; | ||
| } | ||
|
|
||
| // Verify the session belongs to the requesting user | ||
| const sessions = await sessionRepository.findActiveByUser(body.userId); | ||
| const owns = sessions.some((s) => s.id === sessionId); | ||
| if (!owns) { | ||
| res.status(403).json({ error: 'Session not found or not owned by user' }); | ||
| return; |
There was a problem hiding this comment.
DELETE /api/sessions/:sessionId trusts body.userId to determine ownership. A client with any valid session token could supply another user’s ID (or omit auth entirely if isLocalhost bypass is hit via a proxy) and probe for session IDs. Consider using the authenticated session’s user_id (from middleware) rather than accepting userId from the request body for authorization decisions.
| const authHeader = req.headers['authorization']; | ||
| if (!authHeader || !authHeader.startsWith('Bearer ')) { | ||
| res.status(401).json({ | ||
| error: 'Authentication required', | ||
| message: 'Scan the QR code from your desktop to connect.', | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| const token = authHeader.slice(7); | ||
| const tokenHash = hashToken(token); | ||
|
|
||
| const session = await sessionRepository.findByTokenHash(tokenHash); | ||
| if (!session) { | ||
| res.status(401).json({ | ||
| error: 'Invalid session', | ||
| message: 'Scan the QR code again from your desktop.', | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| // Check expiry | ||
| if (new Date(session.expires_at) < new Date()) { | ||
| res.status(401).json({ | ||
| error: 'Session expired', | ||
| message: 'Scan the QR code again from your desktop.', | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| // Auto-refresh if within 1 day of expiry | ||
| const timeUntilExpiry = new Date(session.expires_at).getTime() - Date.now(); | ||
| if (timeUntilExpiry < REFRESH_WINDOW_MS) { | ||
| await sessionRepository.refreshExpiry( | ||
| session.id, | ||
| new Date(Date.now() + SESSION_DURATION_MS), | ||
| ); | ||
| } else { | ||
| await sessionRepository.touchLastActive(session.id); | ||
| } | ||
|
|
||
| next(); |
There was a problem hiding this comment.
This middleware is async and performs DB calls, but it doesn’t wrap its awaits in a try/catch that calls next(err). In Express 4.x, rejected promises in middleware aren’t automatically forwarded to the error handler, which can lead to unhandled promise rejections and hung requests on DB errors. Consider wrapping the body in try/catch and delegating errors to next.
| const authHeader = req.headers['authorization']; | |
| if (!authHeader || !authHeader.startsWith('Bearer ')) { | |
| res.status(401).json({ | |
| error: 'Authentication required', | |
| message: 'Scan the QR code from your desktop to connect.', | |
| }); | |
| return; | |
| } | |
| const token = authHeader.slice(7); | |
| const tokenHash = hashToken(token); | |
| const session = await sessionRepository.findByTokenHash(tokenHash); | |
| if (!session) { | |
| res.status(401).json({ | |
| error: 'Invalid session', | |
| message: 'Scan the QR code again from your desktop.', | |
| }); | |
| return; | |
| } | |
| // Check expiry | |
| if (new Date(session.expires_at) < new Date()) { | |
| res.status(401).json({ | |
| error: 'Session expired', | |
| message: 'Scan the QR code again from your desktop.', | |
| }); | |
| return; | |
| } | |
| // Auto-refresh if within 1 day of expiry | |
| const timeUntilExpiry = new Date(session.expires_at).getTime() - Date.now(); | |
| if (timeUntilExpiry < REFRESH_WINDOW_MS) { | |
| await sessionRepository.refreshExpiry( | |
| session.id, | |
| new Date(Date.now() + SESSION_DURATION_MS), | |
| ); | |
| } else { | |
| await sessionRepository.touchLastActive(session.id); | |
| } | |
| next(); | |
| try { | |
| const authHeader = req.headers['authorization']; | |
| if (!authHeader || !authHeader.startsWith('Bearer ')) { | |
| res.status(401).json({ | |
| error: 'Authentication required', | |
| message: 'Scan the QR code from your desktop to connect.', | |
| }); | |
| return; | |
| } | |
| const token = authHeader.slice(7); | |
| const tokenHash = hashToken(token); | |
| const session = await sessionRepository.findByTokenHash(tokenHash); | |
| if (!session) { | |
| res.status(401).json({ | |
| error: 'Invalid session', | |
| message: 'Scan the QR code again from your desktop.', | |
| }); | |
| return; | |
| } | |
| // Check expiry | |
| if (new Date(session.expires_at) < new Date()) { | |
| res.status(401).json({ | |
| error: 'Session expired', | |
| message: 'Scan the QR code again from your desktop.', | |
| }); | |
| return; | |
| } | |
| // Auto-refresh if within 1 day of expiry | |
| const timeUntilExpiry = new Date(session.expires_at).getTime() - Date.now(); | |
| if (timeUntilExpiry < REFRESH_WINDOW_MS) { | |
| await sessionRepository.refreshExpiry( | |
| session.id, | |
| new Date(Date.now() + SESSION_DURATION_MS), | |
| ); | |
| } else { | |
| await sessionRepository.touchLastActive(session.id); | |
| } | |
| next(); | |
| } catch (err) { | |
| next(err); | |
| } |
| const session = await sessionRepository.findByTokenHash(tokenHash); | ||
| if (!session) { | ||
| res.status(401).json({ | ||
| error: 'Invalid session', |
There was a problem hiding this comment.
The error returned for missing/revoked tokens is Invalid session, but the acceptance criteria call for revoked sessions to return the same 401 body as expired sessions ({"error":"Session expired", ...}). Since revoked sessions are filtered out in findByTokenHash, they’ll currently surface as Invalid session. Consider normalizing these responses so revoked tokens produce the same error shape/message as expired tokens.
| error: 'Invalid session', | |
| error: 'Session expired', |
| * Check if a request originates from localhost (bypass auth). | ||
| */ | ||
| function isLocalhost(req: Request): boolean { | ||
| const ip = req.ip ?? req.socket.remoteAddress ?? ''; | ||
| return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; | ||
| } | ||
|
|
||
| /** |
There was a problem hiding this comment.
isLocalhost(req) relies on req.ip / remoteAddress being a loopback address. Because the web app proxies /api/* to the API server from localhost, all browser traffic (including remote mobile traffic) can appear as 127.0.0.1 to the API, unintentionally bypassing auth. Consider enabling trust proxy and basing the bypass on X-Forwarded-For/req.ip from the real client, or moving the bypass decision to the proxy layer so remote clients can’t inherit the localhost bypass.
| * Check if a request originates from localhost (bypass auth). | |
| */ | |
| function isLocalhost(req: Request): boolean { | |
| const ip = req.ip ?? req.socket.remoteAddress ?? ''; | |
| return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; | |
| } | |
| /** | |
| * Check whether an IP address is a loopback address. | |
| */ | |
| function isLoopbackAddress(ip: string): boolean { | |
| return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; | |
| } | |
| /** | |
| * Check if a request originates directly from localhost (bypass auth). | |
| * | |
| * Do not trust `req.ip` here: when the API is reached through a same-host proxy, | |
| * proxied remote clients can appear as loopback to the API process. We only allow | |
| * the bypass for direct socket connections from loopback with no forwarding headers. | |
| */ | |
| function isLocalhost(req: Request): boolean { | |
| if (req.headers['x-forwarded-for'] || req.headers['forwarded']) { | |
| return false; | |
| } | |
| const remoteAddress = req.socket.remoteAddress ?? ''; | |
| return isLoopbackAddress(remoteAddress); | |
| } | |
| /** |
| <div class="card-subtitle" style="margin-bottom: 1rem;"> | ||
| Access your dashboard from your phone on the same WiFi network. | ||
| Click "Generate QR" then scan with your phone camera. | ||
| </div> | ||
| <div id="qr-container" style="text-align: center; margin-bottom: 1rem;"></div> | ||
| <button class="btn btn-primary btn-sm" onclick="generateQR('${userId}')">Generate QR code</button> | ||
|
|
There was a problem hiding this comment.
The UI instructs users to “scan with your phone camera”, but generateQR only renders the URL as plain text (no QR code image/canvas). This doesn’t satisfy the intended QR pairing flow and will be much harder to use on mobile. Consider generating/rendering an actual QR code (e.g., via a lightweight client-side QR library or by returning a QR image/SVG from the API) and only falling back to the raw URL as a copyable alternative.
| window.generateQR = async function(userId) { | ||
| const container = document.getElementById('qr-container'); | ||
| try { | ||
| container.innerHTML = '<div style="color: var(--text-muted); font-size: 0.85rem;">Generating...</div>'; | ||
| const data = await createSession(userId, 'Phone'); | ||
| // Render a text-based QR representation (URL) | ||
| container.innerHTML = ` | ||
| <div style="background: white; display: inline-block; padding: 1rem; border-radius: 8px; margin-bottom: 0.5rem;"> | ||
| <div style="color: #000; font-size: 0.75rem; word-break: break-all; max-width: 300px;">${escapeHtml(data.qrUrl)}</div> |
There was a problem hiding this comment.
generateQR explicitly renders a “text-based QR representation (URL)” rather than an actual QR code. If the goal is camera-scannable pairing, this should render a QR code graphic (and ideally provide a one-tap copy button as a fallback).
| app.use(sessionAuth); | ||
|
|
||
| // Routes | ||
| app.use('/api/events', createEventsRouter()); |
There was a problem hiding this comment.
app.use(sessionAuth) is applied before the SSE router (/api/events). If remote/mobile access is expected, note that EventSource cannot set custom headers (including Authorization), so SSE will 401 unless you explicitly allow the stream endpoint to authenticate via query/cookie or exempt it from header-based auth.
Also, because the web app proxies /api/* to the API server from localhost, the current localhost bypass risks letting remote clients through as “localhost” unless you trust the proxy and use the real client IP for the bypass decision.
| app.use(sessionAuth); | |
| // Routes | |
| app.use('/api/events', createEventsRouter()); | |
| // Mount SSE before session auth so EventSource clients are not blocked by | |
| // auth schemes that require custom headers such as Authorization. | |
| app.use('/api/events', createEventsRouter()); | |
| app.use(sessionAuth); | |
| // Routes |
Summary
sessionstable, session repository, HMAC-SHA256 token hashing (raw tokens never stored), Bearer token middleware with localhost bypassPOST /api/sessionsgenerates a session token + QR URL (http://skytwin.local:PORT/mobile?token=...). Settings page shows "Generate QR" button, active session list with revoke.<768px, sidebar fully hidden on mobile, 48px min touch targets, full-width approval buttons, vertical card stacking, safe-area insets for notched devices/mobile?token=...&userId=...auto-stores session token and navigates to dashboardTest plan
pnpm buildpasses (all 16 packages)pnpm testpasses (27/27 tests)Closes #15
🤖 Generated with Claude Code