mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
feat: implement authentication state management and routing logic
- Added a new auth store using Zustand to manage authentication state, including `authChecked` and `isAuthenticated`. - Updated `LoginView` to set authentication state upon successful login and navigate based on setup completion. - Enhanced `RootLayoutContent` to enforce routing rules based on authentication status, redirecting users to login or setup as necessary. - Improved error handling and loading states during authentication checks.
This commit is contained in:
@@ -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<SandboxStatus>(() => {
|
||||
// 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<SandboxStatus>('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 <SandboxRejectionScreen />;
|
||||
@@ -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 (
|
||||
<main className="flex h-screen items-center justify-center" data-testid="app-container">
|
||||
<div className="text-muted-foreground">Redirecting to login...</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Show setup page (full screen, no sidebar) - authenticated only
|
||||
if (isSetupRoute) {
|
||||
return (
|
||||
<main className="h-screen overflow-hidden" data-testid="app-container">
|
||||
|
||||
Reference in New Issue
Block a user