Skip to content
Open
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
7 changes: 1 addition & 6 deletions apps/code/src/renderer/api/posthogClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,19 +696,14 @@ export class PostHogAPIClient {
async approveAiDataProcessing(): Promise<void> {
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,
overrides: {
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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<QueryClientProvider client={queryClient}>
<Theme>
<AiApprovalScreen orgName="Acme" isAdmin={isAdmin} />
</Theme>
</QueryClientProvider>,
);
}

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();
});
});
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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(),
});
Comment thread
charlesvien marked this conversation as resolved.
},
Comment thread
charlesvien marked this conversation as resolved.
});

// biome-ignore lint/correctness/useExhaustiveDependencies: fire once on mount; later isAdmin changes from query resolution should not re-fire
useEffect(() => {
Expand All @@ -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 = (
<Button
size="1"
Expand Down Expand Up @@ -141,17 +139,27 @@ export function AiApprovalScreen({ orgName, isAdmin }: AiApprovalScreenProps) {
<Flex direction="column" gap="2">
<Button
size="3"
onClick={openApproval}
disabled={!approvalUrl}
onClick={() => approveMutation.mutate()}
disabled={approveMutation.isPending}
className="w-full"
>
Approve in PostHog
<ArrowSquareOut size={16} />
{approveMutation.isPending ? (
<Spinner size="2" />
) : (
"Approve AI data processing"
)}
</Button>
<Text className="text-(--gray-10) text-[13px]">
Opens PostHog in your browser. Come back here once you've
approved.
</Text>
{approveMutation.isError && (
<Callout.Root color="red" size="1">
<Callout.Icon>
<WarningCircle />
</Callout.Icon>
<Callout.Text>
Could not approve AI data processing. Try again or
contact support.
</Callout.Text>
</Callout.Root>
)}
</Flex>
) : (
<Text className="text-(--gray-11) text-sm">
Expand Down
2 changes: 2 additions & 0 deletions apps/code/src/shared/types/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,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",
Expand Down Expand Up @@ -786,6 +787,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;
Expand Down
Loading