diff --git a/auto-claude-ui/src/renderer/__tests__/OAuthStep.test.tsx b/auto-claude-ui/src/renderer/__tests__/OAuthStep.test.tsx new file mode 100644 index 00000000..91244c80 --- /dev/null +++ b/auto-claude-ui/src/renderer/__tests__/OAuthStep.test.tsx @@ -0,0 +1,464 @@ +/** + * Unit tests for OAuthStep component + * Tests profile management, authentication state display, and user interactions + * + * @vitest-environment jsdom + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { ClaudeProfile, ClaudeProfileSettings, ElectronAPI } from '../../shared/types'; + +// Import browser mock to get full ElectronAPI structure +import '../lib/browser-mock'; + +// Helper to create test profiles +function createTestProfile(overrides: Partial = {}): ClaudeProfile { + return { + id: `profile-${Date.now()}-${Math.random().toString(36).substring(7)}`, + name: 'Test Profile', + isDefault: false, + createdAt: new Date(), + ...overrides + }; +} + +// Mock functions +const mockGetClaudeProfiles = vi.fn(); +const mockSaveClaudeProfile = vi.fn(); +const mockDeleteClaudeProfile = vi.fn(); +const mockRenameClaudeProfile = vi.fn(); +const mockSetActiveClaudeProfile = vi.fn(); +const mockInitializeClaudeProfile = vi.fn(); +const mockSetClaudeProfileToken = vi.fn(); +const mockOnTerminalOAuthToken = vi.fn(); + +describe('OAuthStep Profile Management Logic', () => { + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks(); + + // Setup window.electronAPI mocks + if (window.electronAPI) { + window.electronAPI.getClaudeProfiles = mockGetClaudeProfiles; + window.electronAPI.saveClaudeProfile = mockSaveClaudeProfile; + window.electronAPI.deleteClaudeProfile = mockDeleteClaudeProfile; + window.electronAPI.renameClaudeProfile = mockRenameClaudeProfile; + window.electronAPI.setActiveClaudeProfile = mockSetActiveClaudeProfile; + window.electronAPI.initializeClaudeProfile = mockInitializeClaudeProfile; + window.electronAPI.setClaudeProfileToken = mockSetClaudeProfileToken; + window.electronAPI.onTerminalOAuthToken = mockOnTerminalOAuthToken; + } + + // Default mock implementations + mockGetClaudeProfiles.mockResolvedValue({ + success: true, + data: { profiles: [], activeProfileId: 'default' } + }); + mockOnTerminalOAuthToken.mockReturnValue(() => {}); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Profile List Display', () => { + it('should handle empty profile list', async () => { + mockGetClaudeProfiles.mockResolvedValue({ + success: true, + data: { profiles: [], activeProfileId: null } + }); + + const result = await window.electronAPI.getClaudeProfiles(); + expect(result.success).toBe(true); + expect(result.data?.profiles).toHaveLength(0); + }); + + it('should handle profile list with multiple profiles', async () => { + const profiles = [ + createTestProfile({ id: 'profile-1', name: 'Work' }), + createTestProfile({ id: 'profile-2', name: 'Personal', oauthToken: 'sk-ant-oat01-test' }) + ]; + + mockGetClaudeProfiles.mockResolvedValue({ + success: true, + data: { profiles, activeProfileId: 'profile-1' } + }); + + const result = await window.electronAPI.getClaudeProfiles(); + expect(result.success).toBe(true); + expect(result.data?.profiles).toHaveLength(2); + expect(result.data?.activeProfileId).toBe('profile-1'); + }); + }); + + describe('Authentication State Display', () => { + it('should identify profile as authenticated when oauthToken is present', () => { + const profile = createTestProfile({ oauthToken: 'sk-ant-oat01-test-token' }); + const isAuthenticated = !!(profile.oauthToken || (profile.isDefault && profile.configDir)); + expect(isAuthenticated).toBe(true); + }); + + it('should identify profile as authenticated when it is default with configDir', () => { + const profile = createTestProfile({ isDefault: true, configDir: '~/.claude' }); + const isAuthenticated = !!(profile.oauthToken || (profile.isDefault && profile.configDir)); + expect(isAuthenticated).toBe(true); + }); + + it('should identify profile as needing auth when no token and not default', () => { + const profile = createTestProfile({ isDefault: false, oauthToken: undefined }); + const isAuthenticated = !!(profile.oauthToken || (profile.isDefault && profile.configDir)); + expect(isAuthenticated).toBe(false); + }); + + it('should identify profile as needing auth when default but no configDir', () => { + const profile = createTestProfile({ isDefault: true, configDir: undefined }); + const isAuthenticated = !!(profile.oauthToken || (profile.isDefault && profile.configDir)); + expect(isAuthenticated).toBe(false); + }); + }); + + describe('Add Profile Flow', () => { + it('should call saveClaudeProfile with correct parameters', async () => { + const newProfile = { + id: 'profile-new', + name: 'New Profile', + configDir: '~/.claude-profiles/new-profile', + 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); + }); + + it('should call initializeClaudeProfile after saving profile', async () => { + const newProfile = { + id: 'profile-new', + name: 'New Profile', + configDir: '~/.claude-profiles/new-profile', + isDefault: false, + createdAt: new Date() + }; + + mockSaveClaudeProfile.mockResolvedValue({ + success: true, + data: newProfile + }); + + mockInitializeClaudeProfile.mockResolvedValue({ success: true }); + + await window.electronAPI.saveClaudeProfile(newProfile); + await window.electronAPI.initializeClaudeProfile(newProfile.id); + + expect(mockSaveClaudeProfile).toHaveBeenCalled(); + expect(mockInitializeClaudeProfile).toHaveBeenCalledWith(newProfile.id); + }); + + it('should generate profile slug from name', () => { + const profileName = 'Work Account'; + const profileSlug = profileName.toLowerCase().replace(/\s+/g, '-'); + expect(profileSlug).toBe('work-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'); + }); + }); + + describe('OAuth Authentication 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', async () => { + mockInitializeClaudeProfile.mockResolvedValue({ + success: false, + error: 'Browser failed to open' + }); + + const result = await window.electronAPI.initializeClaudeProfile('profile-1'); + expect(result.success).toBe(false); + }); + + it('should register OAuth token callback', () => { + const callback = vi.fn(); + mockOnTerminalOAuthToken.mockReturnValue(() => {}); + + const unsubscribe = window.electronAPI.onTerminalOAuthToken(callback); + expect(mockOnTerminalOAuthToken).toHaveBeenCalledWith(callback); + expect(typeof unsubscribe).toBe('function'); + }); + }); + + describe('Set Active Profile', () => { + it('should call setActiveClaudeProfile with correct profileId', async () => { + mockSetActiveClaudeProfile.mockResolvedValue({ success: true }); + + const profileId = 'profile-2'; + const result = await window.electronAPI.setActiveClaudeProfile(profileId); + + expect(mockSetActiveClaudeProfile).toHaveBeenCalledWith(profileId); + expect(result.success).toBe(true); + }); + + it('should handle setActiveClaudeProfile failure', async () => { + mockSetActiveClaudeProfile.mockResolvedValue({ + success: false, + error: 'Profile not found' + }); + + const result = await window.electronAPI.setActiveClaudeProfile('invalid-id'); + expect(result.success).toBe(false); + }); + }); + + describe('Delete Profile', () => { + it('should call deleteClaudeProfile with correct profileId', async () => { + mockDeleteClaudeProfile.mockResolvedValue({ success: true }); + + const profileId = 'profile-to-delete'; + const result = await window.electronAPI.deleteClaudeProfile(profileId); + + expect(mockDeleteClaudeProfile).toHaveBeenCalledWith(profileId); + expect(result.success).toBe(true); + }); + }); + + describe('Rename Profile', () => { + it('should call renameClaudeProfile with correct parameters', async () => { + mockRenameClaudeProfile.mockResolvedValue({ success: true }); + + const profileId = 'profile-1'; + const newName = 'Updated Profile Name'; + const result = await window.electronAPI.renameClaudeProfile(profileId, newName); + + expect(mockRenameClaudeProfile).toHaveBeenCalledWith(profileId, newName); + expect(result.success).toBe(true); + }); + }); + + describe('Manual Token Entry', () => { + it('should call setClaudeProfileToken with token and email', async () => { + mockSetClaudeProfileToken.mockResolvedValue({ success: true }); + + const profileId = 'profile-1'; + const token = 'sk-ant-oat01-manual-token'; + const email = 'user@example.com'; + + const result = await window.electronAPI.setClaudeProfileToken(profileId, token, email); + + expect(mockSetClaudeProfileToken).toHaveBeenCalledWith(profileId, token, email); + expect(result.success).toBe(true); + }); + + it('should call setClaudeProfileToken with token only (no email)', async () => { + mockSetClaudeProfileToken.mockResolvedValue({ success: true }); + + const profileId = 'profile-1'; + const token = 'sk-ant-oat01-manual-token'; + + const result = await window.electronAPI.setClaudeProfileToken(profileId, token, undefined); + + expect(mockSetClaudeProfileToken).toHaveBeenCalledWith(profileId, token, undefined); + expect(result.success).toBe(true); + }); + + it('should handle setClaudeProfileToken failure', async () => { + mockSetClaudeProfileToken.mockResolvedValue({ + success: false, + error: 'Invalid token format' + }); + + const result = await window.electronAPI.setClaudeProfileToken( + 'profile-1', + 'invalid-token', + undefined + ); + + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid token format'); + }); + }); + + describe('Continue Button State', () => { + it('should enable Continue when at least one profile is authenticated', () => { + const profiles: ClaudeProfile[] = [ + createTestProfile({ id: 'p1', oauthToken: undefined }), + createTestProfile({ id: 'p2', oauthToken: 'sk-ant-oat01-token' }) + ]; + + const hasAuthenticatedProfile = profiles.some( + (profile) => profile.oauthToken || (profile.isDefault && profile.configDir) + ); + + expect(hasAuthenticatedProfile).toBe(true); + }); + + it('should disable Continue when no profiles are authenticated', () => { + const profiles: ClaudeProfile[] = [ + createTestProfile({ id: 'p1', oauthToken: undefined }), + createTestProfile({ id: 'p2', oauthToken: undefined }) + ]; + + const hasAuthenticatedProfile = profiles.some( + (profile) => profile.oauthToken || (profile.isDefault && profile.configDir) + ); + + expect(hasAuthenticatedProfile).toBe(false); + }); + + it('should disable Continue when no profiles exist', () => { + const profiles: ClaudeProfile[] = []; + + const hasAuthenticatedProfile = profiles.some( + (profile) => profile.oauthToken || (profile.isDefault && profile.configDir) + ); + + expect(hasAuthenticatedProfile).toBe(false); + }); + + it('should enable Continue with default profile with configDir', () => { + const profiles: ClaudeProfile[] = [ + createTestProfile({ id: 'default', isDefault: true, configDir: '~/.claude' }) + ]; + + const hasAuthenticatedProfile = profiles.some( + (profile) => profile.oauthToken || (profile.isDefault && profile.configDir) + ); + + expect(hasAuthenticatedProfile).toBe(true); + }); + }); + + 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 '; + const isValid = newProfileName.trim().length > 0; + expect(isValid).toBe(true); + expect(newProfileName.trim()).toBe('Work'); + }); + + it('should reject whitespace-only profile name', () => { + const newProfileName = ' '; + const isValid = newProfileName.trim().length > 0; + expect(isValid).toBe(false); + }); + }); + + describe('Error Handling', () => { + it('should handle getClaudeProfiles failure gracefully', async () => { + mockGetClaudeProfiles.mockRejectedValue(new Error('Network error')); + + await expect(window.electronAPI.getClaudeProfiles()).rejects.toThrow('Network error'); + }); + + it('should handle API returning unsuccessful response', async () => { + mockGetClaudeProfiles.mockResolvedValue({ + success: false, + error: 'Database connection failed' + }); + + const result = await window.electronAPI.getClaudeProfiles(); + expect(result.success).toBe(false); + expect(result.error).toBe('Database connection failed'); + }); + }); + + describe('Active Profile Highlighting', () => { + it('should identify active profile correctly', () => { + const profiles: ClaudeProfile[] = [ + createTestProfile({ id: 'p1', name: 'Work' }), + createTestProfile({ id: 'p2', name: 'Personal' }) + ]; + const activeProfileId = 'p2'; + + const activeProfile = profiles.find((p) => p.id === activeProfileId); + expect(activeProfile?.name).toBe('Personal'); + }); + + it('should handle when no profile is active', () => { + const profiles: ClaudeProfile[] = [ + createTestProfile({ id: 'p1', name: 'Work' }) + ]; + const activeProfileId: string | null = null; + + const activeProfile = activeProfileId + ? profiles.find((p) => p.id === activeProfileId) + : undefined; + expect(activeProfile).toBeUndefined(); + }); + }); + + describe('Profile Badge Display Logic', () => { + it('should show "Default" badge for default profile', () => { + const profile = createTestProfile({ isDefault: true }); + expect(profile.isDefault).toBe(true); + }); + + it('should show "Active" badge for active profile', () => { + const profiles: ClaudeProfile[] = [ + createTestProfile({ id: 'p1' }), + createTestProfile({ id: 'p2' }) + ]; + const activeProfileId = 'p1'; + + const isActive = (profileId: string) => profileId === activeProfileId; + expect(isActive('p1')).toBe(true); + expect(isActive('p2')).toBe(false); + }); + + it('should show "Authenticated" badge when profile has token', () => { + const profile = createTestProfile({ oauthToken: 'sk-ant-oat01-token' }); + const isAuthenticated = !!profile.oauthToken; + expect(isAuthenticated).toBe(true); + }); + + it('should show "Needs Auth" badge when profile needs authentication', () => { + const profile = createTestProfile({ oauthToken: undefined, isDefault: false }); + const needsAuth = !(profile.oauthToken || (profile.isDefault && profile.configDir)); + expect(needsAuth).toBe(true); + }); + }); + + describe('Profile Email Display', () => { + it('should display email when present on profile', () => { + const profile = createTestProfile({ email: 'user@example.com' }); + expect(profile.email).toBe('user@example.com'); + }); + + it('should handle profile without email', () => { + const profile = createTestProfile({ email: undefined }); + expect(profile.email).toBeUndefined(); + }); + }); +}); diff --git a/auto-claude-ui/src/renderer/components/onboarding/OAuthStep.tsx b/auto-claude-ui/src/renderer/components/onboarding/OAuthStep.tsx index 8114bc39..75035f66 100644 --- a/auto-claude-ui/src/renderer/components/onboarding/OAuthStep.tsx +++ b/auto-claude-ui/src/renderer/components/onboarding/OAuthStep.tsx @@ -7,18 +7,24 @@ import { Loader2, CheckCircle2, AlertCircle, - ExternalLink, - Copy + Plus, + Trash2, + Star, + Check, + Pencil, + X, + LogIn, + ChevronDown, + ChevronRight, + Users } from 'lucide-react'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; import { Card, CardContent } from '../ui/card'; -import { - Tooltip, - TooltipContent, - TooltipTrigger -} from '../ui/tooltip'; +import { cn } from '../../lib/utils'; +import { loadClaudeProfiles as loadGlobalClaudeProfiles } from '../../stores/claude-profile-store'; +import type { ClaudeProfile } from '../../../shared/types'; interface OAuthStepProps { onNext: () => void; @@ -28,93 +34,241 @@ interface OAuthStepProps { /** * OAuth step component for the onboarding wizard. - * Guides users through Claude OAuth token configuration, - * reusing patterns from EnvConfigModal. + * Guides users through Claude profile management and OAuth authentication, + * reusing patterns from IntegrationSettings.tsx. */ export function OAuthStep({ onNext, onBack, onSkip }: OAuthStepProps) { - const [token, setToken] = useState(''); - const [showToken, setShowToken] = useState(false); - const [isChecking, setIsChecking] = useState(true); - const [isSaving, setIsSaving] = useState(false); + // Claude Profiles state + const [claudeProfiles, setClaudeProfiles] = useState([]); + const [activeProfileId, setActiveProfileId] = useState(null); + const [isLoadingProfiles, setIsLoadingProfiles] = useState(true); + 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); + + // Manual token entry state + const [expandedTokenProfileId, setExpandedTokenProfileId] = useState(null); + const [manualToken, setManualToken] = useState(''); + const [manualTokenEmail, setManualTokenEmail] = useState(''); + const [showManualToken, setShowManualToken] = useState(false); + const [savingTokenProfileId, setSavingTokenProfileId] = useState(null); + + // Error state const [error, setError] = useState(null); - const [success, setSuccess] = useState(false); - const [sourcePath, setSourcePath] = useState(null); - const [hasExistingToken, setHasExistingToken] = useState(false); - // Check current token status on mount - useEffect(() => { - const checkToken = async () => { - setIsChecking(true); - setError(null); + // Derived state: check if at least one profile is authenticated + const hasAuthenticatedProfile = claudeProfiles.some( + (profile) => profile.oauthToken || (profile.isDefault && profile.configDir) + ); - try { - const result = await window.electronAPI.checkSourceToken(); - if (result.success && result.data) { - setSourcePath(result.data.sourcePath || null); - setHasExistingToken(result.data.hasToken); - - if (result.data.hasToken) { - // Token exists, show success state - setSuccess(true); - } - } else { - setError(result.error || 'Failed to check token status'); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setIsChecking(false); - } - }; - - checkToken(); - }, []); - - const handleSave = async () => { - if (!token.trim()) { - setError('Please enter a token'); - return; - } - - setIsSaving(true); + // Reusable function to load Claude profiles + const loadClaudeProfiles = async () => { + setIsLoadingProfiles(true); setError(null); - try { - const result = await window.electronAPI.updateSourceEnv({ - claudeOAuthToken: token.trim() - }); - - if (result.success) { - setSuccess(true); - setHasExistingToken(true); - setToken(''); // Clear the input - } else { - setError(result.error || 'Failed to save token'); + 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(); } } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); + setError(err instanceof Error ? err.message : 'Failed to load profiles'); } finally { - setIsSaving(false); + setIsLoadingProfiles(false); } }; - const handleCopyCommand = () => { - navigator.clipboard.writeText('claude setup-token'); + // Load Claude profiles on mount + useEffect(() => { + loadClaudeProfiles(); + }, []); + + // Listen for OAuth authentication completion + useEffect(() => { + const unsubscribe = window.electronAPI.onTerminalOAuthToken(async (info) => { + if (info.success && info.profileId) { + // Reload profiles to show updated state + await loadClaudeProfiles(); + // Show simple success notification + alert(`✅ Profile authenticated successfully!\n\n${info.email ? `Account: ${info.email}` : 'Authentication complete.'}\n\nYou can now use this profile.`); + } + }); + + return unsubscribe; + }, []); + + // Profile management handlers - following patterns from IntegrationSettings.tsx + const handleAddProfile = async () => { + if (!newProfileName.trim()) return; + + setIsAddingProfile(true); + setError(null); + 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) { + // Initialize the profile (starts OAuth flow) + const initResult = await window.electronAPI.initializeClaudeProfile(result.data.id); + + if (initResult.success) { + await loadClaudeProfiles(); + setNewProfileName(''); + + alert( + `Authenticating "${profileName}"...\n\n` + + `A browser window will open for you to log in with your Claude account.\n\n` + + `The authentication will be saved automatically once complete.` + ); + } else { + await loadClaudeProfiles(); + alert(`Failed to start authentication: ${initResult.error || 'Please try again.'}`); + } + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add profile'); + alert('Failed to add profile. Please try again.'); + } finally { + setIsAddingProfile(false); + } }; - const handleOpenDocs = () => { - window.open('https://docs.anthropic.com/en/docs/claude-code', '_blank'); + const handleDeleteProfile = async (profileId: string) => { + setDeletingProfileId(profileId); + setError(null); + try { + const result = await window.electronAPI.deleteClaudeProfile(profileId); + if (result.success) { + await loadClaudeProfiles(); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete profile'); + } 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; + + setError(null); + try { + const result = await window.electronAPI.renameClaudeProfile(editingProfileId, editingProfileName.trim()); + if (result.success) { + await loadClaudeProfiles(); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to rename profile'); + } finally { + setEditingProfileId(null); + setEditingProfileName(''); + } + }; + + const handleSetActiveProfile = async (profileId: string) => { + setError(null); + try { + const result = await window.electronAPI.setActiveClaudeProfile(profileId); + if (result.success) { + setActiveProfileId(profileId); + await loadGlobalClaudeProfiles(); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to set active profile'); + } + }; + + const handleAuthenticateProfile = async (profileId: string) => { + setAuthenticatingProfileId(profileId); + setError(null); + try { + const initResult = await window.electronAPI.initializeClaudeProfile(profileId); + if (initResult.success) { + alert( + `Authenticating profile...\n\n` + + `A browser window will open for you to log in with your Claude account.\n\n` + + `The authentication will be saved automatically once complete.` + ); + } else { + alert(`Failed to start authentication: ${initResult.error || 'Please try again.'}`); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to authenticate profile'); + alert('Failed to start authentication. Please try again.'); + } finally { + setAuthenticatingProfileId(null); + } + }; + + 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); + setError(null); + try { + const result = await window.electronAPI.setClaudeProfileToken( + profileId, + manualToken.trim(), + manualTokenEmail.trim() || undefined + ); + if (result.success) { + await loadClaudeProfiles(); + setExpandedTokenProfileId(null); + setManualToken(''); + setManualTokenEmail(''); + setShowManualToken(false); + } else { + alert(`Failed to save token: ${result.error || 'Please try again.'}`); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save token'); + alert('Failed to save token. Please try again.'); + } finally { + setSavingTokenProfileId(null); + } }; const handleContinue = () => { onNext(); }; - const handleReconfigure = () => { - setSuccess(false); - setError(null); - }; - return (
@@ -122,60 +276,26 @@ export function OAuthStep({ onNext, onBack, onSkip }: OAuthStepProps) {
- +

Configure Claude Authentication

- A Claude Code OAuth token is required to use AI features + Add your Claude accounts to enable AI features

{/* Loading state */} - {isChecking && ( + {isLoadingProfiles && (
)} - {/* Success state - differentiate between existing token and newly configured */} - {!isChecking && success && ( -
- - -
- -
-

- {hasExistingToken && !token - ? 'Token already configured' - : 'Token configured successfully'} -

-

- {hasExistingToken && !token - ? 'Your Claude OAuth token is already set up. You can continue to the next step or reconfigure if needed.' - : "You're all set to use AI features like Ideation, Roadmap generation, and autonomous code generation."} -

-
-
-
-
- -
- -
-
- )} - - {/* Configuration form */} - {!isChecking && !success && ( + {/* Profile management UI - placeholder for subtask-1-4 */} + {!isLoadingProfiles && (
{/* Error banner */} {error && ( @@ -189,119 +309,298 @@ export function OAuthStep({ onNext, onBack, onSkip }: OAuthStepProps) { )} - {/* Info about getting a token */} + {/* Info card */}
-
-

- How to get a Claude Code OAuth token: +

+

+ Add multiple Claude subscriptions to automatically switch between them when you hit rate limits.

-
    -
  1. Install Claude Code CLI if you haven't already
  2. -
  3. - Run{' '} - - claude setup-token - - {' '} - -
  4. -
  5. Copy the token and paste it below
  6. -
-
- {/* Token input */} -
- -
- setToken(e.target.value)} - placeholder="sk-ant-oat01-..." - className="pr-10 font-mono text-sm" - disabled={isSaving} - /> - - - + +
+ ) : ( + <> +
+ {profile.name} + {profile.isDefault && ( + Default + )} + {profile.id === activeProfileId && ( + + + Active + + )} + {(profile.oauthToken || (profile.isDefault && profile.configDir)) ? ( + + + Authenticated + + ) : ( + + Needs Auth + + )} +
+ {profile.email && ( + {profile.email} + )} + + )} +
+
+ {editingProfileId !== profile.id && ( +
+ {/* Authenticate button - show if not authenticated */} + {!profile.oauthToken && ( + + )} + {profile.id !== activeProfileId && ( + + )} + {/* Toggle token entry button */} + + + {!profile.isDefault && ( + + )} +
+ )} +
+ + {/* Expanded token entry section */} + {expandedTokenProfileId === profile.id && ( +
+
+
+ + + Run claude setup-token to get your token + +
+ +
+
+ setManualToken(e.target.value)} + className="pr-10 font-mono text-xs h-8" + /> + +
+ + setManualTokenEmail(e.target.value)} + className="text-xs h-8" + /> +
+ +
+ + +
+
+
+ )} +
+ ))} +
+ )} + + {/* Add new account input */} +
+ setNewProfileName(e.target.value)} + className="flex-1 h-8 text-sm" + onKeyDown={(e) => { + if (e.key === 'Enter' && newProfileName.trim()) { + handleAddProfile(); + } + }} + /> +
-

- The token will be saved to{' '} - - {sourcePath ? `${sourcePath}/.env` : 'auto-claude/.env'} - -

- {/* Existing token info */} - {hasExistingToken && ( - + {/* Success state when profiles are authenticated */} + {hasAuthenticatedProfile && ( + -

- A token is already configured. Enter a new token above to replace it, - or continue to the next step. -

+
+ +

+ You have at least one authenticated Claude account. You can continue to the next step. +

+
)} - - {/* Save button */} -
- -
)} @@ -324,7 +623,7 @@ export function OAuthStep({ onNext, onBack, onSkip }: OAuthStepProps) {