Refactor Authentication Components and Hooks (#7750)

This commit is contained in:
Tommaso Sciortino
2025-09-05 15:35:41 -07:00
committed by GitHub
parent d8dbe6271f
commit 7239c5cd9a
11 changed files with 462 additions and 756 deletions

View File

@@ -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(
<AuthDialog
onSelect={() => {}}
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(
<AuthDialog onSelect={() => {}} 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(
<AuthDialog onSelect={() => {}} 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(
<AuthDialog onSelect={() => {}} 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(
<AuthDialog onSelect={() => {}} 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(
<AuthDialog onSelect={() => {}} 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(
<AuthDialog onSelect={() => {}} 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(
<AuthDialog onSelect={onSelect} settings={settings} />,
);
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(
<AuthDialog
onSelect={onSelect}
settings={settings}
initialErrorMessage="Initial error"
/>,
);
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(
<AuthDialog onSelect={onSelect} settings={settings} />,
);
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(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
expect(lastFrame()).toContain('1. Vertex AI');
});
});
});

View File

@@ -1,183 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState } from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { RadioButtonSelect } from './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 { useKeypress } from '../hooks/useKeypress.js';
interface AuthDialogProps {
onSelect: (authMethod: AuthType | undefined, scope: SettingScope) => void;
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;
}
export function AuthDialog({
onSelect,
settings,
initialErrorMessage,
}: AuthDialogProps): React.JSX.Element {
const [errorMessage, setErrorMessage] = useState<string | null>(() => {
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',
value: AuthType.LOGIN_WITH_GOOGLE,
},
...(process.env['CLOUD_SHELL'] === 'true'
? [
{
label: 'Use Cloud Shell user credentials',
value: AuthType.CLOUD_SHELL,
},
]
: []),
{
label: 'Use Gemini API Key',
value: AuthType.USE_GEMINI,
},
{ label: 'Vertex AI', value: AuthType.USE_VERTEX_AI },
];
if (settings.merged.security?.auth?.enforcedType) {
items = items.filter(
(item) => item.value === settings.merged.security?.auth?.enforcedType,
);
}
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;
}
if (process.env['GEMINI_API_KEY']) {
return item.value === AuthType.USE_GEMINI;
}
return item.value === AuthType.LOGIN_WITH_GOOGLE;
});
if (settings.merged.security?.auth?.enforcedType) {
initialAuthIndex = 0;
}
const handleAuthSelect = (authMethod: AuthType) => {
const error = validateAuthMethod(authMethod);
if (error) {
setErrorMessage(error);
} else {
setErrorMessage(null);
onSelect(authMethod, SettingScope.User);
}
};
useKeypress(
(key) => {
if (key.name === 'escape') {
// Prevent exit if there is an error message.
// This means they user is not authenticated yet.
if (errorMessage) {
return;
}
if (settings.merged.security?.auth?.selectedType === undefined) {
// Prevent exiting if no auth method is set
setErrorMessage(
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
);
return;
}
onSelect(undefined, SettingScope.User);
}
},
{ isActive: true },
);
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold>Get started</Text>
<Box marginTop={1}>
<Text>How would you like to authenticate for this project?</Text>
</Box>
<Box marginTop={1}>
<RadioButtonSelect
items={items}
initialIndex={initialAuthIndex}
onSelect={handleAuthSelect}
/>
</Box>
{errorMessage && (
<Box marginTop={1}>
<Text color={Colors.AccentRed}>{errorMessage}</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={Colors.Gray}>(Use Enter to select)</Text>
</Box>
<Box marginTop={1}>
<Text>Terms of Services and Privacy Notice for Gemini CLI</Text>
</Box>
<Box marginTop={1}>
<Text color={Colors.AccentBlue}>
{
'https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md'
}
</Text>
</Box>
</Box>
);
}

View File

@@ -1,63 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface AuthInProgressProps {
onTimeout: () => void;
}
export function AuthInProgress({
onTimeout,
}: AuthInProgressProps): React.JSX.Element {
const [timedOut, setTimedOut] = useState(false);
useKeypress(
(key) => {
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
onTimeout();
}
},
{ isActive: true },
);
useEffect(() => {
const timer = setTimeout(() => {
setTimedOut(true);
onTimeout();
}, 180000);
return () => clearTimeout(timer);
}, [onTimeout]);
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
flexDirection="column"
padding={1}
width="100%"
>
{timedOut ? (
<Text color={Colors.AccentRed}>
Authentication timed out. Please try again.
</Text>
) : (
<Box>
<Text>
<Spinner type="dots" /> Waiting for auth... (Press ESC or CTRL+C to
cancel)
</Text>
</Box>
)}
</Box>
);
}