mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-29 07:21:27 -07:00
Add interactive ValidationDialog for handling 403 VALIDATION_REQUIRED errors. (#16231)
This commit is contained in:
@@ -17,6 +17,7 @@ import { ApiAuthDialog } from '../auth/ApiAuthDialog.js';
|
||||
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
||||
import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
|
||||
import { ProQuotaDialog } from './ProQuotaDialog.js';
|
||||
import { ValidationDialog } from './ValidationDialog.js';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
|
||||
import { SessionBrowser } from './SessionBrowser.js';
|
||||
@@ -68,6 +69,16 @@ export const DialogManager = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.validationRequest) {
|
||||
return (
|
||||
<ValidationDialog
|
||||
validationLink={uiState.validationRequest.validationLink}
|
||||
validationDescription={uiState.validationRequest.validationDescription}
|
||||
learnMoreUrl={uiState.validationRequest.learnMoreUrl}
|
||||
onChoice={uiActions.handleValidationChoice}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.shouldShowIdePrompt) {
|
||||
return (
|
||||
<IdeIntegrationNudge
|
||||
|
||||
195
packages/cli/src/ui/components/ValidationDialog.test.tsx
Normal file
195
packages/cli/src/ui/components/ValidationDialog.test.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { act } from 'react';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { ValidationDialog } from './ValidationDialog.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
|
||||
// Mock the child components and utilities
|
||||
vi.mock('./shared/RadioButtonSelect.js', () => ({
|
||||
RadioButtonSelect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./CliSpinner.js', () => ({
|
||||
CliSpinner: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
const mockOpenBrowserSecurely = vi.fn();
|
||||
const mockShouldLaunchBrowser = vi.fn();
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
openBrowserSecurely: (...args: unknown[]) =>
|
||||
mockOpenBrowserSecurely(...args),
|
||||
shouldLaunchBrowser: () => mockShouldLaunchBrowser(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('ValidationDialog', () => {
|
||||
const mockOnChoice = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockShouldLaunchBrowser.mockReturnValue(true);
|
||||
mockOpenBrowserSecurely.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('initial render (choosing state)', () => {
|
||||
it('should render the main message and two options', () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
<ValidationDialog onChoice={mockOnChoice} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
'Further action is required to use this service.',
|
||||
);
|
||||
expect(RadioButtonSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
{
|
||||
label: 'Verify your account',
|
||||
value: 'verify',
|
||||
key: 'verify',
|
||||
},
|
||||
{
|
||||
label: 'Change authentication',
|
||||
value: 'change_auth',
|
||||
key: 'change_auth',
|
||||
},
|
||||
],
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render learn more URL when provided', () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
<ValidationDialog
|
||||
learnMoreUrl="https://example.com/help"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Learn more:');
|
||||
expect(lastFrame()).toContain('https://example.com/help');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onChoice handling', () => {
|
||||
it('should call onChoice with change_auth when that option is selected', () => {
|
||||
const { unmount } = render(<ValidationDialog onChoice={mockOnChoice} />);
|
||||
|
||||
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
|
||||
act(() => {
|
||||
onSelect('change_auth');
|
||||
});
|
||||
|
||||
expect(mockOnChoice).toHaveBeenCalledWith('change_auth');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call onChoice with verify when no validation link is provided', () => {
|
||||
const { unmount } = render(<ValidationDialog onChoice={mockOnChoice} />);
|
||||
|
||||
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
|
||||
act(() => {
|
||||
onSelect('verify');
|
||||
});
|
||||
|
||||
expect(mockOnChoice).toHaveBeenCalledWith('verify');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should open browser and transition to waiting state when verify is selected with a link', async () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
<ValidationDialog
|
||||
validationLink="https://accounts.google.com/verify"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
|
||||
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
|
||||
await act(async () => {
|
||||
await onSelect('verify');
|
||||
});
|
||||
|
||||
expect(mockOpenBrowserSecurely).toHaveBeenCalledWith(
|
||||
'https://accounts.google.com/verify',
|
||||
);
|
||||
expect(lastFrame()).toContain('Waiting for verification...');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('headless mode', () => {
|
||||
it('should show URL in message when browser cannot be launched', async () => {
|
||||
mockShouldLaunchBrowser.mockReturnValue(false);
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<ValidationDialog
|
||||
validationLink="https://accounts.google.com/verify"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
|
||||
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
|
||||
await act(async () => {
|
||||
await onSelect('verify');
|
||||
});
|
||||
|
||||
expect(mockOpenBrowserSecurely).not.toHaveBeenCalled();
|
||||
expect(lastFrame()).toContain('Please open this URL in a browser:');
|
||||
expect(lastFrame()).toContain('https://accounts.google.com/verify');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error state', () => {
|
||||
it('should show error and options when browser fails to open', async () => {
|
||||
mockOpenBrowserSecurely.mockRejectedValue(new Error('Browser not found'));
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<ValidationDialog
|
||||
validationLink="https://accounts.google.com/verify"
|
||||
onChoice={mockOnChoice}
|
||||
/>,
|
||||
);
|
||||
|
||||
const onSelect = (RadioButtonSelect as Mock).mock.calls[0][0].onSelect;
|
||||
await act(async () => {
|
||||
await onSelect('verify');
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('Browser not found');
|
||||
// RadioButtonSelect should be rendered again with options in error state
|
||||
expect((RadioButtonSelect as Mock).mock.calls.length).toBeGreaterThan(1);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
177
packages/cli/src/ui/components/ValidationDialog.tsx
Normal file
177
packages/cli/src/ui/components/ValidationDialog.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { CliSpinner } from './CliSpinner.js';
|
||||
import {
|
||||
openBrowserSecurely,
|
||||
shouldLaunchBrowser,
|
||||
type ValidationIntent,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
|
||||
interface ValidationDialogProps {
|
||||
validationLink?: string;
|
||||
validationDescription?: string;
|
||||
learnMoreUrl?: string;
|
||||
onChoice: (choice: ValidationIntent) => void;
|
||||
}
|
||||
|
||||
type DialogState = 'choosing' | 'waiting' | 'complete' | 'error';
|
||||
|
||||
export function ValidationDialog({
|
||||
validationLink,
|
||||
learnMoreUrl,
|
||||
onChoice,
|
||||
}: ValidationDialogProps): React.JSX.Element {
|
||||
const [state, setState] = useState<DialogState>('choosing');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
const items = [
|
||||
{
|
||||
label: 'Verify your account',
|
||||
value: 'verify' as const,
|
||||
key: 'verify',
|
||||
},
|
||||
{
|
||||
label: 'Change authentication',
|
||||
value: 'change_auth' as const,
|
||||
key: 'change_auth',
|
||||
},
|
||||
];
|
||||
|
||||
// Handle keypresses during 'waiting' state (ESC to cancel, Enter to confirm completion)
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key) || keyMatchers[Command.QUIT](key)) {
|
||||
onChoice('cancel');
|
||||
} else if (keyMatchers[Command.RETURN](key)) {
|
||||
// User confirmed verification is complete - transition to 'complete' state
|
||||
setState('complete');
|
||||
}
|
||||
},
|
||||
{ isActive: state === 'waiting' },
|
||||
);
|
||||
|
||||
// When state becomes 'complete', show success message briefly then proceed
|
||||
useEffect(() => {
|
||||
if (state === 'complete') {
|
||||
const timer = setTimeout(() => {
|
||||
onChoice('verify');
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [state, onChoice]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (choice: ValidationIntent) => {
|
||||
if (choice === 'verify') {
|
||||
if (validationLink) {
|
||||
// Check if we're in an environment where we can launch a browser
|
||||
if (!shouldLaunchBrowser()) {
|
||||
// In headless mode, show the link and wait for user to manually verify
|
||||
setErrorMessage(
|
||||
`Please open this URL in a browser: ${validationLink}`,
|
||||
);
|
||||
setState('waiting');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await openBrowserSecurely(validationLink);
|
||||
setState('waiting');
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : 'Failed to open browser',
|
||||
);
|
||||
setState('error');
|
||||
}
|
||||
} else {
|
||||
// No validation link, just retry
|
||||
onChoice('verify');
|
||||
}
|
||||
} else {
|
||||
// 'change_auth' or 'cancel'
|
||||
onChoice(choice);
|
||||
}
|
||||
},
|
||||
[validationLink, onChoice],
|
||||
);
|
||||
|
||||
if (state === 'error') {
|
||||
return (
|
||||
<Box borderStyle="round" flexDirection="column" padding={1}>
|
||||
<Text color={theme.status.error}>
|
||||
{errorMessage ||
|
||||
'Failed to open verification link. Please try again or change authentication.'}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect
|
||||
items={items}
|
||||
onSelect={(choice) => void handleSelect(choice as ValidationIntent)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'waiting') {
|
||||
return (
|
||||
<Box borderStyle="round" flexDirection="column" padding={1}>
|
||||
<Box>
|
||||
<CliSpinner />
|
||||
<Text>
|
||||
{' '}
|
||||
Waiting for verification... (Press ESC or CTRL+C to cancel)
|
||||
</Text>
|
||||
</Box>
|
||||
{errorMessage && (
|
||||
<Box marginTop={1}>
|
||||
<Text>{errorMessage}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Press Enter when verification is complete.</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'complete') {
|
||||
return (
|
||||
<Box borderStyle="round" flexDirection="column" padding={1}>
|
||||
<Text color={theme.status.success}>Verification complete</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box borderStyle="round" flexDirection="column" padding={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text>Further action is required to use this service.</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} marginBottom={1}>
|
||||
<RadioButtonSelect
|
||||
items={items}
|
||||
onSelect={(choice) => void handleSelect(choice as ValidationIntent)}
|
||||
/>
|
||||
</Box>
|
||||
{learnMoreUrl && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
Learn more: <Text color={theme.text.accent}>{learnMoreUrl}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user