mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
feat(cliproxy): add multi-account support for CLIProxy providers
- Implement account-manager.ts for managing multiple OAuth accounts per provider - Add account selector in UI dialog with dropdown for authenticated accounts - Extend auth-handler.ts to support account registration and multi-account authentication - Update web-server routes with account management endpoints (GET/POST/DELETE) - Add account badges and management UI in cliproxy page - Support account field in CLIProxy variant configuration - Add Badge component from shadcn/ui for UI styling - Enable default account selection and account removal functionality
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Account Manager for CLIProxyAPI Multi-Account Support
|
||||
*
|
||||
* Manages multiple OAuth accounts per provider (Gemini, Codex, etc.).
|
||||
* Each provider can have multiple accounts, with one designated as default.
|
||||
*
|
||||
* Account storage: ~/.ccs/cliproxy/accounts.json
|
||||
* Token storage: ~/.ccs/cliproxy/auth/ (flat structure, CLIProxyAPI discovers by type field)
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { CLIProxyProvider } from './types';
|
||||
import { getCliproxyDir, getAuthDir } from './config-generator';
|
||||
|
||||
/** Account information */
|
||||
export interface AccountInfo {
|
||||
/** Account identifier (email or custom name) */
|
||||
id: string;
|
||||
/** Email address from OAuth (if available) */
|
||||
email?: string;
|
||||
/** Provider this account belongs to */
|
||||
provider: CLIProxyProvider;
|
||||
/** Whether this is the default account for the provider */
|
||||
isDefault: boolean;
|
||||
/** Token file name in auth directory */
|
||||
tokenFile: string;
|
||||
/** When account was added */
|
||||
createdAt: string;
|
||||
/** Last usage time */
|
||||
lastUsedAt?: string;
|
||||
}
|
||||
|
||||
/** Provider accounts configuration */
|
||||
interface ProviderAccounts {
|
||||
/** Default account ID for this provider */
|
||||
default: string;
|
||||
/** Map of account ID to account metadata */
|
||||
accounts: Record<string, Omit<AccountInfo, 'id' | 'provider' | 'isDefault'>>;
|
||||
}
|
||||
|
||||
/** Accounts registry structure */
|
||||
interface AccountsRegistry {
|
||||
/** Version for future migrations */
|
||||
version: number;
|
||||
/** Accounts organized by provider */
|
||||
providers: Partial<Record<CLIProxyProvider, ProviderAccounts>>;
|
||||
}
|
||||
|
||||
/** Default registry structure */
|
||||
const DEFAULT_REGISTRY: AccountsRegistry = {
|
||||
version: 1,
|
||||
providers: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get path to accounts registry file
|
||||
*/
|
||||
export function getAccountsRegistryPath(): string {
|
||||
return path.join(getCliproxyDir(), 'accounts.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load accounts registry
|
||||
*/
|
||||
export function loadAccountsRegistry(): AccountsRegistry {
|
||||
const registryPath = getAccountsRegistryPath();
|
||||
|
||||
if (!fs.existsSync(registryPath)) {
|
||||
return { ...DEFAULT_REGISTRY };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(registryPath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
return {
|
||||
version: data.version || 1,
|
||||
providers: data.providers || {},
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_REGISTRY };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save accounts registry
|
||||
*/
|
||||
export function saveAccountsRegistry(registry: AccountsRegistry): void {
|
||||
const registryPath = getAccountsRegistryPath();
|
||||
const dir = path.dirname(registryPath);
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all accounts for a provider
|
||||
*/
|
||||
export function getProviderAccounts(provider: CLIProxyProvider): AccountInfo[] {
|
||||
const registry = loadAccountsRegistry();
|
||||
const providerAccounts = registry.providers[provider];
|
||||
|
||||
if (!providerAccounts) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(providerAccounts.accounts).map(([id, meta]) => ({
|
||||
id,
|
||||
provider,
|
||||
isDefault: id === providerAccounts.default,
|
||||
...meta,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default account for a provider
|
||||
*/
|
||||
export function getDefaultAccount(provider: CLIProxyProvider): AccountInfo | null {
|
||||
const accounts = getProviderAccounts(provider);
|
||||
return accounts.find((a) => a.isDefault) || accounts[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific account by ID
|
||||
*/
|
||||
export function getAccount(provider: CLIProxyProvider, accountId: string): AccountInfo | null {
|
||||
const accounts = getProviderAccounts(provider);
|
||||
return accounts.find((a) => a.id === accountId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new account
|
||||
* Called after successful OAuth to record the account
|
||||
*/
|
||||
export function registerAccount(
|
||||
provider: CLIProxyProvider,
|
||||
tokenFile: string,
|
||||
email?: string
|
||||
): AccountInfo {
|
||||
const registry = loadAccountsRegistry();
|
||||
|
||||
// Initialize provider section if needed
|
||||
if (!registry.providers[provider]) {
|
||||
registry.providers[provider] = {
|
||||
default: 'default',
|
||||
accounts: {},
|
||||
};
|
||||
}
|
||||
|
||||
const providerAccounts = registry.providers[provider];
|
||||
if (!providerAccounts) {
|
||||
throw new Error('Failed to initialize provider accounts');
|
||||
}
|
||||
|
||||
// Determine account ID
|
||||
const accountId = email || 'default';
|
||||
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
|
||||
|
||||
// Create or update account
|
||||
providerAccounts.accounts[accountId] = {
|
||||
email,
|
||||
tokenFile,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Set as default if first account
|
||||
if (isFirstAccount) {
|
||||
providerAccounts.default = accountId;
|
||||
}
|
||||
|
||||
saveAccountsRegistry(registry);
|
||||
|
||||
return {
|
||||
id: accountId,
|
||||
provider,
|
||||
isDefault: accountId === providerAccounts.default,
|
||||
email,
|
||||
tokenFile,
|
||||
createdAt: providerAccounts.accounts[accountId].createdAt,
|
||||
lastUsedAt: providerAccounts.accounts[accountId].lastUsedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set default account for a provider
|
||||
*/
|
||||
export function setDefaultAccount(provider: CLIProxyProvider, accountId: string): boolean {
|
||||
const registry = loadAccountsRegistry();
|
||||
const providerAccounts = registry.providers[provider];
|
||||
|
||||
if (!providerAccounts || !providerAccounts.accounts[accountId]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
providerAccounts.default = accountId;
|
||||
saveAccountsRegistry(registry);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an account
|
||||
*/
|
||||
export function removeAccount(provider: CLIProxyProvider, accountId: string): boolean {
|
||||
const registry = loadAccountsRegistry();
|
||||
const providerAccounts = registry.providers[provider];
|
||||
|
||||
if (!providerAccounts || !providerAccounts.accounts[accountId]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get token file to delete
|
||||
const tokenFile = providerAccounts.accounts[accountId].tokenFile;
|
||||
const tokenPath = path.join(getAuthDir(), tokenFile);
|
||||
|
||||
// Delete token file
|
||||
if (fs.existsSync(tokenPath)) {
|
||||
try {
|
||||
fs.unlinkSync(tokenPath);
|
||||
} catch {
|
||||
// Ignore deletion errors
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from registry
|
||||
delete providerAccounts.accounts[accountId];
|
||||
|
||||
// Update default if needed
|
||||
const remainingAccounts = Object.keys(providerAccounts.accounts);
|
||||
if (providerAccounts.default === accountId && remainingAccounts.length > 0) {
|
||||
providerAccounts.default = remainingAccounts[0];
|
||||
}
|
||||
|
||||
saveAccountsRegistry(registry);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last used timestamp for an account
|
||||
*/
|
||||
export function touchAccount(provider: CLIProxyProvider, accountId: string): void {
|
||||
const registry = loadAccountsRegistry();
|
||||
const providerAccounts = registry.providers[provider];
|
||||
|
||||
if (providerAccounts?.accounts[accountId]) {
|
||||
providerAccounts.accounts[accountId].lastUsedAt = new Date().toISOString();
|
||||
saveAccountsRegistry(registry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token file path for an account
|
||||
*/
|
||||
export function getAccountTokenPath(provider: CLIProxyProvider, accountId?: string): string | null {
|
||||
const account = accountId ? getAccount(provider, accountId) : getDefaultAccount(provider);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.join(getAuthDir(), account.tokenFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-discover accounts from existing token files
|
||||
* Called during migration or first run to populate accounts registry
|
||||
*/
|
||||
export function discoverExistingAccounts(): void {
|
||||
const authDir = getAuthDir();
|
||||
|
||||
if (!fs.existsSync(authDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const registry = loadAccountsRegistry();
|
||||
const files = fs.readdirSync(authDir);
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
|
||||
const filePath = path.join(authDir, file);
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
|
||||
// Skip if no type field
|
||||
if (!data.type) continue;
|
||||
|
||||
const provider = data.type.toLowerCase() as CLIProxyProvider;
|
||||
|
||||
// Validate provider
|
||||
if (!['gemini', 'codex', 'agy', 'qwen', 'iflow'].includes(provider)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract email if available
|
||||
const email = data.email || undefined;
|
||||
const accountId = email || 'default';
|
||||
|
||||
// Initialize provider section if needed
|
||||
if (!registry.providers[provider]) {
|
||||
registry.providers[provider] = {
|
||||
default: accountId,
|
||||
accounts: {},
|
||||
};
|
||||
}
|
||||
|
||||
const providerAccounts = registry.providers[provider];
|
||||
if (!providerAccounts) continue;
|
||||
|
||||
// Skip if account already registered
|
||||
if (providerAccounts.accounts[accountId]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get file stats for creation time
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
// Register account
|
||||
providerAccounts.accounts[accountId] = {
|
||||
email,
|
||||
tokenFile: file,
|
||||
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
|
||||
lastUsedAt: stats.mtime?.toISOString(),
|
||||
};
|
||||
} catch {
|
||||
// Skip invalid files
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
saveAccountsRegistry(registry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary of all accounts across providers
|
||||
*/
|
||||
export function getAllAccountsSummary(): Record<CLIProxyProvider, AccountInfo[]> {
|
||||
const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
|
||||
const summary: Record<CLIProxyProvider, AccountInfo[]> = {} as Record<
|
||||
CLIProxyProvider,
|
||||
AccountInfo[]
|
||||
>;
|
||||
|
||||
for (const provider of providers) {
|
||||
summary[provider] = getProviderAccounts(provider);
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
@@ -19,6 +19,14 @@ import { ProgressIndicator } from '../utils/progress-indicator';
|
||||
import { ensureCLIProxyBinary } from './binary-manager';
|
||||
import { generateConfig, getProviderAuthDir } from './config-generator';
|
||||
import { CLIProxyProvider } from './types';
|
||||
import {
|
||||
AccountInfo,
|
||||
discoverExistingAccounts,
|
||||
getDefaultAccount,
|
||||
getProviderAccounts,
|
||||
registerAccount,
|
||||
touchAccount,
|
||||
} from './account-manager';
|
||||
|
||||
/**
|
||||
* OAuth callback ports used by CLIProxyAPI (hardcoded in binary)
|
||||
@@ -118,6 +126,10 @@ export interface AuthStatus {
|
||||
tokenFiles: string[];
|
||||
/** When last authenticated (if known) */
|
||||
lastAuth?: Date;
|
||||
/** Accounts registered for this provider (multi-account support) */
|
||||
accounts: AccountInfo[];
|
||||
/** Default account ID */
|
||||
defaultAccount?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -323,12 +335,18 @@ export function getAuthStatus(provider: CLIProxyProvider): AuthStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// Get registered accounts for multi-account support
|
||||
const accounts = getProviderAccounts(provider);
|
||||
const defaultAccount = getDefaultAccount(provider);
|
||||
|
||||
return {
|
||||
provider,
|
||||
authenticated: tokenFiles.length > 0,
|
||||
tokenDir,
|
||||
tokenFiles,
|
||||
lastAuth,
|
||||
accounts,
|
||||
defaultAccount: defaultAccount?.id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -384,11 +402,14 @@ export function clearAuth(provider: CLIProxyProvider): boolean {
|
||||
/**
|
||||
* Trigger OAuth flow for provider
|
||||
* Auto-detects headless environment and uses --no-browser flag accordingly
|
||||
* @param provider - The CLIProxy provider to authenticate
|
||||
* @param options - OAuth options
|
||||
* @returns Account info if successful, null otherwise
|
||||
*/
|
||||
export async function triggerOAuth(
|
||||
provider: CLIProxyProvider,
|
||||
options: { verbose?: boolean; headless?: boolean } = {}
|
||||
): Promise<boolean> {
|
||||
options: { verbose?: boolean; headless?: boolean; account?: string } = {}
|
||||
): Promise<AccountInfo | null> {
|
||||
const oauthConfig = getOAuthConfig(provider);
|
||||
const { verbose = false } = options;
|
||||
|
||||
@@ -451,7 +472,7 @@ export async function triggerOAuth(
|
||||
spinner.start();
|
||||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
return new Promise<AccountInfo | null>((resolve) => {
|
||||
// Spawn CLIProxyAPI with auth flag (and --no-browser if headless)
|
||||
const authProcess = spawn(binaryPath, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
@@ -513,7 +534,7 @@ export async function triggerOAuth(
|
||||
console.error(' - Make sure a browser is available');
|
||||
console.error(' - Try running with --verbose for details');
|
||||
}
|
||||
resolve(false);
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
|
||||
authProcess.on('exit', (code) => {
|
||||
@@ -524,7 +545,10 @@ export async function triggerOAuth(
|
||||
if (isAuthenticated(provider)) {
|
||||
if (!headless) spinner.succeed(`Authenticated with ${oauthConfig.displayName}`);
|
||||
console.log('[OK] Authentication successful');
|
||||
resolve(true);
|
||||
|
||||
// Register the account in accounts registry
|
||||
const account = registerAccountFromToken(provider, tokenDir);
|
||||
resolve(account);
|
||||
} else {
|
||||
if (!headless) spinner.fail('Authentication incomplete');
|
||||
console.error('[X] Token not found after authentication');
|
||||
@@ -537,7 +561,7 @@ export async function triggerOAuth(
|
||||
console.error(' The OAuth flow may have been cancelled or callback port was in use');
|
||||
console.error(` Try: pkill -f cli-proxy-api && ccs ${provider} --auth`);
|
||||
}
|
||||
resolve(false);
|
||||
resolve(null);
|
||||
}
|
||||
} else {
|
||||
if (!headless) spinner.fail('Authentication failed');
|
||||
@@ -550,7 +574,7 @@ export async function triggerOAuth(
|
||||
console.error('');
|
||||
console.error('[i] No OAuth URL was displayed. Try with --verbose for details.');
|
||||
}
|
||||
resolve(false);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -558,24 +582,76 @@ export async function triggerOAuth(
|
||||
clearTimeout(timeout);
|
||||
if (!headless) spinner.fail('Authentication error');
|
||||
console.error(`[X] Failed to start auth process: ${error.message}`);
|
||||
resolve(false);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register account from newly created token file
|
||||
* Scans auth directory for new token and extracts email
|
||||
*/
|
||||
function registerAccountFromToken(
|
||||
provider: CLIProxyProvider,
|
||||
tokenDir: string
|
||||
): AccountInfo | null {
|
||||
try {
|
||||
const files = fs.readdirSync(tokenDir);
|
||||
const jsonFiles = files.filter((f) => f.endsWith('.json'));
|
||||
|
||||
// Find newest token file for this provider
|
||||
let newestFile: string | null = null;
|
||||
let newestMtime = 0;
|
||||
|
||||
for (const file of jsonFiles) {
|
||||
const filePath = path.join(tokenDir, file);
|
||||
if (!isTokenFileForProvider(filePath, provider)) continue;
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
if (stats.mtimeMs > newestMtime) {
|
||||
newestMtime = stats.mtimeMs;
|
||||
newestFile = file;
|
||||
}
|
||||
}
|
||||
|
||||
if (!newestFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read token to extract email
|
||||
const tokenPath = path.join(tokenDir, newestFile);
|
||||
const content = fs.readFileSync(tokenPath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
const email = data.email || undefined;
|
||||
|
||||
// Register the account
|
||||
return registerAccount(provider, newestFile, email);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure provider is authenticated
|
||||
* Triggers OAuth flow if not authenticated
|
||||
* @param provider - The CLIProxy provider
|
||||
* @param options - Auth options including optional account
|
||||
* @returns true if authenticated, false otherwise
|
||||
*/
|
||||
export async function ensureAuth(
|
||||
provider: CLIProxyProvider,
|
||||
options: { verbose?: boolean; headless?: boolean } = {}
|
||||
options: { verbose?: boolean; headless?: boolean; account?: string } = {}
|
||||
): Promise<boolean> {
|
||||
// Check if already authenticated
|
||||
if (isAuthenticated(provider)) {
|
||||
if (options.verbose) {
|
||||
console.error(`[auth] ${provider} already authenticated`);
|
||||
}
|
||||
// Touch the account to update last used time
|
||||
const defaultAccount = getDefaultAccount(provider);
|
||||
if (defaultAccount) {
|
||||
touchAccount(provider, options.account || defaultAccount.id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -583,7 +659,16 @@ export async function ensureAuth(
|
||||
const oauthConfig = getOAuthConfig(provider);
|
||||
console.log(`[i] ${oauthConfig.displayName} authentication required`);
|
||||
|
||||
return triggerOAuth(provider, options);
|
||||
const account = await triggerOAuth(provider, options);
|
||||
return account !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize accounts registry from existing tokens
|
||||
* Should be called on startup to populate accounts from existing token files
|
||||
*/
|
||||
export function initializeAccounts(): void {
|
||||
discoverExistingAccounts();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface CLIProxyVariantConfig {
|
||||
provider: 'gemini' | 'codex' | 'agy' | 'qwen';
|
||||
/** Path to settings.json with custom model configuration */
|
||||
settings: string;
|
||||
/** Account identifier for multi-account support (optional, defaults to 'default') */
|
||||
account?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+159
-4
@@ -11,7 +11,14 @@ import { getCcsDir, getConfigPath, loadConfig, loadSettings } from '../utils/con
|
||||
import { Config, Settings } from '../types/config';
|
||||
import { expandPath } from '../utils/helpers';
|
||||
import { runHealthChecks, fixHealthIssue } from './health-service';
|
||||
import { getAllAuthStatus, getOAuthConfig } from '../cliproxy/auth-handler';
|
||||
import { getAllAuthStatus, getOAuthConfig, initializeAccounts } from '../cliproxy/auth-handler';
|
||||
import {
|
||||
getAllAccountsSummary,
|
||||
getProviderAccounts,
|
||||
setDefaultAccount as setDefaultAccountFn,
|
||||
removeAccount as removeAccountFn,
|
||||
} from '../cliproxy/account-manager';
|
||||
import type { CLIProxyProvider } from '../cliproxy/types';
|
||||
|
||||
export const apiRoutes = Router();
|
||||
|
||||
@@ -231,6 +238,7 @@ apiRoutes.get('/cliproxy', (_req: Request, res: Response) => {
|
||||
name,
|
||||
provider: variant.provider,
|
||||
settings: variant.settings,
|
||||
account: variant.account || 'default', // Include account field
|
||||
}));
|
||||
|
||||
res.json({ variants });
|
||||
@@ -240,7 +248,7 @@ apiRoutes.get('/cliproxy', (_req: Request, res: Response) => {
|
||||
* POST /api/cliproxy - Create cliproxy variant
|
||||
*/
|
||||
apiRoutes.post('/cliproxy', (req: Request, res: Response): void => {
|
||||
const { name, provider, model } = req.body;
|
||||
const { name, provider, model, account } = req.body;
|
||||
|
||||
if (!name || !provider) {
|
||||
res.status(400).json({ error: 'Missing required fields: name, provider' });
|
||||
@@ -263,10 +271,69 @@ apiRoutes.post('/cliproxy', (req: Request, res: Response): void => {
|
||||
// Create settings file for variant
|
||||
const settingsPath = createCliproxySettings(name, model);
|
||||
|
||||
config.cliproxy[name] = { provider, settings: settingsPath };
|
||||
// Include account if specified (defaults to 'default' if not provided)
|
||||
config.cliproxy[name] = {
|
||||
provider,
|
||||
settings: settingsPath,
|
||||
...(account && { account }),
|
||||
};
|
||||
writeConfig(config);
|
||||
|
||||
res.status(201).json({ name, provider, settings: settingsPath });
|
||||
res.status(201).json({ name, provider, settings: settingsPath, account: account || 'default' });
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/cliproxy/:name - Update cliproxy variant
|
||||
*/
|
||||
apiRoutes.put('/cliproxy/:name', (req: Request, res: Response): void => {
|
||||
const { name } = req.params;
|
||||
const { provider, account, model } = req.body;
|
||||
|
||||
const config = readConfigSafe();
|
||||
|
||||
if (!config.cliproxy?.[name]) {
|
||||
res.status(404).json({ error: 'Variant not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const variant = config.cliproxy[name];
|
||||
|
||||
// Update fields if provided
|
||||
if (provider) {
|
||||
variant.provider = provider;
|
||||
}
|
||||
if (account !== undefined) {
|
||||
if (account) {
|
||||
variant.account = account;
|
||||
} else {
|
||||
delete variant.account; // Remove account to use default
|
||||
}
|
||||
}
|
||||
|
||||
// Update model in settings file if provided
|
||||
if (model !== undefined) {
|
||||
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
const settings = loadSettings(settingsPath);
|
||||
if (model) {
|
||||
settings.env = settings.env || {};
|
||||
settings.env.ANTHROPIC_MODEL = model;
|
||||
} else if (settings.env) {
|
||||
delete settings.env.ANTHROPIC_MODEL;
|
||||
}
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
writeConfig(config);
|
||||
|
||||
res.json({
|
||||
name,
|
||||
provider: variant.provider,
|
||||
account: variant.account || 'default',
|
||||
settings: variant.settings,
|
||||
updated: true,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -298,6 +365,9 @@ apiRoutes.delete('/cliproxy/:name', (req: Request, res: Response): void => {
|
||||
* GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles
|
||||
*/
|
||||
apiRoutes.get('/cliproxy/auth', (_req: Request, res: Response) => {
|
||||
// Initialize accounts from existing tokens on first request
|
||||
initializeAccounts();
|
||||
|
||||
const statuses = getAllAuthStatus();
|
||||
|
||||
const authStatus = statuses.map((status) => {
|
||||
@@ -308,12 +378,97 @@ apiRoutes.get('/cliproxy/auth', (_req: Request, res: Response) => {
|
||||
authenticated: status.authenticated,
|
||||
lastAuth: status.lastAuth?.toISOString() || null,
|
||||
tokenFiles: status.tokenFiles.length,
|
||||
accounts: status.accounts,
|
||||
defaultAccount: status.defaultAccount,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ authStatus });
|
||||
});
|
||||
|
||||
// ==================== Account Management (Multi-Account Support) ====================
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/accounts - Get all accounts across all providers
|
||||
*/
|
||||
apiRoutes.get('/cliproxy/accounts', (_req: Request, res: Response) => {
|
||||
// Initialize accounts from existing tokens
|
||||
initializeAccounts();
|
||||
|
||||
const accounts = getAllAccountsSummary();
|
||||
res.json({ accounts });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/accounts/:provider - Get accounts for a specific provider
|
||||
*/
|
||||
apiRoutes.get('/cliproxy/accounts/:provider', (req: Request, res: Response): void => {
|
||||
const { provider } = req.params;
|
||||
|
||||
// Validate provider
|
||||
const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
|
||||
if (!validProviders.includes(provider as CLIProxyProvider)) {
|
||||
res.status(400).json({ error: `Invalid provider: ${provider}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const accounts = getProviderAccounts(provider as CLIProxyProvider);
|
||||
res.json({ provider, accounts });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cliproxy/accounts/:provider/default - Set default account for provider
|
||||
*/
|
||||
apiRoutes.post('/cliproxy/accounts/:provider/default', (req: Request, res: Response): void => {
|
||||
const { provider } = req.params;
|
||||
const { accountId } = req.body;
|
||||
|
||||
if (!accountId) {
|
||||
res.status(400).json({ error: 'Missing required field: accountId' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate provider
|
||||
const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
|
||||
if (!validProviders.includes(provider as CLIProxyProvider)) {
|
||||
res.status(400).json({ error: `Invalid provider: ${provider}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const success = setDefaultAccountFn(provider as CLIProxyProvider, accountId);
|
||||
|
||||
if (success) {
|
||||
res.json({ provider, defaultAccount: accountId });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Account not found' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/cliproxy/accounts/:provider/:accountId - Remove an account
|
||||
*/
|
||||
apiRoutes.delete(
|
||||
'/api/cliproxy/accounts/:provider/:accountId',
|
||||
(req: Request, res: Response): void => {
|
||||
const { provider, accountId } = req.params;
|
||||
|
||||
// Validate provider
|
||||
const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow'];
|
||||
if (!validProviders.includes(provider as CLIProxyProvider)) {
|
||||
res.status(400).json({ error: `Invalid provider: ${provider}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const success = removeAccountFn(provider as CLIProxyProvider, accountId);
|
||||
|
||||
if (success) {
|
||||
res.json({ provider, accountId, deleted: true });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Account not found' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ==================== Settings (Phase 05) ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
/**
|
||||
* CLIProxy Variant Dialog Component
|
||||
* Phase 03: REST API Routes & CRUD
|
||||
* Phase 06: Multi-Account Support
|
||||
*/
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useCreateVariant } from '@/hooks/use-cliproxy';
|
||||
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||
|
||||
const providers = ['gemini', 'codex', 'agy', 'qwen'] as const;
|
||||
const providers = ['gemini', 'codex', 'agy', 'qwen', 'iflow'] as const;
|
||||
|
||||
const schema = z.object({
|
||||
name: z
|
||||
@@ -21,6 +22,7 @@ const schema = z.object({
|
||||
.regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid variant name'),
|
||||
provider: z.enum(providers, { message: 'Provider is required' }),
|
||||
model: z.string().optional(),
|
||||
account: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
@@ -35,20 +37,30 @@ const providerOptions = [
|
||||
{ value: 'codex', label: 'OpenAI Codex' },
|
||||
{ value: 'agy', label: 'Antigravity' },
|
||||
{ value: 'qwen', label: 'Alibaba Qwen' },
|
||||
{ value: 'iflow', label: 'iFlow' },
|
||||
];
|
||||
|
||||
export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
||||
const createMutation = useCreateVariant();
|
||||
const { data: authData } = useCliproxyAuth();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors },
|
||||
reset,
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
// Watch provider to show relevant accounts
|
||||
const selectedProvider = useWatch({ control, name: 'provider' });
|
||||
|
||||
// Get accounts for selected provider
|
||||
const providerAuth = authData?.authStatus.find((s) => s.provider === selectedProvider);
|
||||
const providerAccounts = providerAuth?.accounts || [];
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
await createMutation.mutateAsync(data);
|
||||
@@ -91,6 +103,38 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Account selector - only show if provider has accounts */}
|
||||
{selectedProvider && providerAccounts.length > 0 && (
|
||||
<div>
|
||||
<Label htmlFor="account">Account</Label>
|
||||
<select
|
||||
id="account"
|
||||
{...register('account')}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">Use default account</option>
|
||||
{providerAccounts.map((acc) => (
|
||||
<option key={acc.id} value={acc.id}>
|
||||
{acc.email || acc.id}
|
||||
{acc.isDefault ? ' (default)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-muted-foreground mt-1 block">
|
||||
Select which OAuth account this variant should use
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show message if provider selected but no accounts */}
|
||||
{selectedProvider && providerAccounts.length === 0 && providerAuth && (
|
||||
<div className="text-sm text-amber-600 bg-amber-50 dark:bg-amber-950/20 p-3 rounded-md">
|
||||
No accounts authenticated for {providerAuth.displayName}.
|
||||
<br />
|
||||
<code className="text-xs bg-muted px-1 rounded">ccs {selectedProvider} --auth</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="model">Model (optional)</Label>
|
||||
<Input id="model" {...register('model')} placeholder="gemini-2.5-pro" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* CLIProxy Variants Table Component
|
||||
* Phase 03: REST API Routes & CRUD
|
||||
* Phase 06: Multi-Account Support
|
||||
*/
|
||||
|
||||
import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table';
|
||||
@@ -13,13 +14,14 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { MoreHorizontal, Trash2 } from 'lucide-react';
|
||||
import { MoreHorizontal, Trash2, User } from 'lucide-react';
|
||||
import { useDeleteVariant } from '@/hooks/use-cliproxy';
|
||||
import type { Variant } from '@/lib/api-client';
|
||||
|
||||
@@ -32,6 +34,7 @@ const providerLabels: Record<string, string> = {
|
||||
codex: 'OpenAI Codex',
|
||||
agy: 'Antigravity',
|
||||
qwen: 'Alibaba Qwen',
|
||||
iflow: 'iFlow',
|
||||
};
|
||||
|
||||
export function CliproxyTable({ data }: CliproxyTableProps) {
|
||||
@@ -47,6 +50,22 @@ export function CliproxyTable({ data }: CliproxyTableProps) {
|
||||
header: 'Provider',
|
||||
cell: ({ row }) => providerLabels[row.original.provider] || row.original.provider,
|
||||
},
|
||||
{
|
||||
accessorKey: 'account',
|
||||
header: 'Account',
|
||||
cell: ({ row }) => {
|
||||
const account = row.original.account;
|
||||
if (!account) {
|
||||
return <span className="text-muted-foreground text-xs">default</span>;
|
||||
}
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs font-normal">
|
||||
<User className="w-3 h-3 mr-1" />
|
||||
{account}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'settings',
|
||||
header: 'Settings Path',
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge };
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* React Query hooks for CLIProxy variants
|
||||
* React Query hooks for CLIProxy variants and accounts
|
||||
* Phase 03: REST API Routes & CRUD
|
||||
* Phase 06: Multi-Account Management
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type CreateVariant } from '@/lib/api-client';
|
||||
import { api, type CreateVariant, type UpdateVariant } from '@/lib/api-client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function useCliproxy() {
|
||||
@@ -36,6 +37,22 @@ export function useCreateVariant() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateVariant() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ name, data }: { name: string; data: UpdateVariant }) =>
|
||||
api.cliproxy.update(name, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy'] });
|
||||
toast.success('Variant updated successfully');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteVariant() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -50,3 +67,53 @@ export function useDeleteVariant() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Multi-account management hooks
|
||||
export function useCliproxyAccounts() {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy-accounts'],
|
||||
queryFn: () => api.cliproxy.accounts.list(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useProviderAccounts(provider: string) {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy-accounts', provider],
|
||||
queryFn: () => api.cliproxy.accounts.listByProvider(provider),
|
||||
enabled: !!provider,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetDefaultAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) =>
|
||||
api.cliproxy.accounts.setDefault(provider, accountId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
toast.success('Default account updated');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) =>
|
||||
api.cliproxy.accounts.remove(provider, accountId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
toast.success('Account removed');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,14 +41,33 @@ export interface UpdateProfile {
|
||||
|
||||
export interface Variant {
|
||||
name: string;
|
||||
provider: 'gemini' | 'codex' | 'agy' | 'qwen';
|
||||
provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow';
|
||||
settings: string;
|
||||
account?: string;
|
||||
}
|
||||
|
||||
export interface CreateVariant {
|
||||
name: string;
|
||||
provider: 'gemini' | 'codex' | 'agy' | 'qwen';
|
||||
provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow';
|
||||
model?: string;
|
||||
account?: string;
|
||||
}
|
||||
|
||||
export interface UpdateVariant {
|
||||
provider?: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow';
|
||||
model?: string;
|
||||
account?: string;
|
||||
}
|
||||
|
||||
/** OAuth account info for multi-account support */
|
||||
export interface OAuthAccount {
|
||||
id: string;
|
||||
email?: string;
|
||||
provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow';
|
||||
isDefault: boolean;
|
||||
tokenFile: string;
|
||||
createdAt: string;
|
||||
lastUsedAt?: string;
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
@@ -57,8 +76,13 @@ export interface AuthStatus {
|
||||
authenticated: boolean;
|
||||
lastAuth: string | null;
|
||||
tokenFiles: number;
|
||||
accounts: OAuthAccount[];
|
||||
defaultAccount?: string;
|
||||
}
|
||||
|
||||
/** Provider accounts summary */
|
||||
export type ProviderAccountsMap = Record<string, OAuthAccount[]>;
|
||||
|
||||
export interface Account {
|
||||
name: string;
|
||||
type?: string;
|
||||
@@ -90,7 +114,25 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
update: (name: string, data: UpdateVariant) =>
|
||||
request(`/cliproxy/${name}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
delete: (name: string) => request(`/cliproxy/${name}`, { method: 'DELETE' }),
|
||||
// Multi-account management
|
||||
accounts: {
|
||||
list: () => request<{ accounts: ProviderAccountsMap }>('/cliproxy/accounts'),
|
||||
listByProvider: (provider: string) =>
|
||||
request<{ provider: string; accounts: OAuthAccount[] }>(`/cliproxy/accounts/${provider}`),
|
||||
setDefault: (provider: string, accountId: string) =>
|
||||
request(`/cliproxy/accounts/${provider}/default`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ accountId }),
|
||||
}),
|
||||
remove: (provider: string, accountId: string) =>
|
||||
request(`/cliproxy/accounts/${provider}/${accountId}`, { method: 'DELETE' }),
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
list: () => request<{ accounts: Account[]; default: string | null }>('/accounts'),
|
||||
|
||||
+170
-41
@@ -1,19 +1,179 @@
|
||||
/**
|
||||
* CLIProxy Page
|
||||
* Phase 03: REST API Routes & CRUD
|
||||
* Phase 06: Multi-Account Management
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus, Check, X } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Plus, Check, X, User, ChevronDown, Star, Trash2 } from 'lucide-react';
|
||||
import { CliproxyTable } from '@/components/cliproxy-table';
|
||||
import { CliproxyDialog } from '@/components/cliproxy-dialog';
|
||||
import { useCliproxy, useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||
import {
|
||||
useCliproxy,
|
||||
useCliproxyAuth,
|
||||
useSetDefaultAccount,
|
||||
useRemoveAccount,
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import type { OAuthAccount, AuthStatus } from '@/lib/api-client';
|
||||
|
||||
function AccountBadge({
|
||||
account,
|
||||
onSetDefault,
|
||||
onRemove,
|
||||
isRemoving,
|
||||
}: {
|
||||
account: OAuthAccount;
|
||||
onSetDefault: () => void;
|
||||
onRemove: () => void;
|
||||
isRemoving: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-1 text-xs rounded-md border transition-colors hover:bg-muted/50 ${
|
||||
account.isDefault
|
||||
? 'border-primary/30 bg-primary/5 text-primary'
|
||||
: 'border-muted bg-muted/20'
|
||||
}`}
|
||||
>
|
||||
<User className="w-3 h-3" />
|
||||
<span className="max-w-[120px] truncate">{account.email || account.id}</span>
|
||||
{account.isDefault && <Star className="w-3 h-3 fill-current" />}
|
||||
<ChevronDown className="w-3 h-3 opacity-50" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{account.email || account.id}
|
||||
{account.lastUsedAt && (
|
||||
<div className="mt-0.5">
|
||||
Last used: {new Date(account.lastUsedAt).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
{!account.isDefault && (
|
||||
<DropdownMenuItem onClick={onSetDefault}>
|
||||
<Star className="w-4 h-4 mr-2" />
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-red-600 focus:text-red-600"
|
||||
onClick={onRemove}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{isRemoving ? 'Removing...' : 'Remove account'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderCard({
|
||||
status,
|
||||
setDefaultMutation,
|
||||
removeMutation,
|
||||
}: {
|
||||
status: AuthStatus;
|
||||
setDefaultMutation: ReturnType<typeof useSetDefaultAccount>;
|
||||
removeMutation: ReturnType<typeof useRemoveAccount>;
|
||||
}) {
|
||||
const accounts = status.accounts || [];
|
||||
const hasMultipleAccounts = accounts.length > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`p-4 rounded-lg border ${
|
||||
status.authenticated ? 'border-green-500/30 bg-green-500/5' : 'border-muted bg-muted/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{status.authenticated ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<X className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="font-medium">{status.displayName}</span>
|
||||
</div>
|
||||
{accounts.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{accounts.length} account{accounts.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status.authenticated ? (
|
||||
<div className="mt-2">
|
||||
{accounts.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{accounts.map((account) => (
|
||||
<AccountBadge
|
||||
key={account.id}
|
||||
account={account}
|
||||
onSetDefault={() =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: status.provider,
|
||||
accountId: account.id,
|
||||
})
|
||||
}
|
||||
onRemove={() =>
|
||||
removeMutation.mutate({
|
||||
provider: status.provider,
|
||||
accountId: account.id,
|
||||
})
|
||||
}
|
||||
isRemoving={removeMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Authenticated
|
||||
{status.lastAuth && (
|
||||
<span className="ml-1">({new Date(status.lastAuth).toLocaleDateString()})</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasMultipleAccounts && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Click account to manage. Star = default.
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Add account: <code className="bg-muted px-1 rounded">ccs {status.provider} --auth</code>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
Not authenticated
|
||||
<span className="block text-xs mt-1">
|
||||
Run: <code className="bg-muted px-1 rounded">ccs {status.provider} --auth</code>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CliproxyPage() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const { data, isLoading } = useCliproxy();
|
||||
const { data: authData, isLoading: authLoading } = useCliproxyAuth();
|
||||
const setDefaultMutation = useSetDefaultAccount();
|
||||
const removeMutation = useRemoveAccount();
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
@@ -21,7 +181,7 @@ export function CliproxyPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">CLIProxy</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage OAuth-based provider variants (Gemini, Codex, Antigravity, Qwen)
|
||||
Manage OAuth-based provider variants with multi-account support
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setDialogOpen(true)}>
|
||||
@@ -30,51 +190,20 @@ export function CliproxyPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Built-in Profiles Auth Status */}
|
||||
{/* Built-in Profiles with Account Management */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">Built-in Profiles</h2>
|
||||
<h2 className="text-lg font-semibold mb-3">Built-in Profiles & Accounts</h2>
|
||||
{authLoading ? (
|
||||
<div className="text-muted-foreground">Loading auth status...</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{authData?.authStatus.map((status) => (
|
||||
<div
|
||||
<ProviderCard
|
||||
key={status.provider}
|
||||
className={`p-4 rounded-lg border ${
|
||||
status.authenticated
|
||||
? 'border-green-500/30 bg-green-500/5'
|
||||
: 'border-muted bg-muted/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{status.authenticated ? (
|
||||
<Check className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<X className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="font-medium">{status.displayName}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">
|
||||
{status.authenticated ? (
|
||||
<>
|
||||
Authenticated
|
||||
{status.lastAuth && (
|
||||
<span className="ml-1">
|
||||
({new Date(status.lastAuth).toLocaleDateString()})
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Not authenticated
|
||||
<span className="block text-xs mt-1">
|
||||
Run:{' '}
|
||||
<code className="bg-muted px-1 rounded">ccs {status.provider} --auth</code>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
status={status}
|
||||
setDefaultMutation={setDefaultMutation}
|
||||
removeMutation={removeMutation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user