diff --git a/apps/server/src/lib/auth.ts b/apps/server/src/lib/auth.ts
index 3120d512..88f6b375 100644
--- a/apps/server/src/lib/auth.ts
+++ b/apps/server/src/lib/auth.ts
@@ -262,7 +262,7 @@ export function getSessionCookieOptions(): {
return {
httpOnly: true, // JavaScript cannot access this cookie
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
- sameSite: 'strict', // Only sent for same-site requests (CSRF protection)
+ sameSite: 'lax', // Sent on same-site requests including cross-origin fetches
maxAge: SESSION_MAX_AGE_MS,
path: '/',
};
diff --git a/apps/server/src/routes/auth/index.ts b/apps/server/src/routes/auth/index.ts
index 575000a8..9c838b58 100644
--- a/apps/server/src/routes/auth/index.ts
+++ b/apps/server/src/routes/auth/index.ts
@@ -229,12 +229,16 @@ export function createAuthRoutes(): Router {
await invalidateSession(sessionToken);
}
- // Clear the cookie
- res.clearCookie(cookieName, {
+ // Clear the cookie by setting it to empty with immediate expiration
+ // Using res.cookie() with maxAge: 0 is more reliable than clearCookie()
+ // in cross-origin development environments
+ res.cookie(cookieName, '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
- sameSite: 'strict',
+ sameSite: 'lax',
path: '/',
+ maxAge: 0,
+ expires: new Date(0),
});
res.json({
diff --git a/apps/ui/src/app.tsx b/apps/ui/src/app.tsx
index bf9b1086..57a7d08f 100644
--- a/apps/ui/src/app.tsx
+++ b/apps/ui/src/app.tsx
@@ -4,7 +4,6 @@ import { createLogger } from '@automaker/utils/logger';
import { router } from './utils/router';
import { SplashScreen } from './components/splash-screen';
import { LoadingState } from './components/ui/loading-state';
-import { useSettingsMigration } from './hooks/use-settings-migration';
import { useSettingsSync } from './hooks/use-settings-sync';
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
import './styles/global.css';
@@ -34,13 +33,9 @@ export default function App() {
}
}, []);
- // Run settings migration on startup (localStorage -> file storage)
- // IMPORTANT: Wait for this to complete before rendering the router
- // so that currentProject and other settings are available
- const migrationState = useSettingsMigration();
- if (migrationState.migrated) {
- logger.info('Settings migrated to file storage');
- }
+ // Settings are now loaded in __root.tsx after successful session verification
+ // This ensures a unified flow: verify session → load settings → redirect
+ // We no longer block router rendering here - settings loading happens in __root.tsx
// Sync settings changes back to server (API-first persistence)
const settingsSyncState = useSettingsSync();
@@ -56,16 +51,6 @@ export default function App() {
setShowSplash(false);
}, []);
- // Wait for settings migration to complete before rendering the router
- // This ensures currentProject and other settings are available
- if (!migrationState.checked) {
- return (
-
-
-
- );
- }
-
return (
<>
diff --git a/apps/ui/src/components/views/logged-out-view.tsx b/apps/ui/src/components/views/logged-out-view.tsx
new file mode 100644
index 00000000..26ec649c
--- /dev/null
+++ b/apps/ui/src/components/views/logged-out-view.tsx
@@ -0,0 +1,33 @@
+import { useNavigate } from '@tanstack/react-router';
+import { Button } from '@/components/ui/button';
+import { LogOut, RefreshCcw } from 'lucide-react';
+
+export function LoggedOutView() {
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+
+
+
You’ve been logged out
+
+ Your session expired, or the server restarted. Please log in again.
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/login-view.tsx b/apps/ui/src/components/views/login-view.tsx
index 0bcfbece..94b83c35 100644
--- a/apps/ui/src/components/views/login-view.tsx
+++ b/apps/ui/src/components/views/login-view.tsx
@@ -1,110 +1,322 @@
/**
* Login View - Web mode authentication
*
- * Prompts user to enter the API key shown in server console.
- * On successful login, sets an HTTP-only session cookie.
+ * Uses a state machine for clear, maintainable flow:
*
- * On mount, verifies if an existing session is valid using exponential backoff.
- * This handles cases where server live reloads kick users back to login
- * even though their session is still valid.
+ * States:
+ * checking_server → server_error (after 5 retries)
+ * checking_server → awaiting_login (401/unauthenticated)
+ * checking_server → checking_setup (authenticated)
+ * awaiting_login → logging_in → login_error | checking_setup
+ * checking_setup → redirecting
*/
-import { useState, useEffect, useRef } from 'react';
+import { useReducer, useEffect, useRef } from 'react';
import { useNavigate } from '@tanstack/react-router';
-import { login, verifySession } from '@/lib/http-api-client';
+import { login, getHttpApiClient, getServerUrlSync } from '@/lib/http-api-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
-import { KeyRound, AlertCircle, Loader2 } from 'lucide-react';
+import { KeyRound, AlertCircle, Loader2, RefreshCw, ServerCrash } from 'lucide-react';
import { useAuthStore } from '@/store/auth-store';
import { useSetupStore } from '@/store/setup-store';
+// =============================================================================
+// State Machine Types
+// =============================================================================
+
+type State =
+ | { phase: 'checking_server'; attempt: number }
+ | { phase: 'server_error'; message: string }
+ | { phase: 'awaiting_login'; apiKey: string; error: string | null }
+ | { phase: 'logging_in'; apiKey: string }
+ | { phase: 'checking_setup' }
+ | { phase: 'redirecting'; to: string };
+
+type Action =
+ | { type: 'SERVER_CHECK_RETRY'; attempt: number }
+ | { type: 'SERVER_ERROR'; message: string }
+ | { type: 'AUTH_REQUIRED' }
+ | { type: 'AUTH_VALID' }
+ | { type: 'UPDATE_API_KEY'; value: string }
+ | { type: 'SUBMIT_LOGIN' }
+ | { type: 'LOGIN_ERROR'; message: string }
+ | { type: 'REDIRECT'; to: string }
+ | { type: 'RETRY_SERVER_CHECK' };
+
+const initialState: State = { phase: 'checking_server', attempt: 1 };
+
+// =============================================================================
+// State Machine Reducer
+// =============================================================================
+
+function reducer(state: State, action: Action): State {
+ switch (action.type) {
+ case 'SERVER_CHECK_RETRY':
+ return { phase: 'checking_server', attempt: action.attempt };
+
+ case 'SERVER_ERROR':
+ return { phase: 'server_error', message: action.message };
+
+ case 'AUTH_REQUIRED':
+ return { phase: 'awaiting_login', apiKey: '', error: null };
+
+ case 'AUTH_VALID':
+ return { phase: 'checking_setup' };
+
+ case 'UPDATE_API_KEY':
+ if (state.phase !== 'awaiting_login') return state;
+ return { ...state, apiKey: action.value };
+
+ case 'SUBMIT_LOGIN':
+ if (state.phase !== 'awaiting_login') return state;
+ return { phase: 'logging_in', apiKey: state.apiKey };
+
+ case 'LOGIN_ERROR':
+ if (state.phase !== 'logging_in') return state;
+ return { phase: 'awaiting_login', apiKey: state.apiKey, error: action.message };
+
+ case 'REDIRECT':
+ return { phase: 'redirecting', to: action.to };
+
+ case 'RETRY_SERVER_CHECK':
+ return { phase: 'checking_server', attempt: 1 };
+
+ default:
+ return state;
+ }
+}
+
+// =============================================================================
+// Constants
+// =============================================================================
+
+const MAX_RETRIES = 5;
+const BACKOFF_BASE_MS = 400;
+
+// =============================================================================
+// Imperative Flow Logic (runs once on mount)
+// =============================================================================
+
/**
- * Delay helper for exponential backoff
+ * Check auth status without triggering side effects.
+ * Unlike the httpClient methods, this does NOT call handleUnauthorized()
+ * which would navigate us away to /logged-out.
+ *
+ * Relies on HTTP-only session cookie being sent via credentials: 'include'.
+ *
+ * Returns: { authenticated: true } or { authenticated: false }
+ * Throws: on network errors (for retry logic)
*/
-const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+async function checkAuthStatusSafe(): Promise<{ authenticated: boolean }> {
+ const serverUrl = getServerUrlSync();
+
+ const response = await fetch(`${serverUrl}/api/auth/status`, {
+ credentials: 'include', // Send HTTP-only session cookie
+ signal: AbortSignal.timeout(5000),
+ });
+
+ // Any response means server is reachable
+ const data = await response.json();
+ return { authenticated: data.authenticated === true };
+}
+
+/**
+ * Check if server is reachable and if we have a valid session.
+ */
+async function checkServerAndSession(
+ dispatch: React.Dispatch,
+ setAuthState: (state: { isAuthenticated: boolean; authChecked: boolean }) => void
+): Promise {
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
+ dispatch({ type: 'SERVER_CHECK_RETRY', attempt });
+
+ try {
+ const result = await checkAuthStatusSafe();
+
+ if (result.authenticated) {
+ // Server is reachable and we're authenticated
+ setAuthState({ isAuthenticated: true, authChecked: true });
+ dispatch({ type: 'AUTH_VALID' });
+ return;
+ }
+
+ // Server is reachable but we need to login
+ dispatch({ type: 'AUTH_REQUIRED' });
+ return;
+ } catch (error: unknown) {
+ // Network error - server is not reachable
+ console.debug(`Server check attempt ${attempt}/${MAX_RETRIES} failed:`, error);
+
+ if (attempt === MAX_RETRIES) {
+ dispatch({
+ type: 'SERVER_ERROR',
+ message: 'Unable to connect to server. Please check that the server is running.',
+ });
+ return;
+ }
+
+ // Exponential backoff before retry
+ const backoffMs = BACKOFF_BASE_MS * Math.pow(2, attempt - 1);
+ await new Promise((resolve) => setTimeout(resolve, backoffMs));
+ }
+ }
+}
+
+async function checkSetupStatus(dispatch: React.Dispatch): Promise {
+ const httpClient = getHttpApiClient();
+
+ try {
+ const result = await httpClient.settings.getGlobal();
+
+ if (result.success && result.settings) {
+ // Check the setupComplete field from settings
+ // This is set to true when user completes the setup wizard
+ const setupComplete = (result.settings as { setupComplete?: boolean }).setupComplete === true;
+
+ // IMPORTANT: Update the Zustand store BEFORE redirecting
+ // Otherwise __root.tsx routing effect will override our redirect
+ // because it reads setupComplete from the store (which defaults to false)
+ useSetupStore.getState().setSetupComplete(setupComplete);
+
+ dispatch({ type: 'REDIRECT', to: setupComplete ? '/' : '/setup' });
+ } else {
+ // No settings yet = first run = need setup
+ useSetupStore.getState().setSetupComplete(false);
+ dispatch({ type: 'REDIRECT', to: '/setup' });
+ }
+ } catch {
+ // If we can't get settings, go to setup to be safe
+ useSetupStore.getState().setSetupComplete(false);
+ dispatch({ type: 'REDIRECT', to: '/setup' });
+ }
+}
+
+async function performLogin(
+ apiKey: string,
+ dispatch: React.Dispatch,
+ setAuthState: (state: { isAuthenticated: boolean; authChecked: boolean }) => void
+): Promise {
+ try {
+ const result = await login(apiKey.trim());
+
+ if (result.success) {
+ setAuthState({ isAuthenticated: true, authChecked: true });
+ dispatch({ type: 'AUTH_VALID' });
+ } else {
+ dispatch({ type: 'LOGIN_ERROR', message: result.error || 'Invalid API key' });
+ }
+ } catch {
+ dispatch({ type: 'LOGIN_ERROR', message: 'Failed to connect to server' });
+ }
+}
+
+// =============================================================================
+// Component
+// =============================================================================
export function LoginView() {
const navigate = useNavigate();
const setAuthState = useAuthStore((s) => s.setAuthState);
- const setupComplete = useSetupStore((s) => s.setupComplete);
- const [apiKey, setApiKey] = useState('');
- const [error, setError] = useState(null);
- const [isLoading, setIsLoading] = useState(false);
- const [isCheckingSession, setIsCheckingSession] = useState(true);
- const sessionCheckRef = useRef(false);
+ const [state, dispatch] = useReducer(reducer, initialState);
+ const initialCheckDone = useRef(false);
- // Check for existing valid session on mount with exponential backoff
+ // Run initial server/session check once on mount
useEffect(() => {
- // Prevent duplicate checks in strict mode
- if (sessionCheckRef.current) return;
- sessionCheckRef.current = true;
+ if (initialCheckDone.current) return;
+ initialCheckDone.current = true;
- const checkExistingSession = async () => {
- const maxRetries = 5;
- const baseDelay = 500; // Start with 500ms
+ checkServerAndSession(dispatch, setAuthState);
+ }, [setAuthState]);
- for (let attempt = 0; attempt < maxRetries; attempt++) {
- try {
- const isValid = await verifySession();
- if (isValid) {
- // Session is valid, redirect to the main app
- setAuthState({ isAuthenticated: true, authChecked: true });
- navigate({ to: setupComplete ? '/' : '/setup' });
- return;
- }
- // Session is invalid, no need to retry - show login form
- break;
- } catch {
- // Network error or server not ready, retry with exponential backoff
- if (attempt < maxRetries - 1) {
- const waitTime = baseDelay * Math.pow(2, attempt); // 500, 1000, 2000, 4000, 8000ms
- await delay(waitTime);
- }
- }
- }
-
- // Session check complete (either invalid or all retries exhausted)
- setIsCheckingSession(false);
- };
-
- checkExistingSession();
- }, [navigate, setAuthState, setupComplete]);
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- setError(null);
- setIsLoading(true);
-
- try {
- const result = await login(apiKey.trim());
- if (result.success) {
- // Mark as authenticated for this session (cookie-based auth)
- setAuthState({ isAuthenticated: true, authChecked: true });
-
- // After auth, determine if setup is needed or go to app
- navigate({ to: setupComplete ? '/' : '/setup' });
- } else {
- setError(result.error || 'Invalid API key');
- }
- } catch (err) {
- setError('Failed to connect to server');
- } finally {
- setIsLoading(false);
+ // When we enter checking_setup phase, check setup status
+ useEffect(() => {
+ if (state.phase === 'checking_setup') {
+ checkSetupStatus(dispatch);
}
+ }, [state.phase]);
+
+ // When we enter redirecting phase, navigate
+ useEffect(() => {
+ if (state.phase === 'redirecting') {
+ navigate({ to: state.to });
+ }
+ }, [state.phase, state.phase === 'redirecting' ? state.to : null, navigate]);
+
+ // Handle login form submission
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (state.phase !== 'awaiting_login' || !state.apiKey.trim()) return;
+
+ dispatch({ type: 'SUBMIT_LOGIN' });
+ performLogin(state.apiKey, dispatch, setAuthState);
};
- // Show loading state while checking existing session
- if (isCheckingSession) {
+ // Handle retry button for server errors
+ const handleRetry = () => {
+ initialCheckDone.current = false;
+ dispatch({ type: 'RETRY_SERVER_CHECK' });
+ checkServerAndSession(dispatch, setAuthState);
+ };
+
+ // =============================================================================
+ // Render based on current state
+ // =============================================================================
+
+ // Checking server connectivity
+ if (state.phase === 'checking_server') {
return (
-
Checking session...
+
+ Connecting to server
+ {state.attempt > 1 ? ` (attempt ${state.attempt}/${MAX_RETRIES})` : '...'}
+
);
}
+ // Server unreachable after retries
+ if (state.phase === 'server_error') {
+ return (
+
+
+
+
+
+
+
Server Unavailable
+
{state.message}
+
+
+
+
+ );
+ }
+
+ // Checking setup status after auth
+ if (state.phase === 'checking_setup' || state.phase === 'redirecting') {
+ return (
+
+ When enabled, you'll see a warning on app startup if you're not running in a
+ containerized environment (like Docker). This helps remind you to use proper isolation
+ when running AI agents.
+
+
+
+ );
+}
diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts
index 728293d3..9690e2ec 100644
--- a/apps/ui/src/hooks/use-settings-migration.ts
+++ b/apps/ui/src/hooks/use-settings-migration.ts
@@ -20,8 +20,8 @@
import { useEffect, useState, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client';
-import { getItem, removeItem } from '@/lib/storage';
-import { useAppStore } from '@/store/app-store';
+import { getItem, removeItem, setItem } from '@/lib/storage';
+import { useAppStore, THEME_STORAGE_KEY } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import type { GlobalSettings } from '@automaker/types';
@@ -69,7 +69,12 @@ let migrationCompleteResolve: (() => void) | null = null;
let migrationCompletePromise: Promise | null = null;
let migrationCompleted = false;
-function signalMigrationComplete(): void {
+/**
+ * Signal that migration/hydration is complete.
+ * Call this after hydrating the store from server settings.
+ * This unblocks useSettingsSync so it can start syncing changes.
+ */
+export function signalMigrationComplete(): void {
migrationCompleted = true;
if (migrationCompleteResolve) {
migrationCompleteResolve();
@@ -436,7 +441,7 @@ export function useSettingsMigration(): MigrationState {
/**
* Hydrate the Zustand store from settings object
*/
-function hydrateStoreFromSettings(settings: GlobalSettings): void {
+export function hydrateStoreFromSettings(settings: GlobalSettings): void {
const current = useAppStore.getState();
// Convert ProjectRef[] to Project[] (minimal data, features will be loaded separately)
@@ -458,6 +463,11 @@ function hydrateStoreFromSettings(settings: GlobalSettings): void {
}
}
+ // Save theme to localStorage for fallback when server settings aren't available
+ if (settings.theme) {
+ setItem(THEME_STORAGE_KEY, settings.theme);
+ }
+
useAppStore.setState({
theme: settings.theme as unknown as import('@/store/app-store').ThemeMode,
sidebarOpen: settings.sidebarOpen ?? true,
diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts
index 90bc4168..0f9514a9 100644
--- a/apps/ui/src/hooks/use-settings-sync.ts
+++ b/apps/ui/src/hooks/use-settings-sync.ts
@@ -14,7 +14,8 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client';
-import { useAppStore, type ThemeMode } from '@/store/app-store';
+import { setItem } from '@/lib/storage';
+import { useAppStore, type ThemeMode, THEME_STORAGE_KEY } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { waitForMigrationComplete } from './use-settings-migration';
import type { GlobalSettings } from '@automaker/types';
@@ -339,6 +340,11 @@ export async function refreshSettingsFromServer(): Promise {
const serverSettings = result.settings as unknown as GlobalSettings;
const currentAppState = useAppStore.getState();
+ // Save theme to localStorage for fallback when server settings aren't available
+ if (serverSettings.theme) {
+ setItem(THEME_STORAGE_KEY, serverSettings.theme);
+ }
+
useAppStore.setState({
theme: serverSettings.theme as unknown as ThemeMode,
sidebarOpen: serverSettings.sidebarOpen,
diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts
index f01d67cf..b531e3d1 100644
--- a/apps/ui/src/lib/http-api-client.ts
+++ b/apps/ui/src/lib/http-api-client.ts
@@ -45,6 +45,36 @@ const logger = createLogger('HttpClient');
// Cached server URL (set during initialization in Electron mode)
let cachedServerUrl: string | null = null;
+/**
+ * Notify the UI that the current session is no longer valid.
+ * Used to redirect the user to a logged-out route on 401/403 responses.
+ */
+const notifyLoggedOut = (): void => {
+ if (typeof window === 'undefined') return;
+ try {
+ window.dispatchEvent(new CustomEvent('automaker:logged-out'));
+ } catch {
+ // Ignore - navigation will still be handled by failed requests in most cases
+ }
+};
+
+/**
+ * Handle an unauthorized response in cookie/session auth flows.
+ * Clears in-memory token and attempts to clear the cookie (best-effort),
+ * then notifies the UI to redirect.
+ */
+const handleUnauthorized = (): void => {
+ clearSessionToken();
+ // Best-effort cookie clear (avoid throwing)
+ fetch(`${getServerUrl()}/api/auth/logout`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: '{}',
+ }).catch(() => {});
+ notifyLoggedOut();
+};
+
/**
* Initialize server URL from Electron IPC.
* Must be called early in Electron mode before making API requests.
@@ -88,6 +118,7 @@ let apiKeyInitialized = false;
let apiKeyInitPromise: Promise | null = null;
// Cached session token for authentication (Web mode - explicit header auth)
+// Only used in-memory after fresh login; on refresh we rely on HTTP-only cookies
let cachedSessionToken: string | null = null;
// Get API key for Electron mode (returns cached value after initialization)
@@ -105,10 +136,10 @@ export const waitForApiKeyInit = (): Promise => {
return initApiKey();
};
-// Get session token for Web mode (returns cached value after login or token fetch)
+// Get session token for Web mode (returns cached value after login)
export const getSessionToken = (): string | null => cachedSessionToken;
-// Set session token (called after login or token fetch)
+// Set session token (called after login)
export const setSessionToken = (token: string | null): void => {
cachedSessionToken = token;
};
@@ -311,6 +342,7 @@ export const logout = async (): Promise<{ success: boolean }> => {
try {
const response = await fetch(`${getServerUrl()}/api/auth/logout`, {
method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
credentials: 'include',
});
@@ -331,52 +363,52 @@ export const logout = async (): Promise<{ success: boolean }> => {
* This should be called:
* 1. After login to verify the cookie was set correctly
* 2. On app load to verify the session hasn't expired
+ *
+ * Returns:
+ * - true: Session is valid
+ * - false: Session is definitively invalid (401/403 auth failure)
+ * - throws: Network error or server not ready (caller should retry)
*/
export const verifySession = async (): Promise => {
- try {
- const headers: Record = {
- 'Content-Type': 'application/json',
- };
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ };
- // Add session token header if available
- const sessionToken = getSessionToken();
- if (sessionToken) {
- headers['X-Session-Token'] = sessionToken;
- }
+ // Add session token header if available
+ const sessionToken = getSessionToken();
+ if (sessionToken) {
+ headers['X-Session-Token'] = sessionToken;
+ }
- // Make a request to an authenticated endpoint to verify the session
- // We use /api/settings/status as it requires authentication and is lightweight
- const response = await fetch(`${getServerUrl()}/api/settings/status`, {
- headers,
- credentials: 'include',
- });
+ // Make a request to an authenticated endpoint to verify the session
+ // We use /api/settings/status as it requires authentication and is lightweight
+ // Note: fetch throws on network errors, which we intentionally let propagate
+ const response = await fetch(`${getServerUrl()}/api/settings/status`, {
+ headers,
+ credentials: 'include',
+ // Avoid hanging indefinitely during backend reloads or network issues
+ signal: AbortSignal.timeout(2500),
+ });
- // Check for authentication errors
- if (response.status === 401 || response.status === 403) {
- logger.warn('Session verification failed - session expired or invalid');
- // Clear the session since it's no longer valid
- clearSessionToken();
- // Try to clear the cookie via logout (fire and forget)
- fetch(`${getServerUrl()}/api/auth/logout`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
- body: '{}',
- }).catch(() => {});
- return false;
- }
-
- if (!response.ok) {
- logger.warn('Session verification failed with status:', response.status);
- return false;
- }
-
- logger.info('Session verified successfully');
- return true;
- } catch (error) {
- logger.error('Session verification error:', error);
+ // Check for authentication errors - these are definitive "invalid session" responses
+ if (response.status === 401 || response.status === 403) {
+ logger.warn('Session verification failed - session expired or invalid');
+ // Clear the in-memory/localStorage session token since it's no longer valid
+ // Note: We do NOT call logout here - that would destroy a potentially valid
+ // cookie if the issue was transient (e.g., token not sent due to timing)
+ clearSessionToken();
return false;
}
+
+ // For other non-ok responses (5xx, etc.), throw to trigger retry
+ if (!response.ok) {
+ const error = new Error(`Session verification failed with status: ${response.status}`);
+ logger.warn('Session verification failed with status:', response.status);
+ throw error;
+ }
+
+ logger.info('Session verified successfully');
+ return true;
};
/**
@@ -472,6 +504,11 @@ export class HttpApiClient implements ElectronAPI {
credentials: 'include',
});
+ if (response.status === 401 || response.status === 403) {
+ handleUnauthorized();
+ return null;
+ }
+
if (!response.ok) {
logger.warn('Failed to fetch wsToken:', response.status);
return null;
@@ -653,6 +690,11 @@ export class HttpApiClient implements ElectronAPI {
body: body ? JSON.stringify(body) : undefined,
});
+ if (response.status === 401 || response.status === 403) {
+ handleUnauthorized();
+ throw new Error('Unauthorized');
+ }
+
if (!response.ok) {
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
try {
@@ -677,6 +719,11 @@ export class HttpApiClient implements ElectronAPI {
credentials: 'include', // Include cookies for session auth
});
+ if (response.status === 401 || response.status === 403) {
+ handleUnauthorized();
+ throw new Error('Unauthorized');
+ }
+
if (!response.ok) {
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
try {
@@ -703,6 +750,11 @@ export class HttpApiClient implements ElectronAPI {
body: body ? JSON.stringify(body) : undefined,
});
+ if (response.status === 401 || response.status === 403) {
+ handleUnauthorized();
+ throw new Error('Unauthorized');
+ }
+
if (!response.ok) {
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
try {
@@ -728,6 +780,11 @@ export class HttpApiClient implements ElectronAPI {
credentials: 'include', // Include cookies for session auth
});
+ if (response.status === 401 || response.status === 403) {
+ handleUnauthorized();
+ throw new Error('Unauthorized');
+ }
+
if (!response.ok) {
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
try {
diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx
index 502aba11..dcb26bf6 100644
--- a/apps/ui/src/routes/__root.tsx
+++ b/apps/ui/src/routes/__root.tsx
@@ -7,20 +7,19 @@ import {
useFileBrowser,
setGlobalFileBrowser,
} from '@/contexts/file-browser-context';
-import { useAppStore } from '@/store/app-store';
+import { useAppStore, getStoredTheme } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { useAuthStore } from '@/store/auth-store';
import { getElectronAPI, isElectron } from '@/lib/electron';
import { isMac } from '@/lib/utils';
import {
initApiKey,
- isElectronMode,
verifySession,
checkSandboxEnvironment,
getServerUrlSync,
- checkExternalServerMode,
- isExternalServerMode,
+ getHttpApiClient,
} from '@/lib/http-api-client';
+import { hydrateStoreFromSettings, signalMigrationComplete } from '@/hooks/use-settings-migration';
import { Toaster } from 'sonner';
import { ThemeOption, themeOptions } from '@/config/theme-options';
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
@@ -29,6 +28,33 @@ import { LoadingState } from '@/components/ui/loading-state';
const logger = createLogger('RootLayout');
+// Apply stored theme immediately on page load (before React hydration)
+// This prevents flash of default theme on login/setup pages
+function applyStoredTheme(): void {
+ const storedTheme = getStoredTheme();
+ if (storedTheme) {
+ const root = document.documentElement;
+ // Remove all theme classes (themeOptions doesn't include 'system' which is only in ThemeMode)
+ const themeClasses = themeOptions.map((option) => option.value);
+ root.classList.remove(...themeClasses);
+
+ // Apply the stored theme
+ if (storedTheme === 'dark') {
+ root.classList.add('dark');
+ } else if (storedTheme === 'system') {
+ const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+ root.classList.add(isDark ? 'dark' : 'light');
+ } else if (storedTheme !== 'light') {
+ root.classList.add(storedTheme);
+ } else {
+ root.classList.add('light');
+ }
+ }
+}
+
+// Apply stored theme immediately (runs synchronously before render)
+applyStoredTheme();
+
function RootLayoutContent() {
const location = useLocation();
const {
@@ -42,16 +68,13 @@ function RootLayoutContent() {
const navigate = useNavigate();
const [isMounted, setIsMounted] = useState(false);
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
- // Since we removed persist middleware (settings now sync via API),
- // we consider the store "hydrated" immediately - the useSettingsMigration
- // hook in App.tsx handles loading settings from the API
- const [setupHydrated, setSetupHydrated] = useState(true);
const authChecked = useAuthStore((s) => s.authChecked);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const { openFileBrowser } = useFileBrowser();
const isSetupRoute = location.pathname === '/setup';
const isLoginRoute = location.pathname === '/login';
+ const isLoggedOutRoute = location.pathname === '/logged-out';
// Sandbox environment check state
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
@@ -105,13 +128,18 @@ function RootLayoutContent() {
setIsMounted(true);
}, []);
- // Check sandbox environment on mount
+ // Check sandbox environment only after user is authenticated and setup is complete
useEffect(() => {
// Skip if already decided
if (sandboxStatus !== 'pending') {
return;
}
+ // Don't check sandbox until user is authenticated and has completed setup
+ if (!authChecked || !isAuthenticated || !setupComplete) {
+ return;
+ }
+
const checkSandbox = async () => {
try {
const result = await checkSandboxEnvironment();
@@ -138,7 +166,7 @@ function RootLayoutContent() {
};
checkSandbox();
- }, [sandboxStatus, skipSandboxWarning]);
+ }, [sandboxStatus, skipSandboxWarning, authChecked, isAuthenticated, setupComplete]);
// Handle sandbox risk confirmation
const handleSandboxConfirm = useCallback(
@@ -175,6 +203,24 @@ function RootLayoutContent() {
// Ref to prevent concurrent auth checks from running
const authCheckRunning = useRef(false);
+ // Global listener for 401/403 responses during normal app usage.
+ // This is triggered by the HTTP client whenever an authenticated request returns 401/403.
+ // Works for ALL modes (unified flow)
+ useEffect(() => {
+ const handleLoggedOut = () => {
+ useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
+
+ if (location.pathname !== '/logged-out') {
+ navigate({ to: '/logged-out' });
+ }
+ };
+
+ window.addEventListener('automaker:logged-out', handleLoggedOut);
+ return () => {
+ window.removeEventListener('automaker:logged-out', handleLoggedOut);
+ };
+ }, [location.pathname, navigate]);
+
// Initialize authentication
// - Electron mode: Uses API key from IPC (header-based auth)
// - Web mode: Uses HTTP-only session cookie
@@ -191,30 +237,67 @@ function RootLayoutContent() {
// Initialize API key for Electron mode
await initApiKey();
- // Check if running in external server mode (Docker API)
- const externalMode = await checkExternalServerMode();
-
- // In Electron mode (but NOT external server mode), we're always authenticated via header
- if (isElectronMode() && !externalMode) {
- useAuthStore.getState().setAuthState({ isAuthenticated: true, authChecked: true });
- return;
+ // 1. Verify session (Single Request, ALL modes)
+ let isValid = false;
+ try {
+ isValid = await verifySession();
+ } catch (error) {
+ logger.warn('Session verification failed (likely network/server issue):', error);
+ isValid = false;
}
- // In web mode OR external server mode, verify the session cookie is still valid
- // by making a request to an authenticated endpoint
- const isValid = await verifySession();
-
if (isValid) {
- useAuthStore.getState().setAuthState({ isAuthenticated: true, authChecked: true });
- return;
- }
+ // 2. Check Settings if valid
+ const api = getHttpApiClient();
+ try {
+ const settingsResult = await api.settings.getGlobal();
+ if (settingsResult.success && settingsResult.settings) {
+ // Hydrate store (including setupComplete)
+ // This function handles updating the store with all settings
+ // Cast through unknown first to handle type differences between API response and GlobalSettings
+ hydrateStoreFromSettings(
+ settingsResult.settings as unknown as Parameters[0]
+ );
- // Session is invalid or expired - treat as not authenticated
- useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
+ // Signal that settings hydration is complete so useSettingsSync can start
+ signalMigrationComplete();
+
+ // Redirect based on setup status happens in the routing effect below
+ // but we can also hint navigation here if needed.
+ // The routing effect (lines 273+) is robust enough.
+ }
+ } catch (error) {
+ logger.error('Failed to fetch settings after valid session:', error);
+ // If settings fail, we might still be authenticated but can't determine setup status.
+ // We should probably treat as authenticated but setup unknown?
+ // Or fail safe to logged-out/error?
+ // Existing logic relies on setupComplete which defaults to false/true based on env.
+ // Let's assume we proceed as authenticated.
+ // Still signal migration complete so sync can start (will sync current store state)
+ signalMigrationComplete();
+ }
+
+ useAuthStore.getState().setAuthState({ isAuthenticated: true, authChecked: true });
+ } else {
+ // Session is invalid or expired - treat as not authenticated
+ useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
+ // Signal migration complete so sync hook doesn't hang (nothing to sync when not authenticated)
+ signalMigrationComplete();
+
+ // Redirect to logged-out if not already there or login
+ if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
+ navigate({ to: '/logged-out' });
+ }
+ }
} catch (error) {
logger.error('Failed to initialize auth:', error);
// On error, treat as not authenticated
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
+ // Signal migration complete so sync hook doesn't hang
+ signalMigrationComplete();
+ if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
+ navigate({ to: '/logged-out' });
+ }
} finally {
authCheckRunning.current = false;
}
@@ -223,25 +306,21 @@ function RootLayoutContent() {
initAuth();
}, []); // Runs once per load; auth state drives routing rules
- // Note: Setup store hydration is handled by useSettingsMigration in App.tsx
- // No need to wait for persist middleware hydration since we removed it
+ // Note: Settings are now loaded in __root.tsx after successful session verification
+ // This ensures a unified flow across all modes (Electron, web, external server)
- // Routing rules (web mode and external server mode):
- // - If not authenticated: force /login (even /setup is protected)
+ // Routing rules (ALL modes - unified flow):
+ // - If not authenticated: force /logged-out (even /setup is protected)
// - If authenticated but setup incomplete: force /setup
+ // - If authenticated and setup complete: allow access to app
useEffect(() => {
- if (!setupHydrated) return;
-
- // Check if we need session-based auth (web mode OR external server mode)
- const needsSessionAuth = !isElectronMode() || isExternalServerMode() === true;
-
// Wait for auth check to complete before enforcing any redirects
- if (needsSessionAuth && !authChecked) return;
+ if (!authChecked) return;
- // Unauthenticated -> force /login
- if (needsSessionAuth && !isAuthenticated) {
- if (location.pathname !== '/login') {
- navigate({ to: '/login' });
+ // Unauthenticated -> force /logged-out (but allow /login so user can authenticate)
+ if (!isAuthenticated) {
+ if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
+ navigate({ to: '/logged-out' });
}
return;
}
@@ -256,7 +335,7 @@ function RootLayoutContent() {
if (setupComplete && location.pathname === '/setup') {
navigate({ to: '/' });
}
- }, [authChecked, isAuthenticated, setupComplete, setupHydrated, location.pathname, navigate]);
+ }, [authChecked, isAuthenticated, setupComplete, location.pathname, navigate]);
useEffect(() => {
setGlobalFileBrowser(openFileBrowser);
@@ -326,26 +405,17 @@ function RootLayoutContent() {
const showSandboxDialog = sandboxStatus === 'needs-confirmation';
// Show login page (full screen, no sidebar)
- if (isLoginRoute) {
+ // Note: No sandbox dialog here - it only shows after login and setup complete
+ if (isLoginRoute || isLoggedOutRoute) {
return (
- <>
-
-
-
-
- >
+
+
+
);
}
- // Check if we need session-based auth (web mode OR external server mode)
- const needsSessionAuth = !isElectronMode() || isExternalServerMode() === true;
-
- // Wait for auth check before rendering protected routes (web mode and external server mode)
- if (needsSessionAuth && !authChecked) {
+ // Wait for auth check before rendering protected routes (ALL modes - unified flow)
+ if (!authChecked) {
return (
@@ -353,12 +423,12 @@ function RootLayoutContent() {
);
}
- // Redirect to login if not authenticated (web mode and external server mode)
- // Show loading state while navigation to login is in progress
- if (needsSessionAuth && !isAuthenticated) {
+ // Redirect to logged-out if not authenticated (ALL modes - unified flow)
+ // Show loading state while navigation is in progress
+ if (!isAuthenticated) {
return (
-
+
);
}
diff --git a/apps/ui/src/routes/logged-out.tsx b/apps/ui/src/routes/logged-out.tsx
new file mode 100644
index 00000000..4a3a296c
--- /dev/null
+++ b/apps/ui/src/routes/logged-out.tsx
@@ -0,0 +1,6 @@
+import { createFileRoute } from '@tanstack/react-router';
+import { LoggedOutView } from '@/components/views/logged-out-view';
+
+export const Route = createFileRoute('/logged-out')({
+ component: LoggedOutView,
+});
diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts
index a3915fd1..3e75155b 100644
--- a/apps/ui/src/store/app-store.ts
+++ b/apps/ui/src/store/app-store.ts
@@ -2,6 +2,7 @@ import { create } from 'zustand';
// Note: persist middleware removed - settings now sync via API (use-settings-sync.ts)
import type { Project, TrashedProject } from '@/lib/electron';
import { createLogger } from '@automaker/utils/logger';
+import { setItem, getItem } from '@/lib/storage';
import type {
Feature as BaseFeature,
FeatureImagePath,
@@ -60,6 +61,29 @@ export type ThemeMode =
| 'sunset'
| 'gray';
+// LocalStorage key for theme persistence (fallback when server settings aren't available)
+export const THEME_STORAGE_KEY = 'automaker:theme';
+
+/**
+ * Get the theme from localStorage as a fallback
+ * Used before server settings are loaded (e.g., on login/setup pages)
+ */
+export function getStoredTheme(): ThemeMode | null {
+ const stored = getItem(THEME_STORAGE_KEY);
+ if (stored) {
+ return stored as ThemeMode;
+ }
+ return null;
+}
+
+/**
+ * Save theme to localStorage for immediate persistence
+ * This is used as a fallback when server settings can't be loaded
+ */
+function saveThemeToStorage(theme: ThemeMode): void {
+ setItem(THEME_STORAGE_KEY, theme);
+}
+
export type KanbanCardDetailLevel = 'minimal' | 'standard' | 'detailed';
export type BoardViewMode = 'kanban' | 'graph';
@@ -1005,7 +1029,7 @@ const initialState: AppState = {
currentView: 'welcome',
sidebarOpen: true,
lastSelectedSessionByProject: {},
- theme: 'dark',
+ theme: getStoredTheme() || 'dark', // Use localStorage theme as initial value, fallback to 'dark'
features: [],
appSpec: '',
ipcConnected: false,
@@ -1321,7 +1345,11 @@ export const useAppStore = create()((set, get) => ({
setSidebarOpen: (open) => set({ sidebarOpen: open }),
// Theme actions
- setTheme: (theme) => set({ theme }),
+ setTheme: (theme) => {
+ // Save to localStorage for fallback when server settings aren't available
+ saveThemeToStorage(theme);
+ set({ theme });
+ },
setProjectTheme: (projectId, theme) => {
// Update the project's theme property