mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 12:14:19 +00:00
- Remove destructive /start endpoint call from Dashboard OAuth dialog (was killing running CLIProxy Docker instances via killProcessOnPort) - Use /start-url + polling only (management API, non-destructive) - Auto-open browser tab via window.open() with manual fallback URL display - Add paste-callback CLI mode (--paste-callback flag) for headless/SSH - Use dynamic proxy target with management headers instead of hardcoded localhost - Extract timeout constants, restore invariant comment - Move hook-utils tests from src/__tests__/ to tests/unit/ (fixes tsc) - Add try-catch for preset apply, remove auth URL console.log
This commit is contained in:
1 parent
deb62490db
commit
24b03121fd
12 files changed
+871
-181
No files matched your search
@@ -162,6 +162,37 @@ export const PROVIDER_TYPE_VALUES: Record<CLIProxyProvider, string[]> = {
|
|||||||
claude: ['claude', 'anthropic'],
|
claude: ['claude', 'anthropic'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps CCS provider names to CLIProxyAPI callback provider names
|
||||||
|
* Used when submitting OAuth callbacks to CLIProxyAPI management endpoint
|
||||||
|
*/
|
||||||
|
export const CLIPROXY_CALLBACK_PROVIDER_MAP: Record<CLIProxyProvider, string> = {
|
||||||
|
gemini: 'gemini',
|
||||||
|
codex: 'codex',
|
||||||
|
agy: 'antigravity',
|
||||||
|
kiro: 'kiro',
|
||||||
|
ghcp: 'copilot',
|
||||||
|
claude: 'anthropic',
|
||||||
|
qwen: 'qwen',
|
||||||
|
iflow: 'iflow',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps CCS provider names to CLIProxyAPI auth-url endpoint prefixes.
|
||||||
|
* Used for GET /v0/management/${prefix}-auth-url endpoints.
|
||||||
|
* These differ from callback names for some providers (e.g., gemini-cli vs gemini).
|
||||||
|
*/
|
||||||
|
export const CLIPROXY_AUTH_URL_PROVIDER_MAP: Record<CLIProxyProvider, string> = {
|
||||||
|
gemini: 'gemini-cli',
|
||||||
|
codex: 'codex',
|
||||||
|
agy: 'antigravity',
|
||||||
|
kiro: 'kiro',
|
||||||
|
ghcp: 'github',
|
||||||
|
claude: 'anthropic',
|
||||||
|
qwen: 'qwen',
|
||||||
|
iflow: 'iflow',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get OAuth config for provider
|
* Get OAuth config for provider
|
||||||
*/
|
*/
|
||||||
@@ -188,4 +219,6 @@ export interface OAuthOptions {
|
|||||||
noIncognito?: boolean;
|
noIncognito?: boolean;
|
||||||
/** If true, skip OAuth and import token from Kiro IDE directly (Kiro only) */
|
/** If true, skip OAuth and import token from Kiro IDE directly (Kiro only) */
|
||||||
import?: boolean;
|
import?: boolean;
|
||||||
|
/** Enable paste-callback mode: show auth URL and prompt for callback paste */
|
||||||
|
pasteCallback?: boolean;
|
||||||
}
|
}
|
||||||
@@ -107,14 +107,20 @@ export function getTimeoutTroubleshooting(
|
|||||||
lines.push('');
|
lines.push('');
|
||||||
lines.push('TROUBLESHOOTING:');
|
lines.push('TROUBLESHOOTING:');
|
||||||
lines.push(' 1. Check browser completed auth (should show success page)');
|
lines.push(' 1. Check browser completed auth (should show success page)');
|
||||||
|
lines.push(' 2. Complete OAuth in the same browser session that opened');
|
||||||
|
|
||||||
if (port) {
|
if (port) {
|
||||||
lines.push(` 2. Check for port conflicts: lsof -ti:${port} or ss -tlnp | grep ${port}`);
|
lines.push(` 3. Check for port conflicts: lsof -ti:${port} or ss -tlnp | grep ${port}`);
|
||||||
lines.push(` 3. Try: ccs ${provider} --auth --verbose`);
|
lines.push(` 4. Try: ccs ${provider} --auth --verbose`);
|
||||||
} else {
|
} else {
|
||||||
lines.push(` 2. Try: ccs ${provider} --auth --verbose`);
|
lines.push(` 3. Try: ccs ${provider} --auth --verbose`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push('If you copied the URL to another browser:');
|
||||||
|
lines.push(' - OAuth sessions expire after ~10 minutes');
|
||||||
|
lines.push(' - Callback must reach localhost (same machine only)');
|
||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { fail, info, warn, color } from '../../utils/ui';
|
import { fail, info, warn, color, ok } from '../../utils/ui';
|
||||||
import { ensureCLIProxyBinary } from '../binary-manager';
|
import { ensureCLIProxyBinary } from '../binary-manager';
|
||||||
import { generateConfig } from '../config-generator';
|
import { generateConfig } from '../config-generator';
|
||||||
import { CLIProxyProvider } from '../types';
|
import { CLIProxyProvider } from '../types';
|
||||||
@@ -27,11 +27,18 @@ import {
|
|||||||
enhancedPreflightOAuthCheck,
|
enhancedPreflightOAuthCheck,
|
||||||
OAUTH_CALLBACK_PORTS as OAUTH_PORTS,
|
OAUTH_CALLBACK_PORTS as OAUTH_PORTS,
|
||||||
} from '../../management/oauth-port-diagnostics';
|
} from '../../management/oauth-port-diagnostics';
|
||||||
import { OAuthOptions, OAUTH_CALLBACK_PORTS, getOAuthConfig } from './auth-types';
|
import {
|
||||||
|
OAuthOptions,
|
||||||
|
OAUTH_CALLBACK_PORTS,
|
||||||
|
getOAuthConfig,
|
||||||
|
ProviderOAuthConfig,
|
||||||
|
CLIPROXY_CALLBACK_PROVIDER_MAP,
|
||||||
|
} from './auth-types';
|
||||||
import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector';
|
import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector';
|
||||||
import { getProviderTokenDir, isAuthenticated, registerAccountFromToken } from './token-manager';
|
import { getProviderTokenDir, isAuthenticated, registerAccountFromToken } from './token-manager';
|
||||||
import { executeOAuthProcess } from './oauth-process';
|
import { executeOAuthProcess } from './oauth-process';
|
||||||
import { importKiroToken } from './kiro-import';
|
import { importKiroToken } from './kiro-import';
|
||||||
|
import { getProxyTarget, buildProxyUrl, buildManagementHeaders } from '../proxy-target-resolver';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prompt user to add another account
|
* Prompt user to add another account
|
||||||
@@ -186,6 +193,154 @@ async function prepareBinary(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle paste-callback mode: show auth URL, prompt for callback paste
|
||||||
|
* Uses proxy target resolver to connect to correct CLIProxyAPI instance (local or remote)
|
||||||
|
*/
|
||||||
|
async function handlePasteCallbackMode(
|
||||||
|
provider: CLIProxyProvider,
|
||||||
|
oauthConfig: ProviderOAuthConfig,
|
||||||
|
verbose: boolean,
|
||||||
|
tokenDir: string,
|
||||||
|
nickname?: string
|
||||||
|
): Promise<AccountInfo | null> {
|
||||||
|
// Resolve CLIProxyAPI target (local or remote based on config)
|
||||||
|
const target = getProxyTarget();
|
||||||
|
// OAuth state timeout (10 minutes, matches CLIProxyAPI state TTL)
|
||||||
|
const OAUTH_STATE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(info(`Starting ${oauthConfig.displayName} OAuth (paste-callback mode)...`));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request auth URL from CLIProxyAPI
|
||||||
|
// Note: Uses /oauth/${provider}/start endpoint (different from web-server routes which use
|
||||||
|
// /v0/management/${provider}-auth-url). Both start OAuth flows but this endpoint is simpler
|
||||||
|
// for CLI paste-callback mode as it directly returns the auth URL without is_webui param.
|
||||||
|
const startResponse = await fetch(buildProxyUrl(target, `/oauth/${provider}/start`), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: buildManagementHeaders(target, { 'Content-Type': 'application/json' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!startResponse.ok) {
|
||||||
|
console.log(fail('Failed to start OAuth flow'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startData = (await startResponse.json()) as {
|
||||||
|
url?: string;
|
||||||
|
auth_url?: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
const authUrl = startData.url || startData.auth_url;
|
||||||
|
|
||||||
|
if (!authUrl) {
|
||||||
|
console.log(fail('No authorization URL received'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display auth URL in box
|
||||||
|
console.log('');
|
||||||
|
console.log(' +--------------------------------------------------------------+');
|
||||||
|
console.log(' | Open this URL in any browser: |');
|
||||||
|
console.log(' +--------------------------------------------------------------+');
|
||||||
|
console.log('');
|
||||||
|
console.log(` ${authUrl}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Prompt for callback URL
|
||||||
|
const readline = await import('readline');
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
const callbackUrl = await new Promise<string | null>((resolve) => {
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
|
rl.on('close', () => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(info('After completing authentication, paste the callback URL here:'));
|
||||||
|
rl.question('> ', (answer) => {
|
||||||
|
resolved = true;
|
||||||
|
rl.close();
|
||||||
|
resolve(answer.trim() || null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timeout after 10 minutes (match state TTL)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
rl.close();
|
||||||
|
console.log('');
|
||||||
|
console.log(fail('Timed out waiting for callback URL (10 minutes)'));
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
}, OAUTH_STATE_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!callbackUrl) {
|
||||||
|
console.log(info('Cancelled'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate callback URL
|
||||||
|
let code: string | undefined;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(callbackUrl);
|
||||||
|
code = parsed.searchParams.get('code') || undefined;
|
||||||
|
} catch {
|
||||||
|
console.log(fail('Invalid URL format'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
console.log(fail('Invalid callback URL: missing code parameter'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit callback to CLIProxyAPI
|
||||||
|
console.log(info('Submitting callback...'));
|
||||||
|
|
||||||
|
const callbackProvider = CLIPROXY_CALLBACK_PROVIDER_MAP[provider] || provider;
|
||||||
|
|
||||||
|
// Note: /oauth-callback is a CLIProxyAPI endpoint (not /v0/management prefix)
|
||||||
|
const callbackResponse = await fetch(buildProxyUrl(target, '/oauth-callback'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: buildManagementHeaders(target, { 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider: callbackProvider,
|
||||||
|
redirect_url: callbackUrl,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const callbackData = (await callbackResponse.json()) as {
|
||||||
|
status?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!callbackResponse.ok || callbackData.status === 'error') {
|
||||||
|
console.log(fail(callbackData.error || 'OAuth callback failed'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(ok('Authentication successful!'));
|
||||||
|
return registerAccountFromToken(provider, tokenDir, nickname);
|
||||||
|
} catch (error) {
|
||||||
|
if (verbose) {
|
||||||
|
console.log(fail(`Error: ${(error as Error).message}`));
|
||||||
|
} else {
|
||||||
|
console.log(fail('OAuth failed. Use --verbose for details.'));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger OAuth flow for provider
|
* Trigger OAuth flow for provider
|
||||||
* Auto-detects headless environment and uses --no-browser flag accordingly
|
* Auto-detects headless environment and uses --no-browser flag accordingly
|
||||||
@@ -203,6 +358,12 @@ export async function triggerOAuth(
|
|||||||
// Check for existing accounts
|
// Check for existing accounts
|
||||||
const existingAccounts = getProviderAccounts(provider);
|
const existingAccounts = getProviderAccounts(provider);
|
||||||
|
|
||||||
|
// Handle paste-callback mode
|
||||||
|
if (options.pasteCallback) {
|
||||||
|
const tokenDir = getProviderTokenDir(provider);
|
||||||
|
return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname);
|
||||||
|
}
|
||||||
|
|
||||||
// For kiro/ghcp: require nickname if not provided (CLI only, not fromUI)
|
// For kiro/ghcp: require nickname if not provided (CLI only, not fromUI)
|
||||||
if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !nickname && !fromUI) {
|
if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !nickname && !fromUI) {
|
||||||
const promptedNickname = await promptNickname(provider, existingAccounts);
|
const promptedNickname = await promptNickname(provider, existingAccounts);
|
||||||
|
|||||||
@@ -250,6 +250,11 @@ async function handleTokenNotFound(
|
|||||||
console.log(fail('Token not found after authentication'));
|
console.log(fail('Token not found after authentication'));
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('The browser showed success but callback was not received.');
|
console.log('The browser showed success but callback was not received.');
|
||||||
|
console.log('');
|
||||||
|
console.log('Common causes:');
|
||||||
|
console.log(' 1. OAuth session timed out (sessions expire after ~10 minutes)');
|
||||||
|
console.log(' 2. Callback server could not receive the redirect');
|
||||||
|
console.log(' 3. Browser did not redirect to localhost properly');
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -263,6 +268,11 @@ async function handleTokenNotFound(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('If you copied the OAuth URL to a different browser:');
|
||||||
|
console.log(' - Complete authentication within the timeout window');
|
||||||
|
console.log(' - Ensure you are on the same machine (localhost callback)');
|
||||||
|
console.log(' - Copy the entire URL including all parameters');
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(`Try: ccs ${provider} --auth --verbose`);
|
console.log(`Try: ccs ${provider} --auth --verbose`);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ export async function execClaudeWithCLIProxy(
|
|||||||
|
|
||||||
// 2. Handle special flags (use argsWithoutProxy - proxy flags already stripped)
|
// 2. Handle special flags (use argsWithoutProxy - proxy flags already stripped)
|
||||||
const forceAuth = argsWithoutProxy.includes('--auth');
|
const forceAuth = argsWithoutProxy.includes('--auth');
|
||||||
|
const pasteCallback = argsWithoutProxy.includes('--paste-callback');
|
||||||
const forceHeadless = argsWithoutProxy.includes('--headless');
|
const forceHeadless = argsWithoutProxy.includes('--headless');
|
||||||
const forceLogout = argsWithoutProxy.includes('--logout');
|
const forceLogout = argsWithoutProxy.includes('--logout');
|
||||||
const forceConfig = argsWithoutProxy.includes('--config');
|
const forceConfig = argsWithoutProxy.includes('--config');
|
||||||
@@ -521,6 +522,7 @@ export async function execClaudeWithCLIProxy(
|
|||||||
...(forceHeadless ? { headless: true } : {}),
|
...(forceHeadless ? { headless: true } : {}),
|
||||||
...(setNickname ? { nickname: setNickname } : {}),
|
...(setNickname ? { nickname: setNickname } : {}),
|
||||||
...(noIncognito ? { noIncognito: true } : {}),
|
...(noIncognito ? { noIncognito: true } : {}),
|
||||||
|
...(pasteCallback ? { pasteCallback: true } : {}),
|
||||||
});
|
});
|
||||||
if (!authSuccess) {
|
if (!authSuccess) {
|
||||||
throw new Error(`Authentication required for ${providerConfig.displayName}`);
|
throw new Error(`Authentication required for ${providerConfig.displayName}`);
|
||||||
@@ -963,6 +965,7 @@ export async function execClaudeWithCLIProxy(
|
|||||||
// Note: Proxy flags (--proxy-host, etc.) already stripped by resolveProxyConfig()
|
// Note: Proxy flags (--proxy-host, etc.) already stripped by resolveProxyConfig()
|
||||||
const ccsFlags = [
|
const ccsFlags = [
|
||||||
'--auth',
|
'--auth',
|
||||||
|
'--paste-callback',
|
||||||
'--headless',
|
'--headless',
|
||||||
'--logout',
|
'--logout',
|
||||||
'--config',
|
'--config',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
normalizeProtocol,
|
normalizeProtocol,
|
||||||
validateRemotePort,
|
validateRemotePort,
|
||||||
} from './config-generator';
|
} from './config-generator';
|
||||||
|
import { getEffectiveManagementSecret } from './auth-token-manager';
|
||||||
|
|
||||||
/** Resolved proxy target for making requests */
|
/** Resolved proxy target for making requests */
|
||||||
export interface ProxyTarget {
|
export interface ProxyTarget {
|
||||||
@@ -63,9 +64,11 @@ export function getProxyTarget(): ProxyTarget {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const localPort = config?.local?.port ?? CLIPROXY_DEFAULT_PORT;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: config?.local?.port ?? CLIPROXY_DEFAULT_PORT,
|
port: localPort,
|
||||||
protocol: 'http',
|
protocol: 'http',
|
||||||
isRemote: false,
|
isRemote: false,
|
||||||
};
|
};
|
||||||
@@ -108,7 +111,8 @@ export function buildProxyHeaders(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build request headers for management API endpoints (/v0/management/*).
|
* Build request headers for management API endpoints (/v0/management/*).
|
||||||
* Uses management_key if configured, otherwise falls back to authToken.
|
* For remote targets: uses management_key, falls back to authToken.
|
||||||
|
* For local targets: uses the effective management secret from CCS config.
|
||||||
*
|
*
|
||||||
* @param target Resolved proxy target
|
* @param target Resolved proxy target
|
||||||
* @param additionalHeaders Extra headers to merge
|
* @param additionalHeaders Extra headers to merge
|
||||||
@@ -122,8 +126,11 @@ export function buildManagementHeaders(
|
|||||||
...additionalHeaders,
|
...additionalHeaders,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use management key for management API, fallback to authToken
|
// Remote: use configured management key or auth token
|
||||||
const authKey = target.managementKey ?? target.authToken;
|
// Local: use CCS management secret (default: 'ccs')
|
||||||
|
const authKey = target.isRemote
|
||||||
|
? (target.managementKey ?? target.authToken)
|
||||||
|
: getEffectiveManagementSecret();
|
||||||
|
|
||||||
if (authKey) {
|
if (authKey) {
|
||||||
headers['Authorization'] = `Bearer ${authKey}`;
|
headers['Authorization'] = `Bearer ${authKey}`;
|
||||||
|
|||||||
@@ -171,6 +171,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
|||||||
['', ''], // Spacer
|
['', ''], // Spacer
|
||||||
['ccs <provider> --auth', 'Authenticate only'],
|
['ccs <provider> --auth', 'Authenticate only'],
|
||||||
['ccs <provider> --auth --add', 'Add another account'],
|
['ccs <provider> --auth --add', 'Add another account'],
|
||||||
|
[
|
||||||
|
'ccs <provider> --paste-callback',
|
||||||
|
'Show auth URL and prompt for callback paste (cross-browser)',
|
||||||
|
],
|
||||||
['ccs <provider> --accounts', 'List all accounts'],
|
['ccs <provider> --accounts', 'List all accounts'],
|
||||||
['ccs <provider> --use <name>', 'Switch to account'],
|
['ccs <provider> --use <name>', 'Switch to account'],
|
||||||
['ccs <provider> --config', 'Change model (agy, gemini)'],
|
['ccs <provider> --config', 'Change model (agy, gemini)'],
|
||||||
|
|||||||
@@ -134,12 +134,12 @@ export function ensureHookConfig(): boolean {
|
|||||||
return normalized.includes('.ccs/hooks/websearch-transformer');
|
return normalized.includes('.ccs/hooks/websearch-transformer');
|
||||||
});
|
});
|
||||||
|
|
||||||
// INVARIANT: webSearchHookIndex remains valid after deduplication because:
|
|
||||||
// - findIndex() returns the FIRST matching CCS hook
|
|
||||||
// - deduplicateCcsHooks() keeps the FIRST CCS hook and removes subsequent duplicates
|
|
||||||
// This means the index always points to the preserved hook.
|
|
||||||
if (webSearchHookIndex !== -1) {
|
if (webSearchHookIndex !== -1) {
|
||||||
// Hook exists - first clean up any duplicates
|
// Hook exists - first clean up any duplicates
|
||||||
|
// INVARIANT: webSearchHookIndex remains valid after deduplication because:
|
||||||
|
// - findIndex() returns the FIRST matching CCS hook
|
||||||
|
// - deduplicateCcsHooks() keeps the FIRST CCS hook and removes subsequent duplicates
|
||||||
|
// This means the index always points to the preserved hook.
|
||||||
const hadDuplicates = deduplicateCcsHooks(settings);
|
const hadDuplicates = deduplicateCcsHooks(settings);
|
||||||
|
|
||||||
// Then check if it needs updating
|
// Then check if it needs updating
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
/**
|
|
||||||
* CLIProxy Auth Routes - Authentication and account management for CLIProxy providers
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import {
|
import {
|
||||||
getAllAuthStatus,
|
getAllAuthStatus,
|
||||||
@@ -29,11 +25,19 @@ import {
|
|||||||
PROVIDERS_WITHOUT_EMAIL,
|
PROVIDERS_WITHOUT_EMAIL,
|
||||||
validateNickname,
|
validateNickname,
|
||||||
} from '../../cliproxy/account-manager';
|
} from '../../cliproxy/account-manager';
|
||||||
import { getProxyTarget } from '../../cliproxy/proxy-target-resolver';
|
import {
|
||||||
|
getProxyTarget,
|
||||||
|
buildProxyUrl,
|
||||||
|
buildManagementHeaders,
|
||||||
|
} from '../../cliproxy/proxy-target-resolver';
|
||||||
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
|
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
|
||||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||||
import { tryKiroImport } from '../../cliproxy/auth/kiro-import';
|
import { tryKiroImport } from '../../cliproxy/auth/kiro-import';
|
||||||
import { getProviderTokenDir } from '../../cliproxy/auth/token-manager';
|
import { getProviderTokenDir } from '../../cliproxy/auth/token-manager';
|
||||||
|
import {
|
||||||
|
CLIPROXY_CALLBACK_PROVIDER_MAP,
|
||||||
|
CLIPROXY_AUTH_URL_PROVIDER_MAP,
|
||||||
|
} from '../../cliproxy/auth/auth-types';
|
||||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||||
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
||||||
|
|
||||||
@@ -532,4 +536,175 @@ router.post('/kiro/import', async (_req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== Manual Callback Submission ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/cliproxy/auth/:provider/start-url - Start OAuth and return auth URL immediately
|
||||||
|
* Unlike /start which blocks until completion, this returns the URL for manual callback flow
|
||||||
|
*/
|
||||||
|
router.post('/:provider/start-url', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const { provider } = req.params;
|
||||||
|
|
||||||
|
// Check remote mode
|
||||||
|
const target = getProxyTarget();
|
||||||
|
if (target.isRemote) {
|
||||||
|
res.status(501).json({ error: 'Manual OAuth flow not available in remote mode' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate provider
|
||||||
|
if (!validProviders.includes(provider as CLIProxyProvider)) {
|
||||||
|
res.status(400).json({ error: `Invalid provider: ${provider}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authUrlProvider =
|
||||||
|
CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider;
|
||||||
|
|
||||||
|
// Call CLIProxyAPI to start OAuth and get auth URL
|
||||||
|
// CLIProxyAPI management routes are under /v0/management prefix
|
||||||
|
const response = await fetch(
|
||||||
|
buildProxyUrl(target, `/v0/management/${authUrlProvider}-auth-url?is_webui=true`),
|
||||||
|
{ headers: buildManagementHeaders(target) }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
res.status(response.status).json({ error: error || 'Failed to start OAuth' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { url?: string; auth_url?: string; state?: string };
|
||||||
|
const authUrl = data.url || data.auth_url;
|
||||||
|
|
||||||
|
if (!authUrl) {
|
||||||
|
res.status(500).json({ error: 'No authorization URL received from CLIProxyAPI' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
authUrl,
|
||||||
|
state: data.state,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to start OAuth';
|
||||||
|
res.status(503).json({ error: `CLIProxyAPI not reachable: ${message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/cliproxy/auth/:provider/status - Poll OAuth status
|
||||||
|
* Checks if OAuth has completed for the given state
|
||||||
|
*/
|
||||||
|
router.get('/:provider/status', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const { provider } = req.params;
|
||||||
|
const { state } = req.query;
|
||||||
|
|
||||||
|
if (!state || typeof state !== 'string') {
|
||||||
|
res.status(400).json({ error: 'state query parameter required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate provider
|
||||||
|
if (!validProviders.includes(provider as CLIProxyProvider)) {
|
||||||
|
res.status(400).json({ error: `Invalid provider: ${provider}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const target = getProxyTarget();
|
||||||
|
|
||||||
|
// CLIProxyAPI management routes are under /v0/management prefix
|
||||||
|
const response = await fetch(
|
||||||
|
buildProxyUrl(target, `/v0/management/get-auth-status?state=${encodeURIComponent(state)}`),
|
||||||
|
{ headers: buildManagementHeaders(target) }
|
||||||
|
);
|
||||||
|
const data = (await response.json()) as { status?: string; error?: string };
|
||||||
|
|
||||||
|
res.json(data);
|
||||||
|
} catch {
|
||||||
|
res.status(503).json({ status: 'error', error: 'CLIProxyAPI not reachable' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse callback URL to extract code and state parameters.
|
||||||
|
*/
|
||||||
|
function parseCallbackUrl(url: string): { code?: string; state?: string } {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return {
|
||||||
|
code: parsed.searchParams.get('code') || undefined,
|
||||||
|
state: parsed.searchParams.get('state') || undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/cliproxy/auth/:provider/submit-callback - Submit OAuth callback URL manually
|
||||||
|
* For cross-browser OAuth flows where callback cannot redirect directly
|
||||||
|
*/
|
||||||
|
router.post('/:provider/submit-callback', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
const { provider } = req.params;
|
||||||
|
const { redirectUrl } = req.body;
|
||||||
|
|
||||||
|
// Check remote mode
|
||||||
|
const target = getProxyTarget();
|
||||||
|
if (target.isRemote) {
|
||||||
|
res.status(501).json({ error: 'Manual callback not available in remote mode' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate provider
|
||||||
|
if (!validProviders.includes(provider as CLIProxyProvider)) {
|
||||||
|
res.status(400).json({ error: `Invalid provider: ${provider}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate redirectUrl
|
||||||
|
if (!redirectUrl || typeof redirectUrl !== 'string') {
|
||||||
|
res.status(400).json({ error: 'redirectUrl is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseCallbackUrl(redirectUrl);
|
||||||
|
if (!parsed.code) {
|
||||||
|
res.status(400).json({ error: 'Invalid callback URL: missing code parameter' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const callbackProvider =
|
||||||
|
CLIPROXY_CALLBACK_PROVIDER_MAP[provider as CLIProxyProvider] || provider;
|
||||||
|
|
||||||
|
// Forward to CLIProxyAPI /oauth-callback endpoint (under /v0/management prefix)
|
||||||
|
const response = await fetch(buildProxyUrl(target, '/v0/management/oauth-callback'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: buildManagementHeaders(target, { 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider: callbackProvider,
|
||||||
|
redirect_url: redirectUrl,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = (await response.json()) as { status?: string; error?: string };
|
||||||
|
|
||||||
|
if (!response.ok || data.status === 'error') {
|
||||||
|
res.status(response.status >= 400 ? response.status : 400).json({
|
||||||
|
error: data.error || 'OAuth callback failed',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to submit callback';
|
||||||
|
res.status(503).json({ error: `CLIProxyAPI not reachable: ${message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
+64
-64
@@ -1,93 +1,93 @@
|
|||||||
import { expect, test, describe } from "bun:test";
|
import { expect, test, describe } from 'bun:test';
|
||||||
import { isCcsWebSearchHook, deduplicateCcsHooks } from "../hook-utils";
|
import { isCcsWebSearchHook, deduplicateCcsHooks } from '../../../../src/utils/websearch/hook-utils';
|
||||||
|
|
||||||
describe("isCcsWebSearchHook", () => {
|
describe('isCcsWebSearchHook', () => {
|
||||||
test("Returns true for CCS hook with forward slashes (Unix path)", () => {
|
test('Returns true for CCS hook with forward slashes (Unix path)', () => {
|
||||||
const hook = {
|
const hook = {
|
||||||
matcher: "WebSearch",
|
matcher: 'WebSearch',
|
||||||
hooks: [
|
hooks: [
|
||||||
{
|
{
|
||||||
command: "node /home/user/.ccs/hooks/websearch-transformer/index.js",
|
command: 'node /home/user/.ccs/hooks/websearch-transformer/index.js',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
expect(isCcsWebSearchHook(hook)).toBe(true);
|
expect(isCcsWebSearchHook(hook)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Returns true for CCS hook with backslashes (Windows path)", () => {
|
test('Returns true for CCS hook with backslashes (Windows path)', () => {
|
||||||
const hook = {
|
const hook = {
|
||||||
matcher: "WebSearch",
|
matcher: 'WebSearch',
|
||||||
hooks: [
|
hooks: [
|
||||||
{
|
{
|
||||||
command: "node C:\\Users\\user\\.ccs\\hooks\\websearch-transformer\\index.js",
|
command: 'node C:\\Users\\user\\.ccs\\hooks\\websearch-transformer\\index.js',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
expect(isCcsWebSearchHook(hook)).toBe(true);
|
expect(isCcsWebSearchHook(hook)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Returns true for mixed path separators", () => {
|
test('Returns true for mixed path separators', () => {
|
||||||
const hook = {
|
const hook = {
|
||||||
matcher: "WebSearch",
|
matcher: 'WebSearch',
|
||||||
hooks: [
|
hooks: [
|
||||||
{
|
{
|
||||||
command: "node /home/user\\.ccs/hooks\\websearch-transformer/index.js",
|
command: 'node /home/user\\.ccs/hooks\\websearch-transformer/index.js',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
expect(isCcsWebSearchHook(hook)).toBe(true);
|
expect(isCcsWebSearchHook(hook)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Returns false for non-WebSearch matcher", () => {
|
test('Returns false for non-WebSearch matcher', () => {
|
||||||
const hook = {
|
const hook = {
|
||||||
matcher: "SomethingElse",
|
matcher: 'SomethingElse',
|
||||||
hooks: [
|
hooks: [
|
||||||
{
|
{
|
||||||
command: "node /home/user/.ccs/hooks/websearch-transformer/index.js",
|
command: 'node /home/user/.ccs/hooks/websearch-transformer/index.js',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
expect(isCcsWebSearchHook(hook)).toBe(false);
|
expect(isCcsWebSearchHook(hook)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Returns false for WebSearch with non-CCS hook command", () => {
|
test('Returns false for WebSearch with non-CCS hook command', () => {
|
||||||
const hook = {
|
const hook = {
|
||||||
matcher: "WebSearch",
|
matcher: 'WebSearch',
|
||||||
hooks: [
|
hooks: [
|
||||||
{
|
{
|
||||||
command: "node /some/other/path/custom-hook.js",
|
command: 'node /some/other/path/custom-hook.js',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
expect(isCcsWebSearchHook(hook)).toBe(false);
|
expect(isCcsWebSearchHook(hook)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Returns false when hooks array is missing", () => {
|
test('Returns false when hooks array is missing', () => {
|
||||||
const hook = {
|
const hook = {
|
||||||
matcher: "WebSearch",
|
matcher: 'WebSearch',
|
||||||
};
|
};
|
||||||
expect(isCcsWebSearchHook(hook)).toBe(false);
|
expect(isCcsWebSearchHook(hook)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Returns false when hooks array is empty", () => {
|
test('Returns false when hooks array is empty', () => {
|
||||||
const hook = {
|
const hook = {
|
||||||
matcher: "WebSearch",
|
matcher: 'WebSearch',
|
||||||
hooks: [],
|
hooks: [],
|
||||||
};
|
};
|
||||||
expect(isCcsWebSearchHook(hook)).toBe(false);
|
expect(isCcsWebSearchHook(hook)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Returns false when command is missing", () => {
|
test('Returns false when command is missing', () => {
|
||||||
const hook = {
|
const hook = {
|
||||||
matcher: "WebSearch",
|
matcher: 'WebSearch',
|
||||||
hooks: [{}],
|
hooks: [{}],
|
||||||
};
|
};
|
||||||
expect(isCcsWebSearchHook(hook)).toBe(false);
|
expect(isCcsWebSearchHook(hook)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Returns false when command is not a string", () => {
|
test('Returns false when command is not a string', () => {
|
||||||
const hook = {
|
const hook = {
|
||||||
matcher: "WebSearch",
|
matcher: 'WebSearch',
|
||||||
hooks: [
|
hooks: [
|
||||||
{
|
{
|
||||||
command: 123,
|
command: 123,
|
||||||
@@ -98,14 +98,14 @@ describe("isCcsWebSearchHook", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deduplicateCcsHooks", () => {
|
describe('deduplicateCcsHooks', () => {
|
||||||
test("No-op when 0 CCS hooks (returns false)", () => {
|
test('No-op when 0 CCS hooks (returns false)', () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
hooks: {
|
hooks: {
|
||||||
PreToolUse: [
|
PreToolUse: [
|
||||||
{
|
{
|
||||||
matcher: "SomeOtherMatcher",
|
matcher: 'SomeOtherMatcher',
|
||||||
hooks: [{ command: "other-command" }],
|
hooks: [{ command: 'other-command' }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -115,15 +115,15 @@ describe("deduplicateCcsHooks", () => {
|
|||||||
expect(settings.hooks.PreToolUse).toHaveLength(1);
|
expect(settings.hooks.PreToolUse).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("No-op when 1 CCS hook (returns false)", () => {
|
test('No-op when 1 CCS hook (returns false)', () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
hooks: {
|
hooks: {
|
||||||
PreToolUse: [
|
PreToolUse: [
|
||||||
{
|
{
|
||||||
matcher: "WebSearch",
|
matcher: 'WebSearch',
|
||||||
hooks: [
|
hooks: [
|
||||||
{
|
{
|
||||||
command: "node /home/user/.ccs/hooks/websearch-transformer/index.js",
|
command: 'node /home/user/.ccs/hooks/websearch-transformer/index.js',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -135,31 +135,31 @@ describe("deduplicateCcsHooks", () => {
|
|||||||
expect(settings.hooks.PreToolUse).toHaveLength(1);
|
expect(settings.hooks.PreToolUse).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Removes duplicates when 2+ CCS hooks (returns true, keeps first)", () => {
|
test('Removes duplicates when 2+ CCS hooks (returns true, keeps first)', () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
hooks: {
|
hooks: {
|
||||||
PreToolUse: [
|
PreToolUse: [
|
||||||
{
|
{
|
||||||
matcher: "WebSearch",
|
matcher: 'WebSearch',
|
||||||
hooks: [
|
hooks: [
|
||||||
{
|
{
|
||||||
command: "node /home/user/.ccs/hooks/websearch-transformer/index.js",
|
command: 'node /home/user/.ccs/hooks/websearch-transformer/index.js',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
matcher: "WebSearch",
|
matcher: 'WebSearch',
|
||||||
hooks: [
|
hooks: [
|
||||||
{
|
{
|
||||||
command: "node C:\\Users\\user\\.ccs\\hooks\\websearch-transformer\\index.js",
|
command: 'node C:\\Users\\user\\.ccs\\hooks\\websearch-transformer\\index.js',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
matcher: "WebSearch",
|
matcher: 'WebSearch',
|
||||||
hooks: [
|
hooks: [
|
||||||
{
|
{
|
||||||
command: "node /another/path/.ccs/hooks/websearch-transformer/index.js",
|
command: 'node /another/path/.ccs/hooks/websearch-transformer/index.js',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -170,37 +170,37 @@ describe("deduplicateCcsHooks", () => {
|
|||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(settings.hooks.PreToolUse).toHaveLength(1);
|
expect(settings.hooks.PreToolUse).toHaveLength(1);
|
||||||
expect(settings.hooks.PreToolUse[0]).toEqual({
|
expect(settings.hooks.PreToolUse[0]).toEqual({
|
||||||
matcher: "WebSearch",
|
matcher: 'WebSearch',
|
||||||
hooks: [
|
hooks: [
|
||||||
{
|
{
|
||||||
command: "node /home/user/.ccs/hooks/websearch-transformer/index.js",
|
command: 'node /home/user/.ccs/hooks/websearch-transformer/index.js',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Preserves non-CCS hooks in array", () => {
|
test('Preserves non-CCS hooks in array', () => {
|
||||||
const nonCcsHook = {
|
const nonCcsHook = {
|
||||||
matcher: "SomeOtherMatcher",
|
matcher: 'SomeOtherMatcher',
|
||||||
hooks: [{ command: "other-command" }],
|
hooks: [{ command: 'other-command' }],
|
||||||
};
|
};
|
||||||
const settings = {
|
const settings = {
|
||||||
hooks: {
|
hooks: {
|
||||||
PreToolUse: [
|
PreToolUse: [
|
||||||
nonCcsHook,
|
nonCcsHook,
|
||||||
{
|
{
|
||||||
matcher: "WebSearch",
|
matcher: 'WebSearch',
|
||||||
hooks: [
|
hooks: [
|
||||||
{
|
{
|
||||||
command: "node /home/user/.ccs/hooks/websearch-transformer/index.js",
|
command: 'node /home/user/.ccs/hooks/websearch-transformer/index.js',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
matcher: "WebSearch",
|
matcher: 'WebSearch',
|
||||||
hooks: [
|
hooks: [
|
||||||
{
|
{
|
||||||
command: "node C:\\Users\\user\\.ccs\\hooks\\websearch-transformer\\index.js",
|
command: 'node C:\\Users\\user\\.ccs\\hooks\\websearch-transformer\\index.js',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -213,13 +213,13 @@ describe("deduplicateCcsHooks", () => {
|
|||||||
expect(settings.hooks.PreToolUse[0]).toEqual(nonCcsHook);
|
expect(settings.hooks.PreToolUse[0]).toEqual(nonCcsHook);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Returns false when hooks is undefined", () => {
|
test('Returns false when hooks is undefined', () => {
|
||||||
const settings = {};
|
const settings = {};
|
||||||
const result = deduplicateCcsHooks(settings);
|
const result = deduplicateCcsHooks(settings);
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Returns false when PreToolUse is undefined", () => {
|
test('Returns false when PreToolUse is undefined', () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
hooks: {},
|
hooks: {},
|
||||||
};
|
};
|
||||||
@@ -227,31 +227,31 @@ describe("deduplicateCcsHooks", () => {
|
|||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Handles multiple non-CCS hooks with duplicates", () => {
|
test('Handles multiple non-CCS hooks with duplicates', () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
hooks: {
|
hooks: {
|
||||||
PreToolUse: [
|
PreToolUse: [
|
||||||
{
|
{
|
||||||
matcher: "OtherMatcher1",
|
matcher: 'OtherMatcher1',
|
||||||
hooks: [{ command: "command1" }],
|
hooks: [{ command: 'command1' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
matcher: "WebSearch",
|
matcher: 'WebSearch',
|
||||||
hooks: [
|
hooks: [
|
||||||
{
|
{
|
||||||
command: "node /path1/.ccs/hooks/websearch-transformer/index.js",
|
command: 'node /path1/.ccs/hooks/websearch-transformer/index.js',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
matcher: "OtherMatcher2",
|
matcher: 'OtherMatcher2',
|
||||||
hooks: [{ command: "command2" }],
|
hooks: [{ command: 'command2' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
matcher: "WebSearch",
|
matcher: 'WebSearch',
|
||||||
hooks: [
|
hooks: [
|
||||||
{
|
{
|
||||||
command: "node /path2/.ccs/hooks/websearch-transformer/index.js",
|
command: 'node /path2/.ccs/hooks/websearch-transformer/index.js',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -262,12 +262,12 @@ describe("deduplicateCcsHooks", () => {
|
|||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(settings.hooks.PreToolUse).toHaveLength(3);
|
expect(settings.hooks.PreToolUse).toHaveLength(3);
|
||||||
// First and third should be non-CCS hooks, second should be the first CCS hook
|
// First and third should be non-CCS hooks, second should be the first CCS hook
|
||||||
expect(settings.hooks.PreToolUse[0].matcher).toBe("OtherMatcher1");
|
expect(settings.hooks.PreToolUse[0].matcher).toBe('OtherMatcher1');
|
||||||
expect(settings.hooks.PreToolUse[1].matcher).toBe("WebSearch");
|
expect(settings.hooks.PreToolUse[1].matcher).toBe('WebSearch');
|
||||||
expect(settings.hooks.PreToolUse[2].matcher).toBe("OtherMatcher2");
|
expect(settings.hooks.PreToolUse[2].matcher).toBe('OtherMatcher2');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Edge case: Empty PreToolUse array", () => {
|
test('Edge case: Empty PreToolUse array', () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
hooks: {
|
hooks: {
|
||||||
PreToolUse: [],
|
PreToolUse: [],
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Add Account Dialog Component
|
* Add Account Dialog Component
|
||||||
* Triggers OAuth flow server-side to add another account to a provider
|
* Uses /start-url to get OAuth URL + polls for completion via management API.
|
||||||
* Always applies default preset to ensure required env vars are set
|
* Does NOT call /start (which spawns a CLIProxy binary and kills running instances).
|
||||||
* For Kiro: Also shows "Import from IDE" option as fallback
|
* Shows auth URL + callback paste field. Polling auto-closes on success.
|
||||||
|
* For Kiro: Also shows "Import from IDE" option.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -16,8 +17,9 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Loader2, ExternalLink, User, Download } from 'lucide-react';
|
import { Loader2, ExternalLink, User, Download, Copy, Check } from 'lucide-react';
|
||||||
import { useStartAuth, useKiroImport, useCancelAuth } from '@/hooks/use-cliproxy';
|
import { useKiroImport } from '@/hooks/use-cliproxy';
|
||||||
|
import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow';
|
||||||
import { applyDefaultPreset } from '@/lib/preset-utils';
|
import { applyDefaultPreset } from '@/lib/preset-utils';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -38,55 +40,84 @@ export function AddAccountDialog({
|
|||||||
isFirstAccount = false,
|
isFirstAccount = false,
|
||||||
}: AddAccountDialogProps) {
|
}: AddAccountDialogProps) {
|
||||||
const [nickname, setNickname] = useState('');
|
const [nickname, setNickname] = useState('');
|
||||||
const startAuthMutation = useStartAuth();
|
const [callbackUrl, setCallbackUrl] = useState('');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const wasAuthenticatingRef = useRef(false);
|
||||||
|
const authFlow = useCliproxyAuthFlow();
|
||||||
const kiroImportMutation = useKiroImport();
|
const kiroImportMutation = useKiroImport();
|
||||||
const cancelAuthMutation = useCancelAuth();
|
|
||||||
|
|
||||||
const isKiro = provider === 'kiro';
|
const isKiro = provider === 'kiro';
|
||||||
const isPending = startAuthMutation.isPending || kiroImportMutation.isPending;
|
const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending;
|
||||||
|
|
||||||
const handleCancel = () => {
|
const resetAndClose = () => {
|
||||||
if (isPending) {
|
|
||||||
cancelAuthMutation.mutate(provider);
|
|
||||||
}
|
|
||||||
setNickname('');
|
setNickname('');
|
||||||
|
setCallbackUrl('');
|
||||||
|
setCopied(false);
|
||||||
|
wasAuthenticatingRef.current = false;
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartAuth = () => {
|
// When authFlow completes successfully (polling detected success), apply preset and close
|
||||||
startAuthMutation.mutate(
|
useEffect(() => {
|
||||||
{ provider, nickname: nickname.trim() || undefined },
|
if (!authFlow.isAuthenticating && !authFlow.error && authFlow.provider === null && open) {
|
||||||
{
|
if (wasAuthenticatingRef.current) {
|
||||||
onSuccess: async () => {
|
wasAuthenticatingRef.current = false;
|
||||||
// Always apply default preset to ensure BASE_URL and AUTH_TOKEN are set
|
const applyPresetAndClose = async () => {
|
||||||
const result = await applyDefaultPreset(provider);
|
try {
|
||||||
if (result.success && result.presetName) {
|
const result = await applyDefaultPreset(provider);
|
||||||
if (isFirstAccount) {
|
if (result.success && result.presetName && isFirstAccount) {
|
||||||
toast.success(`Applied "${result.presetName}" preset`);
|
toast.success(`Applied "${result.presetName}" preset`);
|
||||||
}
|
}
|
||||||
// Silent success for non-first accounts - preset ensures required vars exist
|
} catch {
|
||||||
} else if (!result.success) {
|
// Continue to close dialog even if preset apply fails
|
||||||
toast.warning(
|
|
||||||
'Account added, but failed to apply default preset. You may need to configure settings manually.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
setNickname('');
|
resetAndClose();
|
||||||
onClose();
|
};
|
||||||
},
|
applyPresetAndClose();
|
||||||
}
|
}
|
||||||
);
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [authFlow.isAuthenticating, authFlow.error, authFlow.provider]);
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
// Always cancel authFlow (handles its own no-op if not active)
|
||||||
|
authFlow.cancelAuth();
|
||||||
|
resetAndClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyUrl = async () => {
|
||||||
|
if (authFlow.authUrl) {
|
||||||
|
await navigator.clipboard.writeText(authFlow.authUrl);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitCallback = () => {
|
||||||
|
if (callbackUrl.trim()) {
|
||||||
|
authFlow.submitCallback(callbackUrl.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate via /start-url + polling only.
|
||||||
|
* Does NOT call /start (which spawns a local CLIProxy binary that kills running instances).
|
||||||
|
* /start-url uses the management API to get auth URL, then polls for completion.
|
||||||
|
*/
|
||||||
|
const handleAuthenticate = () => {
|
||||||
|
wasAuthenticatingRef.current = true;
|
||||||
|
authFlow.startAuth(provider, { nickname: nickname.trim() || undefined });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKiroImport = () => {
|
const handleKiroImport = () => {
|
||||||
|
wasAuthenticatingRef.current = true;
|
||||||
kiroImportMutation.mutate(undefined, {
|
kiroImportMutation.mutate(undefined, {
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
// Always apply default preset for Kiro as well
|
|
||||||
const result = await applyDefaultPreset('kiro');
|
const result = await applyDefaultPreset('kiro');
|
||||||
if (result.success && result.presetName && isFirstAccount) {
|
if (result.success && result.presetName && isFirstAccount) {
|
||||||
toast.success(`Applied "${result.presetName}" preset`);
|
toast.success(`Applied "${result.presetName}" preset`);
|
||||||
}
|
}
|
||||||
setNickname('');
|
resetAndClose();
|
||||||
onClose();
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -97,42 +128,151 @@ export function AddAccountDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showAuthUI = authFlow.isAuthenticating;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent
|
||||||
|
className="sm:max-w-md"
|
||||||
|
onInteractOutside={(e) => {
|
||||||
|
// Prevent accidental close by clicking outside during auth
|
||||||
|
if (showAuthUI) e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add {displayName} Account</DialogTitle>
|
<DialogTitle>Add {displayName} Account</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isKiro
|
{isKiro
|
||||||
? 'Authenticate via browser or import an existing token from Kiro IDE.'
|
? 'Authenticate via browser or import an existing token from Kiro IDE.'
|
||||||
: 'Click the button below to authenticate a new account. A browser window will open for OAuth.'}
|
: 'Click Authenticate to get an OAuth URL. Open it in any browser to sign in.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
{/* Nickname input - only show before auth starts */}
|
||||||
<Label htmlFor="nickname">Nickname (optional)</Label>
|
{!showAuthUI && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-2">
|
||||||
<User className="w-4 h-4 text-muted-foreground" />
|
<Label htmlFor="nickname">Nickname (optional)</Label>
|
||||||
<Input
|
<div className="flex items-center gap-2">
|
||||||
id="nickname"
|
<User className="w-4 h-4 text-muted-foreground" />
|
||||||
value={nickname}
|
<Input
|
||||||
onChange={(e) => setNickname(e.target.value)}
|
id="nickname"
|
||||||
placeholder="e.g., work, personal"
|
value={nickname}
|
||||||
disabled={isPending}
|
onChange={(e) => setNickname(e.target.value)}
|
||||||
className="flex-1"
|
placeholder="e.g., work, personal"
|
||||||
/>
|
disabled={isPending}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
A friendly name to identify this account. Auto-generated from email if left empty.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
)}
|
||||||
A friendly name to identify this account. Auto-generated from email if left empty.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{/* Unified auth state: spinner + auth URL + callback paste */}
|
||||||
|
{showAuthUI && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Spinner */}
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="w-4 h-4 inline mr-2 animate-spin" />
|
||||||
|
Waiting for authentication...
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Complete the authentication in your browser. This dialog closes automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error from /start-url - fallback URL not available */}
|
||||||
|
{authFlow.error && !authFlow.authUrl && (
|
||||||
|
<p className="text-xs text-center text-destructive">{authFlow.error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Auth URL section - appears once /start-url returns */}
|
||||||
|
{authFlow.authUrl && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">Open this URL in any browser to sign in:</Label>
|
||||||
|
<div className="p-3 bg-muted rounded-md">
|
||||||
|
<p className="text-xs text-muted-foreground break-all font-mono line-clamp-3">
|
||||||
|
{authFlow.authUrl}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleCopyUrl}>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-3 h-3 mr-1" />
|
||||||
|
Copied
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-3 h-3 mr-1" />
|
||||||
|
Copy
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
authFlow.authUrl && window.open(authFlow.authUrl, '_blank')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3 mr-1" />
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Callback paste field */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="callback-url" className="text-xs">
|
||||||
|
Redirect didn't work? Paste the callback URL:
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="callback-url"
|
||||||
|
value={callbackUrl}
|
||||||
|
onChange={(e) => setCallbackUrl(e.target.value)}
|
||||||
|
placeholder="Paste the redirect URL here..."
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSubmitCallback}
|
||||||
|
disabled={!callbackUrl.trim() || authFlow.isSubmittingCallback}
|
||||||
|
>
|
||||||
|
{authFlow.isSubmittingCallback ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||||||
|
Submitting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Submit Callback'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Kiro import loading */}
|
||||||
|
{kiroImportMutation.isPending && (
|
||||||
|
<p className="text-sm text-center text-muted-foreground">
|
||||||
|
<Loader2 className="w-4 h-4 inline mr-2 animate-spin" />
|
||||||
|
Importing token from Kiro IDE...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
<div className="flex items-center justify-end gap-2 pt-2">
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
<Button variant="ghost" onClick={handleCancel}>
|
<Button variant="ghost" onClick={handleCancel}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
{isKiro && (
|
{isKiro && !showAuthUI && (
|
||||||
<Button variant="outline" onClick={handleKiroImport} disabled={isPending}>
|
<Button variant="outline" onClick={handleKiroImport} disabled={isPending}>
|
||||||
{kiroImportMutation.isPending ? (
|
{kiroImportMutation.isPending ? (
|
||||||
<>
|
<>
|
||||||
@@ -147,31 +287,13 @@ export function AddAccountDialog({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={handleStartAuth} disabled={isPending}>
|
{!showAuthUI && (
|
||||||
{startAuthMutation.isPending ? (
|
<Button onClick={handleAuthenticate} disabled={isPending}>
|
||||||
<>
|
<ExternalLink className="w-4 h-4 mr-2" />
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
Authenticate
|
||||||
Authenticating...
|
</Button>
|
||||||
</>
|
)}
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ExternalLink className="w-4 h-4 mr-2" />
|
|
||||||
Authenticate
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{startAuthMutation.isPending && (
|
|
||||||
<p className="text-sm text-center text-muted-foreground">
|
|
||||||
Complete the OAuth flow in your browser...
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{kiroImportMutation.isPending && (
|
|
||||||
<p className="text-sm text-center text-muted-foreground">
|
|
||||||
Importing token from Kiro IDE...
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* OAuth Auth Flow Hook for CLIProxy
|
* OAuth Auth Flow Hook for CLIProxy
|
||||||
* Triggers backend-managed OAuth authentication flows
|
* Supports both auto-callback and manual callback flows
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
@@ -13,92 +13,261 @@ interface AuthFlowState {
|
|||||||
provider: string | null;
|
provider: string | null;
|
||||||
isAuthenticating: boolean;
|
isAuthenticating: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
/** Authorization URL for manual callback flow */
|
||||||
|
authUrl: string | null;
|
||||||
|
/** OAuth state parameter for polling */
|
||||||
|
oauthState: string | null;
|
||||||
|
/** Whether callback is being submitted */
|
||||||
|
isSubmittingCallback: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StartAuthOptions {
|
||||||
|
nickname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Polling interval for OAuth status check (3 seconds) */
|
||||||
|
const POLL_INTERVAL = 3000;
|
||||||
|
/** Maximum polling duration (5 minutes) */
|
||||||
|
const MAX_POLL_DURATION = 5 * 60 * 1000;
|
||||||
|
|
||||||
export function useCliproxyAuthFlow() {
|
export function useCliproxyAuthFlow() {
|
||||||
const [state, setState] = useState<AuthFlowState>({
|
const [state, setState] = useState<AuthFlowState>({
|
||||||
provider: null,
|
provider: null,
|
||||||
isAuthenticating: false,
|
isAuthenticating: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
authUrl: null,
|
||||||
|
oauthState: null,
|
||||||
|
isSubmittingCallback: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const pollStartRef = useRef<number>(0);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Clear polling
|
||||||
|
const stopPolling = useCallback(() => {
|
||||||
|
if (pollIntervalRef.current) {
|
||||||
|
clearInterval(pollIntervalRef.current);
|
||||||
|
pollIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
abortControllerRef.current?.abort();
|
abortControllerRef.current?.abort();
|
||||||
|
stopPolling();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [stopPolling]);
|
||||||
|
|
||||||
|
// Poll OAuth status
|
||||||
|
const pollStatus = useCallback(
|
||||||
|
async (provider: string, oauthState: string) => {
|
||||||
|
// Check timeout
|
||||||
|
if (Date.now() - pollStartRef.current > MAX_POLL_DURATION) {
|
||||||
|
stopPolling();
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isAuthenticating: false,
|
||||||
|
error: 'Authentication timed out. Please try again.',
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/cliproxy/auth/${provider}/status?state=${encodeURIComponent(oauthState)}`
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
stopPolling();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['account-quota'] });
|
||||||
|
toast.success(`${provider} authentication successful`);
|
||||||
|
setState({
|
||||||
|
provider: null,
|
||||||
|
isAuthenticating: false,
|
||||||
|
error: null,
|
||||||
|
authUrl: null,
|
||||||
|
oauthState: null,
|
||||||
|
isSubmittingCallback: false,
|
||||||
|
});
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
stopPolling();
|
||||||
|
const errorMsg = data.error || 'Authentication failed';
|
||||||
|
toast.error(errorMsg);
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isAuthenticating: false,
|
||||||
|
error: errorMsg,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// status === 'pending' means continue polling
|
||||||
|
} catch {
|
||||||
|
// Network error - continue polling
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[queryClient, stopPolling]
|
||||||
|
);
|
||||||
|
|
||||||
const startAuth = useCallback(
|
const startAuth = useCallback(
|
||||||
async (provider: string) => {
|
async (provider: string, options?: StartAuthOptions) => {
|
||||||
if (!isValidProvider(provider)) {
|
if (!isValidProvider(provider)) {
|
||||||
setState({
|
setState({
|
||||||
provider: null,
|
provider: null,
|
||||||
isAuthenticating: false,
|
isAuthenticating: false,
|
||||||
error: `Unknown provider: ${provider}`,
|
error: `Unknown provider: ${provider}`,
|
||||||
|
authUrl: null,
|
||||||
|
oauthState: null,
|
||||||
|
isSubmittingCallback: false,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Abort any in-progress auth
|
// Abort any in-progress auth
|
||||||
abortControllerRef.current?.abort();
|
abortControllerRef.current?.abort();
|
||||||
abortControllerRef.current = new AbortController();
|
stopPolling();
|
||||||
|
|
||||||
setState({ provider, isAuthenticating: true, error: null });
|
// Create fresh controller and capture locally to avoid race with cancelAuth
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortControllerRef.current = controller;
|
||||||
|
|
||||||
|
setState({
|
||||||
|
provider,
|
||||||
|
isAuthenticating: true,
|
||||||
|
error: null,
|
||||||
|
authUrl: null,
|
||||||
|
oauthState: null,
|
||||||
|
isSubmittingCallback: false,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// POST to CCS auth endpoint - backend opens browser and waits
|
// Call start-url to get auth URL immediately (non-blocking)
|
||||||
const response = await fetch(`/api/cliproxy/auth/${provider}/start`, {
|
const response = await fetch(`/api/cliproxy/auth/${provider}/start-url`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({ nickname: options?.nickname }),
|
||||||
signal: abortControllerRef.current.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok && data.success) {
|
if (!response.ok || !data.success) {
|
||||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
throw new Error(data.error || 'Failed to start OAuth');
|
||||||
queryClient.invalidateQueries({ queryKey: ['account-quota'] });
|
}
|
||||||
toast.success(`${provider} authentication successful`);
|
|
||||||
setState({ provider: null, isAuthenticating: false, error: null });
|
// Update state with auth URL
|
||||||
} else {
|
setState((prev) => ({
|
||||||
throw new Error(data.error || 'Authentication failed');
|
...prev,
|
||||||
|
authUrl: data.authUrl,
|
||||||
|
oauthState: data.state,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Auto-open auth URL in new browser tab (fallback URL still shown in dialog)
|
||||||
|
if (data.authUrl) {
|
||||||
|
window.open(data.authUrl, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start polling for completion
|
||||||
|
if (data.state) {
|
||||||
|
pollStartRef.current = Date.now();
|
||||||
|
pollIntervalRef.current = setInterval(() => {
|
||||||
|
pollStatus(provider, data.state);
|
||||||
|
}, POLL_INTERVAL);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
setState({ provider: null, isAuthenticating: false, error: null });
|
setState({
|
||||||
|
provider: null,
|
||||||
|
isAuthenticating: false,
|
||||||
|
error: null,
|
||||||
|
authUrl: null,
|
||||||
|
oauthState: null,
|
||||||
|
isSubmittingCallback: false,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const message = error instanceof Error ? error.message : 'Authentication failed';
|
const message = error instanceof Error ? error.message : 'Authentication failed';
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
setState({ provider: null, isAuthenticating: false, error: message });
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isAuthenticating: false,
|
||||||
|
error: message,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[queryClient]
|
[pollStatus, stopPolling]
|
||||||
);
|
);
|
||||||
|
|
||||||
const cancelAuth = useCallback(() => {
|
const cancelAuth = useCallback(() => {
|
||||||
const currentProvider = state.provider;
|
const currentProvider = state.provider;
|
||||||
abortControllerRef.current?.abort();
|
abortControllerRef.current?.abort();
|
||||||
setState({ provider: null, isAuthenticating: false, error: null });
|
stopPolling();
|
||||||
|
setState({
|
||||||
|
provider: null,
|
||||||
|
isAuthenticating: false,
|
||||||
|
error: null,
|
||||||
|
authUrl: null,
|
||||||
|
oauthState: null,
|
||||||
|
isSubmittingCallback: false,
|
||||||
|
});
|
||||||
// Also cancel on backend
|
// Also cancel on backend
|
||||||
if (currentProvider) {
|
if (currentProvider) {
|
||||||
api.cliproxy.auth.cancel(currentProvider).catch(() => {
|
api.cliproxy.auth.cancel(currentProvider).catch(() => {
|
||||||
// Ignore errors - session may have already completed
|
// Ignore errors - session may have already completed
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [state.provider]);
|
}, [state.provider, stopPolling]);
|
||||||
|
|
||||||
|
const submitCallback = useCallback(
|
||||||
|
async (redirectUrl: string) => {
|
||||||
|
if (!state.provider) return;
|
||||||
|
|
||||||
|
setState((prev) => ({ ...prev, isSubmittingCallback: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/cliproxy/auth/${state.provider}/submit-callback`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ redirectUrl }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.success) {
|
||||||
|
stopPolling();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['account-quota'] });
|
||||||
|
toast.success(`${state.provider} authentication successful`);
|
||||||
|
setState({
|
||||||
|
provider: null,
|
||||||
|
isAuthenticating: false,
|
||||||
|
error: null,
|
||||||
|
authUrl: null,
|
||||||
|
oauthState: null,
|
||||||
|
isSubmittingCallback: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'Callback submission failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to submit callback';
|
||||||
|
toast.error(message);
|
||||||
|
setState((prev) => ({ ...prev, isSubmittingCallback: false, error: message }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[state.provider, queryClient, stopPolling]
|
||||||
|
);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
...state,
|
...state,
|
||||||
startAuth,
|
startAuth,
|
||||||
cancelAuth,
|
cancelAuth,
|
||||||
|
submitCallback,
|
||||||
}),
|
}),
|
||||||
[state, startAuth, cancelAuth]
|
[state, startAuth, cancelAuth, submitCallback]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user