fix(dashboard): bootstrap cliproxy control panel session

This commit is contained in:
Tam Nhu Tran
2026-04-03 02:07:47 -04:00
parent 258b1b4561
commit 5432163c8f
7 changed files with 545 additions and 78 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

+198
View File
@@ -0,0 +1,198 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Issue 900 UI Evidence</title>
<style>
:root {
color-scheme: light;
--bg: #f5efe7;
--panel: #fffaf5;
--ink: #231f1c;
--muted: #655c55;
--line: #ddcfc0;
--before: #ff3b30;
--after: #00b894;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 32px;
font-family:
"IBM Plex Sans",
"Avenir Next",
system-ui,
sans-serif;
background: linear-gradient(180deg, #f7f0e7 0%, #efe7dc 100%);
color: var(--ink);
}
main {
max-width: 1300px;
margin: 0 auto;
display: grid;
gap: 24px;
}
.hero,
.section {
background: rgba(255, 250, 245, 0.92);
border: 1px solid var(--line);
border-radius: 24px;
padding: 24px;
box-shadow: 0 18px 40px rgba(35, 31, 28, 0.08);
}
h1,
h2,
p {
margin: 0;
}
.hero {
display: grid;
gap: 12px;
}
.eyebrow {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
}
.hero p,
.section p {
color: var(--muted);
line-height: 1.5;
}
.comparison {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
margin-top: 20px;
}
.card {
border: 1px solid var(--line);
border-radius: 18px;
overflow: hidden;
background: #fff;
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
font-weight: 700;
}
.swatch {
width: 12px;
height: 12px;
border-radius: 999px;
flex: 0 0 auto;
}
.swatch.before {
background: var(--before);
}
.swatch.after {
background: var(--after);
}
img {
display: block;
width: 100%;
height: auto;
}
.notes {
display: grid;
gap: 8px;
margin-top: 16px;
font-size: 15px;
}
code {
font-family:
"IBM Plex Mono",
ui-monospace,
monospace;
font-size: 13px;
background: rgba(35, 31, 28, 0.06);
padding: 2px 6px;
border-radius: 8px;
}
@media (max-width: 900px) {
body {
padding: 20px;
}
.comparison {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<main>
<section class="hero">
<p class="eyebrow">Issue 900</p>
<h1>CLIProxy Control Panel Embed Fix</h1>
<p>
Before the fix, the embedded upstream management center landed on
<code>#/login</code> and asked for credentials again. After the fix, the same surface
boots straight into the authenticated dashboard and starts reading
<code>/api/cliproxy-local/v0/management/*</code>.
</p>
</section>
<section class="section">
<h2>Embedded Control Panel</h2>
<p>
Captures use the same route and light theme. The colored callout box marks the iframe
review area.
</p>
<div class="comparison">
<article class="card">
<div class="card-header">
<span class="swatch before"></span>
Before: iframe falls back to login
</div>
<img src="./control-panel-before-annotated.png" alt="Before fix screenshot" />
</article>
<article class="card">
<div class="card-header">
<span class="swatch after"></span>
After: iframe opens management dashboard
</div>
<img src="./control-panel-after-annotated.png" alt="After fix screenshot" />
</article>
</div>
<div class="notes">
<p>Broken state: iframe URL ended at <code>/management.html#/login</code>.</p>
<p>Fixed state: iframe URL ends at <code>/management.html#/</code>.</p>
<p>
Fixed build verified live against the patched worktree dashboard on
<code>localhost:3001</code>.
</p>
</div>
</section>
</main>
</body>
</html>
@@ -1,12 +1,12 @@
/**
* 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.
* Embeds the CLIProxy management.html with dashboard-aware authentication.
* Local embeds run through the dashboard reverse proxy and need the upstream
* management app's auth state pre-seeded before the iframe loads.
*/
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useState, useEffect, useMemo, useRef } from 'react';
import { RefreshCw, AlertCircle, Gauge } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { api, withApiBase } from '@/lib/api-client';
@@ -22,9 +22,41 @@ interface ControlPanelEmbedProps {
port?: number;
}
const CONTROL_PANEL_AUTH_STORAGE_KEY = 'cli-proxy-auth';
const CONTROL_PANEL_LOGIN_FLAG_KEY = 'isLoggedIn';
const CONTROL_PANEL_API_BASE_KEY = 'apiBase';
const CONTROL_PANEL_API_URL_KEY = 'apiUrl';
const CONTROL_PANEL_MANAGEMENT_KEY = 'managementKey';
function resolveEmbeddedApiBase(checkUrl: string): string {
if (checkUrl.startsWith('/')) {
return new URL(checkUrl.replace(/\/$/, ''), window.location.origin).href;
}
return checkUrl.replace(/\/$/, '');
}
function seedLocalControlPanelSession(apiBase: string, managementKey: string): void {
// The upstream management-center app restores auth from these legacy keys.
// Clear the persisted auth snapshot first so stale iframe state cannot win.
window.localStorage.removeItem(CONTROL_PANEL_AUTH_STORAGE_KEY);
window.localStorage.setItem(CONTROL_PANEL_API_BASE_KEY, apiBase);
window.localStorage.setItem(CONTROL_PANEL_API_URL_KEY, apiBase);
window.localStorage.setItem(CONTROL_PANEL_MANAGEMENT_KEY, managementKey);
window.localStorage.setItem(CONTROL_PANEL_LOGIN_FLAG_KEY, 'true');
}
function clearLocalControlPanelSession(): void {
window.localStorage.removeItem(CONTROL_PANEL_AUTH_STORAGE_KEY);
window.localStorage.removeItem(CONTROL_PANEL_API_BASE_KEY);
window.localStorage.removeItem(CONTROL_PANEL_API_URL_KEY);
window.localStorage.removeItem(CONTROL_PANEL_MANAGEMENT_KEY);
window.localStorage.removeItem(CONTROL_PANEL_LOGIN_FLAG_KEY);
}
export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanelEmbedProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [loadedUrl, setLoadedUrl] = useState<string | null>(null);
const [loadedFrameKey, setLoadedFrameKey] = useState<string | null>(null);
const [iframeRevision, setIframeRevision] = useState(0);
const [error, setError] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState(false);
@@ -36,17 +68,6 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
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(withApiBase('/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) {
@@ -54,6 +75,35 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
}
}, [configError]);
const isRemoteConfig = Boolean(cliproxyConfig?.remote?.enabled && cliproxyConfig?.remote?.host);
// Fetch auth tokens for local mode and seed the upstream auth keys before the iframe mounts.
const { data: authTokens, error: authTokensError } = useQuery<AuthTokensResponse>({
queryKey: ['auth-tokens-raw', isRemoteConfig ? 'remote' : 'local'],
enabled: cliproxyConfig !== undefined && !isRemoteConfig,
queryFn: async () => {
try {
const response = await fetch(withApiBase('/settings/auth/tokens/raw'));
if (!response.ok) throw new Error('Failed to fetch auth tokens');
const tokens = (await response.json()) as AuthTokensResponse;
const managementSecret = tokens.managementSecret.value.trim();
if (!managementSecret) throw new Error('Management secret missing');
seedLocalControlPanelSession(
resolveEmbeddedApiBase(withApiBase('/cliproxy-local/')),
managementSecret
);
return tokens;
} catch (error) {
clearLocalControlPanelSession();
throw error;
}
},
staleTime: 30000, // 30 seconds
});
// Calculate URLs and settings based on remote or local mode
const { managementUrl, checkUrl, authToken, isRemote, displayHost } = useMemo(() => {
const remote = cliproxyConfig?.remote;
@@ -81,18 +131,58 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
// Local mode - proxy through dashboard server to avoid cross-origin/port issues
// (e.g., in Docker the browser cannot reach the internal CLIProxy port directly)
const effectiveSecret = authTokens?.managementSecret?.value || 'ccs';
return {
managementUrl: withApiBase('/cliproxy-local/management.html'),
checkUrl: withApiBase('/cliproxy-local/'),
authToken: effectiveSecret,
authToken: authTokens?.managementSecret?.value || undefined,
isRemote: false,
displayHost: `localhost:${localPort}`,
};
}, [cliproxyConfig, authTokens, port]);
const iframeLoaded = loadedUrl === managementUrl;
const isLoading = !iframeLoaded;
const iframeKey = `${managementUrl}:${isRemote ? 'remote' : 'local'}:${checkUrl}:${authToken ?? 'missing'}:${iframeRevision}`;
const isSessionReady =
cliproxyConfig !== undefined && (isRemote || Boolean(authTokens) || Boolean(authTokensError));
useEffect(() => {
if (authTokensError) {
console.warn(
'[ControlPanelEmbed] Failed to preload local control panel session, falling back to manual login:',
authTokensError
);
}
}, [authTokensError]);
const iframeLoaded = loadedFrameKey === iframeKey;
const isLoading = !isSessionReady || !iframeLoaded;
const postRemoteAutoLoginCredentials = () => {
if (!isRemote || !iframeRef.current?.contentWindow || !authToken) {
return;
}
try {
const apiBase = resolveEmbeddedApiBase(checkUrl);
const targetOrigin = new URL(`${apiBase}/`).origin;
const iframeUrl = new URL(iframeRef.current.src, window.location.origin);
if (iframeUrl.origin !== targetOrigin) {
console.warn('[ControlPanelEmbed] Remote iframe origin mismatch, skipping postMessage');
return;
}
iframeRef.current.contentWindow.postMessage(
{
type: 'ccs-auto-login',
apiBase,
managementKey: authToken,
},
targetOrigin
);
} catch (error) {
console.debug('[ControlPanelEmbed] Remote postMessage bootstrap failed:', error);
}
};
// Check if CLIProxy is running
useEffect(() => {
@@ -159,58 +249,13 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
};
}, [checkUrl, isRemote, displayHost, cliproxyConfig]);
const postAutoLoginCredentials = useCallback(() => {
// Auto-login can only run when iframe has loaded and authToken is available.
if (!iframeLoaded || !iframeRef.current?.contentWindow || !authToken) {
return;
}
try {
// Derive apiBase and targetOrigin from checkUrl.
// Local mode uses the same-origin dashboard proxy; remote mode stays absolute.
const apiBase = checkUrl.startsWith('/')
? new URL(checkUrl.replace(/\/$/, ''), window.location.origin).href
: checkUrl.replace(/\/$/, '');
const apiBaseUrl = new URL(`${apiBase}/`);
const targetOrigin = apiBaseUrl.origin;
// Security: Validate iframe src matches the expected origin/path before sending credentials.
const iframeUrl = new URL(iframeRef.current.src, window.location.origin);
if (
iframeUrl.origin !== apiBaseUrl.origin ||
!iframeUrl.pathname.startsWith(apiBaseUrl.pathname)
) {
console.warn('[ControlPanelEmbed] Iframe origin mismatch, skipping postMessage');
return;
}
// Send credentials to iframe
iframeRef.current.contentWindow.postMessage(
{
type: 'ccs-auto-login',
apiBase,
managementKey: authToken,
},
targetOrigin
);
} catch (e) {
// Cross-origin restriction - expected if not same origin
console.debug('[ControlPanelEmbed] postMessage failed - cross-origin:', e);
}
}, [authToken, checkUrl, iframeLoaded]);
// Retry auto-login when token/checkUrl arrive after iframe onLoad.
useEffect(() => {
postAutoLoginCredentials();
}, [postAutoLoginCredentials]);
// Handle iframe load - mark ready then let effect post credentials.
const handleIframeLoad = useCallback(() => {
setLoadedUrl(managementUrl);
}, [managementUrl]);
const handleIframeLoad = () => {
setLoadedFrameKey(iframeKey);
postRemoteAutoLoginCredentials();
};
const handleRefresh = () => {
setLoadedUrl(null);
setLoadedFrameKey(null);
setIframeRevision((value) => value + 1);
setError(null);
setIsConnected(false);
@@ -269,14 +314,16 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
)}
{/* Iframe */}
<iframe
key={`${managementUrl}:${iframeRevision}`}
ref={iframeRef}
src={managementUrl}
className="flex-1 w-full border-0"
title="CLIProxy Management Panel"
onLoad={handleIframeLoad}
/>
{isSessionReady ? (
<iframe
key={iframeKey}
ref={iframeRef}
src={managementUrl}
className="flex-1 w-full border-0"
title="CLIProxy Management Panel"
onLoad={handleIframeLoad}
/>
) : null}
</div>
</div>
);
@@ -0,0 +1,222 @@
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '../../../setup/test-utils';
import { ControlPanelEmbed } from '@/components/cliproxy/control-panel-embed';
function createJsonResponse(body: Record<string, unknown>, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function getRequestUrl(input: RequestInfo | URL): string {
if (input instanceof Request) {
return input.url;
}
return String(input);
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('ControlPanelEmbed', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('waits for the real local management secret before rendering the iframe', async () => {
const tokenRequest = createDeferred<Response>();
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = getRequestUrl(input);
if (url.endsWith('/api/cliproxy-server')) {
return Promise.resolve(
createJsonResponse({
remote: {
enabled: false,
host: '',
protocol: 'http',
auth_token: '',
},
fallback: {
enabled: true,
auto_start: false,
},
local: {
port: 8317,
auto_start: true,
},
})
);
}
if (url.endsWith('/api/settings/auth/tokens/raw')) {
return tokenRequest.promise;
}
if (url.endsWith('/api/cliproxy-local/')) {
return Promise.resolve(new Response('ok', { status: 200 }));
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
render(<ControlPanelEmbed />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith('/api/settings/auth/tokens/raw');
});
expect(screen.queryByTitle('CLIProxy Management Panel')).toBeNull();
tokenRequest.resolve(
createJsonResponse({
apiKey: { value: 'ccs-internal-managed', isCustom: false },
managementSecret: { value: 'custom-secret', isCustom: true },
})
);
const iframe = await screen.findByTitle('CLIProxy Management Panel');
expect(iframe).toHaveAttribute('src', '/api/cliproxy-local/management.html');
await waitFor(() => {
expect(window.localStorage.removeItem).toHaveBeenCalledWith('cli-proxy-auth');
expect(window.localStorage.setItem).toHaveBeenCalledWith(
'apiBase',
'http://localhost:3000/api/cliproxy-local'
);
expect(window.localStorage.setItem).toHaveBeenCalledWith(
'apiUrl',
'http://localhost:3000/api/cliproxy-local'
);
expect(window.localStorage.setItem).toHaveBeenCalledWith('managementKey', 'custom-secret');
expect(window.localStorage.setItem).toHaveBeenCalledWith('isLoggedIn', 'true');
});
});
it('clears stale local control-panel session keys when token bootstrap fails', async () => {
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = getRequestUrl(input);
if (url.endsWith('/api/cliproxy-server')) {
return Promise.resolve(
createJsonResponse({
remote: {
enabled: false,
host: '',
protocol: 'http',
auth_token: '',
},
fallback: {
enabled: true,
auto_start: false,
},
local: {
port: 8317,
auto_start: true,
},
})
);
}
if (url.endsWith('/api/settings/auth/tokens/raw')) {
return Promise.reject(new Error('token bootstrap failed'));
}
if (url.endsWith('/api/cliproxy-local/')) {
return Promise.resolve(new Response('ok', { status: 200 }));
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
render(<ControlPanelEmbed />);
await screen.findByTitle('CLIProxy Management Panel');
await waitFor(() => {
expect(window.localStorage.removeItem).toHaveBeenCalledWith('cli-proxy-auth');
expect(window.localStorage.removeItem).toHaveBeenCalledWith('apiBase');
expect(window.localStorage.removeItem).toHaveBeenCalledWith('apiUrl');
expect(window.localStorage.removeItem).toHaveBeenCalledWith('managementKey');
expect(window.localStorage.removeItem).toHaveBeenCalledWith('isLoggedIn');
});
});
it('preserves remote postMessage bootstrap for remote iframes', async () => {
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = getRequestUrl(input);
if (url.endsWith('/api/cliproxy-server')) {
return Promise.resolve(
createJsonResponse({
remote: {
enabled: true,
host: 'remote.example.com',
protocol: 'https',
auth_token: 'remote-secret',
port: 443,
},
fallback: {
enabled: true,
auto_start: false,
},
local: {
port: 8317,
auto_start: true,
},
})
);
}
if (url.endsWith('/api/cliproxy-server/test')) {
return Promise.resolve(createJsonResponse({ reachable: true }));
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
render(<ControlPanelEmbed />);
const iframe = await screen.findByTitle('CLIProxy Management Panel');
expect(iframe).toHaveAttribute('src', 'https://remote.example.com/management.html');
const postMessage = vi.fn();
Object.defineProperty(iframe, 'contentWindow', {
configurable: true,
value: { postMessage },
});
fireEvent.load(iframe);
expect(postMessage).toHaveBeenCalledWith(
{
type: 'ccs-auto-login',
apiBase: 'https://remote.example.com',
managementKey: 'remote-secret',
},
'https://remote.example.com'
);
expect(fetchMock).not.toHaveBeenCalledWith('/api/settings/auth/tokens/raw');
});
});