diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index cb4cfbf30..150b50190 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -696,7 +696,7 @@ export class PostHogAPIClient { async approveAiDataProcessing(): Promise { const urlPath = `/api/organizations/@current/`; const url = new URL(`${this.api.baseUrl}${urlPath}`); - const response = await this.api.fetcher.fetch({ + await this.api.fetcher.fetch({ method: "patch", url, path: urlPath, @@ -704,11 +704,6 @@ export class PostHogAPIClient { body: JSON.stringify({ is_ai_data_processing_approved: true }), }, }); - if (!response.ok) { - throw new Error( - `Failed to approve AI data processing: ${response.statusText}`, - ); - } } async getProject(projectId: number) { diff --git a/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.test.tsx b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.test.tsx new file mode 100644 index 000000000..9075faa28 --- /dev/null +++ b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.test.tsx @@ -0,0 +1,104 @@ +import { Theme } from "@radix-ui/themes"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const approveAiDataProcessing = vi.fn(); +const logoutMutate = vi.fn(); +const openSettings = vi.fn(); + +vi.mock("@features/auth/hooks/authClient", () => ({ + useAuthenticatedClient: () => ({ approveAiDataProcessing }), +})); + +vi.mock("@features/auth/hooks/authMutations", () => ({ + useLogoutMutation: () => ({ mutate: logoutMutate }), +})); + +vi.mock("@features/auth/hooks/authQueries", () => ({ + authKeys: { currentUsers: () => ["auth", "current-user"] }, +})); + +vi.mock("@features/settings/components/SettingsDialog", () => ({ + SettingsDialog: () => null, +})); + +vi.mock("@features/settings/stores/settingsDialogStore", () => ({ + useSettingsDialogStore: ( + selector: (state: { open: typeof openSettings }) => unknown, + ) => selector({ open: openSettings }), +})); + +vi.mock("@utils/analytics", () => ({ track: vi.fn() })); + +vi.mock("@renderer/trpc/client", () => ({ + trpcClient: {}, +})); + +import { AiApprovalScreen } from "./AiApprovalScreen"; + +function renderInTheme(isAdmin: boolean) { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, + }); + return render( + + + + + , + ); +} + +describe("AiApprovalScreen", () => { + beforeEach(() => { + approveAiDataProcessing.mockReset(); + logoutMutate.mockReset(); + openSettings.mockReset(); + }); + + it("calls approveAiDataProcessing once when the admin clicks the button", async () => { + approveAiDataProcessing.mockResolvedValueOnce(undefined); + const user = userEvent.setup(); + + renderInTheme(true); + + const button = screen.getByRole("button", { + name: /Approve AI data processing/i, + }); + await user.click(button); + + await waitFor(() => + expect(approveAiDataProcessing).toHaveBeenCalledExactlyOnceWith(), + ); + }); + + it("renders the ask-admin copy and no approve button for non-admin users", () => { + renderInTheme(false); + + expect( + screen.getByText( + /Ask an organization admin to approve AI data processing/i, + ), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Approve AI data processing/i }), + ).not.toBeInTheDocument(); + }); + + it("shows an error callout when the approval request rejects", async () => { + approveAiDataProcessing.mockRejectedValueOnce(new Error("forbidden")); + const user = userEvent.setup(); + + renderInTheme(true); + + await user.click( + screen.getByRole("button", { name: /Approve AI data processing/i }), + ); + + expect( + await screen.findByText(/Could not approve AI data processing/i), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx index 2dfce464d..88df47dc6 100644 --- a/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx +++ b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx @@ -1,20 +1,14 @@ import { FullScreenLayout } from "@components/FullScreenLayout"; +import { useAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useLogoutMutation } from "@features/auth/hooks/authMutations"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { authKeys } from "@features/auth/hooks/authQueries"; import { SettingsDialog } from "@features/settings/components/SettingsDialog"; import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; -import { - ArrowSquareOut, - GearSix, - Robot, - SignOut, - WarningCircle, -} from "@phosphor-icons/react"; -import { Button, Callout, Flex, Text } from "@radix-ui/themes"; +import { GearSix, Robot, SignOut, WarningCircle } from "@phosphor-icons/react"; +import { Button, Callout, Flex, Spinner, Text } from "@radix-ui/themes"; import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; -import { trpcClient } from "@renderer/trpc/client"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { track } from "@utils/analytics"; import { motion } from "framer-motion"; import { useEffect } from "react"; @@ -28,7 +22,20 @@ interface AiApprovalScreenProps { export function AiApprovalScreen({ orgName, isAdmin }: AiApprovalScreenProps) { const logoutMutation = useLogoutMutation(); const openSettings = useSettingsDialogStore((s) => s.open); - const cloudRegion = useAuthStateValue((s) => s.cloudRegion); + const client = useAuthenticatedClient(); + const queryClient = useQueryClient(); + + const approveMutation = useMutation({ + mutationFn: async () => { + await client.approveAiDataProcessing(); + }, + onSuccess: async () => { + track(ANALYTICS_EVENTS.AI_CONSENT_GRANTED_INAPP); + await queryClient.invalidateQueries({ + queryKey: authKeys.currentUsers(), + }); + }, + }); // biome-ignore lint/correctness/useExhaustiveDependencies: fire once on mount; later isAdmin changes from query resolution should not re-fire useEffect(() => { @@ -40,15 +47,6 @@ export function AiApprovalScreen({ orgName, isAdmin }: AiApprovalScreenProps) { enableOnFormTags: true, }); - const approvalUrl = cloudRegion - ? `${getCloudUrlFromRegion(cloudRegion)}/settings/organization-details#organization-ai-consent` - : null; - - const openApproval = () => { - if (!approvalUrl) return; - void trpcClient.os.openExternal.mutate({ url: approvalUrl }); - }; - const footerLeft = ( - - Opens PostHog in your browser. Come back here once you've - approved. - + {approveMutation.isError && ( + + + + + + Could not approve AI data processing. Try again or + contact support. + + + )} ) : ( diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index 23053a9b8..d2a20156c 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -727,6 +727,7 @@ export const ANALYTICS_EVENTS = { ONBOARDING_ABANDONED: "Onboarding abandoned", AI_CONSENT_GATE_SHOWN: "Ai consent gate shown", AI_CONSENT_APPROVED: "Ai consent approved", + AI_CONSENT_GRANTED_INAPP: "Ai consent granted in-app", // Setup / onboarding events SETUP_DISCOVERY_STARTED: "Setup discovery started", @@ -847,6 +848,7 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.ONBOARDING_ABANDONED]: OnboardingAbandonedProperties; [ANALYTICS_EVENTS.AI_CONSENT_GATE_SHOWN]: AiConsentGateShownProperties; [ANALYTICS_EVENTS.AI_CONSENT_APPROVED]: never; + [ANALYTICS_EVENTS.AI_CONSENT_GRANTED_INAPP]: never; // Setup / onboarding events [ANALYTICS_EVENTS.SETUP_DISCOVERY_STARTED]: SetupDiscoveryStartedProperties;