From d057626e1577a9f16b303cffe65d06c8164cfc0f Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Mon, 16 Mar 2026 17:50:00 +0000 Subject: [PATCH] fix(app): stabilize auth bootstrap and direct backend integration --- Backend/index.ts | 14 +- WebApp/src/app.html | 9 +- WebApp/src/lib/app/api.js | 16 +- WebApp/src/lib/app/controller.js | 256 +++++++++++++++++++++++++------ WebApp/src/lib/app/store.js | 2 +- WebApp/vite.config.ts | 3 +- 6 files changed, 239 insertions(+), 61 deletions(-) diff --git a/Backend/index.ts b/Backend/index.ts index 62528f2..71fa44c 100644 --- a/Backend/index.ts +++ b/Backend/index.ts @@ -30,6 +30,10 @@ const openApiDocument = buildOpenApiDocument(); const trustedOrigins = process.env.BETTER_AUTH_TRUSTED_ORIGINS ? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(',').map((origin) => origin.trim()).filter(Boolean) : []; +const corsMiddleware = cors({ + origin: trustedOrigins.length > 0 ? trustedOrigins : true, + credentials: true, +}); const buildMinioConnectOrigin = (): string | null => { const endpoint = process.env.MINIO_ENDPOINT?.trim(); @@ -71,8 +75,6 @@ app.get('/openapi.json', (_req, res) => { app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument)); -app.all('/api/auth/*splat', toNodeHandler(auth)); - app.use( helmet({ contentSecurityPolicy: { @@ -88,12 +90,8 @@ app.use( }, }), ); -app.use( - cors({ - origin: trustedOrigins.length > 0 ? trustedOrigins : true, - credentials: true, - }), -); +app.use(corsMiddleware); +app.all('/api/auth/*splat', corsMiddleware, toNodeHandler(auth)); app.use(rateLimit({ keyPrefix: 'global', windowMs: 60_000, max: 400 })); app.use(requestContext); app.use(express.json()); diff --git a/WebApp/src/app.html b/WebApp/src/app.html index f273cc5..fb2f5f7 100644 --- a/WebApp/src/app.html +++ b/WebApp/src/app.html @@ -1,11 +1,16 @@ - + + %sveltekit.head% - +
%sveltekit.body%
diff --git a/WebApp/src/lib/app/api.js b/WebApp/src/lib/app/api.js index 9c7b023..effce30 100644 --- a/WebApp/src/lib/app/api.js +++ b/WebApp/src/lib/app/api.js @@ -2,6 +2,17 @@ // @ts-nocheck import { getAppState } from './store'; +const rawBackendUrl = import.meta.env.VITE_BACKEND_URL ?? 'http://localhost:3000'; +const backendUrl = rawBackendUrl.replace(/\/+$/, ''); + +const toBackendUrl = (path) => { + if (/^https?:\/\//i.test(path)) { + return path; + } + + return `${backendUrl}${path.startsWith('/') ? path : `/${path}`}`; +}; + const request = async (path, options = {}) => { const { deviceToken } = getAppState(); const headers = { 'Content-Type': 'application/json' }; @@ -10,8 +21,9 @@ const request = async (path, options = {}) => { headers.Authorization = `Bearer ${deviceToken}`; } - const response = await fetch(path, { + const response = await fetch(toBackendUrl(path), { ...options, + credentials: 'include', headers: { ...headers, ...(options.headers || {}) @@ -26,6 +38,8 @@ const request = async (path, options = {}) => { return data; }; +export const getBackendUrl = () => backendUrl; + export const api = { request, auth: { diff --git a/WebApp/src/lib/app/controller.js b/WebApp/src/lib/app/controller.js index 9e7ca6a..3835a6e 100644 --- a/WebApp/src/lib/app/controller.js +++ b/WebApp/src/lib/app/controller.js @@ -2,7 +2,7 @@ // @ts-nocheck import { io } from 'socket.io-client'; -import { api } from './api'; +import { api, getBackendUrl } from './api'; import { createMotionDetector } from './motion-detector'; import { getAppState, patchAppState, resetAppState, setAppState } from './store'; @@ -14,6 +14,13 @@ const PAGE_PATHS = { activity: '/activity', settings: '/settings' }; +const DEVICE_STORAGE_KEY = 'mobileSimDevice'; +const INVALID_DEVICE_TOKEN_ERRORS = new Set([ + 'Missing device token', + 'Invalid device token', + 'Device not found', + 'Token role does not match device role' +]); const MOTION_DETECTION_SETTINGS_STORAGE_KEY = 'securecam-motion-detection-settings'; const MOTION_DETECTION_PROFILES = { @@ -253,6 +260,151 @@ const navigateToScreen = (screen, options = {}) => { return false; }; +const readSavedDeviceRecord = () => { + if (typeof localStorage === 'undefined') { + return null; + } + + const saved = localStorage.getItem(DEVICE_STORAGE_KEY); + if (!saved) { + return null; + } + + try { + return JSON.parse(saved); + } catch (error) { + console.error('Failed to parse saved device', error); + localStorage.removeItem(DEVICE_STORAGE_KEY); + return null; + } +}; + +const persistSavedDeviceRecord = ({ device, deviceToken, userId }) => { + if (typeof localStorage === 'undefined') { + return; + } + + localStorage.setItem( + DEVICE_STORAGE_KEY, + JSON.stringify({ + device, + deviceToken, + userId + }) + ); +}; + +const clearSavedDeviceRecord = () => { + if (typeof localStorage === 'undefined') { + return; + } + + localStorage.removeItem(DEVICE_STORAGE_KEY); +}; + +const applySavedDeviceState = (device, deviceToken) => { + setAppState({ + device, + deviceToken, + onboardingForm: { + ...getAppState().onboardingForm, + name: device?.name ?? '', + role: device?.role ?? 'client', + pushToken: '' + } + }); +}; + +const clearDeviceState = () => { + setAppState({ + device: null, + deviceToken: null, + socketConnected: false, + isMotionActive: false, + activeMotionSource: null, + cameraStatus: 'idle', + cameraPreviewReady: false, + linkedCameras: [], + recordings: [], + activeCameraDeviceId: null, + activeStreamSessionId: null, + openLinkedCameraMenuId: null, + cameraSessions: {}, + connectedStreamSessionIds: [], + clientStreamMode: 'none', + clientPlaceholderText: 'Select a camera to view', + onboardingForm: { + ...getAppState().onboardingForm, + name: '', + role: 'client', + pushToken: '' + } + }); +}; + +const restoreSavedDeviceForSession = async (session, options = {}) => { + const { showMissingToast = false, showInvalidToast = false } = options; + const saved = readSavedDeviceRecord(); + + if (!saved) { + if (showMissingToast) { + pushToast('No saved device found', 'info'); + } + return false; + } + + const sessionUserId = session?.user?.id; + const savedUserId = typeof saved.userId === 'string' ? saved.userId : null; + const savedDeviceId = saved?.device?.id; + const savedDeviceToken = typeof saved?.deviceToken === 'string' ? saved.deviceToken : ''; + + if (!sessionUserId || !savedDeviceId || !savedDeviceToken) { + clearSavedDeviceRecord(); + clearDeviceState(); + if (showInvalidToast) { + pushToast('Saved device is incomplete. Please register again.', 'error'); + } + return false; + } + + if (savedUserId && savedUserId !== sessionUserId) { + clearSavedDeviceRecord(); + clearDeviceState(); + if (showInvalidToast) { + pushToast('Saved device belongs to a different account.', 'info'); + } + return false; + } + + try { + const result = await api.devices.list(); + const matchingDevice = result.devices?.find((device) => device.id === savedDeviceId); + + if (!matchingDevice) { + clearSavedDeviceRecord(); + clearDeviceState(); + if (showInvalidToast) { + pushToast('Saved device was not found for this account.', 'info'); + } + return false; + } + + applySavedDeviceState(matchingDevice, savedDeviceToken); + persistSavedDeviceRecord({ + device: matchingDevice, + deviceToken: savedDeviceToken, + userId: sessionUserId + }); + return true; + } catch (error) { + console.error('Failed to restore saved device', error); + if (showInvalidToast) { + pushToast('Unable to restore saved device right now.', 'error'); + } + return false; + } +}; + const setConnectedStreamSessionIds = () => { setAppState({ connectedStreamSessionIds: Array.from(connectedPeers) }); }; @@ -1267,7 +1419,10 @@ const connectSocket = () => { if (!deviceToken) return; if (socket) socket.disconnect(); - socket = io({ auth: { token: deviceToken } }); + socket = io(getBackendUrl(), { + auth: { token: deviceToken }, + withCredentials: true + }); socket.on('connect', () => { setAppState({ socketConnected: true }); @@ -1286,6 +1441,20 @@ const connectSocket = () => { applyMotionDetectionReadiness(); }); + socket.on('connect_error', (error) => { + const message = error?.message || 'Realtime connection failed'; + setAppState({ socketConnected: false }); + addActivity('System', `Realtime connection failed: ${message}`); + + if (INVALID_DEVICE_TOKEN_ERRORS.has(message)) { + void invalidateSavedDevice('Saved device is invalid for this account. Please register this browser again.'); + return; + } + + pushToast(message, 'error'); + applyMotionDetectionReadiness(); + }); + socket.on('command:received', async (payload) => { addActivity('Command', `Received ${payload.commandType}`); @@ -1519,6 +1688,17 @@ const cleanupConnectionState = async () => { requestedStreams.clear(); }; +const invalidateSavedDevice = async (message, options = {}) => { + const { showToast = true } = options; + clearSavedDeviceRecord(); + await cleanupConnectionState(); + clearDeviceState(); + if (showToast) { + pushToast(message || 'Saved device is no longer valid. Please register this browser again.', 'error'); + } + navigateToScreen('onboarding', { replace: true }); +}; + const enforceRouteForSession = () => { const state = getAppState(); const page = pageFromPath(window.location.pathname); @@ -1554,7 +1734,7 @@ const init = async () => { if (initPromise) return initPromise; initPromise = (async () => { - setAppState({ page: pageFromPath(window.location.pathname) }); + setAppState({ page: pageFromPath(window.location.pathname), loading: true }); if (navigator.mediaDevices?.addEventListener) { navigator.mediaDevices.addEventListener('devicechange', onMediaDeviceChange); } @@ -1562,39 +1742,24 @@ const init = async () => { document.addEventListener('visibilitychange', onVisibilityChange); } - const saved = localStorage.getItem('mobileSimDevice'); - if (saved) { - try { - const parsed = JSON.parse(saved); - setAppState({ - device: parsed.device, - deviceToken: parsed.deviceToken, - onboardingForm: { - ...getAppState().onboardingForm, - name: parsed.device?.name ?? '', - role: parsed.device?.role ?? 'client' - } - }); - } catch (error) { - console.error('Failed to load saved device', error); - } - } - setAppState({ motionDetection: loadMotionDetectionSettings() }); try { const session = await api.auth.getSession(); if (session?.session) { setAppState({ session }); - if (getAppState().deviceToken) { + const restoredSavedDevice = await restoreSavedDeviceForSession(session); + if (restoredSavedDevice) { connectSocket(); startPolling(); } } else { setAppState({ session: null }); + clearDeviceState(); } } catch { setAppState({ session: null }); + clearDeviceState(); } enforceRouteForSession(); @@ -1606,10 +1771,10 @@ const init = async () => { }); initialized = true; - })() - .finally(() => { - initPromise = null; - }); + })().finally(() => { + setAppState({ loading: false }); + initPromise = null; + }); return initPromise; }; @@ -1667,7 +1832,8 @@ const actions = { setAppState({ session, authForm: { ...state.authForm, password: '' } }); pushToast(`Welcome, ${session.user.name}`, 'success'); - if (getAppState().deviceToken) { + const restoredSavedDevice = await restoreSavedDeviceForSession(session); + if (restoredSavedDevice) { connectSocket(); startPolling(); navigateToScreen('home', { role: getAppState().device?.role }); @@ -1704,8 +1870,12 @@ const actions = { } const result = await api.devices.register(payload); - setAppState({ device: result.device, deviceToken: result.deviceToken }); - localStorage.setItem('mobileSimDevice', JSON.stringify({ device: result.device, deviceToken: result.deviceToken })); + applySavedDeviceState(result.device, result.deviceToken); + persistSavedDeviceRecord({ + device: result.device, + deviceToken: result.deviceToken, + userId: getAppState().session?.user?.id ?? null + }); pushToast('Device Registered', 'success'); connectSocket(); @@ -1717,27 +1887,17 @@ const actions = { }, loadSavedDevice() { - const saved = localStorage.getItem('mobileSimDevice'); - if (!saved) { - pushToast('No saved device found', 'info'); + const session = getAppState().session; + if (!session) { + pushToast('Please sign in before loading a saved device', 'error'); return; } - try { - const parsed = JSON.parse(saved); - setAppState({ - device: parsed.device, - deviceToken: parsed.deviceToken, - onboardingForm: { - ...getAppState().onboardingForm, - name: parsed.device?.name ?? '', - role: parsed.device?.role ?? 'client' - } - }); - pushToast('Loaded saved device', 'success'); - } catch { - pushToast('Saved device is invalid', 'error'); - } + void restoreSavedDeviceForSession(session, { showMissingToast: true, showInvalidToast: true }).then((restored) => { + if (restored) { + pushToast('Loaded saved device', 'success'); + } + }); }, async signOut() { @@ -1748,7 +1908,7 @@ const actions = { } await cleanupConnectionState(); - localStorage.removeItem('mobileSimDevice'); + clearSavedDeviceRecord(); const keep = { page: 'auth', toasts: [] }; resetAppState(keep); pushToast('Signed Out', 'info'); diff --git a/WebApp/src/lib/app/store.js b/WebApp/src/lib/app/store.js index ed22d7d..080f9f3 100644 --- a/WebApp/src/lib/app/store.js +++ b/WebApp/src/lib/app/store.js @@ -23,7 +23,7 @@ export const createInitialState = () => ({ activityLog: [], cameraSessions: {}, connectedStreamSessionIds: [], - loading: false, + loading: true, isRegistering: false, authForm: { email: '', diff --git a/WebApp/vite.config.ts b/WebApp/vite.config.ts index 1d42031..ac53059 100644 --- a/WebApp/vite.config.ts +++ b/WebApp/vite.config.ts @@ -4,6 +4,7 @@ import tailwindcss from '@tailwindcss/vite'; import { sveltekit } from '@sveltejs/kit/vite'; const backendTarget = process.env.BACKEND_URL ?? 'http://localhost:3000'; +const enableProxy = process.env.USE_VITE_PROXY === 'true'; const proxiedPaths = [ '/api', @@ -31,7 +32,7 @@ const proxy = Object.fromEntries( export default defineConfig({ plugins: [tailwindcss(), sveltekit()], server: { - proxy + proxy: enableProxy ? proxy : undefined }, test: { expect: { requireAssertions: true },