From 0365d49a3f6e85a8a1132a4dc833b9de350328a5 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 25 May 2026 16:09:00 -0700 Subject: [PATCH 1/3] approve ai data processing in app for admins --- .../components/AiApprovalScreen.test.tsx | 117 ++++++++++++++++++ .../components/AiApprovalScreen.tsx | 71 ++++++----- 2 files changed, 159 insertions(+), 29 deletions(-) create mode 100644 apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.test.tsx 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..815e7711b --- /dev/null +++ b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.test.tsx @@ -0,0 +1,117 @@ +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: { currentUser: (id: string | null) => ["auth", "user", id] }, + getAuthIdentity: () => "us:42", + useAuthStateValue: () => ({ + status: "authenticated", + cloudRegion: "us", + projectId: 42, + }), +})); + +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: { os: { openExternal: { mutate: vi.fn() } } }, +})); + +vi.mock("@utils/queryClient", async () => { + const { QueryClient: TestQueryClient } = await import( + "@tanstack/react-query" + ); + return { queryClient: new TestQueryClient() }; +}); + +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..c0d8b1323 100644 --- a/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx +++ b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx @@ -1,21 +1,20 @@ 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, + getAuthIdentity, + useAuthStateValue, +} 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 } from "@tanstack/react-query"; import { track } from "@utils/analytics"; +import { queryClient } from "@utils/queryClient"; import { motion } from "framer-motion"; import { useEffect } from "react"; import { useHotkeys } from "react-hotkeys-hook"; @@ -28,7 +27,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 authState = useAuthStateValue((s) => s); + const authIdentity = getAuthIdentity(authState); + + const approveMutation = useMutation({ + mutationFn: async () => { + await client.approveAiDataProcessing(); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: authKeys.currentUser(authIdentity), + }); + }, + }); // biome-ignore lint/correctness/useExhaustiveDependencies: fire once on mount; later isAdmin changes from query resolution should not re-fire useEffect(() => { @@ -40,15 +52,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. + + + )} ) : ( From edbed3b4b37f046242836d10c58d65fcb5c2ba97 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 25 May 2026 16:42:59 -0700 Subject: [PATCH 2/3] drop dead ok guard, broaden currentUser invalidation key --- apps/code/src/renderer/api/posthogClient.ts | 7 +------ .../ai-approval/components/AiApprovalScreen.test.tsx | 10 ++-------- .../ai-approval/components/AiApprovalScreen.tsx | 10 ++-------- 3 files changed, 5 insertions(+), 22 deletions(-) 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 index 815e7711b..a2b30d8a9 100644 --- a/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.test.tsx +++ b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.test.tsx @@ -17,13 +17,7 @@ vi.mock("@features/auth/hooks/authMutations", () => ({ })); vi.mock("@features/auth/hooks/authQueries", () => ({ - authKeys: { currentUser: (id: string | null) => ["auth", "user", id] }, - getAuthIdentity: () => "us:42", - useAuthStateValue: () => ({ - status: "authenticated", - cloudRegion: "us", - projectId: 42, - }), + authKeys: { currentUsers: () => ["auth", "current-user"] }, })); vi.mock("@features/settings/components/SettingsDialog", () => ({ @@ -39,7 +33,7 @@ vi.mock("@features/settings/stores/settingsDialogStore", () => ({ vi.mock("@utils/analytics", () => ({ track: vi.fn() })); vi.mock("@renderer/trpc/client", () => ({ - trpcClient: { os: { openExternal: { mutate: vi.fn() } } }, + trpcClient: {}, })); vi.mock("@utils/queryClient", async () => { 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 c0d8b1323..f954390e1 100644 --- a/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx +++ b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx @@ -1,11 +1,7 @@ import { FullScreenLayout } from "@components/FullScreenLayout"; import { useAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useLogoutMutation } from "@features/auth/hooks/authMutations"; -import { - authKeys, - getAuthIdentity, - 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 { GearSix, Robot, SignOut, WarningCircle } from "@phosphor-icons/react"; @@ -28,8 +24,6 @@ export function AiApprovalScreen({ orgName, isAdmin }: AiApprovalScreenProps) { const logoutMutation = useLogoutMutation(); const openSettings = useSettingsDialogStore((s) => s.open); const client = useAuthenticatedClient(); - const authState = useAuthStateValue((s) => s); - const authIdentity = getAuthIdentity(authState); const approveMutation = useMutation({ mutationFn: async () => { @@ -37,7 +31,7 @@ export function AiApprovalScreen({ orgName, isAdmin }: AiApprovalScreenProps) { }, onSuccess: async () => { await queryClient.invalidateQueries({ - queryKey: authKeys.currentUser(authIdentity), + queryKey: authKeys.currentUsers(), }); }, }); From 62cf3c5b62941476245e2a1218dd8646d7291ed5 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 25 May 2026 18:55:25 -0700 Subject: [PATCH 3/3] track in-app ai consent approval and use context query client --- .../ai-approval/components/AiApprovalScreen.test.tsx | 7 ------- .../features/ai-approval/components/AiApprovalScreen.tsx | 5 +++-- apps/code/src/shared/types/analytics.ts | 2 ++ 3 files changed, 5 insertions(+), 9 deletions(-) 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 index a2b30d8a9..9075faa28 100644 --- a/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.test.tsx +++ b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.test.tsx @@ -36,13 +36,6 @@ vi.mock("@renderer/trpc/client", () => ({ trpcClient: {}, })); -vi.mock("@utils/queryClient", async () => { - const { QueryClient: TestQueryClient } = await import( - "@tanstack/react-query" - ); - return { queryClient: new TestQueryClient() }; -}); - import { AiApprovalScreen } from "./AiApprovalScreen"; function renderInTheme(isAdmin: boolean) { 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 f954390e1..88df47dc6 100644 --- a/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx +++ b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx @@ -8,9 +8,8 @@ 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 { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { track } from "@utils/analytics"; -import { queryClient } from "@utils/queryClient"; import { motion } from "framer-motion"; import { useEffect } from "react"; import { useHotkeys } from "react-hotkeys-hook"; @@ -24,12 +23,14 @@ export function AiApprovalScreen({ orgName, isAdmin }: AiApprovalScreenProps) { const logoutMutation = useLogoutMutation(); const openSettings = useSettingsDialogStore((s) => s.open); 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(), }); 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;