new oauth github integration upon onboarding.
This commit is contained in:
@@ -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> = {}): 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ClaudeProfile[]>([]);
|
||||
const [activeProfileId, setActiveProfileId] = useState<string | null>(null);
|
||||
const [isLoadingProfiles, setIsLoadingProfiles] = useState(true);
|
||||
const [newProfileName, setNewProfileName] = useState('');
|
||||
const [isAddingProfile, setIsAddingProfile] = useState(false);
|
||||
const [deletingProfileId, setDeletingProfileId] = useState<string | null>(null);
|
||||
const [editingProfileId, setEditingProfileId] = useState<string | null>(null);
|
||||
const [editingProfileName, setEditingProfileName] = useState('');
|
||||
const [authenticatingProfileId, setAuthenticatingProfileId] = useState<string | null>(null);
|
||||
|
||||
// Manual token entry state
|
||||
const [expandedTokenProfileId, setExpandedTokenProfileId] = useState<string | null>(null);
|
||||
const [manualToken, setManualToken] = useState('');
|
||||
const [manualTokenEmail, setManualTokenEmail] = useState('');
|
||||
const [showManualToken, setShowManualToken] = useState(false);
|
||||
const [savingTokenProfileId, setSavingTokenProfileId] = useState<string | null>(null);
|
||||
|
||||
// Error state
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [sourcePath, setSourcePath] = useState<string | null>(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 (
|
||||
<div className="flex h-full flex-col items-center justify-center px-8 py-6">
|
||||
<div className="w-full max-w-2xl">
|
||||
@@ -122,60 +276,26 @@ export function OAuthStep({ onNext, onBack, onSkip }: OAuthStepProps) {
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Key className="h-7 w-7" />
|
||||
<Users className="h-7 w-7" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground tracking-tight">
|
||||
Configure Claude Authentication
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
A Claude Code OAuth token is required to use AI features
|
||||
Add your Claude accounts to enable AI features
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isChecking && (
|
||||
{isLoadingProfiles && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success state - differentiate between existing token and newly configured */}
|
||||
{!isChecking && success && (
|
||||
<div className="space-y-6">
|
||||
<Card className="border border-success/30 bg-success/10">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<CheckCircle2 className="h-6 w-6 text-success shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-success">
|
||||
{hasExistingToken && !token
|
||||
? 'Token already configured'
|
||||
: 'Token configured successfully'}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-success/80">
|
||||
{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."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<button
|
||||
onClick={handleReconfigure}
|
||||
className="text-primary hover:text-primary/80 underline-offset-4 hover:underline"
|
||||
>
|
||||
{hasExistingToken && !token ? 'Reconfigure token' : 'Configure a different token'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Configuration form */}
|
||||
{!isChecking && !success && (
|
||||
{/* Profile management UI - placeholder for subtask-1-4 */}
|
||||
{!isLoadingProfiles && (
|
||||
<div className="space-y-6">
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
@@ -189,119 +309,298 @@ export function OAuthStep({ onNext, onBack, onSkip }: OAuthStepProps) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Info about getting a token */}
|
||||
{/* Info card */}
|
||||
<Card className="border border-info/30 bg-info/10">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<Info className="h-5 w-5 text-info shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
How to get a Claude Code OAuth token:
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add multiple Claude subscriptions to automatically switch between them when you hit rate limits.
|
||||
</p>
|
||||
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
|
||||
<li>Install Claude Code CLI if you haven't already</li>
|
||||
<li>
|
||||
Run{' '}
|
||||
<code className="px-1.5 py-0.5 bg-muted rounded font-mono text-xs">
|
||||
claude setup-token
|
||||
</code>
|
||||
{' '}
|
||||
<button
|
||||
onClick={handleCopyCommand}
|
||||
className="inline-flex items-center text-info hover:text-info/80"
|
||||
>
|
||||
<Copy className="h-3 w-3 ml-1" />
|
||||
</button>
|
||||
</li>
|
||||
<li>Copy the token and paste it below</li>
|
||||
</ol>
|
||||
<button
|
||||
onClick={handleOpenDocs}
|
||||
className="text-sm text-info hover:text-info/80 flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
View documentation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Token input */}
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="token" className="text-sm font-medium text-foreground">
|
||||
Claude Code OAuth Token
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="token"
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="sk-ant-oat01-..."
|
||||
className="pr-10 font-mono text-sm"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showToken ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
{/* Profile list */}
|
||||
<div className="rounded-lg bg-muted/30 border border-border p-4">
|
||||
{claudeProfiles.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border p-4 text-center mb-4">
|
||||
<p className="text-sm text-muted-foreground">No accounts configured yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 mb-4">
|
||||
{claudeProfiles.map((profile) => (
|
||||
<div
|
||||
key={profile.id}
|
||||
className={cn(
|
||||
"rounded-lg border transition-colors",
|
||||
profile.id === activeProfileId
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-background"
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{showToken ? 'Hide token' : 'Show token'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
>
|
||||
<div className={cn(
|
||||
"flex items-center justify-between p-3",
|
||||
expandedTokenProfileId !== profile.id && "hover:bg-muted/50"
|
||||
)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"h-7 w-7 rounded-full flex items-center justify-center text-xs font-medium shrink-0",
|
||||
profile.id === activeProfileId
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{(editingProfileId === profile.id ? editingProfileName : profile.name).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
{editingProfileId === profile.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={editingProfileName}
|
||||
onChange={(e) => setEditingProfileName(e.target.value)}
|
||||
className="h-7 text-sm w-40"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRenameProfile();
|
||||
if (e.key === 'Escape') cancelEditingProfile();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRenameProfile}
|
||||
className="h-7 w-7 text-success hover:text-success hover:bg-success/10"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={cancelEditingProfile}
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-foreground">{profile.name}</span>
|
||||
{profile.isDefault && (
|
||||
<span className="text-xs bg-muted px-1.5 py-0.5 rounded">Default</span>
|
||||
)}
|
||||
{profile.id === activeProfileId && (
|
||||
<span className="text-xs bg-primary/20 text-primary px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||
<Star className="h-3 w-3" />
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
{(profile.oauthToken || (profile.isDefault && profile.configDir)) ? (
|
||||
<span className="text-xs bg-success/20 text-success px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||
<Check className="h-3 w-3" />
|
||||
Authenticated
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs bg-warning/20 text-warning px-1.5 py-0.5 rounded">
|
||||
Needs Auth
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{profile.email && (
|
||||
<span className="text-xs text-muted-foreground">{profile.email}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{editingProfileId !== profile.id && (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Authenticate button - show if not authenticated */}
|
||||
{!profile.oauthToken && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAuthenticateProfile(profile.id)}
|
||||
disabled={authenticatingProfileId === profile.id}
|
||||
className="gap-1 h-7 text-xs"
|
||||
>
|
||||
{authenticatingProfileId === profile.id ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<LogIn className="h-3 w-3" />
|
||||
)}
|
||||
Authenticate
|
||||
</Button>
|
||||
)}
|
||||
{profile.id !== activeProfileId && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSetActiveProfile(profile.id)}
|
||||
className="gap-1 h-7 text-xs"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
Set Active
|
||||
</Button>
|
||||
)}
|
||||
{/* Toggle token entry button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => toggleTokenEntry(profile.id)}
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
title={expandedTokenProfileId === profile.id ? "Hide token entry" : "Enter token manually"}
|
||||
>
|
||||
{expandedTokenProfileId === profile.id ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => startEditingProfile(profile)}
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
title="Rename profile"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
{!profile.isDefault && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteProfile(profile.id)}
|
||||
disabled={deletingProfileId === profile.id}
|
||||
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
title="Delete profile"
|
||||
>
|
||||
{deletingProfileId === profile.id ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded token entry section */}
|
||||
{expandedTokenProfileId === profile.id && (
|
||||
<div className="px-3 pb-3 pt-0 border-t border-border/50 mt-0">
|
||||
<div className="bg-muted/30 rounded-lg p-3 mt-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
Manual Token Entry
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Run <code className="px-1 py-0.5 bg-muted rounded font-mono text-xs">claude setup-token</code> to get your token
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showManualToken ? 'text' : 'password'}
|
||||
placeholder="sk-ant-oat01-..."
|
||||
value={manualToken}
|
||||
onChange={(e) => setManualToken(e.target.value)}
|
||||
className="pr-10 font-mono text-xs h-8"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowManualToken(!showManualToken)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showManualToken ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Email (optional, for display)"
|
||||
value={manualTokenEmail}
|
||||
onChange={(e) => setManualTokenEmail(e.target.value)}
|
||||
className="text-xs h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleTokenEntry(profile.id)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSaveManualToken(profile.id)}
|
||||
disabled={!manualToken.trim() || savingTokenProfileId === profile.id}
|
||||
className="h-7 text-xs gap-1"
|
||||
>
|
||||
{savingTokenProfileId === profile.id ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
Save Token
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add new account input */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Account name (e.g., Work, Personal)"
|
||||
value={newProfileName}
|
||||
onChange={(e) => setNewProfileName(e.target.value)}
|
||||
className="flex-1 h-8 text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newProfileName.trim()) {
|
||||
handleAddProfile();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddProfile}
|
||||
disabled={!newProfileName.trim() || isAddingProfile}
|
||||
size="sm"
|
||||
className="gap-1 shrink-0"
|
||||
>
|
||||
{isAddingProfile ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-3 w-3" />
|
||||
)}
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The token will be saved to{' '}
|
||||
<code className="px-1 py-0.5 bg-muted rounded font-mono">
|
||||
{sourcePath ? `${sourcePath}/.env` : 'auto-claude/.env'}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Existing token info */}
|
||||
{hasExistingToken && (
|
||||
<Card className="border border-border bg-muted/30">
|
||||
{/* Success state when profiles are authenticated */}
|
||||
{hasAuthenticatedProfile && (
|
||||
<Card className="border border-success/30 bg-success/10">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A token is already configured. Enter a new token above to replace it,
|
||||
or continue to the next step.
|
||||
</p>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-success shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-success">
|
||||
You have at least one authenticated Claude account. You can continue to the next step.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleSave}
|
||||
disabled={!token.trim() || isSaving}
|
||||
className="gap-2 px-8"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Key className="h-4 w-4" />
|
||||
Save Token
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -324,7 +623,7 @@ export function OAuthStep({ onNext, onBack, onSkip }: OAuthStepProps) {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleContinue}
|
||||
disabled={!success && !hasExistingToken}
|
||||
disabled={!hasAuthenticatedProfile}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user