feat: Implement the ability to login via multiple means when signed up via google/non-cal providers (#14082)

Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
Anwar Sadath
2024-04-10 14:38:39 -07:00
committed by GitHub
co-authored by zomars
parent e30b41a85a
commit 4565c3311c
21 changed files with 270 additions and 120 deletions
@@ -31,7 +31,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(401).json({ message: "Not authenticated" });
}
if (user.identityProvider !== IdentityProvider.CAL) {
if (user.identityProvider !== IdentityProvider.CAL && !user.password?.hash) {
return res.status(400).json({ error: ErrorCode.ThirdPartyIdentityProviderEnabled });
}
+1 -1
View File
@@ -59,7 +59,7 @@ inferSSRProps<typeof getServerSideProps> & WithNonceProps<{}>) {
.string()
.min(1, `${t("error_required_field")}`)
.email(`${t("enter_valid_email")}`),
password: !!totpEmail ? z.literal("") : z.string().min(1, `${t("error_required_field")}`),
...(!!totpEmail ? {} : { password: z.string().min(1, `${t("error_required_field")}`) }),
})
// Passthrough other fields like totpCode
.passthrough();
+75 -37
View File
@@ -2,11 +2,11 @@
import { zodResolver } from "@hookform/resolvers/zod";
// eslint-disable-next-line no-restricted-imports
import { pick, get } from "lodash";
import { get, pick } from "lodash";
import { signOut, useSession } from "next-auth/react";
import type { BaseSyntheticEvent } from "react";
import React, { useRef, useState } from "react";
import { Controller, useForm, useFieldArray } from "react-hook-form";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { z } from "zod";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
@@ -19,8 +19,8 @@ import { md } from "@calcom/lib/markdownIt";
import turndown from "@calcom/lib/turndownService";
import { IdentityProvider } from "@calcom/prisma/enums";
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
import type { Ensure } from "@calcom/types/utils";
import {
@@ -96,7 +96,7 @@ const ProfileView = () => {
const { t } = useLocale();
const utils = trpc.useContext();
const { update } = useSession();
const { data: user, isPending } = trpc.viewer.me.useQuery();
const { data: user, isPending } = trpc.viewer.me.useQuery({ includePasswordAdded: true });
const { data: avatarData } = trpc.viewer.avatar.useQuery(undefined, {
enabled: !isPending && !user?.avatarUrl,
@@ -105,15 +105,9 @@ const ProfileView = () => {
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async (res) => {
await update(res);
// signout user only in case of password reset
if (res.signOutUser && tempFormValues && res.passwordReset) {
showToast(t("password_reset_email", { email: tempFormValues.email }), "success");
await signOut({ callbackUrl: "/auth/logout?passReset=true" });
} else {
utils.viewer.me.invalidate();
utils.viewer.avatar.invalidate();
utils.viewer.shouldVerifyEmail.invalidate();
}
utils.viewer.me.invalidate();
utils.viewer.avatar.invalidate();
utils.viewer.shouldVerifyEmail.invalidate();
if (res.hasEmailBeenChanged && res.sendEmailVerification) {
showToast(t("change_of_email_toast", { email: tempFormValues?.email }), "success");
@@ -121,7 +115,6 @@ const ProfileView = () => {
showToast(t("settings_updated_successfully"), "success");
}
setConfirmAuthEmailChangeWarningDialogOpen(false);
setTempFormValues(null);
},
onError: (e) => {
@@ -154,8 +147,8 @@ const ProfileView = () => {
const [confirmPasswordOpen, setConfirmPasswordOpen] = useState(false);
const [tempFormValues, setTempFormValues] = useState<ExtendedFormValues | null>(null);
const [confirmPasswordErrorMessage, setConfirmPasswordDeleteErrorMessage] = useState("");
const [confirmAuthEmailChangeWarningDialogOpen, setConfirmAuthEmailChangeWarningDialogOpen] =
useState(false);
const [showCreateAccountPasswordDialog, setShowCreateAccountPasswordDialog] = useState(false);
const [showAccountDisconnectWarning, setShowAccountDisconnectWarning] = useState(false);
const [showSecondaryEmailModalOpen, setShowSecondaryEmailModalOpen] = useState(false);
const [secondaryEmailAddErrorMessage, setSecondaryEmailAddErrorMessage] = useState("");
const [newlyAddedSecondaryEmail, setNewlyAddedSecondaryEmail] = useState<undefined | string>(undefined);
@@ -215,12 +208,6 @@ const ProfileView = () => {
confirmPasswordMutation.mutate({ passwordInput: password });
};
const onConfirmAuthEmailChange = (e: Event | React.MouseEvent<HTMLElement, MouseEvent>) => {
e.preventDefault();
if (tempFormValues) updateProfileMutation.mutate(tempFormValues);
};
const onConfirmButton = (e: Event | React.MouseEvent<HTMLElement, MouseEvent>) => {
e.preventDefault();
if (isCALIdentityProvider) {
@@ -304,10 +291,6 @@ const ProfileView = () => {
if (values.email !== user.email && isCALIdentityProvider) {
setTempFormValues(values);
setConfirmPasswordOpen(true);
} else if (values.email !== user.email && !isCALIdentityProvider) {
setTempFormValues(values);
// Opens a dialog warning the change
setConfirmAuthEmailChangeWarningDialogOpen(true);
} else {
updateProfileMutation.mutate(values);
}
@@ -317,6 +300,15 @@ const ProfileView = () => {
resendVerifyEmailMutation.mutate({ email });
showToast(t("email_sent"), "success");
}}
handleAccountDisconnect={(values) => {
if (isCALIdentityProvider) return;
if (user?.passwordAdded) {
setTempFormValues(values);
setShowAccountDisconnectWarning(true);
return;
}
setShowCreateAccountPasswordDialog(true);
}}
extraField={
<div className="mt-6">
<UsernameAvailabilityField
@@ -330,6 +322,7 @@ const ProfileView = () => {
/>
</div>
}
isCALIdentityProvider={isCALIdentityProvider}
/>
<div className="border-subtle mt-6 rounded-lg rounded-b-none border border-b-0 p-6">
@@ -433,20 +426,34 @@ const ProfileView = () => {
</DialogContent>
</Dialog>
{/* If changing email from !CAL Login */}
<Dialog
open={confirmAuthEmailChangeWarningDialogOpen}
onOpenChange={setConfirmAuthEmailChangeWarningDialogOpen}>
<Dialog open={showCreateAccountPasswordDialog} onOpenChange={setShowCreateAccountPasswordDialog}>
<DialogContent
title={t("confirm_auth_change")}
description={t("confirm_auth_email_change")}
title={t("create_account_password")}
description={t("create_account_password_hint")}
type="creation"
Icon="triangle-alert">
<DialogFooter>
<DialogClose />
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showAccountDisconnectWarning} onOpenChange={setShowAccountDisconnectWarning}>
<DialogContent
title={t("disconnect_account")}
description={t("disconnect_account_hint")}
type="creation"
Icon="triangle-alert">
<DialogFooter>
<Button
color="primary"
loading={updateProfileMutation.isPending}
onClick={(e) => onConfirmAuthEmailChange(e)}>
onClick={() => {
updateProfileMutation.mutate({
...tempFormValues,
unlinkConnectedAccount: true,
});
setShowAccountDisconnectWarning(false);
}}>
{t("confirm")}
</Button>
<DialogClose />
@@ -497,21 +504,25 @@ const ProfileForm = ({
onSubmit,
handleAddSecondaryEmail,
handleResendVerifyEmail,
handleAccountDisconnect,
extraField,
isPending = false,
isFallbackImg,
user,
userOrganization,
isCALIdentityProvider,
}: {
defaultValues: FormValues;
onSubmit: (values: ExtendedFormValues) => void;
handleAddSecondaryEmail: () => void;
handleResendVerifyEmail: (email: string) => void;
handleAccountDisconnect: (values: ExtendedFormValues) => void;
extraField?: React.ReactNode;
isPending: boolean;
isFallbackImg: boolean;
user: RouterOutputs["viewer"]["me"];
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
isCALIdentityProvider: boolean;
}) => {
const { t } = useLocale();
const [firstRender, setFirstRender] = useState(true);
@@ -553,7 +564,7 @@ const ProfileForm = ({
keyName: "itemId",
});
const handleFormSubmit = (values: FormValues) => {
const getUpdatedFormValues = (values: FormValues) => {
const changedFields = formMethods.formState.dirtyFields?.secondaryEmails || [];
const updatedValues: FormValues = {
...values,
@@ -585,10 +596,19 @@ const ProfileForm = ({
...updatedEmails.map((email) => ({ ...email, isDeleted: false })),
...deletedEmails.map((email) => ({ ...email, isDeleted: true })),
].map((secondaryEmail) => pick(secondaryEmail, ["id", "email", "isDeleted"]));
onSubmit({
return {
...updatedValues,
secondaryEmails,
});
};
};
const handleFormSubmit = (values: FormValues) => {
onSubmit(getUpdatedFormValues(values));
};
const onDisconnect = () => {
handleAccountDisconnect(getUpdatedFormValues(formMethods.getValues()));
};
const {
@@ -695,6 +715,24 @@ const ProfileForm = ({
setFirstRender={setFirstRender}
/>
</div>
{/* // For Non-Cal indentities, we merge the values from DB and the user logging in,
so essentially there is no point in allowing them to disconnect, since when they log in they will get logged into the same account */}
{!isCALIdentityProvider && user.email !== user.identityProviderEmail && (
<div className="mt-6">
<Label>Connected accounts</Label>
<div className="flex items-center">
<span className="text-default text-sm capitalize">{user.identityProvider.toLowerCase()}</span>
{user.identityProviderEmail && (
<span className="text-default ml-2 text-sm">{user.identityProviderEmail}</span>
)}
<div className="flex flex-1 justify-end">
<Button color="destructive" onClick={onDisconnect} size="sm">
Disconnect
</Button>
</div>
</div>
</div>
)}
</div>
<SectionBottomActions align="end">
<Button
+31 -13
View File
@@ -122,6 +122,15 @@ const PasswordView = ({ user }: PasswordViewProps) => {
},
});
const createAccountPasswordMutation = trpc.viewer.auth.createAccountPassword.useMutation({
onSuccess: () => {
showToast(t("password_reset_email", { email: user.email }), "success");
},
onError: (error) => {
showToast(`${t("error_creating_account_password")}, ${t(error.message)}`, "error");
},
});
const formMethods = useForm<ChangePasswordSessionFormValues>({
defaultValues: {
oldPassword: "",
@@ -139,6 +148,7 @@ const PasswordView = ({ user }: PasswordViewProps) => {
{ shouldFocus: true }
);
}
if (!newPassword.length) {
formMethods.setError(
"newPassword",
@@ -165,19 +175,27 @@ const PasswordView = ({ user }: PasswordViewProps) => {
return (
<>
<Meta title={t("password")} description={t("password_description")} borderInShellHeader={true} />
{user && user.identityProvider !== IdentityProvider.CAL ? (
<div className="border-subtle rounded-b-xl border border-t-0 px-4 py-6 sm:px-6">
<h2 className="font-cal text-emphasis text-lg font-medium leading-6">
{t("account_managed_by_identity_provider", {
provider: identityProviderNameMap[user.identityProvider],
})}
</h2>
{user && user.identityProvider !== IdentityProvider.CAL && !user.passwordAdded ? (
<div className="border-subtle rounded-b-xl border border-t-0">
<div className="px-4 py-6 sm:px-6">
<h2 className="font-cal text-emphasis text-lg font-medium leading-6">
{t("account_managed_by_identity_provider", {
provider: identityProviderNameMap[user.identityProvider],
})}
</h2>
<p className="text-subtle mt-1 text-sm">
{t("account_managed_by_identity_provider_description", {
provider: identityProviderNameMap[user.identityProvider],
})}
</p>
<p className="text-subtle mt-1 text-sm">
{t("account_managed_by_identity_provider_description", {
provider: identityProviderNameMap[user.identityProvider],
})}
</p>
<Button
className="mt-3"
onClick={() => createAccountPasswordMutation.mutate()}
loading={createAccountPasswordMutation.isPending}>
{t("create_account_password")}
</Button>
</div>
</div>
) : (
<Form form={formMethods} handleSubmit={handleSubmit}>
@@ -293,7 +311,7 @@ const PasswordView = ({ user }: PasswordViewProps) => {
};
const PasswordViewWrapper = () => {
const { data: user, isPending } = trpc.viewer.me.useQuery();
const { data: user, isPending } = trpc.viewer.me.useQuery({ includePasswordAdded: true });
const { t } = useLocale();
if (isPending || !user)
return <SkeletonLoader title={t("password")} description={t("password_description")} />;
@@ -37,7 +37,7 @@ const TwoFactorAuthView = () => {
const utils = trpc.useContext();
const { t } = useLocale();
const { data: user, isPending } = trpc.viewer.me.useQuery();
const { data: user, isPending } = trpc.viewer.me.useQuery({ includePasswordAdded: true });
const [enableModalOpen, setEnableModalOpen] = useState<boolean>(false);
const [disableModalOpen, setDisableModalOpen] = useState<boolean>(false);
@@ -46,7 +46,7 @@ const TwoFactorAuthView = () => {
return <SkeletonLoader title={t("2fa")} description={t("set_up_two_factor_authentication")} />;
const isCalProvider = user?.identityProvider === "CAL";
const canSetupTwoFactor = !isCalProvider && !user?.twoFactorEnabled;
const canSetupTwoFactor = !isCalProvider && !user?.twoFactorEnabled && !user?.passwordAdded;
return (
<>
<Meta title={t("2fa")} description={t("set_up_two_factor_authentication")} borderInShellHeader={true} />
+12 -7
View File
@@ -252,8 +252,6 @@
"already_have_an_account": "Already have an account?",
"create_account": "Create Account",
"confirm_password": "Confirm password",
"confirm_auth_change": "This will change the way you log in",
"confirm_auth_email_change": "Changing the email address will disconnect your current authentication method to log in to Cal.com. We will ask you to verify your new email address. Moving forward, you will be logged out and use your new email address to log in instead of your current authentication method after setting your password by following the instructions that will be sent to your mail.",
"reset_your_password": "Set your new password with the instructions sent to your email address.",
"org_banner_instructions": "Please upload image of {{width}} width and {{height}} height.",
"email_change": "Log back in with your new email address and password.",
@@ -815,7 +813,7 @@
"additional_input_description": "Require scheduler to input additional inputs prior the booking is confirmed",
"label": "Label",
"placeholder": "Placeholder",
"display_add_to_calendar_organizer":"Use \"Add to calendar\" email as the organizer",
"display_add_to_calendar_organizer": "Use \"Add to calendar\" email as the organizer",
"display_email_as_organizer": "We'll display this email address as the organizer, and send confirmation emails here.",
"if_enabled_email_address_as_organizer": "If enabled, we'll display the email address from your \"Add to calendar\" as organizer and send confirmation emails there",
"reconnect_calendar_to_use": "Please note that you may need to disconnect and then reconnect your 'Add to Calendar' account to use this feature.",
@@ -1129,7 +1127,7 @@
"connect_apple_server": "Connect to Apple Server",
"calendar_url": "Calendar URL",
"apple_server_generate_password": "Generate an app specific password to use with {{appName}} at",
"unable_to_add_apple_calendar":"Unable to add this Apple Calendar account. Please ensure you're using an app-specific password rather than your account password.",
"unable_to_add_apple_calendar": "Unable to add this Apple Calendar account. Please ensure you're using an app-specific password rather than your account password.",
"credentials_stored_encrypted": "Your credentials will be stored and encrypted.",
"it_stored_encrypted": "It will be stored and encrypted.",
"go_to_app_store": "Go to App Store",
@@ -2200,8 +2198,8 @@
"access_bookings": "Read, edit, delete your bookings",
"allow_client_to_do": "Allow {{clientName}} to do this?",
"oauth_access_information": "By clicking allow, you allow this app to use your information in accordance with their terms of service and privacy policy. You can remove access in the {{appName}} App Store.",
"oauth_form_title":"OAuth client creation form",
"oauth_form_description":"This is the form to create a new OAuth client",
"oauth_form_title": "OAuth client creation form",
"oauth_form_description": "This is the form to create a new OAuth client",
"allow": "Allow",
"view_only_edit_availability_not_onboarded": "This user has not completed onboarding. You will not be able to set their availability until they have completed onboarding.",
"view_only_edit_availability": "You are viewing this user's availability. You can only edit your own availability.",
@@ -2364,6 +2362,13 @@
"delete_org_eventtypes": "Delete individual event types",
"lock_org_users_eventtypes": "Lock individual event type creation",
"lock_org_users_eventtypes_description": "Prevent members from creating their own event types.",
"create_account_password": "Create account password",
"error_creating_account_password": "Failed to create account password",
"cannot_create_account_password_cal_provider": "Cannot create account password for cal accounts",
"cannot_create_account_password_already_existing": "Cannot create account password for already created ones",
"create_account_password_hint": "You don't have an account password, create one by navigating to Security -> Password. Can't disconnect until an account password is created.",
"disconnect_account": "Disconnect connected account",
"disconnect_account_hint": "Disconnecting your connected account will change the way you log in. You will only be able to login to your account using email + password",
"cookie_consent_checkbox": "I consent to our privacy policy and cookie usage",
"make_a_call": "Make a Call",
"select_account_header": "Select Account",
@@ -2399,4 +2404,4 @@
"rating_url_info":"The URL for Rating Feedback Form",
"no_show_url_info":"The URL for No Show Feedback",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
}
@@ -72,7 +72,6 @@ export default function CalComAdapter(prismaClient: PrismaClient) {
}
},
linkAccount: (data: Prisma.AccountCreateInput) => prismaClient.account.create({ data }),
// @NOTE: All methods below here are not being used but leaved if they are required
unlinkAccount: (provider_providerAccountId: Prisma.AccountProviderProviderAccountIdCompoundUniqueInput) =>
prismaClient.account.delete({ where: { provider_providerAccountId } }),
};
@@ -1,5 +1,4 @@
import type { Membership, Team, UserPermissionRole } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import type { AuthOptions, Session } from "next-auth";
import type { JWT } from "next-auth/jwt";
import { encode } from "next-auth/jwt";
@@ -130,13 +129,10 @@ const providers: Provider[] = [
identifier: user.email,
});
if (user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) {
throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled);
}
if (!user.password?.hash && user.identityProvider == IdentityProvider.CAL) {
if (!user.password?.hash && user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) {
throw new Error(ErrorCode.IncorrectEmailPassword);
}
if (!user.password?.hash && user.identityProvider !== IdentityProvider.CAL && !credentials.totpCode) {
if (!user.password?.hash && user.identityProvider == IdentityProvider.CAL) {
throw new Error(ErrorCode.IncorrectEmailPassword);
}
@@ -725,7 +721,11 @@ export const AUTH_OPTIONS: AuthOptions = {
try {
// If old user without Account entry we link their google account
if (existingUser.accounts.length === 0) {
const linkAccountWithUserData = { ...account, userId: existingUser.id };
const linkAccountWithUserData = {
...account,
userId: existingUser.id,
providerEmail: user.email,
};
await calcomAdapter.linkAccount(linkAccountWithUserData);
}
} catch (error) {
@@ -824,7 +824,7 @@ export const AUTH_OPTIONS: AuthOptions = {
existingUserWithEmail.identityProvider === IdentityProvider.CAL &&
(idP === IdentityProvider.GOOGLE || idP === IdentityProvider.SAML)
) {
const updatedUser = await prisma.user.update({
await prisma.user.update({
where: { email: existingUserWithEmail.email },
// also update email to the IdP email
data: {
@@ -834,22 +834,6 @@ export const AUTH_OPTIONS: AuthOptions = {
},
});
// safely delete password from UserPassword table if it exists
try {
await prisma.userPassword.delete({
where: { userId: updatedUser.id },
});
} catch (err) {
if (
err instanceof PrismaClientKnownRequestError &&
(err.code === "P2025" || err.code === "P2016")
) {
log.warn("UserPassword not found for user", safeStringify(existingUserWithEmail));
} else {
log.warn("Could not delete UserPassword for user", safeStringify(existingUserWithEmail));
}
}
if (existingUserWithEmail.twoFactorEnabled) {
return loginWithTotp(existingUserWithEmail.email);
} else {
@@ -886,7 +870,7 @@ export const AUTH_OPTIONS: AuthOptions = {
},
});
const linkAccountNewUserData = { ...account, userId: newUser.id };
const linkAccountNewUserData = { ...account, userId: newUser.id, providerEmail: user.email };
await calcomAdapter.linkAccount(linkAccountNewUserData);
if (account.twoFactorEnabled) {
@@ -138,7 +138,7 @@ const organizationRequiredKeys = ["organization"];
const useTabs = () => {
const session = useSession();
const { data: user } = trpc.viewer.me.useQuery();
const { data: user } = trpc.viewer.me.useQuery({ includePasswordAdded: true });
const orgBranding = useOrgBranding();
const isAdmin = session.data?.user.role === UserPermissionRole.ADMIN;
@@ -153,7 +153,8 @@ const useTabs = () => {
} else if (
tab.href === "/settings/security" &&
user?.identityProvider === IdentityProvider.GOOGLE &&
!user?.twoFactorEnabled
!user?.twoFactorEnabled &&
!user?.passwordAdded
) {
tab.children = tab?.children?.filter(
(childTab) => childTab.href !== "/settings/security/two-factor-auth"
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Account" ADD COLUMN "providerEmail" TEXT;
+1
View File
@@ -754,6 +754,7 @@ model Account {
type String
provider String
providerAccountId String
providerEmail String?
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
@@ -51,6 +51,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
twoFactorEnabled: true,
disableImpersonation: true,
identityProvider: true,
identityProviderId: true,
brandColor: true,
darkBrandColor: true,
away: true,
@@ -4,16 +4,20 @@ import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import type { TMeInputSchema } from "./me.schema";
type MeOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
session: Session;
};
input: TMeInputSchema;
};
export const meHandler = async ({ ctx }: MeOptions) => {
export const meHandler = async ({ ctx, input }: MeOptions) => {
const crypto = await import("crypto");
const { user: sessionUser, session } = ctx;
@@ -38,6 +42,35 @@ export const meHandler = async ({ ctx }: MeOptions) => {
},
});
let passwordAdded = false;
if (user.identityProvider !== IdentityProvider.CAL && input?.includePasswordAdded) {
const userWithPassword = await prisma.user.findUnique({
where: {
id: user.id,
},
select: {
password: true,
},
});
if (userWithPassword?.password?.hash) {
passwordAdded = true;
}
}
let identityProviderEmail = "";
if (user.identityProviderId) {
const account = await prisma.account.findUnique({
where: {
provider_providerAccountId: {
provider: user.identityProvider.toLocaleLowerCase(),
providerAccountId: user.identityProviderId,
},
},
select: { providerEmail: true },
});
identityProviderEmail = account?.providerEmail || "";
}
// Destructuring here only makes it more illegible
// pick only the part we want to expose in the API
return {
@@ -61,6 +94,7 @@ export const meHandler = async ({ ctx }: MeOptions) => {
twoFactorEnabled: user.twoFactorEnabled,
disableImpersonation: user.disableImpersonation,
identityProvider: user.identityProvider,
identityProviderEmail,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
away: user.away,
@@ -80,5 +114,6 @@ export const meHandler = async ({ ctx }: MeOptions) => {
profile: user.profile ?? null,
profiles: allUserEnrichedProfiles,
secondaryEmails,
...(passwordAdded ? { passwordAdded } : {}),
};
};
@@ -1 +1,9 @@
export {};
import { z } from "zod";
export const ZMeInputSchema = z
.object({
includePasswordAdded: z.boolean().optional(),
})
.optional();
export type TMeInputSchema = z.infer<typeof ZMeInputSchema>;
@@ -1,7 +1,8 @@
import authedProcedure from "../../../procedures/authedProcedure";
import { ZMeInputSchema } from "../me.schema";
export const me = authedProcedure.query(async ({ ctx }) => {
export const me = authedProcedure.input(ZMeInputSchema).query(async ({ ctx, input }) => {
const handler = (await import("../me.handler")).meHandler;
return handler({ ctx });
return handler({ ctx, input });
});
@@ -6,7 +6,6 @@ import { v4 as uuidv4 } from "uuid";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
import { passwordResetRequest } from "@calcom/features/auth/lib/passwordResetRequest";
import { sendChangeOfEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
@@ -73,6 +72,9 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
const secondaryEmails = input?.secondaryEmails || [];
delete input.secondaryEmails;
const unlinkConnectedAccount = input?.unlinkConnectedAccount || false;
delete input.unlinkConnectedAccount;
const data: Prisma.UserUpdateInput = {
...input,
// DO NOT OVERWRITE AVATAR.
@@ -81,9 +83,6 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
secondaryEmails: undefined,
};
// some actions can invalidate a user session.
let signOutUser = false;
let passwordReset = false;
let isPremiumUsername = false;
const layoutError = validateBookerLayouts(input?.metadata?.defaultBookerLayouts || null);
@@ -142,11 +141,6 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
}
const hasEmailBeenChanged = data.email && user.email !== data.email;
// check if we are changing email and identity provider is not CAL
const hasEmailChangedOnNonCalProvider =
hasEmailBeenChanged && user.identityProvider !== IdentityProvider.CAL;
const hasEmailChangedOnCalProvider = hasEmailBeenChanged && user.identityProvider === IdentityProvider.CAL;
let secondaryEmail:
| {
id: number;
@@ -166,7 +160,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
emailVerified: true,
},
});
if (emailVerification && hasEmailChangedOnCalProvider) {
if (emailVerification) {
if (secondaryEmail?.emailVerified) {
data.emailVerified = secondaryEmail.emailVerified;
} else {
@@ -186,7 +180,17 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
}
}
if (hasEmailChangedOnNonCalProvider) {
if (unlinkConnectedAccount) {
// Unlink the account
const CalComAdapter = (await import("@calcom/features/auth/lib/next-auth-custom-adapter")).default;
const calcomAdapter = CalComAdapter(prisma);
// If it fails to delete, don't stop because the users login data might not be present
try {
await calcomAdapter.unlinkAccount({
provider: user.identityProvider.toLocaleLowerCase(),
providerAccountId: user.identityProviderId || "",
});
} catch {}
// Only validate if we're changing email
data.identityProvider = IdentityProvider.CAL;
data.identityProviderId = null;
@@ -297,14 +301,6 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
});
}
if (hasEmailChangedOnNonCalProvider) {
// Because the email has changed, we are now attempting to use the CAL provider-
// which has no password yet. We have to send the reset password email.
await passwordResetRequest(updatedUser);
signOutUser = true;
passwordReset = true;
}
// Sync Services
await syncServicesUpdateWebUser(updatedUser);
@@ -320,7 +316,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
});
}
if (updatedUser && hasEmailChangedOnCalProvider) {
if (updatedUser) {
// Skip sending verification email when user tries to change his primary email to a verified secondary email
if (secondaryEmail?.emailVerified) {
secondaryEmails.push({
@@ -398,12 +394,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
return {
...input,
email:
emailVerification && hasEmailChangedOnCalProvider && !secondaryEmail?.emailVerified
? user.email
: input.email,
signOutUser,
passwordReset,
email: emailVerification && !secondaryEmail?.emailVerified ? user.email : input.email,
avatarUrl: updatedUser.avatarUrl,
hasEmailBeenChanged,
sendEmailVerification: emailVerification && !secondaryEmail?.emailVerified,
@@ -38,6 +38,7 @@ export const ZUpdateProfileInputSchema = z.object({
})
)
.optional(),
unlinkConnectedAccount: z.boolean().optional(),
});
export type TUpdateProfileInputSchema = z.infer<typeof ZUpdateProfileInputSchema>;
@@ -15,6 +15,7 @@ type AuthRouterHandlerCache = {
resendVerifyEmail?: typeof import("./resendVerifyEmail.handler").resendVerifyEmail;
sendVerifyEmailCode?: typeof import("./sendVerifyEmailCode.handler").sendVerifyEmailCodeHandler;
resendVerifySecondaryEmail?: typeof import("./resendVerifyEmail.handler").resendVerifyEmail;
createAccountPassword?: typeof import("./createAccountPassword.handler").createAccountPasswordHandler;
};
const UNSTABLE_HANDLER_CACHE: AuthRouterHandlerCache = {};
@@ -107,4 +108,21 @@ export const authRouter = router({
ctx,
});
}),
createAccountPassword: authedProcedure.mutation(async ({ ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.createAccountPassword) {
UNSTABLE_HANDLER_CACHE.createAccountPassword = await import("./createAccountPassword.handler").then(
(mod) => mod.createAccountPasswordHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.createAccountPassword) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.createAccountPassword({
ctx,
});
}),
});
@@ -22,7 +22,17 @@ export const changePasswordHandler = async ({ input, ctx }: ChangePasswordOption
const { user } = ctx;
if (user.identityProvider !== IdentityProvider.CAL) {
throw new TRPCError({ code: "FORBIDDEN", message: "THIRD_PARTY_IDENTITY_PROVIDER_ENABLED" });
const userWithPassword = await prisma.user.findUnique({
where: {
id: user.id,
},
select: {
password: true,
},
});
if (!userWithPassword?.password?.hash) {
throw new TRPCError({ code: "FORBIDDEN", message: "THIRD_PARTY_IDENTITY_PROVIDER_ENABLED" });
}
}
const currentPasswordQuery = await prisma.userPassword.findFirst({
@@ -0,0 +1,36 @@
import { passwordResetRequest } from "@calcom/features/auth/lib/passwordResetRequest";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
type CreateAccountPasswordOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
};
export const createAccountPasswordHandler = async ({ ctx }: CreateAccountPasswordOptions) => {
const { user } = ctx;
const isCal = user.identityProvider === IdentityProvider.CAL;
if (isCal) {
throw new TRPCError({ code: "FORBIDDEN", message: "cannot_create_account_password_cal_provider" });
}
const userWithPassword = await prisma.user.findUnique({
where: {
id: user.id,
},
select: {
password: true,
},
});
if (!isCal && userWithPassword?.password?.hash) {
throw new TRPCError({ code: "FORBIDDEN", message: "cannot_create_account_password_already_existing" });
}
await passwordResetRequest(user);
};
@@ -0,0 +1 @@
export {};