diff --git a/apps/web/components/setup/AdminEmailConsent.tsx b/apps/web/components/setup/AdminEmailConsent.tsx new file mode 100644 index 00000000000000..ae87038968d7df --- /dev/null +++ b/apps/web/components/setup/AdminEmailConsent.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { z } from "zod"; + +import { emailRegex } from "@calcom/lib/emailSchema"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button } from "@calcom/ui/components/button"; +import { CheckboxField, EmailField } from "@calcom/ui/components/form"; + +const FORM_ID = "d4fe10cb-6cb3-4c84-ae61-394a817c419e"; +const HEADLESS_ROUTER_URL = "https://i.cal.com/router"; + +type AdminEmailConsentFormValues = { + email: string; + productChangelog: boolean; + marketingConsent: boolean; +}; + +const AdminEmailConsent = (props: { + defaultEmail?: string; + onSubmit: () => void; + onSkip: () => void; + onPrevStep: () => void; +} & Omit) => { + const { defaultEmail = "", onSubmit, onSkip, onPrevStep, ...rest } = props; + const { t } = useLocale(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const formSchema = z.object({ + email: z + .string() + .refine((val) => val === "" || emailRegex.test(val), { + message: t("enter_valid_email"), + }), + productChangelog: z.boolean(), + marketingConsent: z.boolean(), + }); + + const formMethods = useForm({ + defaultValues: { + email: defaultEmail, + productChangelog: false, + marketingConsent: false, + }, + resolver: zodResolver(formSchema), + }); + + const handleSubmit = formMethods.handleSubmit(async (values) => { + setIsSubmitting(true); + + try { + const params = new URLSearchParams(); + params.append("form", FORM_ID); + + if (values.email) { + params.append("email", values.email); + } + + if (values.productChangelog) { + params.append("consent", "product"); + } + + if (values.marketingConsent) { + params.append("consent", "marketing"); + } + + const url = `${HEADLESS_ROUTER_URL}?${params.toString()}`; + + await fetch(url, { + method: "GET", + mode: "no-cors", + }); + } catch (error) { + console.error("Failed to submit admin email consent:", error); + } finally { + setIsSubmitting(false); + onSubmit(); + } + }); + + const handleSkip = () => { + onSkip(); + }; + + return ( + +
+

+ {t("self_hosted_admin_email_description")} +

+ +
+ ( + onChange(e.target.value)} + className="my-0" + name="email" + /> + )} + /> + + ( + onChange(e.target.checked)} + /> + )} + /> + + ( + onChange(e.target.checked)} + /> + )} + /> +
+ +
+ + + +
+
+
+ ); +}; + +export default AdminEmailConsent; diff --git a/apps/web/components/setup/AdminUser.tsx b/apps/web/components/setup/AdminUser.tsx index 797cb96d773088..cd0a8a20d26381 100644 --- a/apps/web/components/setup/AdminUser.tsx +++ b/apps/web/components/setup/AdminUser.tsx @@ -23,7 +23,7 @@ export const AdminUserContainer = (props: React.ComponentProps className="stack-y-4" onSubmit={(e) => { e.preventDefault(); - props.onSuccess(); + props.onSuccess?.(); }}> export const AdminUser = (props: { onSubmit: () => void; onError: () => void; - onSuccess: () => void; + onSuccess: (email?: string) => void; nav: { onNext: () => void; onPrev: () => void }; }) => { const { t } = useLocale(); @@ -96,7 +96,7 @@ export const AdminUser = (props: { email: data.email_address.toLowerCase(), password: data.password, }); - props.onSuccess(); + props.onSuccess(data.email_address.toLowerCase()); } else { props.onError(); } diff --git a/apps/web/modules/auth/setup-view.tsx b/apps/web/modules/auth/setup-view.tsx index a3069ca85695f3..96037c11454765 100644 --- a/apps/web/modules/auth/setup-view.tsx +++ b/apps/web/modules/auth/setup-view.tsx @@ -4,12 +4,13 @@ import { useRouter } from "next/navigation"; import { useMemo, useState } from "react"; import AdminAppsList from "@calcom/features/apps/AdminAppsList"; -import { APP_NAME } from "@calcom/lib/constants"; +import { APP_NAME, IS_CALCOM } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { inferSSRProps } from "@calcom/types/inferSSRProps"; import { WizardForm } from "@calcom/ui/components/form"; import type { WizardStep } from "@calcom/ui/components/form/wizard/WizardForm"; +import AdminEmailConsent from "@components/setup/AdminEmailConsent"; import { AdminUserContainer as AdminUser } from "@components/setup/AdminUser"; import LicenseSelection from "@components/setup/LicenseSelection"; @@ -29,6 +30,9 @@ export function Setup(props: PageProps) { const [licenseOption, setLicenseOption] = useState<"FREE" | "EXISTING">( props.hasValidLicense ? "EXISTING" : "FREE" ); + const [adminEmail, setAdminEmail] = useState(""); + + const showAdminEmailConsent = !IS_CALCOM; const defaultStep = useMemo(() => { if (props.userCount > 0) { @@ -51,9 +55,15 @@ export function Setup(props: PageProps) { onSubmit={() => { setIsPending(true); }} - onSuccess={() => { - // If there's already a valid license or user picked AGPLv3, skip to apps step - if (props.hasValidLicense || hasPickedAGPLv3) { + onSuccess={(email?: string) => { + if (email) { + setAdminEmail(email); + } + // For self-hosters, go to admin email consent step next + if (showAdminEmailConsent) { + nav.onNext(); + } else if (props.hasValidLicense || hasPickedAGPLv3) { + // If there's already a valid license or user picked AGPLv3, skip to apps step nav.onNext(); nav.onNext(); // Skip license step } else { @@ -70,6 +80,44 @@ export function Setup(props: PageProps) { }, ]; + // For self-hosters (!IS_CALCOM), add admin email consent step + if (showAdminEmailConsent) { + steps.push({ + title: t("stay_informed"), + description: t("stay_informed_description"), + customActions: true, + content: (setIsPending, nav) => { + return ( + { + setIsPending(true); + // After consent step, go to license step or apps step + if (props.hasValidLicense || hasPickedAGPLv3) { + nav.onNext(); + nav.onNext(); // Skip license step + } else { + nav.onNext(); + } + }} + onSkip={() => { + // After skipping, go to license step or apps step + if (props.hasValidLicense || hasPickedAGPLv3) { + nav.onNext(); + nav.onNext(); // Skip license step + } else { + nav.onNext(); + } + }} + onPrevStep={nav.onPrev} + /> + ); + }, + }); + } + // Only show license selection step if there's no valid license already and AGPLv3 wasn't picked if (!props.hasValidLicense && !hasPickedAGPLv3) { steps.push({ diff --git a/apps/web/modules/settings/my-account/components/AdminEmailConsentBanner.tsx b/apps/web/modules/settings/my-account/components/AdminEmailConsentBanner.tsx new file mode 100644 index 00000000000000..5a2c868d683ad1 --- /dev/null +++ b/apps/web/modules/settings/my-account/components/AdminEmailConsentBanner.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button } from "@calcom/ui/components/button"; +import { Icon } from "@calcom/ui/components/icon"; + +export const AdminEmailConsentBanner = () => { + const { t } = useLocale(); + const router = useRouter(); + + const handleOpenAdminStep = () => { + router.push("/auth/setup?step=2"); + }; + + return ( +
+
+
+ +
+
+
+

{t("stay_informed")}

+

{t("self_hosted_admin_email_description")}

+
+
+ +
+
+
+
+ ); +}; diff --git a/apps/web/modules/settings/my-account/profile-view.tsx b/apps/web/modules/settings/my-account/profile-view.tsx index d588896a8953b0..e55cd61ba0a9b6 100644 --- a/apps/web/modules/settings/my-account/profile-view.tsx +++ b/apps/web/modules/settings/my-account/profile-view.tsx @@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { revalidateSettingsProfile } from "app/cache/path/settings/my-account"; -// eslint-disable-next-line no-restricted-imports + import { get, pick } from "lodash"; import { signOut, useSession } from "next-auth/react"; import type { BaseSyntheticEvent } from "react"; @@ -16,13 +16,13 @@ import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils"; import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; import { DisplayInfo } from "@calcom/features/users/components/UserTable/EditSheet/DisplayInfo"; -import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants"; +import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT, IS_CALCOM } from "@calcom/lib/constants"; import { emailSchema } from "@calcom/lib/emailSchema"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { md } from "@calcom/lib/markdownIt"; import turndown from "@calcom/lib/turndownService"; -import { IdentityProvider } from "@calcom/prisma/enums"; +import { IdentityProvider, UserPermissionRole } from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import type { AppRouter } from "@calcom/trpc/types/server/routers/_app"; @@ -47,6 +47,7 @@ import { UsernameAvailabilityField } from "@components/ui/UsernameAvailability"; import type { TRPCClientErrorLike } from "@trpc/client"; +import { AdminEmailConsentBanner } from "./components/AdminEmailConsentBanner"; import { CompanyEmailOrganizationBanner } from "./components/CompanyEmailOrganizationBanner"; interface DeleteAccountValues { @@ -220,7 +221,7 @@ const ProfileView = ({ user }: Props) => { } }; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const passwordRef = useRef(null!); const errorMessages: { [key: string]: string } = { @@ -263,6 +264,10 @@ const ProfileView = ({ user }: Props) => { userEmail && isCompanyEmail(userEmail); + // Check if user should see admin email consent banner (self-hosted admins only) + const isSelfHostedAdmin = + !IS_CALCOM && session.data?.user?.role === UserPermissionRole.ADMIN; + return ( { key={JSON.stringify(defaultValues)} defaultValues={defaultValues} isPending={updateProfileMutation.isPending} - isFallbackImg={!user.avatarUrl} + _isFallbackImg={!user.avatarUrl} user={user} - userOrganization={user.organization} + _userOrganization={user.organization} onSubmit={(values) => { if (values.email !== user.email && isCALIdentityProvider) { setTempFormValues(values); @@ -320,6 +325,12 @@ const ProfileView = ({ user }: Props) => { )} + {isSelfHostedAdmin && ( +
+ +
+ )} +

{t("account_deletion_cannot_be_undone")}

@@ -500,9 +511,9 @@ const ProfileForm = ({ handleAccountDisconnect, extraField, isPending = false, - isFallbackImg, + _isFallbackImg, user, - userOrganization, + _userOrganization, isCALIdentityProvider, }: { defaultValues: FormValues; @@ -512,9 +523,9 @@ const ProfileForm = ({ handleAccountDisconnect: (values: ExtendedFormValues) => void; extraField?: React.ReactNode; isPending: boolean; - isFallbackImg: boolean; + _isFallbackImg: boolean; user: RouterOutputs["viewer"]["me"]["get"]; - userOrganization: RouterOutputs["viewer"]["me"]["get"]["organization"]; + _userOrganization: RouterOutputs["viewer"]["me"]["get"]["organization"]; isCALIdentityProvider: boolean; }) => { const { t } = useLocale(); @@ -604,7 +615,7 @@ const ProfileForm = ({ handleAccountDisconnect(getUpdatedFormValues(formMethods.getValues())); }; - const { data: usersAttributes, isPending: usersAttributesPending } = + const { data: usersAttributes, isPending: _usersAttributesPending } = trpc.viewer.attributes.getByUserId.useQuery({ userId: user.id, }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 3fb28a58406109..ef045fbd8cba41 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -767,6 +767,13 @@ "lets_create_first_administrator_user": "Let's create the first administrator user.", "admin_user_created": "Administrator user setup", "admin_user_created_description": "You have already created an administrator user. You can now log in to your account.", + "stay_informed": "Stay informed", + "stay_informed_description": "Get important updates about your Cal.com instance", + "self_hosted_admin_email_description": "To stay informed for upcoming updates, vulnerabilities and more we highly recommend submitting your admin email. We will not send spam without consent, however you have the option to sign up for additional marketing emails as well.", + "admin_email_label": "Email of admin", + "product_changelog_consent": "I want to receive product changelogs", + "marketing_consent": "I consent to be contacted for marketing purposes", + "update_preferences": "Update preferences", "new_member": "New Member", "invite": "Invite", "add_team_members": "Add team members",