feat: add organization-level autofill disable setting (#23504)

* feat: add organization-level autofill disable setting

- Create DisableAutofillOnBookingPageSwitch component following existing patterns
- Add toggle to organization general page alongside other settings
- Update tRPC organizations update handler to support new field
- Add organization-level check to useShouldBeDisabledDueToPrefill hook
- Add translation keys for new autofill disable setting
- Include database migration for disableAutofillOnBookingPage field
- Maintain backward compatibility with individual field settings

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* feat: complete autofill disable implementation

- Add disableAutofillOnBookingPage to orgSettings type definition
- Update Prisma schema with new organization setting field
- Clean up test file formatting

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* fix: resolve tRPC mocking issues in tests and add missing disableAutofillOnBookingPage to organization repository

- Fix tRPC module mocking in useShouldBeDisabledDueToPrefill tests
- Add disableAutofillOnBookingPage to organization repository select and return statements
- All form builder tests now pass (24/24)
- Organization-level autofill disable tests working correctly

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* test: simplify autofill disable test to single focused test

- Replace multiple tests with one test that verifies org setting blocks autocomplete
- Test includes searchParams with prefill data to verify blocking behavior
- Removes unnecessary test complexity as requested

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* fix: add missing disableAutofillOnBookingPage to organizationSettings select statements

- Add disableAutofillOnBookingPage to both parent and main organizationSettings select statements in getTeamWithMembers
- Resolves TypeScript error in getServerSideProps.tsx where MinimumOrganizationSettings type requires this property
- Ensures organization settings type compatibility across the codebase

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* Remove disableAutofillOnBookingPage setting

Removed 'disableAutofillOnBookingPage' setting from organization configuration.

* update

* Remove duplicate settings in common.json

Removed duplicate entries for automatic transcription and autofill settings.

* Fix syntax error in common.json

* update

* add tests

* Remove comments for autofill disabled check

Removed comments explaining scenarios for autofill check.

* addressed review

* fix

* change

* Add handling for disableAutofillOnBookingPage input

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Anik Dhabal Babu
2025-11-19 08:25:46 +00:00
committed by GitHub
co-authored by anik@cal.com Devin AI
parent 5cda123a4c
commit a0eca13c18
16 changed files with 423 additions and 25 deletions
@@ -1076,6 +1076,8 @@
"warning_instant_meeting_experimental": "Experimental: Instant Meeting Events are currently experimental.",
"starting": "Starting",
"disable_guests": "Disable Guests",
"disable_autofill_on_booking_page": "Disable autofill on booking pages",
"disable_autofill_on_booking_page_description": "Prevent booking forms from being automatically filled with URL parameters or session-based prefill for all users in this organization",
"disable_guests_description": "Disable adding additional guests while booking.",
"private_link": "Generate private link",
"enable_private_url": "Enable Private URL",
@@ -12,7 +12,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useInitialFormValues } from "./useInitialFormValues";
export interface IUseBookingForm {
event?: Pick<BookerEvent, "bookingFields"> | null;
event?: Pick<BookerEvent, "bookingFields" | "team" | "owner"> | null;
sessionEmail?: string | null;
sessionName?: string | null;
sessionUsername?: string | null;
@@ -0,0 +1,290 @@
/**
* @vitest-environment jsdom
*/
import { renderHook, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { BookerEvent } from "@calcom/features/bookings/types";
import { useInitialFormValues } from "./useInitialFormValues";
vi.mock("@calcom/features/bookings/Booker/store", () => ({
useBookerStore: vi.fn((selector) => {
const state = {
bookingData: null,
formValues: {},
};
return selector(state);
}),
}));
vi.mock("@calcom/features/bookings/lib/getBookingResponsesSchema", () => ({
getBookingResponsesPartialSchema: vi.fn(() => ({
parseAsync: vi.fn((data) => Promise.resolve(data)),
})),
}));
describe("useInitialFormValues - Autofill Disable Feature", () => {
const mockBookingFields = [
{ name: "name", type: "text" as const, required: true },
{ name: "email", type: "email" as const, required: true },
{ name: "phone", type: "phone" as const, required: false },
] as unknown as BookerEvent["bookingFields"];
const baseProps = {
rescheduleUid: null,
isRescheduling: false,
email: null,
name: null,
username: null,
hasSession: false,
extraOptions: {},
prefillFormParams: {
guests: [],
name: null,
},
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("when organization has NOT disabled autofill", () => {
it("should use URL parameters to prefill form fields", async () => {
const eventType: Pick<BookerEvent, "bookingFields" | "team" | "owner"> = {
bookingFields: mockBookingFields,
team: {
organizationSettings: {
disableAutofillOnBookingPage: false,
},
},
owner: null,
};
const extraOptions = {
phone: "+1234567890",
customField: "custom value",
};
const prefillFormParams = {
guests: ["guest1@example.com", "guest2@example.com"],
name: "John Doe",
};
const { result } = renderHook(() =>
useInitialFormValues({
...baseProps,
eventType,
extraOptions,
prefillFormParams,
})
);
await waitFor(() => {
expect(result.current.values.responses).toBeDefined();
});
expect(result.current.values.responses).toMatchObject({
name: "John Doe",
phone: "+1234567890",
});
});
it("should use URL parameters when organizationSettings is undefined", async () => {
const eventType: Pick<BookerEvent, "bookingFields" | "team" | "owner"> = {
bookingFields: mockBookingFields,
team: null,
owner: null,
};
const extraOptions = {
phone: "+1234567890",
};
const prefillFormParams = {
guests: [],
name: "Jane Smith",
};
const { result } = renderHook(() =>
useInitialFormValues({
...baseProps,
eventType,
extraOptions,
prefillFormParams,
})
);
await waitFor(() => {
expect(result.current.values.responses).toBeDefined();
});
expect(result.current.values.responses).toMatchObject({
name: "Jane Smith",
phone: "+1234567890",
});
});
});
describe("when organization HAS disabled autofill", () => {
it("should NOT use URL parameters when team.organizationSettings.disableAutofillOnBookingPage is true", async () => {
const eventType: Pick<BookerEvent, "bookingFields" | "team" | "owner"> = {
bookingFields: mockBookingFields,
team: {
parent: {
organizationSettings: {
disableAutofillOnBookingPage: true,
},
},
},
owner: null,
};
const extraOptions = {
phone: "+1234567890",
customField: "custom value",
};
const prefillFormParams = {
guests: ["guest1@example.com"],
name: "John Doe",
};
const { result } = renderHook(() =>
useInitialFormValues({
...baseProps,
eventType,
extraOptions,
prefillFormParams,
})
);
await waitFor(() => {
expect(result.current.values.responses).toBeDefined();
});
expect(result.current.values.responses?.phone).toBeUndefined();
expect(result.current.values.responses?.name).toBe("");
});
it("should NOT use URL parameters when team.parent.organizationSettings.disableAutofillOnBookingPage is true", async () => {
const eventType: Pick<BookerEvent, "bookingFields" | "team" | "owner"> = {
bookingFields: mockBookingFields,
team: {
organizationSettings: null,
parent: {
organizationSettings: {
disableAutofillOnBookingPage: true,
},
},
},
owner: null,
};
const extraOptions = {
phone: "+9876543210",
};
const prefillFormParams = {
guests: [],
name: "Parent Team User",
};
const { result } = renderHook(() =>
useInitialFormValues({
...baseProps,
eventType,
extraOptions,
prefillFormParams,
})
);
await waitFor(() => {
expect(result.current.values.responses).toBeDefined();
});
expect(result.current.values.responses?.phone).toBeUndefined();
expect(result.current.values.responses?.name).toBe("");
});
it("should NOT use URL parameters when owner.organization.organizationSettings.disableAutofillOnBookingPage is true", async () => {
const eventType: Pick<BookerEvent, "bookingFields" | "team" | "owner"> = {
bookingFields: mockBookingFields,
team: null,
owner: {
profile: {
organization: {
organizationSettings: {
disableAutofillOnBookingPage: true,
},
},
},
},
};
const extraOptions = {
phone: "+5555555555",
};
const prefillFormParams = {
guests: [],
name: "Personal Event User",
};
const { result } = renderHook(() =>
useInitialFormValues({
...baseProps,
eventType,
extraOptions,
prefillFormParams,
})
);
await waitFor(() => {
expect(result.current.values.responses).toBeDefined();
});
expect(result.current.values.responses?.phone).toBeUndefined();
expect(result.current.values.responses?.name).toBe("");
});
});
describe("session data handling with autofill disabled", () => {
it("should not use session email/name when autofill is disabled", async () => {
const eventType: Pick<BookerEvent, "bookingFields" | "team" | "owner"> = {
bookingFields: mockBookingFields,
team: {
parent: {
organizationSettings: {
disableAutofillOnBookingPage: true,
},
},
},
owner: null,
};
const extraOptions = {
phone: "+1234567890", // This should be blocked
};
const { result } = renderHook(() =>
useInitialFormValues({
...baseProps,
eventType,
extraOptions,
email: "session@example.com", // Session data
name: "Session User", // Session data
hasSession: true,
})
);
await waitFor(() => {
expect(result.current.values.responses).toBeDefined();
});
expect(result.current.values.responses?.email).toBe("");
expect(result.current.values.responses?.name).toBe("");
expect(result.current.values.responses?.phone).toBeUndefined();
});
});
});
@@ -9,7 +9,7 @@ import type { BookerEvent } from "@calcom/features/bookings/types";
export type useInitialFormValuesReturnType = ReturnType<typeof useInitialFormValues>;
type UseInitialFormValuesProps = {
eventType?: Pick<BookerEvent, "bookingFields"> | null;
eventType?: Pick<BookerEvent, "bookingFields" | "team" | "owner"> | null;
rescheduleUid: string | null;
isRescheduling: boolean;
email?: string | null;
@@ -78,6 +78,12 @@ export function useInitialFormValues({
});
const bookingData = useBookerStore((state) => state.bookingData);
const formValues = useBookerStore((state) => state.formValues);
// Check if organization has disabled autofill from URL parameters
const isAutofillDisabledByOrg =
eventType?.team?.parent?.organizationSettings?.disableAutofillOnBookingPage ??
eventType?.owner?.profile?.organization?.organizationSettings?.disableAutofillOnBookingPage ??
false;
useEffect(() => {
(async function () {
if (Object.keys(formValues).length) {
@@ -96,28 +102,41 @@ export function useInitialFormValues({
view: rescheduleUid ? "reschedule" : "booking",
});
const parsedQuery = await querySchema.parseAsync({
...extraOptions,
name: prefillFormParams.name,
// `guest` because we need to support legacy URL with `guest` query param support
// `guests` because the `name` of the corresponding bookingField is `guests`
guests: prefillFormParams.guests,
});
// If organization has disabled autofill, don't use URL parameters for prefill
const urlParamsToUse = isAutofillDisabledByOrg
? {}
: {
...extraOptions,
name: prefillFormParams.name,
// `guest` because we need to support legacy URL with `guest` query param support
// `guests` because the `name` of the corresponding bookingField is `guests`
guests: prefillFormParams.guests,
};
const defaultUserValues = {
email:
const parsedQuery = await querySchema.parseAsync(urlParamsToUse);
const defaultUserValues = (() => {
const rescheduledEmail =
rescheduleUid && bookingData && bookingData.attendees.length > 0
? bookingData?.attendees[0].email
: !!parsedQuery["email"]
? parsedQuery["email"]
: email ?? "",
name:
? bookingData.attendees[0].email ?? ""
: "";
const rescheduledName =
rescheduleUid && bookingData && bookingData.attendees.length > 0
? bookingData?.attendees[0].name
: !!parsedQuery["name"]
? parsedQuery["name"]
: name ?? username ?? "",
};
? bookingData.attendees[0].name ?? ""
: "";
if (isAutofillDisabledByOrg) {
return {
email: rescheduledEmail,
name: rescheduledName,
};
}
return {
email: rescheduledEmail || (parsedQuery["email"] ? parsedQuery["email"] : email ?? ""),
name: rescheduledName || (parsedQuery["name"] ? parsedQuery["name"] : name ?? username ?? ""),
};
})();
if (clientId) {
defaultUserValues.email = defaultUserValues.email.replace(`+${clientId}`, "");
@@ -189,6 +208,7 @@ export function useInitialFormValues({
prefillFormParams,
// We need to have extraOptions as a dep so that any change in query params can update the form values, but extraOptions itself isn't stable and changes reference on every render
stableHashExtraOptions,
isAutofillDisabledByOrg,
]);
return initialValuesState;
+2
View File
@@ -64,6 +64,8 @@ export type BookerEvent = Pick<
| "disableCancelling"
| "disableRescheduling"
| "interfaceLanguage"
| "team"
| "owner"
> & {
subsetOfUsers: BookerEventUser[];
showInstantEventConnectNowModal: boolean;
@@ -2,7 +2,10 @@ import type { OrganizationSettings } from "@calcom/prisma/client";
type MinimumOrganizationSettings = Pick<
OrganizationSettings,
"orgAutoAcceptEmail" | "orgProfileRedirectsToVerifiedDomain" | "allowSEOIndexing"
| "orgAutoAcceptEmail"
| "orgProfileRedirectsToVerifiedDomain"
| "allowSEOIndexing"
| "disableAutofillOnBookingPage"
>;
type SEOOrganizationSettings = Pick<OrganizationSettings, "allowSEOIndexing">;
@@ -0,0 +1,52 @@
"use client";
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { SettingsToggle } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
interface GeneralViewProps {
currentOrg: RouterOutputs["viewer"]["organizations"]["listCurrent"];
}
export const DisableAutofillOnBookingPageSwitch = ({ currentOrg }: GeneralViewProps) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const [disableAutofillOnBookingPageActive, setDisableAutofillOnBookingPageActive] = useState(
!!currentOrg.organizationSettings?.disableAutofillOnBookingPage
);
const mutation = trpc.viewer.organizations.update.useMutation({
onSuccess: async (_data, variables) => {
setDisableAutofillOnBookingPageActive(!!variables.disableAutofillOnBookingPage);
showToast(t("settings_updated_successfully"), "success");
},
onError: () => {
showToast(t("error_updating_settings"), "error");
},
onSettled: () => {
utils.viewer.organizations.listCurrent.invalidate();
},
});
return (
<>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("disable_autofill_on_booking_page")}
disabled={mutation?.isPending}
description={t("disable_autofill_on_booking_page_description")}
checked={disableAutofillOnBookingPageActive}
onCheckedChange={(checked) => {
mutation.mutate({
disableAutofillOnBookingPage: checked,
});
}}
switchContainerClassName="mt-6"
/>
</>
);
};
@@ -20,6 +20,7 @@ import { Select } from "@calcom/ui/components/form";
import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
import { DisableAutofillOnBookingPageSwitch } from "../components/DisableAutofillOnBookingPageSwitch";
import { DisablePhoneOnlySMSNotificationsSwitch } from "../components/DisablePhoneOnlySMSNotificationsSwitch";
import { LockEventTypeSwitch } from "../components/LockEventTypeSwitch";
import { NoSlotsNotificationSwitch } from "../components/NoSlotsNotificationSwitch";
@@ -94,6 +95,7 @@ const OrgGeneralView = ({
<LockEventTypeSwitch currentOrg={currentOrg} />
<NoSlotsNotificationSwitch currentOrg={currentOrg} />
<DisablePhoneOnlySMSNotificationsSwitch currentOrg={currentOrg} />
<DisableAutofillOnBookingPageSwitch currentOrg={currentOrg} />
</>
)}
</LicenseRequired>
@@ -287,6 +287,7 @@ export class OrganizationRepository {
orgProfileRedirectsToVerifiedDomain: true,
orgAutoAcceptEmail: true,
disablePhoneOnlySMSNotifications: true,
disableAutofillOnBookingPage: true,
orgAutoJoinOnSignup: true,
},
});
@@ -306,6 +307,7 @@ export class OrganizationRepository {
orgProfileRedirectsToVerifiedDomain: organizationSettings?.orgProfileRedirectsToVerifiedDomain,
orgAutoAcceptEmail: organizationSettings?.orgAutoAcceptEmail,
disablePhoneOnlySMSNotifications: organizationSettings?.disablePhoneOnlySMSNotifications,
disableAutofillOnBookingPage: organizationSettings?.disableAutofillOnBookingPage,
orgAutoJoinOnSignup: organizationSettings?.orgAutoJoinOnSignup,
},
user: {
@@ -111,6 +111,7 @@ export async function getTeamWithMembers(args: {
allowSEOIndexing: true,
orgProfileRedirectsToVerifiedDomain: true,
orgAutoAcceptEmail: true,
disableAutofillOnBookingPage: true,
},
},
},
@@ -175,6 +176,7 @@ export async function getTeamWithMembers(args: {
allowSEOIndexing: true,
orgProfileRedirectsToVerifiedDomain: true,
orgAutoAcceptEmail: true,
disableAutofillOnBookingPage: true,
},
},
},
@@ -44,6 +44,11 @@ const userSelect = {
name: true,
slug: true,
bannerUrl: true,
organizationSettings: {
select: {
disableAutofillOnBookingPage: true,
},
},
},
},
defaultScheduleId: true,
@@ -105,9 +110,19 @@ export const getPublicEventSelect = (fetchAllUsers: boolean) => {
name: true,
bannerUrl: true,
logoUrl: true,
organizationSettings: {
select: {
disableAutofillOnBookingPage: true,
},
},
},
},
isPrivate: true,
organizationSettings: {
select: {
disableAutofillOnBookingPage: true,
},
},
},
},
successRedirectUrl: true,
@@ -40,6 +40,7 @@ const log = logger.getSubLogger({ prefix: ["repository/profile"] });
const organizationSettingsSelect = {
allowSEOIndexing: true,
orgProfileRedirectsToVerifiedDomain: true,
disableAutofillOnBookingPage: true,
} satisfies Prisma.OrganizationSettingsSelect;
const organizationSelect = {
id: true,
@@ -71,7 +72,6 @@ const profileSelect = {
updatedAt: true,
};
export enum LookupTarget {
User,
Profile,
@@ -583,7 +583,7 @@ export class ProfileRepository {
},
include: {
organization: {
select: organizationSelect,
select: organizationWithSettingsSelect,
},
},
});
@@ -615,7 +615,7 @@ export class ProfileRepository {
},
include: {
organization: {
select: organizationSelect,
select: organizationWithSettingsSelect,
},
},
})
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "OrganizationSettings" ADD COLUMN "disableAutofillOnBookingPage" BOOLEAN NOT NULL DEFAULT false;
+1
View File
@@ -686,6 +686,7 @@ model OrganizationSettings {
allowSEOIndexing Boolean @default(false)
orgProfileRedirectsToVerifiedDomain Boolean @default(false)
disablePhoneOnlySMSNotifications Boolean @default(false)
disableAutofillOnBookingPage Boolean @default(false)
orgAutoJoinOnSignup Boolean @default(true)
}
@@ -90,6 +90,10 @@ const updateOrganizationSettings = async ({
data.disablePhoneOnlySMSNotifications = input.disablePhoneOnlySMSNotifications;
}
if (input.hasOwnProperty("disableAutofillOnBookingPage")) {
data.disableAutofillOnBookingPage = input.disableAutofillOnBookingPage;
}
// eslint-disable-next-line no-prototype-builtins
if (input.hasOwnProperty("orgAutoJoinOnSignup")) {
data.orgAutoJoinOnSignup = input.orgAutoJoinOnSignup;
@@ -35,6 +35,7 @@ export const ZUpdateInputSchema = z.object({
allowSEOIndexing: z.boolean().optional(),
orgProfileRedirectsToVerifiedDomain: z.boolean().optional(),
disablePhoneOnlySMSNotifications: z.boolean().optional(),
disableAutofillOnBookingPage: z.boolean().optional(),
orgAutoJoinOnSignup: z.boolean().optional(),
});