Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 38 additions & 13 deletions apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,26 @@ 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;
}

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 {
Expand Down Expand Up @@ -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 &amp; 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 &amp; 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>
Expand Down
13 changes: 10 additions & 3 deletions apps/ui/src/components/views/login-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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' });
Comment on lines 30 to +37
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if useSetupStore has persist middleware and how it's initialized
ast-grep --pattern 'useSetupStore = create($$$)'
echo "---"
rg -n 'persist' apps/ui/src/store/setup-store.ts

Repository: AutoMaker-Org/automaker

Length of output: 133


🏁 Script executed:

#!/bin/bash
# Read setup-store.ts to understand persist configuration
cat -n apps/ui/src/store/setup-store.ts | head -150

echo "---"
# Check login-view.tsx for context around the login logic
cat -n apps/ui/src/components/views/login-view.tsx | sed -n '1,50p'

Repository: AutoMaker-Org/automaker

Length of output: 6985


🏁 Script executed:

#!/bin/bash
# Read more of setup-store.ts to see persist configuration
cat -n apps/ui/src/store/setup-store.ts | sed -n '125,170p'

echo "---"
# Check __root.tsx for hydration handling
fd --type f -name "__root.tsx" apps/ui/src/ -x cat -n {}

Repository: AutoMaker-Org/automaker

Length of output: 1875


🏁 Script executed:

#!/bin/bash
# Find and read __root.tsx
fd --type f -name "__root.tsx" apps/ui/src/ | head -5

echo "---"
# Also try root.tsx or Root.tsx
fd --type f -name "*root.tsx" apps/ui/src/ | head -10

Repository: AutoMaker-Org/automaker

Length of output: 549


🏁 Script executed:

#!/bin/bash
# Find root files correctly
fd --type f "root\.tsx" apps/ui/src/

echo "---"
# Check router or layout structure
fd --type f -name "*.tsx" apps/ui/src/ | grep -E "(root|main|app|layout)" | head -10

echo "---"
# Check full persist config in setup-store.ts
cat apps/ui/src/store/setup-store.ts | tail -50

Repository: AutoMaker-Org/automaker

Length of output: 1768


🏁 Script executed:

#!/bin/bash
# Read __root.tsx to check for hydration handling
wc -l apps/ui/src/routes/__root.tsx

echo "---"
cat apps/ui/src/routes/__root.tsx

Repository: AutoMaker-Org/automaker

Length of output: 13665


Wait for setup store hydration before reading setupComplete in post-login redirect.

The setupComplete value from useSetupStore is read without checking hydration status. If the user logs in before the persisted store hydrates from storage, setupComplete defaults to false, causing an incorrect redirect to /setup even when setup was previously completed. While __root.tsx will eventually correct this redirect after hydration completes, it creates an unnecessary navigation flicker.

Consider checking useSetupStore.persist?.hasHydrated?.() before or alongside reading setupComplete, or deferring the redirect decision to __root.tsx which already has proper hydration guards in place.

🤖 Prompt for AI Agents
In apps/ui/src/components/views/login-view.tsx around lines 30 to 37, the
post-login redirect reads useSetupStore().setupComplete before the persisted
setup store is hydrated which can cause a false negative redirect to /setup;
update the logic to either (a) check the store hydration status via
useSetupStore.persist?.hasHydrated?.() and only decide/navigate based on
setupComplete after hydration, or (b) skip immediate redirect here and let
__root.tsx (which already guards on hydration) perform the final routing
decision after hydration completes; implement the chosen approach so the
redirect does not happen prematurely.

} else {
setError(result.error || 'Invalid API key');
}
Expand Down Expand Up @@ -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>
)}
Expand Down
4 changes: 4 additions & 0 deletions apps/ui/src/components/views/settings-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export function SettingsView() {
setAutoLoadClaudeMd,
enableSandboxMode,
setEnableSandboxMode,
skipSandboxWarning,
setSkipSandboxWarning,
promptCustomization,
setPromptCustomization,
} = useAppStore();
Expand Down Expand Up @@ -184,6 +186,8 @@ export function SettingsView() {
<DangerZoneSection
project={settingsProject}
onDeleteClick={() => setShowDeleteDialog(true)}
skipSandboxWarning={skipSandboxWarning}
onResetSandboxWarning={() => setSkipSandboxWarning(false)}
/>
);
default:
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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 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>
<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>
<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>
)}

{/* 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>
<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>
);
Expand Down
1 change: 1 addition & 0 deletions apps/ui/src/hooks/use-settings-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export async function syncSettingsToServer(): Promise<boolean> {
validationModel: state.validationModel,
autoLoadClaudeMd: state.autoLoadClaudeMd,
enableSandboxMode: state.enableSandboxMode,
skipSandboxWarning: state.skipSandboxWarning,
keyboardShortcuts: state.keyboardShortcuts,
aiProfiles: state.aiProfiles,
mcpServers: state.mcpServers,
Expand Down
66 changes: 48 additions & 18 deletions apps/ui/src/lib/http-api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;

Expand All @@ -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();
});
}
}

/**
Expand Down Expand Up @@ -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;
Expand Down
Loading