diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts new file mode 100644 index 00000000..62735f32 --- /dev/null +++ b/src/cliproxy/account-manager.ts @@ -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>; +} + +/** Accounts registry structure */ +interface AccountsRegistry { + /** Version for future migrations */ + version: number; + /** Accounts organized by provider */ + providers: Partial>; +} + +/** 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 { + const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow']; + const summary: Record = {} as Record< + CLIProxyProvider, + AccountInfo[] + >; + + for (const provider of providers) { + summary[provider] = getProviderAccounts(provider); + } + + return summary; +} diff --git a/src/cliproxy/auth-handler.ts b/src/cliproxy/auth-handler.ts index 512303ce..b854312f 100644 --- a/src/cliproxy/auth-handler.ts +++ b/src/cliproxy/auth-handler.ts @@ -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 { + options: { verbose?: boolean; headless?: boolean; account?: string } = {} +): Promise { const oauthConfig = getOAuthConfig(provider); const { verbose = false } = options; @@ -451,7 +472,7 @@ export async function triggerOAuth( spinner.start(); } - return new Promise((resolve) => { + return new Promise((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 { // 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(); } /** diff --git a/src/types/config.ts b/src/types/config.ts index decad645..3ff6e2dc 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -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; } /** diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 1e2fc971..26aedfdf 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -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) ==================== /** diff --git a/ui/src/components/cliproxy-dialog.tsx b/ui/src/components/cliproxy-dialog.tsx index 0b629370..e91c1755 100644 --- a/ui/src/components/cliproxy-dialog.tsx +++ b/ui/src/components/cliproxy-dialog.tsx @@ -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; @@ -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({ 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) { )} + {/* Account selector - only show if provider has accounts */} + {selectedProvider && providerAccounts.length > 0 && ( +
+ + + + Select which OAuth account this variant should use + +
+ )} + + {/* Show message if provider selected but no accounts */} + {selectedProvider && providerAccounts.length === 0 && providerAuth && ( +
+ No accounts authenticated for {providerAuth.displayName}. +
+ ccs {selectedProvider} --auth +
+ )} +
diff --git a/ui/src/components/cliproxy-table.tsx b/ui/src/components/cliproxy-table.tsx index 75e4c9ae..af467c4b 100644 --- a/ui/src/components/cliproxy-table.tsx +++ b/ui/src/components/cliproxy-table.tsx @@ -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 = { 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 default; + } + return ( + + + {account} + + ); + }, + }, { accessorKey: 'settings', header: 'Settings Path', diff --git a/ui/src/components/ui/badge.tsx b/ui/src/components/ui/badge.tsx new file mode 100644 index 00000000..ab895f8e --- /dev/null +++ b/ui/src/components/ui/badge.tsx @@ -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, VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge }; diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index 492463a0..4978b583 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -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); + }, + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index b9399847..62d63d3c 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -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; + 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'), diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 7620ee40..56b6cbbc 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -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 ( + + + + + +
+ {account.email || account.id} + {account.lastUsedAt && ( +
+ Last used: {new Date(account.lastUsedAt).toLocaleDateString()} +
+ )} +
+ + {!account.isDefault && ( + + + Set as default + + )} + + + {isRemoving ? 'Removing...' : 'Remove account'} + +
+
+ ); +} + +function ProviderCard({ + status, + setDefaultMutation, + removeMutation, +}: { + status: AuthStatus; + setDefaultMutation: ReturnType; + removeMutation: ReturnType; +}) { + const accounts = status.accounts || []; + const hasMultipleAccounts = accounts.length > 1; + + return ( +
+
+
+ {status.authenticated ? ( + + ) : ( + + )} + {status.displayName} +
+ {accounts.length > 0 && ( + + {accounts.length} account{accounts.length !== 1 ? 's' : ''} + + )} +
+ + {status.authenticated ? ( +
+ {accounts.length > 0 ? ( +
+ {accounts.map((account) => ( + + setDefaultMutation.mutate({ + provider: status.provider, + accountId: account.id, + }) + } + onRemove={() => + removeMutation.mutate({ + provider: status.provider, + accountId: account.id, + }) + } + isRemoving={removeMutation.isPending} + /> + ))} +
+ ) : ( +
+ Authenticated + {status.lastAuth && ( + ({new Date(status.lastAuth).toLocaleDateString()}) + )} +
+ )} + {hasMultipleAccounts && ( +
+ Click account to manage. Star = default. +
+ )} +
+ Add account: ccs {status.provider} --auth +
+
+ ) : ( +
+ Not authenticated + + Run: ccs {status.provider} --auth + +
+ )} +
+ ); +} 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 (
@@ -21,7 +181,7 @@ export function CliproxyPage() {

CLIProxy

- Manage OAuth-based provider variants (Gemini, Codex, Antigravity, Qwen) + Manage OAuth-based provider variants with multi-account support

- {/* Built-in Profiles Auth Status */} + {/* Built-in Profiles with Account Management */}
-

Built-in Profiles

+

Built-in Profiles & Accounts

{authLoading ? (
Loading auth status...
) : (
{authData?.authStatus.map((status) => ( -
-
- {status.authenticated ? ( - - ) : ( - - )} - {status.displayName} -
-
- {status.authenticated ? ( - <> - Authenticated - {status.lastAuth && ( - - ({new Date(status.lastAuth).toLocaleDateString()}) - - )} - - ) : ( - <> - Not authenticated - - Run:{' '} - ccs {status.provider} --auth - - - )} -
-
+ status={status} + setDefaultMutation={setDefaultMutation} + removeMutation={removeMutation} + /> ))}
)}