Add interactive ValidationDialog for handling 403 VALIDATION_REQUIRED errors. (#16231)

This commit is contained in:
Gaurav
2026-01-20 16:23:01 -08:00
committed by GitHub
parent aceb06a587
commit 3b626e7c61
18 changed files with 1060 additions and 12 deletions

View File

@@ -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

View 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();
});
});
});

View 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>
);
}