From f354eebaf43b25bacb176007e449bb9a638fd101 Mon Sep 17 00:00:00 2001 From: Om Patel Date: Fri, 10 Jul 2026 13:07:52 -0400 Subject: [PATCH] fix(privacy): show a clear message when the account has no Code Assist tier (#28304) --- .../src/ui/hooks/usePrivacySettings.test.tsx | 67 +++++++++++++++++-- .../cli/src/ui/hooks/usePrivacySettings.ts | 57 +++++++++++++++- .../privacy/CloudFreePrivacyNotice.test.tsx | 10 +++ .../src/ui/privacy/CloudFreePrivacyNotice.tsx | 33 ++++++++- 4 files changed, 157 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/hooks/usePrivacySettings.test.tsx b/packages/cli/src/ui/hooks/usePrivacySettings.test.tsx index adf1eb53d5..619c6be7b9 100644 --- a/packages/cli/src/ui/hooks/usePrivacySettings.test.tsx +++ b/packages/cli/src/ui/hooks/usePrivacySettings.test.tsx @@ -49,7 +49,7 @@ describe('usePrivacySettings', () => { }; }; - it('should throw error when content generator is not a CodeAssistServer', async () => { + it('should report tier unavailable when OAuth is not being used', async () => { vi.mocked(getCodeAssistServer).mockReturnValue(undefined); const { result } = await act(async () => renderPrivacySettingsHook()); @@ -58,7 +58,8 @@ describe('usePrivacySettings', () => { expect(result.current.privacyState.isLoading).toBe(false); }); - expect(result.current.privacyState.error).toBe('Oauth not being used'); + expect(result.current.privacyState.isTierUnavailable).toBe(true); + expect(result.current.privacyState.error).toBeUndefined(); }); it('should handle paid tier users correctly', async () => { @@ -79,7 +80,7 @@ describe('usePrivacySettings', () => { expect(result.current.privacyState.dataCollectionOptIn).toBeUndefined(); }); - it('should throw error when CodeAssistServer has no projectId', async () => { + it('should report tier unavailable when CodeAssistServer has no projectId', async () => { vi.mocked(getCodeAssistServer).mockReturnValue({ userTier: UserTierId.FREE, } as unknown as CodeAssistServer); @@ -90,9 +91,63 @@ describe('usePrivacySettings', () => { expect(result.current.privacyState.isLoading).toBe(false); }); - expect(result.current.privacyState.error).toBe( - 'CodeAssist server is missing a project ID', - ); + expect(result.current.privacyState.isTierUnavailable).toBe(true); + expect(result.current.privacyState.error).toBeUndefined(); + }); + + it('should report tier unavailable when the user has no tier', async () => { + vi.mocked(getCodeAssistServer).mockReturnValue({ + projectId: 'test-project-id', + userTier: undefined, + } as unknown as CodeAssistServer); + + const { result } = await act(async () => renderPrivacySettingsHook()); + + await waitFor(() => { + expect(result.current.privacyState.isLoading).toBe(false); + }); + + expect(result.current.privacyState.isTierUnavailable).toBe(true); + expect(result.current.privacyState.isFreeTier).toBeUndefined(); + expect(result.current.privacyState.error).toBeUndefined(); + }); + + it('should report tier unavailable when the backend reports no current tier', async () => { + vi.mocked(getCodeAssistServer).mockReturnValue({ + projectId: 'test-project-id', + userTier: UserTierId.FREE, + getCodeAssistGlobalUserSetting: vi + .fn() + .mockRejectedValue(new Error('User does not have a current tier')), + } as unknown as CodeAssistServer); + + const { result } = await act(async () => renderPrivacySettingsHook()); + + await waitFor(() => { + expect(result.current.privacyState.isLoading).toBe(false); + }); + + expect(result.current.privacyState.isTierUnavailable).toBe(true); + expect(result.current.privacyState.error).toBeUndefined(); + }); + + it('should surface unexpected errors while loading opt-in settings', async () => { + vi.mocked(getCodeAssistServer).mockReturnValue({ + projectId: 'test-project-id', + userTier: UserTierId.FREE, + getCodeAssistGlobalUserSetting: vi + .fn() + .mockRejectedValue(new Error('network unavailable')), + } as unknown as CodeAssistServer); + + const { result } = await act(async () => renderPrivacySettingsHook()); + + await waitFor(() => { + expect(result.current.privacyState.isLoading).toBe(false); + }); + + expect(result.current.privacyState.error).toBe('network unavailable'); + expect(result.current.privacyState.isTierUnavailable).toBeUndefined(); }); it('should update data collection opt-in setting', async () => { diff --git a/packages/cli/src/ui/hooks/usePrivacySettings.ts b/packages/cli/src/ui/hooks/usePrivacySettings.ts index 7bf5a5ff1b..0764324aad 100644 --- a/packages/cli/src/ui/hooks/usePrivacySettings.ts +++ b/packages/cli/src/ui/hooks/usePrivacySettings.ts @@ -18,8 +18,22 @@ export interface PrivacyState { error?: string; isFreeTier?: boolean; dataCollectionOptIn?: boolean; + /** + * True when the signed-in account has no consumer Code Assist tier, so the + * data-collection opt-in isn't applicable (e.g. Workspace/enterprise accounts, + * or an OAuth login without a Google Cloud project). This is an expected state + * rendered as a friendly, actionable notice rather than a raw backend `error`. + */ + isTierUnavailable?: boolean; } +/** + * Signals that the current account can't be mapped to a consumer Code Assist + * tier, so the privacy opt-in can't be shown. Handled by rendering a friendly + * notice instead of surfacing a raw backend error. + */ +class TierUnavailableError extends Error {} + export const usePrivacySettings = (config: Config) => { const [privacyState, setPrivacyState] = useState({ isLoading: true, @@ -34,7 +48,13 @@ export const usePrivacySettings = (config: Config) => { const server = getCodeAssistServerOrFail(config); const tier = server.userTier; if (tier === undefined) { - throw new Error('Could not determine user tier.'); + // The account has no resolved Code Assist tier (e.g. Workspace or an + // incomplete OAuth). Show a friendly notice instead of a raw error. + setPrivacyState({ + isLoading: false, + isTierUnavailable: true, + }); + return; } if (tier !== UserTierId.FREE) { // We don't need to fetch opt-out info since non-free tier @@ -53,6 +73,13 @@ export const usePrivacySettings = (config: Config) => { dataCollectionOptIn: optIn, }); } catch (e) { + if (isTierUnavailableError(e)) { + setPrivacyState({ + isLoading: false, + isTierUnavailable: true, + }); + return; + } setPrivacyState({ isLoading: false, error: e instanceof Error ? e.message : String(e), @@ -74,6 +101,13 @@ export const usePrivacySettings = (config: Config) => { dataCollectionOptIn: updatedOptIn, }); } catch (e) { + if (isTierUnavailableError(e)) { + setPrivacyState({ + isLoading: false, + isTierUnavailable: true, + }); + return; + } setPrivacyState({ isLoading: false, error: e instanceof Error ? e.message : String(e), @@ -92,13 +126,30 @@ export const usePrivacySettings = (config: Config) => { function getCodeAssistServerOrFail(config: Config): CodeAssistServer { const server = getCodeAssistServer(config); if (server === undefined) { - throw new Error('Oauth not being used'); + throw new TierUnavailableError('Oauth not being used'); } else if (server.projectId === undefined) { - throw new Error('CodeAssist server is missing a project ID'); + throw new TierUnavailableError('CodeAssist server is missing a project ID'); } return server; } +/** + * Determines whether an error means the account simply has no consumer Code + * Assist tier, as opposed to an unexpected failure. Covers the local + * {@link TierUnavailableError} as well as the Code Assist backend error (e.g. + * "User does not have a current tier") returned for Workspace/enterprise + * accounts. + */ +function isTierUnavailableError(error: unknown): boolean { + if (error instanceof TierUnavailableError) { + return true; + } + const message = error instanceof Error ? error.message : String(error); + // Match the specific Code Assist backend message rather than a broad substring + // so an unrelated error that merely mentions "tier" isn't masked as a benign notice. + return /does not have a current tier/i.test(message); +} + async function getRemoteDataCollectionOptIn( server: CodeAssistServer, ): Promise { diff --git a/packages/cli/src/ui/privacy/CloudFreePrivacyNotice.test.tsx b/packages/cli/src/ui/privacy/CloudFreePrivacyNotice.test.tsx index a6fa1ab626..f84e240168 100644 --- a/packages/cli/src/ui/privacy/CloudFreePrivacyNotice.test.tsx +++ b/packages/cli/src/ui/privacy/CloudFreePrivacyNotice.test.tsx @@ -71,6 +71,11 @@ describe('CloudFreePrivacyNotice', () => { mockState: { isFreeTier: false }, expectedText: 'Gemini Code Assist Privacy Notice', }, + { + stateName: 'tier unavailable state', + mockState: { isFreeTier: undefined, isTierUnavailable: true }, + expectedText: 'GOOGLE_CLOUD_PROJECT', + }, { stateName: 'free tier state', mockState: { isFreeTier: true }, @@ -101,6 +106,11 @@ describe('CloudFreePrivacyNotice', () => { mockState: { isFreeTier: false }, shouldExit: true, }, + { + stateName: 'tier unavailable state', + mockState: { isFreeTier: undefined, isTierUnavailable: true }, + shouldExit: true, + }, { stateName: 'free tier state (no selection)', mockState: { isFreeTier: true }, diff --git a/packages/cli/src/ui/privacy/CloudFreePrivacyNotice.tsx b/packages/cli/src/ui/privacy/CloudFreePrivacyNotice.tsx index 52175c0677..02b54ebef5 100644 --- a/packages/cli/src/ui/privacy/CloudFreePrivacyNotice.tsx +++ b/packages/cli/src/ui/privacy/CloudFreePrivacyNotice.tsx @@ -27,7 +27,9 @@ export const CloudFreePrivacyNotice = ({ useKeypress( (key) => { if ( - (privacyState.error || privacyState.isFreeTier === false) && + (privacyState.error || + privacyState.isFreeTier === false || + privacyState.isTierUnavailable) && key.name === 'escape' ) { onExit(); @@ -53,6 +55,35 @@ export const CloudFreePrivacyNotice = ({ ); } + if (privacyState.isTierUnavailable) { + return ( + + + Gemini Code Assist Privacy Notice + + + + The data collection opt-in isn't available for this account + because it doesn't have a Gemini Code Assist for Individuals + (free) tier. + + + + If you're on a Google Workspace or enterprise account, use the + Vertex AI / Google Cloud path instead by setting the + GOOGLE_CLOUD_PROJECT environment variable to your Google Cloud + project. + + + + Learn more: https://geminicli.com/docs/get-started/authentication/ + + + Press Esc to exit. + + ); + } + if (privacyState.isFreeTier === false) { return (