diff --git a/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts b/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts index ce362d20..ff318463 100644 --- a/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts +++ b/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts @@ -66,7 +66,10 @@ const mockProfileManager = { getProfile: (_profileId: string) => mockProfile, // Token decryption methods - return mock token for tests getActiveProfileToken: () => 'mock-decrypted-token-for-testing', - getProfileToken: (_profileId: string) => 'mock-decrypted-token-for-testing' + getProfileToken: (_profileId: string) => 'mock-decrypted-token-for-testing', + // Environment methods for rate-limit-detector delegation + getActiveProfileEnv: () => ({}), + getProfileEnv: (_profileId: string) => ({}) }; vi.mock('../../main/claude-profile-manager', () => ({ diff --git a/apps/frontend/src/main/__tests__/long-lived-auth.test.ts b/apps/frontend/src/main/__tests__/long-lived-auth.test.ts new file mode 100644 index 00000000..82d3c31f --- /dev/null +++ b/apps/frontend/src/main/__tests__/long-lived-auth.test.ts @@ -0,0 +1,196 @@ +/** + * Tests for Long-Lived Auth Fix + * + * Verifies that: + * 1. getProfileEnv() always uses CLAUDE_CONFIG_DIR instead of cached OAuth tokens + * 2. Profile migration removes cached oauthToken values + * 3. UsageMonitor reads fresh tokens from Keychain + * + * See: docs/LONG_LIVED_AUTH_PLAN.md + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the profile manager +const mockGetProfile = vi.fn(); +const mockGetActiveProfile = vi.fn(); +const mockGetProfileToken = vi.fn(); +const mockGetActiveProfileToken = vi.fn(); +const mockGetProfileEnv = vi.fn(); +const mockGetActiveProfileEnv = vi.fn(); + +vi.mock('../claude-profile-manager', () => ({ + getClaudeProfileManager: () => ({ + getProfile: mockGetProfile, + getActiveProfile: mockGetActiveProfile, + getProfileToken: mockGetProfileToken, + getActiveProfileToken: mockGetActiveProfileToken, + getProfileEnv: mockGetProfileEnv, + getActiveProfileEnv: mockGetActiveProfileEnv, + }), +})); + +// Import after mocking +import { getProfileEnv } from '../rate-limit-detector'; + +// Mock for profile storage tests - needs to be imported dynamically +const mockFs = { + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), +}; + +vi.mock('fs', () => ({ + existsSync: (...args: unknown[]) => mockFs.existsSync(...args), + readFileSync: (...args: unknown[]) => mockFs.readFileSync(...args), + writeFileSync: (...args: unknown[]) => mockFs.writeFileSync(...args), + readFile: vi.fn(), +})); + +describe('Long-Lived Auth Fix', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getProfileEnv', () => { + it('should return empty env for default profile (Claude CLI uses ~/.claude)', () => { + // Since getProfileEnv now delegates to profile manager, mock the manager's method + mockGetActiveProfileEnv.mockReturnValue({}); + + const env = getProfileEnv(); + + expect(env).toEqual({}); + expect(mockGetActiveProfileEnv).toHaveBeenCalled(); + // Should NOT call getProfileToken or getActiveProfileToken + expect(mockGetProfileToken).not.toHaveBeenCalled(); + expect(mockGetActiveProfileToken).not.toHaveBeenCalled(); + }); + + it('should return CLAUDE_CONFIG_DIR for non-default profile with configDir', () => { + // Since getProfileEnv now delegates to profile manager, mock the manager's method + mockGetActiveProfileEnv.mockReturnValue({ + CLAUDE_CONFIG_DIR: '/Users/test/.claude-profiles/work', + }); + + const env = getProfileEnv(); + + expect(env).toEqual({ + CLAUDE_CONFIG_DIR: '/Users/test/.claude-profiles/work', + }); + expect(mockGetActiveProfileEnv).toHaveBeenCalled(); + // Should NOT use the cached token - this is the key fix! + expect(mockGetProfileToken).not.toHaveBeenCalled(); + expect(mockGetActiveProfileToken).not.toHaveBeenCalled(); + }); + + it('should NOT return CLAUDE_CODE_OAUTH_TOKEN even when profile has oauthToken', () => { + // Since getProfileEnv now delegates to profile manager, mock the manager's method + // The profile manager's implementation should never include CLAUDE_CODE_OAUTH_TOKEN + mockGetActiveProfileEnv.mockReturnValue({ + CLAUDE_CONFIG_DIR: '/Users/test/.claude-profiles/personal', + }); + + const env = getProfileEnv(); + + // Key assertion: Should NEVER return CLAUDE_CODE_OAUTH_TOKEN + expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined(); + expect(env.CLAUDE_CONFIG_DIR).toBe('/Users/test/.claude-profiles/personal'); + }); + + it('should return empty env for profile without configDir (edge case)', () => { + // Since getProfileEnv now delegates to profile manager, mock the manager's method + // Profile manager returns empty env when no configDir is set + mockGetActiveProfileEnv.mockReturnValue({}); + + const env = getProfileEnv(); + + // Without configDir, cannot authenticate via CLAUDE_CONFIG_DIR + // Should NOT fall back to oauthToken (that's the bug we're fixing) + expect(env).toEqual({}); + expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined(); + }); + + it('should use specific profile when profileId is provided', () => { + // Since getProfileEnv now delegates to profile manager, mock the manager's method + mockGetProfileEnv.mockReturnValue({ + CLAUDE_CONFIG_DIR: '/Users/test/.claude-profiles/specific', + }); + + const env = getProfileEnv('specific-profile'); + + expect(mockGetProfileEnv).toHaveBeenCalledWith('specific-profile'); + expect(env).toEqual({ + CLAUDE_CONFIG_DIR: '/Users/test/.claude-profiles/specific', + }); + }); + }); + + describe('Profile Storage Migration', () => { + it('should remove oauthToken during profile migration', async () => { + // Create a profile store with cached oauthToken + const storeWithToken = { + version: 3, + activeProfileId: 'work', + profiles: [ + { + id: 'work', + name: 'Work Account', + isDefault: false, + configDir: '/Users/test/.claude-profiles/work', + oauthToken: 'enc:stale-cached-token-that-should-be-removed', + tokenCreatedAt: '2024-01-01T00:00:00.000Z', + createdAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(storeWithToken)); + + // Import profile storage dynamically to get fresh module with mocks + const { loadProfileStore } = await import('../claude-profile/profile-storage'); + + const result = loadProfileStore('/test/path'); + + expect(result).not.toBeNull(); + expect(result?.profiles[0]).toBeDefined(); + + // Key assertion: oauthToken and tokenCreatedAt should be removed + expect(result?.profiles[0]).not.toHaveProperty('oauthToken'); + expect(result?.profiles[0]).not.toHaveProperty('tokenCreatedAt'); + + // Other properties should be preserved + expect(result?.profiles[0].id).toBe('work'); + expect(result?.profiles[0].name).toBe('Work Account'); + expect(result?.profiles[0].configDir).toBe('/Users/test/.claude-profiles/work'); + }); + + it('should preserve profiles without oauthToken', async () => { + const storeWithoutToken = { + version: 3, + activeProfileId: 'default', + profiles: [ + { + id: 'default', + name: 'Default', + isDefault: true, + configDir: '/Users/test/.claude', + createdAt: '2024-01-01T00:00:00.000Z', + // No oauthToken - this profile never had one + }, + ], + }; + + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(storeWithoutToken)); + + const { loadProfileStore } = await import('../claude-profile/profile-storage'); + + const result = loadProfileStore('/test/path'); + + expect(result).not.toBeNull(); + expect(result?.profiles[0].id).toBe('default'); + expect(result?.profiles[0]).not.toHaveProperty('oauthToken'); + }); + }); +}); diff --git a/apps/frontend/src/main/claude-profile-manager.ts b/apps/frontend/src/main/claude-profile-manager.ts index 5ef0083e..5cd8cb07 100644 --- a/apps/frontend/src/main/claude-profile-manager.ts +++ b/apps/frontend/src/main/claude-profile-manager.ts @@ -89,9 +89,49 @@ export class ClaudeProfileManager { this.data = loadedData; } + // Run one-time migration to fix corrupted emails + // This repairs emails that were truncated due to ANSI escape codes in terminal output + this.migrateCorruptedEmails(); + this.initialized = true; } + /** + * One-time migration to fix emails that were corrupted by ANSI escape codes + * during terminal output parsing. + * + * This reads the authoritative email from Claude's config file (.claude.json) + * for each profile and updates any that differ from what we have stored. + */ + private migrateCorruptedEmails(): void { + let needsSave = false; + + for (const profile of this.data.profiles) { + if (!profile.configDir) { + continue; + } + + // Import dynamically to avoid circular dependency issues + const { getEmailFromConfigDir } = require('./claude-profile/profile-utils'); + const configEmail = getEmailFromConfigDir(profile.configDir); + + if (configEmail && profile.email !== configEmail) { + console.warn('[ClaudeProfileManager] Migrating corrupted email for profile:', { + profileId: profile.id, + oldEmail: profile.email, + newEmail: configEmail + }); + profile.email = configEmail; + needsSave = true; + } + } + + if (needsSave) { + this.save(); + console.warn('[ClaudeProfileManager] Email migration complete'); + } + } + /** * Check if the profile manager has been initialized */ @@ -105,6 +145,18 @@ export class ClaudeProfileManager { private load(): ProfileStoreData { const loadedData = loadProfileStore(this.storePath); if (loadedData) { + if (process.env.DEBUG === 'true') { + console.warn('[ClaudeProfileManager] Loaded profiles:', { + count: loadedData.profiles.length, + activeProfileId: loadedData.activeProfileId, + profiles: loadedData.profiles.map(p => ({ + id: p.id, + name: p.name, + email: p.email, + isDefault: p.isDefault + })) + }); + } return loadedData; } @@ -176,6 +228,24 @@ export class ClaudeProfileManager { this.save(); } + /** + * Get unified account priority order + * Returns array of account IDs in priority order (first = highest priority) + * IDs are prefixed: 'oauth-{profileId}' for OAuth, 'api-{profileId}' for API profiles + */ + getAccountPriorityOrder(): string[] { + return this.data.accountPriorityOrder || []; + } + + /** + * Set unified account priority order + * @param order Array of account IDs in priority order + */ + setAccountPriorityOrder(order: string[]): void { + this.data.accountPriorityOrder = order; + this.save(); + } + /** * Get a specific profile by ID */ @@ -192,11 +262,35 @@ export class ClaudeProfileManager { // Fallback to default const defaultProfile = this.data.profiles.find(p => p.isDefault); if (defaultProfile) { + if (process.env.DEBUG === 'true') { + console.warn('[ClaudeProfileManager] getActiveProfile - using default:', { + id: defaultProfile.id, + name: defaultProfile.name, + email: defaultProfile.email + }); + } return defaultProfile; } // If somehow no default exists, return first profile - return this.data.profiles[0]; + const fallback = this.data.profiles[0]; + if (process.env.DEBUG === 'true') { + console.warn('[ClaudeProfileManager] getActiveProfile - using fallback:', { + id: fallback.id, + name: fallback.name, + email: fallback.email + }); + } + return fallback; } + + if (process.env.DEBUG === 'true') { + console.warn('[ClaudeProfileManager] getActiveProfile:', { + id: active.id, + name: active.name, + email: active.email + }); + } + return active; } @@ -278,11 +372,21 @@ export class ClaudeProfileManager { * Set the active profile */ setActiveProfile(profileId: string): boolean { + const previousProfileId = this.data.activeProfileId; const profile = this.getProfile(profileId); if (!profile) { + console.warn('[ClaudeProfileManager] setActiveProfile failed - profile not found:', { profileId }); return false; } + if (process.env.DEBUG === 'true') { + console.warn('[ClaudeProfileManager] setActiveProfile:', { + from: previousProfileId, + to: profileId, + profileName: profile.name + }); + } + this.data.activeProfileId = profileId; profile.lastUsedAt = new Date(); this.save(); @@ -362,39 +466,40 @@ export class ClaudeProfileManager { /** * Get environment variables for spawning processes with the active profile. - * Sets CLAUDE_CONFIG_DIR to point Claude CLI to the profile's config directory. - * Claude CLI handles token storage in the system Keychain. * - * IMPORTANT: When CLAUDE_CONFIG_DIR is set, we do NOT set CLAUDE_CODE_OAUTH_TOKEN - * because Claude Code prioritizes CLAUDE_CODE_OAUTH_TOKEN over Keychain lookup. - * The OAuth token alone doesn't contain subscription tier info (like "max"), - * causing Claude Code to show "Claude API" instead of "Claude Max". - * By only setting CLAUDE_CONFIG_DIR, Claude Code reads from the Keychain which - * has the full credential object including subscriptionType and rateLimitTier. + * IMPORTANT: Always uses CLAUDE_CONFIG_DIR to let Claude CLI read fresh tokens from Keychain. + * We NEVER use cached OAuth tokens (CLAUDE_CODE_OAUTH_TOKEN) because: + * 1. OAuth tokens expire in 8-12 hours + * 2. Claude CLI's token refresh mechanism works (updates Keychain) + * 3. Cached tokens don't benefit from Claude CLI's automatic refresh + * 4. CLAUDE_CODE_OAUTH_TOKEN doesn't include subscription tier info + * + * By using CLAUDE_CONFIG_DIR, Claude CLI reads fresh tokens from Keychain each time, + * which includes any refreshed tokens and full credential metadata. + * + * See: docs/LONG_LIVED_AUTH_PLAN.md for full context. */ getActiveProfileEnv(): Record { const profile = this.getActiveProfile(); const env: Record = {}; - // For non-default profiles, set CLAUDE_CONFIG_DIR - // Claude CLI will use credentials stored in that directory's Keychain - if (profile?.configDir && !profile.isDefault) { + // Default profile: Claude CLI uses ~/.claude implicitly (no env var needed) + if (profile?.isDefault) { + console.warn('[ClaudeProfileManager] Using default profile (Claude CLI uses ~/.claude)'); + return env; + } + + // Non-default profiles: set CLAUDE_CONFIG_DIR to point Claude CLI to profile's config + // Claude CLI will read fresh tokens from Keychain, benefiting from auto-refresh + if (profile?.configDir) { // Expand ~ to home directory for the environment variable const expandedConfigDir = profile.configDir.startsWith('~') ? profile.configDir.replace(/^~/, require('os').homedir()) : profile.configDir; env.CLAUDE_CONFIG_DIR = expandedConfigDir; - console.warn('[ClaudeProfileManager] Using configDir for profile:', profile.name, expandedConfigDir); - // DO NOT set CLAUDE_CODE_OAUTH_TOKEN here - let Claude Code use Keychain - // credentials which include subscription tier info. See comment above. - } else if (profile?.oauthToken) { - // Only use stored OAuth token for default profile (no configDir) - // This is a legacy path for backward compatibility - const decryptedToken = decryptToken(profile.oauthToken); - if (decryptedToken) { - env.CLAUDE_CODE_OAUTH_TOKEN = decryptedToken; - console.warn('[ClaudeProfileManager] Using stored OAuth token for profile:', profile.name); - } + console.warn('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', profile.name, expandedConfigDir); + } else { + console.warn('[ClaudeProfileManager] Profile has no configDir configured:', profile?.name); } return env; @@ -415,6 +520,71 @@ export class ClaudeProfileManager { return usage; } + /** + * Update usage data for a profile from API response (percentages directly) + * This is called by the usage monitor after fetching usage via the API + */ + updateProfileUsageFromAPI(profileId: string, sessionPercent: number, weeklyPercent: number): ClaudeUsageData | null { + const profile = this.getProfile(profileId); + if (!profile) { + return null; + } + + // Preserve existing reset times if available, otherwise use empty string + const existingUsage = profile.usage; + const usage: ClaudeUsageData = { + sessionUsagePercent: sessionPercent, + sessionResetTime: existingUsage?.sessionResetTime ?? '', + weeklyUsagePercent: weeklyPercent, + weeklyResetTime: existingUsage?.weeklyResetTime ?? '', + opusUsagePercent: existingUsage?.opusUsagePercent, + lastUpdated: new Date() + }; + profile.usage = usage; + this.save(); + return usage; + } + + /** + * Batch update usage data for multiple profiles from API responses. + * Updates all profiles in memory first, then saves once to avoid race conditions. + * + * @param updates - Array of { profileId, sessionPercent, weeklyPercent } objects + * @returns Number of profiles successfully updated + */ + batchUpdateProfileUsageFromAPI( + updates: Array<{ profileId: string; sessionPercent: number; weeklyPercent: number }> + ): number { + let updatedCount = 0; + + for (const { profileId, sessionPercent, weeklyPercent } of updates) { + const profile = this.getProfile(profileId); + if (!profile) { + continue; + } + + // Preserve existing reset times if available + const existingUsage = profile.usage; + const usage: ClaudeUsageData = { + sessionUsagePercent: sessionPercent, + sessionResetTime: existingUsage?.sessionResetTime ?? '', + weeklyUsagePercent: weeklyPercent, + weeklyResetTime: existingUsage?.weeklyResetTime ?? '', + opusUsagePercent: existingUsage?.opusUsagePercent, + lastUpdated: new Date() + }; + profile.usage = usage; + updatedCount++; + } + + // Single save after all updates + if (updatedCount > 0) { + this.save(); + } + + return updatedCount; + } + /** * Record a rate limit event for a profile */ diff --git a/apps/frontend/src/main/claude-profile/credential-utils.test.ts b/apps/frontend/src/main/claude-profile/credential-utils.test.ts new file mode 100644 index 00000000..0143e5bb --- /dev/null +++ b/apps/frontend/src/main/claude-profile/credential-utils.test.ts @@ -0,0 +1,474 @@ +/** + * Cross-Platform Credential Utilities Tests + * + * Tests for credential retrieval on macOS, Linux, and Windows platforms. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createHash } from 'crypto'; +import { join } from 'path'; + +// Mock dependencies before importing the module +vi.mock('../platform', () => ({ + isMacOS: vi.fn(() => false), + isWindows: vi.fn(() => false), + isLinux: vi.fn(() => false), +})); + +vi.mock('fs', () => ({ + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ''), +})); + +vi.mock('child_process', () => ({ + execFileSync: vi.fn(() => ''), +})); + +vi.mock('os', () => ({ + homedir: vi.fn(() => '/home/testuser'), +})); + +// Import after mocks are set up +import { + calculateConfigDirHash, + getKeychainServiceName, + getWindowsCredentialTarget, + getCredentialsFromKeychain, + getCredentials, + clearKeychainCache, + clearCredentialCache, +} from './credential-utils'; +import { isMacOS, isWindows, isLinux } from '../platform'; +import { existsSync, readFileSync } from 'fs'; +import { execFileSync } from 'child_process'; +import { homedir } from 'os'; + +describe('credential-utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Clear the credential cache before each test + clearCredentialCache(); + }); + + describe('calculateConfigDirHash', () => { + it('should return first 8 characters of SHA256 hash', () => { + const configDir = '/home/user/.claude-profiles/work'; + const expectedHash = createHash('sha256').update(configDir).digest('hex').slice(0, 8); + expect(calculateConfigDirHash(configDir)).toBe(expectedHash); + }); + + it('should return different hashes for different paths', () => { + const hash1 = calculateConfigDirHash('/path/one'); + const hash2 = calculateConfigDirHash('/path/two'); + expect(hash1).not.toBe(hash2); + }); + + it('should return consistent hash for same path', () => { + const path = '/home/user/.claude'; + expect(calculateConfigDirHash(path)).toBe(calculateConfigDirHash(path)); + }); + }); + + describe('getKeychainServiceName', () => { + it('should return default service name when no configDir provided', () => { + expect(getKeychainServiceName()).toBe('Claude Code-credentials'); + }); + + it('should return default service name for undefined', () => { + expect(getKeychainServiceName(undefined)).toBe('Claude Code-credentials'); + }); + + it('should return hashed service name for custom configDir', () => { + const configDir = '/home/user/.claude-profiles/work'; + const hash = calculateConfigDirHash(configDir); + expect(getKeychainServiceName(configDir)).toBe(`Claude Code-credentials-${hash}`); + }); + }); + + describe('getWindowsCredentialTarget', () => { + it('should use same naming convention as macOS Keychain', () => { + expect(getWindowsCredentialTarget()).toBe('Claude Code-credentials'); + + const configDir = '/home/user/.claude-profiles/work'; + expect(getWindowsCredentialTarget(configDir)).toBe(getKeychainServiceName(configDir)); + }); + }); + + describe('getCredentialsFromKeychain (macOS)', () => { + beforeEach(() => { + vi.mocked(isMacOS).mockReturnValue(true); + vi.mocked(isWindows).mockReturnValue(false); + vi.mocked(isLinux).mockReturnValue(false); + }); + + it('should return credentials from macOS Keychain', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(execFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-test-token-123', + email: 'test@example.com', + }, + })); + + const result = getCredentialsFromKeychain(); + + expect(result.token).toBe('sk-ant-test-token-123'); + expect(result.email).toBe('test@example.com'); + expect(result.error).toBeUndefined(); + }); + + it('should return null when security command not found', () => { + vi.mocked(existsSync).mockReturnValue(false); + + const result = getCredentialsFromKeychain(); + + expect(result.token).toBeNull(); + expect(result.email).toBeNull(); + expect(result.error).toBe('macOS security command not found'); + }); + + it('should return null for invalid JSON', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(execFileSync).mockReturnValue('invalid json'); + + const result = getCredentialsFromKeychain(); + + expect(result.token).toBeNull(); + expect(result.email).toBeNull(); + }); + + it('should reject invalid token format', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(execFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { + accessToken: 'invalid-token', + email: 'test@example.com', + }, + })); + + const result = getCredentialsFromKeychain(); + + expect(result.token).toBeNull(); + expect(result.email).toBe('test@example.com'); + }); + + it('should handle exit code 44 (item not found)', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(execFileSync).mockImplementation(() => { + const error = new Error('Item not found') as Error & { status: number }; + error.status = 44; + throw error; + }); + + const result = getCredentialsFromKeychain(); + + expect(result.token).toBeNull(); + expect(result.email).toBeNull(); + expect(result.error).toBeUndefined(); + }); + + it('should use cache on subsequent calls', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(execFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-test-token-123', + email: 'test@example.com', + }, + })); + + // First call + getCredentialsFromKeychain(); + // Second call should use cache + getCredentialsFromKeychain(); + + expect(execFileSync).toHaveBeenCalledTimes(1); + }); + + it('should bypass cache when forceRefresh is true', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(execFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-test-token-123', + email: 'test@example.com', + }, + })); + + // First call + getCredentialsFromKeychain(); + // Second call with forceRefresh + getCredentialsFromKeychain(undefined, true); + + expect(execFileSync).toHaveBeenCalledTimes(2); + }); + }); + + describe('getCredentialsFromKeychain (Linux)', () => { + beforeEach(() => { + vi.mocked(isMacOS).mockReturnValue(false); + vi.mocked(isWindows).mockReturnValue(false); + vi.mocked(isLinux).mockReturnValue(true); + vi.mocked(homedir).mockReturnValue('/home/testuser'); + }); + + it('should return credentials from .credentials.json', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-linux-token-456', + email: 'linux@example.com', + }, + })); + + const result = getCredentialsFromKeychain(); + + expect(result.token).toBe('sk-ant-linux-token-456'); + expect(result.email).toBe('linux@example.com'); + expect(result.error).toBeUndefined(); + }); + + it('should return null when credentials file not found', () => { + vi.mocked(existsSync).mockReturnValue(false); + + const result = getCredentialsFromKeychain(); + + expect(result.token).toBeNull(); + expect(result.email).toBeNull(); + }); + + it('should use custom configDir for credentials path', () => { + const customConfigDir = '/home/user/.claude-profiles/work'; + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-custom-token', + email: 'custom@example.com', + }, + })); + + const result = getCredentialsFromKeychain(customConfigDir); + + expect(existsSync).toHaveBeenCalledWith(join(customConfigDir, '.credentials.json')); + expect(result.token).toBe('sk-ant-custom-token'); + }); + + it('should handle emailAddress field (alternative email location)', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-test-token', + emailAddress: 'alternative@example.com', + }, + })); + + const result = getCredentialsFromKeychain(); + + expect(result.email).toBe('alternative@example.com'); + }); + + it('should handle top-level email field', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-test-token', + }, + email: 'toplevel@example.com', + })); + + const result = getCredentialsFromKeychain(); + + expect(result.email).toBe('toplevel@example.com'); + }); + + it('should handle file read permission errors', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockImplementation(() => { + throw new Error('EACCES: permission denied'); + }); + + const result = getCredentialsFromKeychain(); + + expect(result.token).toBeNull(); + expect(result.email).toBeNull(); + }); + }); + + describe('getCredentialsFromKeychain (Windows)', () => { + beforeEach(() => { + vi.mocked(isMacOS).mockReturnValue(false); + vi.mocked(isWindows).mockReturnValue(true); + vi.mocked(isLinux).mockReturnValue(false); + vi.mocked(homedir).mockReturnValue('C:\\Users\\TestUser'); + }); + + it('should return null when PowerShell not found', () => { + vi.mocked(existsSync).mockReturnValue(false); + + const result = getCredentialsFromKeychain(); + + expect(result.token).toBeNull(); + expect(result.email).toBeNull(); + expect(result.error).toBe('PowerShell not found'); + }); + + it('should return credentials from Windows Credential Manager', () => { + // Mock PowerShell path found + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(execFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-windows-token-789', + email: 'windows@example.com', + }, + })); + + const result = getCredentialsFromKeychain(); + + expect(result.token).toBe('sk-ant-windows-token-789'); + expect(result.email).toBe('windows@example.com'); + }); + + it('should return null when credential not found', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(execFileSync).mockReturnValue(''); + + const result = getCredentialsFromKeychain(); + + expect(result.token).toBeNull(); + expect(result.email).toBeNull(); + }); + + it('should handle invalid JSON from Credential Manager', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(execFileSync).mockReturnValue('invalid json'); + + const result = getCredentialsFromKeychain(); + + expect(result.token).toBeNull(); + expect(result.email).toBeNull(); + }); + }); + + describe('getCredentialsFromKeychain (unsupported platform)', () => { + beforeEach(() => { + vi.mocked(isMacOS).mockReturnValue(false); + vi.mocked(isWindows).mockReturnValue(false); + vi.mocked(isLinux).mockReturnValue(false); + }); + + it('should return error for unsupported platform', () => { + const result = getCredentialsFromKeychain(); + + expect(result.token).toBeNull(); + expect(result.email).toBeNull(); + expect(result.error).toContain('Unsupported platform'); + }); + }); + + describe('getCredentials alias', () => { + it('should be an alias for getCredentialsFromKeychain', () => { + expect(getCredentials).toBe(getCredentialsFromKeychain); + }); + }); + + describe('clearKeychainCache', () => { + beforeEach(() => { + vi.mocked(isMacOS).mockReturnValue(true); + vi.mocked(existsSync).mockReturnValue(true); + }); + + it('should clear all caches when no configDir provided', () => { + vi.mocked(execFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { accessToken: 'sk-ant-test', email: 'test@test.com' }, + })); + + // Prime the cache + getCredentialsFromKeychain(); + expect(execFileSync).toHaveBeenCalledTimes(1); + + // Clear cache + clearKeychainCache(); + + // Should fetch again + getCredentialsFromKeychain(); + expect(execFileSync).toHaveBeenCalledTimes(2); + }); + + it('should clear specific profile cache when configDir provided', () => { + vi.mocked(execFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { accessToken: 'sk-ant-test', email: 'test@test.com' }, + })); + + const configDir = '/custom/path'; + + // Prime the cache + getCredentialsFromKeychain(configDir); + expect(execFileSync).toHaveBeenCalledTimes(1); + + // Clear specific cache + clearKeychainCache(configDir); + + // Should fetch again + getCredentialsFromKeychain(configDir); + expect(execFileSync).toHaveBeenCalledTimes(2); + }); + }); + + describe('clearCredentialCache alias', () => { + it('should be an alias for clearKeychainCache', () => { + expect(clearCredentialCache).toBe(clearKeychainCache); + }); + }); + + describe('token validation', () => { + beforeEach(() => { + vi.mocked(isMacOS).mockReturnValue(true); + vi.mocked(existsSync).mockReturnValue(true); + }); + + it('should accept tokens starting with sk-ant-', () => { + const validTokens = [ + 'sk-ant-oat01-test', + 'sk-ant-oat02-test', + 'sk-ant-api-key', + ]; + + for (const token of validTokens) { + clearCredentialCache(); + vi.mocked(execFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { accessToken: token, email: 'test@test.com' }, + })); + + const result = getCredentialsFromKeychain(); + expect(result.token).toBe(token); + } + }); + + it('should reject tokens not starting with sk-ant-', () => { + const invalidTokens = [ + 'invalid-token', + 'sk-api-key', + 'api-key-123', + ]; + + for (const token of invalidTokens) { + clearCredentialCache(); + vi.mocked(execFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { accessToken: token, email: 'test@test.com' }, + })); + + const result = getCredentialsFromKeychain(); + expect(result.token).toBeNull(); + } + }); + + it('should reject empty token string', () => { + clearCredentialCache(); + vi.mocked(execFileSync).mockReturnValue(JSON.stringify({ + claudeAiOauth: { accessToken: '', email: 'test@test.com' }, + })); + + const result = getCredentialsFromKeychain(); + expect(result.token).toBeNull(); + expect(result.email).toBe('test@test.com'); + }); + }); +}); diff --git a/apps/frontend/src/main/claude-profile/credential-utils.ts b/apps/frontend/src/main/claude-profile/credential-utils.ts new file mode 100644 index 00000000..32659755 --- /dev/null +++ b/apps/frontend/src/main/claude-profile/credential-utils.ts @@ -0,0 +1,690 @@ +/** + * Cross-Platform Credential Utilities + * + * Provides functions to retrieve Claude Code OAuth tokens and email from + * platform-specific secure storage: + * - macOS: Keychain (via `security` command) + * - Linux: .credentials.json file in config directory + * - Windows: Windows Credential Manager (via PowerShell) + * + * Supports both: + * - Default profile: "Claude Code-credentials" service / default config dir + * - Custom profiles: "Claude Code-credentials-{sha256-8-hash}" where hash is first 8 chars + * of SHA256 hash of the CLAUDE_CONFIG_DIR path + * + * Mirrors the functionality of apps/backend/core/auth.py get_token_from_keychain() + */ + +import { execFileSync } from 'child_process'; +import { createHash } from 'crypto'; +import { existsSync, readFileSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import { isMacOS, isWindows, isLinux } from '../platform'; + +/** + * Create a safe fingerprint of a token for debug logging. + * Shows first 8 and last 4 characters, hiding the sensitive middle portion. + * This is NOT for authentication - only for human-readable debug identification. + * + * @param token - The token to create a fingerprint for + * @returns A safe fingerprint like "sk-ant-oa...xyz9" or "null" if no token + */ +function getTokenFingerprint(token: string | null | undefined): string { + if (!token) return 'null'; + if (token.length <= 16) return token.slice(0, 4) + '...' + token.slice(-2); + return token.slice(0, 8) + '...' + token.slice(-4); +} + +/** + * Credentials retrieved from platform-specific secure storage + */ +export interface PlatformCredentials { + token: string | null; + email: string | null; + error?: string; // Set when credential access fails (locked, permission denied, etc.) +} + +// Legacy alias for backwards compatibility +export type KeychainCredentials = PlatformCredentials; + +/** + * Cache for credentials to avoid repeated blocking calls + * Map key is the cache key (e.g., "macos:Claude Code-credentials" or "linux:/home/user/.claude") + */ +interface CredentialCacheEntry { + credentials: PlatformCredentials; + timestamp: number; +} + +const credentialCache = new Map(); +// Cache for 5 minutes (300,000 ms) for successful results +const CACHE_TTL_MS = 5 * 60 * 1000; +// Cache for 10 seconds for error results (allows quick retry after unlock) +const ERROR_CACHE_TTL_MS = 10 * 1000; + +// Timeouts for credential retrieval operations +const MACOS_KEYCHAIN_TIMEOUT_MS = 5000; +const WINDOWS_CREDMAN_TIMEOUT_MS = 10000; + +// Defense-in-depth: Pattern for valid credential target names +// Matches "Claude Code-credentials" or "Claude Code-credentials-{8 hex chars}" +const VALID_TARGET_NAME_PATTERN = /^Claude Code-credentials(-[a-f0-9]{8})?$/; + +/** + * Validate that a credential target name matches the expected format. + * Defense-in-depth check to prevent injection attacks. + * + * @param targetName - The target name to validate + * @returns true if valid, false otherwise + */ +function isValidTargetName(targetName: string): boolean { + return VALID_TARGET_NAME_PATTERN.test(targetName); +} + +/** + * Validate that a credentials path is within expected boundaries. + * Defense-in-depth check to prevent path traversal attacks. + * + * @param credentialsPath - The path to validate + * @returns true if valid, false otherwise + */ +function isValidCredentialsPath(credentialsPath: string): boolean { + // Credentials path should: + // 1. Not contain path traversal sequences (works on both Unix and Windows) + // 2. End with the expected file name + // Note: We allow custom config directories since they come from user settings + // The configDir is from profile settings, which is trusted user input + return ( + !credentialsPath.includes('..') && + credentialsPath.endsWith('.credentials.json') + ); +} + +/** + * Calculate the credential storage identifier suffix for a config directory. + * Claude Code uses SHA256 hash of the config dir path, taking first 8 hex chars. + * + * @param configDir - The CLAUDE_CONFIG_DIR path + * @returns The 8-character hex hash suffix + */ +export function calculateConfigDirHash(configDir: string): string { + return createHash('sha256').update(configDir).digest('hex').slice(0, 8); +} + +/** + * Get the Keychain service name for a config directory (macOS). + * + * @param configDir - Optional CLAUDE_CONFIG_DIR path. If not provided, returns default service name. + * @returns The Keychain service name (e.g., "Claude Code-credentials-d74c9506") + */ +export function getKeychainServiceName(configDir?: string): string { + if (!configDir) { + return 'Claude Code-credentials'; + } + const hash = calculateConfigDirHash(configDir); + return `Claude Code-credentials-${hash}`; +} + +/** + * Get the Windows Credential Manager target name for a config directory. + * + * @param configDir - Optional CLAUDE_CONFIG_DIR path. If not provided, returns default target name. + * @returns The Credential Manager target name (e.g., "Claude Code-credentials-d74c9506") + */ +export function getWindowsCredentialTarget(configDir?: string): string { + // Windows uses the same naming convention as macOS Keychain + return getKeychainServiceName(configDir); +} + +/** + * Validate the structure of parsed credential JSON data + * @param data - Parsed JSON data from credential store + * @returns true if data structure is valid, false otherwise + */ +function validateCredentialData(data: unknown): data is { claudeAiOauth?: { accessToken?: string; email?: string; emailAddress?: string }; email?: string } { + if (!data || typeof data !== 'object') { + return false; + } + + const obj = data as Record; + + // Check if claudeAiOauth exists and is an object + if (obj.claudeAiOauth !== undefined) { + if (typeof obj.claudeAiOauth !== 'object' || obj.claudeAiOauth === null) { + return false; + } + const oauth = obj.claudeAiOauth as Record; + // Validate accessToken if present + if (oauth.accessToken !== undefined && typeof oauth.accessToken !== 'string') { + return false; + } + // Validate email if present (can be 'email' or 'emailAddress') + if (oauth.email !== undefined && typeof oauth.email !== 'string') { + return false; + } + if (oauth.emailAddress !== undefined && typeof oauth.emailAddress !== 'string') { + return false; + } + } + + // Validate top-level email if present + if (obj.email !== undefined && typeof obj.email !== 'string') { + return false; + } + + return true; +} + +/** + * Extract token and email from validated credential data + */ +function extractCredentials(data: { claudeAiOauth?: { accessToken?: string; email?: string; emailAddress?: string }; email?: string }): { token: string | null; email: string | null } { + // Extract OAuth token from nested structure + const token = data?.claudeAiOauth?.accessToken || null; + + // Extract email (might be in different locations depending on Claude Code version) + const email = data?.claudeAiOauth?.email || data?.claudeAiOauth?.emailAddress || data?.email || null; + + return { token, email }; +} + +/** + * Validate token format + * Use 'sk-ant-' prefix to support future token format versions (oat02, oat03, etc.) + */ +function isValidTokenFormat(token: string): boolean { + return token.startsWith('sk-ant-'); +} + +// ============================================================================= +// macOS Keychain Implementation +// ============================================================================= + +/** + * Retrieve credentials from macOS Keychain + */ +function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = false): PlatformCredentials { + const serviceName = getKeychainServiceName(configDir); + const cacheKey = `macos:${serviceName}`; + const isDebug = process.env.DEBUG === 'true'; + const now = Date.now(); + + // Return cached credentials if available and fresh + const cached = credentialCache.get(cacheKey); + if (!forceRefresh && cached) { + const ttl = cached.credentials.error ? ERROR_CACHE_TTL_MS : CACHE_TTL_MS; + if ((now - cached.timestamp) < ttl) { + if (isDebug) { + const cacheAge = now - cached.timestamp; + console.warn('[CredentialUtils:macOS:CACHE] Returning cached credentials:', { + serviceName, + hasToken: !!cached.credentials.token, + tokenFingerprint: getTokenFingerprint(cached.credentials.token), + cacheAge: Math.round(cacheAge / 1000) + 's' + }); + } + return cached.credentials; + } + } + + // Locate the security executable + let securityPath: string | null = null; + const candidatePaths = ['/usr/bin/security', '/bin/security']; + + for (const candidate of candidatePaths) { + if (existsSync(candidate)) { + securityPath = candidate; + break; + } + } + + if (!securityPath) { + const notFoundResult = { token: null, email: null, error: 'macOS security command not found' }; + credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now }); + return notFoundResult; + } + + try { + // Query macOS Keychain for Claude Code credentials + const result = execFileSync( + securityPath, + ['find-generic-password', '-s', serviceName, '-w'], + { + encoding: 'utf-8', + timeout: MACOS_KEYCHAIN_TIMEOUT_MS, + windowsHide: true, + } + ); + + const credentialsJson = result.trim(); + if (!credentialsJson) { + const emptyResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: emptyResult, timestamp: now }); + return emptyResult; + } + + // Parse JSON response + let data: unknown; + try { + data = JSON.parse(credentialsJson); + } catch { + console.warn('[CredentialUtils:macOS] Failed to parse Keychain JSON for service:', serviceName); + const errorResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now }); + return errorResult; + } + + // Validate JSON structure + if (!validateCredentialData(data)) { + console.warn('[CredentialUtils:macOS] Invalid Keychain data structure for service:', serviceName); + const invalidResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now }); + return invalidResult; + } + + const { token, email } = extractCredentials(data); + + // Validate token format if present + if (token && !isValidTokenFormat(token)) { + console.warn('[CredentialUtils:macOS] Invalid token format for service:', serviceName); + const result = { token: null, email }; + credentialCache.set(cacheKey, { credentials: result, timestamp: now }); + return result; + } + + const credentials = { token, email }; + credentialCache.set(cacheKey, { credentials, timestamp: now }); + + if (isDebug) { + console.warn('[CredentialUtils:macOS] Retrieved credentials from Keychain for service:', serviceName, { + hasToken: !!token, + hasEmail: !!email, + tokenFingerprint: getTokenFingerprint(token), + forceRefresh + }); + } + return credentials; + } catch (error) { + // Check for exit code 44 (errSecItemNotFound) which indicates item not found + if (error && typeof error === 'object' && 'status' in error && error.status === 44) { + const notFoundResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now }); + return notFoundResult; + } + + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn('[CredentialUtils:macOS] Keychain access failed for service:', serviceName, errorMessage); + const errorResult = { token: null, email: null, error: `Keychain access failed: ${errorMessage}` }; + // Use shorter TTL for errors + credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now }); + return errorResult; + } +} + +// ============================================================================= +// Linux Credentials File Implementation +// ============================================================================= + +/** + * Get the credentials file path for Linux + */ +function getLinuxCredentialsPath(configDir?: string): string { + const baseDir = configDir || join(homedir(), '.claude'); + return join(baseDir, '.credentials.json'); +} + +/** + * Retrieve credentials from Linux .credentials.json file + */ +function getCredentialsFromLinuxFile(configDir?: string, forceRefresh = false): PlatformCredentials { + const credentialsPath = getLinuxCredentialsPath(configDir); + const cacheKey = `linux:${credentialsPath}`; + const isDebug = process.env.DEBUG === 'true'; + const now = Date.now(); + + // Return cached credentials if available and fresh + const cached = credentialCache.get(cacheKey); + if (!forceRefresh && cached) { + const ttl = cached.credentials.error ? ERROR_CACHE_TTL_MS : CACHE_TTL_MS; + if ((now - cached.timestamp) < ttl) { + if (isDebug) { + const cacheAge = now - cached.timestamp; + console.warn('[CredentialUtils:Linux:CACHE] Returning cached credentials:', { + credentialsPath, + hasToken: !!cached.credentials.token, + tokenFingerprint: getTokenFingerprint(cached.credentials.token), + cacheAge: Math.round(cacheAge / 1000) + 's' + }); + } + return cached.credentials; + } + } + + // Defense-in-depth: Validate credentials path is within expected boundaries + if (!isValidCredentialsPath(credentialsPath)) { + if (isDebug) { + console.warn('[CredentialUtils:Linux] Invalid credentials path rejected:', { credentialsPath }); + } + const invalidResult = { token: null, email: null, error: 'Invalid credentials path' }; + credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now }); + return invalidResult; + } + + // Check if credentials file exists + if (!existsSync(credentialsPath)) { + if (isDebug) { + console.warn('[CredentialUtils:Linux] Credentials file not found:', credentialsPath); + } + const notFoundResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now }); + return notFoundResult; + } + + try { + const content = readFileSync(credentialsPath, 'utf-8'); + + // Parse JSON + let data: unknown; + try { + data = JSON.parse(content); + } catch { + console.warn('[CredentialUtils:Linux] Failed to parse credentials JSON:', credentialsPath); + const errorResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now }); + return errorResult; + } + + // Validate JSON structure + if (!validateCredentialData(data)) { + console.warn('[CredentialUtils:Linux] Invalid credentials data structure:', credentialsPath); + const invalidResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now }); + return invalidResult; + } + + const { token, email } = extractCredentials(data); + + // Validate token format if present + if (token && !isValidTokenFormat(token)) { + console.warn('[CredentialUtils:Linux] Invalid token format in:', credentialsPath); + const result = { token: null, email }; + credentialCache.set(cacheKey, { credentials: result, timestamp: now }); + return result; + } + + const credentials = { token, email }; + credentialCache.set(cacheKey, { credentials, timestamp: now }); + + if (isDebug) { + console.warn('[CredentialUtils:Linux] Retrieved credentials from file:', credentialsPath, { + hasToken: !!token, + hasEmail: !!email, + tokenFingerprint: getTokenFingerprint(token), + forceRefresh + }); + } + return credentials; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn('[CredentialUtils:Linux] Failed to read credentials file:', credentialsPath, errorMessage); + const errorResult = { token: null, email: null, error: `Failed to read credentials: ${errorMessage}` }; + credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now }); + return errorResult; + } +} + +// ============================================================================= +// Windows Credential Manager Implementation +// ============================================================================= + +/** + * Retrieve credentials from Windows Credential Manager using PowerShell + * + * Windows Credential Manager stores credentials with: + * - Target Name: "Claude Code-credentials" or "Claude Code-credentials-{hash}" + * - Type: Generic credential + * - Password field contains JSON with { claudeAiOauth: { accessToken, email } } + */ +function getCredentialsFromWindowsCredentialManager(configDir?: string, forceRefresh = false): PlatformCredentials { + const targetName = getWindowsCredentialTarget(configDir); + const cacheKey = `windows:${targetName}`; + const isDebug = process.env.DEBUG === 'true'; + const now = Date.now(); + + // Return cached credentials if available and fresh + const cached = credentialCache.get(cacheKey); + if (!forceRefresh && cached) { + const ttl = cached.credentials.error ? ERROR_CACHE_TTL_MS : CACHE_TTL_MS; + if ((now - cached.timestamp) < ttl) { + if (isDebug) { + const cacheAge = now - cached.timestamp; + console.warn('[CredentialUtils:Windows:CACHE] Returning cached credentials:', { + targetName, + hasToken: !!cached.credentials.token, + tokenFingerprint: getTokenFingerprint(cached.credentials.token), + cacheAge: Math.round(cacheAge / 1000) + 's' + }); + } + return cached.credentials; + } + } + + // Defense-in-depth: Validate target name format before using in PowerShell + if (!isValidTargetName(targetName)) { + const invalidResult = { token: null, email: null, error: 'Invalid credential target name format' }; + credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now }); + if (isDebug) { + console.warn('[CredentialUtils:Windows] Invalid target name rejected:', { targetName }); + } + return invalidResult; + } + + // Find PowerShell executable + const psPath = findPowerShellPath(); + if (!psPath) { + const notFoundResult = { token: null, email: null, error: 'PowerShell not found' }; + credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now }); + return notFoundResult; + } + + try { + // PowerShell script to read from Credential Manager + // Uses the Windows Credential Manager API via .NET + const psScript = ` + $ErrorActionPreference = 'Stop' + Add-Type -AssemblyName System.Runtime.WindowsRuntime + + # Use CredRead from advapi32.dll to read generic credentials + $sig = @' + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CredRead(string target, int type, int reservedFlag, out IntPtr credentialPtr); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool CredFree(IntPtr cred); +'@ + Add-Type -MemberDefinition $sig -Namespace Win32 -Name Credential + + $credPtr = [IntPtr]::Zero + # CRED_TYPE_GENERIC = 1 + $success = [Win32.Credential]::CredRead("${targetName.replace(/"/g, '`"')}", 1, 0, [ref]$credPtr) + + if ($success) { + try { + $cred = [Runtime.InteropServices.Marshal]::PtrToStructure($credPtr, [Type][System.Management.Automation.PSCredential].Assembly.GetType('Microsoft.PowerShell.Commands.CREDENTIAL')) + + # Read the credential blob (password field) + $blobSize = $cred.CredentialBlobSize + if ($blobSize -gt 0) { + $blob = [byte[]]::new($blobSize) + [Runtime.InteropServices.Marshal]::Copy($cred.CredentialBlob, $blob, 0, $blobSize) + $password = [System.Text.Encoding]::Unicode.GetString($blob) + Write-Output $password + } + } finally { + [Win32.Credential]::CredFree($credPtr) | Out-Null + } + } else { + # Credential not found - this is expected if user hasn't authenticated + Write-Output "" + } + `; + + const result = execFileSync( + psPath, + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', psScript], + { + encoding: 'utf-8', + timeout: WINDOWS_CREDMAN_TIMEOUT_MS, + windowsHide: true, + } + ); + + const credentialsJson = result.trim(); + if (!credentialsJson) { + if (isDebug) { + console.warn('[CredentialUtils:Windows] Credential not found for target:', targetName); + } + const notFoundResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now }); + return notFoundResult; + } + + // Parse JSON response + let data: unknown; + try { + data = JSON.parse(credentialsJson); + } catch { + console.warn('[CredentialUtils:Windows] Failed to parse credential JSON for target:', targetName); + const errorResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now }); + return errorResult; + } + + // Validate JSON structure + if (!validateCredentialData(data)) { + console.warn('[CredentialUtils:Windows] Invalid credential data structure for target:', targetName); + const invalidResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now }); + return invalidResult; + } + + const { token, email } = extractCredentials(data); + + // Validate token format if present + if (token && !isValidTokenFormat(token)) { + console.warn('[CredentialUtils:Windows] Invalid token format for target:', targetName); + const result = { token: null, email }; + credentialCache.set(cacheKey, { credentials: result, timestamp: now }); + return result; + } + + const credentials = { token, email }; + credentialCache.set(cacheKey, { credentials, timestamp: now }); + + if (isDebug) { + console.warn('[CredentialUtils:Windows] Retrieved credentials from Credential Manager for target:', targetName, { + hasToken: !!token, + hasEmail: !!email, + tokenFingerprint: getTokenFingerprint(token), + forceRefresh + }); + } + return credentials; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn('[CredentialUtils:Windows] Credential Manager access failed for target:', targetName, errorMessage); + const errorResult = { token: null, email: null, error: `Credential Manager access failed: ${errorMessage}` }; + credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now }); + return errorResult; + } +} + +/** + * Find PowerShell executable path on Windows + */ +function findPowerShellPath(): string | null { + // Prefer PowerShell 7+ (pwsh) over Windows PowerShell + const candidatePaths = [ + join(process.env.ProgramFiles || 'C:\\Program Files', 'PowerShell', '7', 'pwsh.exe'), + join(homedir(), 'AppData', 'Local', 'Microsoft', 'WindowsApps', 'pwsh.exe'), + join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + ]; + + for (const candidate of candidatePaths) { + if (existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +// ============================================================================= +// Cross-Platform Public API +// ============================================================================= + +/** + * Retrieve Claude Code OAuth credentials (token and email) from platform-specific + * secure storage. + * + * - macOS: Reads from Keychain + * - Linux: Reads from .credentials.json file + * - Windows: Reads from Windows Credential Manager + * + * For default profile: reads from "Claude Code-credentials" or default config dir + * For custom profiles: uses SHA256(configDir).slice(0,8) hash suffix + * + * Uses caching (5-minute TTL) to avoid repeated blocking calls. + * + * @param configDir - Optional CLAUDE_CONFIG_DIR path for custom profiles + * @param forceRefresh - Set to true to bypass cache and fetch fresh credentials + * @returns Object with token and email (both may be null if not found or invalid) + */ +export function getCredentialsFromKeychain(configDir?: string, forceRefresh = false): PlatformCredentials { + if (isMacOS()) { + return getCredentialsFromMacOSKeychain(configDir, forceRefresh); + } + + if (isLinux()) { + return getCredentialsFromLinuxFile(configDir, forceRefresh); + } + + if (isWindows()) { + return getCredentialsFromWindowsCredentialManager(configDir, forceRefresh); + } + + // Unknown platform - return empty + return { token: null, email: null, error: `Unsupported platform: ${process.platform}` }; +} + +/** + * Alias for getCredentialsFromKeychain for semantic clarity on non-macOS platforms + */ +export const getCredentials = getCredentialsFromKeychain; + +/** + * Clear the credentials cache for a specific profile or all profiles. + * Useful when you know the credentials have changed (e.g., after running claude /login) + * + * @param configDir - Optional config dir to clear cache for specific profile. If not provided, clears all. + */ +export function clearKeychainCache(configDir?: string): void { + if (configDir) { + // Clear cache for this specific configDir on all platforms + const macOSKey = `macos:${getKeychainServiceName(configDir)}`; + const linuxKey = `linux:${getLinuxCredentialsPath(configDir)}`; + const windowsKey = `windows:${getWindowsCredentialTarget(configDir)}`; + + credentialCache.delete(macOSKey); + credentialCache.delete(linuxKey); + credentialCache.delete(windowsKey); + } else { + credentialCache.clear(); + } +} + +/** + * Alias for clearKeychainCache for semantic clarity + */ +export const clearCredentialCache = clearKeychainCache; diff --git a/apps/frontend/src/main/claude-profile/keychain-utils.ts b/apps/frontend/src/main/claude-profile/keychain-utils.ts deleted file mode 100644 index d4d70fc6..00000000 --- a/apps/frontend/src/main/claude-profile/keychain-utils.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** - * macOS Keychain Utilities - * - * Provides functions to retrieve Claude Code OAuth tokens and email from macOS Keychain. - * Supports both: - * - Default profile: "Claude Code-credentials" service - * - Custom profiles: "Claude Code-credentials-{sha256-8-hash}" where hash is first 8 chars - * of SHA256 hash of the CLAUDE_CONFIG_DIR path - * - * Mirrors the functionality of apps/backend/core/auth.py get_token_from_keychain() - */ - -import { execFileSync } from 'child_process'; -import { createHash } from 'crypto'; -import { existsSync } from 'fs'; -import { isMacOS } from '../platform'; - -/** - * Credentials retrieved from macOS Keychain - */ -export interface KeychainCredentials { - token: string | null; - email: string | null; - error?: string; // Set when keychain access fails (locked, permission denied, etc.) -} - -/** - * Cache for keychain credentials to avoid repeated blocking calls - * Map key is the service name (e.g., "Claude Code-credentials" or "Claude Code-credentials-d74c9506") - */ -interface KeychainCacheEntry { - credentials: KeychainCredentials; - timestamp: number; -} - -const keychainCache = new Map(); -// Cache for 5 minutes (300,000 ms) for successful results -const CACHE_TTL_MS = 5 * 60 * 1000; -// Cache for 10 seconds for error results (allows quick retry after keychain unlock) -const ERROR_CACHE_TTL_MS = 10 * 1000; - -/** - * Calculate the Keychain service name suffix for a config directory. - * Claude Code uses SHA256 hash of the config dir path, taking first 8 hex chars. - * - * @param configDir - The CLAUDE_CONFIG_DIR path - * @returns The 8-character hex hash suffix - */ -export function calculateConfigDirHash(configDir: string): string { - return createHash('sha256').update(configDir).digest('hex').slice(0, 8); -} - -/** - * Get the Keychain service name for a config directory. - * - * @param configDir - Optional CLAUDE_CONFIG_DIR path. If not provided, returns default service name. - * @returns The Keychain service name (e.g., "Claude Code-credentials-d74c9506") - */ -export function getKeychainServiceName(configDir?: string): string { - if (!configDir) { - return 'Claude Code-credentials'; - } - const hash = calculateConfigDirHash(configDir); - return `Claude Code-credentials-${hash}`; -} - -/** - * Validate the structure of parsed Keychain JSON data - * @param data - Parsed JSON data from Keychain - * @returns true if data structure is valid, false otherwise - */ -function validateKeychainData(data: unknown): data is { claudeAiOauth?: { accessToken?: string; email?: string }; email?: string } { - if (!data || typeof data !== 'object') { - return false; - } - - const obj = data as Record; - - // Check if claudeAiOauth exists and is an object - if (obj.claudeAiOauth !== undefined) { - if (typeof obj.claudeAiOauth !== 'object' || obj.claudeAiOauth === null) { - return false; - } - const oauth = obj.claudeAiOauth as Record; - // Validate accessToken if present - if (oauth.accessToken !== undefined && typeof oauth.accessToken !== 'string') { - return false; - } - // Validate email if present - if (oauth.email !== undefined && typeof oauth.email !== 'string') { - return false; - } - } - - // Validate top-level email if present - if (obj.email !== undefined && typeof obj.email !== 'string') { - return false; - } - - return true; -} - -/** - * Retrieve Claude Code OAuth credentials (token and email) from macOS Keychain. - * - * For default profile: reads from "Claude Code-credentials" - * For custom profiles: reads from "Claude Code-credentials-{hash}" where hash is - * SHA256(configDir).slice(0,8) - * - * Uses caching (5-minute TTL) to avoid repeated blocking calls. - * Only works on macOS (Darwin platform). - * - * @param configDir - Optional CLAUDE_CONFIG_DIR path for custom profiles - * @param forceRefresh - Set to true to bypass cache and fetch fresh credentials - * @returns Object with token and email (both may be null if not found or invalid) - */ -export function getCredentialsFromKeychain(configDir?: string, forceRefresh = false): KeychainCredentials { - // Only attempt on macOS - if (!isMacOS()) { - return { token: null, email: null }; - } - - const serviceName = getKeychainServiceName(configDir); - - // Return cached credentials if available and fresh - const now = Date.now(); - const cached = keychainCache.get(serviceName); - if (!forceRefresh && cached && (now - cached.timestamp) < CACHE_TTL_MS) { - return cached.credentials; - } - - // Locate the security executable using platform abstraction - let securityPath: string | null = null; - try { - // The 'security' command is macOS-specific and typically in /usr/bin - // Try common macOS locations instead of hardcoding - const candidatePaths = ['/usr/bin/security', '/bin/security']; - - for (const candidate of candidatePaths) { - if (existsSync(candidate)) { - securityPath = candidate; - break; - } - } - - if (!securityPath) { - // Security command not found - this is expected on non-macOS or if security is missing - const notFoundResult = { token: null, email: null, error: 'macOS security command not found' }; - keychainCache.set(serviceName, { credentials: notFoundResult, timestamp: now }); - return notFoundResult; - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.warn('[KeychainUtils] Failed to locate security executable:', errorMessage); - const errorResult = { token: null, email: null, error: `Failed to locate security executable: ${errorMessage}` }; - keychainCache.set(serviceName, { credentials: errorResult, timestamp: now - (CACHE_TTL_MS - ERROR_CACHE_TTL_MS) }); - return errorResult; - } - - try { - // Query macOS Keychain for Claude Code credentials - // Use execFileSync with argument array to prevent command injection - const result = execFileSync( - securityPath, - ['find-generic-password', '-s', serviceName, '-w'], - { - encoding: 'utf-8', - timeout: 5000, - windowsHide: true, - } - ); - - const credentialsJson = result.trim(); - if (!credentialsJson) { - const emptyResult = { token: null, email: null }; - keychainCache.set(serviceName, { credentials: emptyResult, timestamp: now }); - return emptyResult; - } - - // Parse JSON response - let data: unknown; - try { - data = JSON.parse(credentialsJson); - } catch { - console.warn('[KeychainUtils] Failed to parse Keychain JSON for service:', serviceName); - const errorResult = { token: null, email: null }; - keychainCache.set(serviceName, { credentials: errorResult, timestamp: now }); - return errorResult; - } - - // Validate JSON structure - if (!validateKeychainData(data)) { - console.warn('[KeychainUtils] Invalid Keychain data structure for service:', serviceName); - const invalidResult = { token: null, email: null }; - keychainCache.set(serviceName, { credentials: invalidResult, timestamp: now }); - return invalidResult; - } - - // Extract OAuth token from nested structure - const token = data?.claudeAiOauth?.accessToken; - - // Extract email (might be in different locations depending on Claude Code version) - const email = data?.claudeAiOauth?.email || data?.email || null; - - // Validate token format if present - // Use 'sk-ant-' prefix instead of 'sk-ant-oat01-' to support future token format versions - // (e.g., oat02, oat03, etc.) without breaking validation - if (token && !token.startsWith('sk-ant-')) { - console.warn('[KeychainUtils] Invalid token format for service:', serviceName); - const result = { token: null, email }; - keychainCache.set(serviceName, { credentials: result, timestamp: now }); - return result; - } - - const credentials = { token: token || null, email }; - keychainCache.set(serviceName, { credentials, timestamp: now }); - console.debug('[KeychainUtils] Retrieved credentials from Keychain for service:', serviceName, { hasToken: !!token, hasEmail: !!email }); - return credentials; - } catch (error) { - // Check for exit code 44 (errSecItemNotFound) which indicates item not found - if (error && typeof error === 'object' && 'status' in error && error.status === 44) { - // Item not found - this is expected if user hasn't authenticated yet - const notFoundResult = { token: null, email: null }; - keychainCache.set(serviceName, { credentials: notFoundResult, timestamp: now }); - return notFoundResult; - } - - // Other errors (keychain locked, access denied, etc.) - return error details - const errorMessage = error instanceof Error ? error.message : String(error); - console.warn('[KeychainUtils] Keychain access failed for service:', serviceName, errorMessage); - const errorResult = { token: null, email: null, error: `Keychain access failed: ${errorMessage}` }; - // Use shorter TTL for errors so users who unlock keychain see quick recovery - keychainCache.set(serviceName, { credentials: errorResult, timestamp: now - (CACHE_TTL_MS - ERROR_CACHE_TTL_MS) }); - return errorResult; - } -} - -/** - * Clear the keychain credentials cache for a specific service or all services. - * Useful when you know the credentials have changed (e.g., after running claude /login) - * - * @param configDir - Optional config dir to clear cache for specific profile. If not provided, clears all. - */ -export function clearKeychainCache(configDir?: string): void { - if (configDir) { - const serviceName = getKeychainServiceName(configDir); - keychainCache.delete(serviceName); - } else { - keychainCache.clear(); - } -} diff --git a/apps/frontend/src/main/claude-profile/profile-storage.ts b/apps/frontend/src/main/claude-profile/profile-storage.ts index a4c825e2..8d51e4c1 100644 --- a/apps/frontend/src/main/claude-profile/profile-storage.ts +++ b/apps/frontend/src/main/claude-profile/profile-storage.ts @@ -29,6 +29,8 @@ export interface ProfileStoreData { profiles: ClaudeProfile[]; activeProfileId: string; autoSwitch?: ClaudeAutoSwitchSettings; + /** Unified priority order for both OAuth and API profiles */ + accountPriorityOrder?: string[]; } /** @@ -45,22 +47,35 @@ function parseAndMigrateProfileData(data: Record): ProfileStore } if (data.version === STORE_VERSION) { - // Parse dates + // Parse dates and migrate profile data const profiles = data.profiles as ClaudeProfile[]; - data.profiles = profiles.map((p: ClaudeProfile) => ({ - ...p, - createdAt: new Date(p.createdAt), - lastUsedAt: p.lastUsedAt ? new Date(p.lastUsedAt) : undefined, - usage: p.usage ? { - ...p.usage, - lastUpdated: new Date(p.usage.lastUpdated) - } : undefined, - rateLimitEvents: p.rateLimitEvents?.map(e => ({ - ...e, - hitAt: new Date(e.hitAt), - resetAt: new Date(e.resetAt) - })) - })); + data.profiles = profiles.map((p: ClaudeProfile) => { + // MIGRATION: Clear cached oauthToken to prevent stale token issues + // OAuth tokens expire in 8-12 hours. We now read fresh tokens from Keychain + // instead of caching them. See: docs/LONG_LIVED_AUTH_PLAN.md + if (p.oauthToken) { + console.warn('[ProfileStorage] Migrating profile - removing cached oauthToken:', p.name); + } + + // Destructure to remove oauthToken and tokenCreatedAt from the profile + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { oauthToken: _, tokenCreatedAt: __, ...profileWithoutToken } = p; + + return { + ...profileWithoutToken, + createdAt: new Date(p.createdAt), + lastUsedAt: p.lastUsedAt ? new Date(p.lastUsedAt) : undefined, + usage: p.usage ? { + ...p.usage, + lastUpdated: new Date(p.usage.lastUpdated) + } : undefined, + rateLimitEvents: p.rateLimitEvents?.map(e => ({ + ...e, + hitAt: new Date(e.hitAt), + resetAt: new Date(e.resetAt) + })) + }; + }); return data as unknown as ProfileStoreData; } diff --git a/apps/frontend/src/main/claude-profile/profile-utils.ts b/apps/frontend/src/main/claude-profile/profile-utils.ts index e6d8ceea..f2aeac7a 100644 --- a/apps/frontend/src/main/claude-profile/profile-utils.ts +++ b/apps/frontend/src/main/claude-profile/profile-utils.ts @@ -128,23 +128,30 @@ export function isProfileAuthenticated(profile: ClaudeProfile): boolean { } /** - * Check if a profile has a valid OAuth token. - * Token is valid for 1 year from creation. + * Check if a profile has a valid OAuth token stored in the profile. + * + * DEPRECATED: This function checks for CACHED OAuth tokens which we no longer store. + * OAuth tokens expire in 8-12 hours, not 1 year. We now use CLAUDE_CONFIG_DIR + * to let Claude CLI read fresh tokens from Keychain on each invocation. + * + * This function is kept for backwards compatibility with existing profiles that + * have oauthToken stored. For these profiles, we return true (assuming token might + * still be valid) and let the actual API call determine if re-auth is needed. + * + * New profiles will NOT have oauthToken stored (per the auth flow changes). + * Use isProfileAuthenticated() to check for configDir-based credentials instead. + * + * See: docs/LONG_LIVED_AUTH_PLAN.md for full context. */ export function hasValidToken(profile: ClaudeProfile): boolean { if (!profile?.oauthToken) { return false; } - // Check if token is expired (1 year validity) - if (profile.tokenCreatedAt) { - const oneYearAgo = new Date(); - oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); - if (new Date(profile.tokenCreatedAt) < oneYearAgo) { - return false; - } - } - + // For legacy profiles with stored oauthToken, return true. + // The actual token validity is determined by the Keychain (via CLAUDE_CONFIG_DIR). + // We keep this for backwards compat to avoid breaking existing profiles during migration. + console.warn('[hasValidToken] DEPRECATED: Profile has cached oauthToken. Using CLAUDE_CONFIG_DIR for fresh tokens.'); return true; } @@ -158,3 +165,57 @@ export function expandHomePath(path: string): string { } return path; } + +/** + * Get the email address from a profile's Claude config file (.claude.json). + * + * This reads the email directly from Claude's config file, which is the authoritative + * source for the user's email. This is more reliable than parsing terminal output + * which may contain ANSI escape codes that corrupt the email. + * + * @param configDir - The profile's config directory (e.g., ~/.claude or ~/.claude-profiles/work) + * @returns The email address if found, null otherwise + */ +export function getEmailFromConfigDir(configDir?: string): string | null { + if (!configDir) { + return null; + } + + // Expand ~ to home directory + const expandedConfigDir = expandHomePath(configDir); + + // Check .claude.json (primary config file) + const claudeJsonPath = join(expandedConfigDir, '.claude.json'); + if (existsSync(claudeJsonPath)) { + try { + const content = readFileSync(claudeJsonPath, 'utf-8'); + const data = JSON.parse(content); + + // Check for oauthAccount.emailAddress (modern Claude Code CLI format) + if (data?.oauthAccount?.emailAddress && typeof data.oauthAccount.emailAddress === 'string') { + return data.oauthAccount.emailAddress; + } + } catch (error) { + console.warn(`[profile-utils] Failed to read email from ${claudeJsonPath}:`, error); + } + } + + // Fallback: check .credentials.json (used on some Linux setups) + const credentialsJsonPath = join(expandedConfigDir, '.credentials.json'); + if (existsSync(credentialsJsonPath)) { + try { + const content = readFileSync(credentialsJsonPath, 'utf-8'); + const data = JSON.parse(content); + + // Check claudeAiOauth.email or emailAddress + const email = data?.claudeAiOauth?.email || data?.claudeAiOauth?.emailAddress || data?.email; + if (email && typeof email === 'string') { + return email; + } + } catch (error) { + console.warn(`[profile-utils] Failed to read email from ${credentialsJsonPath}:`, error); + } + } + + return null; +} diff --git a/apps/frontend/src/main/claude-profile/usage-monitor.test.ts b/apps/frontend/src/main/claude-profile/usage-monitor.test.ts index 17c0f7b3..0928ce68 100644 --- a/apps/frontend/src/main/claude-profile/usage-monitor.test.ts +++ b/apps/frontend/src/main/claude-profile/usage-monitor.test.ts @@ -56,6 +56,15 @@ vi.mock('../services/profile/profile-manager', () => ({ loadProfilesFile: () => mockLoadProfilesFile() })); +// Mock credential-utils to return mock token instead of reading real credentials +vi.mock('./credential-utils', () => ({ + getCredentialsFromKeychain: vi.fn(() => ({ + token: 'mock-decrypted-token', + email: 'test@example.com' + })), + clearKeychainCache: vi.fn() +})); + // Mock global fetch global.fetch = vi.fn(() => Promise.resolve({ @@ -720,7 +729,7 @@ describe('usage-monitor', () => { // 401 errors should throw await expect( - monitor['fetchUsageViaAPI']('invalid-token', 'test-profile-1', 'Test Profile') + monitor['fetchUsageViaAPI']('invalid-token', 'test-profile-1', 'Test Profile', undefined) ).rejects.toThrow('API Auth Failure: 401'); expect(consoleSpy).toHaveBeenCalled(); @@ -751,7 +760,7 @@ describe('usage-monitor', () => { // 403 errors should throw await expect( - monitor['fetchUsageViaAPI']('expired-token', 'test-profile-1', 'Test Profile') + monitor['fetchUsageViaAPI']('expired-token', 'test-profile-1', 'Test Profile', undefined) ).rejects.toThrow('API Auth Failure: 403'); expect(consoleSpy).toHaveBeenCalled(); @@ -771,7 +780,7 @@ describe('usage-monitor', () => { const monitor = getUsageMonitor(); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile'); + const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile', undefined); expect(usage).toBeNull(); expect(consoleSpy).toHaveBeenCalled(); @@ -786,7 +795,7 @@ describe('usage-monitor', () => { const monitor = getUsageMonitor(); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile'); + const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile', undefined); expect(usage).toBeNull(); expect(consoleSpy).toHaveBeenCalled(); @@ -808,7 +817,7 @@ describe('usage-monitor', () => { const monitor = getUsageMonitor(); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile'); + const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile', undefined); expect(usage).toBeNull(); expect(consoleSpy).toHaveBeenCalled(); @@ -831,7 +840,7 @@ describe('usage-monitor', () => { // 401 errors should throw with proper message await expect( - monitor['fetchUsageViaAPI']('invalid-token', 'test-profile-1', 'Test Profile') + monitor['fetchUsageViaAPI']('invalid-token', 'test-profile-1', 'Test Profile', undefined) ).rejects.toThrow('API Auth Failure: 401'); expect(consoleSpy).toHaveBeenCalled(); @@ -945,7 +954,7 @@ describe('usage-monitor', () => { const monitor = getUsageMonitor(); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const usage = await monitor['fetchUsageViaAPI']('zai-api-key', 'zai-profile-1', 'z.ai Profile'); + const usage = await monitor['fetchUsageViaAPI']('zai-api-key', 'zai-profile-1', 'z.ai Profile', undefined); expect(usage).toBeNull(); expect(consoleSpy).toHaveBeenCalled(); @@ -977,7 +986,7 @@ describe('usage-monitor', () => { const monitor = getUsageMonitor(); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const usage = await monitor['fetchUsageViaAPI']('zhipu-api-key', 'zhipu-profile-1', 'ZHIPU Profile'); + const usage = await monitor['fetchUsageViaAPI']('zhipu-api-key', 'zhipu-profile-1', 'ZHIPU Profile', undefined); expect(usage).toBeNull(); expect(consoleSpy).toHaveBeenCalled(); @@ -1013,6 +1022,7 @@ describe('usage-monitor', () => { 'unknown-api-key', 'unknown-profile-1', 'Unknown Profile', + undefined, unknownProviderProfile ); @@ -1442,7 +1452,7 @@ describe('usage-monitor', () => { const profileId = 'test-profile-cooldown'; // Call fetchUsageViaAPI which should fail and record timestamp - await monitor['fetchUsageViaAPI']('valid-token', profileId, 'Test Profile'); + await monitor['fetchUsageViaAPI']('valid-token', profileId, 'Test Profile', undefined); // Verify failure timestamp was recorded const failureTimestamp = monitor['apiFailureTimestamps'].get(profileId); @@ -1569,6 +1579,7 @@ describe('usage-monitor', () => { 'sk-ant-api-key', 'api-profile-1', 'API Profile', + undefined, predeterminedProfile ); @@ -1615,6 +1626,7 @@ describe('usage-monitor', () => { 'sk-ant-api-key', 'api-profile-1', 'API Profile', + undefined, // No email undefined // No activeProfile passed ); @@ -1655,6 +1667,7 @@ describe('usage-monitor', () => { 'oauth-token', 'oauth-profile', 'OAuth Profile', + undefined, oauthProfile ); diff --git a/apps/frontend/src/main/claude-profile/usage-monitor.ts b/apps/frontend/src/main/claude-profile/usage-monitor.ts index 55f72d70..439e556a 100644 --- a/apps/frontend/src/main/claude-profile/usage-monitor.ts +++ b/apps/frontend/src/main/claude-profile/usage-monitor.ts @@ -10,15 +10,42 @@ */ import { EventEmitter } from 'events'; +import { homedir } from 'os'; import { getClaudeProfileManager } from '../claude-profile-manager'; -import { ClaudeUsageSnapshot } from '../../shared/types/agent'; +import { ClaudeUsageSnapshot, ProfileUsageSummary, AllProfilesUsage } from '../../shared/types/agent'; import { loadProfilesFile } from '../services/profile/profile-manager'; import type { APIProfile } from '../../shared/types/profile'; import { detectProvider as sharedDetectProvider, type ApiProvider } from '../../shared/utils/provider-detection'; +import { getCredentialsFromKeychain, clearKeychainCache } from './credential-utils'; +import { isProfileRateLimited } from './rate-limit-manager'; // Re-export for backward compatibility export type { ApiProvider }; +/** + * Create a safe fingerprint of a credential for debug logging. + * Shows first 8 and last 4 characters, hiding the sensitive middle portion. + * This is NOT for authentication - only for human-readable debug identification. + * + * @param credential - The credential (token or API key) to create a fingerprint for + * @returns A safe fingerprint like "sk-ant-oa...xyz9" or "null" if no credential + */ +function getCredentialFingerprint(credential: string | null | undefined): string { + if (!credential) return 'null'; + if (credential.length <= 16) return credential.slice(0, 4) + '...' + credential.slice(-2); + return credential.slice(0, 8) + '...' + credential.slice(-4); +} + +/** + * Allowed domains for usage API requests. + * Only these domains are permitted for outbound usage monitoring requests. + */ +const ALLOWED_USAGE_API_DOMAINS = new Set([ + 'api.anthropic.com', + 'api.z.ai', + 'open.bigmodel.cn', +]); + /** * Provider usage endpoint configuration * Maps each provider to its usage monitoring endpoint path @@ -155,11 +182,21 @@ export function detectProvider(baseUrl: string): ApiProvider { interface ActiveProfileResult { profileId: string; profileName: string; + profileEmail?: string; isAPIProfile: boolean; baseUrl: string; credential?: string; } +/** + * Type guard to check if an error has an HTTP status code + * @param error - The error to check + * @returns true if the error has a statusCode property + */ +function isHttpError(error: unknown): error is Error & { statusCode?: number } { + return error instanceof Error && 'statusCode' in error; +} + export class UsageMonitor extends EventEmitter { private static instance: UsageMonitor; private intervalId: NodeJS.Timeout | null = null; @@ -175,6 +212,11 @@ export class UsageMonitor extends EventEmitter { private authFailedProfiles: Map = new Map(); // profileId -> timestamp private static AUTH_FAILURE_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes cooldown + // Cache for all profiles' usage data + // Map + private allProfilesUsageCache: Map = new Map(); + private static PROFILE_USAGE_CACHE_TTL_MS = 60 * 1000; // 1 minute cache for inactive profiles + // Debug flag for verbose logging private readonly isDebug = process.env.DEBUG === 'true'; @@ -237,13 +279,330 @@ export class UsageMonitor extends EventEmitter { return this.currentUsage; } + /** + * Clear the usage cache for a specific profile. + * Called after re-authentication to ensure fresh usage data is fetched. + * + * @param profileId - Profile identifier to clear cache for + */ + clearProfileUsageCache(profileId: string): void { + const deleted = this.allProfilesUsageCache.delete(profileId); + if (this.isDebug) { + console.warn('[UsageMonitor] Cleared usage cache for profile:', { + profileId, + wasInCache: deleted + }); + } + } + + /** + * Get all profiles usage data (for multi-profile display in UI) + * Returns cached data if fresh, otherwise fetches for all profiles + * + * Uses parallel fetching for inactive profiles to minimize blocking delays. + */ + async getAllProfilesUsage(): Promise { + const profileManager = getClaudeProfileManager(); + const settings = profileManager.getSettings(); + const activeProfileId = settings.activeProfileId; + + if (!this.currentUsage) { + return null; + } + + const now = Date.now(); + const allProfiles: ProfileUsageSummary[] = []; + + // First pass: identify profiles that need fresh data vs cached + type ProfileToFetch = { profile: typeof settings.profiles[0]; index: number }; + const profilesToFetch: ProfileToFetch[] = []; + const profileResults: (ProfileUsageSummary | null)[] = new Array(settings.profiles.length).fill(null); + + for (let i = 0; i < settings.profiles.length; i++) { + const profile = settings.profiles[i]; + const cached = this.allProfilesUsageCache.get(profile.id); + + // Use cached data if fresh (within TTL) + if (cached && (now - cached.fetchedAt) < UsageMonitor.PROFILE_USAGE_CACHE_TTL_MS) { + profileResults[i] = { + ...cached.usage, + isActive: profile.id === activeProfileId + }; + continue; + } + + // For active profile, use the current detailed usage + if (profile.id === activeProfileId && this.currentUsage) { + const summary = this.buildProfileUsageSummary(profile, this.currentUsage); + profileResults[i] = summary; + this.allProfilesUsageCache.set(profile.id, { usage: summary, fetchedAt: now }); + continue; + } + + // Mark for parallel fetch + profilesToFetch.push({ profile, index: i }); + } + + // Parallel fetch for all inactive profiles that need fresh data + if (profilesToFetch.length > 0) { + // Collect usage updates for batch save (avoids race condition with concurrent saves) + const usageUpdates: Array<{ profileId: string; sessionPercent: number; weeklyPercent: number }> = []; + + const fetchPromises = profilesToFetch.map(async ({ profile, index }) => { + const inactiveUsage = await this.fetchUsageForInactiveProfile(profile); + const rateLimitStatus = isProfileRateLimited(profile); + + let sessionPercent = 0; + let weeklyPercent = 0; + + if (inactiveUsage) { + sessionPercent = inactiveUsage.sessionPercent; + weeklyPercent = inactiveUsage.weeklyPercent; + // Collect update for batch save (don't save here to avoid race condition) + return { + index, + update: { profileId: profile.id, sessionPercent, weeklyPercent }, + profile, + inactiveUsage, + rateLimitStatus + }; + } else { + // Fallback to cached profile data if API fetch failed + sessionPercent = profile.usage?.sessionUsagePercent ?? 0; + weeklyPercent = profile.usage?.weeklyUsagePercent ?? 0; + return { + index, + update: null, // No update needed for fallback + profile, + inactiveUsage, + rateLimitStatus, + sessionPercent, + weeklyPercent + }; + } + }); + + // Wait for all fetches to complete in parallel + const fetchResults = await Promise.all(fetchPromises); + + // Collect all updates and build summaries + for (const result of fetchResults) { + const { index, update, profile, inactiveUsage, rateLimitStatus } = result; + + // Get percentages from either the update or the fallback values + const sessionPercent = update?.sessionPercent ?? result.sessionPercent ?? 0; + const weeklyPercent = update?.weeklyPercent ?? result.weeklyPercent ?? 0; + + if (update) { + usageUpdates.push(update); + } + + const summary: ProfileUsageSummary = { + profileId: profile.id, + profileName: profile.name, + profileEmail: profile.email, + sessionPercent, + weeklyPercent, + isAuthenticated: profile.isAuthenticated ?? false, + isRateLimited: rateLimitStatus.limited, + rateLimitType: rateLimitStatus.type, + availabilityScore: this.calculateAvailabilityScore( + sessionPercent, + weeklyPercent, + rateLimitStatus.limited, + rateLimitStatus.type, + profile.isAuthenticated ?? false + ), + isActive: profile.id === activeProfileId, + lastFetchedAt: inactiveUsage?.fetchedAt?.toISOString() ?? profile.usage?.lastUpdated?.toISOString() + }; + + this.allProfilesUsageCache.set(profile.id, { usage: summary, fetchedAt: now }); + profileResults[index] = summary; + } + + // Batch save all usage updates at once (single disk write, no race condition) + if (usageUpdates.length > 0) { + profileManager.batchUpdateProfileUsageFromAPI(usageUpdates); + } + } + + // Collect non-null results + for (const result of profileResults) { + if (result) { + allProfiles.push(result); + } + } + + // Sort by availability score (highest first = most available) + allProfiles.sort((a, b) => b.availabilityScore - a.availabilityScore); + + return { + activeProfile: this.currentUsage, + allProfiles, + fetchedAt: new Date() + }; + } + + /** + * Fetch usage for an inactive profile using its own credentials + * This allows showing real usage data for non-active profiles + */ + private async fetchUsageForInactiveProfile( + profile: { id: string; name: string; email?: string; configDir?: string; isAuthenticated?: boolean } + ): Promise { + // Only fetch for authenticated profiles with a configDir + if (!profile.isAuthenticated || !profile.configDir) { + if (this.isDebug) { + console.warn('[UsageMonitor] Skipping inactive profile fetch - not authenticated or no configDir:', { + profileId: profile.id, + profileName: profile.name, + isAuthenticated: profile.isAuthenticated, + hasConfigDir: !!profile.configDir + }); + } + return null; + } + + try { + // Get credentials from keychain for this profile's configDir + const expandedConfigDir = profile.configDir.startsWith('~') + ? profile.configDir.replace(/^~/, homedir()) + : profile.configDir; + + const keychainCreds = getCredentialsFromKeychain(expandedConfigDir); + + if (!keychainCreds.token) { + if (this.isDebug) { + console.warn('[UsageMonitor] No keychain credentials for inactive profile:', profile.name); + } + return null; + } + + if (this.isDebug) { + console.warn('[UsageMonitor] Fetching usage for inactive profile:', { + profileId: profile.id, + profileName: profile.name, + tokenFingerprint: getCredentialFingerprint(keychainCreds.token) + }); + } + + // Fetch usage via API - OAuth profiles always use Anthropic + const usage = await this.fetchUsageViaAPI( + keychainCreds.token, + profile.id, + profile.name, + keychainCreds.email ?? profile.email, + { + profileId: profile.id, + profileName: profile.name, + profileEmail: keychainCreds.email ?? profile.email, + isAPIProfile: false, + baseUrl: 'https://api.anthropic.com' + } + ); + + if (this.isDebug && usage) { + console.warn('[UsageMonitor] Successfully fetched inactive profile usage:', { + profileName: profile.name, + sessionPercent: usage.sessionPercent, + weeklyPercent: usage.weeklyPercent + }); + } + + return usage; + } catch (error) { + console.warn('[UsageMonitor] Failed to fetch inactive profile usage:', profile.name, error); + return null; + } + } + + /** + * Build a ProfileUsageSummary from a ClaudeUsageSnapshot + */ + private buildProfileUsageSummary( + profile: { id: string; name: string; email?: string; isAuthenticated?: boolean }, + usage: ClaudeUsageSnapshot + ): ProfileUsageSummary { + const profileManager = getClaudeProfileManager(); + const fullProfile = profileManager.getProfile(profile.id); + const rateLimitStatus = fullProfile ? isProfileRateLimited(fullProfile) : { limited: false }; + + return { + profileId: profile.id, + profileName: profile.name, + profileEmail: usage.profileEmail || profile.email, + sessionPercent: usage.sessionPercent, + weeklyPercent: usage.weeklyPercent, + sessionResetTimestamp: usage.sessionResetTimestamp, + weeklyResetTimestamp: usage.weeklyResetTimestamp, + isAuthenticated: profile.isAuthenticated ?? true, + isRateLimited: rateLimitStatus.limited, + rateLimitType: rateLimitStatus.type, + availabilityScore: this.calculateAvailabilityScore( + usage.sessionPercent, + usage.weeklyPercent, + rateLimitStatus.limited, + rateLimitStatus.type, + profile.isAuthenticated ?? true + ), + isActive: usage.profileId === profileManager.getActiveProfile()?.id, + lastFetchedAt: usage.fetchedAt?.toISOString() + }; + } + + /** + * Calculate availability score for a profile (higher = more available) + * + * Scoring algorithm: + * - Base score: 100 + * - Rate limited: -500 (session) or -1000 (weekly) + * - Unauthenticated: -500 + * - Weekly usage penalty: -(weeklyPercent * 0.5) + * - Session usage penalty: -(sessionPercent * 0.2) + */ + private calculateAvailabilityScore( + sessionPercent: number, + weeklyPercent: number, + isRateLimited: boolean, + rateLimitType?: 'session' | 'weekly', + isAuthenticated: boolean = true + ): number { + let score = 100; + + // Penalize rate-limited profiles heavily + if (isRateLimited) { + if (rateLimitType === 'weekly') { + score -= 1000; // Weekly limit is worse (takes longer to reset) + } else { + score -= 500; // Session limit resets sooner + } + } + + // Penalize unauthenticated profiles + if (!isAuthenticated) { + score -= 500; + } + + // Penalize based on current usage (weekly more important) + score -= weeklyPercent * 0.5; + score -= sessionPercent * 0.2; + + return Math.round(score * 100) / 100; // Round to 2 decimal places + } + /** * Get credential for usage monitoring (OAuth token or API key) * Detects profile type and returns appropriate credential * * Priority: * 1. API Profile (if active) - returns apiKey directly - * 2. OAuth Profile - returns decrypted oauthToken + * 2. OAuth Profile - reads FRESH token from Keychain (not cached oauthToken) + * + * IMPORTANT: For OAuth profiles, we read from Keychain instead of cached profile.oauthToken. + * OAuth tokens expire in 8-12 hours, but Claude CLI auto-refreshes and stores fresh tokens + * in Keychain. Using cached tokens causes 401 errors after a few hours. + * See: docs/LONG_LIVED_AUTH_PLAN.md * * @returns The credential string or undefined if none available */ @@ -269,15 +628,32 @@ export class UsageMonitor extends EventEmitter { } } - // Fall back to OAuth profile + // Fall back to OAuth profile - read FRESH token from Keychain const profileManager = getClaudeProfileManager(); const activeProfile = profileManager.getActiveProfile(); - if (activeProfile?.oauthToken) { - const decryptedToken = profileManager.getProfileToken(activeProfile.id); - if (this.isDebug && decryptedToken) { - console.warn('[UsageMonitor:TRACE] Using OAuth profile credential:', activeProfile.name); + if (activeProfile) { + // Read fresh token from Keychain using configDir (same as CLAUDE_CONFIG_DIR) + // This ensures we get the auto-refreshed token, not a stale cached copy + // IMPORTANT: Always pass configDir, even for default profiles - the keychain + // service name is based on the expanded path (e.g., /Users/xxx/.claude), not undefined + const keychainCreds = getCredentialsFromKeychain(activeProfile.configDir); + + if (keychainCreds.token) { + if (this.isDebug) { + console.warn('[UsageMonitor:TRACE] Using OAuth token from Keychain for profile:', activeProfile.name, { + tokenFingerprint: getCredentialFingerprint(keychainCreds.token) + }); + } + return keychainCreds.token; + } + + // Keychain read failed - log warning (don't fall back to cached token) + if (keychainCreds.error) { + console.warn('[UsageMonitor] Keychain access failed:', keychainCreds.error); + } else if (this.isDebug) { + console.warn('[UsageMonitor:TRACE] No token in Keychain for profile:', activeProfile.name, + '- user may need to re-authenticate with claude /login'); } - return decryptedToken; } // No credential available @@ -324,9 +700,19 @@ export class UsageMonitor extends EventEmitter { this.currentUsage = usage; + // Step 2.5: Persist usage to profile for caching (so other profiles can display cached usage) + const profileManager = getClaudeProfileManager(); + profileManager.updateProfileUsageFromAPI(profileId, usage.sessionPercent, usage.weeklyPercent); + // Step 3: Emit usage update for UI (always emit, regardless of proactive swap settings) this.emit('usage-updated', usage); + // Step 3.5: Emit all profiles usage for multi-profile display + const allProfilesUsage = await this.getAllProfilesUsage(); + if (allProfilesUsage) { + this.emit('all-profiles-usage-updated', allProfilesUsage); + } + // Step 4: Check thresholds and perform proactive swap (OAuth profiles only) if (!isAPIProfile) { const profileManager = getClaudeProfileManager(); @@ -378,7 +764,7 @@ export class UsageMonitor extends EventEmitter { } } catch (error) { // Step 5: Handle auth failures - if ((error as any).statusCode === 401 || (error as any).statusCode === 403) { + if (isHttpError(error) && (error.statusCode === 401 || error.statusCode === 403)) { if (profileId) { await this.handleAuthFailure(profileId, isAPIProfile); return; // handleAuthFailure manages its own logging @@ -467,19 +853,32 @@ export class UsageMonitor extends EventEmitter { return null; } + // Get email from profile or try keychain + let profileEmail = activeOAuthProfile.email; + if (!profileEmail) { + // Try to get email from keychain + // IMPORTANT: Always pass configDir - service name is based on expanded path (e.g., /Users/xxx/.claude) + const keychainCreds = getCredentialsFromKeychain(activeOAuthProfile.configDir); + profileEmail = keychainCreds.email ?? undefined; + } + if (this.isDebug) { console.warn('[UsageMonitor:TRACE] Active auth type: OAuth Profile', { profileId: activeOAuthProfile.id, - profileName: activeOAuthProfile.name + profileName: activeOAuthProfile.name, + profileEmail }); } - return { + const result = { profileId: activeOAuthProfile.id, profileName: activeOAuthProfile.name, + profileEmail, isAPIProfile: false, baseUrl: 'https://api.anthropic.com' }; + + return result; } /** @@ -511,6 +910,17 @@ export class UsageMonitor extends EventEmitter { */ private async handleAuthFailure(profileId: string, isAPIProfile: boolean): Promise { const profileManager = getClaudeProfileManager(); + + // Clear keychain cache for this profile so next attempt gets fresh credentials + // This handles cases where the token was refreshed by Claude CLI but our cache is stale + if (!isAPIProfile) { + const profile = profileManager.getProfile(profileId); + if (profile?.configDir) { + console.warn('[UsageMonitor] Auth failure - clearing keychain cache for profile:', profileId); + clearKeychainCache(profile.configDir); + } + } + const settings = profileManager.getAutoSwitchSettings(); // Proactive swap is only supported for OAuth profiles, not API profiles @@ -560,27 +970,45 @@ export class UsageMonitor extends EventEmitter { credential?: string, activeProfile?: ActiveProfileResult ): Promise { - // Get profile name - check both API profiles and OAuth profiles + // Get profile name and email - prefer activeProfile since it's already determined let profileName: string | undefined; + let profileEmail: string | undefined; - // First, check if it's an API profile - try { - const profilesFile = await loadProfilesFile(); - const apiProfile = profilesFile.profiles.find(p => p.id === profileId); - if (apiProfile) { - profileName = apiProfile.name; - if (this.isDebug) { - console.warn('[UsageMonitor:FETCH] Found API profile:', { - profileId, - profileName, - baseUrl: apiProfile.baseUrl - }); - } - } - } catch (error) { - // Failed to load API profiles, continue to OAuth check + // Use activeProfile data if available (already fetched and validated) + // This fixes the bug where API profile names were incorrectly shown for OAuth profiles + if (activeProfile?.profileName) { + profileName = activeProfile.profileName; + profileEmail = activeProfile.profileEmail; if (this.isDebug) { - console.warn('[UsageMonitor:FETCH] Failed to load API profiles:', error); + console.warn('[UsageMonitor:FETCH] Using activeProfile data:', { + profileId, + profileName, + profileEmail, + isAPIProfile: activeProfile.isAPIProfile + }); + } + } + + // Only search API profiles if not already set from activeProfile + if (!profileName) { + try { + const profilesFile = await loadProfilesFile(); + const apiProfile = profilesFile.profiles.find(p => p.id === profileId); + if (apiProfile) { + profileName = apiProfile.name; + if (this.isDebug) { + console.warn('[UsageMonitor:FETCH] Found API profile:', { + profileId, + profileName, + baseUrl: apiProfile.baseUrl + }); + } + } + } catch (error) { + // Failed to load API profiles, continue to OAuth check + if (this.isDebug) { + console.warn('[UsageMonitor:FETCH] Failed to load API profiles:', error); + } } } @@ -590,10 +1018,15 @@ export class UsageMonitor extends EventEmitter { const oauthProfile = profileManager.getProfile(profileId); if (oauthProfile) { profileName = oauthProfile.name; + // Get email from OAuth profile if not already set + if (!profileEmail) { + profileEmail = oauthProfile.email; + } if (this.isDebug) { console.warn('[UsageMonitor:FETCH] Found OAuth profile:', { profileId, - profileName + profileName, + profileEmail }); } } @@ -620,7 +1053,7 @@ export class UsageMonitor extends EventEmitter { if (this.isDebug) { console.warn('[UsageMonitor:FETCH] Attempting API fetch method'); } - const apiUsage = await this.fetchUsageViaAPI(credential, profileId, profileName, activeProfile); + const apiUsage = await this.fetchUsageViaAPI(credential, profileId, profileName, profileEmail, activeProfile); if (apiUsage) { console.warn('[UsageMonitor] Successfully fetched via API'); if (this.isDebug) { @@ -665,6 +1098,7 @@ export class UsageMonitor extends EventEmitter { * @param credential - OAuth token or API key * @param profileId - Profile identifier * @param profileName - Profile display name + * @param profileEmail - Optional email associated with the profile * @param activeProfile - Optional pre-determined active profile info to avoid race conditions * @returns Normalized usage snapshot or null on failure */ @@ -672,6 +1106,7 @@ export class UsageMonitor extends EventEmitter { credential: string, profileId: string, profileName: string, + profileEmail?: string, activeProfile?: ActiveProfileResult ): Promise { if (this.isDebug) { @@ -736,6 +1171,14 @@ export class UsageMonitor extends EventEmitter { return null; } + if (this.isDebug) { + console.warn('[UsageMonitor:API_FETCH] API request:', { + endpoint: usageEndpoint, + profileId, + credentialFingerprint: getCredentialFingerprint(credential) + }); + } + if (this.isDebug) { console.warn('[UsageMonitor:API_FETCH] Fetching from endpoint:', { provider, @@ -744,17 +1187,45 @@ export class UsageMonitor extends EventEmitter { }); } - // Step 4: Fetch usage from provider endpoint + // Step 4: Validate endpoint domain before making request + // Security: Only allow requests to known provider domains + let endpointHostname: string; + try { + const endpointUrl = new URL(usageEndpoint); + endpointHostname = endpointUrl.hostname; + } catch { + console.error('[UsageMonitor] Invalid usage endpoint URL:', usageEndpoint); + return null; + } + + if (!ALLOWED_USAGE_API_DOMAINS.has(endpointHostname)) { + console.error('[UsageMonitor] Blocked request to unauthorized domain:', endpointHostname, { + allowedDomains: Array.from(ALLOWED_USAGE_API_DOMAINS) + }); + return null; + } + + // Step 5: Fetch usage from provider endpoint // All providers use Bearer token authentication (RFC 6750) const authHeader = `Bearer ${credential}`; + // Build headers based on provider + // Anthropic OAuth requires the 'anthropic-beta: oauth-2025-04-20' header + // See: https://codelynx.dev/posts/claude-code-usage-limits-statusline + const headers: Record = { + 'Authorization': authHeader, + 'Content-Type': 'application/json', + }; + + if (provider === 'anthropic') { + // OAuth authentication requires the beta header + headers['anthropic-beta'] = 'oauth-2025-04-20'; + headers['anthropic-version'] = '2023-06-01'; + } + const response = await fetch(usageEndpoint, { method: 'GET', - headers: { - 'Authorization': authHeader, - 'Content-Type': 'application/json', - ...(provider === 'anthropic' && { 'anthropic-version': '2023-06-01' }) - } + headers }); if (!response.ok) { @@ -874,13 +1345,13 @@ export class UsageMonitor extends EventEmitter { switch (provider) { case 'anthropic': - normalizedUsage = this.normalizeAnthropicResponse(rawData, profileId, profileName); + normalizedUsage = this.normalizeAnthropicResponse(rawData, profileId, profileName, profileEmail); break; case 'zai': - normalizedUsage = this.normalizeZAIResponse(responseData, profileId, profileName); + normalizedUsage = this.normalizeZAIResponse(responseData, profileId, profileName, profileEmail); break; case 'zhipu': - normalizedUsage = this.normalizeZhipuResponse(responseData, profileId, profileName); + normalizedUsage = this.normalizeZhipuResponse(responseData, profileId, profileName, profileEmail); break; default: console.warn('[UsageMonitor] Unsupported provider for usage normalization:', provider); @@ -895,7 +1366,10 @@ export class UsageMonitor extends EventEmitter { } if (this.isDebug) { - console.warn('[UsageMonitor:PROVIDER] Normalized usage:', { + console.warn('[UsageMonitor:API_FETCH] Fetch completed - usage:', { + profileId, + profileName, + email: normalizedUsage.profileEmail, provider, sessionPercent: normalizedUsage.sessionPercent, weeklyPercent: normalizedUsage.weeklyPercent, @@ -922,32 +1396,63 @@ export class UsageMonitor extends EventEmitter { /** * Normalize Anthropic API response to ClaudeUsageSnapshot * - * Expected Anthropic response format: + * Actual Anthropic OAuth usage API response format: * { - * "five_hour_utilization": 0.72, // 0.0-1.0 - * "seven_day_utilization": 0.45, // 0.0-1.0 - * "five_hour_reset_at": "2025-01-17T15:00:00Z", - * "seven_day_reset_at": "2025-01-20T12:00:00Z" + * "five_hour": { + * "utilization": 19, // integer 0-100 + * "resets_at": "2025-01-17T15:00:00Z" + * }, + * "seven_day": { + * "utilization": 45, // integer 0-100 + * "resets_at": "2025-01-20T12:00:00Z" + * } * } */ private normalizeAnthropicResponse( data: any, profileId: string, - profileName: string + profileName: string, + profileEmail?: string ): ClaudeUsageSnapshot { - const fiveHourUtil = data.five_hour_utilization ?? 0; - const sevenDayUtil = data.seven_day_utilization ?? 0; + // Support both new nested format and legacy flat format for backward compatibility + // + // NEW format (current API): { five_hour: { utilization: 72, resets_at: "..." } } + // OLD format (legacy): { five_hour_utilization: 0.72, five_hour_reset_at: "..." } + + let fiveHourUtil: number; + let sevenDayUtil: number; + let sessionResetTimestamp: string | undefined; + let weeklyResetTimestamp: string | undefined; + + // Check for new nested format first + if (data.five_hour !== undefined || data.seven_day !== undefined) { + // New nested format - utilization is already 0-100 integer + fiveHourUtil = data.five_hour?.utilization ?? 0; + sevenDayUtil = data.seven_day?.utilization ?? 0; + sessionResetTimestamp = data.five_hour?.resets_at; + weeklyResetTimestamp = data.seven_day?.resets_at; + } else { + // Legacy flat format - utilization is 0-1 float, needs *100 + const rawFiveHour = data.five_hour_utilization ?? 0; + const rawSevenDay = data.seven_day_utilization ?? 0; + // Convert 0-1 float to 0-100 integer + fiveHourUtil = Math.round(rawFiveHour * 100); + sevenDayUtil = Math.round(rawSevenDay * 100); + sessionResetTimestamp = data.five_hour_reset_at; + weeklyResetTimestamp = data.seven_day_reset_at; + } return { - sessionPercent: Math.round(fiveHourUtil * 100), - weeklyPercent: Math.round(sevenDayUtil * 100), + sessionPercent: fiveHourUtil, + weeklyPercent: sevenDayUtil, // Omit sessionResetTime/weeklyResetTime - renderer uses timestamps with formatTimeRemaining sessionResetTime: undefined, weeklyResetTime: undefined, - sessionResetTimestamp: data.five_hour_reset_at, - weeklyResetTimestamp: data.seven_day_reset_at, + sessionResetTimestamp, + weeklyResetTimestamp, profileId, profileName, + profileEmail, fetchedAt: new Date(), limitType: sevenDayUtil > fiveHourUtil ? 'weekly' : 'session', usageWindows: { @@ -966,6 +1471,7 @@ export class UsageMonitor extends EventEmitter { * @param data - Raw response data with limits array * @param profileId - Profile identifier * @param profileName - Profile display name + * @param profileEmail - Optional email associated with the profile * @param providerName - Provider name for logging ('zai' or 'zhipu') * @returns Normalized usage snapshot or null on parse failure */ @@ -973,6 +1479,7 @@ export class UsageMonitor extends EventEmitter { data: any, profileId: string, profileName: string, + profileEmail: string | undefined, providerName: 'zai' | 'zhipu' ): ClaudeUsageSnapshot | null { const logPrefix = providerName.toUpperCase(); @@ -1072,6 +1579,7 @@ export class UsageMonitor extends EventEmitter { weeklyResetTimestamp, profileId, profileName, + profileEmail, fetchedAt: new Date(), limitType: weeklyPercent > sessionPercent ? 'weekly' : 'session', usageWindows: { @@ -1120,10 +1628,11 @@ export class UsageMonitor extends EventEmitter { private normalizeZAIResponse( data: any, profileId: string, - profileName: string + profileName: string, + profileEmail?: string ): ClaudeUsageSnapshot | null { // Delegate to shared quota/limit response normalization - return this.normalizeQuotaLimitResponse(data, profileId, profileName, 'zai'); + return this.normalizeQuotaLimitResponse(data, profileId, profileName, profileEmail, 'zai'); } /** @@ -1137,10 +1646,11 @@ export class UsageMonitor extends EventEmitter { private normalizeZhipuResponse( data: any, profileId: string, - profileName: string + profileName: string, + profileEmail?: string ): ClaudeUsageSnapshot | null { // Delegate to shared quota/limit response normalization - return this.normalizeQuotaLimitResponse(data, profileId, profileName, 'zhipu'); + return this.normalizeQuotaLimitResponse(data, profileId, profileName, profileEmail, 'zhipu'); } /** @@ -1172,13 +1682,61 @@ export class UsageMonitor extends EventEmitter { additionalExclusions: string[] = [] ): Promise { const profileManager = getClaudeProfileManager(); - - // Get all profiles to swap to, excluding current and any additional exclusions - const allProfiles = profileManager.getProfilesSortedByAvailability(); const excludeIds = new Set([currentProfileId, ...additionalExclusions]); - const eligibleProfiles = allProfiles.filter(p => !excludeIds.has(p.id)); - if (eligibleProfiles.length === 0) { + // Get priority order for unified account system + const priorityOrder = profileManager.getAccountPriorityOrder(); + + // Build unified list of available accounts + type UnifiedSwapTarget = { + id: string; + unifiedId: string; // oauth-{id} or api-{id} + name: string; + type: 'oauth' | 'api'; + priorityIndex: number; + }; + + const unifiedAccounts: UnifiedSwapTarget[] = []; + + // Add OAuth profiles (sorted by availability) + const oauthProfiles = profileManager.getProfilesSortedByAvailability(); + for (const profile of oauthProfiles) { + if (!excludeIds.has(profile.id)) { + const unifiedId = `oauth-${profile.id}`; + const priorityIndex = priorityOrder.indexOf(unifiedId); + unifiedAccounts.push({ + id: profile.id, + unifiedId, + name: profile.name, + type: 'oauth', + priorityIndex: priorityIndex === -1 ? Infinity : priorityIndex + }); + } + } + + // Add API profiles (always considered available since they have unlimited usage) + try { + const profilesFile = await loadProfilesFile(); + for (const apiProfile of profilesFile.profiles) { + if (!excludeIds.has(apiProfile.id) && apiProfile.apiKey) { + const unifiedId = `api-${apiProfile.id}`; + const priorityIndex = priorityOrder.indexOf(unifiedId); + unifiedAccounts.push({ + id: apiProfile.id, + unifiedId, + name: apiProfile.name, + type: 'api', + priorityIndex: priorityIndex === -1 ? Infinity : priorityIndex + }); + } + } + } catch (error) { + if (this.isDebug) { + console.warn('[UsageMonitor] Failed to load API profiles for swap:', error); + } + } + + if (unifiedAccounts.length === 0) { console.warn('[UsageMonitor] No alternative profile for proactive swap (excluded:', Array.from(excludeIds), ')'); this.emit('proactive-swap-failed', { reason: additionalExclusions.length > 0 ? 'all_alternatives_failed_auth' : 'no_alternative', @@ -1188,30 +1746,75 @@ export class UsageMonitor extends EventEmitter { return; } - // Use the best available from eligible profiles - const bestProfile = eligibleProfiles[0]; + // Sort by priority order (lower index = higher priority) + // If no priority order is set, OAuth profiles come first (they were already sorted by availability) + unifiedAccounts.sort((a, b) => { + // If both have priority indices, use them + if (a.priorityIndex !== Infinity || b.priorityIndex !== Infinity) { + return a.priorityIndex - b.priorityIndex; + } + // Otherwise, prefer OAuth profiles (which are sorted by availability) + if (a.type !== b.type) { + return a.type === 'oauth' ? -1 : 1; + } + return 0; + }); + + // Use the best available from unified accounts + const bestAccount = unifiedAccounts[0]; console.warn('[UsageMonitor] Proactive swap:', { from: currentProfileId, - to: bestProfile.id, + to: bestAccount.id, + toType: bestAccount.type, reason: limitType }); - // Switch profile - profileManager.setActiveProfile(bestProfile.id); + // Switch to the new profile + if (bestAccount.type === 'oauth') { + // Switch OAuth profile via profile manager + profileManager.setActiveProfile(bestAccount.id); + } else { + // Switch API profile via profile-manager service + try { + const { setActiveAPIProfile } = await import('../services/profile/profile-manager'); + await setActiveAPIProfile(bestAccount.id); + } catch (error) { + console.error('[UsageMonitor] Failed to set active API profile:', error); + return; + } + } + + // Get the "from" profile name + let fromProfileName: string | undefined; + const fromOAuthProfile = profileManager.getProfile(currentProfileId); + if (fromOAuthProfile) { + fromProfileName = fromOAuthProfile.name; + } else { + // It might be an API profile + try { + const profilesFile = await loadProfilesFile(); + const fromAPIProfile = profilesFile.profiles.find(p => p.id === currentProfileId); + if (fromAPIProfile) { + fromProfileName = fromAPIProfile.name; + } + } catch { + // Ignore + } + } // Emit swap event this.emit('proactive-swap-completed', { - fromProfile: { id: currentProfileId, name: profileManager.getProfile(currentProfileId)?.name }, - toProfile: { id: bestProfile.id, name: bestProfile.name }, + fromProfile: { id: currentProfileId, name: fromProfileName }, + toProfile: { id: bestAccount.id, name: bestAccount.name }, limitType, timestamp: new Date() }); // Notify UI this.emit('show-swap-notification', { - fromProfile: profileManager.getProfile(currentProfileId)?.name, - toProfile: bestProfile.name, + fromProfile: fromProfileName, + toProfile: bestAccount.name, reason: 'proactive', limitType }); diff --git a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts index ec050bfa..7b3bee78 100644 --- a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts @@ -22,6 +22,8 @@ import { readSettingsFile, writeSettingsFile } from '../settings-utils'; import { isSecurePath } from '../utils/windows-paths'; import { getClaudeProfileManager } from '../claude-profile-manager'; import { isValidConfigDir } from '../utils/config-path-validator'; +import { clearKeychainCache } from '../claude-profile/credential-utils'; +import { getUsageMonitor } from '../claude-profile/usage-monitor'; import semver from 'semver'; const execFileAsync = promisify(execFile); @@ -1310,7 +1312,13 @@ export function registerClaudeCodeHandlers(): void { } } - // If authenticated, update the profile with the email and OAuth token + // If authenticated, update the profile with the email + // NOTE: We intentionally do NOT store the OAuth token in the profile. + // Storing the token causes AutoClaude to use a stale cached token instead of + // letting Claude CLI read fresh tokens from Keychain (which auto-refreshes). + // By only storing metadata, we ensure getProfileEnv() uses CLAUDE_CONFIG_DIR, + // which allows Claude CLI's working token refresh mechanism to be used. + // See: docs/LONG_LIVED_AUTH_PLAN.md for full context. if (result.authenticated) { profile.isAuthenticated = true; @@ -1318,18 +1326,21 @@ export function registerClaudeCodeHandlers(): void { profile.email = result.email; } - // Save the OAuth token if available (critical for re-authentication) - if (result.oauthAccount?.accessToken) { - console.warn('[Claude Code] Saving OAuth token for profile:', profileId); - profileManager.setProfileToken( - profileId, - result.oauthAccount.accessToken, - result.email - ); - } else { - // No OAuth token, just save the email update - profileManager.saveProfile(profile); - } + // Save profile metadata (email, isAuthenticated) but NOT the OAuth token + profileManager.saveProfile(profile); + + // CRITICAL: Clear keychain cache for this profile's configDir + // This ensures the new token is read from keychain instead of using a stale cached token + // Without this, UsageMonitor would use the old cached token and show incorrect usage data + clearKeychainCache(expandedConfigDir); + console.warn('[Claude Code] Cleared keychain cache for profile after re-authentication:', profileId); + + // CRITICAL: Also clear the UsageMonitor's usage cache for this profile + // This ensures fresh usage data is fetched from the API instead of using stale cached data + // The keychain cache clear alone is not enough - we also need to clear the usage cache + const usageMonitor = getUsageMonitor(); + usageMonitor.clearProfileUsageCache(profileId); + console.warn('[Claude Code] Cleared usage cache for profile after re-authentication:', profileId); // Clean up backup file after successful authentication if (existsSync(claudeJsonBakPath)) { diff --git a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts index 1b860697..a9eed426 100644 --- a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts @@ -5,6 +5,7 @@ import path from 'path'; import { minimatch } from 'minimatch'; import { existsSync, readdirSync, statSync, readFileSync } from 'fs'; import { execSync, execFileSync, spawn, spawnSync, exec, execFile } from 'child_process'; +import { homedir } from 'os'; import { projectStore } from '../../project-store'; import { getConfiguredPythonPath, PythonEnvManager, pythonEnvManager as pythonEnvManagerSingleton } from '../../python-env-manager'; import { getEffectiveSourcePath } from '../../updater/path-resolver'; @@ -951,7 +952,7 @@ async function detectLinuxApps(): Promise> { const desktopDirs = [ '/usr/share/applications', '/usr/local/share/applications', - `${process.env.HOME}/.local/share/applications`, + `${homedir()}/.local/share/applications`, '/var/lib/flatpak/exports/share/applications', '/var/lib/snapd/desktop/applications' ]; @@ -1021,7 +1022,7 @@ function isAppInstalled( for (const checkPath of specificPaths) { const expandedPath = checkPath .replace('%USERNAME%', process.env.USERNAME || process.env.USER || '') - .replace('~', process.env.HOME || ''); + .replace('~', homedir()); // Validate path doesn't contain traversal attempts after expansion if (!isPathSafe(expandedPath)) { diff --git a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts index f1abfbc8..705f6500 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts @@ -1,7 +1,7 @@ import { ipcMain } from 'electron'; import type { BrowserWindow } from 'electron'; import { IPC_CHANNELS } from '../../shared/constants'; -import type { IPCResult, TerminalCreateOptions, ClaudeProfile, ClaudeProfileSettings, ClaudeUsageSnapshot } from '../../shared/types'; +import type { IPCResult, TerminalCreateOptions, ClaudeProfile, ClaudeProfileSettings, ClaudeUsageSnapshot, AllProfilesUsage } from '../../shared/types'; import { getClaudeProfileManager } from '../claude-profile-manager'; import { getUsageMonitor } from '../claude-profile/usage-monitor'; import { TerminalManager } from '../terminal-manager'; @@ -10,7 +10,7 @@ import { terminalNameGenerator } from '../terminal-name-generator'; import { readSettingsFileAsync } from '../settings-utils'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; import { migrateSession } from '../claude-profile/session-utils'; -import { DEFAULT_CLAUDE_CONFIG_DIR } from '../claude-profile/profile-utils'; +import { DEFAULT_CLAUDE_CONFIG_DIR, createProfileDirectory } from '../claude-profile/profile-utils'; import { isValidConfigDir } from '../utils/config-path-validator'; @@ -142,9 +142,17 @@ export function registerTerminalHandlers( profile.id = profileManager.generateProfileId(profile.name); } - // Security: Validate configDir path to prevent path traversal attacks - // Only validate non-default profiles with custom configDir - if (!profile.isDefault && profile.configDir) { + // For non-default profiles, ensure configDir is ALWAYS set + // This is critical for the CLAUDE_CONFIG_DIR-based auth flow + // See: docs/LONG_LIVED_AUTH_PLAN.md for context + if (!profile.isDefault) { + if (!profile.configDir) { + // Auto-create a configDir in ~/.claude-profiles/{profile-name}/ + console.warn('[CLAUDE_PROFILE_SAVE] Profile missing configDir, creating one:', profile.name); + profile.configDir = await createProfileDirectory(profile.name); + } + + // Security: Validate configDir path to prevent path traversal attacks if (!isValidConfigDir(profile.configDir)) { return { success: false, @@ -152,7 +160,7 @@ export function registerTerminalHandlers( }; } - // Ensure config directory exists for non-default profiles + // Ensure config directory exists const { mkdirSync, existsSync } = await import('fs'); if (!existsSync(profile.configDir)) { mkdirSync(profile.configDir, { recursive: true }); @@ -395,6 +403,40 @@ export function registerTerminalHandlers( } ); + // Get account priority order + ipcMain.handle( + IPC_CHANNELS.ACCOUNT_PRIORITY_GET, + async (): Promise> => { + try { + const profileManager = getClaudeProfileManager(); + const order = profileManager.getAccountPriorityOrder(); + return { success: true, data: order }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get account priority order' + }; + } + } + ); + + // Set account priority order + ipcMain.handle( + IPC_CHANNELS.ACCOUNT_PRIORITY_SET, + async (_, order: string[]): Promise => { + try { + const profileManager = getClaudeProfileManager(); + profileManager.setAccountPriorityOrder(order); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to set account priority order' + }; + } + } + ); + // Fetch usage by sending /usage command to terminal ipcMain.handle( IPC_CHANNELS.CLAUDE_PROFILE_FETCH_USAGE, @@ -498,6 +540,23 @@ export function registerTerminalHandlers( } ); + // Request all profiles usage immediately (for startup/refresh) + ipcMain.handle( + IPC_CHANNELS.ALL_PROFILES_USAGE_REQUEST, + async (): Promise> => { + try { + const monitor = getUsageMonitor(); + const allProfilesUsage = await monitor.getAllProfilesUsage(); + return { success: true, data: allProfilesUsage }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get all profiles usage' + }; + } + } + ); + // Terminal session management (persistence/restore) ipcMain.handle( @@ -676,6 +735,11 @@ export function initializeUsageMonitorForwarding(mainWindow: BrowserWindow): voi mainWindow.webContents.send(IPC_CHANNELS.USAGE_UPDATED, usage); }); + // Forward all profiles usage updates to renderer (for multi-profile display) + monitor.on('all-profiles-usage-updated', (allProfilesUsage: AllProfilesUsage) => { + mainWindow.webContents.send(IPC_CHANNELS.ALL_PROFILES_USAGE_UPDATED, allProfilesUsage); + }); + // Forward proactive swap notifications to renderer monitor.on('show-swap-notification', (notification: unknown) => { mainWindow.webContents.send(IPC_CHANNELS.PROACTIVE_SWAP_NOTIFICATION, notification); diff --git a/apps/frontend/src/main/rate-limit-detector.ts b/apps/frontend/src/main/rate-limit-detector.ts index 9b018f5e..b9882559 100644 --- a/apps/frontend/src/main/rate-limit-detector.ts +++ b/apps/frontend/src/main/rate-limit-detector.ts @@ -254,63 +254,29 @@ export function isAuthFailureError(output: string): boolean { /** * Get environment variables for a specific Claude profile. - * Uses OAuth token (CLAUDE_CODE_OAUTH_TOKEN) if available, otherwise falls back to CLAUDE_CONFIG_DIR. - * OAuth tokens are preferred as they provide instant, reliable profile switching. - * Note: Tokens are decrypted automatically by the profile manager. + * + * IMPORTANT: Always uses CLAUDE_CONFIG_DIR to let Claude CLI read fresh tokens from Keychain. + * We do NOT use cached OAuth tokens (CLAUDE_CODE_OAUTH_TOKEN) because: + * 1. OAuth tokens expire in 8-12 hours + * 2. Claude CLI's token refresh mechanism works (updates Keychain) + * 3. Cached tokens don't benefit from Claude CLI's automatic refresh + * + * By using CLAUDE_CONFIG_DIR, Claude CLI reads fresh tokens from Keychain each time, + * which includes any refreshed tokens. This solves the 401 errors after a few hours. + * + * See: docs/LONG_LIVED_AUTH_PLAN.md for full context. + * + * @param profileId - Optional profile ID. If not provided, uses active profile. + * @returns Environment variables for Claude CLI invocation */ export function getProfileEnv(profileId?: string): Record { const profileManager = getClaudeProfileManager(); - const profile = profileId - ? profileManager.getProfile(profileId) - : profileManager.getActiveProfile(); - console.warn('[getProfileEnv] Active profile:', { - profileId: profile?.id, - profileName: profile?.name, - email: profile?.email, - isDefault: profile?.isDefault, - hasOAuthToken: !!profile?.oauthToken, - configDir: profile?.configDir - }); - - if (!profile) { - console.warn('[getProfileEnv] No profile found, using defaults'); - return {}; + // Delegate to profile manager's implementation to avoid code duplication + if (profileId) { + return profileManager.getProfileEnv(profileId); } - - // Prefer OAuth token (instant switching, no browser auth needed) - // Use profile manager to get decrypted token - if (profile.oauthToken) { - const decryptedToken = profileId - ? profileManager.getProfileToken(profileId) - : profileManager.getActiveProfileToken(); - - if (decryptedToken) { - console.warn('[getProfileEnv] Using OAuth token for profile:', profile.name); - return { - CLAUDE_CODE_OAUTH_TOKEN: decryptedToken - }; - } else { - console.warn('[getProfileEnv] Failed to decrypt token for profile:', profile.name); - } - } - - // Fallback: If default profile, no env vars needed - if (profile.isDefault) { - console.warn('[getProfileEnv] Using default profile (no env vars)'); - return {}; - } - - // Fallback: Use configDir for profiles without OAuth token (legacy) - if (profile.configDir) { - console.warn('[getProfileEnv] Using configDir for profile:', profile.name); - return { - CLAUDE_CONFIG_DIR: profile.configDir - }; - } - - console.warn('[getProfileEnv] Profile has no auth method configured'); - return {}; + return profileManager.getActiveProfileEnv(); } /** diff --git a/apps/frontend/src/main/services/profile/profile-manager.ts b/apps/frontend/src/main/services/profile/profile-manager.ts index 6cb1bf1f..dcb75d28 100644 --- a/apps/frontend/src/main/services/profile/profile-manager.ts +++ b/apps/frontend/src/main/services/profile/profile-manager.ts @@ -216,6 +216,29 @@ export async function withProfilesLock(fn: () => Promise): Promise { } } +/** + * Set the active API profile by ID + * This atomically updates the activeProfileId in profiles.json + * + * @param profileId - The profile ID to set as active, or null to clear + * @returns The updated ProfilesFile + */ +export async function setActiveAPIProfile(profileId: string | null): Promise { + return await atomicModifyProfiles((file) => { + // Validate that the profile exists if setting an ID + if (profileId !== null) { + const profile = file.profiles.find(p => p.id === profileId); + if (!profile) { + throw new Error(`API profile not found: ${profileId}`); + } + } + return { + ...file, + activeProfileId: profileId + }; + }); +} + /** * Atomically modify the profiles file * Loads, modifies, and saves the file within an exclusive lock diff --git a/apps/frontend/src/main/terminal/__tests__/output-parser.test.ts b/apps/frontend/src/main/terminal/__tests__/output-parser.test.ts index 4d2a2c92..4f8060c1 100644 --- a/apps/frontend/src/main/terminal/__tests__/output-parser.test.ts +++ b/apps/frontend/src/main/terminal/__tests__/output-parser.test.ts @@ -253,5 +253,58 @@ describe('output-parser', () => { // The pattern includes space after "as" to match Claude CLI output expect(extractEmail('Authenticated as user@example.com')).toBe('user@example.com'); }); + + it('extracts email from "user@example.com\'s Organization" format', () => { + expect(extractEmail("andre@mikalsenai.no's Organization")).toBe('andre@mikalsenai.no'); + expect(extractEmail("user@example.com's Organization")).toBe('user@example.com'); + }); + + describe('ANSI escape code handling', () => { + it('strips basic CSI color codes from email', () => { + // Email with bold formatting: \x1b[1m starts bold, \x1b[0m resets + const withBold = "\x1b[1mandre\x1b[0m@mikalsenai.no's Organization"; + expect(extractEmail(withBold)).toBe('andre@mikalsenai.no'); + }); + + it('strips OSC 8 hyperlink sequences from email', () => { + // Modern terminals wrap emails in OSC 8 hyperlinks + // Format: \x1b]8;;url\x07visible_text\x1b]8;;\x07 + const withHyperlink = "\x1b]8;;mailto:andre@mikalsenai.no\x07andre@mikalsenai.no\x1b]8;;\x07's Organization"; + expect(extractEmail(withHyperlink)).toBe('andre@mikalsenai.no'); + }); + + it('strips mixed ANSI codes (CSI + OSC)', () => { + // Combination of color codes and hyperlinks + const mixed = "\x1b[1m\x1b]8;;mailto:andre@mikalsenai.no\x07andre@mikalsenai.no\x1b]8;;\x07\x1b[0m's Organization"; + expect(extractEmail(mixed)).toBe('andre@mikalsenai.no'); + }); + + it('strips OSC window title sequences', () => { + // \x1b]0;title\x07 sets window title + const withTitle = "\x1b]0;Claude Code\x07andre@mikalsenai.no's Organization"; + expect(extractEmail(withTitle)).toBe('andre@mikalsenai.no'); + }); + + it('strips 256-color and true-color codes', () => { + // 256-color: \x1b[38;5;123m + // True-color: \x1b[38;2;255;128;0m + const with256Color = "\x1b[38;5;123mandre\x1b[0m@mikalsenai.no's Organization"; + const withTrueColor = "\x1b[38;2;255;128;0mandre\x1b[0m@mikalsenai.no's Organization"; + expect(extractEmail(with256Color)).toBe('andre@mikalsenai.no'); + expect(extractEmail(withTrueColor)).toBe('andre@mikalsenai.no'); + }); + + it('strips private mode CSI sequences', () => { + // \x1b[?25h shows cursor, \x1b[?25l hides cursor + const withPrivateMode = "\x1b[?25handre@mikalsenai.no\x1b[?25l's Organization"; + expect(extractEmail(withPrivateMode)).toBe('andre@mikalsenai.no'); + }); + + it('handles email with formatting inside the address', () => { + // Edge case: color code appears INSIDE the email address + const colorInsideEmail = "andr\x1b[1me\x1b[0m@mikalsenai.no's Organization"; + expect(extractEmail(colorInsideEmail)).toBe('andre@mikalsenai.no'); + }); + }); }); }); diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index 88d52206..8128ea7a 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -10,7 +10,8 @@ import * as path from 'path'; import * as crypto from 'crypto'; import { IPC_CHANNELS } from '../../shared/constants'; import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager'; -import { getCredentialsFromKeychain, clearKeychainCache } from '../claude-profile/keychain-utils'; +import { getCredentialsFromKeychain, clearKeychainCache } from '../claude-profile/credential-utils'; +import { getEmailFromConfigDir } from '../claude-profile/profile-utils'; import * as OutputParser from './output-parser'; import * as SessionHandler from './session-handler'; import * as PtyManager from './pty-manager'; @@ -495,34 +496,54 @@ export function handleOAuthToken( } if (keychainCreds.token) { - // Store the token in our profile store - const success = profileManager.setProfileToken( - profileId, - keychainCreds.token, - emailFromOutput || keychainCreds.email || undefined - ); + // NOTE: We intentionally do NOT store the OAuth token in the profile. + // Storing causes AutoClaude to use a stale cached token instead of letting + // Claude CLI read fresh tokens from Keychain (which auto-refreshes). + // See: docs/LONG_LIVED_AUTH_PLAN.md for full context. - if (success) { - console.warn('[ClaudeIntegration] OAuth token extracted from Keychain and saved to profile:', profileId); + // Get email from multiple sources, preferring config file as the authoritative source + // Terminal output parsing can be corrupted by ANSI escape codes + let email = emailFromOutput || keychainCreds.email; - // Set flag to watch for Claude's ready state (onboarding complete) - terminal.awaitingOnboardingComplete = true; - - const win = getWindow(); - if (win) { - // needsOnboarding: true tells the UI to show "complete setup" message - // instead of "success" - user should finish Claude's onboarding before closing - win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { - terminalId: terminal.id, - profileId, - email: emailFromOutput || keychainCreds.email || profile?.email, - success: true, - needsOnboarding: true, - detectedAt: new Date().toISOString() - } as OAuthTokenEvent); + // Fallback/validation: Read from Claude's config file (authoritative source) + const configEmail = getEmailFromConfigDir(profile.configDir); + if (configEmail) { + if (!email) { + console.warn('[ClaudeIntegration] Email not found in output/keychain, using config file:', maskEmail(configEmail)); + email = configEmail; + } else if (configEmail !== email) { + // Config file email is different (terminal extraction might be corrupt) + console.warn('[ClaudeIntegration] Email from output differs from config file, using config file:', { + outputEmail: maskEmail(email), + configEmail: maskEmail(configEmail) + }); + email = configEmail; } - } else { - console.error('[ClaudeIntegration] Failed to save Keychain token to profile:', profileId); + } + + if (email) { + profile.email = email; + } + profile.isAuthenticated = true; + profileManager.saveProfile(profile); + + console.warn('[ClaudeIntegration] Profile credentials verified via Keychain (not caching token):', profileId); + + // Set flag to watch for Claude's ready state (onboarding complete) + terminal.awaitingOnboardingComplete = true; + + const win = getWindow(); + if (win) { + // needsOnboarding: true tells the UI to show "complete setup" message + // instead of "success" - user should finish Claude's onboarding before closing + win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { + terminalId: terminal.id, + profileId, + email: emailFromOutput || keychainCreds.email || profile?.email, + success: true, + needsOnboarding: true, + detectedAt: new Date().toISOString() + } as OAuthTokenEvent); } } else { // Token not in Keychain yet, but profile may still be authenticated via configDir @@ -563,18 +584,38 @@ export function handleOAuthToken( console.warn('[ClaudeIntegration] OAuth token detected in output'); - const email = OutputParser.extractEmail(terminal.outputBuffer); + let email = OutputParser.extractEmail(terminal.outputBuffer); if (profileId) { - // Save to specific profile (profile login terminal) + // Update profile metadata (but NOT the token - see docs/LONG_LIVED_AUTH_PLAN.md) const profileManager = getClaudeProfileManager(); const profile = profileManager.getProfile(profileId); - const success = profileManager.setProfileToken(profileId, token, email || undefined); - if (success) { + if (profile) { + // Fallback/validation: Read email from Claude's config file (authoritative source) + const configEmail = getEmailFromConfigDir(profile.configDir); + if (configEmail) { + if (!email) { + console.warn('[ClaudeIntegration] Email not found in output, using config file:', maskEmail(configEmail)); + email = configEmail; + } else if (configEmail !== email) { + console.warn('[ClaudeIntegration] Email from output differs from config file, using config file:', { + outputEmail: maskEmail(email), + configEmail: maskEmail(configEmail) + }); + email = configEmail; + } + } + + if (email) { + profile.email = email; + } + profile.isAuthenticated = true; + profileManager.saveProfile(profile); + // Clear keychain cache so next getCredentialsFromKeychain() fetches fresh token - clearKeychainCache(profile?.configDir); - console.warn('[ClaudeIntegration] OAuth token auto-saved to profile:', profileId); + clearKeychainCache(profile.configDir); + console.warn('[ClaudeIntegration] Profile credentials verified (not caching token):', profileId); const win = getWindow(); if (win) { @@ -587,17 +628,18 @@ export function handleOAuthToken( } as OAuthTokenEvent); } } else { - console.error('[ClaudeIntegration] Failed to save OAuth token to profile:', profileId); + console.error('[ClaudeIntegration] Profile not found for OAuth token:', profileId); } } else { - // No profile-specific terminal, save to active profile (GitHub OAuth flow, etc.) - console.warn('[ClaudeIntegration] OAuth token detected in non-profile terminal, saving to active profile'); + // No profile-specific terminal, update active profile metadata (GitHub OAuth flow, etc.) + // NOTE: We do NOT store the token - see docs/LONG_LIVED_AUTH_PLAN.md + console.warn('[ClaudeIntegration] OAuth token detected in non-profile terminal, updating active profile metadata'); const profileManager = getClaudeProfileManager(); const activeProfile = profileManager.getActiveProfile(); // Defensive null check for active profile if (!activeProfile) { - console.error('[ClaudeIntegration] Failed to save OAuth token: no active profile found'); + console.error('[ClaudeIntegration] Failed to update profile: no active profile found'); const win = getWindow(); if (win) { win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { @@ -612,37 +654,41 @@ export function handleOAuthToken( return; } - const success = profileManager.setProfileToken(activeProfile.id, token, email || undefined); - - if (success) { - // Clear keychain cache so next getCredentialsFromKeychain() fetches fresh token - clearKeychainCache(activeProfile.configDir); - console.warn('[ClaudeIntegration] OAuth token auto-saved to active profile:', activeProfile.name); - - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { - terminalId: terminal.id, - profileId: activeProfile.id, - email, - success: true, - detectedAt: new Date().toISOString() - } as OAuthTokenEvent); - } - } else { - console.error('[ClaudeIntegration] Failed to save OAuth token to active profile:', activeProfile.name); - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { - terminalId: terminal.id, - profileId: activeProfile?.id, - email, - success: false, - message: 'Failed to save token to active profile', - detectedAt: new Date().toISOString() - } as OAuthTokenEvent); + // Fallback/validation: Read email from Claude's config file (authoritative source) + const configEmail = getEmailFromConfigDir(activeProfile.configDir); + if (configEmail) { + if (!email) { + console.warn('[ClaudeIntegration] Email not found in output, using config file:', maskEmail(configEmail)); + email = configEmail; + } else if (configEmail !== email) { + console.warn('[ClaudeIntegration] Email from output differs from config file, using config file:', { + outputEmail: maskEmail(email), + configEmail: maskEmail(configEmail) + }); + email = configEmail; } } + + if (email) { + activeProfile.email = email; + } + activeProfile.isAuthenticated = true; + profileManager.saveProfile(activeProfile); + + // Clear keychain cache so next getCredentialsFromKeychain() fetches fresh token + clearKeychainCache(activeProfile.configDir); + console.warn('[ClaudeIntegration] Active profile credentials verified (not caching token):', activeProfile.name); + + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { + terminalId: terminal.id, + profileId: activeProfile.id, + email, + success: true, + detectedAt: new Date().toISOString() + } as OAuthTokenEvent); + } } } @@ -679,57 +725,54 @@ export function handleOnboardingComplete( const profileId = extractProfileIdFromAuthTerminalId(terminal.id) || undefined; // Try to extract email from the welcome screen (e.g., "user@example.com's Organization") - // Strip ANSI escape codes first since terminal output contains formatting - // eslint-disable-next-line no-control-regex - const stripAnsi = (str: string) => str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ''); - - const cleanData = stripAnsi(data); - const cleanBuffer = stripAnsi(terminal.outputBuffer); - - let email = OutputParser.extractEmail(cleanData); + // Note: extractEmail automatically strips ANSI escape codes internally + let email = OutputParser.extractEmail(data); if (!email) { - email = OutputParser.extractEmail(cleanBuffer); + email = OutputParser.extractEmail(terminal.outputBuffer); } - // Debug: Test each pattern individually to see what matches - const emailPatternTests = [ - { name: 'Authenticated/Logged in', pattern: /(?:Authenticated as |Logged in as |email[:\s]+)([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i }, - { name: "email's Organization", pattern: /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})['\u2019]s\s*Organization/i }, - { name: 'Claude Max · email', pattern: /Claude\s+(?:Max|Pro|Team|Enterprise)\s*[·•]\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i }, - { name: "email's (broad)", pattern: /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})['\u2019]s/i }, - { name: 'any email', pattern: /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i }, - ]; + // Fallback: If terminal extraction failed or might be corrupt, read directly from Claude's config file + // This is the authoritative source and doesn't suffer from ANSI escape code issues + const profileManager = getClaudeProfileManager(); + const profile = profileId ? profileManager.getProfile(profileId) : null; - const patternResults = emailPatternTests.map(({ name, pattern }) => { - const match = cleanBuffer.match(pattern); - return { name, matched: !!match, email: match?.[1] || null }; - }); + if (!email && profile?.configDir) { + const configEmail = getEmailFromConfigDir(profile.configDir); + if (configEmail) { + console.warn('[ClaudeIntegration] Email not found in terminal output, using config file:', maskEmail(configEmail)); + email = configEmail; + } + } - // Redact PII from pattern results for logging - const redactedPatternResults = patternResults.map(({ name, matched, email: foundEmail }) => ({ - name, - matched, - email: maskEmail(foundEmail) - })); + // Validate email looks correct (basic sanity check) + // If terminal extraction gave us a truncated email but config file has the correct one, prefer config + if (email && profile?.configDir) { + const configEmail = getEmailFromConfigDir(profile.configDir); + if (configEmail && configEmail !== email) { + // Config file email is different - it's more authoritative + console.warn('[ClaudeIntegration] Terminal email differs from config file, using config file:', { + terminalEmail: maskEmail(email), + configEmail: maskEmail(configEmail) + }); + email = configEmail; + } + } console.warn('[ClaudeIntegration] Email extraction attempt:', { profileId, foundEmail: maskEmail(email), dataLength: data.length, - bufferLength: terminal.outputBuffer.length, - cleanBufferLength: cleanBuffer.length, - patternResults: redactedPatternResults + bufferLength: terminal.outputBuffer.length }); // Update profile with email if found and profile exists - if (profileId && email) { - const profileManager = getClaudeProfileManager(); - const profile = profileManager.getProfile(profileId); - if (profile && !profile.email) { - // Update the profile with the email - profile.email = email; - profileManager.saveProfile(profile); - console.warn('[ClaudeIntegration] Updated profile email from welcome screen:', profileId, maskEmail(email)); + // Always update - the newly extracted email from re-authentication should overwrite any stale/truncated email + if (profileId && email && profile) { + const previousEmail = profile.email; + profile.email = email; + profileManager.saveProfile(profile); + if (previousEmail !== email) { + console.warn('[ClaudeIntegration] Updated profile email from welcome screen:', profileId, maskEmail(email), '(was:', maskEmail(previousEmail), ')'); } } diff --git a/apps/frontend/src/main/terminal/output-parser.ts b/apps/frontend/src/main/terminal/output-parser.ts index c28d7296..1d83d4db 100644 --- a/apps/frontend/src/main/terminal/output-parser.ts +++ b/apps/frontend/src/main/terminal/output-parser.ts @@ -99,13 +99,66 @@ export function hasOAuthUrl(data: string): boolean { return OAUTH_URL_PATTERN.test(data); } +/** + * Strip ANSI escape codes from a string + * + * Handles comprehensive escape sequences including: + * - CSI sequences: \x1b[...X (colors, cursor movement, SGR, etc.) + * - OSC sequences: \x1b]...BEL or \x1b]...ST (hyperlinks, window title, etc.) + * - OSC 8 hyperlinks wrap text like: \x1b]8;;url\x07text\x1b]8;;\x07 + * - These are used by modern terminals to make emails/URLs clickable + * - DCS sequences: \x1bP...ST (device control) + * - Other single-character escape sequences + * + * This comprehensive stripping is critical for email extraction because terminals + * often wrap emails in OSC 8 hyperlink sequences, which would otherwise corrupt + * the email address during regex matching. + */ +// eslint-disable-next-line no-control-regex +const ANSI_ESCAPE_PATTERNS = [ + // CSI sequences: \x1b[ followed by optional private mode indicator (?, >, !), + // then parameters (numbers and semicolons), then a command letter + // Examples: \x1b[0m (reset), \x1b[1;32m (bold green), \x1b[?25h (show cursor) + /\x1b\[[?!>]?[0-9;]*[a-zA-Z]/g, + + // OSC sequences: \x1b] followed by content, terminated by BEL (\x07) or ST (\x1b\\) + // Examples: \x1b]0;title\x07 (set window title), \x1b]8;;url\x07 (hyperlink) + // The [^\x07]* matches any chars except BEL, allowing nested content + /\x1b\][^\x07]*(?:\x07|\x1b\\)/g, + + // DCS sequences: \x1bP followed by content, terminated by ST (\x1b\\) + // Used for device control strings (less common but should be handled) + /\x1bP[^\x1b]*\x1b\\/g, + + // Single-character escapes: \x1b followed by specific characters + // Examples: \x1b= (keypad mode), \x1b> (normal keypad), \x1bM (reverse index) + /\x1b[=>ABCDEFGHIJKLMNOPQRSTUVWXYZ\\^_`abcdefghijklmnopqrstuvwxyz{|}~]/g, + + // APC, PM, SOS sequences (Application Program Command, Privacy Message, Start of String) + // Format: \x1b_ or \x1b^ or \x1bX followed by content, terminated by ST + /\x1b[_X^][^\x1b]*\x1b\\/g, +]; + +function stripAnsi(str: string): string { + let result = str; + for (const pattern of ANSI_ESCAPE_PATTERNS) { + result = result.replace(pattern, ''); + } + return result; +} + /** * Extract email from output * Tries multiple patterns to handle different output formats + * Automatically strips ANSI escape codes before matching */ export function extractEmail(data: string): string | null { + // Strip ANSI escape codes - terminal output often contains formatting + // that can break regex matching (e.g., color codes within the email text) + const cleanData = stripAnsi(data); + for (const pattern of EMAIL_PATTERNS) { - const match = data.match(pattern); + const match = cleanData.match(pattern); if (match && match[1]) { return match[1]; } diff --git a/apps/frontend/src/preload/api/terminal-api.ts b/apps/frontend/src/preload/api/terminal-api.ts index 28bb25e1..4374ef75 100644 --- a/apps/frontend/src/preload/api/terminal-api.ts +++ b/apps/frontend/src/preload/api/terminal-api.ts @@ -110,6 +110,8 @@ export interface TerminalAPI { verifyClaudeProfileAuth: (profileId: string) => Promise>; getAutoSwitchSettings: () => Promise>; updateAutoSwitchSettings: (settings: Partial) => Promise; + getAccountPriorityOrder: () => Promise>; + setAccountPriorityOrder: (order: string[]) => Promise; fetchClaudeUsage: (terminalId: string) => Promise; getBestAvailableProfile: (excludeProfileId?: string) => Promise>; onSDKRateLimit: (callback: (info: import('../../shared/types').SDKRateLimitInfo) => void) => () => void; @@ -118,7 +120,9 @@ export interface TerminalAPI { // Usage Monitoring (Proactive Account Switching) requestUsageUpdate: () => Promise>; + requestAllProfilesUsage: () => Promise>; onUsageUpdated: (callback: (usage: import('../../shared/types').ClaudeUsageSnapshot) => void) => () => void; + onAllProfilesUsageUpdated: (callback: (allProfilesUsage: import('../../shared/types').AllProfilesUsage) => void) => () => void; onProactiveSwapNotification: (callback: (notification: ProactiveSwapNotification) => void) => () => void; } @@ -465,6 +469,12 @@ export const createTerminalAPI = (): TerminalAPI => ({ updateAutoSwitchSettings: (settings: Partial): Promise => ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_UPDATE_AUTO_SWITCH, settings), + getAccountPriorityOrder: (): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.ACCOUNT_PRIORITY_GET), + + setAccountPriorityOrder: (order: string[]): Promise => + ipcRenderer.invoke(IPC_CHANNELS.ACCOUNT_PRIORITY_SET, order), + fetchClaudeUsage: (terminalId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_FETCH_USAGE, terminalId), @@ -508,6 +518,9 @@ export const createTerminalAPI = (): TerminalAPI => ({ requestUsageUpdate: (): Promise> => ipcRenderer.invoke(IPC_CHANNELS.USAGE_REQUEST), + requestAllProfilesUsage: (): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.ALL_PROFILES_USAGE_REQUEST), + onUsageUpdated: ( callback: (usage: import('../../shared/types').ClaudeUsageSnapshot) => void ): (() => void) => { @@ -523,6 +536,21 @@ export const createTerminalAPI = (): TerminalAPI => ({ }; }, + onAllProfilesUsageUpdated: ( + callback: (allProfilesUsage: import('../../shared/types').AllProfilesUsage) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + allProfilesUsage: import('../../shared/types').AllProfilesUsage + ): void => { + callback(allProfilesUsage); + }; + ipcRenderer.on(IPC_CHANNELS.ALL_PROFILES_USAGE_UPDATED, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.ALL_PROFILES_USAGE_UPDATED, handler); + }; + }, + onProactiveSwapNotification: ( callback: (notification: ProactiveSwapNotification) => void ): (() => void) => { diff --git a/apps/frontend/src/renderer/App.tsx b/apps/frontend/src/renderer/App.tsx index a6359097..c94abc97 100644 --- a/apps/frontend/src/renderer/App.tsx +++ b/apps/frontend/src/renderer/App.tsx @@ -1118,7 +1118,7 @@ export function App() { {/* Auth Failure Modal - shows when Claude CLI encounters 401/auth errors */} { - setSettingsInitialSection('integrations'); + setSettingsInitialSection('accounts'); setIsSettingsDialogOpen(true); }} /> @@ -1128,7 +1128,7 @@ export function App() { onClose={handleVersionWarningClose} onOpenSettings={() => { handleVersionWarningClose(); - setSettingsInitialSection('integrations'); + setSettingsInitialSection('accounts'); setIsSettingsDialogOpen(true); }} /> diff --git a/apps/frontend/src/renderer/components/UsageIndicator.tsx b/apps/frontend/src/renderer/components/UsageIndicator.tsx index 2ed7fa03..eb64fe49 100644 --- a/apps/frontend/src/renderer/components/UsageIndicator.tsx +++ b/apps/frontend/src/renderer/components/UsageIndicator.tsx @@ -2,11 +2,17 @@ * Usage Indicator - Real-time Claude usage display in header * * Displays current session/weekly usage as a badge with color-coded status. - * Shows detailed breakdown on hover. + * - Hover to show breakdown popup (auto-closes on mouse leave) + * - Click to pin popup open (stays until clicking outside) */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Activity, TrendingUp, AlertCircle, Clock, User, ChevronRight, Info } from 'lucide-react'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from './ui/popover'; import { Tooltip, TooltipContent, @@ -15,33 +21,87 @@ import { } from './ui/tooltip'; import { useTranslation } from 'react-i18next'; import { formatTimeRemaining, localizeUsageWindowLabel, hasHardcodedText } from '../../shared/utils/format-time'; -import type { ClaudeUsageSnapshot } from '../../shared/types/agent'; +import type { ClaudeUsageSnapshot, ProfileUsageSummary } from '../../shared/types/agent'; +import type { AppSection } from './settings/AppSettings'; + +/** + * Usage threshold constants for color coding + */ +const THRESHOLD_CRITICAL = 95; // Red: At or near limit +const THRESHOLD_WARNING = 91; // Orange: Very high usage +const THRESHOLD_ELEVATED = 71; // Yellow: Moderate usage +// Below 71 is considered normal (green) + +/** + * Get color class based on usage percentage + */ +const getColorClass = (percent: number): string => { + if (percent >= THRESHOLD_CRITICAL) return 'text-red-500'; + if (percent >= THRESHOLD_WARNING) return 'text-orange-500'; + if (percent >= THRESHOLD_ELEVATED) return 'text-yellow-500'; + return 'text-green-500'; +}; + +/** + * Get background/border color classes for badges based on usage percentage + */ +const getBadgeColorClasses = (percent: number): string => { + if (percent >= THRESHOLD_CRITICAL) return 'text-red-500 bg-red-500/10 border-red-500/20'; + if (percent >= THRESHOLD_WARNING) return 'text-orange-500 bg-orange-500/10 border-orange-500/20'; + if (percent >= THRESHOLD_ELEVATED) return 'text-yellow-500 bg-yellow-500/10 border-yellow-500/20'; + return 'text-green-500 bg-green-500/10 border-green-500/20'; +}; + +/** + * Get gradient background class based on usage percentage + */ +const getGradientClass = (percent: number): string => { + if (percent >= THRESHOLD_CRITICAL) return 'bg-gradient-to-r from-red-600 to-red-500'; + if (percent >= THRESHOLD_WARNING) return 'bg-gradient-to-r from-orange-600 to-orange-500'; + if (percent >= THRESHOLD_ELEVATED) return 'bg-gradient-to-r from-yellow-600 to-yellow-500'; + return 'bg-gradient-to-r from-green-600 to-green-500'; +}; + +/** + * Get background class for small usage bars based on usage percentage + */ +const getBarColorClass = (percent: number): string => { + if (percent >= THRESHOLD_CRITICAL) return 'bg-red-500'; + if (percent >= THRESHOLD_WARNING) return 'bg-orange-500'; + if (percent >= THRESHOLD_ELEVATED) return 'bg-yellow-500'; + return 'bg-green-500'; +}; export function UsageIndicator() { const { t, i18n } = useTranslation(['common']); const [usage, setUsage] = useState(null); + const [otherProfiles, setOtherProfiles] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isAvailable, setIsAvailable] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [isPinned, setIsPinned] = useState(false); + const hoverTimeoutRef = useRef(null); + + /** + * Helper function to get initials from a profile name + */ + const getInitials = (name: string): string => { + if (!name || name.trim().length === 0) { + return 'UN'; // Unknown + } + const words = name.trim().split(/\s+/); + if (words.length >= 2) { + return (words[0][0] + words[1][0]).toUpperCase(); + } + return name.substring(0, 2).toUpperCase(); + }; /** * Helper function to format large numbers with locale-aware compact notation - * - * Returns undefined for null/undefined values. The caller (JSX conditional guards) - * is responsible for checking values before calling this function. - * - * @param value - The number to format (undefined, null, or number) - * @returns Formatted compact number string (e.g., "1.2K", "3.4M"), or undefined if input is null/undefined - * - * @example - * formatUsageValue(1234) // "1.2K" (en-US) - * formatUsageValue(null) // undefined - * formatUsageValue(undefined) // undefined */ const formatUsageValue = (value?: number | null): string | undefined => { if (value == null) return undefined; - // Use Intl.NumberFormat for locale-aware compact number formatting - // Fallback to toString() if Intl is not available if (typeof Intl !== 'undefined' && Intl.NumberFormat) { try { return new Intl.NumberFormat(i18n.language, { @@ -56,8 +116,172 @@ export function UsageIndicator() { return value.toString(); }; + /** + * Navigate to settings accounts tab + */ + const handleOpenAccounts = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + // Close the popover first + setIsOpen(false); + setIsPinned(false); + // Dispatch custom event to open settings with accounts section + // Small delay to allow popover to close first + setTimeout(() => { + const event = new CustomEvent('open-app-settings', { + detail: 'accounts' + }); + window.dispatchEvent(event); + }, 100); + }, []); + + /** + * Handle swapping to a different profile + * Uses optimistic UI update for immediate feedback, then fetches fresh data + */ + const handleSwapProfile = useCallback(async (e: React.MouseEvent, profileId: string) => { + e.preventDefault(); + e.stopPropagation(); + + // Capture previous state for revert (before any changes) + const previousUsage = usage; + const previousOtherProfiles = otherProfiles; + + // Find the profile we're swapping to + const targetProfile = otherProfiles.find(p => p.profileId === profileId); + if (!targetProfile) { + console.error('[UsageIndicator] Target profile not found:', profileId); + return; + } + + // Optimistic update: immediately swap profiles in the UI + // 1. Convert current active profile to a ProfileUsageSummary for the "other" list + const currentActiveAsSummary: ProfileUsageSummary = { + profileId: usage?.profileId || '', + profileName: usage?.profileName || '', + profileEmail: usage?.profileEmail, + sessionPercent: usage?.sessionPercent || 0, + weeklyPercent: usage?.weeklyPercent || 0, + sessionResetTimestamp: usage?.sessionResetTimestamp, + weeklyResetTimestamp: usage?.weeklyResetTimestamp, + isAuthenticated: true, + isRateLimited: false, + availabilityScore: 100 - Math.max(usage?.sessionPercent || 0, usage?.weeklyPercent || 0), + isActive: false, // It's no longer active + }; + + // 2. Convert target profile to a ClaudeUsageSnapshot for the active display + const newActiveUsage: ClaudeUsageSnapshot = { + profileId: targetProfile.profileId, + profileName: targetProfile.profileName, + profileEmail: targetProfile.profileEmail, + sessionPercent: targetProfile.sessionPercent, + weeklyPercent: targetProfile.weeklyPercent, + sessionResetTimestamp: targetProfile.sessionResetTimestamp, + weeklyResetTimestamp: targetProfile.weeklyResetTimestamp, + fetchedAt: new Date(), + }; + + // 3. Update the other profiles list: remove target, add current active + const newOtherProfiles = otherProfiles + .filter(p => p.profileId !== profileId) + .concat(usage ? [currentActiveAsSummary] : []) + .sort((a, b) => b.availabilityScore - a.availabilityScore); + + // Apply optimistic update immediately + setUsage(newActiveUsage); + setOtherProfiles(newOtherProfiles); + + try { + // Actually switch the profile on the backend + const result = await window.electronAPI.setActiveClaudeProfile(profileId); + if (result.success) { + // Fetch fresh data in the background (will update via event listeners) + window.electronAPI.requestUsageUpdate(); + window.electronAPI.requestAllProfilesUsage?.(); + } else { + // Revert to captured previous state + console.error('[UsageIndicator] Failed to swap profile, reverting'); + if (previousUsage) setUsage(previousUsage); + setOtherProfiles(previousOtherProfiles); + } + } catch (error) { + console.error('[UsageIndicator] Failed to swap profile:', error); + // Revert to captured previous state + if (previousUsage) setUsage(previousUsage); + setOtherProfiles(previousOtherProfiles); + } + }, [usage, otherProfiles]); + + /** + * Handle mouse enter - show popup after short delay (unless pinned) + */ + const handleMouseEnter = useCallback(() => { + if (isPinned) return; + // Clear any pending close timeout + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + // Open after short delay for smoother UX + hoverTimeoutRef.current = setTimeout(() => { + setIsOpen(true); + }, 150); + }, [isPinned]); + + /** + * Handle mouse leave - close popup after delay (unless pinned) + */ + const handleMouseLeave = useCallback(() => { + if (isPinned) return; + // Clear any pending open timeout + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + // Close after delay to allow moving to popup content + hoverTimeoutRef.current = setTimeout(() => { + setIsOpen(false); + }, 300); + }, [isPinned]); + + /** + * Handle click on trigger - toggle pinned state + */ + const handleTriggerClick = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + if (isPinned) { + // Clicking when pinned unpins and closes + setIsPinned(false); + setIsOpen(false); + } else { + // Clicking when not pinned pins it open + setIsPinned(true); + setIsOpen(true); + } + }, [isPinned]); + + /** + * Handle popover open change (e.g., clicking outside) + */ + const handleOpenChange = useCallback((open: boolean) => { + if (!open) { + // Closing from outside click + setIsOpen(false); + setIsPinned(false); + } + }, []); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + } + }; + }, []); + // Get formatted reset times (calculated dynamically from timestamps) - // Only fall back to sessionResetTime/weeklyResetTime if they don't contain placeholder/hardcoded text const sessionResetTime = usage?.sessionResetTimestamp ? (formatTimeRemaining(usage.sessionResetTimestamp, t) ?? (hasHardcodedText(usage?.sessionResetTime) ? undefined : usage?.sessionResetTime)) @@ -75,6 +299,13 @@ export function UsageIndicator() { setIsLoading(false); }); + // Listen for all profiles usage updates (for multi-profile display) + const unsubscribeAllProfiles = window.electronAPI.onAllProfilesUsageUpdated?.((allProfilesUsage) => { + // Filter out the active profile - we only want to show "other" profiles + const nonActiveProfiles = allProfilesUsage.allProfiles.filter(p => !p.isActive); + setOtherProfiles(nonActiveProfiles); + }); + // Request initial usage on mount window.electronAPI.requestUsageUpdate().then((result) => { setIsLoading(false); @@ -82,23 +313,31 @@ export function UsageIndicator() { setUsage(result.data); setIsAvailable(true); } else { - // No usage data available (endpoint not supported or error) setIsAvailable(false); } }).catch((error) => { - // Handle errors (IPC failure, network issues, etc.) console.warn('[UsageIndicator] Failed to fetch initial usage:', error); setIsLoading(false); setIsAvailable(false); }); + // Request all profiles usage immediately on mount (so other accounts show right away) + window.electronAPI.requestAllProfilesUsage?.().then((result) => { + if (result.success && result.data) { + const nonActiveProfiles = result.data.allProfiles.filter(p => !p.isActive); + setOtherProfiles(nonActiveProfiles); + } + }).catch((error) => { + console.warn('[UsageIndicator] Failed to fetch all profiles usage:', error); + }); + return () => { unsubscribe(); + unsubscribeAllProfiles?.(); }; }, []); - // Always show the badge, but display different states - // Show loading state initially + // Show loading state if (isLoading) { return (
@@ -108,7 +347,7 @@ export function UsageIndicator() { ); } - // Show unavailable state when endpoint doesn't return data + // Show unavailable state if (!isAvailable || !usage) { return ( @@ -132,17 +371,18 @@ export function UsageIndicator() { ); } - // Determine color based on session usage (5-hour window) - // This is what should be shown on the badge per QA feedback - const badgeUsage = usage.sessionPercent; - const badgeColorClasses = - badgeUsage >= 95 ? 'text-red-500 bg-red-500/10 border-red-500/20' : - badgeUsage >= 91 ? 'text-orange-500 bg-orange-500/10 border-orange-500/20' : - badgeUsage >= 71 ? 'text-yellow-500 bg-yellow-500/10 border-yellow-500/20' : - 'text-green-500 bg-green-500/10 border-green-500/20'; + // Determine colors and labels based on the LIMITING factor (higher of session/weekly) + const sessionPercent = usage.sessionPercent; + const weeklyPercent = usage.weeklyPercent; + const limitingPercent = Math.max(sessionPercent, weeklyPercent); + + // Badge color based on the limiting (higher) percentage + const badgeColorClasses = getBadgeColorClasses(limitingPercent); + + // Individual colors for session and weekly in the badge + const sessionColorClass = getColorClass(sessionPercent); + const weeklyColorClass = getColorClass(weeklyPercent); - // Get window labels for display - // Map backend-provided labels to localized versions with appropriate defaults const sessionLabel = localizeUsageWindowLabel( usage?.usageWindows?.sessionWindowLabel, t, @@ -154,145 +394,253 @@ export function UsageIndicator() { 'common:usage.weeklyDefault' ); - // For icon, use the highest of the two windows const maxUsage = Math.max(usage.sessionPercent, usage.weeklyPercent); const Icon = - maxUsage >= 91 ? AlertCircle : - maxUsage >= 71 ? TrendingUp : + maxUsage >= THRESHOLD_WARNING ? AlertCircle : + maxUsage >= THRESHOLD_ELEVATED ? TrendingUp : Activity; return ( - - - - - - -
- {/* Header with overall status */} -
- - {t('common:usage.usageBreakdown')} -
- - {/* Session/5-hour usage */} -
-
- - - {sessionLabel} - - = 95 ? 'text-red-500' : - usage.sessionPercent >= 91 ? 'text-orange-500' : - usage.sessionPercent >= 71 ? 'text-yellow-600' : - 'text-green-600' - }`}> - {Math.round(usage.sessionPercent)}% - -
- {sessionResetTime && ( -
- - {sessionResetTime} -
- )} - {/* Enhanced progress bar with gradient */} -
-
= 95 ? 'bg-gradient-to-r from-red-600 to-red-500' : - usage.sessionPercent >= 91 ? 'bg-gradient-to-r from-orange-600 to-orange-500' : - usage.sessionPercent >= 71 ? 'bg-gradient-to-r from-yellow-600 to-yellow-500' : - 'bg-gradient-to-r from-green-600 to-green-500' - }`} - style={{ width: `${Math.min(usage.sessionPercent, 100)}%` }} - > - {/* Subtle shine effect */} -
-
-
- {/* Raw usage value with better styling */} - {usage.sessionUsageValue != null && usage.sessionUsageLimit != null && ( -
- {t('common:usage.used')} - - {formatUsageValue(usage.sessionUsageValue)} / {formatUsageValue(usage.sessionUsageLimit)} - -
- )} -
- - {/* Weekly/Monthly usage */} -
-
- - - {weeklyLabel} - - = 99 ? 'text-red-500' : - usage.weeklyPercent >= 91 ? 'text-orange-500' : - usage.weeklyPercent >= 71 ? 'text-yellow-600' : - 'text-green-600' - }`}> - {Math.round(usage.weeklyPercent)}% - -
- {weeklyResetTime && ( -
- - {weeklyResetTime} -
- )} - {/* Enhanced progress bar with gradient */} -
-
= 99 ? 'bg-gradient-to-r from-red-600 to-red-500' : - usage.weeklyPercent >= 91 ? 'bg-gradient-to-r from-orange-600 to-orange-500' : - usage.weeklyPercent >= 71 ? 'bg-gradient-to-r from-yellow-600 to-yellow-500' : - 'bg-gradient-to-r from-green-600 to-green-500' - }`} - style={{ width: `${Math.min(usage.weeklyPercent, 100)}%` }} - > - {/* Subtle shine effect */} -
-
-
- {/* Raw usage value with better styling */} - {usage.weeklyUsageValue != null && usage.weeklyUsageLimit != null && ( -
- {t('common:usage.used')} - - {formatUsageValue(usage.weeklyUsageValue)} / {formatUsageValue(usage.weeklyUsageLimit)} - -
- )} -
- - {/* Active account footer */} -
-
- - {t('common:usage.activeAccount')} -
-
- {usage.profileName} - -
-
- - - + + + +
+ {/* Header with overall status */} +
+ + {t('common:usage.usageBreakdown')} +
+ + {/* Session/5-hour usage */} +
+
+ + + {sessionLabel} + + + {Math.round(usage.sessionPercent)}% + +
+ {sessionResetTime && ( +
+ + {sessionResetTime} +
+ )} +
+
+
+
+
+ {usage.sessionUsageValue != null && usage.sessionUsageLimit != null && ( +
+ {t('common:usage.used')} + + {formatUsageValue(usage.sessionUsageValue)} / {formatUsageValue(usage.sessionUsageLimit)} + +
+ )} +
+ + {/* Weekly/Monthly usage */} +
+
+ + + {weeklyLabel} + + + {Math.round(usage.weeklyPercent)}% + +
+ {weeklyResetTime && ( +
+ + {weeklyResetTime} +
+ )} +
+
+
+
+
+ {usage.weeklyUsageValue != null && usage.weeklyUsageLimit != null && ( +
+ {t('common:usage.used')} + + {formatUsageValue(usage.weeklyUsageValue)} / {formatUsageValue(usage.weeklyUsageLimit)} + +
+ )} +
+ + {/* Active account footer - clickable to go to settings */} + + + {/* Other profiles section - sorted by availability */} + {otherProfiles.length > 0 && ( +
+
+ {t('common:usage.otherAccounts')} +
+ {otherProfiles.map((profile, index) => ( +
+ {/* Initials Avatar with status indicator */} +
+
+ + {getInitials(profile.profileName)} + +
+ {/* Status dot */} + {profile.isRateLimited && ( +
+ )} +
+ + {/* Profile Info */} +
+
+ + {profile.profileEmail || profile.profileName} + + {index === 0 && !profile.isRateLimited && profile.isAuthenticated && ( + + {t('common:usage.next')} + + )} + {/* Swap button - only show for authenticated profiles */} + {profile.isAuthenticated && ( + + )} +
+ {/* Usage bars or status - show both session and weekly */} + {profile.isRateLimited ? ( + + {profile.rateLimitType === 'weekly' + ? t('common:usage.weeklyLimitReached') + : t('common:usage.sessionLimitReached')} + + ) : !profile.isAuthenticated ? ( + + {t('common:usage.notAuthenticated')} + + ) : ( +
+ {/* Session usage (short-term) */} +
+ +
+
+
+ + {Math.round(profile.sessionPercent)}% + +
+ {/* Weekly usage (long-term) */} +
+ +
+
+
+ + {Math.round(profile.weeklyPercent)}% + +
+
+ )} +
+
+ ))} +
+ )} +
+ + ); } diff --git a/apps/frontend/src/renderer/components/settings/AccountPriorityList.tsx b/apps/frontend/src/renderer/components/settings/AccountPriorityList.tsx new file mode 100644 index 00000000..4d38a4cf --- /dev/null +++ b/apps/frontend/src/renderer/components/settings/AccountPriorityList.tsx @@ -0,0 +1,457 @@ +/** + * AccountPriorityList - Unified drag-and-drop priority list with usage visualization + * + * Displays ALL accounts in a single, unified priority list. Position determines + * fallback order - the system uses accounts from top to bottom. + * + * Supports all user scenarios: + * - OAuth accounts as primary with API fallback + * - API endpoints as primary with OAuth fallback + * - Any mix of providers in any order + */ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { + GripVertical, + Star, + Tag, + Infinity, + AlertCircle, + Users, + Server, + Clock, + TrendingUp, + Info +} from 'lucide-react'; +import { cn } from '../../lib/utils'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; + +/** + * Usage threshold constants for color coding (matching UsageIndicator) + */ +const THRESHOLD_CRITICAL = 95; // Red: At or near limit +const THRESHOLD_WARNING = 91; // Orange: Very high usage +const THRESHOLD_ELEVATED = 71; // Yellow: Moderate usage + +/** + * Get color class based on usage percentage + */ +const getColorClass = (percent: number): string => { + if (percent >= THRESHOLD_CRITICAL) return 'text-red-500'; + if (percent >= THRESHOLD_WARNING) return 'text-orange-500'; + if (percent >= THRESHOLD_ELEVATED) return 'text-yellow-500'; + return 'text-green-500'; +}; + +/** + * Get background class for progress bars + */ +const getBarColorClass = (percent: number): string => { + if (percent >= THRESHOLD_CRITICAL) return 'bg-red-500'; + if (percent >= THRESHOLD_WARNING) return 'bg-orange-500'; + if (percent >= THRESHOLD_ELEVATED) return 'bg-yellow-500'; + return 'bg-green-500'; +}; + +/** + * Get status label key based on usage + */ +const getStatusKey = (sessionPercent?: number, weeklyPercent?: number, isRateLimited?: boolean): string => { + if (isRateLimited) return 'rateLimited'; + const maxPercent = Math.max(sessionPercent ?? 0, weeklyPercent ?? 0); + if (maxPercent >= THRESHOLD_CRITICAL) return 'nearLimit'; + if (maxPercent >= THRESHOLD_WARNING) return 'highUsage'; + if (maxPercent >= THRESHOLD_ELEVATED) return 'moderate'; + return 'healthy'; +}; + +/** + * Unified account representation for the priority list + */ +export interface UnifiedAccount { + id: string; + name: string; + type: 'oauth' | 'api'; + displayName: string; + identifier: string; // email for OAuth, baseUrl for API + isActive: boolean; // TRUE only for the ONE account currently in use + isNext: boolean; + isAvailable: boolean; + hasUnlimitedUsage: boolean; + sessionPercent?: number; + weeklyPercent?: number; + isRateLimited?: boolean; + rateLimitType?: 'session' | 'weekly'; + isAuthenticated?: boolean; + /** Set when this account has identical usage to another - may indicate same underlying account */ + isDuplicateUsage?: boolean; +} + +interface SortableAccountItemProps { + account: UnifiedAccount; + index: number; +} + +function SortableAccountItem({ account, index }: SortableAccountItemProps) { + const { t } = useTranslation('settings'); + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging + } = useSortable({ id: account.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 50 : undefined + }; + + const statusKey = getStatusKey(account.sessionPercent, account.weeklyPercent, account.isRateLimited); + + return ( +
+ {/* Drag handle */} +
+ +
+ + {/* Priority number */} +
+ {index + 1} +
+ + {/* Account icon - visual distinction between OAuth and API */} +
+ {account.type === 'oauth' ? ( + + ) : ( + + )} +
+ + {/* Account info and usage */} +
+
+ + {account.displayName} + + {/* Account type indicator */} + + {account.type === 'oauth' ? t('accounts.priority.typeOAuth') : t('accounts.priority.typeAPI')} + + {/* Status badges - only ONE account should have "In Use" */} + {account.isActive && ( + + + {t('accounts.priority.inUse')} + + )} + {account.isNext && !account.isActive && ( + + + {t('accounts.priority.next')} + + )} +
+ + {account.identifier} + + + {/* Usage bars for OAuth accounts */} + {account.type === 'oauth' && account.isAvailable && account.sessionPercent !== undefined && ( +
+ {/* Session usage */} + + +
+ +
+
+
+ + {Math.round(account.sessionPercent)}% + +
+ + + {t('accounts.priority.sessionUsage')} + + + + {/* Weekly usage */} + + +
+ +
+
+
+ + {Math.round(account.weeklyPercent ?? 0)}% + +
+ + + {t('accounts.priority.weeklyUsage')} + + + + {/* Status indicator */} + + {t(`accounts.priority.status.${statusKey}`)} + +
+ )} + + {/* OAuth account not authenticated */} + {account.type === 'oauth' && !account.isAvailable && ( +
+ + + {t('accounts.priority.needsAuth')} + +
+ )} + + {/* Duplicate usage warning - may indicate same underlying Anthropic account */} + {account.type === 'oauth' && account.isDuplicateUsage && account.isAvailable && ( + + +
+ + + {t('accounts.priority.duplicateUsage')} + +
+
+ + {t('accounts.priority.duplicateUsageHint')} + +
+ )} +
+ + {/* Right side badge for API profiles */} + {account.type === 'api' && ( +
+ + + {t('accounts.priority.payPerUse')} + +
+ )} +
+ ); +} + +interface AccountPriorityListProps { + accounts: UnifiedAccount[]; + onReorder: (newOrder: string[]) => void; + isLoading?: boolean; +} + +export function AccountPriorityList({ accounts, onReorder, isLoading }: AccountPriorityListProps) { + const { t } = useTranslation('settings'); + const [items, setItems] = useState(accounts); + + // Sync with external accounts prop + useEffect(() => { + setItems(accounts); + }, [accounts]); + + // Determine "next" account - first available account after the active one + const nextAccountId = useMemo(() => { + const activeIndex = items.findIndex(a => a.isActive); + if (activeIndex === -1) { + // No active account - first available is "next" + return items.find(a => a.isAvailable)?.id ?? null; + } + for (let i = activeIndex + 1; i < items.length; i++) { + if (items[i].isAvailable && !items[i].isActive) { + return items[i].id; + } + } + // Wrap around to beginning if needed + for (let i = 0; i < activeIndex; i++) { + if (items[i].isAvailable && !items[i].isActive) { + return items[i].id; + } + } + return null; + }, [items]); + + // Detect duplicate usage - OAuth accounts with identical non-zero usage may be the same underlying account + const duplicateUsageIds = useMemo(() => { + const duplicates = new Set(); + const oauthAccounts = items.filter(a => a.type === 'oauth' && a.isAvailable); + + // Only check if we have 2+ OAuth accounts with usage data + if (oauthAccounts.length < 2) return duplicates; + + // Build usage signature map + const usageSignatures = new Map(); + for (const account of oauthAccounts) { + // Create a signature from usage percentages + // Only consider it a duplicate if both session and weekly are defined and non-zero + if (account.sessionPercent !== undefined && account.weeklyPercent !== undefined) { + // Skip if both are 0 (could be new accounts or accounts with reset usage) + if (account.sessionPercent === 0 && account.weeklyPercent === 0) continue; + + const signature = `${account.sessionPercent}-${account.weeklyPercent}`; + const existing = usageSignatures.get(signature) ?? []; + existing.push(account.id); + usageSignatures.set(signature, existing); + } + } + + // Mark accounts with duplicate signatures + for (const [, ids] of usageSignatures) { + if (ids.length > 1) { + ids.forEach(id => duplicates.add(id)); + } + } + + return duplicates; + }, [items]); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + setItems((currentItems) => { + const oldIndex = currentItems.findIndex((item) => item.id === active.id); + const newIndex = currentItems.findIndex((item) => item.id === over.id); + const newItems = arrayMove(currentItems, oldIndex, newIndex); + + // Notify parent of new order + onReorder(newItems.map(item => item.id)); + + return newItems; + }); + } + }, [onReorder]); + + if (items.length === 0) { + return ( +
+

{t('accounts.priority.noAccounts')}

+
+ ); + } + + return ( +
+ {/* Header */} +
+

+ {t('accounts.priority.title')} +

+

+ {t('accounts.priority.description')} +

+
+ + + item.id)} + strategy={verticalListSortingStrategy} + > +
+ {items.map((account, index) => ( + + ))} +
+
+
+ + {/* Explanatory tip - provider agnostic */} +
+
+ +
+

{t('accounts.priority.tipTitle')}

+

{t('accounts.priority.tipDescription')}

+
+
+
+
+ ); +} diff --git a/apps/frontend/src/renderer/components/settings/AccountSettings.tsx b/apps/frontend/src/renderer/components/settings/AccountSettings.tsx new file mode 100644 index 00000000..4e38b601 --- /dev/null +++ b/apps/frontend/src/renderer/components/settings/AccountSettings.tsx @@ -0,0 +1,1337 @@ +/** + * AccountSettings - Unified account management for Claude Code and Custom Endpoints + * + * Consolidates the former "Integrations" and "API Profiles" settings into a single + * tabbed interface with shared automatic account switching controls. + * + * Structure: + * - Tabs: "Claude Code" (OAuth accounts) | "Custom Endpoints" (API profiles) + * - Persistent: Automatic Account Switching section (below tabs) + */ +import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Eye, + EyeOff, + Users, + Plus, + Trash2, + Star, + Check, + Pencil, + X, + Loader2, + LogIn, + ChevronDown, + ChevronRight, + RefreshCw, + Activity, + AlertCircle, + Server, + Globe +} from 'lucide-react'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { Label } from '../ui/label'; +import { Switch } from '../ui/switch'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '../ui/tabs'; +import { cn } from '../../lib/utils'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; +import { SettingsSection } from './SettingsSection'; +import { AuthTerminal } from './AuthTerminal'; +import { ProfileEditDialog } from './ProfileEditDialog'; +import { AccountPriorityList, type UnifiedAccount } from './AccountPriorityList'; +import { maskApiKey } from '../../lib/profile-utils'; +import { loadClaudeProfiles as loadGlobalClaudeProfiles } from '../../stores/claude-profile-store'; +import { useSettingsStore } from '../../stores/settings-store'; +import { useToast } from '../../hooks/use-toast'; +import type { AppSettings, ClaudeProfile, ClaudeAutoSwitchSettings, ProfileUsageSummary } from '../../../shared/types'; +import type { APIProfile } from '@shared/types/profile'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '../ui/alert-dialog'; + +interface AccountSettingsProps { + settings: AppSettings; + onSettingsChange: (settings: AppSettings) => void; + isOpen: boolean; +} + +/** + * Unified account settings with tabs for Claude Code and Custom Endpoints + */ +export function AccountSettings({ settings, onSettingsChange, isOpen }: AccountSettingsProps) { + const { t } = useTranslation('settings'); + const { t: tCommon } = useTranslation('common'); + const { toast } = useToast(); + + // Tab state + const [activeTab, setActiveTab] = useState<'claude-code' | 'custom-endpoints'>('claude-code'); + + // ============================================ + // Claude Code (OAuth) state + // ============================================ + const [claudeProfiles, setClaudeProfiles] = useState([]); + const [activeClaudeProfileId, setActiveClaudeProfileId] = useState(null); + const [isLoadingProfiles, setIsLoadingProfiles] = useState(false); + const [newProfileName, setNewProfileName] = useState(''); + const [isAddingProfile, setIsAddingProfile] = useState(false); + const [deletingProfileId, setDeletingProfileId] = useState(null); + const [editingProfileId, setEditingProfileId] = useState(null); + const [editingProfileName, setEditingProfileName] = useState(''); + const [authenticatingProfileId, setAuthenticatingProfileId] = useState(null); + const [expandedTokenProfileId, setExpandedTokenProfileId] = useState(null); + const [manualToken, setManualToken] = useState(''); + const [manualTokenEmail, setManualTokenEmail] = useState(''); + const [showManualToken, setShowManualToken] = useState(false); + const [savingTokenProfileId, setSavingTokenProfileId] = useState(null); + + // Auth terminal state + const [authTerminal, setAuthTerminal] = useState<{ + terminalId: string; + configDir: string; + profileId: string; + profileName: string; + } | null>(null); + + // ============================================ + // Custom Endpoints (API Profiles) state + // ============================================ + const { + profiles: apiProfiles, + activeProfileId: activeApiProfileId, + deleteProfile: deleteApiProfile, + setActiveProfile: setActiveApiProfile, + profilesError + } = useSettingsStore(); + + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + const [editApiProfile, setEditApiProfile] = useState(null); + const [deleteConfirmProfile, setDeleteConfirmProfile] = useState(null); + const [isDeletingApiProfile, setIsDeletingApiProfile] = useState(false); + const [isSettingActiveApiProfile, setIsSettingActiveApiProfile] = useState(false); + + // ============================================ + // Auto-switch settings state (shared) + // ============================================ + const [autoSwitchSettings, setAutoSwitchSettings] = useState(null); + const [isLoadingAutoSwitch, setIsLoadingAutoSwitch] = useState(false); + + // ============================================ + // Priority order state + // ============================================ + const [priorityOrder, setPriorityOrder] = useState([]); + const [isSavingPriority, setIsSavingPriority] = useState(false); + + // ============================================ + // Usage data state (for priority list visualization) + // ============================================ + const [profileUsageData, setProfileUsageData] = useState>(new Map()); + + // Fetch all profiles usage data + const loadProfileUsageData = useCallback(async () => { + try { + const result = await window.electronAPI.requestAllProfilesUsage?.(); + if (result?.success && result.data) { + const usageMap = new Map(); + result.data.allProfiles.forEach(profile => { + usageMap.set(profile.profileId, profile); + }); + setProfileUsageData(usageMap); + } + } catch (err) { + console.warn('[AccountSettings] Failed to load profile usage data:', err); + } + }, []); + + // Build unified accounts list from both OAuth and API profiles + const buildUnifiedAccounts = useCallback((): UnifiedAccount[] => { + const unifiedList: UnifiedAccount[] = []; + + // Add OAuth profiles with usage data + claudeProfiles.forEach((profile) => { + const usageData = profileUsageData.get(profile.id); + unifiedList.push({ + id: `oauth-${profile.id}`, + name: profile.name, + type: 'oauth', + displayName: profile.name, + identifier: profile.email || t('accounts.priority.noEmail'), + isActive: profile.id === activeClaudeProfileId && !activeApiProfileId, + isNext: false, // Will be computed by AccountPriorityList + isAvailable: profile.isAuthenticated ?? false, + hasUnlimitedUsage: false, + // Use real usage data from the usage monitor + sessionPercent: usageData?.sessionPercent, + weeklyPercent: usageData?.weeklyPercent, + isRateLimited: usageData?.isRateLimited, + rateLimitType: usageData?.rateLimitType, + isAuthenticated: profile.isAuthenticated, + }); + }); + + // Add API profiles + apiProfiles.forEach((profile) => { + unifiedList.push({ + id: `api-${profile.id}`, + name: profile.name, + type: 'api', + displayName: profile.name, + identifier: profile.baseUrl, + isActive: profile.id === activeApiProfileId, + isNext: false, // Will be computed by AccountPriorityList + isAvailable: true, // API profiles are always considered available + hasUnlimitedUsage: true, // API profiles have no rate limits + sessionPercent: undefined, + weeklyPercent: undefined, + }); + }); + + // Sort by priority order if available + if (priorityOrder.length > 0) { + unifiedList.sort((a, b) => { + const aIndex = priorityOrder.indexOf(a.id); + const bIndex = priorityOrder.indexOf(b.id); + // Items not in priority order go to the end + const aPos = aIndex === -1 ? Infinity : aIndex; + const bPos = bIndex === -1 ? Infinity : bIndex; + return aPos - bPos; + }); + } + + return unifiedList; + }, [claudeProfiles, apiProfiles, activeClaudeProfileId, activeApiProfileId, priorityOrder, profileUsageData, t]); + + const unifiedAccounts = buildUnifiedAccounts(); + + // Load priority order from settings + const loadPriorityOrder = async () => { + try { + const result = await window.electronAPI.getAccountPriorityOrder(); + if (result.success && result.data) { + setPriorityOrder(result.data); + } + } catch (err) { + console.warn('[AccountSettings] Failed to load priority order:', err); + } + }; + + // Save priority order + const handlePriorityReorder = async (newOrder: string[]) => { + setPriorityOrder(newOrder); + setIsSavingPriority(true); + try { + await window.electronAPI.setAccountPriorityOrder(newOrder); + } catch (err) { + console.warn('[AccountSettings] Failed to save priority order:', err); + toast({ + variant: 'destructive', + title: t('accounts.toast.settingsUpdateFailed'), + description: t('accounts.toast.tryAgain'), + }); + } finally { + setIsSavingPriority(false); + } + }; + + // Load data when section is opened + useEffect(() => { + if (isOpen) { + loadClaudeProfiles(); + loadAutoSwitchSettings(); + loadPriorityOrder(); + loadProfileUsageData(); + } + }, [isOpen, loadProfileUsageData]); + + // Subscribe to usage updates for real-time data + useEffect(() => { + const unsubscribe = window.electronAPI.onAllProfilesUsageUpdated?.((allProfilesUsage) => { + const usageMap = new Map(); + allProfilesUsage.allProfiles.forEach(profile => { + usageMap.set(profile.profileId, profile); + }); + setProfileUsageData(usageMap); + }); + + return () => { + unsubscribe?.(); + }; + }, []); + + // ============================================ + // Claude Code (OAuth) handlers + // ============================================ + const loadClaudeProfiles = async () => { + setIsLoadingProfiles(true); + try { + const result = await window.electronAPI.getClaudeProfiles(); + if (result.success && result.data) { + setClaudeProfiles(result.data.profiles); + setActiveClaudeProfileId(result.data.activeProfileId); + await loadGlobalClaudeProfiles(); + } else if (!result.success) { + toast({ + variant: 'destructive', + title: t('accounts.toast.loadProfilesFailed'), + description: result.error || t('accounts.toast.tryAgain'), + }); + } + } catch (err) { + console.warn('[AccountSettings] Failed to load Claude profiles:', err); + toast({ + variant: 'destructive', + title: t('accounts.toast.loadProfilesFailed'), + description: t('accounts.toast.tryAgain'), + }); + } finally { + setIsLoadingProfiles(false); + } + }; + + const handleAddClaudeProfile = async () => { + if (!newProfileName.trim()) return; + + setIsAddingProfile(true); + try { + const profileName = newProfileName.trim(); + const profileSlug = profileName.toLowerCase().replace(/\s+/g, '-'); + + const result = await window.electronAPI.saveClaudeProfile({ + id: `profile-${Date.now()}`, + name: profileName, + configDir: `~/.claude-profiles/${profileSlug}`, + isDefault: false, + createdAt: new Date() + }); + + if (result.success && result.data) { + await loadClaudeProfiles(); + setNewProfileName(''); + + const authResult = await window.electronAPI.authenticateClaudeProfile(result.data.id); + if (authResult.success && authResult.data) { + setAuthenticatingProfileId(result.data.id); + setAuthTerminal({ + terminalId: authResult.data.terminalId, + configDir: authResult.data.configDir, + profileId: result.data.id, + profileName, + }); + } else { + toast({ + variant: 'destructive', + title: t('accounts.toast.authFailed'), + description: authResult.error || t('accounts.toast.tryAgain'), + }); + } + } + } catch (err) { + toast({ + variant: 'destructive', + title: t('accounts.toast.addProfileFailed'), + description: t('accounts.toast.tryAgain'), + }); + } finally { + setIsAddingProfile(false); + } + }; + + const handleDeleteClaudeProfile = async (profileId: string) => { + setDeletingProfileId(profileId); + try { + const result = await window.electronAPI.deleteClaudeProfile(profileId); + if (result.success) { + await loadClaudeProfiles(); + // Remove from priority order + const unifiedId = `oauth-${profileId}`; + if (priorityOrder.includes(unifiedId)) { + const newOrder = priorityOrder.filter(id => id !== unifiedId); + await handlePriorityReorder(newOrder); + } + } else { + toast({ + variant: 'destructive', + title: t('accounts.toast.deleteProfileFailed'), + description: result.error || t('accounts.toast.tryAgain'), + }); + } + } catch (err) { + toast({ + variant: 'destructive', + title: t('accounts.toast.deleteProfileFailed'), + description: t('accounts.toast.tryAgain'), + }); + } finally { + setDeletingProfileId(null); + } + }; + + const startEditingProfile = (profile: ClaudeProfile) => { + setEditingProfileId(profile.id); + setEditingProfileName(profile.name); + }; + + const cancelEditingProfile = () => { + setEditingProfileId(null); + setEditingProfileName(''); + }; + + const handleRenameProfile = async () => { + if (!editingProfileId || !editingProfileName.trim()) return; + + try { + const result = await window.electronAPI.renameClaudeProfile(editingProfileId, editingProfileName.trim()); + if (result.success) { + await loadClaudeProfiles(); + } else { + toast({ + variant: 'destructive', + title: t('accounts.toast.renameProfileFailed'), + description: result.error || t('accounts.toast.tryAgain'), + }); + } + } catch (err) { + toast({ + variant: 'destructive', + title: t('accounts.toast.renameProfileFailed'), + description: t('accounts.toast.tryAgain'), + }); + } finally { + setEditingProfileId(null); + setEditingProfileName(''); + } + }; + + const handleSetActiveClaudeProfile = async (profileId: string) => { + try { + // If an API profile is currently active, clear it first + // so the OAuth profile becomes the active account + if (activeApiProfileId) { + await setActiveApiProfile(null); + } + + const result = await window.electronAPI.setActiveClaudeProfile(profileId); + if (result.success) { + setActiveClaudeProfileId(profileId); + await loadGlobalClaudeProfiles(); + } else { + toast({ + variant: 'destructive', + title: t('accounts.toast.setActiveProfileFailed'), + description: result.error || t('accounts.toast.tryAgain'), + }); + } + } catch (err) { + toast({ + variant: 'destructive', + title: t('accounts.toast.setActiveProfileFailed'), + description: t('accounts.toast.tryAgain'), + }); + } + }; + + const handleAuthenticateProfile = async (profileId: string) => { + const profile = claudeProfiles.find(p => p.id === profileId); + const profileName = profile?.name || 'Profile'; + + setAuthenticatingProfileId(profileId); + try { + const result = await window.electronAPI.authenticateClaudeProfile(profileId); + if (!result.success || !result.data) { + toast({ + variant: 'destructive', + title: t('accounts.toast.authFailed'), + description: result.error || t('accounts.toast.tryAgain'), + }); + setAuthenticatingProfileId(null); + return; + } + + setAuthTerminal({ + terminalId: result.data.terminalId, + configDir: result.data.configDir, + profileId, + profileName, + }); + } catch (err) { + console.error('Failed to authenticate profile:', err); + toast({ + variant: 'destructive', + title: t('accounts.toast.authFailed'), + description: t('accounts.toast.tryAgain'), + }); + setAuthenticatingProfileId(null); + } + }; + + const handleAuthTerminalClose = useCallback(() => { + setAuthTerminal(null); + setAuthenticatingProfileId(null); + }, []); + + const handleAuthTerminalSuccess = useCallback(async () => { + setAuthTerminal(null); + setAuthenticatingProfileId(null); + await loadClaudeProfiles(); + }, []); + + const handleAuthTerminalError = useCallback(() => { + // Don't auto-close on error + }, []); + + const toggleTokenEntry = (profileId: string) => { + if (expandedTokenProfileId === profileId) { + setExpandedTokenProfileId(null); + setManualToken(''); + setManualTokenEmail(''); + setShowManualToken(false); + } else { + setExpandedTokenProfileId(profileId); + setManualToken(''); + setManualTokenEmail(''); + setShowManualToken(false); + } + }; + + const handleSaveManualToken = async (profileId: string) => { + if (!manualToken.trim()) return; + + setSavingTokenProfileId(profileId); + try { + const result = await window.electronAPI.setClaudeProfileToken( + profileId, + manualToken.trim(), + manualTokenEmail.trim() || undefined + ); + if (result.success) { + await loadClaudeProfiles(); + setExpandedTokenProfileId(null); + setManualToken(''); + setManualTokenEmail(''); + setShowManualToken(false); + toast({ + title: t('accounts.toast.tokenSaved'), + description: t('accounts.toast.tokenSavedDescription'), + }); + } else { + toast({ + variant: 'destructive', + title: t('accounts.toast.tokenSaveFailed'), + description: result.error || t('accounts.toast.tryAgain'), + }); + } + } catch (err) { + toast({ + variant: 'destructive', + title: t('accounts.toast.tokenSaveFailed'), + description: t('accounts.toast.tryAgain'), + }); + } finally { + setSavingTokenProfileId(null); + } + }; + + // ============================================ + // Custom Endpoints (API Profiles) handlers + // ============================================ + const handleDeleteApiProfile = async () => { + if (!deleteConfirmProfile) return; + + setIsDeletingApiProfile(true); + const success = await deleteApiProfile(deleteConfirmProfile.id); + setIsDeletingApiProfile(false); + + if (success) { + toast({ + title: t('apiProfiles.toast.delete.title'), + description: t('apiProfiles.toast.delete.description', { name: deleteConfirmProfile.name }), + }); + // Remove from priority order + const unifiedId = `api-${deleteConfirmProfile.id}`; + if (priorityOrder.includes(unifiedId)) { + const newOrder = priorityOrder.filter(id => id !== unifiedId); + await handlePriorityReorder(newOrder); + } + setDeleteConfirmProfile(null); + } else { + toast({ + variant: 'destructive', + title: t('apiProfiles.toast.delete.errorTitle'), + description: profilesError || t('apiProfiles.toast.delete.errorFallback'), + }); + } + }; + + const handleSetActiveApiProfileClick = async (profileId: string | null) => { + if (profileId !== null && profileId === activeApiProfileId) return; + + setIsSettingActiveApiProfile(true); + const success = await setActiveApiProfile(profileId); + setIsSettingActiveApiProfile(false); + + if (success) { + if (profileId === null) { + toast({ + title: t('apiProfiles.toast.switch.oauthTitle'), + description: t('apiProfiles.toast.switch.oauthDescription'), + }); + } else { + const activeProfile = apiProfiles.find(p => p.id === profileId); + if (activeProfile) { + toast({ + title: t('apiProfiles.toast.switch.profileTitle'), + description: t('apiProfiles.toast.switch.profileDescription', { name: activeProfile.name }), + }); + } + } + } else { + toast({ + variant: 'destructive', + title: t('apiProfiles.toast.switch.errorTitle'), + description: profilesError || t('apiProfiles.toast.switch.errorFallback'), + }); + } + }; + + const getHostFromUrl = (url: string): string => { + try { + return new URL(url).host; + } catch { + return url; + } + }; + + // ============================================ + // Auto-switch settings handlers (shared) + // ============================================ + const loadAutoSwitchSettings = async () => { + setIsLoadingAutoSwitch(true); + try { + const result = await window.electronAPI.getAutoSwitchSettings(); + if (result.success && result.data) { + setAutoSwitchSettings(result.data); + } + } catch (err) { + console.warn('[AccountSettings] Failed to load auto-switch settings:', err); + } finally { + setIsLoadingAutoSwitch(false); + } + }; + + const handleUpdateAutoSwitch = async (updates: Partial) => { + setIsLoadingAutoSwitch(true); + try { + const result = await window.electronAPI.updateAutoSwitchSettings(updates); + if (result.success) { + await loadAutoSwitchSettings(); + } else { + toast({ + variant: 'destructive', + title: t('accounts.toast.settingsUpdateFailed'), + description: result.error || t('accounts.toast.tryAgain'), + }); + } + } catch (err) { + toast({ + variant: 'destructive', + title: t('accounts.toast.settingsUpdateFailed'), + description: t('accounts.toast.tryAgain'), + }); + } finally { + setIsLoadingAutoSwitch(false); + } + }; + + // Calculate total accounts for auto-switch visibility + const totalAccounts = claudeProfiles.length + apiProfiles.length; + + return ( + +
+ {/* Tabs for Claude Code vs Custom Endpoints */} + setActiveTab(v as 'claude-code' | 'custom-endpoints')}> + + + + {t('accounts.tabs.claudeCode')} + + + + {t('accounts.tabs.customEndpoints')} + + + + {/* Claude Code Tab Content */} + +
+

+ {t('accounts.claudeCode.description')} +

+ + {/* Accounts list */} + {isLoadingProfiles ? ( +
+ +
+ ) : claudeProfiles.length === 0 ? ( +
+

{t('accounts.claudeCode.noAccountsYet')}

+
+ ) : ( +
+ {claudeProfiles.map((profile) => ( +
+
+
+
+ {(editingProfileId === profile.id ? editingProfileName : profile.name).charAt(0).toUpperCase()} +
+
+ {editingProfileId === profile.id ? ( +
+ setEditingProfileName(e.target.value)} + className="h-7 text-sm w-40" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') handleRenameProfile(); + if (e.key === 'Escape') cancelEditingProfile(); + }} + /> + + +
+ ) : ( + <> +
+ {profile.name} + {profile.isDefault && ( + {t('accounts.claudeCode.default')} + )} + {profile.id === activeClaudeProfileId && !activeApiProfileId && ( + + + {t('accounts.claudeCode.active')} + + )} + {profile.isAuthenticated ? ( + + + {t('accounts.claudeCode.authenticated')} + + ) : ( + + {t('accounts.claudeCode.needsAuth')} + + )} +
+ {profile.email && ( + {profile.email} + )} + + )} +
+
+ {editingProfileId !== profile.id && ( +
+ {!profile.isAuthenticated ? ( + + ) : ( + + + + + {tCommon('accessibility.reAuthenticateProfileAriaLabel')} + + )} + {(profile.id !== activeClaudeProfileId || activeApiProfileId) && ( + + )} + + + + + + {expandedTokenProfileId === profile.id + ? tCommon('accessibility.hideTokenEntryAriaLabel') + : tCommon('accessibility.enterTokenManuallyAriaLabel')} + + + + + + + {tCommon('accessibility.renameProfileAriaLabel')} + + {!profile.isDefault && ( + + + + + {tCommon('accessibility.deleteProfileAriaLabel')} + + )} +
+ )} +
+ + {/* Expanded token entry section */} + {expandedTokenProfileId === profile.id && ( +
+
+
+ + + {t('accounts.claudeCode.runSetupToken')} + +
+ +
+
+ setManualToken(e.target.value)} + className="pr-10 font-mono text-xs h-8" + /> + +
+ + setManualTokenEmail(e.target.value)} + className="text-xs h-8" + /> +
+ +
+ + +
+
+
+ )} +
+ ))} +
+ )} + + {/* Embedded Auth Terminal */} + {authTerminal && ( +
+
+ +
+
+ )} + + {/* Add new account */} +
+ setNewProfileName(e.target.value)} + className="flex-1 h-8 text-sm" + disabled={!!authTerminal} + onKeyDown={(e) => { + if (e.key === 'Enter' && newProfileName.trim()) { + handleAddClaudeProfile(); + } + }} + /> + +
+
+
+ + {/* Custom Endpoints Tab Content */} + +
+ {/* Header with Add button */} +
+

+ {t('accounts.customEndpoints.description')} +

+ +
+ + {/* Empty state */} + {apiProfiles.length === 0 && ( +
+ +

{t('accounts.customEndpoints.empty.title')}

+

+ {t('accounts.customEndpoints.empty.description')} +

+ +
+ )} + + {/* Profile list */} + {apiProfiles.length > 0 && ( +
+ {activeApiProfileId && ( +
+ +
+ )} + {apiProfiles.map((profile) => { + const isActive = activeApiProfileId === profile.id; + return ( +
+
+
+

{profile.name}

+ {isActive && ( + + + {t('accounts.customEndpoints.activeBadge')} + + )} +
+
+ + +
+ + + {getHostFromUrl(profile.baseUrl)} + +
+
+ +

{profile.baseUrl}

+
+
+
+ {maskApiKey(profile.apiKey)} +
+
+ {profile.models && Object.keys(profile.models).length > 0 && ( +
+ {t('accounts.customEndpoints.customModels', { + models: Object.keys(profile.models).join(', ') + })} +
+ )} +
+ +
+ {!isActive && ( + + )} + + + + + {t('accounts.customEndpoints.tooltips.edit')} + + + + + + + {isActive + ? t('accounts.customEndpoints.tooltips.deleteActive') + : t('accounts.customEndpoints.tooltips.deleteInactive')} + + +
+
+ ); + })} +
+ )} + + {/* Add/Edit Dialog */} + { + if (!open) { + setIsAddDialogOpen(false); + setEditApiProfile(null); + } + }} + onSaved={() => { + setIsAddDialogOpen(false); + setEditApiProfile(null); + }} + profile={editApiProfile ?? undefined} + /> + + {/* Delete Confirmation Dialog */} + setDeleteConfirmProfile(null)} + > + + + {t('accounts.customEndpoints.dialog.deleteTitle')} + + {t('accounts.customEndpoints.dialog.deleteDescription', { + name: deleteConfirmProfile?.name ?? '' + })} + + + + + {t('accounts.customEndpoints.dialog.cancel')} + + + {isDeletingApiProfile + ? t('accounts.customEndpoints.dialog.deleting') + : t('accounts.customEndpoints.dialog.delete')} + + + + +
+
+
+ + {/* Auto-Switch Settings Section - Persistent below tabs */} + {totalAccounts > 1 && ( +
+
+ +

{t('accounts.autoSwitching.title')}

+
+ +
+

+ {t('accounts.autoSwitching.description')} +

+ + {/* Master toggle */} +
+
+ +

+ {t('accounts.autoSwitching.masterSwitch')} +

+
+ handleUpdateAutoSwitch({ enabled })} + disabled={isLoadingAutoSwitch} + /> +
+ + {autoSwitchSettings?.enabled && ( + <> + {/* Proactive Monitoring Section */} +
+
+
+ +

+ {t('accounts.autoSwitching.proactiveDescription')} +

+
+ handleUpdateAutoSwitch({ proactiveSwapEnabled: value })} + disabled={isLoadingAutoSwitch} + /> +
+ + {autoSwitchSettings?.proactiveSwapEnabled && ( + <> + {/* Session threshold */} +
+
+ + {autoSwitchSettings?.sessionThreshold ?? 95}% +
+ handleUpdateAutoSwitch({ sessionThreshold: parseInt(e.target.value) })} + disabled={isLoadingAutoSwitch} + className="w-full" + aria-describedby="session-threshold-description" + /> +

+ {t('accounts.autoSwitching.sessionThresholdDescription')} +

+
+ + {/* Weekly threshold */} +
+
+ + {autoSwitchSettings?.weeklyThreshold ?? 99}% +
+ handleUpdateAutoSwitch({ weeklyThreshold: parseInt(e.target.value) })} + disabled={isLoadingAutoSwitch} + className="w-full" + aria-describedby="weekly-threshold-description" + /> +

+ {t('accounts.autoSwitching.weeklyThresholdDescription')} +

+
+ + )} +
+ + {/* Reactive Recovery Section */} +
+
+
+ +

+ {t('accounts.autoSwitching.reactiveDescription')} +

+
+ handleUpdateAutoSwitch({ autoSwitchOnRateLimit: value })} + disabled={isLoadingAutoSwitch} + /> +
+
+ + {/* Account Priority Order */} +
+ +
+ + )} +
+
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/renderer/components/settings/AppSettings.tsx b/apps/frontend/src/renderer/components/settings/AppSettings.tsx index a68f33eb..56278323 100644 --- a/apps/frontend/src/renderer/components/settings/AppSettings.tsx +++ b/apps/frontend/src/renderer/components/settings/AppSettings.tsx @@ -7,7 +7,6 @@ import { Palette, Bot, FolderOpen, - Key, Package, Bell, Settings2, @@ -19,7 +18,7 @@ import { Globe, Code, Bug, - Server + Users } from 'lucide-react'; // GitLab icon component (lucide-react doesn't have one) @@ -48,11 +47,10 @@ import { ThemeSettings } from './ThemeSettings'; import { DisplaySettings } from './DisplaySettings'; import { LanguageSettings } from './LanguageSettings'; import { GeneralSettings } from './GeneralSettings'; -import { IntegrationSettings } from './IntegrationSettings'; import { AdvancedSettings } from './AdvancedSettings'; import { DevToolsSettings } from './DevToolsSettings'; import { DebugSettings } from './DebugSettings'; -import { ProfileList } from './ProfileList'; +import { AccountSettings } from './AccountSettings'; import { ProjectSelector } from './ProjectSelector'; import { ProjectSettingsContent, ProjectSettingsSection } from './ProjectSettingsContent'; import { useProjectStore } from '../../stores/project-store'; @@ -67,7 +65,7 @@ interface AppSettingsDialogProps { } // App-level settings sections -export type AppSection = 'appearance' | 'display' | 'language' | 'devtools' | 'agent' | 'paths' | 'integrations' | 'api-profiles' | 'updates' | 'notifications' | 'debug'; +export type AppSection = 'appearance' | 'display' | 'language' | 'devtools' | 'agent' | 'paths' | 'accounts' | 'updates' | 'notifications' | 'debug'; interface NavItemConfig { id: T; @@ -81,8 +79,7 @@ const appNavItemsConfig: NavItemConfig[] = [ { id: 'devtools', icon: Code }, { id: 'agent', icon: Bot }, { id: 'paths', icon: FolderOpen }, - { id: 'integrations', icon: Key }, - { id: 'api-profiles', icon: Server }, + { id: 'accounts', icon: Users }, { id: 'updates', icon: Package }, { id: 'notifications', icon: Bell }, { id: 'debug', icon: Bug } @@ -192,10 +189,8 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP return ; case 'paths': return ; - case 'integrations': - return ; - case 'api-profiles': - return ; + case 'accounts': + return ; case 'updates': return ; case 'notifications': diff --git a/apps/frontend/src/renderer/components/settings/IntegrationSettings.tsx b/apps/frontend/src/renderer/components/settings/IntegrationSettings.tsx deleted file mode 100644 index 3498eaf5..00000000 --- a/apps/frontend/src/renderer/components/settings/IntegrationSettings.tsx +++ /dev/null @@ -1,936 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - Key, - Eye, - EyeOff, - Info, - Users, - Plus, - Trash2, - Star, - Check, - Pencil, - X, - Loader2, - LogIn, - ChevronDown, - ChevronRight, - RefreshCw, - Activity, - AlertCircle -} from 'lucide-react'; -import { Button } from '../ui/button'; -import { Input } from '../ui/input'; -import { Label } from '../ui/label'; -import { Switch } from '../ui/switch'; -import { cn } from '../../lib/utils'; -import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; -import { SettingsSection } from './SettingsSection'; -import { AuthTerminal } from './AuthTerminal'; -import { loadClaudeProfiles as loadGlobalClaudeProfiles } from '../../stores/claude-profile-store'; -import { useToast } from '../../hooks/use-toast'; -import type { AppSettings, ClaudeProfile, ClaudeAutoSwitchSettings } from '../../../shared/types'; - -interface IntegrationSettingsProps { - settings: AppSettings; - onSettingsChange: (settings: AppSettings) => void; - isOpen: boolean; -} - -/** - * Integration settings for Claude accounts and API keys - */ -export function IntegrationSettings({ settings, onSettingsChange, isOpen }: IntegrationSettingsProps) { - const { t } = useTranslation('settings'); - const { t: tCommon } = useTranslation('common'); - const { toast } = useToast(); - // Password visibility toggle for global API keys - const [showGlobalOpenAIKey, setShowGlobalOpenAIKey] = useState(false); - - // Claude Accounts state - const [claudeProfiles, setClaudeProfiles] = useState([]); - const [activeProfileId, setActiveProfileId] = useState(null); - const [isLoadingProfiles, setIsLoadingProfiles] = useState(false); - const [newProfileName, setNewProfileName] = useState(''); - const [isAddingProfile, setIsAddingProfile] = useState(false); - const [deletingProfileId, setDeletingProfileId] = useState(null); - const [editingProfileId, setEditingProfileId] = useState(null); - const [editingProfileName, setEditingProfileName] = useState(''); - const [authenticatingProfileId, setAuthenticatingProfileId] = useState(null); - const [expandedTokenProfileId, setExpandedTokenProfileId] = useState(null); - const [manualToken, setManualToken] = useState(''); - const [manualTokenEmail, setManualTokenEmail] = useState(''); - const [showManualToken, setShowManualToken] = useState(false); - const [savingTokenProfileId, setSavingTokenProfileId] = useState(null); - - // Auto-swap settings state - const [autoSwitchSettings, setAutoSwitchSettings] = useState(null); - const [isLoadingAutoSwitch, setIsLoadingAutoSwitch] = useState(false); - - // Auth terminal state - for embedded authentication - const [authTerminal, setAuthTerminal] = useState<{ - terminalId: string; - configDir: string; - profileId: string; - profileName: string; - } | null>(null); - - // Load Claude profiles and auto-swap settings when section is shown - useEffect(() => { - if (isOpen) { - loadClaudeProfiles(); - loadAutoSwitchSettings(); - } - }, [isOpen]); - - const loadClaudeProfiles = async () => { - setIsLoadingProfiles(true); - try { - const result = await window.electronAPI.getClaudeProfiles(); - if (result.success && result.data) { - setClaudeProfiles(result.data.profiles); - setActiveProfileId(result.data.activeProfileId); - // Also update the global store - await loadGlobalClaudeProfiles(); - } else if (!result.success) { - toast({ - variant: 'destructive', - title: t('integrations.toast.loadProfilesFailed'), - description: result.error || t('integrations.toast.tryAgain'), - }); - } - } catch (err) { - console.warn('[IntegrationSettings] Failed to load Claude profiles:', err); - toast({ - variant: 'destructive', - title: t('integrations.toast.loadProfilesFailed'), - description: t('integrations.toast.tryAgain'), - }); - } finally { - setIsLoadingProfiles(false); - } - }; - - const handleAddProfile = async () => { - if (!newProfileName.trim()) { - return; - } - - setIsAddingProfile(true); - try { - const profileName = newProfileName.trim(); - const profileSlug = profileName.toLowerCase().replace(/\s+/g, '-'); - - // Create the profile first - const result = await window.electronAPI.saveClaudeProfile({ - id: `profile-${Date.now()}`, - name: profileName, - configDir: `~/.claude-profiles/${profileSlug}`, - isDefault: false, - createdAt: new Date() - }); - - if (result.success && result.data) { - await loadClaudeProfiles(); - setNewProfileName(''); - - // Get terminal config for authentication - const authResult = await window.electronAPI.authenticateClaudeProfile(result.data.id); - - if (authResult.success && authResult.data) { - setAuthenticatingProfileId(result.data.id); - - // Set up embedded auth terminal - setAuthTerminal({ - terminalId: authResult.data.terminalId, - configDir: authResult.data.configDir, - profileId: result.data.id, - profileName, - }); - - console.warn('[IntegrationSettings] New profile auth terminal ready:', authResult.data); - } else { - alert(t('integrations.alerts.profileCreatedAuthFailed', { error: authResult.error || t('integrations.toast.tryAgain') })); - } - } - } catch (err) { - toast({ - variant: 'destructive', - title: t('integrations.toast.addProfileFailed'), - description: t('integrations.toast.tryAgain'), - }); - } finally { - setIsAddingProfile(false); - } - }; - - const handleDeleteProfile = async (profileId: string) => { - setDeletingProfileId(profileId); - try { - const result = await window.electronAPI.deleteClaudeProfile(profileId); - if (result.success) { - await loadClaudeProfiles(); - } else { - toast({ - variant: 'destructive', - title: t('integrations.toast.deleteProfileFailed'), - description: result.error || t('integrations.toast.tryAgain'), - }); - } - } catch (err) { - console.warn('[IntegrationSettings] Failed to delete profile:', err); - toast({ - variant: 'destructive', - title: t('integrations.toast.deleteProfileFailed'), - description: t('integrations.toast.tryAgain'), - }); - } finally { - setDeletingProfileId(null); - } - }; - - const startEditingProfile = (profile: ClaudeProfile) => { - setEditingProfileId(profile.id); - setEditingProfileName(profile.name); - }; - - const cancelEditingProfile = () => { - setEditingProfileId(null); - setEditingProfileName(''); - }; - - const handleRenameProfile = async () => { - if (!editingProfileId || !editingProfileName.trim()) return; - - try { - const result = await window.electronAPI.renameClaudeProfile(editingProfileId, editingProfileName.trim()); - if (result.success) { - await loadClaudeProfiles(); - } else { - toast({ - variant: 'destructive', - title: t('integrations.toast.renameProfileFailed'), - description: result.error || t('integrations.toast.tryAgain'), - }); - } - } catch (err) { - console.warn('[IntegrationSettings] Failed to rename profile:', err); - toast({ - variant: 'destructive', - title: t('integrations.toast.renameProfileFailed'), - description: t('integrations.toast.tryAgain'), - }); - } finally { - setEditingProfileId(null); - setEditingProfileName(''); - } - }; - - const handleSetActiveProfile = async (profileId: string) => { - try { - const result = await window.electronAPI.setActiveClaudeProfile(profileId); - if (result.success) { - setActiveProfileId(profileId); - await loadGlobalClaudeProfiles(); - } else { - toast({ - variant: 'destructive', - title: t('integrations.toast.setActiveProfileFailed'), - description: result.error || t('integrations.toast.tryAgain'), - }); - } - } catch (err) { - console.warn('[IntegrationSettings] Failed to set active profile:', err); - toast({ - variant: 'destructive', - title: t('integrations.toast.setActiveProfileFailed'), - description: t('integrations.toast.tryAgain'), - }); - } - }; - - const handleAuthenticateProfile = async (profileId: string) => { - // Find the profile name for display - const profile = claudeProfiles.find(p => p.id === profileId); - const profileName = profile?.name || 'Profile'; - - setAuthenticatingProfileId(profileId); - try { - // Get terminal config from backend (terminalId and configDir) - const result = await window.electronAPI.authenticateClaudeProfile(profileId); - - if (!result.success || !result.data) { - alert(t('integrations.alerts.authPrepareFailed', { error: result.error || t('integrations.toast.tryAgain') })); - setAuthenticatingProfileId(null); - return; - } - - // Set up embedded auth terminal - setAuthTerminal({ - terminalId: result.data.terminalId, - configDir: result.data.configDir, - profileId, - profileName, - }); - - console.warn('[IntegrationSettings] Auth terminal ready:', result.data); - } catch (err) { - console.error('Failed to authenticate profile:', err); - alert(t('integrations.alerts.authStartFailedMessage')); - setAuthenticatingProfileId(null); - } - }; - - // Handle auth terminal close - const handleAuthTerminalClose = useCallback(() => { - setAuthTerminal(null); - setAuthenticatingProfileId(null); - }, []); - - // Handle auth terminal success - const handleAuthTerminalSuccess = useCallback(async (email?: string) => { - console.warn('[IntegrationSettings] Auth success:', email); - - // Close terminal immediately - setAuthTerminal(null); - setAuthenticatingProfileId(null); - - // Reload profiles to get updated auth state - await loadClaudeProfiles(); - }, []); - - // Handle auth terminal error - const handleAuthTerminalError = useCallback((error: string) => { - console.error('[IntegrationSettings] Auth error:', error); - // Don't auto-close on error - let user see the error and close manually - }, []); - - const toggleTokenEntry = (profileId: string) => { - if (expandedTokenProfileId === profileId) { - setExpandedTokenProfileId(null); - setManualToken(''); - setManualTokenEmail(''); - setShowManualToken(false); - } else { - setExpandedTokenProfileId(profileId); - setManualToken(''); - setManualTokenEmail(''); - setShowManualToken(false); - } - }; - - const handleSaveManualToken = async (profileId: string) => { - if (!manualToken.trim()) return; - - setSavingTokenProfileId(profileId); - try { - const result = await window.electronAPI.setClaudeProfileToken( - profileId, - manualToken.trim(), - manualTokenEmail.trim() || undefined - ); - if (result.success) { - await loadClaudeProfiles(); - setExpandedTokenProfileId(null); - setManualToken(''); - setManualTokenEmail(''); - setShowManualToken(false); - toast({ - title: t('integrations.toast.tokenSaved'), - description: t('integrations.toast.tokenSavedDescription'), - }); - } else { - toast({ - variant: 'destructive', - title: t('integrations.toast.tokenSaveFailed'), - description: result.error || t('integrations.toast.tryAgain'), - }); - } - } catch (err) { - toast({ - variant: 'destructive', - title: t('integrations.toast.tokenSaveFailed'), - description: t('integrations.toast.tryAgain'), - }); - } finally { - setSavingTokenProfileId(null); - } - }; - - // Load auto-swap settings - const loadAutoSwitchSettings = async () => { - setIsLoadingAutoSwitch(true); - try { - const result = await window.electronAPI.getAutoSwitchSettings(); - if (result.success && result.data) { - setAutoSwitchSettings(result.data); - } - } catch (err) { - // Silently handle errors - } finally { - setIsLoadingAutoSwitch(false); - } - }; - - // Update auto-swap settings - const handleUpdateAutoSwitch = async (updates: Partial) => { - setIsLoadingAutoSwitch(true); - try { - const result = await window.electronAPI.updateAutoSwitchSettings(updates); - if (result.success) { - await loadAutoSwitchSettings(); - } else { - toast({ - variant: 'destructive', - title: t('integrations.toast.settingsUpdateFailed'), - description: result.error || t('integrations.toast.tryAgain'), - }); - } - } catch (err) { - toast({ - variant: 'destructive', - title: t('integrations.toast.settingsUpdateFailed'), - description: t('integrations.toast.tryAgain'), - }); - } finally { - setIsLoadingAutoSwitch(false); - } - }; - - return ( - -
- {/* Claude Accounts Section */} -
-
- -

{t('integrations.claudeAccounts')}

-
- -
-

- {t('integrations.claudeAccountsDescription')} -

- - {/* Accounts list */} - {isLoadingProfiles ? ( -
- -
- ) : claudeProfiles.length === 0 ? ( -
-

{t('integrations.noAccountsYet')}

-
- ) : ( -
- {claudeProfiles.map((profile) => ( -
-
-
-
- {(editingProfileId === profile.id ? editingProfileName : profile.name).charAt(0).toUpperCase()} -
-
- {editingProfileId === profile.id ? ( -
- setEditingProfileName(e.target.value)} - className="h-7 text-sm w-40" - autoFocus - onKeyDown={(e) => { - if (e.key === 'Enter') handleRenameProfile(); - if (e.key === 'Escape') cancelEditingProfile(); - }} - /> - - -
- ) : ( - <> -
- {profile.name} - {profile.isDefault && ( - {t('integrations.default')} - )} - {profile.id === activeProfileId && ( - - - {t('integrations.active')} - - )} - {profile.isAuthenticated ? ( - - - {t('integrations.authenticated')} - - ) : ( - - {t('integrations.needsAuth')} - - )} -
- {profile.email && ( - {profile.email} - )} - - )} -
-
- {editingProfileId !== profile.id && ( -
- {/* Authenticate button - show only if NOT authenticated */} - {!profile.isAuthenticated ? ( - - ) : ( - /* Re-authenticate button for already authenticated profiles */ - - - - - {t('common:accessibility.reAuthenticateProfileAriaLabel')} - - )} - {profile.id !== activeProfileId && ( - - )} - {/* Toggle token entry button */} - - - - - - {expandedTokenProfileId === profile.id ? t('common:accessibility.hideTokenEntryAriaLabel') : t('common:accessibility.enterTokenManuallyAriaLabel')} - - - - - - - {t('common:accessibility.renameProfileAriaLabel')} - - {!profile.isDefault && ( - - - - - {t('common:accessibility.deleteProfileAriaLabel')} - - )} -
- )} -
- - {/* Expanded token entry section */} - {expandedTokenProfileId === profile.id && ( -
-
-
- - - {t('integrations.runSetupToken')} - -
- -
-
- setManualToken(e.target.value)} - className="pr-10 font-mono text-xs h-8" - /> - -
- - setManualTokenEmail(e.target.value)} - className="text-xs h-8" - /> -
- -
- - -
-
-
- )} -
- ))} -
- )} - - {/* Embedded Auth Terminal */} - {authTerminal && ( -
-
- -
-
- )} - - {/* Add new account */} -
- setNewProfileName(e.target.value)} - className="flex-1 h-8 text-sm" - disabled={!!authTerminal} - onKeyDown={(e) => { - if (e.key === 'Enter' && newProfileName.trim()) { - handleAddProfile(); - } - }} - /> - -
-
-
- - {/* Auto-Switch Settings Section */} - {claudeProfiles.length > 1 && ( -
-
- -

{t('integrations.autoSwitching')}

-
- -
-

- {t('integrations.autoSwitchingDescription')} -

- - {/* Master toggle */} -
-
- -

- {t('integrations.masterSwitch')} -

-
- handleUpdateAutoSwitch({ enabled })} - disabled={isLoadingAutoSwitch} - /> -
- - {autoSwitchSettings?.enabled && ( - <> - {/* Proactive Monitoring Section */} -
-
-
- -

- {t('integrations.proactiveDescription')} -

-
- handleUpdateAutoSwitch({ proactiveSwapEnabled: value })} - disabled={isLoadingAutoSwitch} - /> -
- - {autoSwitchSettings?.proactiveSwapEnabled && ( - <> - {/* Check interval */} -
- - -
- - {/* Session threshold */} -
-
- - {autoSwitchSettings?.sessionThreshold ?? 95}% -
- handleUpdateAutoSwitch({ sessionThreshold: parseInt(e.target.value) })} - disabled={isLoadingAutoSwitch} - className="w-full" - /> -

- {t('integrations.sessionThresholdDescription')} -

-
- - {/* Weekly threshold */} -
-
- - {autoSwitchSettings?.weeklyThreshold ?? 99}% -
- handleUpdateAutoSwitch({ weeklyThreshold: parseInt(e.target.value) })} - disabled={isLoadingAutoSwitch} - className="w-full" - /> -

- {t('integrations.weeklyThresholdDescription')} -

-
- - )} -
- - {/* Reactive Recovery Section */} -
-
-
- -

- {t('integrations.reactiveDescription')} -

-
- handleUpdateAutoSwitch({ autoSwitchOnRateLimit: value })} - disabled={isLoadingAutoSwitch} - /> -
-
- - )} -
-
- )} - - {/* API Keys Section */} -
-
- -

{t('integrations.apiKeys')}

-
- -
-
- -

- {t('integrations.apiKeysInfo')} -

-
-
- -
-
- -

- {t('integrations.openaiKeyDescription')} -

-
- - onSettingsChange({ ...settings, globalOpenAIApiKey: e.target.value || undefined }) - } - className="pr-10 font-mono text-sm" - /> - -
-
-
-
-
-
- ); -} diff --git a/apps/frontend/src/renderer/components/settings/__tests__/IntegrationSettings.test.tsx b/apps/frontend/src/renderer/components/settings/__tests__/IntegrationSettings.test.tsx deleted file mode 100644 index 99795da3..00000000 --- a/apps/frontend/src/renderer/components/settings/__tests__/IntegrationSettings.test.tsx +++ /dev/null @@ -1,283 +0,0 @@ -/** - * IntegrationSettings handleAddProfile function tests - * - * Tests for the handleAddProfile function logic in IntegrationSettings component. - * Verifies that IPC calls (saveClaudeProfile and initializeClaudeProfile) are made correctly. - * - * @vitest-environment jsdom - */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -// Import browser mock to get full ElectronAPI structure -import '../../../lib/browser-mock'; - -// Mock functions for IPC calls -const mockSaveClaudeProfile = vi.fn(); -const mockInitializeClaudeProfile = vi.fn(); -const mockGetClaudeProfiles = vi.fn(); - -describe('IntegrationSettings - handleAddProfile IPC Logic', () => { - beforeEach(() => { - // Reset all mocks - vi.clearAllMocks(); - - // Setup window.electronAPI mocks - if (window.electronAPI) { - window.electronAPI.saveClaudeProfile = mockSaveClaudeProfile; - window.electronAPI.initializeClaudeProfile = mockInitializeClaudeProfile; - window.electronAPI.getClaudeProfiles = mockGetClaudeProfiles; - } - - // Default mock implementations - mockSaveClaudeProfile.mockResolvedValue({ - success: true, - data: { - id: 'profile-123', - name: 'Test Profile', - configDir: '~/.claude-profiles/test-profile', - isDefault: false, - createdAt: new Date() - } - }); - - mockInitializeClaudeProfile.mockResolvedValue({ - success: true - }); - - mockGetClaudeProfiles.mockResolvedValue({ - success: true, - data: { profiles: [], activeProfileId: null } - }); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe('Add Profile Flow', () => { - it('should call saveClaudeProfile with correct parameters when adding a profile', async () => { - const newProfile = { - id: 'profile-new', - name: 'Work Account', - configDir: '~/.claude-profiles/work-account', - isDefault: false, - createdAt: new Date() - }; - - mockSaveClaudeProfile.mockResolvedValue({ - success: true, - data: newProfile - }); - - const result = await window.electronAPI.saveClaudeProfile(newProfile); - - expect(mockSaveClaudeProfile).toHaveBeenCalledWith(newProfile); - expect(result.success).toBe(true); - expect(result.data?.name).toBe('Work Account'); - }); - - it('should call initializeClaudeProfile after saveClaudeProfile succeeds', async () => { - const newProfile = { - id: 'profile-456', - name: 'Personal Account', - configDir: '~/.claude-profiles/personal-account', - isDefault: false, - createdAt: new Date() - }; - - mockSaveClaudeProfile.mockResolvedValue({ - success: true, - data: newProfile - }); - - mockInitializeClaudeProfile.mockResolvedValue({ success: true }); - - // Simulate the handleAddProfile flow - const saveResult = await window.electronAPI.saveClaudeProfile(newProfile); - if (saveResult.success && saveResult.data) { - await window.electronAPI.initializeClaudeProfile(saveResult.data.id); - } - - expect(mockSaveClaudeProfile).toHaveBeenCalled(); - expect(mockInitializeClaudeProfile).toHaveBeenCalledWith('profile-456'); - }); - - it('should generate profile slug from name (lowercase with dashes)', () => { - const profileName = 'Work Account'; - const profileSlug = profileName.toLowerCase().replace(/\s+/g, '-'); - expect(profileSlug).toBe('work-account'); - }); - - it('should handle profile names with multiple spaces', () => { - const profileName = 'My Personal Account'; - const profileSlug = profileName.toLowerCase().replace(/\s+/g, '-'); - expect(profileSlug).toBe('my-personal-account'); - }); - - it('should handle saveClaudeProfile failure', async () => { - mockSaveClaudeProfile.mockResolvedValue({ - success: false, - error: 'Failed to save profile' - }); - - const result = await window.electronAPI.saveClaudeProfile({ - id: 'profile-fail', - name: 'Failing Profile', - isDefault: false, - createdAt: new Date() - }); - - expect(result.success).toBe(false); - expect(result.error).toBe('Failed to save profile'); - }); - - it('should not call initializeClaudeProfile if saveClaudeProfile fails', async () => { - mockSaveClaudeProfile.mockResolvedValue({ - success: false, - error: 'Failed to save profile' - }); - - // Simulate the handleAddProfile flow - const saveResult = await window.electronAPI.saveClaudeProfile({ - id: 'profile-fail', - name: 'Failing Profile', - isDefault: false, - createdAt: new Date() - }); - - if (saveResult.success && saveResult.data) { - await window.electronAPI.initializeClaudeProfile(saveResult.data.id); - } - - expect(mockSaveClaudeProfile).toHaveBeenCalled(); - expect(mockInitializeClaudeProfile).not.toHaveBeenCalled(); - }); - }); - - describe('Initialize Profile Flow', () => { - it('should call initializeClaudeProfile to trigger OAuth flow', async () => { - mockInitializeClaudeProfile.mockResolvedValue({ success: true }); - - const profileId = 'profile-1'; - const result = await window.electronAPI.initializeClaudeProfile(profileId); - - expect(mockInitializeClaudeProfile).toHaveBeenCalledWith(profileId); - expect(result.success).toBe(true); - }); - - it('should handle initializeClaudeProfile failure (terminal creation error)', async () => { - mockInitializeClaudeProfile.mockResolvedValue({ - success: false, - error: 'Terminal creation failed' - }); - - const result = await window.electronAPI.initializeClaudeProfile('profile-1'); - expect(result.success).toBe(false); - expect(result.error).toBe('Terminal creation failed'); - }); - - it('should handle initializeClaudeProfile failure (max terminals reached)', async () => { - mockInitializeClaudeProfile.mockResolvedValue({ - success: false, - error: 'Max terminals reached' - }); - - const result = await window.electronAPI.initializeClaudeProfile('profile-2'); - expect(result.success).toBe(false); - expect(result.error).toContain('Max terminals'); - }); - }); - - describe('Profile Name Validation', () => { - it('should require non-empty profile name', () => { - const newProfileName = ''; - const isValid = newProfileName.trim().length > 0; - expect(isValid).toBe(false); - }); - - it('should trim whitespace from profile name', () => { - const newProfileName = ' Work Account '; - const isValid = newProfileName.trim().length > 0; - expect(isValid).toBe(true); - expect(newProfileName.trim()).toBe('Work Account'); - }); - - it('should reject whitespace-only profile name', () => { - const newProfileName = ' '; - const isValid = newProfileName.trim().length > 0; - expect(isValid).toBe(false); - }); - - it('should accept single character profile name', () => { - const newProfileName = 'A'; - const isValid = newProfileName.trim().length > 0; - expect(isValid).toBe(true); - }); - - it('should handle profile names with special characters', () => { - const profileName = "John's Work Account!"; - const profileSlug = profileName.toLowerCase().replace(/\s+/g, '-'); - expect(profileSlug).toBe("john's-work-account!"); - // Note: The actual implementation may further sanitize special characters - }); - }); - - describe('Error Handling', () => { - it('should handle network errors during save', async () => { - mockSaveClaudeProfile.mockRejectedValue(new Error('Network error')); - - await expect( - window.electronAPI.saveClaudeProfile({ - id: 'profile-test', - name: 'Test', - isDefault: false, - createdAt: new Date() - }) - ).rejects.toThrow('Network error'); - }); - - it('should handle network errors during initialization', async () => { - mockInitializeClaudeProfile.mockRejectedValue(new Error('Network error')); - - await expect( - window.electronAPI.initializeClaudeProfile('profile-1') - ).rejects.toThrow('Network error'); - }); - }); - - describe('Profile Data Structure', () => { - it('should include all required profile fields when saving', async () => { - const newProfile = { - id: expect.stringContaining('profile-'), - name: 'Test Profile', - configDir: '~/.claude-profiles/test-profile', - isDefault: false, - createdAt: expect.any(Date) - }; - - mockSaveClaudeProfile.mockResolvedValue({ - success: true, - data: newProfile - }); - - await window.electronAPI.saveClaudeProfile(newProfile); - - expect(mockSaveClaudeProfile).toHaveBeenCalledWith( - expect.objectContaining({ - name: expect.any(String), - configDir: expect.any(String), - isDefault: expect.any(Boolean), - createdAt: expect.any(Date) - }) - ); - }); - - it('should generate unique profile IDs based on timestamp', () => { - const id1 = `profile-${Date.now()}`; - const id2 = `profile-${Date.now()}`; - // IDs should be similar (may be identical if generated in same millisecond) - expect(id1).toContain('profile-'); - expect(id2).toContain('profile-'); - }); - }); -}); diff --git a/apps/frontend/src/renderer/components/settings/index.ts b/apps/frontend/src/renderer/components/settings/index.ts index 072df94b..d721c705 100644 --- a/apps/frontend/src/renderer/components/settings/index.ts +++ b/apps/frontend/src/renderer/components/settings/index.ts @@ -7,7 +7,6 @@ export { AppSettingsDialog, type AppSection } from './AppSettings'; export { ThemeSettings } from './ThemeSettings'; export { ThemeSelector } from './ThemeSelector'; export { GeneralSettings } from './GeneralSettings'; -export { IntegrationSettings } from './IntegrationSettings'; export { AdvancedSettings } from './AdvancedSettings'; export { SettingsSection } from './SettingsSection'; export { useSettings } from './hooks/useSettings'; diff --git a/apps/frontend/src/renderer/lib/mocks/claude-profile-mock.ts b/apps/frontend/src/renderer/lib/mocks/claude-profile-mock.ts index c266395c..3ab73480 100644 --- a/apps/frontend/src/renderer/lib/mocks/claude-profile-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/claude-profile-mock.ts @@ -49,6 +49,13 @@ export const claudeProfileMock = { updateAutoSwitchSettings: async () => ({ success: true }), + getAccountPriorityOrder: async () => ({ + success: true, + data: [] as string[] + }), + + setAccountPriorityOrder: async () => ({ success: true }), + fetchClaudeUsage: async () => ({ success: true }), getBestAvailableProfile: async () => ({ @@ -68,8 +75,15 @@ export const claudeProfileMock = { data: null }), + requestAllProfilesUsage: async () => ({ + success: true, + data: null + }), + onUsageUpdated: () => () => {}, + onAllProfilesUsageUpdated: () => () => {}, + onProactiveSwapNotification: () => () => {}, // Returns terminal config for embedded authentication diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index 2e6a6b9c..b5956492 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -119,6 +119,10 @@ export const IPC_CHANNELS = { CLAUDE_PROFILE_FETCH_USAGE: 'claude:fetchUsage', CLAUDE_PROFILE_GET_BEST_PROFILE: 'claude:getBestProfile', + // Account priority order (unified OAuth + API profile ordering) + ACCOUNT_PRIORITY_GET: 'account:priorityGet', + ACCOUNT_PRIORITY_SET: 'account:prioritySet', + // SDK/CLI rate limit event (for non-terminal Claude invocations) CLAUDE_SDK_RATE_LIMIT: 'claude:sdkRateLimit', // Auth failure event (401 errors requiring re-authentication) @@ -129,6 +133,8 @@ export const IPC_CHANNELS = { // Usage monitoring (proactive account switching) USAGE_UPDATED: 'claude:usageUpdated', // Event: usage data updated (main -> renderer) USAGE_REQUEST: 'claude:usageRequest', // Request current usage snapshot + ALL_PROFILES_USAGE_REQUEST: 'claude:allProfilesUsageRequest', // Request all profiles usage immediately + ALL_PROFILES_USAGE_UPDATED: 'claude:allProfilesUsageUpdated', // Event: all profiles usage data (main -> renderer) PROACTIVE_SWAP_NOTIFICATION: 'claude:proactiveSwapNotification', // Event: proactive swap occurred // Settings diff --git a/apps/frontend/src/shared/i18n/locales/en/common.json b/apps/frontend/src/shared/i18n/locales/en/common.json index b6582b7c..5b59e249 100644 --- a/apps/frontend/src/shared/i18n/locales/en/common.json +++ b/apps/frontend/src/shared/i18n/locales/en/common.json @@ -91,7 +91,8 @@ "noData": "No data", "optional": "Optional", "required": "Required", - "dismiss": "Dismiss" + "dismiss": "Dismiss", + "important": "Important" }, "selection": { "select": "Select", @@ -441,7 +442,15 @@ "window5Hour": "5-hour window", "window7Day": "7-day window", "window5HoursQuota": "5 Hours Quota", - "windowMonthlyToolsQuota": "Monthly Tools Quota" + "windowMonthlyToolsQuota": "Monthly Tools Quota", + "otherAccounts": "Other Accounts", + "next": "Next", + "weeklyLimitReached": "Weekly limit reached", + "sessionLimitReached": "Session limit reached", + "notAuthenticated": "Not authenticated", + "sessionShort": "5-hour session usage", + "weeklyShort": "7-day weekly usage", + "swap": "Swap" }, "oauth": { "enterCode": "Manual Code Entry (Fallback)", diff --git a/apps/frontend/src/shared/i18n/locales/en/settings.json b/apps/frontend/src/shared/i18n/locales/en/settings.json index 6c1942ba..7cde3478 100644 --- a/apps/frontend/src/shared/i18n/locales/en/settings.json +++ b/apps/frontend/src/shared/i18n/locales/en/settings.json @@ -29,13 +29,9 @@ "title": "Paths", "description": "CLI tools and framework paths" }, - "integrations": { - "title": "Integrations", - "description": "API keys & Claude accounts" - }, - "api-profiles": { - "title": "API Profiles", - "description": "Custom API endpoint profiles" + "accounts": { + "title": "Accounts", + "description": "Claude accounts & API endpoints" }, "updates": { "title": "Updates", @@ -412,6 +408,7 @@ "description": "Manage Claude accounts and API keys", "claudeAccounts": "Claude Accounts", "claudeAccountsDescription": "Add multiple Claude subscriptions to automatically switch between them when you hit rate limits.", + "claudeAccountsWarning": "When authenticating, ensure you're logged into the correct Claude account in your browser. Each profile should use a different subscription.", "noAccountsYet": "No accounts configured yet", "default": "Default", "active": "Active", @@ -479,6 +476,123 @@ "authStartFailedMessage": "Failed to start authentication. Please try again." } }, + "accounts": { + "title": "Accounts", + "description": "Manage Claude accounts and API endpoints", + "tabs": { + "claudeCode": "Claude Code", + "customEndpoints": "Custom Endpoints" + }, + "claudeCode": { + "description": "Add multiple Claude subscriptions to automatically switch between them when you hit rate limits.", + "noAccountsYet": "No accounts configured yet", + "default": "Default", + "active": "Active", + "authenticated": "Authenticated", + "needsAuth": "Needs Auth", + "authenticate": "Authenticate", + "authenticating": "Authenticating...", + "setActive": "Set Active", + "manualTokenEntry": "Manual Token Entry", + "runSetupToken": "Run claude and type /login to authenticate", + "tokenPlaceholder": "sk-ant-oat01-...", + "emailPlaceholder": "Email (optional, for display)", + "saveToken": "Save Token", + "accountNamePlaceholder": "Account name (e.g., Work, Personal)" + }, + "customEndpoints": { + "description": "Configure custom Anthropic-compatible API endpoints", + "addButton": "Add Profile", + "activeBadge": "Active", + "customModels": "Custom models: {{models}}", + "setActive": { + "label": "Set Active", + "loading": "Setting..." + }, + "switchToOauth": { + "label": "Use Claude Code", + "loading": "Switching..." + }, + "tooltips": { + "edit": "Edit profile", + "deleteActive": "Cannot delete active profile", + "deleteInactive": "Delete profile" + }, + "empty": { + "title": "No Custom Endpoints", + "description": "Configure custom Anthropic-compatible API endpoints to use alternative providers.", + "action": "Add Profile" + }, + "dialog": { + "deleteTitle": "Delete Profile", + "deleteDescription": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", + "cancel": "Cancel", + "delete": "Delete", + "deleting": "Deleting..." + } + }, + "autoSwitching": { + "title": "Automatic Account Switching", + "description": "Automatically switch between accounts to avoid interruptions. Configure proactive monitoring to switch before hitting limits.", + "enableAutoSwitching": "Enable automatic switching", + "masterSwitch": "Master switch for all auto-swap features", + "proactiveMonitoring": "Proactive Monitoring", + "proactiveDescription": "Check usage regularly and swap before hitting limits", + "sessionThreshold": "Session usage threshold", + "sessionThresholdDescription": "Switch when session usage reaches this level (recommended: 95%)", + "weeklyThreshold": "Weekly usage threshold", + "weeklyThresholdDescription": "Switch when weekly usage reaches this level (recommended: 99%)", + "reactiveRecovery": "Reactive Recovery", + "reactiveDescription": "Auto-swap when unexpected rate limit is hit" + }, + "priority": { + "title": "Account Priority Order", + "description": "Drag to reorder. System will switch to the next available account in order.", + "noAccounts": "No accounts configured. Add accounts above to set priority.", + "noEmail": "No email", + "active": "Active", + "inUse": "In Use", + "next": "Next", + "unlimited": "Unlimited", + "unavailable": "Unavailable", + "typeOAuth": "OAuth", + "typeAPI": "API", + "payPerUse": "Pay-per-use", + "needsAuth": "Not authenticated", + "duplicateUsage": "Duplicate usage detected", + "duplicateUsageHint": "This profile has identical usage to another profile, suggesting they may be authenticated to the same Anthropic account. Re-authenticate with a different account to fix.", + "sessionUsage": "Session usage (5-hour window)", + "weeklyUsage": "Weekly usage (7-day window)", + "oauthSection": "Claude Accounts (cycle through first)", + "apiSection": "Fallback Endpoints (when all accounts exhausted)", + "tipTitle": "How priority works", + "tipDescription": "Claude accounts are included in your subscription and will be cycled through first. API endpoints charge per request and are used as fallbacks when all Claude accounts hit their limits.", + "status": { + "healthy": "Healthy", + "moderate": "Moderate", + "highUsage": "High usage", + "nearLimit": "Near limit", + "rateLimited": "Rate limited" + } + }, + "toast": { + "loadProfilesFailed": "Failed to Load Profiles", + "addProfileFailed": "Failed to Add Profile", + "deleteProfileFailed": "Failed to Delete Profile", + "renameProfileFailed": "Failed to Rename Profile", + "setActiveProfileFailed": "Failed to Set Active Profile", + "tokenSaved": "Token Saved", + "tokenSavedDescription": "Your token has been saved successfully.", + "tokenSaveFailed": "Failed to Save Token", + "settingsUpdateFailed": "Failed to Update Settings", + "tryAgain": "Please try again." + }, + "alerts": { + "profileCreatedAuthFailed": "Profile created but failed to prepare authentication: {{error}}", + "authPrepareFailed": "Failed to prepare authentication: {{error}}", + "authStartFailedMessage": "Failed to start authentication. Please try again." + } + }, "debug": { "title": "Debug & Logs", "description": "Access logs and debug information for troubleshooting", diff --git a/apps/frontend/src/shared/i18n/locales/fr/common.json b/apps/frontend/src/shared/i18n/locales/fr/common.json index be83e517..7be7c00a 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/common.json +++ b/apps/frontend/src/shared/i18n/locales/fr/common.json @@ -91,7 +91,8 @@ "noData": "Aucune donnée", "optional": "Optionnel", "required": "Requis", - "dismiss": "Ignorer" + "dismiss": "Ignorer", + "important": "Important" }, "selection": { "select": "Sélectionner", @@ -441,7 +442,15 @@ "window5Hour": "Fenêtre de 5 heures", "window7Day": "Fenêtre de 7 jours", "window5HoursQuota": "Quota de 5 heures", - "windowMonthlyToolsQuota": "Quota mensuel d'outils" + "windowMonthlyToolsQuota": "Quota mensuel d'outils", + "otherAccounts": "Autres comptes", + "next": "Suivant", + "weeklyLimitReached": "Limite hebdomadaire atteinte", + "sessionLimitReached": "Limite de session atteinte", + "notAuthenticated": "Non authentifié", + "sessionShort": "Utilisation session 5 heures", + "weeklyShort": "Utilisation hebdomadaire 7 jours", + "swap": "Changer" }, "oauth": { "enterCode": "Saisie manuelle du code (secours)", diff --git a/apps/frontend/src/shared/i18n/locales/fr/settings.json b/apps/frontend/src/shared/i18n/locales/fr/settings.json index 0fcee50e..c96a3aa9 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/settings.json +++ b/apps/frontend/src/shared/i18n/locales/fr/settings.json @@ -29,13 +29,9 @@ "title": "Chemins", "description": "Outils CLI et chemins framework" }, - "integrations": { - "title": "Intégrations", - "description": "Clés API & comptes Claude" - }, - "api-profiles": { - "title": "Profils API", - "description": "Profils d'endpoint API personnalisés" + "accounts": { + "title": "Comptes", + "description": "Comptes Claude & endpoints API" }, "updates": { "title": "Mises à jour", @@ -412,6 +408,7 @@ "description": "Gérer les comptes Claude et les clés API", "claudeAccounts": "Comptes Claude", "claudeAccountsDescription": "Ajoutez plusieurs abonnements Claude pour basculer automatiquement entre eux quand vous atteignez les limites.", + "claudeAccountsWarning": "Lors de l'authentification, assurez-vous d'être connecté au bon compte Claude dans votre navigateur. Chaque profil doit utiliser un abonnement différent.", "noAccountsYet": "Aucun compte configuré", "default": "Par défaut", "active": "Actif", @@ -479,6 +476,123 @@ "authStartFailedMessage": "Échec du démarrage de l'authentification. Veuillez réessayer." } }, + "accounts": { + "title": "Comptes", + "description": "Gérer les comptes Claude et les endpoints API", + "tabs": { + "claudeCode": "Claude Code", + "customEndpoints": "Endpoints personnalisés" + }, + "claudeCode": { + "description": "Ajoutez plusieurs abonnements Claude pour basculer automatiquement entre eux lorsque vous atteignez les limites de taux.", + "noAccountsYet": "Aucun compte configuré", + "default": "Par défaut", + "active": "Actif", + "authenticated": "Authentifié", + "needsAuth": "Auth requise", + "authenticate": "Authentifier", + "authenticating": "Authentification...", + "setActive": "Définir actif", + "manualTokenEntry": "Saisie manuelle du token", + "runSetupToken": "Exécutez claude et tapez /login pour vous authentifier", + "tokenPlaceholder": "sk-ant-oat01-...", + "emailPlaceholder": "Email (optionnel, pour l'affichage)", + "saveToken": "Enregistrer le token", + "accountNamePlaceholder": "Nom du compte (ex: Travail, Personnel)" + }, + "customEndpoints": { + "description": "Configurez des endpoints API compatibles Anthropic personnalisés", + "addButton": "Ajouter un profil", + "activeBadge": "Actif", + "customModels": "Modèles personnalisés: {{models}}", + "setActive": { + "label": "Définir actif", + "loading": "Configuration..." + }, + "switchToOauth": { + "label": "Utiliser Claude Code", + "loading": "Basculement..." + }, + "tooltips": { + "edit": "Modifier le profil", + "deleteActive": "Impossible de supprimer le profil actif", + "deleteInactive": "Supprimer le profil" + }, + "empty": { + "title": "Aucun endpoint personnalisé", + "description": "Configurez des endpoints API compatibles Anthropic personnalisés pour utiliser des fournisseurs alternatifs.", + "action": "Ajouter un profil" + }, + "dialog": { + "deleteTitle": "Supprimer le profil", + "deleteDescription": "Êtes-vous sûr de vouloir supprimer \"{{name}}\" ? Cette action est irréversible.", + "cancel": "Annuler", + "delete": "Supprimer", + "deleting": "Suppression..." + } + }, + "autoSwitching": { + "title": "Basculement automatique de compte", + "description": "Basculez automatiquement entre les comptes pour éviter les interruptions. Configurez la surveillance proactive pour basculer avant d'atteindre les limites.", + "enableAutoSwitching": "Activer le basculement automatique", + "masterSwitch": "Interrupteur principal pour toutes les fonctionnalités d'auto-basculement", + "proactiveMonitoring": "Surveillance proactive", + "proactiveDescription": "Vérifier l'utilisation régulièrement et basculer avant d'atteindre les limites", + "sessionThreshold": "Seuil d'utilisation de session", + "sessionThresholdDescription": "Basculer lorsque l'utilisation de session atteint ce niveau (recommandé: 95%)", + "weeklyThreshold": "Seuil d'utilisation hebdomadaire", + "weeklyThresholdDescription": "Basculer lorsque l'utilisation hebdomadaire atteint ce niveau (recommandé: 99%)", + "reactiveRecovery": "Récupération réactive", + "reactiveDescription": "Auto-basculement en cas de limite de taux inattendue" + }, + "priority": { + "title": "Ordre de priorité des comptes", + "description": "Glissez pour réorganiser. Le système basculera vers le prochain compte disponible dans l'ordre.", + "noAccounts": "Aucun compte configuré. Ajoutez des comptes ci-dessus pour définir la priorité.", + "noEmail": "Pas d'email", + "active": "Actif", + "inUse": "En cours", + "next": "Suivant", + "unlimited": "Illimité", + "unavailable": "Indisponible", + "typeOAuth": "OAuth", + "typeAPI": "API", + "payPerUse": "Paiement à l'usage", + "needsAuth": "Non authentifié", + "duplicateUsage": "Doublon détecté", + "duplicateUsageHint": "Ce profil a une utilisation identique à un autre profil, suggérant qu'ils sont peut-être authentifiés sur le même compte Anthropic. Réauthentifiez-vous avec un autre compte pour corriger.", + "sessionUsage": "Utilisation de session (fenêtre de 5 heures)", + "weeklyUsage": "Utilisation hebdomadaire (fenêtre de 7 jours)", + "oauthSection": "Comptes Claude (utilisés en premier)", + "apiSection": "Points de terminaison de secours (quand tous les comptes sont épuisés)", + "tipTitle": "Comment fonctionne la priorité", + "tipDescription": "Les comptes Claude sont inclus dans votre abonnement et seront utilisés en premier. Les endpoints API facturent par requête et sont utilisés comme solutions de secours lorsque tous les comptes Claude atteignent leurs limites.", + "status": { + "healthy": "Sain", + "moderate": "Modéré", + "highUsage": "Utilisation élevée", + "nearLimit": "Proche de la limite", + "rateLimited": "Limité" + } + }, + "toast": { + "loadProfilesFailed": "Échec du chargement des profils", + "addProfileFailed": "Échec de l'ajout du profil", + "deleteProfileFailed": "Échec de la suppression du profil", + "renameProfileFailed": "Échec du renommage du profil", + "setActiveProfileFailed": "Échec de la définition du profil actif", + "tokenSaved": "Token enregistré", + "tokenSavedDescription": "Votre token a été enregistré avec succès.", + "tokenSaveFailed": "Échec de l'enregistrement du token", + "settingsUpdateFailed": "Échec de la mise à jour des paramètres", + "tryAgain": "Veuillez réessayer." + }, + "alerts": { + "profileCreatedAuthFailed": "Profil créé mais échec de la préparation de l'authentification: {{error}}", + "authPrepareFailed": "Échec de la préparation de l'authentification: {{error}}", + "authStartFailedMessage": "Échec du démarrage de l'authentification. Veuillez réessayer." + } + }, "debug": { "title": "Debug & Logs", "description": "Accédez aux logs et informations de débogage pour le dépannage", diff --git a/apps/frontend/src/shared/types/agent.ts b/apps/frontend/src/shared/types/agent.ts index 35f60f28..5c743f87 100644 --- a/apps/frontend/src/shared/types/agent.ts +++ b/apps/frontend/src/shared/types/agent.ts @@ -56,6 +56,8 @@ export interface ClaudeUsageSnapshot { profileId: string; /** Profile name for display */ profileName: string; + /** Email address associated with the profile (from Keychain or profile data) */ + profileEmail?: string; /** When this snapshot was captured */ fetchedAt: Date; /** Which limit is closest to threshold ('session' or 'weekly') */ @@ -77,6 +79,54 @@ export interface ClaudeUsageSnapshot { weeklyUsageLimit?: number; } +/** + * Profile usage summary for multi-profile display + * Contains the essential data needed to rank and display profiles in the usage indicator + */ +export interface ProfileUsageSummary { + /** Profile ID */ + profileId: string; + /** Profile name for display */ + profileName: string; + /** Email address (from Keychain or profile) */ + profileEmail?: string; + /** Session usage percentage (0-100) */ + sessionPercent: number; + /** Weekly usage percentage (0-100) */ + weeklyPercent: number; + /** ISO timestamp of when the session limit resets */ + sessionResetTimestamp?: string; + /** ISO timestamp of when the weekly limit resets */ + weeklyResetTimestamp?: string; + /** Whether this profile is authenticated */ + isAuthenticated: boolean; + /** Whether this profile is currently rate limited */ + isRateLimited: boolean; + /** Type of rate limit if limited */ + rateLimitType?: 'session' | 'weekly'; + /** Availability score (higher = more available, used for sorting) */ + availabilityScore: number; + /** Whether this is the currently active profile */ + isActive: boolean; + /** When this data was last fetched (ISO timestamp) */ + lastFetchedAt?: string; + /** Error message if usage fetch failed */ + fetchError?: string; +} + +/** + * All profiles usage data for the usage indicator + * Emitted alongside the active profile's detailed snapshot + */ +export interface AllProfilesUsage { + /** Detailed snapshot for the active profile */ + activeProfile: ClaudeUsageSnapshot; + /** Summary usage data for all profiles (sorted by availability, best first) */ + allProfiles: ProfileUsageSummary[]; + /** When this data was collected */ + fetchedAt: Date; +} + /** * Rate limit event recorded for a profile */ diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index 7de16a76..f6c1111c 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -66,6 +66,7 @@ import type { ClaudeAutoSwitchSettings, ClaudeAuthResult, ClaudeUsageSnapshot, + AllProfilesUsage, TerminalProfileChangedEvent } from './agent'; import type { AppSettings, SourceEnvConfig, SourceEnvCheckResult } from './settings'; @@ -292,6 +293,10 @@ export interface ElectronAPI { getAutoSwitchSettings: () => Promise>; /** Update auto-switch settings */ updateAutoSwitchSettings: (settings: Partial) => Promise; + /** Get unified account priority order (both OAuth and API profiles) */ + getAccountPriorityOrder: () => Promise>; + /** Set unified account priority order */ + setAccountPriorityOrder: (order: string[]) => Promise; /** Request usage fetch from a terminal (sends /usage command) */ fetchClaudeUsage: (terminalId: string) => Promise; /** Get the best available profile (for manual switching) */ @@ -306,6 +311,8 @@ export interface ElectronAPI { // Usage Monitoring (Proactive Account Switching) /** Request current usage snapshot */ requestUsageUpdate: () => Promise>; + /** Request all profiles usage immediately (for startup/refresh) */ + requestAllProfilesUsage: () => Promise>; /** Listen for usage data updates */ onUsageUpdated: (callback: (usage: ClaudeUsageSnapshot) => void) => () => void; /** Listen for proactive swap notifications */ @@ -315,6 +322,8 @@ export interface ElectronAPI { reason: string; usageSnapshot: ClaudeUsageSnapshot; }) => void) => () => void; + /** Listen for all profiles usage updates (for multi-profile display) */ + onAllProfilesUsageUpdated?: (callback: (allProfilesUsage: AllProfilesUsage) => void) => () => void; // App settings getSettings: () => Promise>;