fix: restore auth and auto-open last project

This commit is contained in:
DhanushSantosh
2026-01-11 20:43:55 +05:30
parent 6e4b611662
commit 785a4d2c3b
8 changed files with 378 additions and 30 deletions

View File

@@ -12,12 +12,14 @@ import { useSetupStore } from '@/store/setup-store';
import { useAuthStore } from '@/store/auth-store';
import { getElectronAPI, isElectron } from '@/lib/electron';
import { isMac } from '@/lib/utils';
import { initializeProject } from '@/lib/project-init';
import {
initApiKey,
verifySession,
checkSandboxEnvironment,
getServerUrlSync,
getHttpApiClient,
handleServerOffline,
} from '@/lib/http-api-client';
import {
hydrateStoreFromSettings,
@@ -30,8 +32,17 @@ import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
import { LoadingState } from '@/components/ui/loading-state';
import { useProjectSettingsLoader } from '@/hooks/use-project-settings-loader';
import type { Project } from '@/lib/electron';
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)
// 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)
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() {
const location = useLocation();
const {
setIpcConnected,
projects,
currentProject,
projectHistory,
upsertAndSetCurrentProject,
getEffectiveTheme,
skipSandboxWarning,
setSkipSandboxWarning,
@@ -85,6 +169,8 @@ function RootLayoutContent() {
const isLoginRoute = location.pathname === '/login';
const isLoggedOutRoute = location.pathname === '/logged-out';
const isDashboardRoute = location.pathname === '/dashboard';
const isBoardRoute = location.pathname === '/board';
const isRootRoute = location.pathname === '/';
// Sandbox environment check state
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
@@ -212,15 +298,18 @@ function RootLayoutContent() {
// Ref to prevent concurrent auth checks from running
const authCheckRunning = useRef(false);
const autoOpenAttemptedRef = 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 = () => {
logger.warn('automaker:logged-out event received!');
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
if (location.pathname !== '/logged-out') {
logger.warn('Navigating to /logged-out due to logged-out event');
navigate({ to: '/logged-out' });
}
};
@@ -236,6 +325,7 @@ function RootLayoutContent() {
// Redirects to login page which will detect server is offline and show error UI.
useEffect(() => {
const handleServerOffline = () => {
logger.warn('automaker:server-offline event received!');
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
// 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
await initApiKey();
const serverReady = await waitForServerReady();
if (!serverReady) {
handleServerOffline();
return;
}
// 1. Verify session (Single Request, ALL modes)
let isValid = false;
try {
@@ -302,13 +398,28 @@ function RootLayoutContent() {
// Hydrate store with the final settings (merged if migration occurred)
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();
// Mark auth as checked only after settings hydration succeeded.
useAuthStore
.getState()
.setAuthState({ isAuthenticated: true, authChecked: true });
// Now mark auth as checked AND settings as loaded.
// The settingsLoaded flag ensures useSettingsSync won't start syncing
// until settings have been properly hydrated, even if authChecked was
// set earlier by login-view.
useAuthStore.getState().setAuthState({
isAuthenticated: true,
authChecked: true,
settingsLoaded: true,
});
return;
}
@@ -368,22 +479,46 @@ function RootLayoutContent() {
// Note: Settings are now loaded in __root.tsx after successful session verification
// 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):
// - 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(() => {
logger.debug('Routing effect triggered:', {
authChecked,
isAuthenticated,
settingsLoaded,
setupComplete,
pathname: location.pathname,
});
// 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)
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') {
navigate({ to: '/logged-out' });
}
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
if (!setupComplete && location.pathname !== '/setup') {
navigate({ to: '/setup' });
@@ -394,7 +529,46 @@ function RootLayoutContent() {
if (setupComplete && location.pathname === '/setup') {
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(() => {
setGlobalFileBrowser(openFileBrowser);
@@ -428,7 +602,7 @@ function RootLayoutContent() {
// Redirect from welcome page based on project state
useEffect(() => {
if (isMounted && location.pathname === '/') {
if (isMounted && isRootRoute) {
if (currentProject) {
// Project is selected, go to board
navigate({ to: '/board' });
@@ -437,14 +611,64 @@ function RootLayoutContent() {
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)
useEffect(() => {
// Only fetch if authenticated and Codex CLI is available
if (!authChecked || !isAuthenticated) return;
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated;
const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.hasApiKey;
if (!isCodexAvailable) return;
// Fetch models in the background