diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index ef3dd4a9fe..9b9cb84b81 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -15,7 +15,6 @@ import type { ToolRegistry, SandboxConfig, GeminiClient, - AuthType, } from '@google/gemini-cli-core'; import { ApprovalMode, @@ -34,7 +33,6 @@ import type { UpdateObject } from './utils/updateCheck.js'; import { checkForUpdates } from './utils/updateCheck.js'; import { EventEmitter } from 'node:events'; import { updateEventEmitter } from '../utils/updateEventEmitter.js'; -import * as auth from '../config/auth.js'; import * as useTerminalSize from './hooks/useTerminalSize.js'; // Define a more complete mock server config based on actual Config @@ -224,14 +222,12 @@ vi.mock('./hooks/useGeminiStream', () => ({ })), })); -vi.mock('./hooks/useAuthCommand', () => ({ +vi.mock('./auth/useAuth.js', () => ({ useAuthCommand: vi.fn(() => ({ - isAuthDialogOpen: false, - openAuthDialog: vi.fn(), - handleAuthSelect: vi.fn(), - handleAuthHighlight: vi.fn(), - isAuthenticating: false, - cancelAuthentication: vi.fn(), + authState: 'authenticated', + setAuthState: vi.fn(), + authError: null, + onAuthError: vi.fn(), })), })); @@ -1189,60 +1185,6 @@ describe('App UI', () => { }); }); - describe('auth validation', () => { - it('should call validateAuthMethod when useExternalAuth is false', async () => { - const validateAuthMethodSpy = vi.spyOn(auth, 'validateAuthMethod'); - mockSettings = createMockSettings({ - workspace: { - security: { - auth: { - selectedType: 'USE_GEMINI' as AuthType, - useExternal: false, - }, - }, - ui: { theme: 'Default' }, - }, - }); - - const { unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - - expect(validateAuthMethodSpy).toHaveBeenCalledWith('USE_GEMINI'); - }); - - it('should NOT call validateAuthMethod when useExternalAuth is true', async () => { - const validateAuthMethodSpy = vi.spyOn(auth, 'validateAuthMethod'); - mockSettings = createMockSettings({ - workspace: { - security: { - auth: { - selectedType: 'USE_GEMINI' as AuthType, - useExternal: true, - }, - }, - ui: { theme: 'Default' }, - }, - }); - - const { unmount } = renderWithProviders( - , - ); - currentUnmount = unmount; - - expect(validateAuthMethodSpy).not.toHaveBeenCalled(); - }); - }); - describe('when in a narrow terminal', () => { it('should render with a column layout', () => { vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({ diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index dc1a2c9cd3..28bf124895 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -15,6 +15,7 @@ import { useStdout, } from 'ink'; import { + AuthState, StreamingState, type HistoryItem, MessageType, @@ -25,7 +26,7 @@ import { useTerminalSize } from './hooks/useTerminalSize.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useThemeCommand } from './hooks/useThemeCommand.js'; -import { useAuthCommand } from './hooks/useAuthCommand.js'; +import { useAuthCommand } from './auth/useAuth.js'; import { useFolderTrust } from './hooks/useFolderTrust.js'; import { useIdeTrustListener } from './hooks/useIdeTrustListener.js'; import { useEditorSettings } from './hooks/useEditorSettings.js'; @@ -40,8 +41,6 @@ import { ShellModeIndicator } from './components/ShellModeIndicator.js'; import { InputPrompt } from './components/InputPrompt.js'; import { Footer } from './components/Footer.js'; import { ThemeDialog } from './components/ThemeDialog.js'; -import { AuthDialog } from './components/AuthDialog.js'; -import { AuthInProgress } from './components/AuthInProgress.js'; import { EditorSettingsDialog } from './components/EditorSettingsDialog.js'; import { FolderTrustDialog } from './components/FolderTrustDialog.js'; import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js'; @@ -82,7 +81,6 @@ import { } from '@google/gemini-cli-core'; import type { IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js'; import { IdeIntegrationNudge } from './IdeIntegrationNudge.js'; -import { validateAuthMethod } from '../config/auth.js'; import { useLogger } from './hooks/useLogger.js'; import { StreamingContext } from './contexts/StreamingContext.js'; import { @@ -116,6 +114,8 @@ import { isNarrowWidth } from './utils/isNarrowWidth.js'; import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js'; import { WorkspaceMigrationDialog } from './components/WorkspaceMigrationDialog.js'; import { isWorkspaceTrusted } from '../config/trustedFolders.js'; +import { AuthInProgress } from './auth/AuthInProgress.js'; +import { AuthDialog } from './auth/AuthDialog.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; // Maximum number of queued messages to display in UI to prevent performance issues @@ -217,7 +217,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { const [geminiMdFileCount, setGeminiMdFileCount] = useState(0); const [debugMessage, setDebugMessage] = useState(''); const [themeError, setThemeError] = useState(null); - const [authError, setAuthError] = useState(null); + const [editorError, setEditorError] = useState(null); const [footerHeight, setFooterHeight] = useState(0); const [corgiMode, setCorgiMode] = useState(false); @@ -338,52 +338,18 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { { isActive: showIdeRestartPrompt }, ); - const { - isAuthDialogOpen, - openAuthDialog, - handleAuthSelect, - isAuthenticating, - cancelAuthentication, - } = useAuthCommand(settings, setAuthError, config); - - useEffect(() => { - if ( - settings.merged.security?.auth?.enforcedType && - settings.merged.security?.auth.selectedType && - settings.merged.security?.auth.enforcedType !== - settings.merged.security?.auth.selectedType - ) { - setAuthError( - `Authentication is enforced to be ${settings.merged.security?.auth.enforcedType}, but you are currently using ${settings.merged.security?.auth.selectedType}.`, - ); - openAuthDialog(); - } else if ( - settings.merged.security?.auth?.selectedType && - !settings.merged.security?.auth?.useExternal - ) { - const error = validateAuthMethod( - settings.merged.security.auth.selectedType, - ); - if (error) { - setAuthError(error); - openAuthDialog(); - } - } - }, [ - settings.merged.security?.auth?.selectedType, - settings.merged.security?.auth?.enforcedType, - settings.merged.security?.auth?.useExternal, - openAuthDialog, - setAuthError, - ]); + const { authState, setAuthState, authError, onAuthError } = useAuthCommand( + settings, + config, + ); // Sync user tier from config when authentication changes useEffect(() => { // Only sync when not currently authenticating - if (!isAuthenticating) { + if (authState === AuthState.Authenticated) { setUserTier(config.getGeminiClient()?.getUserTier()); } - }, [config, isAuthenticating]); + }, [config, authState]); const { isEditorDialogOpen, @@ -622,11 +588,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { return editorType as EditorType; }, [settings, openEditorDialog]); - const onAuthError = useCallback(() => { - setAuthError('reauth required'); - openAuthDialog(); - }, [openAuthDialog, setAuthError]); - // Core hooks and processors const { vimEnabled: vimModeEnabled, @@ -650,7 +611,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { refreshStatic, setDebugMessage, openThemeDialog, - openAuthDialog, + setAuthState, openEditorDialog, toggleCorgiMode, setQuittingMessages, @@ -843,7 +804,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { handleSlashCommand('/ide status'); } else if (keyMatchers[Command.QUIT](key)) { // When authenticating, let AuthInProgress component handle Ctrl+C. - if (isAuthenticating) { + if (authState === AuthState.Unauthenticated) { return; } if (!ctrlCPressedOnce) { @@ -879,7 +840,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { setCtrlDPressedOnce, ctrlDTimerRef, handleSlashCommand, - isAuthenticating, + authState, cancelOngoingRequest, settings.merged.general?.debugKeystrokeLogging, ], @@ -981,8 +942,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { if ( initialPrompt && !initialPromptSubmitted.current && - !isAuthenticating && - !isAuthDialogOpen && + authState === AuthState.Authenticated && !isThemeDialogOpen && !isEditorDialogOpen && !showPrivacyNotice && @@ -994,8 +954,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { }, [ initialPrompt, submitQuery, - isAuthenticating, - isAuthDialogOpen, + authState, isThemeDialogOpen, isEditorDialogOpen, showPrivacyNotice, @@ -1136,7 +1095,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { if (choice === 'auth') { cancelOngoingRequest?.(); - openAuthDialog(); + setAuthState(AuthState.Updating); } else { addItem( { @@ -1209,13 +1168,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { onRestartRequest={() => process.exit(0)} /> - ) : isAuthenticating ? ( + ) : authState === AuthState.Unauthenticated ? ( <> { - setAuthError('Authentication timed out. Please try again.'); - cancelAuthentication(); - openAuthDialog(); + onAuthError('Authentication timed out. Please try again.'); }} /> {showErrorDetails && ( @@ -1233,12 +1190,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { )} - ) : isAuthDialogOpen ? ( + ) : authState === AuthState.Updating ? ( ) : isEditorDialogOpen ? ( diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx new file mode 100644 index 0000000000..1c3a017352 --- /dev/null +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -0,0 +1,258 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../test-utils/render.js'; +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import { AuthDialog } from './AuthDialog.js'; +import { AuthType, type Config } from '@google/gemini-cli-core'; +import type { LoadedSettings } from '../../config/settings.js'; +import { SettingScope } from '../../config/settings.js'; +import { AuthState } from '../types.js'; +import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { validateAuthMethodWithSettings } from './useAuth.js'; +import { runExitCleanup } from '../../utils/cleanup.js'; +import { clearCachedCredentialFile } from '@google/gemini-cli-core'; +import { Text } from 'ink'; + +// Mocks +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + clearCachedCredentialFile: vi.fn(), + }; +}); + +vi.mock('../../utils/cleanup.js', () => ({ + runExitCleanup: vi.fn(), +})); + +vi.mock('./useAuth.js', () => ({ + validateAuthMethodWithSettings: vi.fn(), +})); + +vi.mock('../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + +vi.mock('../components/shared/RadioButtonSelect.js', () => ({ + RadioButtonSelect: vi.fn(({ items, initialIndex }) => ( + <> + {items.map((item: { value: string; label: string }, index: number) => ( + + {index === initialIndex ? '(selected)' : '(not selected)'}{' '} + {item.label} + + ))} + + )), +})); + +const mockedUseKeypress = useKeypress as Mock; +const mockedRadioButtonSelect = RadioButtonSelect as Mock; +const mockedValidateAuthMethod = validateAuthMethodWithSettings as Mock; +const mockedRunExitCleanup = runExitCleanup as Mock; +const mockedClearCachedCredentialFile = clearCachedCredentialFile as Mock; + +describe('AuthDialog', () => { + let props: { + config: Config; + settings: LoadedSettings; + setAuthState: (state: AuthState) => void; + authError: string | null; + onAuthError: (error: string) => void; + }; + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetAllMocks(); + process.env = {}; + + props = { + config: { + isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false), + } as unknown as Config, + settings: { + merged: { + security: { + auth: {}, + }, + }, + setValue: vi.fn(), + } as unknown as LoadedSettings, + setAuthState: vi.fn(), + authError: null, + onAuthError: vi.fn(), + }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('shows Cloud Shell option when in Cloud Shell environment', () => { + process.env['CLOUD_SHELL'] = 'true'; + renderWithProviders(); + const items = mockedRadioButtonSelect.mock.calls[0][0].items; + expect(items).toContainEqual({ + label: 'Use Cloud Shell user credentials', + value: AuthType.CLOUD_SHELL, + }); + }); + + it('filters auth types when enforcedType is set', () => { + props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI; + renderWithProviders(); + const items = mockedRadioButtonSelect.mock.calls[0][0].items; + expect(items).toHaveLength(1); + expect(items[0].value).toBe(AuthType.USE_GEMINI); + }); + + it('sets initial index to 0 when enforcedType is set', () => { + props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI; + renderWithProviders(); + const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; + expect(initialIndex).toBe(0); + }); + + it('selects initial auth type from settings', () => { + props.settings.merged.security!.auth!.selectedType = AuthType.USE_VERTEX_AI; + renderWithProviders(); + const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; + expect(items[initialIndex].value).toBe(AuthType.USE_VERTEX_AI); + }); + + it('selects initial auth type from GEMINI_DEFAULT_AUTH_TYPE env var', () => { + process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI; + renderWithProviders(); + const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; + expect(items[initialIndex].value).toBe(AuthType.USE_GEMINI); + }); + + it('selects initial auth type from GEMINI_API_KEY env var', () => { + process.env['GEMINI_API_KEY'] = 'test-key'; + renderWithProviders(); + const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; + expect(items[initialIndex].value).toBe(AuthType.USE_GEMINI); + }); + + it('defaults to Login with Google', () => { + renderWithProviders(); + const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; + expect(items[initialIndex].value).toBe(AuthType.LOGIN_WITH_GOOGLE); + }); + + describe('handleAuthSelect', () => { + it('calls onAuthError if validation fails', () => { + mockedValidateAuthMethod.mockReturnValue('Invalid method'); + renderWithProviders(); + const { onSelect: handleAuthSelect } = + mockedRadioButtonSelect.mock.calls[0][0]; + handleAuthSelect(AuthType.USE_GEMINI); + + expect(mockedValidateAuthMethod).toHaveBeenCalledWith( + AuthType.USE_GEMINI, + props.settings, + ); + expect(props.onAuthError).toHaveBeenCalledWith('Invalid method'); + expect(props.settings.setValue).not.toHaveBeenCalled(); + }); + + it('calls onSelect if validation passes', async () => { + mockedValidateAuthMethod.mockReturnValue(null); + renderWithProviders(); + const { onSelect: handleAuthSelect } = + mockedRadioButtonSelect.mock.calls[0][0]; + await handleAuthSelect(AuthType.USE_GEMINI); + + expect(mockedValidateAuthMethod).toHaveBeenCalledWith( + AuthType.USE_GEMINI, + props.settings, + ); + expect(props.onAuthError).not.toHaveBeenCalled(); + expect(mockedClearCachedCredentialFile).toHaveBeenCalled(); + expect(props.settings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'security.auth.selectedType', + AuthType.USE_GEMINI, + ); + expect(props.setAuthState).toHaveBeenCalledWith( + AuthState.Unauthenticated, + ); + }); + + it('exits process for Login with Google when browser is suppressed', async () => { + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true); + mockedValidateAuthMethod.mockReturnValue(null); + + renderWithProviders(); + const { onSelect: handleAuthSelect } = + mockedRadioButtonSelect.mock.calls[0][0]; + await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE); + + expect(mockedRunExitCleanup).toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Please restart Gemini CLI'), + ); + expect(exitSpy).toHaveBeenCalledWith(0); + + exitSpy.mockRestore(); + logSpy.mockRestore(); + }); + }); + + it('displays authError when provided', () => { + props.authError = 'Something went wrong'; + const { lastFrame } = renderWithProviders(); + expect(lastFrame()).toContain('Something went wrong'); + }); + + describe('useKeypress', () => { + it('does nothing on escape if authError is present', () => { + props.authError = 'Some error'; + renderWithProviders(); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + keypressHandler({ name: 'escape' }); + expect(props.onAuthError).not.toHaveBeenCalled(); + expect(props.setAuthState).not.toHaveBeenCalled(); + }); + + it('calls onAuthError on escape if no auth method is set', () => { + props.settings.merged.security!.auth!.selectedType = undefined; + renderWithProviders(); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + keypressHandler({ name: 'escape' }); + expect(props.onAuthError).toHaveBeenCalledWith( + 'You must select an auth method to proceed. Press Ctrl+C twice to exit.', + ); + }); + + it('calls onSelect(undefined) on escape if auth method is set', () => { + props.settings.merged.security!.auth!.selectedType = AuthType.USE_GEMINI; + renderWithProviders(); + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + keypressHandler({ name: 'escape' }); + expect(props.setAuthState).toHaveBeenCalledWith( + AuthState.Unauthenticated, + ); + expect(props.settings.setValue).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx similarity index 64% rename from packages/cli/src/ui/components/AuthDialog.tsx rename to packages/cli/src/ui/auth/AuthDialog.tsx index 719aa1574f..5a3a01ca56 100644 --- a/packages/cli/src/ui/components/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -5,63 +5,37 @@ */ import type React from 'react'; -import { useState } from 'react'; +import { useCallback } from 'react'; import { Box, Text } from 'ink'; import { Colors } from '../colors.js'; -import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; import type { LoadedSettings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; -import { AuthType } from '@google/gemini-cli-core'; -import { validateAuthMethod } from '../../config/auth.js'; +import { + AuthType, + clearCachedCredentialFile, + type Config, +} from '@google/gemini-cli-core'; import { useKeypress } from '../hooks/useKeypress.js'; +import { AuthState } from '../types.js'; +import { runExitCleanup } from '../../utils/cleanup.js'; +import { validateAuthMethodWithSettings } from './useAuth.js'; interface AuthDialogProps { - onSelect: (authMethod: AuthType | undefined, scope: SettingScope) => void; + config: Config; settings: LoadedSettings; - initialErrorMessage?: string | null; -} - -function parseDefaultAuthType( - defaultAuthType: string | undefined, -): AuthType | null { - if ( - defaultAuthType && - Object.values(AuthType).includes(defaultAuthType as AuthType) - ) { - return defaultAuthType as AuthType; - } - return null; + setAuthState: (state: AuthState) => void; + authError: string | null; + onAuthError: (error: string) => void; } export function AuthDialog({ - onSelect, + config, settings, - initialErrorMessage, + setAuthState, + authError, + onAuthError, }: AuthDialogProps): React.JSX.Element { - const [errorMessage, setErrorMessage] = useState(() => { - if (initialErrorMessage) { - return initialErrorMessage; - } - - const defaultAuthType = parseDefaultAuthType( - process.env['GEMINI_DEFAULT_AUTH_TYPE'], - ); - - if (process.env['GEMINI_DEFAULT_AUTH_TYPE'] && defaultAuthType === null) { - return ( - `Invalid value for GEMINI_DEFAULT_AUTH_TYPE: "${process.env['GEMINI_DEFAULT_AUTH_TYPE']}". ` + - `Valid values are: ${Object.values(AuthType).join(', ')}.` - ); - } - - if ( - process.env['GEMINI_API_KEY'] && - (!defaultAuthType || defaultAuthType === AuthType.USE_GEMINI) - ) { - return 'Existing API key detected (GEMINI_API_KEY). Select "Gemini API Key" option to use it.'; - } - return null; - }); let items = [ { label: 'Login with Google', @@ -88,14 +62,20 @@ export function AuthDialog({ ); } + let defaultAuthType = null; + const defaultAuthTypeEnv = process.env['GEMINI_DEFAULT_AUTH_TYPE']; + if ( + defaultAuthTypeEnv && + Object.values(AuthType).includes(defaultAuthTypeEnv as AuthType) + ) { + defaultAuthType = defaultAuthTypeEnv as AuthType; + } + let initialAuthIndex = items.findIndex((item) => { if (settings.merged.security?.auth?.selectedType) { return item.value === settings.merged.security.auth.selectedType; } - const defaultAuthType = parseDefaultAuthType( - process.env['GEMINI_DEFAULT_AUTH_TYPE'], - ); if (defaultAuthType) { return item.value === defaultAuthType; } @@ -110,12 +90,37 @@ export function AuthDialog({ initialAuthIndex = 0; } + const onSelect = useCallback( + async (authType: AuthType | undefined, scope: SettingScope) => { + if (authType) { + await clearCachedCredentialFile(); + + settings.setValue(scope, 'security.auth.selectedType', authType); + if ( + authType === AuthType.LOGIN_WITH_GOOGLE && + config.isBrowserLaunchSuppressed() + ) { + runExitCleanup(); + console.log( + ` +---------------------------------------------------------------- +Logging in with Google... Please restart Gemini CLI to continue. +---------------------------------------------------------------- + `, + ); + process.exit(0); + } + } + setAuthState(AuthState.Unauthenticated); + }, + [settings, config, setAuthState], + ); + const handleAuthSelect = (authMethod: AuthType) => { - const error = validateAuthMethod(authMethod); + const error = validateAuthMethodWithSettings(authMethod, settings); if (error) { - setErrorMessage(error); + onAuthError(error); } else { - setErrorMessage(null); onSelect(authMethod, SettingScope.User); } }; @@ -125,12 +130,12 @@ export function AuthDialog({ if (key.name === 'escape') { // Prevent exit if there is an error message. // This means they user is not authenticated yet. - if (errorMessage) { + if (authError) { return; } if (settings.merged.security?.auth?.selectedType === undefined) { // Prevent exiting if no auth method is set - setErrorMessage( + onAuthError( 'You must select an auth method to proceed. Press Ctrl+C twice to exit.', ); return; @@ -160,9 +165,9 @@ export function AuthDialog({ onSelect={handleAuthSelect} /> - {errorMessage && ( + {authError && ( - {errorMessage} + {authError} )} diff --git a/packages/cli/src/ui/components/AuthInProgress.tsx b/packages/cli/src/ui/auth/AuthInProgress.tsx similarity index 100% rename from packages/cli/src/ui/components/AuthInProgress.tsx rename to packages/cli/src/ui/auth/AuthInProgress.tsx diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts new file mode 100644 index 0000000000..69898f64a4 --- /dev/null +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useCallback } from 'react'; +import type { LoadedSettings } from '../../config/settings.js'; +import { AuthType, type Config } from '@google/gemini-cli-core'; +import { getErrorMessage } from '@google/gemini-cli-core'; +import { AuthState } from '../types.js'; +import { validateAuthMethod } from '../../config/auth.js'; + +export function validateAuthMethodWithSettings( + authType: AuthType, + settings: LoadedSettings, +): string | null { + const enforcedType = settings.merged.security?.auth?.enforcedType; + if (enforcedType && enforcedType !== authType) { + return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`; + } + if (settings.merged.security?.auth?.useExternal) { + return null; + } + return validateAuthMethod(authType); +} + +export const useAuthCommand = (settings: LoadedSettings, config: Config) => { + const [authState, setAuthState] = useState( + AuthState.Unauthenticated, + ); + + const [authError, setAuthError] = useState(null); + + const onAuthError = useCallback( + (error: string) => { + setAuthError(error); + setAuthState(AuthState.Updating); + }, + [setAuthError, setAuthState], + ); + + useEffect(() => { + (async () => { + if (authState !== AuthState.Unauthenticated) { + return; + } + + const authType = settings.merged.security?.auth?.selectedType; + if (!authType) { + if (process.env['GEMINI_API_KEY']) { + onAuthError( + 'Existing API key detected (GEMINI_API_KEY). Select "Gemini API Key" option to use it.', + ); + } else { + onAuthError('No authentication method selected.'); + } + return; + } + const error = validateAuthMethodWithSettings(authType, settings); + if (error) { + onAuthError(error); + return; + } + + const defaultAuthType = process.env['GEMINI_DEFAULT_AUTH_TYPE']; + if ( + defaultAuthType && + !Object.values(AuthType).includes(defaultAuthType as AuthType) + ) { + onAuthError( + `Invalid value for GEMINI_DEFAULT_AUTH_TYPE: "${defaultAuthType}". ` + + `Valid values are: ${Object.values(AuthType).join(', ')}.`, + ); + return; + } + + try { + await config.refreshAuth(authType); + + console.log(`Authenticated via "${authType}".`); + setAuthError(null); + setAuthState(AuthState.Authenticated); + } catch (e) { + onAuthError(`Failed to login. Message: ${getErrorMessage(e)}`); + } + })(); + }, [settings, config, authState, setAuthState, setAuthError, onAuthError]); + + return { + authState, + setAuthState, + authError, + onAuthError, + }; +}; diff --git a/packages/cli/src/ui/components/AuthDialog.test.tsx b/packages/cli/src/ui/components/AuthDialog.test.tsx deleted file mode 100644 index abe68b3755..0000000000 --- a/packages/cli/src/ui/components/AuthDialog.test.tsx +++ /dev/null @@ -1,473 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { AuthDialog } from './AuthDialog.js'; -import { LoadedSettings, SettingScope } from '../../config/settings.js'; -import { AuthType } from '@google/gemini-cli-core'; -import { renderWithProviders } from '../../test-utils/render.js'; - -describe('AuthDialog', () => { - const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); - - let originalEnv: NodeJS.ProcessEnv; - - beforeEach(() => { - originalEnv = { ...process.env }; - process.env['GEMINI_API_KEY'] = ''; - process.env['GEMINI_DEFAULT_AUTH_TYPE'] = ''; - vi.clearAllMocks(); - }); - - afterEach(() => { - process.env = originalEnv; - }); - - it('should show an error if the initial auth type is invalid', () => { - process.env['GEMINI_API_KEY'] = ''; - - const settings: LoadedSettings = new LoadedSettings( - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - { - settings: {}, - path: '', - }, - { - settings: { - security: { - auth: { - selectedType: AuthType.USE_GEMINI, - }, - }, - }, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - true, - new Set(), - ); - - const { lastFrame } = renderWithProviders( - {}} - settings={settings} - initialErrorMessage="GEMINI_API_KEY environment variable not found" - />, - ); - - expect(lastFrame()).toContain( - 'GEMINI_API_KEY environment variable not found', - ); - }); - - describe('GEMINI_API_KEY environment variable', () => { - it('should detect GEMINI_API_KEY environment variable', () => { - process.env['GEMINI_API_KEY'] = 'foobar'; - - const settings: LoadedSettings = new LoadedSettings( - { - settings: { - security: { auth: { selectedType: undefined } }, - ui: { customThemes: {} }, - mcpServers: {}, - }, - path: '', - }, - { - settings: {}, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - true, - new Set(), - ); - - const { lastFrame } = renderWithProviders( - {}} settings={settings} />, - ); - - expect(lastFrame()).toContain( - 'Existing API key detected (GEMINI_API_KEY)', - ); - }); - - it('should not show the GEMINI_API_KEY message if GEMINI_DEFAULT_AUTH_TYPE is set to something else', () => { - process.env['GEMINI_API_KEY'] = 'foobar'; - process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.LOGIN_WITH_GOOGLE; - - const settings: LoadedSettings = new LoadedSettings( - { - settings: { - security: { auth: { selectedType: undefined } }, - ui: { customThemes: {} }, - mcpServers: {}, - }, - path: '', - }, - { - settings: {}, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - true, - new Set(), - ); - - const { lastFrame } = renderWithProviders( - {}} settings={settings} />, - ); - - expect(lastFrame()).not.toContain( - 'Existing API key detected (GEMINI_API_KEY)', - ); - }); - - it('should show the GEMINI_API_KEY message if GEMINI_DEFAULT_AUTH_TYPE is set to use api key', () => { - process.env['GEMINI_API_KEY'] = 'foobar'; - process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI; - - const settings: LoadedSettings = new LoadedSettings( - { - settings: { - security: { auth: { selectedType: undefined } }, - ui: { customThemes: {} }, - mcpServers: {}, - }, - path: '', - }, - { - settings: {}, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - true, - new Set(), - ); - - const { lastFrame } = renderWithProviders( - {}} settings={settings} />, - ); - - expect(lastFrame()).toContain( - 'Existing API key detected (GEMINI_API_KEY)', - ); - }); - }); - - describe('GEMINI_DEFAULT_AUTH_TYPE environment variable', () => { - it('should select the auth type specified by GEMINI_DEFAULT_AUTH_TYPE', () => { - process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.LOGIN_WITH_GOOGLE; - - const settings: LoadedSettings = new LoadedSettings( - { - settings: { - security: { auth: { selectedType: undefined } }, - ui: { customThemes: {} }, - mcpServers: {}, - }, - path: '', - }, - { - settings: {}, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - true, - new Set(), - ); - - const { lastFrame } = renderWithProviders( - {}} settings={settings} />, - ); - - // This is a bit brittle, but it's the best way to check which item is selected. - expect(lastFrame()).toContain('● 1. Login with Google'); - }); - - it('should fall back to default if GEMINI_DEFAULT_AUTH_TYPE is not set', () => { - const settings: LoadedSettings = new LoadedSettings( - { - settings: { - security: { auth: { selectedType: undefined } }, - ui: { customThemes: {} }, - mcpServers: {}, - }, - path: '', - }, - { - settings: {}, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - true, - new Set(), - ); - - const { lastFrame } = renderWithProviders( - {}} settings={settings} />, - ); - - // Default is LOGIN_WITH_GOOGLE - expect(lastFrame()).toContain('● 1. Login with Google'); - }); - - it('should show an error and fall back to default if GEMINI_DEFAULT_AUTH_TYPE is invalid', () => { - process.env['GEMINI_DEFAULT_AUTH_TYPE'] = 'invalid-auth-type'; - - const settings: LoadedSettings = new LoadedSettings( - { - settings: { - security: { auth: { selectedType: undefined } }, - ui: { customThemes: {} }, - mcpServers: {}, - }, - path: '', - }, - { - settings: {}, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - true, - new Set(), - ); - - const { lastFrame } = renderWithProviders( - {}} settings={settings} />, - ); - - expect(lastFrame()).toContain( - 'Invalid value for GEMINI_DEFAULT_AUTH_TYPE: "invalid-auth-type"', - ); - - // Default is LOGIN_WITH_GOOGLE - expect(lastFrame()).toContain('● 1. Login with Google'); - }); - }); - - it('should prevent exiting when no auth method is selected and show error message', async () => { - const onSelect = vi.fn(); - const settings: LoadedSettings = new LoadedSettings( - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - { - settings: {}, - path: '', - }, - { - settings: { - security: { auth: { selectedType: undefined } }, - ui: { customThemes: {} }, - mcpServers: {}, - }, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - true, - new Set(), - ); - - const { lastFrame, stdin, unmount } = renderWithProviders( - , - ); - await wait(); - - // Simulate pressing escape key - stdin.write('\u001b'); // ESC key - await wait(); - - // Should show error message instead of calling onSelect - expect(lastFrame()).toContain( - 'You must select an auth method to proceed. Press Ctrl+C twice to exit.', - ); - expect(onSelect).not.toHaveBeenCalled(); - unmount(); - }); - - it('should not exit if there is already an error message', async () => { - const onSelect = vi.fn(); - const settings: LoadedSettings = new LoadedSettings( - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - { - settings: {}, - path: '', - }, - { - settings: { - security: { auth: { selectedType: undefined } }, - ui: { customThemes: {} }, - mcpServers: {}, - }, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - true, - new Set(), - ); - - const { lastFrame, stdin, unmount } = renderWithProviders( - , - ); - await wait(); - - expect(lastFrame()).toContain('Initial error'); - - // Simulate pressing escape key - stdin.write('\u001b'); // ESC key - await wait(); - - // Should not call onSelect - expect(onSelect).not.toHaveBeenCalled(); - unmount(); - }); - - it('should allow exiting when auth method is already selected', async () => { - const onSelect = vi.fn(); - const settings: LoadedSettings = new LoadedSettings( - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - { - settings: {}, - path: '', - }, - { - settings: { - security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } }, - ui: { customThemes: {} }, - mcpServers: {}, - }, - path: '', - }, - { - settings: { ui: { customThemes: {} }, mcpServers: {} }, - path: '', - }, - true, - new Set(), - ); - - const { stdin, unmount } = renderWithProviders( - , - ); - await wait(); - - // Simulate pressing escape key - stdin.write('\u001b'); // ESC key - await wait(); - - // Should call onSelect with undefined to exit - expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User); - unmount(); - }); - - describe('enforcedAuthType', () => { - it('should display the enforced auth type message if enforcedAuthType is set', () => { - const settings: LoadedSettings = new LoadedSettings( - { - settings: { - security: { - auth: { - enforcedType: AuthType.USE_VERTEX_AI, - }, - }, - }, - path: '', - }, - { - settings: { - security: { - auth: { - selectedType: AuthType.USE_VERTEX_AI, - }, - }, - }, - path: '', - }, - { - settings: {}, - path: '', - }, - { - settings: {}, - path: '', - }, - true, - new Set(), - ); - - const { lastFrame } = renderWithProviders( - {}} settings={settings} />, - ); - - expect(lastFrame()).toContain('1. Vertex AI'); - }); - }); -}); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index a7c3ce583f..1587af93b9 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -21,11 +21,12 @@ import { } from '@google/gemini-cli-core'; import { useSessionStats } from '../contexts/SessionContext.js'; import { runExitCleanup } from '../../utils/cleanup.js'; -import type { - Message, - HistoryItemWithoutId, - HistoryItem, - SlashCommandProcessorResult, +import { + type Message, + type HistoryItemWithoutId, + type HistoryItem, + type SlashCommandProcessorResult, + AuthState, } from '../types.js'; import { MessageType } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; @@ -47,7 +48,7 @@ export const useSlashCommandProcessor = ( refreshStatic: () => void, onDebugMessage: (message: string) => void, openThemeDialog: () => void, - openAuthDialog: () => void, + setAuthState: (state: AuthState) => void, openEditorDialog: () => void, toggleCorgiMode: () => void, setQuittingMessages: (message: HistoryItem[]) => void, @@ -375,7 +376,7 @@ export const useSlashCommandProcessor = ( case 'dialog': switch (result.dialog) { case 'auth': - openAuthDialog(); + setAuthState(AuthState.Updating); return { type: 'handled' }; case 'theme': openThemeDialog(); @@ -554,7 +555,7 @@ export const useSlashCommandProcessor = ( [ config, addItem, - openAuthDialog, + setAuthState, commands, commandContext, addMessage, diff --git a/packages/cli/src/ui/hooks/useAuthCommand.ts b/packages/cli/src/ui/hooks/useAuthCommand.ts deleted file mode 100644 index f1fdcd9118..0000000000 --- a/packages/cli/src/ui/hooks/useAuthCommand.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useState, useCallback, useEffect } from 'react'; -import type { LoadedSettings, SettingScope } from '../../config/settings.js'; -import { AuthType, type Config } from '@google/gemini-cli-core'; -import { - clearCachedCredentialFile, - getErrorMessage, -} from '@google/gemini-cli-core'; -import { runExitCleanup } from '../../utils/cleanup.js'; - -export const useAuthCommand = ( - settings: LoadedSettings, - setAuthError: (error: string | null) => void, - config: Config, -) => { - const [isAuthDialogOpen, setIsAuthDialogOpen] = useState( - settings.merged.security?.auth?.selectedType === undefined, - ); - - const openAuthDialog = useCallback(() => { - setIsAuthDialogOpen(true); - }, []); - - const [isAuthenticating, setIsAuthenticating] = useState(false); - - useEffect(() => { - const authFlow = async () => { - const authType = settings.merged.security?.auth?.selectedType; - if (isAuthDialogOpen || !authType) { - return; - } - - try { - setIsAuthenticating(true); - await config.refreshAuth(authType); - console.log(`Authenticated via "${authType}".`); - } catch (e) { - setAuthError(`Failed to login. Message: ${getErrorMessage(e)}`); - openAuthDialog(); - } finally { - setIsAuthenticating(false); - } - }; - - void authFlow(); - }, [isAuthDialogOpen, settings, config, setAuthError, openAuthDialog]); - - const handleAuthSelect = useCallback( - async (authType: AuthType | undefined, scope: SettingScope) => { - if (authType) { - await clearCachedCredentialFile(); - - settings.setValue(scope, 'security.auth.selectedType', authType); - if ( - authType === AuthType.LOGIN_WITH_GOOGLE && - config.isBrowserLaunchSuppressed() - ) { - runExitCleanup(); - console.log( - ` ----------------------------------------------------------------- -Logging in with Google... Please restart Gemini CLI to continue. ----------------------------------------------------------------- - `, - ); - process.exit(0); - } - } - setIsAuthDialogOpen(false); - setAuthError(null); - }, - [settings, setAuthError, config], - ); - - const cancelAuthentication = useCallback(() => { - setIsAuthenticating(false); - }, []); - - return { - isAuthDialogOpen, - openAuthDialog, - handleAuthSelect, - isAuthenticating, - cancelAuthentication, - }; -}; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 7e325c3c1b..edaf33d547 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -95,7 +95,7 @@ export const useGeminiStream = ( ) => Promise, shellModeActive: boolean, getPreferredEditor: () => EditorType | undefined, - onAuthError: () => void, + onAuthError: (error: string) => void, performMemoryRefresh: () => Promise, modelSwitchedFromQuotaError: boolean, setModelSwitchedFromQuotaError: React.Dispatch>, @@ -751,7 +751,7 @@ export const useGeminiStream = ( } } catch (error: unknown) { if (error instanceof UnauthorizedError) { - onAuthError(); + onAuthError('Session expired or is unauthorized.'); } else if (!isNodeError(error) || error.name !== 'AbortError') { addItem( { diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 5461b521a5..ca222f4d6b 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -11,6 +11,15 @@ import type { } from '@google/gemini-cli-core'; import type { PartListUnion } from '@google/genai'; +export enum AuthState { + // Attemtping to authenticate or re-authenticate + Unauthenticated = 'unauthenticated', + // Auth dialog is open for user to select auth method + Updating = 'updating', + // Successfully authenticated + Authenticated = 'authenticated', +} + // Only defining the state enum needed by the UI export enum StreamingState { Idle = 'idle',