diff --git a/apps/web/pages/api/auth/two-factor/totp/setup.ts b/apps/web/pages/api/auth/two-factor/totp/setup.ts index 628346d2ca..03eb5284ff 100644 --- a/apps/web/pages/api/auth/two-factor/totp/setup.ts +++ b/apps/web/pages/api/auth/two-factor/totp/setup.ts @@ -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 }); } diff --git a/apps/web/pages/auth/login.tsx b/apps/web/pages/auth/login.tsx index 18540fc607..3ea55fa1b8 100644 --- a/apps/web/pages/auth/login.tsx +++ b/apps/web/pages/auth/login.tsx @@ -59,7 +59,7 @@ inferSSRProps & 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(); diff --git a/apps/web/pages/settings/my-account/profile.tsx b/apps/web/pages/settings/my-account/profile.tsx index eb1605bfc7..63c6567cc7 100644 --- a/apps/web/pages/settings/my-account/profile.tsx +++ b/apps/web/pages/settings/my-account/profile.tsx @@ -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(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); @@ -215,12 +208,6 @@ const ProfileView = () => { confirmPasswordMutation.mutate({ passwordInput: password }); }; - const onConfirmAuthEmailChange = (e: Event | React.MouseEvent) => { - e.preventDefault(); - - if (tempFormValues) updateProfileMutation.mutate(tempFormValues); - }; - const onConfirmButton = (e: Event | React.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={
{ />
} + isCALIdentityProvider={isCALIdentityProvider} />
@@ -433,20 +426,34 @@ const ProfileView = () => { - {/* If changing email from !CAL Login */} - + + + + + + + + + @@ -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} />
+ {/* // 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 && ( +
+ +
+ {user.identityProvider.toLowerCase()} + {user.identityProviderEmail && ( + {user.identityProviderEmail} + )} +
+ +
+
+
+ )} + ) : (
@@ -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 ; diff --git a/apps/web/pages/settings/security/two-factor-auth.tsx b/apps/web/pages/settings/security/two-factor-auth.tsx index 712b6867ee..567d585288 100644 --- a/apps/web/pages/settings/security/two-factor-auth.tsx +++ b/apps/web/pages/settings/security/two-factor-auth.tsx @@ -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(false); const [disableModalOpen, setDisableModalOpen] = useState(false); @@ -46,7 +46,7 @@ const TwoFactorAuthView = () => { return ; const isCalProvider = user?.identityProvider === "CAL"; - const canSetupTwoFactor = !isCalProvider && !user?.twoFactorEnabled; + const canSetupTwoFactor = !isCalProvider && !user?.twoFactorEnabled && !user?.passwordAdded; return ( <> diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 95fb1fef09..dbae30bf85 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" -} +} \ No newline at end of file diff --git a/packages/features/auth/lib/next-auth-custom-adapter.ts b/packages/features/auth/lib/next-auth-custom-adapter.ts index b811eae986..a3aff5e12a 100644 --- a/packages/features/auth/lib/next-auth-custom-adapter.ts +++ b/packages/features/auth/lib/next-auth-custom-adapter.ts @@ -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 } }), }; diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index d38b3d67d1..ada217709d 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -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) { diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index bb044fb2bd..e633a067ad 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -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" diff --git a/packages/prisma/migrations/20240318085938_add_provider_email/migration.sql b/packages/prisma/migrations/20240318085938_add_provider_email/migration.sql new file mode 100644 index 0000000000..19595aae71 --- /dev/null +++ b/packages/prisma/migrations/20240318085938_add_provider_email/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Account" ADD COLUMN "providerEmail" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 4fb8350d31..e3a3d5307f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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? diff --git a/packages/trpc/server/middlewares/sessionMiddleware.ts b/packages/trpc/server/middlewares/sessionMiddleware.ts index c694332f3e..6cfcf1b959 100644 --- a/packages/trpc/server/middlewares/sessionMiddleware.ts +++ b/packages/trpc/server/middlewares/sessionMiddleware.ts @@ -51,6 +51,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe; 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 } : {}), }; }; diff --git a/packages/trpc/server/routers/loggedInViewer/me.schema.ts b/packages/trpc/server/routers/loggedInViewer/me.schema.ts index cb0ff5c3b5..e405c374f0 100644 --- a/packages/trpc/server/routers/loggedInViewer/me.schema.ts +++ b/packages/trpc/server/routers/loggedInViewer/me.schema.ts @@ -1 +1,9 @@ -export {}; +import { z } from "zod"; + +export const ZMeInputSchema = z + .object({ + includePasswordAdded: z.boolean().optional(), + }) + .optional(); + +export type TMeInputSchema = z.infer; diff --git a/packages/trpc/server/routers/loggedInViewer/procedures/me.ts b/packages/trpc/server/routers/loggedInViewer/procedures/me.ts index ac5d1f5367..9ff7c27588 100644 --- a/packages/trpc/server/routers/loggedInViewer/procedures/me.ts +++ b/packages/trpc/server/routers/loggedInViewer/procedures/me.ts @@ -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 }); }); diff --git a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts index 87519a78ab..d06ff775bb 100644 --- a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts @@ -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, diff --git a/packages/trpc/server/routers/loggedInViewer/updateProfile.schema.ts b/packages/trpc/server/routers/loggedInViewer/updateProfile.schema.ts index 6875d0161a..c621f69856 100644 --- a/packages/trpc/server/routers/loggedInViewer/updateProfile.schema.ts +++ b/packages/trpc/server/routers/loggedInViewer/updateProfile.schema.ts @@ -38,6 +38,7 @@ export const ZUpdateProfileInputSchema = z.object({ }) ) .optional(), + unlinkConnectedAccount: z.boolean().optional(), }); export type TUpdateProfileInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/auth/_router.tsx b/packages/trpc/server/routers/viewer/auth/_router.tsx index be4747bb7c..05ceeae9f3 100644 --- a/packages/trpc/server/routers/viewer/auth/_router.tsx +++ b/packages/trpc/server/routers/viewer/auth/_router.tsx @@ -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, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/auth/changePassword.handler.ts b/packages/trpc/server/routers/viewer/auth/changePassword.handler.ts index 919b1cf99c..042e3585a2 100644 --- a/packages/trpc/server/routers/viewer/auth/changePassword.handler.ts +++ b/packages/trpc/server/routers/viewer/auth/changePassword.handler.ts @@ -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({ diff --git a/packages/trpc/server/routers/viewer/auth/createAccountPassword.handler.ts b/packages/trpc/server/routers/viewer/auth/createAccountPassword.handler.ts new file mode 100644 index 0000000000..7e0da64028 --- /dev/null +++ b/packages/trpc/server/routers/viewer/auth/createAccountPassword.handler.ts @@ -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; + }; +}; + +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); +}; diff --git a/packages/trpc/server/routers/viewer/auth/createAccountPassword.schema.ts b/packages/trpc/server/routers/viewer/auth/createAccountPassword.schema.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/auth/createAccountPassword.schema.ts @@ -0,0 +1 @@ +export {};