Merge branch 'develop' into feat-gitlab-parity
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Tests for ensureOnboardingComplete function in cli-integration-handler.ts
|
||||
*
|
||||
* Tests the exported ensureOnboardingComplete() which reads/writes .claude.json
|
||||
* to set hasCompletedOnboarding: true, suppressing Claude's onboarding wizard
|
||||
* for already-authenticated profiles.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
// ---- fs mock (sync only — the function uses fs, not fs/promises) ----
|
||||
const mockFiles: Map<string, string | Error> = new Map();
|
||||
|
||||
vi.mock('fs', () => {
|
||||
const readFileSync = vi.fn((filePath: string, _encoding?: string): string => {
|
||||
const entry = mockFiles.get(filePath);
|
||||
if (entry === undefined) {
|
||||
const err = new Error(`ENOENT: no such file or directory, open '${filePath}'`) as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
}
|
||||
if (entry instanceof Error) {
|
||||
throw entry;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
const writeFileSync = vi.fn();
|
||||
const renameSync = vi.fn();
|
||||
|
||||
return { default: { readFileSync, writeFileSync, renameSync }, readFileSync, writeFileSync, renameSync };
|
||||
});
|
||||
|
||||
// ---- stubs for heavy transitive dependencies ----
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: { handle: vi.fn() },
|
||||
app: { getPath: vi.fn(() => os.tmpdir()), getAppPath: vi.fn(() => os.tmpdir()) },
|
||||
dialog: { showOpenDialog: vi.fn() },
|
||||
shell: { openExternal: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@electron-toolkit/utils', () => ({ is: { dev: true } }));
|
||||
|
||||
vi.mock('../../shared/constants', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../shared/constants')>('../../shared/constants');
|
||||
return { ...actual };
|
||||
});
|
||||
|
||||
vi.mock('../claude-profile-manager', () => ({
|
||||
getClaudeProfileManager: vi.fn(),
|
||||
initializeClaudeProfileManager: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../claude-profile/credential-utils', () => ({
|
||||
getFullCredentialsFromKeychain: vi.fn(),
|
||||
clearKeychainCache: vi.fn(),
|
||||
updateProfileSubscriptionMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../claude-profile/usage-monitor', () => ({
|
||||
getUsageMonitor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../claude-profile/profile-utils', () => ({
|
||||
getEmailFromConfigDir: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./output-parser', () => ({}));
|
||||
vi.mock('./session-handler', () => ({}));
|
||||
|
||||
vi.mock('./pty-manager', () => ({
|
||||
writeToPty: vi.fn(),
|
||||
resizePty: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../ipc-handlers/utils', () => ({
|
||||
safeSendToRenderer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../shared/utils/debug-logger', () => ({
|
||||
debugLog: vi.fn(),
|
||||
debugError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../shared/utils/shell-escape', () => ({
|
||||
escapeShellArg: vi.fn((s: string) => s),
|
||||
escapeForWindowsDoubleQuote: vi.fn((s: string) => s),
|
||||
buildCdCommand: vi.fn((cwd: string) => `cd ${cwd}`),
|
||||
}));
|
||||
|
||||
vi.mock('../cli-utils', () => ({
|
||||
getClaudeCliInvocation: vi.fn(() => 'claude'),
|
||||
getClaudeCliInvocationAsync: vi.fn(async () => 'claude'),
|
||||
}));
|
||||
|
||||
vi.mock('../platform', () => ({
|
||||
isWindows: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('../settings-utils', () => ({
|
||||
readSettingsFileAsync: vi.fn(async () => ({})),
|
||||
readSettingsFile: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
// ---- import the function under test ----
|
||||
import { ensureOnboardingComplete } from '../terminal/cli-integration-handler';
|
||||
import * as fs from 'fs';
|
||||
|
||||
// ---- helpers ----
|
||||
function claudeJsonPath(configDir: string): string {
|
||||
const expanded = configDir.startsWith('~')
|
||||
? configDir.replace(/^~/, os.homedir())
|
||||
: configDir;
|
||||
return path.join(path.resolve(expanded), '.claude.json');
|
||||
}
|
||||
|
||||
const TEST_DIR = '/tmp/test-profile';
|
||||
|
||||
describe('ensureOnboardingComplete', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFiles.clear();
|
||||
});
|
||||
|
||||
// ---- ENOENT: file does not exist ----
|
||||
test('returns early (no write) when .claude.json does not exist', () => {
|
||||
// mockFiles is empty → readFileSync will throw ENOENT
|
||||
ensureOnboardingComplete(TEST_DIR);
|
||||
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---- already set ----
|
||||
test('returns early (no write) when hasCompletedOnboarding is already true', () => {
|
||||
const filePath = claudeJsonPath(TEST_DIR);
|
||||
mockFiles.set(filePath, JSON.stringify({ hasCompletedOnboarding: true }));
|
||||
|
||||
ensureOnboardingComplete(TEST_DIR);
|
||||
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---- missing flag → should write ----
|
||||
test('writes hasCompletedOnboarding: true when flag is absent', () => {
|
||||
const filePath = claudeJsonPath(TEST_DIR);
|
||||
mockFiles.set(filePath, JSON.stringify({ someOtherField: 'value' }));
|
||||
|
||||
ensureOnboardingComplete(TEST_DIR);
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalledOnce();
|
||||
const written = JSON.parse((fs.writeFileSync as ReturnType<typeof vi.fn>).mock.calls[0][1] as string);
|
||||
expect(written.hasCompletedOnboarding).toBe(true);
|
||||
expect(written.someOtherField).toBe('value');
|
||||
});
|
||||
|
||||
// ---- flag is false → should write ----
|
||||
test('writes hasCompletedOnboarding: true when flag is false', () => {
|
||||
const filePath = claudeJsonPath(TEST_DIR);
|
||||
mockFiles.set(filePath, JSON.stringify({ hasCompletedOnboarding: false }));
|
||||
|
||||
ensureOnboardingComplete(TEST_DIR);
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalledOnce();
|
||||
const written = JSON.parse((fs.writeFileSync as ReturnType<typeof vi.fn>).mock.calls[0][1] as string);
|
||||
expect(written.hasCompletedOnboarding).toBe(true);
|
||||
});
|
||||
|
||||
// ---- non-object JSON (string) → should return silently ----
|
||||
test('returns early (no write) when .claude.json contains a JSON string', () => {
|
||||
const filePath = claudeJsonPath(TEST_DIR);
|
||||
mockFiles.set(filePath, JSON.stringify('just a string'));
|
||||
|
||||
ensureOnboardingComplete(TEST_DIR);
|
||||
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---- array JSON → should return silently ----
|
||||
test('returns early (no write) when .claude.json contains a JSON array', () => {
|
||||
const filePath = claudeJsonPath(TEST_DIR);
|
||||
mockFiles.set(filePath, JSON.stringify([1, 2, 3]));
|
||||
|
||||
ensureOnboardingComplete(TEST_DIR);
|
||||
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---- corrupted / invalid JSON → outer catch swallows error ----
|
||||
test('handles corrupted JSON gracefully without throwing', () => {
|
||||
const filePath = claudeJsonPath(TEST_DIR);
|
||||
mockFiles.set(filePath, '{ invalid json }');
|
||||
|
||||
expect(() => ensureOnboardingComplete(TEST_DIR)).not.toThrow();
|
||||
expect(fs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---- tilde expansion ----
|
||||
test('expands leading tilde to home directory', () => {
|
||||
const tildeDir = '~/myprofile';
|
||||
const resolvedDir = path.resolve(tildeDir.replace(/^~/, os.homedir()));
|
||||
const filePath = path.join(resolvedDir, '.claude.json');
|
||||
|
||||
mockFiles.set(filePath, JSON.stringify({}));
|
||||
|
||||
ensureOnboardingComplete(tildeDir);
|
||||
|
||||
expect(fs.writeFileSync).toHaveBeenCalledOnce();
|
||||
// Writes to a temp file (claudeJsonPath + UUID + .tmp), then renames to target
|
||||
const writtenPath = (fs.writeFileSync as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
||||
expect(writtenPath).toMatch(new RegExp(`^${filePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\..*\\.tmp$`));
|
||||
expect(fs.renameSync).toHaveBeenCalledWith(writtenPath, filePath);
|
||||
});
|
||||
|
||||
// ---- write error → outer catch swallows error ----
|
||||
test('handles write error gracefully without throwing', () => {
|
||||
const filePath = claudeJsonPath(TEST_DIR);
|
||||
mockFiles.set(filePath, JSON.stringify({}));
|
||||
|
||||
(fs.writeFileSync as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
|
||||
throw new Error('EACCES: permission denied');
|
||||
});
|
||||
|
||||
expect(() => ensureOnboardingComplete(TEST_DIR)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -27,8 +27,7 @@ import type {
|
||||
TerminalProcess,
|
||||
WindowGetter,
|
||||
RateLimitEvent,
|
||||
OAuthTokenEvent,
|
||||
OnboardingCompleteEvent
|
||||
OAuthTokenEvent
|
||||
} from './types';
|
||||
|
||||
// ============================================================================
|
||||
@@ -564,17 +563,17 @@ export function handleOAuthToken(
|
||||
|
||||
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;
|
||||
// Mark onboarding complete so future `claude` invocations skip the wizard.
|
||||
// `claude auth login` creates .claude.json but doesn't set this flag.
|
||||
if (profile.configDir) {
|
||||
ensureOnboardingComplete(profile.configDir);
|
||||
}
|
||||
|
||||
// needsOnboarding: true tells the UI to show "complete setup" message
|
||||
// instead of "success" - user should finish Claude's onboarding before closing
|
||||
safeSendToRenderer(getWindow, 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 {
|
||||
@@ -585,17 +584,16 @@ export function handleOAuthToken(
|
||||
if (hasCredentials) {
|
||||
console.warn('[ClaudeIntegration] Profile credentials verified (no Keychain token):', profileId);
|
||||
|
||||
// Set flag to watch for Claude's ready state (onboarding complete)
|
||||
terminal.awaitingOnboardingComplete = true;
|
||||
// Mark onboarding complete so future `claude` invocations skip the wizard
|
||||
if (profile.configDir) {
|
||||
ensureOnboardingComplete(profile.configDir);
|
||||
}
|
||||
|
||||
// needsOnboarding: true tells the UI to show "complete setup" message
|
||||
// instead of "success" - user should finish Claude's onboarding before closing
|
||||
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
|
||||
terminalId: terminal.id,
|
||||
profileId,
|
||||
email: emailFromOutput || profile?.email,
|
||||
success: true,
|
||||
needsOnboarding: true,
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OAuthTokenEvent);
|
||||
} else {
|
||||
@@ -718,135 +716,19 @@ export function handleOAuthToken(
|
||||
|
||||
/**
|
||||
* Handle onboarding complete detection
|
||||
* Called when terminal output indicates Claude Code is ready after login/onboarding
|
||||
* Called when terminal output indicates Claude Code is ready after login/onboarding.
|
||||
*
|
||||
* This detects the Claude Code welcome screen that appears after successful login,
|
||||
* which includes patterns like "Welcome back", "Claude Code v2.x", or subscription
|
||||
* tier info like "Claude Max". When detected, it notifies the frontend to auto-close
|
||||
* the auth terminal.
|
||||
* Note: This is now a no-op. The onboarding flag is set proactively via
|
||||
* ensureOnboardingComplete() in handleOAuthToken() and executeProfileCommand(),
|
||||
* so awaitingOnboardingComplete is never set and this path is never reached.
|
||||
* Kept as a stub to satisfy the terminal-event-handler callback interface.
|
||||
*/
|
||||
export function handleOnboardingComplete(
|
||||
terminal: TerminalProcess,
|
||||
data: string,
|
||||
getWindow: WindowGetter
|
||||
_terminal: TerminalProcess,
|
||||
_data: string,
|
||||
_getWindow: WindowGetter
|
||||
): void {
|
||||
// Only check if we're waiting for onboarding to complete
|
||||
if (!terminal.awaitingOnboardingComplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if output shows Claude Code welcome screen (onboarding complete indicators)
|
||||
if (!OutputParser.isOnboardingCompleteOutput(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn('[ClaudeIntegration] Onboarding complete detected for terminal:', terminal.id);
|
||||
|
||||
// Clear the flag
|
||||
terminal.awaitingOnboardingComplete = false;
|
||||
|
||||
// Extract profile ID from terminal ID pattern (claude-login-{profileId}-*)
|
||||
const profileId = extractProfileIdFromAuthTerminalId(terminal.id) || undefined;
|
||||
|
||||
// Try to extract email from the welcome screen (e.g., "user@example.com's Organization")
|
||||
// Note: extractEmail automatically strips ANSI escape codes internally
|
||||
let email = OutputParser.extractEmail(data);
|
||||
if (!email) {
|
||||
email = OutputParser.extractEmail(terminal.outputBuffer);
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
});
|
||||
|
||||
// Update profile with email and subscription metadata if found and profile exists
|
||||
// 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;
|
||||
// Also update subscription metadata from Keychain credentials
|
||||
updateProfileSubscriptionMetadata(profile, profile.configDir);
|
||||
profileManager.saveProfile(profile);
|
||||
if (previousEmail !== email) {
|
||||
console.warn('[ClaudeIntegration] Updated profile email from welcome screen:', profileId, maskEmail(email), '(was:', maskEmail(previousEmail), ')');
|
||||
}
|
||||
}
|
||||
|
||||
// Persist onboarding completion so future invocations skip the wizard
|
||||
if (profile?.configDir) {
|
||||
ensureOnboardingComplete(profile.configDir);
|
||||
}
|
||||
|
||||
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_ONBOARDING_COMPLETE, {
|
||||
terminalId: terminal.id,
|
||||
profileId,
|
||||
detectedAt: new Date().toISOString()
|
||||
} as OnboardingCompleteEvent);
|
||||
|
||||
// Trigger immediate usage fetch after successful re-authentication
|
||||
// This gives the user immediate feedback that their account is working
|
||||
if (profileId) {
|
||||
try {
|
||||
const usageMonitor = getUsageMonitor();
|
||||
if (usageMonitor) {
|
||||
// Clear any auth failure status for this profile since they just re-authenticated
|
||||
usageMonitor.clearAuthFailedProfile(profileId);
|
||||
|
||||
console.warn('[ClaudeIntegration] Triggering immediate usage fetch after re-authentication:', profileId);
|
||||
|
||||
// Switch to this profile if it's not already active, then fetch usage
|
||||
const profileManager = getClaudeProfileManager();
|
||||
|
||||
// Also clear the migration flag if this profile was migrated to an isolated directory
|
||||
// This prevents the auth failure modal from showing again on next startup
|
||||
if (profileManager.isProfileMigrated(profileId)) {
|
||||
profileManager.clearMigratedProfile(profileId);
|
||||
console.warn('[ClaudeIntegration] Cleared migration flag for re-authenticated profile:', profileId);
|
||||
}
|
||||
const activeProfile = profileManager.getActiveProfile();
|
||||
if (activeProfile?.id !== profileId) {
|
||||
profileManager.setActiveProfile(profileId);
|
||||
}
|
||||
|
||||
// Small delay to allow profile switch to settle, then trigger usage fetch
|
||||
setTimeout(() => {
|
||||
usageMonitor.checkNow();
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ClaudeIntegration] Failed to trigger post-auth usage fetch:', error);
|
||||
}
|
||||
}
|
||||
// No-op — onboarding is handled proactively in handleOAuthToken()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -903,7 +785,7 @@ export function handleClaudeExit(
|
||||
* When CLAUDE_CONFIG_DIR is set, Claude Code reads .claude.json from that directory.
|
||||
* Without this flag, it triggers the onboarding wizard even for authenticated profiles.
|
||||
*/
|
||||
function ensureOnboardingComplete(configDir: string): void {
|
||||
export function ensureOnboardingComplete(configDir: string): void {
|
||||
try {
|
||||
const expandedDir = path.resolve(
|
||||
configDir.startsWith('~') ? configDir.replace(/^~/, os.homedir()) : configDir
|
||||
@@ -932,7 +814,13 @@ function ensureOnboardingComplete(configDir: string): void {
|
||||
}
|
||||
|
||||
config.hasCompletedOnboarding = true;
|
||||
fs.writeFileSync(claudeJsonPath, JSON.stringify(config, null, 2), { encoding: 'utf-8' });
|
||||
const updatedContent = JSON.stringify(config, null, 2);
|
||||
|
||||
// Write atomically via temp file + rename to avoid partial writes and satisfy CodeQL js/insecure-temporary-file.
|
||||
// crypto.randomUUID() ensures no collisions; mode 0o600 restricts to owner-only.
|
||||
const tmpPath = `${claudeJsonPath}.${crypto.randomUUID()}.tmp`;
|
||||
fs.writeFileSync(tmpPath, updatedContent, { encoding: 'utf-8', mode: 0o600 });
|
||||
fs.renameSync(tmpPath, claudeJsonPath);
|
||||
debugLog(`[ClaudeIntegration] Set hasCompletedOnboarding in ${claudeJsonPath}`);
|
||||
} catch (error) {
|
||||
// Non-fatal — worst case the user sees onboarding once
|
||||
|
||||
@@ -26,8 +26,6 @@ export interface TerminalProcess {
|
||||
dangerouslySkipPermissions?: boolean;
|
||||
/** Shell type for Windows (affects command chaining syntax) */
|
||||
shellType?: WindowsShellType;
|
||||
/** Whether this terminal is waiting for Claude onboarding to complete (login flow) */
|
||||
awaitingOnboardingComplete?: boolean;
|
||||
/** Whether PTY has emitted exit; used to avoid writes/resizes on dead PTYs */
|
||||
hasExited?: boolean;
|
||||
}
|
||||
@@ -55,19 +53,8 @@ export interface OAuthTokenEvent {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
detectedAt: string;
|
||||
/** If true, user should complete onboarding in terminal before closing */
|
||||
needsOnboarding?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding complete event data
|
||||
* Sent when Claude Code shows its ready state after login/onboarding
|
||||
*/
|
||||
export interface OnboardingCompleteEvent {
|
||||
terminalId: string;
|
||||
profileId?: string;
|
||||
detectedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session capture result
|
||||
|
||||
@@ -83,7 +83,7 @@ export interface TerminalAPI {
|
||||
onTerminalClaudeSession: (callback: (id: string, sessionId: string) => void) => () => void;
|
||||
onTerminalRateLimit: (callback: (info: RateLimitInfo) => void) => () => void;
|
||||
onTerminalOAuthToken: (
|
||||
callback: (info: { terminalId: string; profileId?: string; email?: string; success: boolean; message?: string; detectedAt: string; needsOnboarding?: boolean }) => void
|
||||
callback: (info: { terminalId: string; profileId?: string; email?: string; success: boolean; message?: string; detectedAt: string }) => void
|
||||
) => () => void;
|
||||
onTerminalAuthCreated: (
|
||||
callback: (info: { terminalId: string; profileId: string; profileName: string }) => void
|
||||
|
||||
@@ -33,11 +33,15 @@ export function ProjectTabBar({
|
||||
// Keyboard shortcuts for tab navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Skip if in input fields
|
||||
// Skip if in input fields (but NOT xterm's hidden textarea —
|
||||
// xterm already passes through Cmd/Ctrl+1-9 via attachCustomKeyEventHandler)
|
||||
const target = e.target as HTMLElement;
|
||||
const isXtermTextarea = target.classList?.contains('xterm-helper-textarea');
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
!isXtermTextarea &&
|
||||
(e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
(e.target as HTMLElement)?.isContentEditable
|
||||
target?.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -51,9 +51,8 @@ export function AuthTerminal({
|
||||
const loginSentRef = useRef(false); // Track if /login was already sent
|
||||
const loginTimeoutRef = useRef<NodeJS.Timeout | null>(null); // Track setTimeout for cleanup
|
||||
const successTimeoutRef = useRef<NodeJS.Timeout | null>(null); // Track success auto-close timeout for cleanup
|
||||
const authCompletedRef = useRef(false); // Track if auth has already completed to prevent race conditions
|
||||
|
||||
const [status, setStatus] = useState<'connecting' | 'ready' | 'onboarding' | 'success' | 'error'>('connecting');
|
||||
const [status, setStatus] = useState<'connecting' | 'ready' | 'success' | 'error'>('connecting');
|
||||
const [authEmail, setAuthEmail] = useState<string | undefined>();
|
||||
const [errorMessage, setErrorMessage] = useState<string | undefined>();
|
||||
|
||||
@@ -235,7 +234,6 @@ export function AuthTerminal({
|
||||
thisTerminalId: terminalId,
|
||||
isMatch: info.terminalId === terminalId,
|
||||
success: info.success,
|
||||
needsOnboarding: info.needsOnboarding,
|
||||
email: info.email,
|
||||
currentStatus: statusRef.current,
|
||||
loginSent: loginSentRef.current
|
||||
@@ -243,16 +241,9 @@ export function AuthTerminal({
|
||||
if (info.terminalId === terminalId) {
|
||||
if (info.success) {
|
||||
setAuthEmail(info.email);
|
||||
// If needsOnboarding is true, user should complete setup in terminal
|
||||
// Otherwise, authentication is fully complete
|
||||
if (info.needsOnboarding) {
|
||||
debugLog('Setting status to onboarding', { terminalId });
|
||||
setStatus('onboarding');
|
||||
} else {
|
||||
debugLog('Setting status to success (no onboarding needed)', { terminalId });
|
||||
setStatus('success');
|
||||
onAuthSuccess?.(info.email);
|
||||
}
|
||||
debugLog('Setting status to success', { terminalId });
|
||||
setStatus('success');
|
||||
onAuthSuccess?.(info.email);
|
||||
} else {
|
||||
debugLog('OAuth failed', { terminalId, message: info.message });
|
||||
setStatus('error');
|
||||
@@ -272,60 +263,13 @@ export function AuthTerminal({
|
||||
terminalId,
|
||||
exitCode,
|
||||
currentStatus: statusRef.current,
|
||||
loginSent: loginSentRef.current,
|
||||
willTransitionToSuccess: statusRef.current === 'onboarding' && exitCode === 0
|
||||
loginSent: loginSentRef.current
|
||||
});
|
||||
// If we were in onboarding status and terminal exits with code 0,
|
||||
// that means the user completed the onboarding successfully
|
||||
if (statusRef.current === 'onboarding' && exitCode === 0) {
|
||||
// Prevent race condition with onboarding-complete handler
|
||||
if (authCompletedRef.current) {
|
||||
debugLog('SKIPPED exit handler - auth already completed', { terminalId });
|
||||
return;
|
||||
}
|
||||
authCompletedRef.current = true;
|
||||
debugLog('Transitioning from onboarding to success', { terminalId });
|
||||
setStatus('success');
|
||||
onAuthSuccess?.(authEmailRef.current);
|
||||
}
|
||||
// Don't close automatically - let user see any error messages
|
||||
}
|
||||
});
|
||||
cleanupFnsRef.current.push(unsubExit);
|
||||
|
||||
// Handle onboarding complete (Claude shows ready state after login)
|
||||
const unsubOnboardingComplete = window.electronAPI.onTerminalOnboardingComplete((info) => {
|
||||
if (info.terminalId === terminalId) {
|
||||
console.warn('[AuthTerminal] Onboarding complete:', info);
|
||||
debugLog('Onboarding complete event', {
|
||||
terminalId: info.terminalId,
|
||||
profileId: info.profileId,
|
||||
currentStatus: statusRef.current
|
||||
});
|
||||
// Only process if we're in onboarding status
|
||||
if (statusRef.current === 'onboarding') {
|
||||
// Prevent race condition with terminal exit handler
|
||||
if (authCompletedRef.current) {
|
||||
debugLog('SKIPPED onboarding-complete handler - auth already completed', { terminalId });
|
||||
return;
|
||||
}
|
||||
authCompletedRef.current = true;
|
||||
debugLog('Auto-closing terminal after onboarding complete', { terminalId });
|
||||
setStatus('success');
|
||||
onAuthSuccess?.(authEmailRef.current);
|
||||
// Auto-close after a brief delay to show success UI
|
||||
successTimeoutRef.current = setTimeout(() => {
|
||||
if (isCreatedRef.current) {
|
||||
window.electronAPI.destroyTerminal(terminalId).catch(console.error);
|
||||
isCreatedRef.current = false;
|
||||
}
|
||||
onClose();
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
});
|
||||
cleanupFnsRef.current.push(unsubOnboardingComplete);
|
||||
|
||||
return () => {
|
||||
debugLog('Cleaning up event listeners', { terminalId });
|
||||
inputDisposable.dispose();
|
||||
@@ -408,9 +352,6 @@ export function AuthTerminal({
|
||||
{status === 'ready' && (
|
||||
<div className="h-2 w-2 rounded-full bg-yellow-500 animate-pulse" />
|
||||
)}
|
||||
{status === 'onboarding' && (
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500 animate-pulse" />
|
||||
)}
|
||||
{status === 'success' && (
|
||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||
)}
|
||||
@@ -420,7 +361,6 @@ export function AuthTerminal({
|
||||
<span className="text-sm font-medium">
|
||||
{status === 'connecting' && t('authTerminal.connecting')}
|
||||
{status === 'ready' && t('authTerminal.authenticate', { profileName })}
|
||||
{status === 'onboarding' && t('authTerminal.completeSetup', { profileName })}
|
||||
{status === 'success' && (authEmail ? t('authTerminal.authenticatedAs', { email: authEmail }) : t('authTerminal.authenticated'))}
|
||||
{status === 'error' && t('authTerminal.authError')}
|
||||
</span>
|
||||
@@ -446,13 +386,6 @@ export function AuthTerminal({
|
||||
/>
|
||||
|
||||
{/* Status bar */}
|
||||
{status === 'onboarding' && (
|
||||
<div className="px-3 py-2 border-t border-border bg-blue-500/10">
|
||||
<p className="text-sm text-blue-600 dark:text-blue-400">
|
||||
{t('authTerminal.onboardingMessage')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{status === 'success' && (
|
||||
<div className="px-3 py-2 border-t border-border bg-success/10">
|
||||
<p className="text-sm text-success">
|
||||
|
||||
@@ -300,8 +300,6 @@ export interface ElectronAPILegacy {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
detectedAt: string;
|
||||
/** If true, user should complete onboarding in terminal before closing */
|
||||
needsOnboarding?: boolean;
|
||||
}) => void) => () => void;
|
||||
/** Listen for auth terminal creation - allows UI to display the OAuth terminal */
|
||||
onTerminalAuthCreated: (callback: (info: {
|
||||
|
||||
Reference in New Issue
Block a user