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
67 changes: 61 additions & 6 deletions packages/cli/src/ui/hooks/usePrivacySettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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 () => {
Expand All @@ -79,9 +80,64 @@ 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);

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 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());
Expand All @@ -90,9 +146,8 @@ 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.error).toBe('network unavailable');
expect(result.current.privacyState.isTierUnavailable).toBeUndefined();
});

it('should update data collection opt-in setting', async () => {
Expand Down
57 changes: 54 additions & 3 deletions packages/cli/src/ui/hooks/usePrivacySettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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),
Expand All @@ -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),
Expand All @@ -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> {
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/ui/privacy/CloudFreePrivacyNotice.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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 },
Expand Down
33 changes: 32 additions & 1 deletion packages/cli/src/ui/privacy/CloudFreePrivacyNotice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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}>
Expand Down
Loading