Files
ccs/ui/src/components/cliproxy/control-panel-embed.tsx
T
kaitranntt 7a6341f0d9 feat(ui): add Settings link to control panel key hint
Adds a gear icon link next to the masked key hint that navigates
to Settings > Auth, improving UX for users who need to manage
their auth tokens.
2025-12-26 12:23:24 -05:00

278 lines
9.8 KiB
TypeScript

/**
* CLIProxy Control Panel Embed
*
* Embeds the CLIProxy management.html with auto-authentication.
* Uses postMessage to inject credentials into the iframe.
* Supports both local and remote CLIProxy server connections.
*/
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { RefreshCw, AlertCircle, Key, X, Gauge, Globe, Settings } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { CliproxyServerConfig } from '@/lib/api-client';
/** CLIProxyAPI default port */
const CLIPROXY_DEFAULT_PORT = 8317;
interface AuthTokensResponse {
apiKey: { value: string; isCustom: boolean };
managementSecret: { value: string; isCustom: boolean };
}
interface ControlPanelEmbedProps {
port?: number;
}
export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanelEmbedProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [showLoginHint, setShowLoginHint] = useState(true);
// Fetch cliproxy_server config for remote/local mode detection
const { data: cliproxyConfig, error: configError } = useQuery<CliproxyServerConfig>({
queryKey: ['cliproxy-server-config'],
queryFn: () => api.cliproxyServer.get(),
staleTime: 30000, // 30 seconds
});
// Fetch auth tokens for local mode (gets effective management secret)
const { data: authTokens } = useQuery<AuthTokensResponse>({
queryKey: ['auth-tokens-raw'],
queryFn: async () => {
const response = await fetch('/api/settings/auth/tokens/raw');
if (!response.ok) throw new Error('Failed to fetch auth tokens');
return response.json();
},
staleTime: 30000, // 30 seconds
});
// Log config fetch errors (fallback to local mode on error)
useEffect(() => {
if (configError) {
console.warn('[ControlPanelEmbed] Config fetch failed, using local mode:', configError);
}
}, [configError]);
// Calculate URLs and settings based on remote or local mode
const { managementUrl, checkUrl, authToken, isRemote, displayHost } = useMemo(() => {
const remote = cliproxyConfig?.remote;
if (remote?.enabled && remote?.host) {
const protocol = remote.protocol || 'http';
// Use port from config, or default based on protocol (443 for https, 80 for http)
const remotePort = remote.port || (protocol === 'https' ? 443 : 80);
// Only include port in URL if it's non-standard
const portSuffix =
(protocol === 'https' && remotePort === 443) || (protocol === 'http' && remotePort === 80)
? ''
: `:${remotePort}`;
const baseUrl = `${protocol}://${remote.host}${portSuffix}`;
return {
managementUrl: `${baseUrl}/management.html`,
checkUrl: `${baseUrl}/`,
authToken: remote.auth_token || undefined,
isRemote: true,
displayHost: `${remote.host}${portSuffix}`,
};
}
// Local mode - use effective management secret from auth tokens API
const effectiveSecret = authTokens?.managementSecret?.value || 'ccs';
return {
managementUrl: `http://localhost:${port}/management.html`,
checkUrl: `http://localhost:${port}/`,
authToken: effectiveSecret,
isRemote: false,
displayHost: `localhost:${port}`,
};
}, [cliproxyConfig, authTokens, port]);
// Check if CLIProxy is running
useEffect(() => {
const controller = new AbortController();
const checkConnection = async () => {
try {
const response = await fetch(checkUrl, {
signal: controller.signal,
});
if (response.ok) {
setIsConnected(true);
setError(null);
} else {
setIsConnected(false);
setError(
isRemote
? `Remote CLIProxy at ${displayHost} returned an error`
: 'CLIProxy returned an error'
);
}
} catch (e) {
// Ignore abort errors (component unmounting)
if (e instanceof Error && e.name === 'AbortError') return;
setIsConnected(false);
setError(
isRemote
? `Remote CLIProxy at ${displayHost} is not reachable`
: 'CLIProxy is not running'
);
}
};
// Start connection check with timeout
const timeoutId = setTimeout(() => controller.abort(), 2000);
checkConnection().finally(() => clearTimeout(timeoutId));
// Cleanup: abort fetch on unmount
return () => controller.abort();
}, [checkUrl, isRemote, displayHost]);
// Handle iframe load - attempt to auto-login via postMessage
const handleIframeLoad = useCallback(() => {
setIsLoading(false);
// Try to inject credentials via postMessage
// The management.html needs to listen for this message
// If it doesn't support it, user will see the login page
if (iframeRef.current?.contentWindow && authToken) {
try {
// Derive apiBase from checkUrl (remove trailing slash)
const apiBase = checkUrl.replace(/\/$/, '');
// Security: Validate iframe src matches target origin before sending credentials
const iframeSrc = iframeRef.current.src;
if (!iframeSrc.startsWith(apiBase)) {
console.warn('[ControlPanelEmbed] Iframe origin mismatch, skipping postMessage');
return;
}
// Send credentials to iframe
iframeRef.current.contentWindow.postMessage(
{
type: 'ccs-auto-login',
apiBase,
managementKey: authToken,
},
apiBase
);
} catch (e) {
// Cross-origin restriction - expected if not same origin
console.debug('[ControlPanelEmbed] postMessage failed - cross-origin:', e);
}
}
}, [checkUrl, authToken]);
const handleRefresh = () => {
setIsLoading(true);
setError(null);
setIsConnected(false);
if (iframeRef.current) {
iframeRef.current.src = managementUrl;
}
};
// Show error state if CLIProxy is not running
if (!isConnected && error) {
return (
<div className="flex-1 flex flex-col">
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-2">
<Gauge className="w-5 h-5 text-primary" />
<h2 className="font-semibold">CLIProxy Control Panel</h2>
</div>
<button
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm border rounded-md hover:bg-muted"
onClick={handleRefresh}
>
<RefreshCw className="w-4 h-4" />
Retry
</button>
</div>
<div className="flex-1 flex items-center justify-center bg-muted/20">
<div className="text-center max-w-md px-8">
<div className="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center mx-auto mb-6">
<AlertCircle className="w-8 h-8 text-destructive" />
</div>
<h3 className="text-lg font-semibold mb-2">CLIProxy Not Available</h3>
<p className="text-muted-foreground mb-4">{error}</p>
<p className="text-sm text-muted-foreground">
Start a CLIProxy session with{' '}
<code className="bg-muted px-1 rounded">ccs gemini</code> or run{' '}
<code className="bg-muted px-1 rounded">ccs config</code> which auto-starts it.
</p>
</div>
</div>
</div>
);
}
return (
<div className="flex-1 flex flex-col relative">
{/* Remote indicator and login hint banner */}
{showLoginHint && !isLoading && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20">
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
{isRemote && (
<>
<Globe className="h-3.5 w-3.5 text-green-600" />
<span className="text-green-600 font-medium">Remote</span>
<span className="text-blue-300 dark:text-blue-700">|</span>
</>
)}
<Key className="h-3.5 w-3.5 text-blue-600" />
<span>
Key:{' '}
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded font-mono font-semibold">
{authToken && authToken.length > 4
? `***${authToken.slice(-4)}`
: authToken || 'ccs'}
</code>
</span>
<a
href="/settings?tab=auth"
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
title="Manage auth tokens"
>
<Settings className="h-3.5 w-3.5" />
</a>
<button
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
onClick={() => setShowLoginHint(false)}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
{/* Loading overlay */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
<div className="text-center">
<RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
{isRemote
? `Loading Control Panel from ${displayHost}...`
: 'Loading Control Panel...'}
</p>
</div>
</div>
)}
{/* Iframe */}
<iframe
ref={iframeRef}
src={managementUrl}
className="flex-1 w-full border-0"
title="CLIProxy Management Panel"
onLoad={handleIframeLoad}
/>
</div>
);
}