mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-06 18:22:08 +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 { ensureCLIProxyBinary } from './binary-manager';
|
||||||
import { generateConfig, getProviderAuthDir } from './config-generator';
|
import { generateConfig, getProviderAuthDir } from './config-generator';
|
||||||
import { CLIProxyProvider } from './types';
|
import { CLIProxyProvider } from './types';
|
||||||
|
import {
|
||||||
|
AccountInfo,
|
||||||
|
discoverExistingAccounts,
|
||||||
|
getDefaultAccount,
|
||||||
|
getProviderAccounts,
|
||||||
|
registerAccount,
|
||||||
|
touchAccount,
|
||||||
|
} from './account-manager';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OAuth callback ports used by CLIProxyAPI (hardcoded in binary)
|
* OAuth callback ports used by CLIProxyAPI (hardcoded in binary)
|
||||||
@@ -118,6 +126,10 @@ export interface AuthStatus {
|
|||||||
tokenFiles: string[];
|
tokenFiles: string[];
|
||||||
/** When last authenticated (if known) */
|
/** When last authenticated (if known) */
|
||||||
lastAuth?: Date;
|
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 {
|
return {
|
||||||
provider,
|
provider,
|
||||||
authenticated: tokenFiles.length > 0,
|
authenticated: tokenFiles.length > 0,
|
||||||
tokenDir,
|
tokenDir,
|
||||||
tokenFiles,
|
tokenFiles,
|
||||||
lastAuth,
|
lastAuth,
|
||||||
|
accounts,
|
||||||
|
defaultAccount: defaultAccount?.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,11 +402,14 @@ export function clearAuth(provider: CLIProxyProvider): boolean {
|
|||||||
/**
|
/**
|
||||||
* 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
|
||||||
|
* @param provider - The CLIProxy provider to authenticate
|
||||||
|
* @param options - OAuth options
|
||||||
|
* @returns Account info if successful, null otherwise
|
||||||
*/
|
*/
|
||||||
export async function triggerOAuth(
|
export async function triggerOAuth(
|
||||||
provider: CLIProxyProvider,
|
provider: CLIProxyProvider,
|
||||||
options: { verbose?: boolean; headless?: boolean } = {}
|
options: { verbose?: boolean; headless?: boolean; account?: string } = {}
|
||||||
): Promise<boolean> {
|
): Promise<AccountInfo | null> {
|
||||||
const oauthConfig = getOAuthConfig(provider);
|
const oauthConfig = getOAuthConfig(provider);
|
||||||
const { verbose = false } = options;
|
const { verbose = false } = options;
|
||||||
|
|
||||||
@@ -451,7 +472,7 @@ export async function triggerOAuth(
|
|||||||
spinner.start();
|
spinner.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<AccountInfo | null>((resolve) => {
|
||||||
// Spawn CLIProxyAPI with auth flag (and --no-browser if headless)
|
// Spawn CLIProxyAPI with auth flag (and --no-browser if headless)
|
||||||
const authProcess = spawn(binaryPath, args, {
|
const authProcess = spawn(binaryPath, args, {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
@@ -513,7 +534,7 @@ export async function triggerOAuth(
|
|||||||
console.error(' - Make sure a browser is available');
|
console.error(' - Make sure a browser is available');
|
||||||
console.error(' - Try running with --verbose for details');
|
console.error(' - Try running with --verbose for details');
|
||||||
}
|
}
|
||||||
resolve(false);
|
resolve(null);
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|
||||||
authProcess.on('exit', (code) => {
|
authProcess.on('exit', (code) => {
|
||||||
@@ -524,7 +545,10 @@ export async function triggerOAuth(
|
|||||||
if (isAuthenticated(provider)) {
|
if (isAuthenticated(provider)) {
|
||||||
if (!headless) spinner.succeed(`Authenticated with ${oauthConfig.displayName}`);
|
if (!headless) spinner.succeed(`Authenticated with ${oauthConfig.displayName}`);
|
||||||
console.log('[OK] Authentication successful');
|
console.log('[OK] Authentication successful');
|
||||||
resolve(true);
|
|
||||||
|
// Register the account in accounts registry
|
||||||
|
const account = registerAccountFromToken(provider, tokenDir);
|
||||||
|
resolve(account);
|
||||||
} else {
|
} else {
|
||||||
if (!headless) spinner.fail('Authentication incomplete');
|
if (!headless) spinner.fail('Authentication incomplete');
|
||||||
console.error('[X] Token not found after authentication');
|
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(' The OAuth flow may have been cancelled or callback port was in use');
|
||||||
console.error(` Try: pkill -f cli-proxy-api && ccs ${provider} --auth`);
|
console.error(` Try: pkill -f cli-proxy-api && ccs ${provider} --auth`);
|
||||||
}
|
}
|
||||||
resolve(false);
|
resolve(null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!headless) spinner.fail('Authentication failed');
|
if (!headless) spinner.fail('Authentication failed');
|
||||||
@@ -550,7 +574,7 @@ export async function triggerOAuth(
|
|||||||
console.error('');
|
console.error('');
|
||||||
console.error('[i] No OAuth URL was displayed. Try with --verbose for details.');
|
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);
|
clearTimeout(timeout);
|
||||||
if (!headless) spinner.fail('Authentication error');
|
if (!headless) spinner.fail('Authentication error');
|
||||||
console.error(`[X] Failed to start auth process: ${error.message}`);
|
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
|
* Ensure provider is authenticated
|
||||||
* Triggers OAuth flow if not 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(
|
export async function ensureAuth(
|
||||||
provider: CLIProxyProvider,
|
provider: CLIProxyProvider,
|
||||||
options: { verbose?: boolean; headless?: boolean } = {}
|
options: { verbose?: boolean; headless?: boolean; account?: string } = {}
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
// Check if already authenticated
|
// Check if already authenticated
|
||||||
if (isAuthenticated(provider)) {
|
if (isAuthenticated(provider)) {
|
||||||
if (options.verbose) {
|
if (options.verbose) {
|
||||||
console.error(`[auth] ${provider} already authenticated`);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,7 +659,16 @@ export async function ensureAuth(
|
|||||||
const oauthConfig = getOAuthConfig(provider);
|
const oauthConfig = getOAuthConfig(provider);
|
||||||
console.log(`[i] ${oauthConfig.displayName} authentication required`);
|
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';
|
provider: 'gemini' | 'codex' | 'agy' | 'qwen';
|
||||||
/** Path to settings.json with custom model configuration */
|
/** Path to settings.json with custom model configuration */
|
||||||
settings: string;
|
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 { Config, Settings } from '../types/config';
|
||||||
import { expandPath } from '../utils/helpers';
|
import { expandPath } from '../utils/helpers';
|
||||||
import { runHealthChecks, fixHealthIssue } from './health-service';
|
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();
|
export const apiRoutes = Router();
|
||||||
|
|
||||||
@@ -231,6 +238,7 @@ apiRoutes.get('/cliproxy', (_req: Request, res: Response) => {
|
|||||||
name,
|
name,
|
||||||
provider: variant.provider,
|
provider: variant.provider,
|
||||||
settings: variant.settings,
|
settings: variant.settings,
|
||||||
|
account: variant.account || 'default', // Include account field
|
||||||
}));
|
}));
|
||||||
|
|
||||||
res.json({ variants });
|
res.json({ variants });
|
||||||
@@ -240,7 +248,7 @@ apiRoutes.get('/cliproxy', (_req: Request, res: Response) => {
|
|||||||
* POST /api/cliproxy - Create cliproxy variant
|
* POST /api/cliproxy - Create cliproxy variant
|
||||||
*/
|
*/
|
||||||
apiRoutes.post('/cliproxy', (req: Request, res: Response): void => {
|
apiRoutes.post('/cliproxy', (req: Request, res: Response): void => {
|
||||||
const { name, provider, model } = req.body;
|
const { name, provider, model, account } = req.body;
|
||||||
|
|
||||||
if (!name || !provider) {
|
if (!name || !provider) {
|
||||||
res.status(400).json({ error: 'Missing required fields: 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
|
// Create settings file for variant
|
||||||
const settingsPath = createCliproxySettings(name, model);
|
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);
|
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
|
* GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles
|
||||||
*/
|
*/
|
||||||
apiRoutes.get('/cliproxy/auth', (_req: Request, res: Response) => {
|
apiRoutes.get('/cliproxy/auth', (_req: Request, res: Response) => {
|
||||||
|
// Initialize accounts from existing tokens on first request
|
||||||
|
initializeAccounts();
|
||||||
|
|
||||||
const statuses = getAllAuthStatus();
|
const statuses = getAllAuthStatus();
|
||||||
|
|
||||||
const authStatus = statuses.map((status) => {
|
const authStatus = statuses.map((status) => {
|
||||||
@@ -308,12 +378,97 @@ apiRoutes.get('/cliproxy/auth', (_req: Request, res: Response) => {
|
|||||||
authenticated: status.authenticated,
|
authenticated: status.authenticated,
|
||||||
lastAuth: status.lastAuth?.toISOString() || null,
|
lastAuth: status.lastAuth?.toISOString() || null,
|
||||||
tokenFiles: status.tokenFiles.length,
|
tokenFiles: status.tokenFiles.length,
|
||||||
|
accounts: status.accounts,
|
||||||
|
defaultAccount: status.defaultAccount,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ authStatus });
|
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) ====================
|
// ==================== Settings (Phase 05) ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* CLIProxy Variant Dialog Component
|
* CLIProxy Variant Dialog Component
|
||||||
* Phase 03: REST API Routes & CRUD
|
* 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 { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
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 { 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({
|
const schema = z.object({
|
||||||
name: z
|
name: z
|
||||||
@@ -21,6 +22,7 @@ const schema = z.object({
|
|||||||
.regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid variant name'),
|
.regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid variant name'),
|
||||||
provider: z.enum(providers, { message: 'Provider is required' }),
|
provider: z.enum(providers, { message: 'Provider is required' }),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
|
account: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormData = z.infer<typeof schema>;
|
type FormData = z.infer<typeof schema>;
|
||||||
@@ -35,20 +37,30 @@ const providerOptions = [
|
|||||||
{ value: 'codex', label: 'OpenAI Codex' },
|
{ value: 'codex', label: 'OpenAI Codex' },
|
||||||
{ value: 'agy', label: 'Antigravity' },
|
{ value: 'agy', label: 'Antigravity' },
|
||||||
{ value: 'qwen', label: 'Alibaba Qwen' },
|
{ value: 'qwen', label: 'Alibaba Qwen' },
|
||||||
|
{ value: 'iflow', label: 'iFlow' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
||||||
const createMutation = useCreateVariant();
|
const createMutation = useCreateVariant();
|
||||||
|
const { data: authData } = useCliproxyAuth();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
control,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
reset,
|
reset,
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(schema),
|
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) => {
|
const onSubmit = async (data: FormData) => {
|
||||||
try {
|
try {
|
||||||
await createMutation.mutateAsync(data);
|
await createMutation.mutateAsync(data);
|
||||||
@@ -91,6 +103,38 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<Label htmlFor="model">Model (optional)</Label>
|
<Label htmlFor="model">Model (optional)</Label>
|
||||||
<Input id="model" {...register('model')} placeholder="gemini-2.5-pro" />
|
<Input id="model" {...register('model')} placeholder="gemini-2.5-pro" />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* CLIProxy Variants Table Component
|
* CLIProxy Variants Table Component
|
||||||
* Phase 03: REST API Routes & CRUD
|
* Phase 03: REST API Routes & CRUD
|
||||||
|
* Phase 06: Multi-Account Support
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table';
|
import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table';
|
||||||
@@ -13,13 +14,14 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} 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 { useDeleteVariant } from '@/hooks/use-cliproxy';
|
||||||
import type { Variant } from '@/lib/api-client';
|
import type { Variant } from '@/lib/api-client';
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ const providerLabels: Record<string, string> = {
|
|||||||
codex: 'OpenAI Codex',
|
codex: 'OpenAI Codex',
|
||||||
agy: 'Antigravity',
|
agy: 'Antigravity',
|
||||||
qwen: 'Alibaba Qwen',
|
qwen: 'Alibaba Qwen',
|
||||||
|
iflow: 'iFlow',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CliproxyTable({ data }: CliproxyTableProps) {
|
export function CliproxyTable({ data }: CliproxyTableProps) {
|
||||||
@@ -47,6 +50,22 @@ export function CliproxyTable({ data }: CliproxyTableProps) {
|
|||||||
header: 'Provider',
|
header: 'Provider',
|
||||||
cell: ({ row }) => providerLabels[row.original.provider] || row.original.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',
|
accessorKey: 'settings',
|
||||||
header: 'Settings Path',
|
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 03: REST API Routes & CRUD
|
||||||
|
* Phase 06: Multi-Account Management
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export function useCliproxy() {
|
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() {
|
export function useDeleteVariant() {
|
||||||
const queryClient = useQueryClient();
|
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 {
|
export interface Variant {
|
||||||
name: string;
|
name: string;
|
||||||
provider: 'gemini' | 'codex' | 'agy' | 'qwen';
|
provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow';
|
||||||
settings: string;
|
settings: string;
|
||||||
|
account?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateVariant {
|
export interface CreateVariant {
|
||||||
name: string;
|
name: string;
|
||||||
provider: 'gemini' | 'codex' | 'agy' | 'qwen';
|
provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow';
|
||||||
model?: string;
|
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 {
|
export interface AuthStatus {
|
||||||
@@ -57,8 +76,13 @@ export interface AuthStatus {
|
|||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
lastAuth: string | null;
|
lastAuth: string | null;
|
||||||
tokenFiles: number;
|
tokenFiles: number;
|
||||||
|
accounts: OAuthAccount[];
|
||||||
|
defaultAccount?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Provider accounts summary */
|
||||||
|
export type ProviderAccountsMap = Record<string, OAuthAccount[]>;
|
||||||
|
|
||||||
export interface Account {
|
export interface Account {
|
||||||
name: string;
|
name: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -90,7 +114,25 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
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' }),
|
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: {
|
accounts: {
|
||||||
list: () => request<{ accounts: Account[]; default: string | null }>('/accounts'),
|
list: () => request<{ accounts: Account[]; default: string | null }>('/accounts'),
|
||||||
|
|||||||
+170
-41
@@ -1,19 +1,179 @@
|
|||||||
/**
|
/**
|
||||||
* CLIProxy Page
|
* CLIProxy Page
|
||||||
* Phase 03: REST API Routes & CRUD
|
* Phase 03: REST API Routes & CRUD
|
||||||
|
* Phase 06: Multi-Account Management
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { CliproxyTable } from '@/components/cliproxy-table';
|
||||||
import { CliproxyDialog } from '@/components/cliproxy-dialog';
|
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() {
|
export function CliproxyPage() {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const { data, isLoading } = useCliproxy();
|
const { data, isLoading } = useCliproxy();
|
||||||
const { data: authData, isLoading: authLoading } = useCliproxyAuth();
|
const { data: authData, isLoading: authLoading } = useCliproxyAuth();
|
||||||
|
const setDefaultMutation = useSetDefaultAccount();
|
||||||
|
const removeMutation = useRemoveAccount();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
@@ -21,7 +181,7 @@ export function CliproxyPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">CLIProxy</h1>
|
<h1 className="text-2xl font-bold">CLIProxy</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setDialogOpen(true)}>
|
<Button onClick={() => setDialogOpen(true)}>
|
||||||
@@ -30,51 +190,20 @@ export function CliproxyPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Built-in Profiles Auth Status */}
|
{/* Built-in Profiles with Account Management */}
|
||||||
<div>
|
<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 ? (
|
{authLoading ? (
|
||||||
<div className="text-muted-foreground">Loading auth status...</div>
|
<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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{authData?.authStatus.map((status) => (
|
{authData?.authStatus.map((status) => (
|
||||||
<div
|
<ProviderCard
|
||||||
key={status.provider}
|
key={status.provider}
|
||||||
className={`p-4 rounded-lg border ${
|
status={status}
|
||||||
status.authenticated
|
setDefaultMutation={setDefaultMutation}
|
||||||
? 'border-green-500/30 bg-green-500/5'
|
removeMutation={removeMutation}
|
||||||
: '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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user