Skip to content

feat: mobile QR pairing, session auth, responsive UI (#15)#24

Merged
jayzalowitz merged 3 commits into
mainfrom
jayzalowitz/mobile-access
Apr 5, 2026
Merged

feat: mobile QR pairing, session auth, responsive UI (#15)#24
jayzalowitz merged 3 commits into
mainfrom
jayzalowitz/mobile-access

Conversation

@jayzalowitz

Copy link
Copy Markdown
Owner

Summary

  • Session auth infrastructure — DB migration for sessions table, session repository, HMAC-SHA256 token hashing (raw tokens never stored), Bearer token middleware with localhost bypass
  • QR pairing flowPOST /api/sessions generates a session token + QR URL (http://skytwin.local:PORT/mobile?token=...). Settings page shows "Generate QR" button, active session list with revoke.
  • Mobile responsive CSS — Bottom nav bar with 5 items on <768px, sidebar fully hidden on mobile, 48px min touch targets, full-width approval buttons, vertical card stacking, safe-area insets for notched devices
  • Mobile entry point/mobile?token=...&userId=... auto-stores session token and navigates to dashboard

Test plan

  • pnpm build passes (all 16 packages)
  • pnpm test passes (27/27 tests)
  • Verify session auth middleware allows localhost without token
  • Verify QR URL generation in Settings page
  • Verify session revocation returns 401 on next request
  • Verify bottom nav renders on 375px/390px/768px widths
  • Verify all interactive elements >= 44px on mobile
  • Verify approval buttons are full-width on mobile

Closes #15

🤖 Generated with Claude Code

jayzalowitz and others added 2 commits April 4, 2026 18:30
- 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>
@jayzalowitz jayzalowitz added the copilot Built with AI copilot label Apr 5, 2026
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 5, 2026 02:26
@jayzalowitz jayzalowitz merged commit 837c7af into main Apr 5, 2026
3 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 sessions persistence (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.

Comment on lines +190 to +205
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 }),
});

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +43 to +45
// Build the QR URL
const port = config.apiPort;
const qrUrl = `http://skytwin.local:${port}/mobile?token=${encodeURIComponent(rawToken)}&userId=${encodeURIComponent(body.userId)}`;

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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)}`;

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +67
router.get('/:userId', async (req, res, next) => {
try {
const { userId } = req.params;
const sessions = await sessionRepository.findActiveByUser(userId);

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +88 to +101
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;

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +42 to +83
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();

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);
}

Copilot uses AI. Check for mistakes.
const session = await sessionRepository.findByTokenHash(tokenHash);
if (!session) {
res.status(401).json({
error: 'Invalid session',

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
error: 'Invalid session',
error: 'Session expired',

Copilot uses AI. Check for mistakes.
Comment on lines +17 to +24
* 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';
}

/**

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
* 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);
}
/**

Copilot uses AI. Check for mistakes.
Comment on lines +226 to +232
<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>

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +408 to +416
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>

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread apps/api/src/index.ts
Comment on lines +56 to 59
app.use(sessionAuth);

// Routes
app.use('/api/events', createEventsRouter());

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

copilot Built with AI copilot

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mobile local access: QR pairing, mDNS, responsive dashboard

2 participants