fix: skip Claude onboarding for profiles + prevent duplicate accounts (#1952)
* fix: skip Claude Code onboarding for authenticated profiles When CLAUDE_CONFIG_DIR points to a profile directory, Claude Code reads .claude.json from that directory instead of ~/.claude.json. Profile configs created by `claude auth login` don't include hasCompletedOnboarding, causing the onboarding wizard to appear every time Claude Code is launched. Set hasCompletedOnboarding: true in the profile's .claude.json after successful authentication and before each Claude Code invocation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: clean up dead onboarding code paths and add tests Remove dead `needsOnboarding` UI branch from AuthTerminal.tsx and stale type declarations from types.ts, ipc.ts, terminal-api.ts. Remove unreachable `ensureOnboardingComplete` call from `handleOnboardingComplete` (guard prevents execution). Export `ensureOnboardingComplete` and add 9 unit tests covering all branches. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: allow project-switching shortcuts when terminal is focused xterm.js uses a hidden <textarea> (xterm-helper-textarea) for keyboard input. ProjectTabBar's keydown handler skipped all HTMLTextAreaElement targets, which prevented Cmd/Ctrl+1-9 project switching from working when a terminal had focus. Exclude xterm's textarea from the skip-filter since xterm already passes these shortcuts through via attachCustomKeyEventHandler. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use atomic write to satisfy CodeQL insecure-temporary-file rule Write .claude.json via temp file + rename instead of direct writeFileSync to address CodeQL js/insecure-temporary-file false positive. The temp file is created with mode 0o600 (owner-only) and atomically renamed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: prevent duplicate provider accounts and clean up dead onboarding API surface Add backend gate in PROVIDER_ACCOUNTS_SAVE to reject duplicate email+provider combinations with a user-friendly error. Clean up dead onTerminalOnboardingComplete IPC surface (preload, types, constants, mock) that was never fired after the onboarding flow was made proactive. Fix i18n compliance (hardcoded strings in handleFallbackTerminal, orphaned translation keys) and Codex OAuth silent error swallowing. Correct vi.mock paths in onboarding test file. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -67,8 +67,8 @@ vi.mock('../claude-profile/profile-utils', () => ({
|
||||
getEmailFromConfigDir: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./output-parser', () => ({}));
|
||||
vi.mock('./session-handler', () => ({}));
|
||||
vi.mock('../terminal/output-parser', () => ({}));
|
||||
vi.mock('../terminal/session-handler', () => ({}));
|
||||
|
||||
vi.mock('./pty-manager', () => ({
|
||||
writeToPty: vi.fn(),
|
||||
|
||||
@@ -933,6 +933,20 @@ export function registerSettingsHandlers(
|
||||
try {
|
||||
const settings = readSettingsFile() ?? {};
|
||||
const accounts: ProviderAccount[] = (settings.providerAccounts as ProviderAccount[] | undefined) ?? [];
|
||||
|
||||
// Prevent duplicate: same email + provider already registered
|
||||
if (account.email) {
|
||||
const duplicate = accounts.find(
|
||||
(a) => a.provider === account.provider && a.email?.toLowerCase() === account.email!.toLowerCase()
|
||||
);
|
||||
if (duplicate) {
|
||||
return {
|
||||
success: false,
|
||||
error: `DUPLICATE_EMAIL:${duplicate.name}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const newAccount: ProviderAccount = {
|
||||
...account,
|
||||
|
||||
@@ -91,9 +91,6 @@ export interface TerminalAPI {
|
||||
submitOAuthCode: (terminalId: string, code: string) => Promise<IPCResult>;
|
||||
onTerminalClaudeBusy: (callback: (id: string, isBusy: boolean) => void) => () => void;
|
||||
onTerminalClaudeExit: (callback: (id: string) => void) => () => void;
|
||||
onTerminalOnboardingComplete: (
|
||||
callback: (info: { terminalId: string; profileId?: string; detectedAt: string }) => void
|
||||
) => () => void;
|
||||
onTerminalPendingResume: (callback: (id: string, sessionId?: string) => void) => () => void;
|
||||
onTerminalProfileChanged: (callback: (event: TerminalProfileChangedEvent) => void) => () => void;
|
||||
|
||||
@@ -388,21 +385,6 @@ export const createTerminalAPI = (): TerminalAPI => ({
|
||||
};
|
||||
},
|
||||
|
||||
onTerminalOnboardingComplete: (
|
||||
callback: (info: { terminalId: string; profileId?: string; detectedAt: string }) => void
|
||||
): (() => void) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
info: { terminalId: string; profileId?: string; detectedAt: string }
|
||||
): void => {
|
||||
callback(info);
|
||||
};
|
||||
ipcRenderer.on(IPC_CHANNELS.TERMINAL_ONBOARDING_COMPLETE, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_ONBOARDING_COMPLETE, handler);
|
||||
};
|
||||
},
|
||||
|
||||
onTerminalPendingResume: (
|
||||
callback: (id: string, sessionId?: string) => void
|
||||
): (() => void) => {
|
||||
|
||||
@@ -110,6 +110,20 @@ export function AddAccountDialog({
|
||||
}
|
||||
}, [open, editAccount, provider, billingModelOverride]);
|
||||
|
||||
// Parse DUPLICATE_EMAIL error from backend and show user-friendly toast
|
||||
const handleDuplicateEmailError = useCallback((error: string): boolean => {
|
||||
if (error.startsWith('DUPLICATE_EMAIL:')) {
|
||||
const existingName = error.slice('DUPLICATE_EMAIL:'.length);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('providers.dialog.toast.error'),
|
||||
description: t('providers.dialog.toast.duplicateEmail', { existingName }),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [toast, t]);
|
||||
|
||||
const isOAuthOnly = (provider === 'anthropic' || provider === 'openai') && authType === 'oauth';
|
||||
const isCodexOAuth = provider === 'openai' && authType === 'oauth';
|
||||
|
||||
@@ -190,10 +204,16 @@ export function AddAccountDialog({
|
||||
: t('providers.dialog.toast.added'),
|
||||
description: name.trim(),
|
||||
});
|
||||
} else if (result.error && !handleDuplicateEmailError(result.error)) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('providers.dialog.toast.error'),
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
};
|
||||
autoSave();
|
||||
}, [oauthStatus, isCodexOAuth, accountSaved, name, provider, oauthProfileId, isEditing, editAccount, oauthEmail, addProviderAccount, updateProviderAccount, toast, t, refreshUsageData]);
|
||||
}, [oauthStatus, isCodexOAuth, accountSaved, name, provider, oauthProfileId, isEditing, editAccount, oauthEmail, addProviderAccount, updateProviderAccount, handleDuplicateEmailError, toast, t, refreshUsageData]);
|
||||
|
||||
const canSave = () => {
|
||||
if (!name.trim()) return false;
|
||||
@@ -264,8 +284,14 @@ export function AddAccountDialog({
|
||||
description: name.trim(),
|
||||
});
|
||||
await refreshUsageData();
|
||||
onOpenChange(false);
|
||||
} else if (saveResult.error && !handleDuplicateEmailError(saveResult.error)) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('providers.dialog.toast.error'),
|
||||
description: saveResult.error,
|
||||
});
|
||||
}
|
||||
onOpenChange(false);
|
||||
}, 800);
|
||||
} else {
|
||||
setOauthStatus('error');
|
||||
@@ -318,7 +344,7 @@ export function AddAccountDialog({
|
||||
setOauthStatus('error');
|
||||
setOauthError(err instanceof Error ? err.message : 'Unexpected error');
|
||||
}
|
||||
}, [name, t, toast, isCodexOAuth, isEditing, editAccount, provider, addProviderAccount, updateProviderAccount, onOpenChange, refreshUsageData]);
|
||||
}, [name, t, toast, isCodexOAuth, isEditing, editAccount, provider, addProviderAccount, updateProviderAccount, handleDuplicateEmailError, onOpenChange, refreshUsageData]);
|
||||
|
||||
const handleFallbackTerminal = useCallback(async () => {
|
||||
if (!name.trim()) {
|
||||
@@ -342,7 +368,7 @@ export function AddAccountDialog({
|
||||
createdAt: new Date(),
|
||||
});
|
||||
if (!profileResult.success || !profileResult.data) {
|
||||
toast({ variant: 'destructive', title: 'Failed to create profile' });
|
||||
toast({ variant: 'destructive', title: t('providers.dialog.toast.createProfileFailed') });
|
||||
return;
|
||||
}
|
||||
profileId = profileResult.data.id;
|
||||
@@ -352,7 +378,7 @@ export function AddAccountDialog({
|
||||
// Get terminal config for embedded AuthTerminal
|
||||
const authResult = await window.electronAPI.authenticateClaudeProfile(profileId);
|
||||
if (!authResult.success || !authResult.data) {
|
||||
toast({ variant: 'destructive', title: authResult.error ?? 'Failed to prepare terminal' });
|
||||
toast({ variant: 'destructive', title: authResult.error ?? t('providers.dialog.toast.authPrepareFailed') });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -362,7 +388,7 @@ export function AddAccountDialog({
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: err instanceof Error ? err.message : 'Unexpected error',
|
||||
title: err instanceof Error ? err.message : t('providers.dialog.toast.unexpectedError'),
|
||||
});
|
||||
}
|
||||
}, [name, oauthProfileId, t, toast]);
|
||||
@@ -421,7 +447,7 @@ export function AddAccountDialog({
|
||||
description: name.trim(),
|
||||
});
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
} else if (result.error && !handleDuplicateEmailError(result.error)) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('providers.dialog.toast.error'),
|
||||
|
||||
@@ -104,7 +104,6 @@ export const terminalMock = {
|
||||
onTerminalAuthCreated: () => () => {},
|
||||
onTerminalClaudeBusy: () => () => {},
|
||||
onTerminalClaudeExit: () => () => {},
|
||||
onTerminalOnboardingComplete: () => () => {},
|
||||
onTerminalPendingResume: () => () => {},
|
||||
onTerminalProfileChanged: () => () => {},
|
||||
onTerminalOAuthCodeNeeded: () => () => {},
|
||||
|
||||
@@ -109,7 +109,6 @@ export const IPC_CHANNELS = {
|
||||
TERMINAL_OAUTH_CODE_SUBMIT: 'terminal:oauthCodeSubmit', // User submitted OAuth code to send to terminal
|
||||
TERMINAL_CLAUDE_BUSY: 'terminal:claudeBusy', // Claude Code busy state (for visual indicator)
|
||||
TERMINAL_CLAUDE_EXIT: 'terminal:claudeExit', // Claude Code exited (returned to shell)
|
||||
TERMINAL_ONBOARDING_COMPLETE: 'terminal:onboardingComplete', // Claude onboarding complete (ready for input after login)
|
||||
TERMINAL_PROFILE_CHANGED: 'terminal:profileChanged', // Profile changed, terminals need refresh (main -> renderer)
|
||||
|
||||
// Claude profile management (multi-account support)
|
||||
|
||||
@@ -674,11 +674,9 @@
|
||||
"authFailed": "Authentication failed",
|
||||
"connecting": "Connecting...",
|
||||
"authenticate": "Authenticate: {{profileName}}",
|
||||
"completeSetup": "Complete setup: {{profileName}}",
|
||||
"authenticatedAs": "Authenticated as {{email}}",
|
||||
"authenticated": "Authenticated!",
|
||||
"authError": "Authentication Error",
|
||||
"onboardingMessage": "Token received! Complete the Claude Code setup below - this window will close automatically when done.",
|
||||
"successMessage": "Authentication successful! Closing..."
|
||||
},
|
||||
"profileCreated": {
|
||||
|
||||
@@ -810,7 +810,11 @@
|
||||
"toast": {
|
||||
"added": "Account added",
|
||||
"updated": "Account updated",
|
||||
"error": "Failed to save account"
|
||||
"error": "Failed to save account",
|
||||
"duplicateEmail": "This email is already registered as \"{{existingName}}\"",
|
||||
"createProfileFailed": "Failed to create profile",
|
||||
"authPrepareFailed": "Failed to prepare terminal",
|
||||
"unexpectedError": "Unexpected error"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
|
||||
@@ -674,11 +674,9 @@
|
||||
"authFailed": "Échec de l'authentification",
|
||||
"connecting": "Connexion...",
|
||||
"authenticate": "Authentifier : {{profileName}}",
|
||||
"completeSetup": "Terminer la configuration : {{profileName}}",
|
||||
"authenticatedAs": "Authentifié en tant que {{email}}",
|
||||
"authenticated": "Authentifié !",
|
||||
"authError": "Erreur d'authentification",
|
||||
"onboardingMessage": "Jeton reçu ! Terminez la configuration de Claude Code ci-dessous - cette fenêtre se fermera automatiquement une fois terminé.",
|
||||
"successMessage": "Authentification réussie ! Fermeture..."
|
||||
},
|
||||
"profileCreated": {
|
||||
|
||||
@@ -810,7 +810,11 @@
|
||||
"toast": {
|
||||
"added": "Compte ajouté",
|
||||
"updated": "Compte mis à jour",
|
||||
"error": "Échec de l'enregistrement du compte"
|
||||
"error": "Échec de l'enregistrement du compte",
|
||||
"duplicateEmail": "Cet e-mail est déjà enregistré sous \"{{existingName}}\"",
|
||||
"createProfileFailed": "Échec de la création du profil",
|
||||
"authPrepareFailed": "Échec de la préparation du terminal",
|
||||
"unexpectedError": "Erreur inattendue"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
|
||||
@@ -298,12 +298,6 @@ export interface ElectronAPI {
|
||||
onTerminalClaudeBusy: (callback: (id: string, isBusy: boolean) => void) => () => void;
|
||||
/** Listen for Claude exit (user closed Claude within terminal, returned to shell) */
|
||||
onTerminalClaudeExit: (callback: (id: string) => void) => () => void;
|
||||
/** Listen for onboarding complete (Claude shows ready state after login/onboarding) */
|
||||
onTerminalOnboardingComplete: (callback: (info: {
|
||||
terminalId: string;
|
||||
profileId?: string;
|
||||
detectedAt: string;
|
||||
}) => void) => () => void;
|
||||
/** Listen for pending Claude resume notifications (for deferred resume on tab activation) */
|
||||
onTerminalPendingResume: (callback: (id: string, sessionId?: string) => void) => () => void;
|
||||
/** Listen for profile change events - terminals need to be recreated with new profile env vars */
|
||||
|
||||
Reference in New Issue
Block a user