adding more security to api endpoints to require api token for all access, no by passing

This commit is contained in:
Test User
2025-12-29 16:16:28 -05:00
parent dd822c41c5
commit d68de99c15
26 changed files with 1347 additions and 184 deletions

View File

@@ -5,6 +5,7 @@ import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } f
import { cn } from '@/lib/utils';
import { getElectronAPI } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
// Error codes for distinguishing failure modes
const ERROR_CODES = {
@@ -25,10 +26,15 @@ const REFRESH_INTERVAL_SECONDS = 45;
export function ClaudeUsagePopover() {
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<UsageError | null>(null);
// Check if CLI is verified/authenticated
const isCliVerified =
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
// Check if data is stale (older than 2 minutes) - recalculates when claudeUsageLastUpdated changes
const isStale = useMemo(() => {
return !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
@@ -68,14 +74,17 @@ export function ClaudeUsagePopover() {
[setClaudeUsage]
);
// Auto-fetch on mount if data is stale
// Auto-fetch on mount if data is stale (only if CLI is verified)
useEffect(() => {
if (isStale) {
if (isStale && isCliVerified) {
fetchUsage(true);
}
}, [isStale, fetchUsage]);
}, [isStale, isCliVerified, fetchUsage]);
useEffect(() => {
// Skip if CLI is not verified
if (!isCliVerified) return;
// Initial fetch when opened
if (open) {
if (!claudeUsage || isStale) {
@@ -94,7 +103,7 @@ export function ClaudeUsagePopover() {
return () => {
if (intervalId) clearInterval(intervalId);
};
}, [open, claudeUsage, isStale, fetchUsage]);
}, [open, claudeUsage, isStale, isCliVerified, fetchUsage]);
// Derived status color/icon helper
const getStatusInfo = (percentage: number) => {

View File

@@ -14,6 +14,7 @@ import { Kbd, KbdGroup } from '@/components/ui/kbd';
import { getJSON, setJSON } from '@/lib/storage';
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
import { useOSDetection } from '@/hooks';
import { apiPost } from '@/lib/api-fetch';
interface DirectoryEntry {
name: string;
@@ -98,16 +99,7 @@ export function FileBrowserDialog({
setWarning('');
try {
// Get server URL from environment or default
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3008';
const response = await fetch(`${serverUrl}/api/fs/browse`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dirPath }),
});
const result: BrowseResult = await response.json();
const result = await apiPost<BrowseResult>('/api/fs/browse', { dirPath });
if (result.success) {
setCurrentPath(result.currentPath);

View File

@@ -7,6 +7,7 @@ import { Plus, Bot, Wand2 } from 'lucide-react';
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
import { ClaudeUsagePopover } from '@/components/claude-usage-popover';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
interface BoardHeaderProps {
projectName: string;
@@ -34,12 +35,18 @@ export function BoardHeader({
isMounted,
}: BoardHeaderProps) {
const apiKeys = useAppStore((state) => state.apiKeys);
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
// Hide usage tracking when using API key (only show for Claude Code CLI users)
// Check both user-entered API key and environment variable ANTHROPIC_API_KEY
// Also hide on Windows for now (CLI usage command not supported)
// Only show if CLI has been verified/authenticated
const isWindows =
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const showUsageTracking = !apiKeys.anthropic && !isWindows;
const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
const isCliVerified =
claudeAuthStatus?.authenticated && claudeAuthStatus?.method === 'cli_authenticated';
const showUsageTracking = !hasApiKey && !isWindows && isCliVerified;
return (
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">

View File

@@ -0,0 +1,104 @@
/**
* Login View - Web mode authentication
*
* Prompts user to enter the API key shown in server console.
* On successful login, sets an HTTP-only session cookie.
*/
import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
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';
export function LoginView() {
const navigate = useNavigate();
const [apiKey, setApiKey] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsLoading(true);
try {
const result = await login(apiKey.trim());
if (result.success) {
// Redirect to home/board on success
navigate({ to: '/' });
} else {
setError(result.error || 'Invalid API key');
}
} catch (err) {
setError('Failed to connect to server');
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md space-y-8">
{/* Header */}
<div className="text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<KeyRound className="h-8 w-8 text-primary" />
</div>
<h1 className="mt-6 text-2xl font-bold tracking-tight">Authentication Required</h1>
<p className="mt-2 text-sm text-muted-foreground">
Enter the API key shown in the server console to continue.
</p>
</div>
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label htmlFor="apiKey" className="text-sm font-medium">
API Key
</label>
<Input
id="apiKey"
type="password"
placeholder="Enter API key..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={isLoading}
autoFocus
className="font-mono"
/>
</div>
{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" />
<span>{error}</span>
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading || !apiKey.trim()}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Authenticating...
</>
) : (
'Login'
)}
</Button>
</form>
{/* Help Text */}
<div className="rounded-lg border bg-muted/50 p-4 text-sm">
<p className="font-medium">Where to find the API key:</p>
<ol className="mt-2 list-inside list-decimal space-y-1 text-muted-foreground">
<li>Look at the server terminal/console output</li>
<li>Find the box labeled "API Key for Web Mode Authentication"</li>
<li>Copy the UUID displayed there</li>
</ol>
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { useCliStatus, useSettingsView } from './settings-view/hooks';
import { NAV_ITEMS } from './settings-view/config/navigation';
@@ -55,11 +56,15 @@ export function SettingsView() {
setEnableSandboxMode,
} = useAppStore();
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
// Hide usage tracking when using API key (only show for Claude Code CLI users)
// Check both user-entered API key and environment variable ANTHROPIC_API_KEY
// Also hide on Windows for now (CLI usage command not supported)
const isWindows =
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win');
const showUsageTracking = !apiKeys.anthropic && !isWindows;
const hasApiKey = !!apiKeys.anthropic || !!claudeAuthStatus?.hasEnvApiKey;
const showUsageTracking = !hasApiKey && !isWindows;
// Convert electron Project to settings-view Project type
const convertProject = (project: ElectronProject | null): SettingsProject | null => {

View File

@@ -46,6 +46,8 @@ import {
defaultDropAnimationSideEffects,
} from '@dnd-kit/core';
import { cn } from '@/lib/utils';
import { apiFetch, apiGet, apiPost, apiDeleteRaw, getAuthHeaders } from '@/lib/api-fetch';
import { getApiKey } from '@/lib/http-api-client';
interface TerminalStatus {
enabled: boolean;
@@ -304,16 +306,13 @@ export function TerminalView() {
await Promise.allSettled(
sessionIds.map(async (sessionId) => {
try {
await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
method: 'DELETE',
headers,
});
await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
} catch (err) {
console.error(`[Terminal] Failed to kill session ${sessionId}:`, err);
}
})
);
}, [collectAllSessionIds, terminalState.authToken, serverUrl]);
}, [collectAllSessionIds, terminalState.authToken]);
const CREATE_COOLDOWN_MS = 500; // Prevent rapid terminal creation
// Helper to check if terminal creation should be debounced
@@ -434,9 +433,10 @@ export function TerminalView() {
try {
setLoading(true);
setError(null);
const response = await fetch(`${serverUrl}/api/terminal/status`);
const data = await response.json();
if (data.success) {
const data = await apiGet<{ success: boolean; data?: TerminalStatus; error?: string }>(
'/api/terminal/status'
);
if (data.success && data.data) {
setStatus(data.data);
if (!data.data.passwordRequired) {
setTerminalUnlocked(true);
@@ -450,7 +450,7 @@ export function TerminalView() {
} finally {
setLoading(false);
}
}, [serverUrl, setTerminalUnlocked]);
}, [setTerminalUnlocked]);
// Fetch server session settings
const fetchServerSettings = useCallback(async () => {
@@ -460,15 +460,17 @@ export function TerminalView() {
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
}
const response = await fetch(`${serverUrl}/api/terminal/settings`, { headers });
const data = await response.json();
if (data.success) {
const data = await apiGet<{
success: boolean;
data?: { currentSessions: number; maxSessions: number };
}>('/api/terminal/settings', { headers });
if (data.success && data.data) {
setServerSessionInfo({ current: data.data.currentSessions, max: data.data.maxSessions });
}
} catch (err) {
console.error('[Terminal] Failed to fetch server settings:', err);
}
}, [serverUrl, terminalState.isUnlocked, terminalState.authToken]);
}, [terminalState.isUnlocked, terminalState.authToken]);
useEffect(() => {
fetchStatus();
@@ -483,22 +485,20 @@ export function TerminalView() {
const sessionIds = collectAllSessionIds();
if (sessionIds.length === 0) return;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
}
// Try to use the bulk delete endpoint if available, otherwise delete individually
// Using sendBeacon for reliability during page unload
// Using sync XMLHttpRequest for reliability during page unload (async doesn't complete)
sessionIds.forEach((sessionId) => {
const url = `${serverUrl}/api/terminal/sessions/${sessionId}`;
// sendBeacon doesn't support DELETE method, so we'll use a sync XMLHttpRequest
// which is more reliable during page unload than fetch
try {
const xhr = new XMLHttpRequest();
xhr.open('DELETE', url, false); // synchronous
xhr.withCredentials = true; // Include cookies for session auth
// Add API auth header
const apiKey = getApiKey();
if (apiKey) {
xhr.setRequestHeader('X-API-Key', apiKey);
}
// Add terminal-specific auth
if (terminalState.authToken) {
xhr.setRequestHeader('X-Terminal-Token', terminalState.authToken);
}
@@ -593,9 +593,7 @@ export function TerminalView() {
let reconnectedSessions = 0;
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const headers: Record<string, string> = {};
// Get fresh auth token from store
const authToken = useAppStore.getState().terminalState.authToken;
if (authToken) {
@@ -605,11 +603,9 @@ export function TerminalView() {
// Helper to check if a session still exists on server
const checkSessionExists = async (sessionId: string): Promise<boolean> => {
try {
const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
method: 'GET',
const data = await apiGet<{ success: boolean }>(`/api/terminal/sessions/${sessionId}`, {
headers,
});
const data = await response.json();
return data.success === true;
} catch {
return false;
@@ -619,17 +615,12 @@ export function TerminalView() {
// Helper to create a new terminal session
const createSession = async (): Promise<string | null> => {
try {
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
method: 'POST',
headers,
body: JSON.stringify({
cwd: currentPath,
cols: 80,
rows: 24,
}),
});
const data = await response.json();
return data.success ? data.data.id : null;
const data = await apiPost<{ success: boolean; data?: { id: string } }>(
'/api/terminal/sessions',
{ cwd: currentPath, cols: 80, rows: 24 },
{ headers }
);
return data.success && data.data ? data.data.id : null;
} catch (err) {
console.error('[Terminal] Failed to create terminal session:', err);
return null;
@@ -801,14 +792,12 @@ export function TerminalView() {
setAuthError(null);
try {
const response = await fetch(`${serverUrl}/api/terminal/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const data = await response.json();
const data = await apiPost<{ success: boolean; data?: { token: string }; error?: string }>(
'/api/terminal/auth',
{ password }
);
if (data.success) {
if (data.success && data.data) {
setTerminalUnlocked(true, data.data.token);
setPassword('');
} else {
@@ -833,21 +822,14 @@ export function TerminalView() {
}
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const headers: Record<string, string> = {};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
}
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
method: 'POST',
const response = await apiFetch('/api/terminal/sessions', 'POST', {
headers,
body: JSON.stringify({
cwd: currentProject?.path || undefined,
cols: 80,
rows: 24,
}),
body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
});
const data = await response.json();
@@ -892,21 +874,14 @@ export function TerminalView() {
const tabId = addTerminalTab();
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const headers: Record<string, string> = {};
if (terminalState.authToken) {
headers['X-Terminal-Token'] = terminalState.authToken;
}
const response = await fetch(`${serverUrl}/api/terminal/sessions`, {
method: 'POST',
const response = await apiFetch('/api/terminal/sessions', 'POST', {
headers,
body: JSON.stringify({
cwd: currentProject?.path || undefined,
cols: 80,
rows: 24,
}),
body: { cwd: currentProject?.path || undefined, cols: 80, rows: 24 },
});
const data = await response.json();
@@ -959,10 +934,7 @@ export function TerminalView() {
headers['X-Terminal-Token'] = terminalState.authToken;
}
const response = await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
method: 'DELETE',
headers,
});
const response = await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
// Always remove from UI - even if server says 404 (session may have already exited)
removeTerminalFromLayout(sessionId);
@@ -1008,10 +980,7 @@ export function TerminalView() {
await Promise.all(
sessionIds.map(async (sessionId) => {
try {
await fetch(`${serverUrl}/api/terminal/sessions/${sessionId}`, {
method: 'DELETE',
headers,
});
await apiDeleteRaw(`/api/terminal/sessions/${sessionId}`, { headers });
} catch (err) {
console.error(`[Terminal] Failed to kill session ${sessionId}:`, err);
}

View File

@@ -40,6 +40,7 @@ import {
} from '@/config/terminal-themes';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
import { getApiKey } from '@/lib/http-api-client';
// Font size constraints
const MIN_FONT_SIZE = 8;
@@ -940,8 +941,17 @@ export function TerminalPanel({
if (!terminal) return;
const connect = () => {
// Build WebSocket URL with token
// Build WebSocket URL with auth params
let url = `${wsUrl}/api/terminal/ws?sessionId=${sessionId}`;
// Add API key for Electron mode auth
const apiKey = getApiKey();
if (apiKey) {
url += `&apiKey=${encodeURIComponent(apiKey)}`;
}
// In web mode, cookies are sent automatically with same-origin WebSocket
// Add terminal password token if required
if (authToken) {
url += `&token=${encodeURIComponent(authToken)}`;
}