mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge remote-tracking branch 'origin/main' into feat/cursor-cli
This commit is contained in:
@@ -16,10 +16,12 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
interface SandboxRiskDialogProps {
|
||||
open: boolean;
|
||||
onConfirm: () => void;
|
||||
onConfirm: (skipInFuture: boolean) => void;
|
||||
onDeny: () => void;
|
||||
}
|
||||
|
||||
@@ -27,6 +29,13 @@ const DOCKER_COMMAND = 'npm run dev:docker';
|
||||
|
||||
export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialogProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [skipInFuture, setSkipInFuture] = useState(false);
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(skipInFuture);
|
||||
// Reset checkbox state after confirmation
|
||||
setSkipInFuture(false);
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
@@ -93,18 +102,34 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-2 pt-4">
|
||||
<Button variant="outline" onClick={onDeny} className="px-4" data-testid="sandbox-deny">
|
||||
Deny & Exit
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
className="px-4"
|
||||
data-testid="sandbox-confirm"
|
||||
>
|
||||
<ShieldAlert className="w-4 h-4 mr-2" />I Accept the Risks
|
||||
</Button>
|
||||
<DialogFooter className="flex-col gap-4 sm:flex-col pt-4">
|
||||
<div className="flex items-center space-x-2 self-start">
|
||||
<Checkbox
|
||||
id="skip-sandbox-warning"
|
||||
checked={skipInFuture}
|
||||
onCheckedChange={(checked) => setSkipInFuture(checked === true)}
|
||||
data-testid="sandbox-skip-checkbox"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="skip-sandbox-warning"
|
||||
className="text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
Do not show this warning again
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex gap-2 sm:gap-2 w-full sm:justify-end">
|
||||
<Button variant="outline" onClick={onDeny} className="px-4" data-testid="sandbox-deny">
|
||||
Deny & Exit
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirm}
|
||||
className="px-4"
|
||||
data-testid="sandbox-confirm"
|
||||
>
|
||||
<ShieldAlert className="w-4 h-4 mr-2" />I Accept the Risks
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -11,9 +11,13 @@ import { login } from '@/lib/http-api-client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { KeyRound, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { useAuthStore } from '@/store/auth-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
|
||||
export function LoginView() {
|
||||
const navigate = useNavigate();
|
||||
const setAuthState = useAuthStore((s) => s.setAuthState);
|
||||
const setupComplete = useSetupStore((s) => s.setupComplete);
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -26,8 +30,11 @@ export function LoginView() {
|
||||
try {
|
||||
const result = await login(apiKey.trim());
|
||||
if (result.success) {
|
||||
// Redirect to home/board on success
|
||||
navigate({ to: '/' });
|
||||
// Mark as authenticated for this session (cookie-based auth)
|
||||
setAuthState({ isAuthenticated: true, authChecked: true });
|
||||
|
||||
// After auth, determine if setup is needed or go to app
|
||||
navigate({ to: setupComplete ? '/' : '/setup' });
|
||||
} else {
|
||||
setError(result.error || 'Invalid API key');
|
||||
}
|
||||
@@ -73,7 +80,7 @@ export function LoginView() {
|
||||
|
||||
{error && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -50,6 +50,8 @@ export function SettingsView() {
|
||||
setAutoLoadClaudeMd,
|
||||
enableSandboxMode,
|
||||
setEnableSandboxMode,
|
||||
skipSandboxWarning,
|
||||
setSkipSandboxWarning,
|
||||
promptCustomization,
|
||||
setPromptCustomization,
|
||||
} = useAppStore();
|
||||
@@ -147,6 +149,8 @@ export function SettingsView() {
|
||||
<DangerZoneSection
|
||||
project={settingsProject}
|
||||
onDeleteClick={() => setShowDeleteDialog(true)}
|
||||
skipSandboxWarning={skipSandboxWarning}
|
||||
onResetSandboxWarning={() => setSkipSandboxWarning(false)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Trash2, Folder, AlertTriangle } from 'lucide-react';
|
||||
import { Trash2, Folder, AlertTriangle, Shield, RotateCcw } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Project } from '../shared/types';
|
||||
|
||||
interface DangerZoneSectionProps {
|
||||
project: Project | null;
|
||||
onDeleteClick: () => void;
|
||||
skipSandboxWarning: boolean;
|
||||
onResetSandboxWarning: () => void;
|
||||
}
|
||||
|
||||
export function DangerZoneSection({ project, onDeleteClick }: DangerZoneSectionProps) {
|
||||
if (!project) return null;
|
||||
|
||||
export function DangerZoneSection({
|
||||
project,
|
||||
onDeleteClick,
|
||||
skipSandboxWarning,
|
||||
onResetSandboxWarning,
|
||||
}: DangerZoneSectionProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -28,35 +33,75 @@ export function DangerZoneSection({ project, onDeleteClick }: DangerZoneSectionP
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Danger Zone</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Permanently remove this project from Automaker.
|
||||
Destructive actions and reset options.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
|
||||
<div className="flex items-center gap-3.5 min-w-0">
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/15 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shrink-0">
|
||||
<Folder className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">{project.name}</p>
|
||||
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">{project.path}</p>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Sandbox Warning Reset */}
|
||||
{skipSandboxWarning && (
|
||||
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
|
||||
<div className="flex items-center gap-3.5 min-w-0">
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-destructive/15 to-destructive/10 border border-destructive/20 flex items-center justify-center shrink-0">
|
||||
<Shield className="w-5 h-5 text-destructive" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground">Sandbox Warning Disabled</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-0.5">
|
||||
The sandbox environment warning is hidden on startup
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onResetSandboxWarning}
|
||||
data-testid="reset-sandbox-warning-button"
|
||||
className={cn(
|
||||
'shrink-0 gap-2',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:scale-[1.02] active:scale-[0.98]'
|
||||
)}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onDeleteClick}
|
||||
data-testid="delete-project-button"
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
'shadow-md shadow-destructive/20 hover:shadow-lg hover:shadow-destructive/25',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:scale-[1.02] active:scale-[0.98]'
|
||||
)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete Project
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project Delete */}
|
||||
{project && (
|
||||
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
|
||||
<div className="flex items-center gap-3.5 min-w-0">
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/15 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shrink-0">
|
||||
<Folder className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">{project.name}</p>
|
||||
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">{project.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onDeleteClick}
|
||||
data-testid="delete-project-button"
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
'shadow-md shadow-destructive/20 hover:shadow-lg hover:shadow-destructive/25',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:scale-[1.02] active:scale-[0.98]'
|
||||
)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete Project
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state when nothing to show */}
|
||||
{!skipSandboxWarning && !project && (
|
||||
<p className="text-sm text-muted-foreground/60 text-center py-4">
|
||||
No danger zone actions available.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -227,6 +227,7 @@ export async function syncSettingsToServer(): Promise<boolean> {
|
||||
phaseModels: state.phaseModels,
|
||||
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
||||
enableSandboxMode: state.enableSandboxMode,
|
||||
skipSandboxWarning: state.skipSandboxWarning,
|
||||
keyboardShortcuts: state.keyboardShortcuts,
|
||||
aiProfiles: state.aiProfiles,
|
||||
mcpServers: state.mcpServers,
|
||||
|
||||
@@ -40,9 +40,12 @@ let cachedServerUrl: string | null = null;
|
||||
* Must be called early in Electron mode before making API requests.
|
||||
*/
|
||||
export const initServerUrl = async (): Promise<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 {
|
||||
cachedServerUrl = await window.electronAPI.getServerUrl();
|
||||
cachedServerUrl = await electron.getServerUrl();
|
||||
console.log('[HTTP Client] Server URL from Electron:', cachedServerUrl);
|
||||
} catch (error) {
|
||||
console.warn('[HTTP Client] Failed to get server URL from Electron:', error);
|
||||
@@ -109,7 +112,13 @@ export const clearSessionToken = (): void => {
|
||||
* Check if we're running in Electron mode
|
||||
*/
|
||||
export const isElectronMode = (): boolean => {
|
||||
return typeof window !== 'undefined' && !!window.electronAPI?.getApiKey;
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
// Prefer a stable runtime marker from preload.
|
||||
// In some dev/electron setups, method availability can be temporarily undefined
|
||||
// during early startup, but `isElectron` remains reliable.
|
||||
const api = window.electronAPI as any;
|
||||
return api?.isElectron === true || !!api?.getApiKey;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -307,7 +316,9 @@ export const verifySession = async (): Promise<boolean> => {
|
||||
// Try to clear the cookie via logout (fire and forget)
|
||||
fetch(`${getServerUrl()}/api/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: '{}',
|
||||
}).catch(() => {});
|
||||
return false;
|
||||
}
|
||||
@@ -356,7 +367,8 @@ type EventType =
|
||||
| 'auto-mode:event'
|
||||
| 'suggestions:event'
|
||||
| 'spec-regeneration:event'
|
||||
| 'issue-validation:event';
|
||||
| 'issue-validation:event'
|
||||
| 'backlog-plan:event';
|
||||
|
||||
type EventCallback = (payload: unknown) => void;
|
||||
|
||||
@@ -378,17 +390,20 @@ export class HttpApiClient implements ElectronAPI {
|
||||
|
||||
constructor() {
|
||||
this.serverUrl = getServerUrl();
|
||||
// Wait for API key initialization before connecting WebSocket
|
||||
// This prevents 401 errors on startup in Electron mode
|
||||
waitForApiKeyInit()
|
||||
.then(() => {
|
||||
this.connectWebSocket();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[HttpApiClient] API key initialization failed:', error);
|
||||
// Still attempt WebSocket connection - it may work with cookie auth
|
||||
this.connectWebSocket();
|
||||
});
|
||||
// Electron mode: connect WebSocket immediately once API key is ready.
|
||||
// Web mode: defer WebSocket connection until a consumer subscribes to events,
|
||||
// to avoid noisy 401s on first-load/login/setup routes.
|
||||
if (isElectronMode()) {
|
||||
waitForApiKeyInit()
|
||||
.then(() => {
|
||||
this.connectWebSocket();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[HttpApiClient] API key initialization failed:', error);
|
||||
// Still attempt WebSocket connection - it may work with cookie auth
|
||||
this.connectWebSocket();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -436,9 +451,24 @@ export class HttpApiClient implements ElectronAPI {
|
||||
|
||||
this.isConnecting = true;
|
||||
|
||||
// In Electron mode, use API key directly
|
||||
const apiKey = getApiKey();
|
||||
if (apiKey) {
|
||||
// Electron mode must authenticate with the injected API key.
|
||||
// If the key isn't ready yet, do NOT fall back to /api/auth/token (web-mode flow).
|
||||
if (isElectronMode()) {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
console.warn(
|
||||
'[HttpApiClient] Electron mode: API key not ready, delaying WebSocket connect'
|
||||
);
|
||||
this.isConnecting = false;
|
||||
if (!this.reconnectTimer) {
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connectWebSocket();
|
||||
}, 250);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const wsUrl = this.serverUrl.replace(/^http/, 'ws') + '/api/events';
|
||||
this.establishWebSocket(`${wsUrl}?apiKey=${encodeURIComponent(apiKey)}`);
|
||||
return;
|
||||
|
||||
@@ -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,19 +16,22 @@ 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();
|
||||
const {
|
||||
setIpcConnected,
|
||||
currentProject,
|
||||
getEffectiveTheme,
|
||||
skipSandboxWarning,
|
||||
setSkipSandboxWarning,
|
||||
} = useAppStore();
|
||||
const { setupComplete } = useSetupStore();
|
||||
const navigate = useNavigate();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
@@ -35,23 +39,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) => {
|
||||
@@ -113,6 +112,9 @@ function RootLayoutContent() {
|
||||
if (result.isContainerized) {
|
||||
// Running in a container, no warning needed
|
||||
setSandboxStatus('containerized');
|
||||
} else if (skipSandboxWarning) {
|
||||
// User opted to skip the warning, auto-confirm
|
||||
setSandboxStatus('confirmed');
|
||||
} else {
|
||||
// Not containerized, show warning dialog
|
||||
setSandboxStatus('needs-confirmation');
|
||||
@@ -120,23 +122,30 @@ function RootLayoutContent() {
|
||||
} catch (error) {
|
||||
console.error('[Sandbox] Failed to check environment:', error);
|
||||
// On error, assume not containerized and show warning
|
||||
setSandboxStatus('needs-confirmation');
|
||||
if (skipSandboxWarning) {
|
||||
setSandboxStatus('confirmed');
|
||||
} else {
|
||||
setSandboxStatus('needs-confirmation');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkSandbox();
|
||||
}, [sandboxStatus]);
|
||||
}, [sandboxStatus, skipSandboxWarning]);
|
||||
|
||||
// Handle sandbox risk confirmation
|
||||
const handleSandboxConfirm = useCallback(() => {
|
||||
sessionStorage.setItem(SANDBOX_RISK_ACKNOWLEDGED_KEY, 'true');
|
||||
setSandboxStatus('confirmed');
|
||||
}, []);
|
||||
const handleSandboxConfirm = useCallback(
|
||||
(skipInFuture: boolean) => {
|
||||
if (skipInFuture) {
|
||||
setSkipSandboxWarning(true);
|
||||
}
|
||||
setSandboxStatus('confirmed');
|
||||
},
|
||||
[setSkipSandboxWarning]
|
||||
);
|
||||
|
||||
// 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 +165,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 +195,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 +231,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 +268,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 +318,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 +357,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">
|
||||
|
||||
@@ -499,6 +499,7 @@ export interface AppState {
|
||||
// Claude Agent SDK Settings
|
||||
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
|
||||
enableSandboxMode: boolean; // Enable sandbox mode for bash commands (may cause issues on some systems)
|
||||
skipSandboxWarning: boolean; // Skip the sandbox environment warning dialog on startup
|
||||
|
||||
// MCP Servers
|
||||
mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use
|
||||
@@ -798,6 +799,7 @@ export interface AppActions {
|
||||
// Claude Agent SDK Settings actions
|
||||
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
|
||||
setEnableSandboxMode: (enabled: boolean) => Promise<void>;
|
||||
setSkipSandboxWarning: (skip: boolean) => Promise<void>;
|
||||
setMcpAutoApproveTools: (enabled: boolean) => Promise<void>;
|
||||
setMcpUnrestrictedTools: (enabled: boolean) => Promise<void>;
|
||||
|
||||
@@ -1014,6 +1016,7 @@ const initialState: AppState = {
|
||||
cursorDefaultModel: 'auto', // Default to auto selection
|
||||
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
||||
enableSandboxMode: false, // Default to disabled (can be enabled for additional security)
|
||||
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
|
||||
mcpServers: [], // No MCP servers configured by default
|
||||
mcpAutoApproveTools: true, // Default to enabled - bypass permission prompts for MCP tools
|
||||
mcpUnrestrictedTools: true, // Default to enabled - don't filter allowedTools when MCP enabled
|
||||
@@ -1709,6 +1712,12 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||
await syncSettingsToServer();
|
||||
},
|
||||
setSkipSandboxWarning: async (skip) => {
|
||||
set({ skipSandboxWarning: skip });
|
||||
// Sync to server settings file
|
||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||
await syncSettingsToServer();
|
||||
},
|
||||
setMcpAutoApproveTools: async (enabled) => {
|
||||
set({ mcpAutoApproveTools: enabled });
|
||||
// Sync to server settings file
|
||||
@@ -3010,6 +3019,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
||||
cursorDefaultModel: state.cursorDefaultModel,
|
||||
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
||||
enableSandboxMode: state.enableSandboxMode,
|
||||
skipSandboxWarning: state.skipSandboxWarning,
|
||||
// MCP settings
|
||||
mcpServers: state.mcpServers,
|
||||
mcpAutoApproveTools: state.mcpAutoApproveTools,
|
||||
|
||||
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),
|
||||
}));
|
||||
22
init.mjs
22
init.mjs
@@ -268,6 +268,20 @@ function runNpm(args, options = {}) {
|
||||
return crossSpawn('npm', args, spawnOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an npm command and wait for completion
|
||||
*/
|
||||
function runNpmAndWait(args, options = {}) {
|
||||
const child = runNpm(args, options);
|
||||
return new Promise((resolve, reject) => {
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`npm ${args.join(' ')} failed with code ${code}`));
|
||||
});
|
||||
child.on('error', (err) => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run npx command using cross-spawn for Windows compatibility
|
||||
*/
|
||||
@@ -525,6 +539,10 @@ async function main() {
|
||||
console.log('');
|
||||
log('Launching Web Application...', 'blue');
|
||||
|
||||
// Build shared packages once (dev:server and dev:web both do this at the root level)
|
||||
log('Building shared packages...', 'blue');
|
||||
await runNpmAndWait(['run', 'build:packages'], { stdio: 'inherit' });
|
||||
|
||||
// Start the backend server
|
||||
log(`Starting backend server on port ${serverPort}...`, 'blue');
|
||||
|
||||
@@ -535,7 +553,7 @@ async function main() {
|
||||
|
||||
// Start server in background, showing output in console AND logging to file
|
||||
const logStream = fs.createWriteStream(path.join(__dirname, 'logs', 'server.log'));
|
||||
serverProcess = runNpm(['run', 'dev:server'], {
|
||||
serverProcess = runNpm(['run', '_dev:server'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
PORT: String(serverPort),
|
||||
@@ -582,7 +600,7 @@ async function main() {
|
||||
console.log('');
|
||||
|
||||
// Start web app
|
||||
webProcess = runNpm(['run', 'dev:web'], {
|
||||
webProcess = runNpm(['run', '_dev:web'], {
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
TEST_PORT: String(webPort),
|
||||
|
||||
@@ -437,6 +437,8 @@ export interface GlobalSettings {
|
||||
autoLoadClaudeMd?: boolean;
|
||||
/** Enable sandbox mode for bash commands (default: false, enable for additional security) */
|
||||
enableSandboxMode?: boolean;
|
||||
/** Skip showing the sandbox risk warning dialog */
|
||||
skipSandboxWarning?: boolean;
|
||||
|
||||
// MCP Server Configuration
|
||||
/** List of configured MCP servers for agent use */
|
||||
@@ -635,6 +637,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
||||
lastSelectedSessionByProject: {},
|
||||
autoLoadClaudeMd: false,
|
||||
enableSandboxMode: false,
|
||||
skipSandboxWarning: false,
|
||||
mcpServers: [],
|
||||
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||
// via the security warning dialog that explains the risks.
|
||||
|
||||
Reference in New Issue
Block a user