mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
fix: restore auth and auto-open last project
This commit is contained in:
@@ -13,7 +13,15 @@
|
|||||||
|
|
||||||
import { useReducer, useEffect, useRef } from 'react';
|
import { useReducer, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { login, getHttpApiClient, getServerUrlSync } from '@/lib/http-api-client';
|
import {
|
||||||
|
login,
|
||||||
|
getHttpApiClient,
|
||||||
|
getServerUrlSync,
|
||||||
|
getApiKey,
|
||||||
|
getSessionToken,
|
||||||
|
initApiKey,
|
||||||
|
waitForApiKeyInit,
|
||||||
|
} from '@/lib/http-api-client';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { KeyRound, AlertCircle, Loader2, RefreshCw, ServerCrash } from 'lucide-react';
|
import { KeyRound, AlertCircle, Loader2, RefreshCw, ServerCrash } from 'lucide-react';
|
||||||
@@ -92,6 +100,7 @@ function reducer(state: State, action: Action): State {
|
|||||||
|
|
||||||
const MAX_RETRIES = 5;
|
const MAX_RETRIES = 5;
|
||||||
const BACKOFF_BASE_MS = 400;
|
const BACKOFF_BASE_MS = 400;
|
||||||
|
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Imperative Flow Logic (runs once on mount)
|
// Imperative Flow Logic (runs once on mount)
|
||||||
@@ -102,7 +111,9 @@ const BACKOFF_BASE_MS = 400;
|
|||||||
* Unlike the httpClient methods, this does NOT call handleUnauthorized()
|
* Unlike the httpClient methods, this does NOT call handleUnauthorized()
|
||||||
* which would navigate us away to /logged-out.
|
* which would navigate us away to /logged-out.
|
||||||
*
|
*
|
||||||
* Relies on HTTP-only session cookie being sent via credentials: 'include'.
|
* Supports both:
|
||||||
|
* - Electron mode: Uses X-API-Key header (API key from IPC)
|
||||||
|
* - Web mode: Uses HTTP-only session cookie
|
||||||
*
|
*
|
||||||
* Returns: { authenticated: true } or { authenticated: false }
|
* Returns: { authenticated: true } or { authenticated: false }
|
||||||
* Throws: on network errors (for retry logic)
|
* Throws: on network errors (for retry logic)
|
||||||
@@ -110,9 +121,31 @@ const BACKOFF_BASE_MS = 400;
|
|||||||
async function checkAuthStatusSafe(): Promise<{ authenticated: boolean }> {
|
async function checkAuthStatusSafe(): Promise<{ authenticated: boolean }> {
|
||||||
const serverUrl = getServerUrlSync();
|
const serverUrl = getServerUrlSync();
|
||||||
|
|
||||||
|
// Wait for API key to be initialized before checking auth
|
||||||
|
// This ensures we have a valid API key to send in the header
|
||||||
|
await waitForApiKeyInit();
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Electron mode: use API key header
|
||||||
|
const apiKey = getApiKey();
|
||||||
|
if (apiKey) {
|
||||||
|
headers['X-API-Key'] = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add session token header if available (web mode)
|
||||||
|
const sessionToken = getSessionToken();
|
||||||
|
if (sessionToken) {
|
||||||
|
headers['X-Session-Token'] = sessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${serverUrl}/api/auth/status`, {
|
const response = await fetch(`${serverUrl}/api/auth/status`, {
|
||||||
credentials: 'include', // Send HTTP-only session cookie
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Any response means server is reachable
|
// Any response means server is reachable
|
||||||
@@ -246,6 +279,14 @@ export function LoginView() {
|
|||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
const retryControllerRef = useRef<AbortController | null>(null);
|
const retryControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// Initialize API key before checking session
|
||||||
|
// This ensures getApiKey() returns a valid value in checkAuthStatusSafe()
|
||||||
|
useEffect(() => {
|
||||||
|
initApiKey().catch((error) => {
|
||||||
|
console.warn('Failed to initialize API key:', error);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Run initial server/session check on mount.
|
// Run initial server/session check on mount.
|
||||||
// IMPORTANT: Do not "run once" via a ref guard here.
|
// IMPORTANT: Do not "run once" via a ref guard here.
|
||||||
// In React StrictMode (dev), effects mount -> cleanup -> mount.
|
// In React StrictMode (dev), effects mount -> cleanup -> mount.
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { getElectronAPI } from '@/lib/electron';
|
|||||||
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
|
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
|
||||||
|
|
||||||
const logger = createLogger('Terminal');
|
const logger = createLogger('Terminal');
|
||||||
|
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
||||||
|
|
||||||
// Font size constraints
|
// Font size constraints
|
||||||
const MIN_FONT_SIZE = 8;
|
const MIN_FONT_SIZE = 8;
|
||||||
@@ -504,6 +505,7 @@ export function TerminalPanel({
|
|||||||
const response = await fetch(`${serverUrl}/api/auth/token`, {
|
const response = await fetch(`${serverUrl}/api/auth/token`, {
|
||||||
headers,
|
headers,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
|
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
const authChecked = useAuthStore((s) => s.authChecked);
|
const authChecked = useAuthStore((s) => s.authChecked);
|
||||||
|
const settingsLoaded = useAuthStore((s) => s.settingsLoaded);
|
||||||
|
|
||||||
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const lastSyncedRef = useRef<string>('');
|
const lastSyncedRef = useRef<string>('');
|
||||||
@@ -117,9 +118,17 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
// Debounced sync function
|
// Debounced sync function
|
||||||
const syncToServer = useCallback(async () => {
|
const syncToServer = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
// Never sync when not authenticated (prevents overwriting server settings during logout/login transitions)
|
// Never sync when not authenticated or settings not loaded
|
||||||
|
// The settingsLoaded flag ensures we don't sync default empty state before hydration
|
||||||
const auth = useAuthStore.getState();
|
const auth = useAuthStore.getState();
|
||||||
if (!auth.authChecked || !auth.isAuthenticated) {
|
logger.debug('syncToServer check:', {
|
||||||
|
authChecked: auth.authChecked,
|
||||||
|
isAuthenticated: auth.isAuthenticated,
|
||||||
|
settingsLoaded: auth.settingsLoaded,
|
||||||
|
projectsCount: useAppStore.getState().projects?.length ?? 0,
|
||||||
|
});
|
||||||
|
if (!auth.authChecked || !auth.isAuthenticated || !auth.settingsLoaded) {
|
||||||
|
logger.debug('Sync skipped: not authenticated or settings not loaded');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +136,8 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
const api = getHttpApiClient();
|
const api = getHttpApiClient();
|
||||||
const appState = useAppStore.getState();
|
const appState = useAppStore.getState();
|
||||||
|
|
||||||
|
logger.debug('Syncing to server:', { projectsCount: appState.projects?.length ?? 0 });
|
||||||
|
|
||||||
// Build updates object from current state
|
// Build updates object from current state
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
for (const field of SETTINGS_FIELDS_TO_SYNC) {
|
for (const field of SETTINGS_FIELDS_TO_SYNC) {
|
||||||
@@ -147,10 +158,13 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
// Create a hash of the updates to avoid redundant syncs
|
// Create a hash of the updates to avoid redundant syncs
|
||||||
const updateHash = JSON.stringify(updates);
|
const updateHash = JSON.stringify(updates);
|
||||||
if (updateHash === lastSyncedRef.current) {
|
if (updateHash === lastSyncedRef.current) {
|
||||||
|
logger.debug('Sync skipped: no changes');
|
||||||
setState((s) => ({ ...s, syncing: false }));
|
setState((s) => ({ ...s, syncing: false }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('Sending settings update:', { projects: updates.projects });
|
||||||
|
|
||||||
const result = await api.settings.updateGlobal(updates);
|
const result = await api.settings.updateGlobal(updates);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
lastSyncedRef.current = updateHash;
|
lastSyncedRef.current = updateHash;
|
||||||
@@ -184,11 +198,20 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
void syncToServer();
|
void syncToServer();
|
||||||
}, [syncToServer]);
|
}, [syncToServer]);
|
||||||
|
|
||||||
// Initialize sync - WAIT for migration to complete first
|
// Initialize sync - WAIT for settings to be loaded and migration to complete
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Don't initialize syncing until we know auth status and are authenticated.
|
// Don't initialize syncing until:
|
||||||
// Prevents accidental overwrites when the app boots before settings are hydrated.
|
// 1. Auth has been checked
|
||||||
if (!authChecked || !isAuthenticated) return;
|
// 2. User is authenticated
|
||||||
|
// 3. Settings have been loaded from server (settingsLoaded flag)
|
||||||
|
// This prevents syncing empty/default state before hydration completes.
|
||||||
|
logger.debug('useSettingsSync initialization check:', {
|
||||||
|
authChecked,
|
||||||
|
isAuthenticated,
|
||||||
|
settingsLoaded,
|
||||||
|
stateLoaded: state.loaded,
|
||||||
|
});
|
||||||
|
if (!authChecked || !isAuthenticated || !settingsLoaded) return;
|
||||||
if (isInitializedRef.current) return;
|
if (isInitializedRef.current) return;
|
||||||
isInitializedRef.current = true;
|
isInitializedRef.current = true;
|
||||||
|
|
||||||
@@ -198,14 +221,26 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
await waitForApiKeyInit();
|
await waitForApiKeyInit();
|
||||||
|
|
||||||
// CRITICAL: Wait for migration/hydration to complete before we start syncing
|
// CRITICAL: Wait for migration/hydration to complete before we start syncing
|
||||||
// This prevents overwriting server data with empty/default state
|
// This is a backup to the settingsLoaded flag for extra safety
|
||||||
logger.info('Waiting for migration to complete before starting sync...');
|
logger.info('Waiting for migration to complete before starting sync...');
|
||||||
await waitForMigrationComplete();
|
await waitForMigrationComplete();
|
||||||
|
|
||||||
|
// Wait for React to finish rendering after store hydration.
|
||||||
|
// Zustand's subscribe() fires during setState(), which happens BEFORE React's
|
||||||
|
// render completes. Use a small delay to ensure all pending state updates
|
||||||
|
// have propagated through the React tree before we read state.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
logger.info('Migration complete, initializing sync');
|
logger.info('Migration complete, initializing sync');
|
||||||
|
|
||||||
|
// Read state - at this point React has processed the store update
|
||||||
|
const appState = useAppStore.getState();
|
||||||
|
const setupState = useSetupStore.getState();
|
||||||
|
|
||||||
|
logger.info('Initial state read:', { projectsCount: appState.projects?.length ?? 0 });
|
||||||
|
|
||||||
// Store the initial state hash to avoid immediate re-sync
|
// Store the initial state hash to avoid immediate re-sync
|
||||||
// (migration has already hydrated the store from server/localStorage)
|
// (migration has already hydrated the store from server/localStorage)
|
||||||
const appState = useAppStore.getState();
|
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
for (const field of SETTINGS_FIELDS_TO_SYNC) {
|
for (const field of SETTINGS_FIELDS_TO_SYNC) {
|
||||||
if (field === 'currentProjectId') {
|
if (field === 'currentProjectId') {
|
||||||
@@ -214,7 +249,6 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
updates[field] = appState[field as keyof typeof appState];
|
updates[field] = appState[field as keyof typeof appState];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const setupState = useSetupStore.getState();
|
|
||||||
for (const field of SETUP_FIELDS_TO_SYNC) {
|
for (const field of SETUP_FIELDS_TO_SYNC) {
|
||||||
updates[field] = setupState[field as keyof typeof setupState];
|
updates[field] = setupState[field as keyof typeof setupState];
|
||||||
}
|
}
|
||||||
@@ -233,16 +267,33 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initializeSync();
|
initializeSync();
|
||||||
}, [authChecked, isAuthenticated]);
|
}, [authChecked, isAuthenticated, settingsLoaded]);
|
||||||
|
|
||||||
// Subscribe to store changes and sync to server
|
// Subscribe to store changes and sync to server
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.loaded || !authChecked || !isAuthenticated) return;
|
if (!state.loaded || !authChecked || !isAuthenticated || !settingsLoaded) return;
|
||||||
|
|
||||||
// Subscribe to app store changes
|
// Subscribe to app store changes
|
||||||
const unsubscribeApp = useAppStore.subscribe((newState, prevState) => {
|
const unsubscribeApp = useAppStore.subscribe((newState, prevState) => {
|
||||||
|
const auth = useAuthStore.getState();
|
||||||
|
logger.debug('Store subscription fired:', {
|
||||||
|
prevProjects: prevState.projects?.length ?? 0,
|
||||||
|
newProjects: newState.projects?.length ?? 0,
|
||||||
|
authChecked: auth.authChecked,
|
||||||
|
isAuthenticated: auth.isAuthenticated,
|
||||||
|
settingsLoaded: auth.settingsLoaded,
|
||||||
|
loaded: state.loaded,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't sync if settings not loaded yet
|
||||||
|
if (!auth.settingsLoaded) {
|
||||||
|
logger.debug('Store changed but settings not loaded, skipping sync');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If the current project changed, sync immediately so we can restore on next launch
|
// If the current project changed, sync immediately so we can restore on next launch
|
||||||
if (newState.currentProject?.id !== prevState.currentProject?.id) {
|
if (newState.currentProject?.id !== prevState.currentProject?.id) {
|
||||||
|
logger.debug('Current project changed, syncing immediately');
|
||||||
syncNow();
|
syncNow();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -266,6 +317,7 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
|
logger.debug('Store changed, scheduling sync');
|
||||||
scheduleSyncToServer();
|
scheduleSyncToServer();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -294,11 +346,11 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
clearTimeout(syncTimeoutRef.current);
|
clearTimeout(syncTimeoutRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [state.loaded, authChecked, isAuthenticated, scheduleSyncToServer, syncNow]);
|
}, [state.loaded, authChecked, isAuthenticated, settingsLoaded, scheduleSyncToServer, syncNow]);
|
||||||
|
|
||||||
// Best-effort flush on tab close / backgrounding
|
// Best-effort flush on tab close / backgrounding
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.loaded || !authChecked || !isAuthenticated) return;
|
if (!state.loaded || !authChecked || !isAuthenticated || !settingsLoaded) return;
|
||||||
|
|
||||||
const handleBeforeUnload = () => {
|
const handleBeforeUnload = () => {
|
||||||
// Fire-and-forget; may not complete in all browsers, but helps in Electron/webview
|
// Fire-and-forget; may not complete in all browsers, but helps in Electron/webview
|
||||||
@@ -318,7 +370,7 @@ export function useSettingsSync(): SettingsSyncState {
|
|||||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
};
|
};
|
||||||
}, [state.loaded, authChecked, isAuthenticated, syncNow]);
|
}, [state.loaded, authChecked, isAuthenticated, settingsLoaded, syncNow]);
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { getApiKey, getSessionToken, getServerUrlSync } from './http-api-client'
|
|||||||
|
|
||||||
// Server URL - uses shared cached URL from http-api-client
|
// Server URL - uses shared cached URL from http-api-client
|
||||||
const getServerUrl = (): string => getServerUrlSync();
|
const getServerUrl = (): string => getServerUrlSync();
|
||||||
|
const DEFAULT_CACHE_MODE: RequestCache = 'no-store';
|
||||||
|
|
||||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||||
|
|
||||||
@@ -80,7 +81,7 @@ export async function apiFetch(
|
|||||||
method: HttpMethod = 'GET',
|
method: HttpMethod = 'GET',
|
||||||
options: ApiFetchOptions = {}
|
options: ApiFetchOptions = {}
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const { headers: additionalHeaders, body, skipAuth, ...restOptions } = options;
|
const { headers: additionalHeaders, body, skipAuth, cache, ...restOptions } = options;
|
||||||
|
|
||||||
const headers = skipAuth
|
const headers = skipAuth
|
||||||
? { 'Content-Type': 'application/json', ...additionalHeaders }
|
? { 'Content-Type': 'application/json', ...additionalHeaders }
|
||||||
@@ -90,6 +91,7 @@ export async function apiFetch(
|
|||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
cache: cache ?? DEFAULT_CACHE_MODE,
|
||||||
...restOptions,
|
...restOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/typ
|
|||||||
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
|
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
|
||||||
|
|
||||||
const logger = createLogger('HttpClient');
|
const logger = createLogger('HttpClient');
|
||||||
|
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
||||||
|
|
||||||
// Cached server URL (set during initialization in Electron mode)
|
// Cached server URL (set during initialization in Electron mode)
|
||||||
let cachedServerUrl: string | null = null;
|
let cachedServerUrl: string | null = null;
|
||||||
@@ -69,6 +70,7 @@ const handleUnauthorized = (): void => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: '{}',
|
body: '{}',
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
notifyLoggedOut();
|
notifyLoggedOut();
|
||||||
};
|
};
|
||||||
@@ -296,6 +298,7 @@ export const checkAuthStatus = async (): Promise<{
|
|||||||
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
|
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: getApiKey() ? { 'X-API-Key': getApiKey()! } : undefined,
|
headers: getApiKey() ? { 'X-API-Key': getApiKey()! } : undefined,
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return {
|
return {
|
||||||
@@ -322,6 +325,7 @@ export const login = async (
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ apiKey }),
|
body: JSON.stringify({ apiKey }),
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
@@ -361,6 +365,7 @@ export const fetchSessionToken = async (): Promise<boolean> => {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
|
const response = await fetch(`${getServerUrl()}/api/auth/status`, {
|
||||||
credentials: 'include', // Send the session cookie
|
credentials: 'include', // Send the session cookie
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -391,6 +396,7 @@ export const logout = async (): Promise<{ success: boolean }> => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear the cached session token
|
// Clear the cached session token
|
||||||
@@ -439,6 +445,7 @@ export const verifySession = async (): Promise<boolean> => {
|
|||||||
const response = await fetch(`${getServerUrl()}/api/settings/status`, {
|
const response = await fetch(`${getServerUrl()}/api/settings/status`, {
|
||||||
headers,
|
headers,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
// Avoid hanging indefinitely during backend reloads or network issues
|
// Avoid hanging indefinitely during backend reloads or network issues
|
||||||
signal: AbortSignal.timeout(2500),
|
signal: AbortSignal.timeout(2500),
|
||||||
});
|
});
|
||||||
@@ -475,6 +482,7 @@ export const checkSandboxEnvironment = async (): Promise<{
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`${getServerUrl()}/api/health/environment`, {
|
const response = await fetch(`${getServerUrl()}/api/health/environment`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -556,6 +564,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
const response = await fetch(`${this.serverUrl}/api/auth/token`, {
|
const response = await fetch(`${this.serverUrl}/api/auth/token`, {
|
||||||
headers,
|
headers,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
@@ -587,6 +596,17 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
|
|
||||||
this.isConnecting = true;
|
this.isConnecting = true;
|
||||||
|
|
||||||
|
// Wait for API key initialization to complete before attempting connection
|
||||||
|
// This prevents race conditions during app startup
|
||||||
|
waitForApiKeyInit()
|
||||||
|
.then(() => this.doConnectWebSocketInternal())
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error('Failed to initialize for WebSocket connection:', error);
|
||||||
|
this.isConnecting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private doConnectWebSocketInternal(): void {
|
||||||
// Electron mode typically authenticates with the injected API key.
|
// Electron mode typically authenticates with the injected API key.
|
||||||
// However, in external-server/cookie-auth flows, the API key may be unavailable.
|
// However, in external-server/cookie-auth flows, the API key may be unavailable.
|
||||||
// In that case, fall back to the same wsToken/cookie authentication used in web mode
|
// In that case, fall back to the same wsToken/cookie authentication used in web mode
|
||||||
@@ -771,6 +791,7 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
const response = await fetch(`${this.serverUrl}${endpoint}`, {
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
credentials: 'include', // Include cookies for session auth
|
credentials: 'include', // Include cookies for session auth
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import { useSetupStore } from '@/store/setup-store';
|
|||||||
import { useAuthStore } from '@/store/auth-store';
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
import { getElectronAPI, isElectron } from '@/lib/electron';
|
import { getElectronAPI, isElectron } from '@/lib/electron';
|
||||||
import { isMac } from '@/lib/utils';
|
import { isMac } from '@/lib/utils';
|
||||||
|
import { initializeProject } from '@/lib/project-init';
|
||||||
import {
|
import {
|
||||||
initApiKey,
|
initApiKey,
|
||||||
verifySession,
|
verifySession,
|
||||||
checkSandboxEnvironment,
|
checkSandboxEnvironment,
|
||||||
getServerUrlSync,
|
getServerUrlSync,
|
||||||
getHttpApiClient,
|
getHttpApiClient,
|
||||||
|
handleServerOffline,
|
||||||
} from '@/lib/http-api-client';
|
} from '@/lib/http-api-client';
|
||||||
import {
|
import {
|
||||||
hydrateStoreFromSettings,
|
hydrateStoreFromSettings,
|
||||||
@@ -30,8 +32,17 @@ import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
|
|||||||
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
|
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
|
||||||
import { LoadingState } from '@/components/ui/loading-state';
|
import { LoadingState } from '@/components/ui/loading-state';
|
||||||
import { useProjectSettingsLoader } from '@/hooks/use-project-settings-loader';
|
import { useProjectSettingsLoader } from '@/hooks/use-project-settings-loader';
|
||||||
|
import type { Project } from '@/lib/electron';
|
||||||
|
|
||||||
const logger = createLogger('RootLayout');
|
const logger = createLogger('RootLayout');
|
||||||
|
const SERVER_READY_MAX_ATTEMPTS = 8;
|
||||||
|
const SERVER_READY_BACKOFF_BASE_MS = 250;
|
||||||
|
const SERVER_READY_MAX_DELAY_MS = 1500;
|
||||||
|
const SERVER_READY_TIMEOUT_MS = 2000;
|
||||||
|
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
||||||
|
const AUTO_OPEN_HISTORY_INDEX = 0;
|
||||||
|
const SINGLE_PROJECT_COUNT = 1;
|
||||||
|
const DEFAULT_LAST_OPENED_TIME_MS = 0;
|
||||||
|
|
||||||
// Apply stored theme immediately on page load (before React hydration)
|
// Apply stored theme immediately on page load (before React hydration)
|
||||||
// This prevents flash of default theme on login/setup pages
|
// This prevents flash of default theme on login/setup pages
|
||||||
@@ -60,11 +71,84 @@ function applyStoredTheme(): void {
|
|||||||
// Apply stored theme immediately (runs synchronously before render)
|
// Apply stored theme immediately (runs synchronously before render)
|
||||||
applyStoredTheme();
|
applyStoredTheme();
|
||||||
|
|
||||||
|
async function waitForServerReady(): Promise<boolean> {
|
||||||
|
const serverUrl = getServerUrlSync();
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= SERVER_READY_MAX_ATTEMPTS; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${serverUrl}/api/health`, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: AbortSignal.timeout(SERVER_READY_TIMEOUT_MS),
|
||||||
|
cache: NO_STORE_CACHE_MODE,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Server readiness check failed (attempt ${attempt})`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const delayMs = Math.min(SERVER_READY_MAX_DELAY_MS, SERVER_READY_BACKOFF_BASE_MS * attempt);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectLastOpenedMs(project: Project): number {
|
||||||
|
if (!project.lastOpened) return DEFAULT_LAST_OPENED_TIME_MS;
|
||||||
|
const parsed = Date.parse(project.lastOpened);
|
||||||
|
return Number.isNaN(parsed) ? DEFAULT_LAST_OPENED_TIME_MS : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAutoOpenProject(
|
||||||
|
currentProject: Project | null,
|
||||||
|
projects: Project[],
|
||||||
|
projectHistory: string[]
|
||||||
|
): Project | null {
|
||||||
|
if (currentProject) return currentProject;
|
||||||
|
|
||||||
|
if (projectHistory.length > 0) {
|
||||||
|
const historyProjectId = projectHistory[AUTO_OPEN_HISTORY_INDEX];
|
||||||
|
const historyProject = projects.find((project) => project.id === historyProjectId);
|
||||||
|
if (historyProject) {
|
||||||
|
return historyProject;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projects.length === SINGLE_PROJECT_COUNT) {
|
||||||
|
return projects[AUTO_OPEN_HISTORY_INDEX] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projects.length > SINGLE_PROJECT_COUNT) {
|
||||||
|
let latestProject: Project | null = projects[AUTO_OPEN_HISTORY_INDEX] ?? null;
|
||||||
|
let latestTimestamp = latestProject
|
||||||
|
? getProjectLastOpenedMs(latestProject)
|
||||||
|
: DEFAULT_LAST_OPENED_TIME_MS;
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
const openedAt = getProjectLastOpenedMs(project);
|
||||||
|
if (openedAt > latestTimestamp) {
|
||||||
|
latestTimestamp = openedAt;
|
||||||
|
latestProject = project;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestProject;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function RootLayoutContent() {
|
function RootLayoutContent() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const {
|
const {
|
||||||
setIpcConnected,
|
setIpcConnected,
|
||||||
|
projects,
|
||||||
currentProject,
|
currentProject,
|
||||||
|
projectHistory,
|
||||||
|
upsertAndSetCurrentProject,
|
||||||
getEffectiveTheme,
|
getEffectiveTheme,
|
||||||
skipSandboxWarning,
|
skipSandboxWarning,
|
||||||
setSkipSandboxWarning,
|
setSkipSandboxWarning,
|
||||||
@@ -85,6 +169,8 @@ function RootLayoutContent() {
|
|||||||
const isLoginRoute = location.pathname === '/login';
|
const isLoginRoute = location.pathname === '/login';
|
||||||
const isLoggedOutRoute = location.pathname === '/logged-out';
|
const isLoggedOutRoute = location.pathname === '/logged-out';
|
||||||
const isDashboardRoute = location.pathname === '/dashboard';
|
const isDashboardRoute = location.pathname === '/dashboard';
|
||||||
|
const isBoardRoute = location.pathname === '/board';
|
||||||
|
const isRootRoute = location.pathname === '/';
|
||||||
|
|
||||||
// Sandbox environment check state
|
// Sandbox environment check state
|
||||||
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
|
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
|
||||||
@@ -212,15 +298,18 @@ function RootLayoutContent() {
|
|||||||
|
|
||||||
// Ref to prevent concurrent auth checks from running
|
// Ref to prevent concurrent auth checks from running
|
||||||
const authCheckRunning = useRef(false);
|
const authCheckRunning = useRef(false);
|
||||||
|
const autoOpenAttemptedRef = useRef(false);
|
||||||
|
|
||||||
// Global listener for 401/403 responses during normal app usage.
|
// Global listener for 401/403 responses during normal app usage.
|
||||||
// This is triggered by the HTTP client whenever an authenticated request returns 401/403.
|
// This is triggered by the HTTP client whenever an authenticated request returns 401/403.
|
||||||
// Works for ALL modes (unified flow)
|
// Works for ALL modes (unified flow)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleLoggedOut = () => {
|
const handleLoggedOut = () => {
|
||||||
|
logger.warn('automaker:logged-out event received!');
|
||||||
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
|
|
||||||
if (location.pathname !== '/logged-out') {
|
if (location.pathname !== '/logged-out') {
|
||||||
|
logger.warn('Navigating to /logged-out due to logged-out event');
|
||||||
navigate({ to: '/logged-out' });
|
navigate({ to: '/logged-out' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -236,6 +325,7 @@ function RootLayoutContent() {
|
|||||||
// Redirects to login page which will detect server is offline and show error UI.
|
// Redirects to login page which will detect server is offline and show error UI.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleServerOffline = () => {
|
const handleServerOffline = () => {
|
||||||
|
logger.warn('automaker:server-offline event received!');
|
||||||
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
|
|
||||||
// Navigate to login - the login page will detect server is offline and show appropriate UI
|
// Navigate to login - the login page will detect server is offline and show appropriate UI
|
||||||
@@ -266,6 +356,12 @@ function RootLayoutContent() {
|
|||||||
// Initialize API key for Electron mode
|
// Initialize API key for Electron mode
|
||||||
await initApiKey();
|
await initApiKey();
|
||||||
|
|
||||||
|
const serverReady = await waitForServerReady();
|
||||||
|
if (!serverReady) {
|
||||||
|
handleServerOffline();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Verify session (Single Request, ALL modes)
|
// 1. Verify session (Single Request, ALL modes)
|
||||||
let isValid = false;
|
let isValid = false;
|
||||||
try {
|
try {
|
||||||
@@ -302,13 +398,28 @@ function RootLayoutContent() {
|
|||||||
// Hydrate store with the final settings (merged if migration occurred)
|
// Hydrate store with the final settings (merged if migration occurred)
|
||||||
hydrateStoreFromSettings(finalSettings);
|
hydrateStoreFromSettings(finalSettings);
|
||||||
|
|
||||||
// Signal that settings hydration is complete so useSettingsSync can start
|
// CRITICAL: Wait for React to render the hydrated state before
|
||||||
|
// signaling completion. Zustand updates are synchronous, but React
|
||||||
|
// hasn't necessarily re-rendered yet. This prevents race conditions
|
||||||
|
// where useSettingsSync reads state before the UI has updated.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
// Signal that settings hydration is complete FIRST.
|
||||||
|
// This ensures useSettingsSync's waitForMigrationComplete() will resolve
|
||||||
|
// immediately when it starts after auth state change, preventing it from
|
||||||
|
// syncing default empty state to the server.
|
||||||
signalMigrationComplete();
|
signalMigrationComplete();
|
||||||
|
|
||||||
// Mark auth as checked only after settings hydration succeeded.
|
// Now mark auth as checked AND settings as loaded.
|
||||||
useAuthStore
|
// The settingsLoaded flag ensures useSettingsSync won't start syncing
|
||||||
.getState()
|
// until settings have been properly hydrated, even if authChecked was
|
||||||
.setAuthState({ isAuthenticated: true, authChecked: true });
|
// set earlier by login-view.
|
||||||
|
useAuthStore.getState().setAuthState({
|
||||||
|
isAuthenticated: true,
|
||||||
|
authChecked: true,
|
||||||
|
settingsLoaded: true,
|
||||||
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,22 +479,46 @@ function RootLayoutContent() {
|
|||||||
// Note: Settings are now loaded in __root.tsx after successful session verification
|
// Note: Settings are now loaded in __root.tsx after successful session verification
|
||||||
// This ensures a unified flow across all modes (Electron, web, external server)
|
// This ensures a unified flow across all modes (Electron, web, external server)
|
||||||
|
|
||||||
|
// Get settingsLoaded from auth store for routing decisions
|
||||||
|
const settingsLoaded = useAuthStore((s) => s.settingsLoaded);
|
||||||
|
|
||||||
// Routing rules (ALL modes - unified flow):
|
// Routing rules (ALL modes - unified flow):
|
||||||
// - If not authenticated: force /logged-out (even /setup is protected)
|
// - If not authenticated: force /logged-out (even /setup is protected)
|
||||||
// - If authenticated but setup incomplete: force /setup
|
// - If authenticated but setup incomplete: force /setup
|
||||||
// - If authenticated and setup complete: allow access to app
|
// - If authenticated and setup complete: allow access to app
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
logger.debug('Routing effect triggered:', {
|
||||||
|
authChecked,
|
||||||
|
isAuthenticated,
|
||||||
|
settingsLoaded,
|
||||||
|
setupComplete,
|
||||||
|
pathname: location.pathname,
|
||||||
|
});
|
||||||
|
|
||||||
// Wait for auth check to complete before enforcing any redirects
|
// Wait for auth check to complete before enforcing any redirects
|
||||||
if (!authChecked) return;
|
if (!authChecked) {
|
||||||
|
logger.debug('Auth not checked yet, skipping routing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Unauthenticated -> force /logged-out (but allow /login so user can authenticate)
|
// Unauthenticated -> force /logged-out (but allow /login so user can authenticate)
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
|
logger.warn('Not authenticated, redirecting to /logged-out. Auth state:', {
|
||||||
|
authChecked,
|
||||||
|
isAuthenticated,
|
||||||
|
settingsLoaded,
|
||||||
|
currentPath: location.pathname,
|
||||||
|
});
|
||||||
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
|
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
|
||||||
navigate({ to: '/logged-out' });
|
navigate({ to: '/logged-out' });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for settings to be loaded before making setupComplete-based routing decisions
|
||||||
|
// This prevents redirecting to /setup before we know the actual setupComplete value
|
||||||
|
if (!settingsLoaded) return;
|
||||||
|
|
||||||
// Authenticated -> determine whether setup is required
|
// Authenticated -> determine whether setup is required
|
||||||
if (!setupComplete && location.pathname !== '/setup') {
|
if (!setupComplete && location.pathname !== '/setup') {
|
||||||
navigate({ to: '/setup' });
|
navigate({ to: '/setup' });
|
||||||
@@ -394,7 +529,46 @@ function RootLayoutContent() {
|
|||||||
if (setupComplete && location.pathname === '/setup') {
|
if (setupComplete && location.pathname === '/setup') {
|
||||||
navigate({ to: '/dashboard' });
|
navigate({ to: '/dashboard' });
|
||||||
}
|
}
|
||||||
}, [authChecked, isAuthenticated, setupComplete, location.pathname, navigate]);
|
}, [authChecked, isAuthenticated, settingsLoaded, setupComplete, location.pathname, navigate]);
|
||||||
|
|
||||||
|
// Fallback: If auth is checked and authenticated but settings not loaded,
|
||||||
|
// it means login-view or another component set auth state before __root.tsx's
|
||||||
|
// auth flow completed. Load settings now to prevent sync with empty state.
|
||||||
|
useEffect(() => {
|
||||||
|
// Only trigger if auth is valid but settings aren't loaded yet
|
||||||
|
// This handles the case where login-view sets authChecked=true before we finish our auth flow
|
||||||
|
if (!authChecked || !isAuthenticated || settingsLoaded) {
|
||||||
|
logger.debug('Fallback skipped:', { authChecked, isAuthenticated, settingsLoaded });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Auth valid but settings not loaded - triggering fallback load');
|
||||||
|
|
||||||
|
const loadSettings = async () => {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
try {
|
||||||
|
logger.debug('Fetching settings in fallback...');
|
||||||
|
const settingsResult = await api.settings.getGlobal();
|
||||||
|
logger.debug('Settings fetched:', settingsResult.success ? 'success' : 'failed');
|
||||||
|
if (settingsResult.success && settingsResult.settings) {
|
||||||
|
const { settings: finalSettings } = await performSettingsMigration(
|
||||||
|
settingsResult.settings as unknown as Parameters<typeof performSettingsMigration>[0]
|
||||||
|
);
|
||||||
|
logger.debug('Settings migrated, hydrating stores...');
|
||||||
|
hydrateStoreFromSettings(finalSettings);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
signalMigrationComplete();
|
||||||
|
logger.debug('Setting settingsLoaded=true');
|
||||||
|
useAuthStore.getState().setAuthState({ settingsLoaded: true });
|
||||||
|
logger.info('Fallback settings load completed successfully');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to load settings in fallback:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSettings();
|
||||||
|
}, [authChecked, isAuthenticated, settingsLoaded]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGlobalFileBrowser(openFileBrowser);
|
setGlobalFileBrowser(openFileBrowser);
|
||||||
@@ -428,7 +602,7 @@ function RootLayoutContent() {
|
|||||||
|
|
||||||
// Redirect from welcome page based on project state
|
// Redirect from welcome page based on project state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMounted && location.pathname === '/') {
|
if (isMounted && isRootRoute) {
|
||||||
if (currentProject) {
|
if (currentProject) {
|
||||||
// Project is selected, go to board
|
// Project is selected, go to board
|
||||||
navigate({ to: '/board' });
|
navigate({ to: '/board' });
|
||||||
@@ -437,14 +611,64 @@ function RootLayoutContent() {
|
|||||||
navigate({ to: '/dashboard' });
|
navigate({ to: '/dashboard' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isMounted, currentProject, location.pathname, navigate]);
|
}, [isMounted, currentProject, isRootRoute, navigate]);
|
||||||
|
|
||||||
|
// Auto-open the most recent project on startup
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoOpenAttemptedRef.current) return;
|
||||||
|
if (!authChecked || !isAuthenticated || !settingsLoaded) return;
|
||||||
|
if (!setupComplete) return;
|
||||||
|
if (isLoginRoute || isLoggedOutRoute || isSetupRoute) return;
|
||||||
|
if (isBoardRoute) return;
|
||||||
|
|
||||||
|
const projectToOpen = selectAutoOpenProject(currentProject, projects, projectHistory);
|
||||||
|
if (!projectToOpen) return;
|
||||||
|
|
||||||
|
autoOpenAttemptedRef.current = true;
|
||||||
|
|
||||||
|
const openProject = async () => {
|
||||||
|
const initResult = await initializeProject(projectToOpen.path);
|
||||||
|
if (!initResult.success) {
|
||||||
|
logger.warn('Auto-open project failed:', initResult.error);
|
||||||
|
if (isRootRoute) {
|
||||||
|
navigate({ to: '/dashboard' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentProject || currentProject.id !== projectToOpen.id) {
|
||||||
|
upsertAndSetCurrentProject(projectToOpen.path, projectToOpen.name, projectToOpen.theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isBoardRoute) {
|
||||||
|
navigate({ to: '/board' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void openProject();
|
||||||
|
}, [
|
||||||
|
authChecked,
|
||||||
|
isAuthenticated,
|
||||||
|
settingsLoaded,
|
||||||
|
setupComplete,
|
||||||
|
isLoginRoute,
|
||||||
|
isLoggedOutRoute,
|
||||||
|
isSetupRoute,
|
||||||
|
isBoardRoute,
|
||||||
|
isRootRoute,
|
||||||
|
currentProject,
|
||||||
|
projects,
|
||||||
|
projectHistory,
|
||||||
|
navigate,
|
||||||
|
upsertAndSetCurrentProject,
|
||||||
|
]);
|
||||||
|
|
||||||
// Bootstrap Codex models on app startup (after auth completes)
|
// Bootstrap Codex models on app startup (after auth completes)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only fetch if authenticated and Codex CLI is available
|
// Only fetch if authenticated and Codex CLI is available
|
||||||
if (!authChecked || !isAuthenticated) return;
|
if (!authChecked || !isAuthenticated) return;
|
||||||
|
|
||||||
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated;
|
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.hasApiKey;
|
||||||
if (!isCodexAvailable) return;
|
if (!isCodexAvailable) return;
|
||||||
|
|
||||||
// Fetch models in the background
|
// Fetch models in the background
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ interface AuthState {
|
|||||||
authChecked: boolean;
|
authChecked: boolean;
|
||||||
/** Whether the user is currently authenticated (web mode: valid session cookie) */
|
/** Whether the user is currently authenticated (web mode: valid session cookie) */
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
|
/** Whether settings have been loaded and hydrated from server */
|
||||||
|
settingsLoaded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthActions {
|
interface AuthActions {
|
||||||
@@ -15,15 +17,18 @@ interface AuthActions {
|
|||||||
const initialState: AuthState = {
|
const initialState: AuthState = {
|
||||||
authChecked: false,
|
authChecked: false,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
settingsLoaded: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Web authentication state.
|
* Web authentication state.
|
||||||
*
|
*
|
||||||
* Intentionally NOT persisted: source of truth is the server session cookie.
|
* Intentionally NOT persisted: source of truth is server session cookie.
|
||||||
*/
|
*/
|
||||||
export const useAuthStore = create<AuthState & AuthActions>((set) => ({
|
export const useAuthStore = create<AuthState & AuthActions>((set) => ({
|
||||||
...initialState,
|
...initialState,
|
||||||
setAuthState: (state) => set(state),
|
setAuthState: (state) => {
|
||||||
|
set({ ...state });
|
||||||
|
},
|
||||||
resetAuth: () => set(initialState),
|
resetAuth: () => set(initialState),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface CliStatus {
|
|||||||
path: string | null;
|
path: string | null;
|
||||||
version: string | null;
|
version: string | null;
|
||||||
method: string;
|
method: string;
|
||||||
|
hasApiKey?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user