mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +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:
@@ -11,9 +11,13 @@ import { login } 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 } from 'lucide-react';
|
import { KeyRound, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
import { useAuthStore } from '@/store/auth-store';
|
||||||
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
|
|
||||||
export function LoginView() {
|
export function LoginView() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const setAuthState = useAuthStore((s) => s.setAuthState);
|
||||||
|
const setupComplete = useSetupStore((s) => s.setupComplete);
|
||||||
const [apiKey, setApiKey] = useState('');
|
const [apiKey, setApiKey] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -26,8 +30,11 @@ export function LoginView() {
|
|||||||
try {
|
try {
|
||||||
const result = await login(apiKey.trim());
|
const result = await login(apiKey.trim());
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Redirect to home/board on success
|
// Mark as authenticated for this session (cookie-based auth)
|
||||||
navigate({ to: '/' });
|
setAuthState({ isAuthenticated: true, authChecked: true });
|
||||||
|
|
||||||
|
// After auth, determine if setup is needed or go to app
|
||||||
|
navigate({ to: setupComplete ? '/' : '/setup' });
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || 'Invalid API key');
|
setError(result.error || 'Invalid API key');
|
||||||
}
|
}
|
||||||
@@ -73,7 +80,7 @@ export function LoginView() {
|
|||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
<div className="flex items-center gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -40,9 +40,12 @@ let cachedServerUrl: string | null = null;
|
|||||||
* Must be called early in Electron mode before making API requests.
|
* Must be called early in Electron mode before making API requests.
|
||||||
*/
|
*/
|
||||||
export const initServerUrl = async (): Promise<void> => {
|
export const initServerUrl = async (): Promise<void> => {
|
||||||
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 {
|
try {
|
||||||
cachedServerUrl = await window.electronAPI.getServerUrl();
|
cachedServerUrl = await electron.getServerUrl();
|
||||||
console.log('[HTTP Client] Server URL from Electron:', cachedServerUrl);
|
console.log('[HTTP Client] Server URL from Electron:', cachedServerUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[HTTP Client] Failed to get server URL from Electron:', 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
|
* Check if we're running in Electron mode
|
||||||
*/
|
*/
|
||||||
export const isElectronMode = (): boolean => {
|
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<boolean> => {
|
|||||||
// Try to clear the cookie via logout (fire and forget)
|
// Try to clear the cookie via logout (fire and forget)
|
||||||
fetch(`${getServerUrl()}/api/auth/logout`, {
|
fetch(`${getServerUrl()}/api/auth/logout`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
body: '{}',
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -356,7 +367,8 @@ type EventType =
|
|||||||
| 'auto-mode:event'
|
| 'auto-mode:event'
|
||||||
| 'suggestions:event'
|
| 'suggestions:event'
|
||||||
| 'spec-regeneration:event'
|
| 'spec-regeneration:event'
|
||||||
| 'issue-validation:event';
|
| 'issue-validation:event'
|
||||||
|
| 'backlog-plan:event';
|
||||||
|
|
||||||
type EventCallback = (payload: unknown) => void;
|
type EventCallback = (payload: unknown) => void;
|
||||||
|
|
||||||
@@ -378,17 +390,20 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.serverUrl = getServerUrl();
|
this.serverUrl = getServerUrl();
|
||||||
// Wait for API key initialization before connecting WebSocket
|
// Electron mode: connect WebSocket immediately once API key is ready.
|
||||||
// This prevents 401 errors on startup in Electron mode
|
// Web mode: defer WebSocket connection until a consumer subscribes to events,
|
||||||
waitForApiKeyInit()
|
// to avoid noisy 401s on first-load/login/setup routes.
|
||||||
.then(() => {
|
if (isElectronMode()) {
|
||||||
this.connectWebSocket();
|
waitForApiKeyInit()
|
||||||
})
|
.then(() => {
|
||||||
.catch((error) => {
|
this.connectWebSocket();
|
||||||
console.error('[HttpApiClient] API key initialization failed:', error);
|
})
|
||||||
// Still attempt WebSocket connection - it may work with cookie auth
|
.catch((error) => {
|
||||||
this.connectWebSocket();
|
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;
|
this.isConnecting = true;
|
||||||
|
|
||||||
// In Electron mode, use API key directly
|
// Electron mode must authenticate with the injected API key.
|
||||||
const apiKey = getApiKey();
|
// If the key isn't ready yet, do NOT fall back to /api/auth/token (web-mode flow).
|
||||||
if (apiKey) {
|
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';
|
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
|
||||||
this.establishWebSocket(`${wsUrl}?apiKey=${encodeURIComponent(apiKey)}`);
|
this.establishWebSocket(`${wsUrl}?apiKey=${encodeURIComponent(apiKey)}`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
|
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 { Sidebar } from '@/components/layout/sidebar';
|
||||||
import {
|
import {
|
||||||
FileBrowserProvider,
|
FileBrowserProvider,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@/contexts/file-browser-context';
|
} from '@/contexts/file-browser-context';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-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 {
|
import {
|
||||||
@@ -15,16 +16,13 @@ import {
|
|||||||
isElectronMode,
|
isElectronMode,
|
||||||
verifySession,
|
verifySession,
|
||||||
checkSandboxEnvironment,
|
checkSandboxEnvironment,
|
||||||
|
getServerUrlSync,
|
||||||
} from '@/lib/http-api-client';
|
} from '@/lib/http-api-client';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import { ThemeOption, themeOptions } from '@/config/theme-options';
|
import { ThemeOption, themeOptions } from '@/config/theme-options';
|
||||||
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
|
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
|
||||||
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
|
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() {
|
function RootLayoutContent() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { setIpcConnected, currentProject, getEffectiveTheme } = useAppStore();
|
const { setIpcConnected, currentProject, getEffectiveTheme } = useAppStore();
|
||||||
@@ -35,23 +33,18 @@ function RootLayoutContent() {
|
|||||||
const [setupHydrated, setSetupHydrated] = useState(
|
const [setupHydrated, setSetupHydrated] = useState(
|
||||||
() => useSetupStore.persist?.hasHydrated?.() ?? false
|
() => useSetupStore.persist?.hasHydrated?.() ?? false
|
||||||
);
|
);
|
||||||
const [authChecked, setAuthChecked] = useState(false);
|
const authChecked = useAuthStore((s) => s.authChecked);
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
const { openFileBrowser } = useFileBrowser();
|
const { openFileBrowser } = useFileBrowser();
|
||||||
|
|
||||||
|
const isSetupRoute = location.pathname === '/setup';
|
||||||
|
const isLoginRoute = location.pathname === '/login';
|
||||||
|
|
||||||
// Sandbox environment check state
|
// Sandbox environment check state
|
||||||
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
|
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
|
||||||
const [sandboxStatus, setSandboxStatus] = useState<SandboxStatus>(() => {
|
// Always start from pending on a fresh page load so the user sees the prompt
|
||||||
// Check if user previously denied in this session
|
// each time the app is launched/refreshed (unless running in a container).
|
||||||
if (sessionStorage.getItem(SANDBOX_DENIED_KEY)) {
|
const [sandboxStatus, setSandboxStatus] = useState<SandboxStatus>('pending');
|
||||||
return 'denied';
|
|
||||||
}
|
|
||||||
// Check if user previously acknowledged in this session
|
|
||||||
if (sessionStorage.getItem(SANDBOX_RISK_ACKNOWLEDGED_KEY)) {
|
|
||||||
return 'confirmed';
|
|
||||||
}
|
|
||||||
return 'pending';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hidden streamer panel - opens with "\" key
|
// Hidden streamer panel - opens with "\" key
|
||||||
const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => {
|
const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => {
|
||||||
@@ -129,14 +122,11 @@ function RootLayoutContent() {
|
|||||||
|
|
||||||
// Handle sandbox risk confirmation
|
// Handle sandbox risk confirmation
|
||||||
const handleSandboxConfirm = useCallback(() => {
|
const handleSandboxConfirm = useCallback(() => {
|
||||||
sessionStorage.setItem(SANDBOX_RISK_ACKNOWLEDGED_KEY, 'true');
|
|
||||||
setSandboxStatus('confirmed');
|
setSandboxStatus('confirmed');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle sandbox risk denial
|
// Handle sandbox risk denial
|
||||||
const handleSandboxDeny = useCallback(async () => {
|
const handleSandboxDeny = useCallback(async () => {
|
||||||
sessionStorage.setItem(SANDBOX_DENIED_KEY, 'true');
|
|
||||||
|
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
// In Electron mode, quit the application
|
// In Electron mode, quit the application
|
||||||
// Use window.electronAPI directly since getElectronAPI() returns the HTTP client
|
// 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
|
// Initialize authentication
|
||||||
// - Electron mode: Uses API key from IPC (header-based auth)
|
// - Electron mode: Uses API key from IPC (header-based auth)
|
||||||
// - Web mode: Uses HTTP-only session cookie
|
// - Web mode: Uses HTTP-only session cookie
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Prevent concurrent auth checks
|
||||||
|
if (authCheckRunning.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const initAuth = async () => {
|
const initAuth = async () => {
|
||||||
|
authCheckRunning.current = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Initialize API key for Electron mode
|
// Initialize API key for Electron mode
|
||||||
await initApiKey();
|
await initApiKey();
|
||||||
|
|
||||||
// In Electron mode, we're always authenticated via header
|
// In Electron mode, we're always authenticated via header
|
||||||
if (isElectronMode()) {
|
if (isElectronMode()) {
|
||||||
setIsAuthenticated(true);
|
useAuthStore.getState().setAuthState({ isAuthenticated: true, authChecked: true });
|
||||||
setAuthChecked(true);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,31 +176,23 @@ function RootLayoutContent() {
|
|||||||
const isValid = await verifySession();
|
const isValid = await verifySession();
|
||||||
|
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
setIsAuthenticated(true);
|
useAuthStore.getState().setAuthState({ isAuthenticated: true, authChecked: true });
|
||||||
setAuthChecked(true);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session is invalid or expired - redirect to login
|
// Session is invalid or expired - treat as not authenticated
|
||||||
console.log('Session invalid or expired - redirecting to login');
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
setIsAuthenticated(false);
|
|
||||||
setAuthChecked(true);
|
|
||||||
|
|
||||||
if (location.pathname !== '/login') {
|
|
||||||
navigate({ to: '/login' });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize auth:', error);
|
console.error('Failed to initialize auth:', error);
|
||||||
setAuthChecked(true);
|
// On error, treat as not authenticated
|
||||||
// On error, redirect to login to be safe
|
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
|
||||||
if (location.pathname !== '/login') {
|
} finally {
|
||||||
navigate({ to: '/login' });
|
authCheckRunning.current = false;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
initAuth();
|
initAuth();
|
||||||
}, [location.pathname, navigate]);
|
}, []); // Runs once per load; auth state drives routing rules
|
||||||
|
|
||||||
// Wait for setup store hydration before enforcing routing rules
|
// Wait for setup store hydration before enforcing routing rules
|
||||||
useEffect(() => {
|
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(() => {
|
useEffect(() => {
|
||||||
if (!setupHydrated) return;
|
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') {
|
if (!setupComplete && location.pathname !== '/setup') {
|
||||||
navigate({ to: '/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: '/' });
|
navigate({ to: '/' });
|
||||||
}
|
}
|
||||||
}, [setupComplete, setupHydrated, location.pathname, navigate]);
|
}, [authChecked, isAuthenticated, setupComplete, setupHydrated, location.pathname, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGlobalFileBrowser(openFileBrowser);
|
setGlobalFileBrowser(openFileBrowser);
|
||||||
@@ -240,9 +249,19 @@ function RootLayoutContent() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const testConnection = async () => {
|
const testConnection = async () => {
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
if (isElectron()) {
|
||||||
const result = await api.ping();
|
const api = getElectronAPI();
|
||||||
setIpcConnected(result === 'pong');
|
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) {
|
} catch (error) {
|
||||||
console.error('IPC connection failed:', error);
|
console.error('IPC connection failed:', error);
|
||||||
setIpcConnected(false);
|
setIpcConnected(false);
|
||||||
@@ -280,10 +299,6 @@ function RootLayoutContent() {
|
|||||||
}
|
}
|
||||||
}, [deferredTheme]);
|
}, [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)
|
// Show rejection screen if user denied sandbox risk (web mode only)
|
||||||
if (sandboxStatus === 'denied' && !isElectron()) {
|
if (sandboxStatus === 'denied' && !isElectron()) {
|
||||||
return <SandboxRejectionScreen />;
|
return <SandboxRejectionScreen />;
|
||||||
@@ -323,10 +338,16 @@ function RootLayoutContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to login if not authenticated (web mode)
|
// Redirect to login if not authenticated (web mode)
|
||||||
|
// Show loading state while navigation to login is in progress
|
||||||
if (!isElectronMode() && !isAuthenticated) {
|
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) {
|
if (isSetupRoute) {
|
||||||
return (
|
return (
|
||||||
<main className="h-screen overflow-hidden" data-testid="app-container">
|
<main className="h-screen overflow-hidden" data-testid="app-container">
|
||||||
|
|||||||
29
apps/ui/src/store/auth-store.ts
Normal file
29
apps/ui/src/store/auth-store.ts
Normal file
@@ -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<AuthState>) => 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<AuthState & AuthActions>((set) => ({
|
||||||
|
...initialState,
|
||||||
|
setAuthState: (state) => set(state),
|
||||||
|
resetAuth: () => set(initialState),
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user