diff --git a/README.md b/README.md index 6e60745d..828919e2 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,8 @@ The dashboard provides visual management for all account types: > **OAuth providers** authenticate via browser on first run. Tokens are cached in `~/.ccs/cliproxy/auth/`. +> **Kiro / Copilot account naming:** Manual nicknames are optional. If the provider does not expose an email, CCS derives a safe internal identifier automatically and you can rename it later. + > **AI Providers dashboard:** Configure CLIProxy-managed API key families at `ccs config` -> `CLIProxy` -> `AI Providers`. Use `API Profiles` only for CCS-native Anthropic-compatible profiles. **Powered by:** diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index a49fd640..bb1b8a07 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -28,8 +28,11 @@ export { getPausedDir, getAccountTokenPath, extractAccountIdFromTokenFile, + deriveNoEmailProviderAccountId, generateNickname, validateNickname, + hasAccountNameConflict, + findAccountNameMatch, tokenFileExists, loadAccountsRegistry, saveAccountsRegistry, diff --git a/src/cliproxy/accounts/index.ts b/src/cliproxy/accounts/index.ts index 621d5562..9408bea7 100644 --- a/src/cliproxy/accounts/index.ts +++ b/src/cliproxy/accounts/index.ts @@ -21,8 +21,11 @@ export { getPausedDir, getAccountTokenPath, extractAccountIdFromTokenFile, + deriveNoEmailProviderAccountId, generateNickname, validateNickname, + hasAccountNameConflict, + findAccountNameMatch, tokenFileExists, } from './token-file-ops'; diff --git a/src/cliproxy/accounts/query.ts b/src/cliproxy/accounts/query.ts index c91f9f45..c1189129 100644 --- a/src/cliproxy/accounts/query.ts +++ b/src/cliproxy/accounts/query.ts @@ -67,12 +67,12 @@ export function findAccountByQuery(provider: CLIProxyProvider, query: string): A if (exactMatch) return exactMatch; // Partial match on nickname or email prefix - const partialMatch = accounts.find( + const partialMatches = accounts.filter( (a) => a.nickname?.toLowerCase().startsWith(lowerQuery) || a.email?.toLowerCase().startsWith(lowerQuery) ); - return partialMatch || null; + return partialMatches.length === 1 ? partialMatches[0] : null; } /** diff --git a/src/cliproxy/accounts/registry.ts b/src/cliproxy/accounts/registry.ts index 553864d5..6287d50d 100644 --- a/src/cliproxy/accounts/registry.ts +++ b/src/cliproxy/accounts/registry.ts @@ -13,7 +13,9 @@ import { getAccountsRegistryPath, getPausedDir, extractAccountIdFromTokenFile, + deriveNoEmailProviderAccountId, generateNickname, + hasAccountNameConflict, validateNickname, moveTokenToPaused, moveTokenFromPaused, @@ -21,10 +23,12 @@ import { } from './token-file-ops'; /** Default registry structure */ -const DEFAULT_REGISTRY: AccountsRegistry = { - version: 1, - providers: {}, -}; +function createDefaultRegistry(): AccountsRegistry { + return { + version: 1, + providers: {}, + }; +} /** * Load accounts registry @@ -33,7 +37,7 @@ export function loadAccountsRegistry(): AccountsRegistry { const registryPath = getAccountsRegistryPath(); if (!fs.existsSync(registryPath)) { - return { ...DEFAULT_REGISTRY }; + return createDefaultRegistry(); } try { @@ -44,7 +48,7 @@ export function loadAccountsRegistry(): AccountsRegistry { providers: data.providers || {}, }; } catch { - return { ...DEFAULT_REGISTRY }; + return createDefaultRegistry(); } } @@ -115,8 +119,8 @@ export function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean * Called after successful OAuth to record the account * * For providers without email (kiro, ghcp): - * - nickname is REQUIRED and used as accountId - * - Uniqueness is enforced to prevent overwriting + * - internal accountId is derived from token metadata + * - nickname is optional metadata * * For providers with email: * - email is used as accountId @@ -149,23 +153,22 @@ export function registerAccount( let accountNickname: string; if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) { - // For kiro/ghcp: nickname is REQUIRED and used as accountId - if (!nickname || nickname === 'default') { - throw new Error( - `Nickname is required when adding ${provider} accounts. ` + - `Use --nickname or provide a nickname in the UI.` - ); - } + accountId = email + ? extractAccountIdFromTokenFile(tokenFile, email) + : deriveNoEmailProviderAccountId(provider, tokenFile, providerAccounts.accounts); + const existingAccount = providerAccounts.accounts[accountId]; - // Validate nickname format - const validationError = validateNickname(nickname); - if (validationError) { - throw new Error(validationError); - } + if (nickname) { + const validationError = validateNickname(nickname); + if (validationError) { + throw new Error(validationError); + } - // Check uniqueness - for (const [existingId, _account] of Object.entries(providerAccounts.accounts)) { - if (existingId.toLowerCase() === nickname.toLowerCase()) { + const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({ + id, + nickname: account.nickname, + })); + if (hasAccountNameConflict(existingAccounts, nickname, accountId)) { throw new Error( `An account with nickname "${nickname}" already exists for ${provider}. ` + `Choose a different nickname.` @@ -173,8 +176,8 @@ export function registerAccount( } } - accountId = nickname; - accountNickname = nickname; + accountNickname = + nickname || existingAccount?.nickname || (email ? generateNickname(email) : accountId); } else { // For other providers: use email as accountId, fallback to filename extraction accountId = extractAccountIdFromTokenFile(tokenFile, email); @@ -184,11 +187,12 @@ export function registerAccount( const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0; // Create or update account + const existingAccount = providerAccounts.accounts[accountId]; const accountMeta: Omit = { email, nickname: accountNickname, tokenFile, - createdAt: new Date().toISOString(), + createdAt: existingAccount?.createdAt || new Date().toISOString(), lastUsedAt: new Date().toISOString(), }; @@ -339,11 +343,12 @@ export function renameAccount( return false; } - // Check if nickname is already used by another account - for (const [id, account] of Object.entries(providerAccounts.accounts)) { - if (id !== accountId && account.nickname?.toLowerCase() === newNickname.toLowerCase()) { - throw new Error(`Nickname "${newNickname}" is already used by another account`); - } + const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({ + id, + nickname: account.nickname, + })); + if (hasAccountNameConflict(existingAccounts, newNickname, accountId)) { + throw new Error(`Nickname "${newNickname}" is already used by another account`); } providerAccounts.accounts[accountId].nickname = newNickname; @@ -476,28 +481,10 @@ export function discoverExistingAccounts(): void { continue; } - // Determine accountId based on provider type - let accountId: string; - - if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email) { - // For kiro/ghcp without email: extract from filename or generate unique - // Pattern: kiro-github-ABC123.json -> github-ABC123 - const filenameId = extractAccountIdFromTokenFile(file, undefined); - - if (filenameId !== 'default') { - accountId = filenameId; - } else { - // Generate unique ID: provider + incrementing index - let index = 1; - while (providerAccounts.accounts[`${provider}-${index}`]) { - index++; - } - accountId = `${provider}-${index}`; - } - } else { - // For providers with email: use email or filename extraction - accountId = extractAccountIdFromTokenFile(file, email); - } + const accountId = + PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email + ? deriveNoEmailProviderAccountId(provider, file, providerAccounts.accounts) + : extractAccountIdFromTokenFile(file, email); // Skip if account already registered if (providerAccounts.accounts[accountId]) { @@ -517,7 +504,7 @@ export function discoverExistingAccounts(): void { const lastModified = stats.mtime || stats.birthtime || new Date(); const accountMeta: Omit = { email, - nickname: generateNickname(email), + nickname: email ? generateNickname(email) : accountId, tokenFile: file, createdAt: stats.birthtime?.toISOString() || new Date().toISOString(), lastUsedAt: lastModified.toISOString(), diff --git a/src/cliproxy/accounts/token-file-ops.ts b/src/cliproxy/accounts/token-file-ops.ts index d06f4df7..2f35d95d 100644 --- a/src/cliproxy/accounts/token-file-ops.ts +++ b/src/cliproxy/accounts/token-file-ops.ts @@ -5,6 +5,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCliproxyDir, getAuthDir } from '../config-generator'; +import type { CLIProxyProvider } from '../types'; import { AccountInfo } from './types'; /** @@ -143,6 +144,50 @@ export function extractAccountIdFromTokenFile(filename: string, email?: string): return 'default'; } +/** + * Derive a collision-safe internal account ID for providers that may not expose email. + * Reuses the existing entry when the token file is already known, otherwise prefers the + * filename-derived ID before falling back to provider-scoped sequential IDs. + */ +export function deriveNoEmailProviderAccountId( + provider: CLIProxyProvider, + tokenFile: string, + existingAccounts: Record> +): string { + const existingEntries = Object.entries(existingAccounts); + const existingEntry = existingEntries.find(([, account]) => account.tokenFile === tokenFile); + if (existingEntry) { + return existingEntry[0]; + } + + const extractedId = extractAccountIdFromTokenFile(tokenFile); + const lowerExtractedId = extractedId.toLowerCase(); + + if ( + extractedId !== 'default' && + !existingEntries.some( + ([existingId, account]) => + existingId.toLowerCase() === lowerExtractedId || + account.nickname?.toLowerCase() === lowerExtractedId + ) + ) { + return extractedId; + } + + let index = 1; + while ( + existingEntries.some( + ([existingId, account]) => + existingId.toLowerCase() === `${provider}-${index}` || + account.nickname?.toLowerCase() === `${provider}-${index}` + ) + ) { + index++; + } + + return `${provider}-${index}`; +} + /** * Generate nickname from email * Takes prefix before @ symbol, sanitizes whitespace @@ -180,3 +225,44 @@ export function validateNickname(nickname: string): string | null { } return null; } + +/** + * Check whether a nickname would collide with any existing account ID or nickname. + */ +export function hasAccountNameConflict( + accounts: Array>, + candidateName: string, + excludeAccountId?: string +): boolean { + const normalizedCandidate = candidateName.toLowerCase(); + const normalizedExcludedId = excludeAccountId?.toLowerCase(); + + return accounts.some((account) => { + if (normalizedExcludedId && account.id.toLowerCase() === normalizedExcludedId) { + return false; + } + + return ( + account.id.toLowerCase() === normalizedCandidate || + account.nickname?.toLowerCase() === normalizedCandidate + ); + }); +} + +/** + * Find the existing account that already owns the supplied id/nickname. + */ +export function findAccountNameMatch( + accounts: Array>, + candidateName: string +): Pick | null { + const normalizedCandidate = candidateName.toLowerCase(); + + return ( + accounts.find( + (account) => + account.id.toLowerCase() === normalizedCandidate || + account.nickname?.toLowerCase() === normalizedCandidate + ) || null + ); +} diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index e989ae56..b30baf09 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -20,6 +20,8 @@ import { getProviderAccounts, getDefaultAccount, touchAccount, + hasAccountNameConflict, + findAccountNameMatch, PROVIDERS_WITHOUT_EMAIL, validateNickname, } from '../account-manager'; @@ -88,6 +90,28 @@ export async function requestPasteCallbackStart( return (await response.json()) as PasteCallbackStartData; } +export function getCliAuthNicknameError( + provider: CLIProxyProvider, + nickname: string | undefined, + existingAccounts: Array>, + allowExistingAccountId?: string +): string | null { + if (!nickname || !PROVIDERS_WITHOUT_EMAIL.includes(provider)) { + return null; + } + + const validationError = validateNickname(nickname); + if (validationError) { + return validationError; + } + + if (hasAccountNameConflict(existingAccounts, nickname, allowExistingAccountId)) { + return `Nickname "${nickname}" is already in use. Choose a different one.`; + } + + return null; +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -206,74 +230,6 @@ async function promptOAuthModeChoice(callbackPort: number | null): Promise<'past }); } -/** - * Prompt user for account nickname (required for kiro/ghcp) - * Returns null if user cancels - */ -async function promptNickname( - provider: CLIProxyProvider, - existingAccounts: AccountInfo[] -): Promise { - const readline = await import('readline'); - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - const existingNicknames = existingAccounts.map( - (a) => a.nickname?.toLowerCase() || a.id.toLowerCase() - ); - - console.log(''); - console.log(info(`${provider} accounts require a unique nickname to distinguish them.`)); - if (existingNicknames.length > 0) { - console.log(` Existing: ${existingNicknames.join(', ')}`); - } - - return new Promise((resolve) => { - let resolved = false; - - // Handle Ctrl+C gracefully (only if not already resolved) - rl.on('close', () => { - if (!resolved) { - resolved = true; - resolve(null); - } - }); - - const askForNickname = () => { - rl.question('[?] Enter a nickname for this account: ', (answer) => { - const nickname = answer.trim(); - - if (!nickname) { - console.log(fail('Nickname cannot be empty')); - askForNickname(); - return; - } - - const validationError = validateNickname(nickname); - if (validationError) { - console.log(fail(validationError)); - askForNickname(); - return; - } - - if (existingNicknames.includes(nickname.toLowerCase())) { - console.log(fail(`Nickname "${nickname}" is already in use. Choose a different one.`)); - askForNickname(); - return; - } - - resolved = true; - rl.close(); - resolve(nickname); - }); - }; - - askForNickname(); - }); -} - /** * Run pre-flight OAuth checks */ @@ -350,7 +306,8 @@ async function handlePasteCallbackMode( oauthConfig: ProviderOAuthConfig, verbose: boolean, tokenDir: string, - nickname?: string + nickname?: string, + expectedAccountId?: string ): Promise { // Resolve CLIProxyAPI target (local or remote based on config) const target = getProxyTarget(); @@ -474,7 +431,13 @@ async function handlePasteCallbackMode( } console.log(ok('Authentication successful!')); - const account = registerAccountFromToken(provider, tokenDir, nickname); + const account = registerAccountFromToken( + provider, + tokenDir, + nickname, + verbose, + expectedAccountId + ); // Account safety: check for cross-provider conflicts if (account?.email) { @@ -509,7 +472,7 @@ export async function triggerOAuth( warnOAuthBanRisk(provider); const { verbose = false, add = false, fromUI = false, noIncognito = true } = options; const acceptAgyRisk = options.acceptAgyRisk === true; - let { nickname } = options; + const { nickname } = options; const resolvedKiroMethod = provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD; @@ -533,21 +496,26 @@ export async function triggerOAuth( // Check for existing accounts const existingAccounts = getProviderAccounts(provider); + const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null; + const nicknameError = !fromUI + ? getCliAuthNicknameError(provider, nickname, existingAccounts, existingNameMatch?.id) + : null; + if (nicknameError) { + console.log(fail(nicknameError)); + return null; + } // Handle paste-callback mode if (options.pasteCallback) { const tokenDir = getProviderTokenDir(provider); - return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname); - } - - // For kiro/ghcp: require nickname if not provided (CLI only, not fromUI) - if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !nickname && !fromUI) { - const promptedNickname = await promptNickname(provider, existingAccounts); - if (!promptedNickname) { - console.log(info('Cancelled')); - return null; - } - nickname = promptedNickname; + return handlePasteCallbackMode( + provider, + oauthConfig, + verbose, + tokenDir, + nickname, + existingNameMatch?.id + ); } // Handle --import flag: skip OAuth and import from Kiro IDE directly @@ -555,7 +523,7 @@ export async function triggerOAuth( const tokenDir = getProviderTokenDir(provider); const success = await importKiroToken(verbose); if (success) { - return registerAccountFromToken(provider, tokenDir, nickname); + return registerAccountFromToken(provider, tokenDir, nickname, verbose, existingNameMatch?.id); } return null; } @@ -589,12 +557,26 @@ export async function triggerOAuth( // Non-interactive environment (piped input) - default to paste mode if (!process.stdin.isTTY) { const tokenDir = getProviderTokenDir(provider); - return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname); + return handlePasteCallbackMode( + provider, + oauthConfig, + verbose, + tokenDir, + nickname, + existingNameMatch?.id + ); } const mode = await promptOAuthModeChoice(callbackPort); if (mode === 'paste') { const tokenDir = getProviderTokenDir(provider); - return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname); + return handlePasteCallbackMode( + provider, + oauthConfig, + verbose, + tokenDir, + nickname, + existingNameMatch?.id + ); } // mode === 'forward' continues to existing port-forwarding flow below } @@ -678,6 +660,7 @@ export async function triggerOAuth( verbose, isCLI, nickname, + expectedAccountId: existingNameMatch?.id, }); // Show hint for Kiro users about --no-incognito option (first-time auth only) diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index d3458a18..8fcdfa60 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -50,6 +50,7 @@ export interface OAuthProcessOptions { verbose: boolean; isCLI: boolean; nickname?: string; + expectedAccountId?: string; } /** Internal state for OAuth process */ @@ -276,6 +277,7 @@ async function handleTokenNotFound( callbackPort: number | null, tokenDir: string, nickname: string | undefined, + expectedAccountId: string | undefined, verbose: boolean, failureReason?: string ): Promise { @@ -289,7 +291,7 @@ async function handleTokenNotFound( if (result.success) { const providerInfo = result.provider ? ` (Provider: ${result.provider})` : ''; console.log(ok(`Imported Kiro token from IDE${providerInfo}`)); - return registerAccountFromToken(provider, tokenDir, nickname); + return registerAccountFromToken(provider, tokenDir, nickname, verbose, expectedAccountId); } console.log(fail(`Auto-import failed: ${result.error}`)); @@ -376,6 +378,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { @@ -538,7 +541,9 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise f.endsWith('.json')); - let newestFile: string | null = null; let newestMtime = 0; for (const file of jsonFiles) { @@ -233,19 +235,33 @@ export function registerAccountFromToken( const email = data.email || undefined; const projectId = data.project_id || undefined; - const account = registerAccount( - provider, - newestFile, - email, - nickname || generateNickname(email), - projectId - ); + const account = registerAccount(provider, newestFile, email, nickname, projectId); // Upload token to remote server if configured (async, don't block) uploadTokenToRemoteAsync(tokenPath, verbose); return account; - } catch { + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (verbose) { + console.error(`[auth] Failed to register token-backed account: ${message}`); + } + + if (typeof newestFile === 'string') { + const hasExistingRegistration = getProviderAccounts(provider).some( + (account) => account.tokenFile === newestFile + ); + if (!hasExistingRegistration) { + deleteTokenFile(newestFile); + } + } + + if (expectedAccountId && verbose) { + console.error( + `[auth] Reauthentication target ${expectedAccountId} did not resolve cleanly from the new token` + ); + } + return null; } } diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 9945ef4d..ec772b15 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -418,7 +418,7 @@ export async function execClaudeWithCLIProxy( const nickname = acct.nickname ? `[${acct.nickname}]` : ''; console.log(` ${nickname.padEnd(12)} ${acct.email || acct.id}${defaultMark}`); } - console.log(`\n Use "ccs ${provider} --use " to switch accounts`); + console.log(`\n Use "ccs ${provider} --use " to switch accounts`); } process.exit(0); } diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 41d365aa..acb6adbe 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -209,7 +209,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); 'Show auth URL and prompt for callback paste (cross-browser)', ], ['ccs --accounts', 'List all accounts'], - ['ccs --use ', 'Switch to account'], + ['ccs --use ', 'Switch to account'], ['ccs --config', 'Change model (agy, gemini)'], [ 'ccs agy --accept-agr-risk', diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index af838c5b..0072fc67 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -22,6 +22,8 @@ import { pauseAccount as pauseAccountFn, resumeAccount as resumeAccountFn, touchAccount, + hasAccountNameConflict, + findAccountNameMatch, PROVIDERS_WITHOUT_EMAIL, validateNickname, } from '../../cliproxy/account-manager'; @@ -33,7 +35,7 @@ import { import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { tryKiroImport } from '../../cliproxy/auth/kiro-import'; -import { getProviderTokenDir } from '../../cliproxy/auth/token-manager'; +import { getProviderTokenDir, registerAccountFromToken } from '../../cliproxy/auth/token-manager'; import { CLIPROXY_CALLBACK_PROVIDER_MAP, CLIPROXY_AUTH_URL_PROVIDER_MAP, @@ -53,12 +55,55 @@ import { import { createRouteErrorHelpers } from './route-helpers'; const router = Router(); +const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 1000; +const pendingManualAuthState = new Map< + string, + { nickname?: string; expectedAccountId?: string; createdAt: number } +>(); // Valid providers list - derived from canonical CLIPROXY_PROFILES const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; const { respondInternalError } = createRouteErrorHelpers('cliproxy-auth-routes'); +function pruneExpiredManualAuthState(now = Date.now()): void { + for (const [state, pending] of pendingManualAuthState.entries()) { + if (now - pending.createdAt > MANUAL_AUTH_STATE_TTL_MS) { + pendingManualAuthState.delete(state); + } + } +} + +function rememberManualAuthState( + state: string, + pending: { nickname?: string; expectedAccountId?: string } +): void { + pruneExpiredManualAuthState(); + pendingManualAuthState.set(state, { + ...pending, + createdAt: Date.now(), + }); +} + +function getManualAuthState( + state: string | undefined +): { nickname?: string; expectedAccountId?: string } | null { + if (!state) { + return null; + } + + pruneExpiredManualAuthState(); + const pending = pendingManualAuthState.get(state); + if (!pending) { + return null; + } + + return { + nickname: pending.nickname, + expectedAccountId: pending.expectedAccountId, + }; +} + function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } { if (raw === undefined || raw === null) { return { method: normalizeKiroAuthMethod(), invalid: false }; @@ -104,6 +149,34 @@ export function getStartAuthFailureMessage(provider: CLIProxyProvider): string { return 'Authentication failed or was cancelled'; } +export function getStartAuthNicknameError( + provider: CLIProxyProvider, + nickname: string | undefined, + existingAccounts: Array<{ id: string; nickname?: string }>, + allowExistingAccountId?: string +): { error: string; code: 'INVALID_NICKNAME' | 'NICKNAME_EXISTS' } | null { + if (!PROVIDERS_WITHOUT_EMAIL.includes(provider) || !nickname) { + return null; + } + + const validationError = validateNickname(nickname); + if (validationError) { + return { + error: validationError, + code: 'INVALID_NICKNAME', + }; + } + + if (hasAccountNameConflict(existingAccounts, nickname, allowExistingAccountId)) { + return { + error: `Nickname "${nickname}" is already in use. Choose a different one.`, + code: 'NICKNAME_EXISTS', + }; + } + + return null; +} + /** * GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles * Also fetches CLIProxyAPI stats to update lastUsedAt for active providers @@ -430,37 +503,17 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise a.nickname?.toLowerCase() || a.id.toLowerCase() - ); - if (existingNicknames.includes(nickname.toLowerCase())) { - res.status(400).json({ - error: `Nickname "${nickname}" is already in use. Choose a different one.`, - code: 'NICKNAME_EXISTS', - }); - return; - } + const existingAccounts = getProviderAccounts(provider as CLIProxyProvider); + const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null; + const nicknameError = getStartAuthNicknameError( + provider as CLIProxyProvider, + nickname, + existingAccounts, + existingNameMatch?.id + ); + if (nicknameError) { + res.status(400).json(nicknameError); + return; } // Check Kiro no-incognito setting from config (or request body) @@ -625,7 +678,12 @@ router.post('/kiro/import', async (_req: Request, res: Response): Promise */ router.post('/:provider/start-url', async (req: Request, res: Response): Promise => { const { provider } = req.params; - const { kiroMethod: kiroMethodRaw, riskAcknowledgement } = req.body ?? {}; + const requestBody = + req.body && typeof req.body === 'object' ? (req.body as Record) : {}; + const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined; + const kiroMethodRaw = requestBody.kiroMethod; + const riskAcknowledgement = requestBody.riskAcknowledgement; + const nickname = nicknameRaw?.trim(); const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw); // Check remote mode @@ -668,6 +726,19 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise return; } + const existingAccounts = getProviderAccounts(provider as CLIProxyProvider); + const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null; + const nicknameError = getStartAuthNicknameError( + provider as CLIProxyProvider, + nickname, + existingAccounts, + existingNameMatch?.id + ); + if (nicknameError) { + res.status(400).json(nicknameError); + return; + } + try { const authUrlProvider = CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider; @@ -696,19 +767,27 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise method?: string; }; const authUrl = data.url || data.auth_url; + const oauthState = data.state || parseAuthUrlState(authUrl); // Some upstream flows return state first and provide auth_url in subsequent status polling. - if (!authUrl && !data.state) { + if (!authUrl && !oauthState) { res .status(500) .json({ error: 'No OAuth state or authorization URL received from CLIProxyAPI' }); return; } + if (oauthState) { + rememberManualAuthState(oauthState, { + nickname: nickname || undefined, + expectedAccountId: existingNameMatch?.id, + }); + } + res.json({ success: true, authUrl: authUrl || null, - state: data.state || null, + state: oauthState, method: data.method || null, }); } catch (error) { @@ -768,6 +847,18 @@ function parseCallbackUrl(url: string): { code?: string; state?: string } { } } +function parseAuthUrlState(url: string | null | undefined): string | null { + if (!url) { + return null; + } + + try { + return new URL(url).searchParams.get('state'); + } catch { + return null; + } +} + /** * POST /api/cliproxy/auth/:provider/submit-callback - Submit OAuth callback URL manually * For cross-browser OAuth flows where callback cannot redirect directly @@ -800,6 +891,7 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P res.status(400).json({ error: 'Invalid callback URL: missing code parameter' }); return; } + const pendingAuth = getManualAuthState(parsed.state); try { const callbackProvider = @@ -824,7 +916,29 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P return; } - res.json({ success: true }); + const account = registerAccountFromToken( + provider as CLIProxyProvider, + getProviderTokenDir(provider as CLIProxyProvider), + pendingAuth?.nickname, + false, + pendingAuth?.expectedAccountId + ); + if (parsed.state) { + pendingManualAuthState.delete(parsed.state); + } + + res.json({ + success: true, + account: account + ? { + id: account.id, + email: account.email, + nickname: account.nickname, + provider: account.provider, + isDefault: account.isDefault, + } + : null, + }); } catch (error) { respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503); } diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index bdb2e90a..6ec2d361 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -41,7 +41,6 @@ import { DEFAULT_KIRO_AUTH_METHOD, getKiroAuthMethodOption, isDeviceCodeProvider, - isNicknameRequiredProvider, KIRO_AUTH_METHOD_OPTIONS, } from '@/lib/provider-config'; import type { KiroAuthMethod } from '@/lib/provider-config'; @@ -89,7 +88,6 @@ export function AddAccountDialog({ const isAgyRiskChecklistComplete = isAntigravityRiskChecklistComplete(agyRiskChecklist); const isGeminiRiskAcknowledged = normalizeRiskPhrase(riskAcknowledgementText) === RISK_ACK_PHRASE; const defaultDeviceCode = isDeviceCodeProvider(provider); - const requiresNickname = isNicknameRequiredProvider(provider); const kiroMethodOption = getKiroAuthMethodOption(kiroAuthMethod); const isDeviceCode = isKiro ? kiroMethodOption.flowType === 'device_code' : defaultDeviceCode; const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending; @@ -266,10 +264,6 @@ export function AddAccountDialog({ ); return; } - if (requiresNickname && !nicknameTrimmed) { - setLocalError(`Nickname is required for ${displayName} accounts.`); - return; - } setLocalError(null); wasAuthenticatingRef.current = true; authFlow.startAuth(provider, { @@ -394,11 +388,7 @@ export function AddAccountDialog({ {/* Nickname input - only show before auth starts */} {!showAuthUI && (
- +

- {requiresNickname - ? t('addAccountDialog.nicknameRequiredHint') - : t('addAccountDialog.nicknameOptionalHint')} + {t('addAccountDialog.nicknameOptionalHint')}

)} @@ -554,7 +542,6 @@ export function AddAccountDialog({ disabled={ isPending || isAgyBypassStatePending || - (requiresNickname && !nicknameTrimmed) || (requiresAgyResponsibilityFlow && !isAgyRiskChecklistComplete) || (requiresSafetyAcknowledgement && !isGeminiRiskAcknowledged) } diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 038d5ee0..4dd64dfe 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -473,7 +473,7 @@ const resources = { nicknameRequiredHint: 'Required for this provider. Use a unique friendly name (e.g., work, personal).', nicknameOptionalHint: - 'A friendly name to identify this account. Auto-generated from email if left empty.', + 'A friendly name to identify this account. Leave blank to use a safe generated identifier.', waitingForAuth: 'Waiting for authentication...', deviceCodeHint: 'A verification code dialog will appear shortly. Enter the code on the provider website.', @@ -1632,7 +1632,7 @@ const resources = { nicknameOptional: '昵称(选填)', nicknamePlaceholder: '例如:工作、个人', nicknameRequiredHint: '该提供商必填。请使用唯一易记名称(如工作、个人)。', - nicknameOptionalHint: '用于区分账号的友好名称。留空将根据邮箱自动生成。', + nicknameOptionalHint: '用于区分账号的友好名称。留空将自动生成安全标识。', waitingForAuth: '等待认证中...', deviceCodeHint: '验证码对话框即将出现,请在提供商网站输入验证码。', browserHint: '在浏览器中完成认证后,本对话框将自动关闭。', @@ -2804,7 +2804,7 @@ const resources = { nicknameRequiredHint: 'Bắt buộc với nhà cung cấp này. Dùng tên thân thiện duy nhất (ví dụ: work, personal).', nicknameOptionalHint: - 'Một cái tên thân thiện để xác định tài khoản này. Tự động tạo từ email nếu để trống.', + 'Tên thân thiện để nhận biết tài khoản này. Để trống để dùng mã nhận dạng an toàn do hệ thống tạo.', waitingForAuth: 'Đang chờ xác thực...', deviceCodeHint: 'Hộp thoại mã xác minh sẽ sớm xuất hiện. Nhập mã trên trang web của nhà cung cấp.', @@ -4004,7 +4004,7 @@ const resources = { nicknameRequiredHint: 'このプロバイダーでは必須です。重複しないわかりやすい名前を付けてください(例: work, personal)。', nicknameOptionalHint: - 'このアカウントを識別しやすい名前です。空欄ならメールアドレスから自動生成されます。', + 'このアカウントを識別しやすい名前です。空欄の場合は安全な識別子を自動生成します。', waitingForAuth: '認証を待機中...', deviceCodeHint: '確認コードのダイアログがまもなく表示されます。プロバイダーのサイトでコードを入力してください。', diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index 9d8b96a0..b1cbf0d2 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -233,15 +233,6 @@ export function getDeviceCodeProviderInstruction(provider: unknown): string { return 'Complete the authorization in your browser.'; } -/** Providers that require nickname because token payload may not include email. */ -export const NICKNAME_REQUIRED_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro']; - -/** Check if provider requires user-supplied nickname in auth flow */ -export function isNicknameRequiredProvider(provider: unknown): boolean { - const normalized = normalizeProviderInput(provider); - return isValidProvider(normalized) && NICKNAME_REQUIRED_PROVIDERS.includes(normalized); -} - /** Kiro auth methods exposed in CCS UI (aligned with CLIProxyAPIPlus support). */ export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github'] as const; export type KiroAuthMethod = (typeof KIRO_AUTH_METHODS)[number];