chore: Move user's analytics consent to the backend (#6505)

This commit is contained in:
sp.wack
2025-01-30 18:28:29 +04:00
committed by GitHub
parent 0afe889ccd
commit c54911d877
20 changed files with 219 additions and 65 deletions

View File

@@ -0,0 +1,45 @@
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
import OpenHands from "#/api/open-hands";
import { SettingsProvider } from "#/context/settings-context";
import { AuthProvider } from "#/context/auth-context";
describe("AnalyticsConsentFormModal", () => {
it("should call saveUserSettings with default settings on confirm reset settings", async () => {
const user = userEvent.setup();
const onCloseMock = vi.fn();
const saveUserSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
render(<AnalyticsConsentFormModal onClose={onCloseMock} />, {
wrapper: ({ children }) => (
<AuthProvider>
<QueryClientProvider client={new QueryClient()}>
<SettingsProvider>{children}</SettingsProvider>
</QueryClientProvider>
</AuthProvider>
),
});
const confirmButton = screen.getByTestId("confirm-preferences");
await user.click(confirmButton);
expect(saveUserSettingsSpy).toHaveBeenCalledWith({
user_consents_to_analytics: true,
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
github_token: undefined,
language: "en",
llm_api_key: undefined,
llm_base_url: "",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
security_analyzer: "",
unset_github_token: undefined,
});
expect(onCloseMock).toHaveBeenCalled();
});
});

View File

@@ -82,6 +82,10 @@ describe("Sidebar", () => {
within(accountSettingsModal).getByLabelText(/GITHUB\$TOKEN_LABEL/i);
await user.type(tokenInput, "new-token");
const analyticsConsentInput =
within(accountSettingsModal).getByTestId("analytics-consent");
await user.click(analyticsConsentInput);
const saveButton =
within(accountSettingsModal).getByTestId("save-settings");
await user.click(saveButton);
@@ -96,6 +100,7 @@ describe("Sidebar", () => {
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
security_analyzer: "",
user_consents_to_analytics: true,
});
});

View File

@@ -1,16 +1,74 @@
import { screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import OpenHands from "#/api/open-hands";
import * as ConsentHandlers from "#/utils/handle-capture-consent";
describe("AccountSettingsModal", () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
beforeEach(() => {
vi.resetAllMocks();
afterEach(() => {
vi.clearAllMocks();
});
it.skip("should set the appropriate user analytics consent default", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
user_consents_to_analytics: true,
});
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
const analyticsConsentInput = screen.getByTestId("analytics-consent");
await waitFor(() => expect(analyticsConsentInput).toBeChecked());
});
it("should save the users consent to analytics when saving account settings", async () => {
const user = userEvent.setup();
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
const analyticsConsentInput = screen.getByTestId("analytics-consent");
await user.click(analyticsConsentInput);
const saveButton = screen.getByTestId("save-settings");
await user.click(saveButton);
expect(saveSettingsSpy).toHaveBeenCalledWith({
agent: "CodeActAgent",
confirmation_mode: false,
enable_default_condenser: false,
language: "en",
llm_base_url: "",
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
security_analyzer: "",
user_consents_to_analytics: true,
});
});
it("should call handleCaptureConsent with the analytics consent value if the save is successful", async () => {
const user = userEvent.setup();
const handleCaptureConsentSpy = vi.spyOn(
ConsentHandlers,
"handleCaptureConsent",
);
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
const analyticsConsentInput = screen.getByTestId("analytics-consent");
await user.click(analyticsConsentInput);
const saveButton = screen.getByTestId("save-settings");
await user.click(saveButton);
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
await user.click(analyticsConsentInput);
await user.click(saveButton);
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(false);
});
it("should send all settings data when saving account settings", async () => {
@@ -39,11 +97,11 @@ describe("AccountSettingsModal", () => {
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
security_analyzer: "",
user_consents_to_analytics: false,
});
});
it("should render a checkmark and not the input if the github token is set", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
@@ -61,7 +119,6 @@ describe("AccountSettingsModal", () => {
it("should send an unset github token property when pressing disconnect", async () => {
const user = userEvent.setup();
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
@@ -86,7 +143,6 @@ describe("AccountSettingsModal", () => {
it("should not unset the github token when changing the language", async () => {
const user = userEvent.setup();
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
@@ -111,6 +167,7 @@ describe("AccountSettingsModal", () => {
llm_model: "anthropic/claude-3-5-sonnet-20241022",
remote_runtime_resource_factor: 1,
security_analyzer: "",
user_consents_to_analytics: false,
});
});
});

View File

@@ -74,7 +74,6 @@ describe("frontend/routes/_oh", () => {
// The user has not consented to tracking
const consentForm = await screen.findByTestId("user-capture-consent-form");
expect(handleCaptureConsentSpy).not.toHaveBeenCalled();
expect(localStorage.getItem("analytics-consent")).toBeNull();
const submitButton = within(consentForm).getByRole("button", {
name: /confirm preferences/i,
@@ -83,7 +82,6 @@ describe("frontend/routes/_oh", () => {
// The user has now consented to tracking
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
expect(localStorage.getItem("analytics-consent")).toBe("true");
expect(
screen.queryByTestId("user-capture-consent-form"),
).not.toBeInTheDocument();
@@ -106,17 +104,6 @@ describe("frontend/routes/_oh", () => {
});
});
it("should not render the user consent form if the user has already made a decision", async () => {
localStorage.setItem("analytics-consent", "true");
renderWithProviders(<RouteStub />);
await waitFor(() => {
expect(
screen.queryByTestId("user-capture-consent-form"),
).not.toBeInTheDocument();
});
});
// TODO: Likely failing due to how tokens are now handled in context. Move to e2e tests
it.skip("should render a new project button if a token is set", async () => {
localStorage.setItem("token", "test-token");

View File

@@ -5,6 +5,7 @@ import {
} from "#/components/shared/modals/confirmation-modals/base-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { useCurrentSettings } from "#/context/settings-context";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
interface AnalyticsConsentFormModalProps {
@@ -14,13 +15,21 @@ interface AnalyticsConsentFormModalProps {
export function AnalyticsConsentFormModal({
onClose,
}: AnalyticsConsentFormModalProps) {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
const { saveUserSettings } = useCurrentSettings();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const analytics = formData.get("analytics") === "on";
handleCaptureConsent(analytics);
localStorage.setItem("analytics-consent", analytics.toString());
await saveUserSettings(
{ user_consents_to_analytics: analytics },
{
onSuccess: () => {
handleCaptureConsent(analytics);
},
},
);
onClose();
};
@@ -46,6 +55,7 @@ export function AnalyticsConsentFormModal({
</label>
<ModalButton
testId="confirm-preferences"
type="submit"
text="Confirm Preferences"
className="bg-primary text-white w-full hover:opacity-80"

View File

@@ -15,25 +15,21 @@ import { useConfig } from "#/hooks/query/use-config";
import { useCurrentSettings } from "#/context/settings-context";
import { GitHubTokenInput } from "./github-token-input";
import { PostSettings } from "#/types/settings";
import { useGitHubUser } from "#/hooks/query/use-github-user";
interface AccountSettingsFormProps {
onClose: () => void;
selectedLanguage: string;
gitHubError: boolean;
analyticsConsent: string | null;
}
export function AccountSettingsForm({
onClose,
selectedLanguage,
gitHubError,
analyticsConsent,
}: AccountSettingsFormProps) {
export function AccountSettingsForm({ onClose }: AccountSettingsFormProps) {
const { isError: isGitHubError } = useGitHubUser();
const { data: config } = useConfig();
const { saveUserSettings, settings } = useCurrentSettings();
const { t } = useTranslation();
const githubTokenIsSet = !!settings?.GITHUB_TOKEN_IS_SET;
const analyticsConsentValue = !!settings?.USER_CONSENTS_TO_ANALYTICS;
const selectedLanguage = settings?.LANGUAGE || "en";
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -44,6 +40,7 @@ export function AccountSettingsForm({
const analytics = formData.get("analytics")?.toString() === "on";
const newSettings: Partial<PostSettings> = {};
newSettings.user_consents_to_analytics = analytics;
if (ghToken) newSettings.github_token = ghToken;
@@ -57,11 +54,11 @@ export function AccountSettingsForm({
if (languageKey) newSettings.LANGUAGE = languageKey;
}
await saveUserSettings(newSettings);
handleCaptureConsent(analytics);
const ANALYTICS = analytics.toString();
localStorage.setItem("analytics-consent", ANALYTICS);
await saveUserSettings(newSettings, {
onSuccess: () => {
handleCaptureConsent(analytics);
},
});
onClose();
};
@@ -117,12 +114,12 @@ export function AccountSettingsForm({
)}
</>
)}
{gitHubError && (
{isGitHubError && (
<p className="text-danger text-xs">
{t(I18nKey.GITHUB$TOKEN_INVALID)}
</p>
)}
{githubTokenIsSet && !gitHubError && (
{githubTokenIsSet && !isGitHubError && (
<ModalButton
testId="disconnect-github"
variant="text-like"
@@ -135,9 +132,10 @@ export function AccountSettingsForm({
<label className="flex gap-2 items-center self-start">
<input
data-testid="analytics-consent"
name="analytics"
type="checkbox"
defaultChecked={analyticsConsent === "true"}
defaultChecked={analyticsConsentValue}
/>
{t(I18nKey.ANALYTICS$ENABLE)}
</label>

View File

@@ -1,5 +1,3 @@
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useSettings } from "#/hooks/query/use-settings";
import { ModalBackdrop } from "../modal-backdrop";
import { AccountSettingsForm } from "./account-settings-form";
@@ -8,20 +6,9 @@ interface AccountSettingsModalProps {
}
export function AccountSettingsModal({ onClose }: AccountSettingsModalProps) {
const user = useGitHubUser();
const { data: settings } = useSettings();
// FIXME: Bad practice to use localStorage directly
const analyticsConsent = localStorage.getItem("analytics-consent");
return (
<ModalBackdrop onClose={onClose}>
<AccountSettingsForm
onClose={onClose}
selectedLanguage={settings?.LANGUAGE || "en"}
gitHubError={user.isError}
analyticsConsent={analyticsConsent}
/>
<AccountSettingsForm onClose={onClose} />
</ModalBackdrop>
);
}

View File

@@ -1,10 +1,18 @@
import React from "react";
import { MutateOptions } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { PostSettings, Settings } from "#/types/settings";
type SaveUserSettingsConfig = {
onSuccess: MutateOptions<void, Error, Partial<PostSettings>>["onSuccess"];
};
interface SettingsContextType {
saveUserSettings: (newSettings: Partial<PostSettings>) => Promise<void>;
saveUserSettings: (
newSettings: Partial<PostSettings>,
config?: SaveUserSettingsConfig,
) => Promise<void>;
settings: Settings | undefined;
}
@@ -20,7 +28,10 @@ export function SettingsProvider({ children }: SettingsProviderProps) {
const { data: userSettings } = useSettings();
const { mutateAsync: saveSettings } = useSaveSettings();
const saveUserSettings = async (newSettings: Partial<PostSettings>) => {
const saveUserSettings = async (
newSettings: Partial<PostSettings>,
config?: SaveUserSettingsConfig,
) => {
const updatedSettings: Partial<PostSettings> = {
...userSettings,
...newSettings,
@@ -30,7 +41,7 @@ export function SettingsProvider({ children }: SettingsProviderProps) {
delete updatedSettings.LLM_API_KEY;
}
await saveSettings(updatedSettings);
await saveSettings(updatedSettings, { onSuccess: config?.onSuccess });
};
const value = React.useMemo(

View File

@@ -16,6 +16,7 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
github_token: settings.github_token,
unset_github_token: settings.unset_github_token,
enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER,
user_consents_to_analytics: settings.user_consents_to_analytics,
};
await OpenHands.saveSettings(apiSettings);

View File

@@ -19,6 +19,7 @@ const getSettingsQueryFn = async () => {
REMOTE_RUNTIME_RESOURCE_FACTOR: apiSettings.remote_runtime_resource_factor,
GITHUB_TOKEN_IS_SET: apiSettings.github_token_is_set,
ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser,
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
};
};

View File

@@ -0,0 +1,34 @@
import React from "react";
import { useCurrentSettings } from "#/context/settings-context";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
export const useMigrateUserConsent = () => {
const { saveUserSettings } = useCurrentSettings();
/**
* Migrate user consent to the settings store on the server.
*/
const migrateUserConsent = React.useCallback(
async (args?: { handleAnalyticsWasPresentInLocalStorage: () => void }) => {
const userAnalyticsConsent = localStorage.getItem("analytics-consent");
if (userAnalyticsConsent) {
args?.handleAnalyticsWasPresentInLocalStorage();
await saveUserSettings(
{ user_consents_to_analytics: userAnalyticsConsent === "true" },
{
onSuccess: () => {
handleCaptureConsent(userAnalyticsConsent === "true");
},
},
);
localStorage.removeItem("analytics-consent");
}
},
[],
);
return { migrateUserConsent };
};

View File

@@ -19,6 +19,7 @@ export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
github_token_is_set: DEFAULT_SETTINGS.GITHUB_TOKEN_IS_SET,
enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
user_consents_to_analytics: DEFAULT_SETTINGS.USER_CONSENTS_TO_ANALYTICS,
};
const MOCK_USER_PREFERENCES: {

View File

@@ -9,6 +9,7 @@ import { WaitlistModal } from "#/components/features/waitlist/waitlist-modal";
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
import { useSettings } from "#/hooks/query/use-settings";
import { useAuth } from "#/context/auth-context";
import { useMigrateUserConsent } from "#/hooks/use-migrate-user-consent";
export function ErrorBoundary() {
const error = useRouteError();
@@ -45,9 +46,10 @@ export function ErrorBoundary() {
export default function MainApp() {
const { githubTokenIsSet } = useAuth();
const { data: settings } = useSettings();
const { migrateUserConsent } = useMigrateUserConsent();
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(
!localStorage.getItem("analytics-consent"),
settings.USER_CONSENTS_TO_ANALYTICS === null,
);
const config = useConfig();
@@ -68,6 +70,15 @@ export default function MainApp() {
}
}, [settings?.LANGUAGE]);
React.useEffect(() => {
// Migrate user consent to the server if it was previously stored in localStorage
migrateUserConsent({
handleAnalyticsWasPresentInLocalStorage: () => {
setConsentFormIsOpen(false);
},
});
}, []);
const userIsAuthed = !!isAuthed && !authError;
const renderWaitlistModal =
!isFetchingAuth && !userIsAuthed && config.data?.APP_MODE === "saas";
@@ -95,7 +106,9 @@ export default function MainApp() {
{config.data?.APP_MODE === "oss" && consentFormIsOpen && (
<AnalyticsConsentFormModal
onClose={() => setConsentFormIsOpen(false)}
onClose={() => {
setConsentFormIsOpen(false);
}}
/>
)}
</div>

View File

@@ -13,6 +13,7 @@ export const DEFAULT_SETTINGS: Settings = {
REMOTE_RUNTIME_RESOURCE_FACTOR: 1,
GITHUB_TOKEN_IS_SET: false,
ENABLE_DEFAULT_CONDENSER: false,
USER_CONSENTS_TO_ANALYTICS: null,
};
/**

View File

@@ -9,6 +9,7 @@ export type Settings = {
REMOTE_RUNTIME_RESOURCE_FACTOR: number;
GITHUB_TOKEN_IS_SET: boolean;
ENABLE_DEFAULT_CONDENSER: boolean;
USER_CONSENTS_TO_ANALYTICS: boolean | null;
};
export type ApiSettings = {
@@ -22,14 +23,17 @@ export type ApiSettings = {
remote_runtime_resource_factor: number;
github_token_is_set: boolean;
enable_default_condenser: boolean;
user_consents_to_analytics: boolean | null;
};
export type PostSettings = Settings & {
github_token: string;
unset_github_token: boolean;
user_consents_to_analytics: boolean | null;
};
export type PostApiSettings = ApiSettings & {
github_token: string;
unset_github_token: boolean;
user_consents_to_analytics: boolean | null;
};

View File

@@ -33,7 +33,6 @@ test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.evaluate(() => {
localStorage.setItem("FEATURE_MULTI_CONVERSATION_UI", "true");
localStorage.setItem("analytics-consent", "true");
});
});

View File

@@ -7,9 +7,6 @@ const dirname = path.dirname(filename);
test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.evaluate(() => {
localStorage.setItem("analytics-consent", "true");
});
});
test("should redirect to /conversations after uploading a project zip", async ({

View File

@@ -2,9 +2,6 @@ import test, { expect, Page } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.goto("/");
await page.evaluate(() => {
localStorage.setItem("analytics-consent", "true");
});
});
const selectGpt4o = async (page: Page) => {

View File

@@ -85,6 +85,11 @@ async def store_settings(
if settings.github_token is None:
settings.github_token = existing_settings.github_token
if settings.user_consents_to_analytics is None:
settings.user_consents_to_analytics = (
existing_settings.user_consents_to_analytics
)
response = JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Settings stored'},

View File

@@ -23,6 +23,7 @@ class Settings(BaseModel):
remote_runtime_resource_factor: int | None = None
github_token: str | None = None
enable_default_condenser: bool = False
user_consents_to_analytics: bool | None = None
@field_serializer('llm_api_key')
def llm_api_key_serializer(self, llm_api_key: SecretStr, info: SerializationInfo):