Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 48 additions & 5 deletions packages/cli/src/core/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getErrorMessage: (e: unknown) => (e as Error).message,
};
});

Expand All @@ -32,7 +31,7 @@ describe('auth', () => {

it('should return null if authType is undefined', async () => {
const result = await performInitialAuth(mockConfig, undefined);
expect(result).toBeNull();
expect(result).toEqual({ authError: null, accountSuspensionInfo: null });
expect(mockConfig.refreshAuth).not.toHaveBeenCalled();
});

Expand All @@ -41,7 +40,7 @@ describe('auth', () => {
mockConfig,
AuthType.LOGIN_WITH_GOOGLE,
);
expect(result).toBeNull();
expect(result).toEqual({ authError: null, accountSuspensionInfo: null });
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
Expand All @@ -54,7 +53,10 @@ describe('auth', () => {
mockConfig,
AuthType.LOGIN_WITH_GOOGLE,
);
expect(result).toBe('Failed to login. Message: Auth failed');
expect(result).toEqual({
authError: 'Failed to login. Message: Auth failed',
accountSuspensionInfo: null,
});
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
Expand All @@ -68,7 +70,48 @@ describe('auth', () => {
mockConfig,
AuthType.LOGIN_WITH_GOOGLE,
);
expect(result).toBeNull();
expect(result).toEqual({ authError: null, accountSuspensionInfo: null });
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
});

it('should return accountSuspensionInfo for 403 TOS_VIOLATION error', async () => {
vi.mocked(mockConfig.refreshAuth).mockRejectedValue({
response: {
data: {
error: {
code: 403,
message:
'This service has been disabled for violation of Terms of Service.',
details: [
{
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
reason: 'TOS_VIOLATION',
domain: 'example.googleapis.com',
metadata: {
appeal_url: 'https://example.com/appeal',
appeal_url_link_text: 'Appeal Here',
},
},
],
},
},
},
});
const result = await performInitialAuth(
mockConfig,
AuthType.LOGIN_WITH_GOOGLE,
);
expect(result).toEqual({
authError: null,
accountSuspensionInfo: {
message:
'This service has been disabled for violation of Terms of Service.',
appealUrl: 'https://example.com/appeal',
appealLinkText: 'Appeal Here',
},
});
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
Expand Down
34 changes: 28 additions & 6 deletions packages/cli/src/core/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,28 @@ import {
type Config,
getErrorMessage,
ValidationRequiredError,
isAccountSuspendedError,
} from '@google/gemini-cli-core';

import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js';

export interface InitialAuthResult {
authError: string | null;
accountSuspensionInfo: AccountSuspensionInfo | null;
}

/**
* Handles the initial authentication flow.
* @param config The application config.
* @param authType The selected auth type.
* @returns An error message if authentication fails, otherwise null.
* @returns The auth result with error message and account suspension status.
*/
export async function performInitialAuth(
config: Config,
authType: AuthType | undefined,
): Promise<string | null> {
): Promise<InitialAuthResult> {
if (!authType) {
return null;
return { authError: null, accountSuspensionInfo: null };
}

try {
Expand All @@ -33,10 +41,24 @@ export async function performInitialAuth(
if (e instanceof ValidationRequiredError) {
// Don't treat validation required as a fatal auth error during startup.
// This allows the React UI to load and show the ValidationDialog.
return null;
return { authError: null, accountSuspensionInfo: null };
}
const suspendedError = isAccountSuspendedError(e);
if (suspendedError) {
return {
authError: null,
accountSuspensionInfo: {
message: suspendedError.message,
appealUrl: suspendedError.appealUrl,
appealLinkText: suspendedError.appealLinkText,
},
};
}
return `Failed to login. Message: ${getErrorMessage(e)}`;
return {
authError: `Failed to login. Message: ${getErrorMessage(e)}`,
accountSuspensionInfo: null,
};
}

return null;
return { authError: null, accountSuspensionInfo: null };
}
12 changes: 10 additions & 2 deletions packages/cli/src/core/initializer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ describe('initializer', () => {
vi.mocked(IdeClient.getInstance).mockResolvedValue(
mockIdeClient as unknown as IdeClient,
);
vi.mocked(performInitialAuth).mockResolvedValue(null);
vi.mocked(performInitialAuth).mockResolvedValue({
authError: null,
accountSuspensionInfo: null,
});
vi.mocked(validateTheme).mockReturnValue(null);
});

Expand All @@ -84,6 +87,7 @@ describe('initializer', () => {

expect(result).toEqual({
authError: null,
accountSuspensionInfo: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 5,
Expand All @@ -103,6 +107,7 @@ describe('initializer', () => {

expect(result).toEqual({
authError: null,
accountSuspensionInfo: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 5,
Expand All @@ -116,7 +121,10 @@ describe('initializer', () => {
});

it('should handle auth error', async () => {
vi.mocked(performInitialAuth).mockResolvedValue('Auth failed');
vi.mocked(performInitialAuth).mockResolvedValue({
authError: 'Auth failed',
accountSuspensionInfo: null,
});
const result = await initializeApp(
mockConfig as unknown as Config,
mockSettings,
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/core/initializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ import {
import { type LoadedSettings } from '../config/settings.js';
import { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js';
import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js';

export interface InitializationResult {
authError: string | null;
accountSuspensionInfo: AccountSuspensionInfo | null;
themeError: string | null;
shouldOpenAuthDialog: boolean;
geminiMdFileCount: number;
Expand All @@ -37,7 +39,7 @@ export async function initializeApp(
settings: LoadedSettings,
): Promise<InitializationResult> {
const authHandle = startupProfiler.start('authenticate');
const authError = await performInitialAuth(
const { authError, accountSuspensionInfo } = await performInitialAuth(
config,
settings.merged.security.auth.selectedType,
);
Expand All @@ -60,6 +62,7 @@ export async function initializeApp(

return {
authError,
accountSuspensionInfo,
themeError,
shouldOpenAuthDialog,
geminiMdFileCount: config.getGeminiMdFileCount(),
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/gemini.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1202,6 +1202,7 @@ describe('startInteractiveUI', () => {
const mockWorkspaceRoot = '/root';
const mockInitializationResult = {
authError: null,
accountSuspensionInfo: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 0,
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/test-utils/AppRig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ vi.mock('../ui/auth/useAuth.js', () => ({
onAuthError: vi.fn(),
apiKeyDefaultValue: 'test-api-key',
reloadApiKey: vi.fn().mockResolvedValue('test-api-key'),
accountSuspensionInfo: null,
setAccountSuspensionInfo: vi.fn(),
}),
validateAuthMethodWithSettings: () => null,
}));
Expand Down Expand Up @@ -387,6 +389,7 @@ export class AppRig {
version="test-version"
initializationResult={{
authError: null,
accountSuspensionInfo: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 0,
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/test-utils/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,7 @@ const mockUIActions: UIActions = {
handleRestart: vi.fn(),
handleNewAgentsSelect: vi.fn(),
getPreferredEditor: vi.fn(),
clearAccountSuspension: vi.fn(),
};

let capturedOverflowState: OverflowState | undefined;
Expand Down
16 changes: 15 additions & 1 deletion packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -669,7 +669,14 @@ export const AppContainer = (props: AppContainerProps) => {
onAuthError,
apiKeyDefaultValue,
reloadApiKey,
} = useAuthCommand(settings, config, initializationResult.authError);
accountSuspensionInfo,
setAccountSuspensionInfo,
} = useAuthCommand(
settings,
config,
initializationResult.authError,
initializationResult.accountSuspensionInfo,
);
const [authContext, setAuthContext] = useState<{ requiresRestart?: boolean }>(
{},
);
Expand Down Expand Up @@ -2173,6 +2180,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isAuthenticating,
isConfigInitialized,
authError,
accountSuspensionInfo,
isAuthDialogOpen,
isAwaitingApiKeyInput: authState === AuthState.AwaitingApiKeyInput,
apiKeyDefaultValue,
Expand Down Expand Up @@ -2301,6 +2309,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isAuthenticating,
isConfigInitialized,
authError,
accountSuspensionInfo,
isAuthDialogOpen,
editorError,
isEditorDialogOpen,
Expand Down Expand Up @@ -2505,6 +2514,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
setNewAgents(null);
},
getPreferredEditor,
clearAccountSuspension: () => {
setAccountSuspensionInfo(null);
setAuthState(AuthState.Updating);
},
}),
[
handleThemeSelect,
Expand Down Expand Up @@ -2553,6 +2566,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
setAuthContext,
setAccountSuspensionInfo,
newAgents,
config,
historyManager,
Expand Down
Loading
Loading