mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
fix(dashboard): bootstrap cliproxy control panel session
This commit is contained in:
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 |
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user