diff --git a/apps/ui/src/components/views/login-view.tsx b/apps/ui/src/components/views/login-view.tsx index a30ca4ec..c619f1f2 100644 --- a/apps/ui/src/components/views/login-view.tsx +++ b/apps/ui/src/components/views/login-view.tsx @@ -11,9 +11,13 @@ import { login } 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 { useAuthStore } from '@/store/auth-store'; +import { useSetupStore } from '@/store/setup-store'; 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); @@ -26,8 +30,11 @@ export function LoginView() { try { const result = await login(apiKey.trim()); if (result.success) { - // Redirect to home/board on success - navigate({ to: '/' }); + // 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'); } @@ -73,7 +80,7 @@ export function LoginView() { {error && (
- + {error}
)} diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 95342349..32bd88f8 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -40,9 +40,12 @@ let cachedServerUrl: string | null = null; * Must be called early in Electron mode before making API requests. */ export const initServerUrl = async (): Promise => { - if (typeof window !== 'undefined' && window.electronAPI?.getServerUrl) { + // window.electronAPI is typed as ElectronAPI, but some Electron-only helpers + // (like getServerUrl) are not part of the shared interface. Narrow via `any`. + const electron = typeof window !== 'undefined' ? (window.electronAPI as any) : null; + if (electron?.getServerUrl) { try { - cachedServerUrl = await window.electronAPI.getServerUrl(); + cachedServerUrl = await electron.getServerUrl(); console.log('[HTTP Client] Server URL from Electron:', cachedServerUrl); } catch (error) { console.warn('[HTTP Client] Failed to get server URL from Electron:', error); @@ -109,7 +112,13 @@ export const clearSessionToken = (): void => { * Check if we're running in Electron mode */ export const isElectronMode = (): boolean => { - return typeof window !== 'undefined' && !!window.electronAPI?.getApiKey; + if (typeof window === 'undefined') return false; + + // Prefer a stable runtime marker from preload. + // In some dev/electron setups, method availability can be temporarily undefined + // during early startup, but `isElectron` remains reliable. + const api = window.electronAPI as any; + return api?.isElectron === true || !!api?.getApiKey; }; /** @@ -307,7 +316,9 @@ export const verifySession = async (): Promise => { // 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; } @@ -356,7 +367,8 @@ type EventType = | 'auto-mode:event' | 'suggestions:event' | 'spec-regeneration:event' - | 'issue-validation:event'; + | 'issue-validation:event' + | 'backlog-plan:event'; type EventCallback = (payload: unknown) => void; @@ -378,17 +390,20 @@ export class HttpApiClient implements ElectronAPI { constructor() { this.serverUrl = getServerUrl(); - // Wait for API key initialization before connecting WebSocket - // This prevents 401 errors on startup in Electron mode - waitForApiKeyInit() - .then(() => { - this.connectWebSocket(); - }) - .catch((error) => { - console.error('[HttpApiClient] API key initialization failed:', error); - // Still attempt WebSocket connection - it may work with cookie auth - this.connectWebSocket(); - }); + // Electron mode: connect WebSocket immediately once API key is ready. + // Web mode: defer WebSocket connection until a consumer subscribes to events, + // to avoid noisy 401s on first-load/login/setup routes. + if (isElectronMode()) { + waitForApiKeyInit() + .then(() => { + this.connectWebSocket(); + }) + .catch((error) => { + console.error('[HttpApiClient] API key initialization failed:', error); + // Still attempt WebSocket connection - it may work with cookie auth + this.connectWebSocket(); + }); + } } /** @@ -436,9 +451,24 @@ export class HttpApiClient implements ElectronAPI { this.isConnecting = true; - // In Electron mode, use API key directly - const apiKey = getApiKey(); - if (apiKey) { + // Electron mode must authenticate with the injected API key. + // If the key isn't ready yet, do NOT fall back to /api/auth/token (web-mode flow). + if (isElectronMode()) { + const apiKey = getApiKey(); + if (!apiKey) { + console.warn( + '[HttpApiClient] Electron mode: API key not ready, delaying WebSocket connect' + ); + this.isConnecting = false; + if (!this.reconnectTimer) { + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connectWebSocket(); + }, 250); + } + return; + } + const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events'; this.establishWebSocket(`${wsUrl}?apiKey=${encodeURIComponent(apiKey)}`); return; diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 3608334d..8cffeff5 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -1,5 +1,5 @@ import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'; -import { useEffect, useState, useCallback, useDeferredValue } from 'react'; +import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'react'; import { Sidebar } from '@/components/layout/sidebar'; import { FileBrowserProvider, @@ -8,6 +8,7 @@ import { } from '@/contexts/file-browser-context'; import { useAppStore } 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 { @@ -15,16 +16,13 @@ import { isElectronMode, verifySession, checkSandboxEnvironment, + getServerUrlSync, } from '@/lib/http-api-client'; import { Toaster } from 'sonner'; import { ThemeOption, themeOptions } from '@/config/theme-options'; import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog'; import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen'; -// Session storage key for sandbox risk acknowledgment -const SANDBOX_RISK_ACKNOWLEDGED_KEY = 'automaker-sandbox-risk-acknowledged'; -const SANDBOX_DENIED_KEY = 'automaker-sandbox-denied'; - function RootLayoutContent() { const location = useLocation(); const { setIpcConnected, currentProject, getEffectiveTheme } = useAppStore(); @@ -35,23 +33,18 @@ function RootLayoutContent() { const [setupHydrated, setSetupHydrated] = useState( () => useSetupStore.persist?.hasHydrated?.() ?? false ); - const [authChecked, setAuthChecked] = useState(false); - const [isAuthenticated, setIsAuthenticated] = useState(false); + 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'; + // Sandbox environment check state type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed'; - const [sandboxStatus, setSandboxStatus] = useState(() => { - // Check if user previously denied in this session - if (sessionStorage.getItem(SANDBOX_DENIED_KEY)) { - return 'denied'; - } - // Check if user previously acknowledged in this session - if (sessionStorage.getItem(SANDBOX_RISK_ACKNOWLEDGED_KEY)) { - return 'confirmed'; - } - return 'pending'; - }); + // Always start from pending on a fresh page load so the user sees the prompt + // each time the app is launched/refreshed (unless running in a container). + const [sandboxStatus, setSandboxStatus] = useState('pending'); // Hidden streamer panel - opens with "\" key const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => { @@ -129,14 +122,11 @@ function RootLayoutContent() { // Handle sandbox risk confirmation const handleSandboxConfirm = useCallback(() => { - sessionStorage.setItem(SANDBOX_RISK_ACKNOWLEDGED_KEY, 'true'); setSandboxStatus('confirmed'); }, []); // Handle sandbox risk denial const handleSandboxDeny = useCallback(async () => { - sessionStorage.setItem(SANDBOX_DENIED_KEY, 'true'); - if (isElectron()) { // In Electron mode, quit the application // Use window.electronAPI directly since getElectronAPI() returns the HTTP client @@ -156,19 +146,28 @@ function RootLayoutContent() { } }, []); + // Ref to prevent concurrent auth checks from running + const authCheckRunning = useRef(false); + // Initialize authentication // - Electron mode: Uses API key from IPC (header-based auth) // - Web mode: Uses HTTP-only session cookie useEffect(() => { + // Prevent concurrent auth checks + if (authCheckRunning.current) { + return; + } + const initAuth = async () => { + authCheckRunning.current = true; + try { // Initialize API key for Electron mode await initApiKey(); // In Electron mode, we're always authenticated via header if (isElectronMode()) { - setIsAuthenticated(true); - setAuthChecked(true); + useAuthStore.getState().setAuthState({ isAuthenticated: true, authChecked: true }); return; } @@ -177,31 +176,23 @@ function RootLayoutContent() { const isValid = await verifySession(); if (isValid) { - setIsAuthenticated(true); - setAuthChecked(true); + useAuthStore.getState().setAuthState({ isAuthenticated: true, authChecked: true }); return; } - // Session is invalid or expired - redirect to login - console.log('Session invalid or expired - redirecting to login'); - setIsAuthenticated(false); - setAuthChecked(true); - - if (location.pathname !== '/login') { - navigate({ to: '/login' }); - } + // Session is invalid or expired - treat as not authenticated + useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true }); } catch (error) { console.error('Failed to initialize auth:', error); - setAuthChecked(true); - // On error, redirect to login to be safe - if (location.pathname !== '/login') { - navigate({ to: '/login' }); - } + // On error, treat as not authenticated + useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true }); + } finally { + authCheckRunning.current = false; } }; initAuth(); - }, [location.pathname, navigate]); + }, []); // Runs once per load; auth state drives routing rules // Wait for setup store hydration before enforcing routing rules useEffect(() => { @@ -221,16 +212,34 @@ function RootLayoutContent() { }; }, []); - // Redirect first-run users (or anyone who reopened the wizard) to /setup + // Routing rules (web mode): + // - If not authenticated: force /login (even /setup is protected) + // - If authenticated but setup incomplete: force /setup useEffect(() => { if (!setupHydrated) return; + // Wait for auth check to complete before enforcing any redirects + if (!isElectronMode() && !authChecked) return; + + // Unauthenticated -> force /login + if (!isElectronMode() && !isAuthenticated) { + if (location.pathname !== '/login') { + navigate({ to: '/login' }); + } + return; + } + + // Authenticated -> determine whether setup is required if (!setupComplete && location.pathname !== '/setup') { navigate({ to: '/setup' }); - } else if (setupComplete && location.pathname === '/setup') { + return; + } + + // Setup complete but user is still on /setup -> go to app + if (setupComplete && location.pathname === '/setup') { navigate({ to: '/' }); } - }, [setupComplete, setupHydrated, location.pathname, navigate]); + }, [authChecked, isAuthenticated, setupComplete, setupHydrated, location.pathname, navigate]); useEffect(() => { setGlobalFileBrowser(openFileBrowser); @@ -240,9 +249,19 @@ function RootLayoutContent() { useEffect(() => { const testConnection = async () => { try { - const api = getElectronAPI(); - const result = await api.ping(); - setIpcConnected(result === 'pong'); + if (isElectron()) { + const api = getElectronAPI(); + const result = await api.ping(); + setIpcConnected(result === 'pong'); + return; + } + + // Web mode: check backend availability without instantiating the full HTTP client + const response = await fetch(`${getServerUrlSync()}/api/health`, { + method: 'GET', + signal: AbortSignal.timeout(2000), + }); + setIpcConnected(response.ok); } catch (error) { console.error('IPC connection failed:', error); setIpcConnected(false); @@ -280,10 +299,6 @@ function RootLayoutContent() { } }, [deferredTheme]); - // Login and setup views are full-screen without sidebar - const isSetupRoute = location.pathname === '/setup'; - const isLoginRoute = location.pathname === '/login'; - // Show rejection screen if user denied sandbox risk (web mode only) if (sandboxStatus === 'denied' && !isElectron()) { return ; @@ -323,10 +338,16 @@ function RootLayoutContent() { } // Redirect to login if not authenticated (web mode) + // Show loading state while navigation to login is in progress if (!isElectronMode() && !isAuthenticated) { - return null; // Will redirect via useEffect + return ( +
+
Redirecting to login...
+
+ ); } + // Show setup page (full screen, no sidebar) - authenticated only if (isSetupRoute) { return (
diff --git a/apps/ui/src/store/auth-store.ts b/apps/ui/src/store/auth-store.ts new file mode 100644 index 00000000..7c594d0d --- /dev/null +++ b/apps/ui/src/store/auth-store.ts @@ -0,0 +1,29 @@ +import { create } from 'zustand'; + +interface AuthState { + /** Whether we've attempted to determine auth status for this page load */ + authChecked: boolean; + /** Whether the user is currently authenticated (web mode: valid session cookie) */ + isAuthenticated: boolean; +} + +interface AuthActions { + setAuthState: (state: Partial) => void; + resetAuth: () => void; +} + +const initialState: AuthState = { + authChecked: false, + isAuthenticated: false, +}; + +/** + * Web authentication state. + * + * Intentionally NOT persisted: source of truth is the server session cookie. + */ +export const useAuthStore = create((set) => ({ + ...initialState, + setAuthState: (state) => set(state), + resetAuth: () => set(initialState), +}));