fix(privacy): show a clear message when the account has no Code Assist tier (#28304)

This commit is contained in:
Om Patel
2026-07-10 13:07:52 -04:00
committed by GitHub
parent a4c91ce191
commit f354eebaf4
4 changed files with 157 additions and 10 deletions
@@ -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 () => {
@@ -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<PrivacyState>({
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<boolean> {
@@ -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 },
@@ -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 (
<Box flexDirection="column" marginY={1}>
<Text bold color={theme.text.accent}>
Gemini Code Assist Privacy Notice
</Text>
<Newline />
<Text color={theme.text.primary}>
The data collection opt-in isn&apos;t available for this account
because it doesn&apos;t have a Gemini Code Assist for Individuals
(free) tier.
</Text>
<Newline />
<Text color={theme.text.primary}>
If you&apos;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.
</Text>
<Newline />
<Text color={theme.text.primary}>
Learn more: https://geminicli.com/docs/get-started/authentication/
</Text>
<Newline />
<Text color={theme.text.secondary}>Press Esc to exit.</Text>
</Box>
);
}
if (privacyState.isFreeTier === false) {
return (
<Box flexDirection="column" marginY={1}>