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 },