Merge branch 'main' into fix/after-meeting-ends-migration

This commit is contained in:
kodiakhq[bot] 2022-08-31 20:59:15 +00:00 committed by GitHub
commit 5f589e34c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 301 additions and 138 deletions

View File

@ -2,11 +2,10 @@ import React, { useEffect, useState } from "react";
import useDigitInput from "react-digit-input";
import { useFormContext } from "react-hook-form";
import { Input } from "@calcom/ui/form/fields";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Label, Input } from "@calcom/ui/form/fields";
import { useLocale } from "@lib/hooks/useLocale";
export default function TwoFactor() {
export default function TwoFactor({ center = true }) {
const [value, onChange] = useState("");
const { t } = useLocale();
const methods = useFormContext();
@ -26,7 +25,9 @@ export default function TwoFactor() {
const className = "h-12 w-12 !text-xl text-center";
return (
<div className="mx-auto !mt-0 max-w-sm">
<div className={center ? "mx-auto !mt-0 max-w-sm" : "!mt-0 max-w-sm"}>
<Label className="mt-4"> {t("2fa_code")}</Label>
<p className="mb-4 text-sm text-gray-500">{t("2fa_enabled_instructions")}</p>
<input hidden type="hidden" value={value} {...methods.register("totpCode")} />
<div className="flex flex-row justify-between">

View File

@ -1,10 +1,14 @@
import { SyntheticEvent, useState } from "react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { ErrorCode } from "@calcom/lib/auth";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button";
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
import { Form, Label } from "@calcom/ui/form/fields";
import { PasswordField } from "@calcom/ui/v2/core/form/fields";
import { ErrorCode } from "@lib/auth";
import { useLocale } from "@lib/hooks/useLocale";
import TwoFactor from "@components/auth/TwoFactor";
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
import TwoFactorModalHeader from "./TwoFactorModalHeader";
@ -16,15 +20,17 @@ interface DisableTwoFactorAuthModalProps {
onDisable: () => void;
}
interface DisableTwoFactorValues {
totpCode: string;
password: string;
}
const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuthModalProps) => {
const [password, setPassword] = useState("");
const [isDisabling, setIsDisabling] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { t } = useLocale();
async function handleDisable(e: SyntheticEvent) {
e.preventDefault();
const form = useForm<DisableTwoFactorValues>();
async function handleDisable({ totpCode, password }: DisableTwoFactorValues) {
if (isDisabling) {
return;
}
@ -32,7 +38,7 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
setErrorMessage(null);
try {
const response = await TwoFactorAuthAPI.disable(password);
const response = await TwoFactorAuthAPI.disable(password, totpCode);
if (response.status === 200) {
onDisable();
return;
@ -41,6 +47,12 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
const body = await response.json();
if (body.error === ErrorCode.IncorrectPassword) {
setErrorMessage(t("incorrect_password"));
}
if (body.error === ErrorCode.SecondFactorRequired) {
setErrorMessage(t("2fa_required"));
}
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
setErrorMessage(t("incorrect_2fa"));
} else {
setErrorMessage(t("something_went_wrong"));
}
@ -55,41 +67,32 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
return (
<Dialog open={true}>
<DialogContent>
<TwoFactorModalHeader title={t("disable_2fa")} description={t("disable_2fa_recommendation")} />
<Form form={form} handleSubmit={handleDisable}>
<TwoFactorModalHeader title={t("disable_2fa")} description={t("disable_2fa_recommendation")} />
<form onSubmit={handleDisable}>
<div className="mb-4">
<label htmlFor="password" className="mt-4 block text-sm font-medium text-gray-700">
{t("password")}
</label>
<div className="mt-1">
<input
type="password"
name="password"
id="password"
required
value={password}
onInput={(e) => setPassword(e.currentTarget.value)}
className="block w-full rounded-sm border-gray-300 text-sm"
/>
</div>
<PasswordField
labelProps={{
className: "block text-sm font-medium text-gray-700",
}}
{...form.register("password")}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
/>
<Label className="mt-4"> {t("2fa_code")}</Label>
<TwoFactor center={false} />
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
</div>
</form>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<Button
type="submit"
className="ltr:ml-2 rtl:mr-2"
onClick={handleDisable}
disabled={password.length === 0 || isDisabling}>
{t("disable")}
</Button>
<Button color="secondary" onClick={onCancel}>
{t("cancel")}
</Button>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<Button type="submit" className="ltr:ml-2 rtl:mr-2" disabled={isDisabling}>
{t("disable")}
</Button>
<Button color="secondary" onClick={onCancel}>
{t("cancel")}
</Button>
</div>
</Form>
</DialogContent>
</Dialog>
);

View File

@ -1,10 +1,13 @@
import React, { SyntheticEvent, useState } from "react";
import React, { BaseSyntheticEvent, useState } from "react";
import { useForm } from "react-hook-form";
import { ErrorCode } from "@calcom/lib/auth";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button";
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
import { Form } from "@calcom/ui/v2/core/form/fields";
import { ErrorCode } from "@lib/auth";
import { useLocale } from "@lib/hooks/useLocale";
import TwoFactor from "@components/auth/TwoFactor";
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
import TwoFactorModalHeader from "./TwoFactorModalHeader";
@ -39,8 +42,14 @@ const WithStep = ({
return step === current ? children : null;
};
interface EnableTwoFactorValues {
totpCode: string;
}
const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps) => {
const { t } = useLocale();
const form = useForm<EnableTwoFactorValues>();
const setupDescriptions = {
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
@ -48,13 +57,12 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
};
const [step, setStep] = useState(SetupStep.ConfirmPassword);
const [password, setPassword] = useState("");
const [totpCode, setTotpCode] = useState("");
const [dataUri, setDataUri] = useState("");
const [secret, setSecret] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
async function handleSetup(e: SyntheticEvent) {
async function handleSetup(e: React.FormEvent) {
e.preventDefault();
if (isSubmitting) {
@ -88,10 +96,10 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
}
}
async function handleEnable(e: SyntheticEvent) {
e.preventDefault();
async function handleEnable({ totpCode }: EnableTwoFactorValues, e: BaseSyntheticEvent | undefined) {
e?.preventDefault();
if (isSubmitting || totpCode.length !== 6) {
if (isSubmitting) {
return;
}
@ -158,64 +166,43 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
<p className="text-center font-mono text-xs">{secret}</p>
</>
</WithStep>
<WithStep step={SetupStep.EnterTotpCode} current={step}>
<form onSubmit={handleEnable}>
<Form handleSubmit={handleEnable} form={form}>
<WithStep step={SetupStep.EnterTotpCode} current={step}>
<div className="mb-4">
<label htmlFor="code" className="mt-4 block text-sm font-medium text-gray-700">
{t("code")}
</label>
<div className="mt-1">
<input
type="text"
name="code"
id="code"
required
value={totpCode}
maxLength={6}
minLength={6}
inputMode="numeric"
onInput={(e) => setTotpCode(e.currentTarget.value)}
className="block w-full rounded-sm border-gray-300 text-sm"
autoComplete="one-time-code"
/>
</div>
<TwoFactor center />
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
</div>
</form>
</WithStep>
</WithStep>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<WithStep step={SetupStep.ConfirmPassword} current={step}>
<Button
type="submit"
className="ltr:ml-2 rtl:mr-2"
onClick={handleSetup}
disabled={password.length === 0 || isSubmitting}>
{t("continue")}
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<WithStep step={SetupStep.ConfirmPassword} current={step}>
<Button
type="submit"
className="ltr:ml-2 rtl:mr-2"
onClick={handleSetup}
disabled={password.length === 0 || isSubmitting}>
{t("continue")}
</Button>
</WithStep>
<WithStep step={SetupStep.DisplayQrCode} current={step}>
<Button
type="submit"
className="ltr:ml-2 rtl:mr-2"
onClick={() => setStep(SetupStep.EnterTotpCode)}>
{t("continue")}
</Button>
</WithStep>
<WithStep step={SetupStep.EnterTotpCode} current={step}>
<Button type="submit" className="ltr:ml-2 rtl:mr-2" disabled={isSubmitting}>
{t("enable")}
</Button>
</WithStep>
<Button color="secondary" onClick={onCancel}>
{t("cancel")}
</Button>
</WithStep>
<WithStep step={SetupStep.DisplayQrCode} current={step}>
<Button
type="submit"
className="ltr:ml-2 rtl:mr-2"
onClick={() => setStep(SetupStep.EnterTotpCode)}>
{t("continue")}
</Button>
</WithStep>
<WithStep step={SetupStep.EnterTotpCode} current={step}>
<Button
type="submit"
className="ltr:ml-2 rtl:mr-2"
onClick={handleEnable}
disabled={totpCode.length !== 6 || isSubmitting}>
{t("enable")}
</Button>
</WithStep>
<Button color="secondary" onClick={onCancel}>
{t("cancel")}
</Button>
</div>
</div>
</Form>
</DialogContent>
</Dialog>
);

View File

@ -19,10 +19,10 @@ const TwoFactorAuthAPI = {
});
},
async disable(password: string) {
async disable(password: string, code: string) {
return fetch("/api/auth/two-factor/totp/disable", {
method: "POST",
body: JSON.stringify({ password }),
body: JSON.stringify({ password, code }),
headers: {
"Content-Type": "application/json",
},

View File

@ -6,7 +6,7 @@ const TwoFactorModalHeader = ({ title, description }: { title: string; descripti
return (
<div className="mb-4 sm:flex sm:items-start">
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<ShieldCheckIcon className="h-6 w-6 text-black" />
<ShieldCheckIcon className="h-6 w-6 text-white" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="font-cal text-lg font-medium leading-6 text-gray-900" id="modal-title">

View File

@ -3,23 +3,24 @@ import { compare, hash } from "bcryptjs";
import { Session } from "next-auth";
import { getSession as getSessionInner, GetSessionParams } from "next-auth/react";
/** @deprecated use the one from `@calcom/lib/auth` */
export async function hashPassword(password: string) {
const hashedPassword = await hash(password, 12);
return hashedPassword;
}
/** @deprecated use the one from `@calcom/lib/auth` */
export async function verifyPassword(password: string, hashedPassword: string) {
const isValid = await compare(password, hashedPassword);
return isValid;
}
/** @deprecated use the one from `@calcom/lib/auth` */
export async function getSession(options: GetSessionParams): Promise<Session | null> {
const session = await getSessionInner(options);
// that these are equal are ensured in `[...nextauth]`'s callback
return session as Session | null;
}
/** @deprecated use the one from `@calcom/lib/auth` */
export enum ErrorCode {
UserNotFound = "user-not-found",
IncorrectPassword = "incorrect-password",
@ -35,7 +36,7 @@ export enum ErrorCode {
RateLimitExceeded = "rate-limit-exceeded",
InvalidPassword = "invalid-password",
}
/** @deprecated use the one from `@calcom/lib/auth` */
export const identityProviderNameMap: { [key in IdentityProvider]: string } = {
[IdentityProvider.CAL]: "Cal",
[IdentityProvider.GOOGLE]: "Google",

View File

@ -1,5 +1,7 @@
import { NextApiRequest, NextApiResponse } from "next";
import { authenticator } from "otplib";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import prisma from "@calcom/prisma";
import { ErrorCode, getSession, verifyPassword } from "@lib/auth";
@ -37,7 +39,40 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
if (!isCorrectPassword) {
return res.status(400).json({ error: ErrorCode.IncorrectPassword });
}
// if user has 2fa
if (user.twoFactorEnabled) {
if (!req.body.code) {
return res.status(400).json({ error: ErrorCode.SecondFactorRequired });
// throw new Error(ErrorCode.SecondFactorRequired);
}
if (!user.twoFactorSecret) {
console.error(`Two factor is enabled for user ${user.id} but they have no secret`);
throw new Error(ErrorCode.InternalServerError);
}
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
console.error(`"Missing encryption key; cannot proceed with two factor login."`);
throw new Error(ErrorCode.InternalServerError);
}
const secret = symmetricDecrypt(user.twoFactorSecret, process.env.CALENDSO_ENCRYPTION_KEY);
if (secret.length !== 32) {
console.error(
`Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}`
);
throw new Error(ErrorCode.InternalServerError);
}
// If user has 2fa enabled, check if body.code is correct
const isValidToken = authenticator.check(req.body.code, secret);
if (!isValidToken) {
return res.status(400).json({ error: ErrorCode.IncorrectTwoFactorCode });
// throw new Error(ErrorCode.IncorrectTwoFactorCode);
}
}
// If it is, disable users 2fa
await prisma.user.update({
where: {
id: session.user.id,

View File

@ -161,7 +161,7 @@ export default function Login({
</div>
</div>
{twoFactorRequired && <TwoFactor />}
{twoFactorRequired && <TwoFactor center />}
{errorMessage && <Alert severity="error" title={errorMessage} />}
<div className="flex space-y-2">

View File

@ -2,7 +2,17 @@ import crypto from "crypto";
import { GetServerSidePropsContext } from "next";
import { signOut } from "next-auth/react";
import { useRouter } from "next/router";
import { ComponentProps, FormEvent, RefObject, useEffect, useMemo, useRef, useState } from "react";
import {
ComponentProps,
RefObject,
FormEvent,
useEffect,
useMemo,
useRef,
useState,
BaseSyntheticEvent,
} from "react";
import { useForm } from "react-hook-form";
import TimezoneSelect, { ITimezone } from "react-timezone-select";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -17,16 +27,19 @@ import Button from "@calcom/ui/Button";
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
import { Icon } from "@calcom/ui/Icon";
import { Form, PasswordField } from "@calcom/ui/form/fields";
import { Label } from "@calcom/ui/form/fields";
import { withQuery } from "@lib/QueryCell";
import { asStringOrNull, asStringOrUndefined } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth";
import { ErrorCode, getSession } from "@lib/auth";
import { nameOfDay } from "@lib/core/i18n/weekday";
import { isBrandingHidden } from "@lib/isBrandingHidden";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import ImageUploader from "@components/ImageUploader";
import SettingsShell from "@components/SettingsShell";
import TwoFactor from "@components/auth/TwoFactor";
import Avatar from "@components/ui/Avatar";
import InfoBadge from "@components/ui/InfoBadge";
import { UsernameAvailability } from "@components/ui/UsernameAvailability";
@ -68,9 +81,14 @@ function HideBrandingInput(props: { hideBrandingRef: RefObject<HTMLInputElement>
</>
);
}
interface DeleteAccountValues {
totpCode: string;
}
function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: string }) {
const { user } = props;
const form = useForm<DeleteAccountValues>();
const { t } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
@ -93,15 +111,11 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
},
});
const deleteAccount = async () => {
await fetch("/api/user/me", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}).catch((e) => {
console.error(`Error Removing user: ${user.id}, email: ${user.email} :`, e);
});
const onDeleteMeSuccessMutation = async () => {
await utils.invalidateQueries(["viewer.me"]);
showToast(t("Your account was deleted"), "success");
setHasDeleteErrors(false); // dismiss any open errors
if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") {
signOut({ callbackUrl: "/auth/logout?survey=true" });
} else {
@ -109,6 +123,18 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
}
};
const onDeleteMeErrorMutation = (error: TRPCClientErrorLike<AppRouter>) => {
setHasDeleteErrors(true);
setDeleteErrorMessage(errorMessages[error.message]);
};
const deleteMeMutation = trpc.useMutation("viewer.deleteMe", {
onSuccess: onDeleteMeSuccessMutation,
onError: onDeleteMeErrorMutation,
async onSettled() {
await utils.invalidateQueries(["viewer.me"]);
},
});
const localeOptions = useMemo(() => {
return (router.locales || []).map((locale) => ({
value: locale,
@ -126,6 +152,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
{ value: 24, label: t("24_hour") },
];
const usernameRef = useRef<HTMLInputElement>(null!);
const passwordRef = useRef<HTMLInputElement>(null!);
const nameRef = useRef<HTMLInputElement>(null!);
const emailRef = useRef<HTMLInputElement>(null!);
const descriptionRef = useRef<HTMLTextAreaElement>(null!);
@ -149,7 +176,19 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
});
const [imageSrc, setImageSrc] = useState<string>(user.avatar || "");
const [hasErrors, setHasErrors] = useState(false);
const [hasDeleteErrors, setHasDeleteErrors] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const errorMessages: { [key: string]: string } = {
[ErrorCode.SecondFactorRequired]: t("2fa_enabled_instructions"),
[ErrorCode.IncorrectPassword]: `${t("incorrect_password")} ${t("please_try_again")}`,
[ErrorCode.UserNotFound]: t("no_account_exists"),
[ErrorCode.IncorrectTwoFactorCode]: `${t("incorrect_2fa_code")} ${t("please_try_again")}`,
[ErrorCode.InternalServerError]: `${t("something_went_wrong")} ${t("please_try_again_and_contact_us")}`,
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
};
const [deleteErrorMessage, setDeleteErrorMessage] = useState("");
const [brandColor, setBrandColor] = useState(user.brandColor);
const [darkBrandColor, setDarkBrandColor] = useState(user.darkBrandColor);
@ -161,6 +200,17 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onConfirmButton = (e: FormEvent) => {
e.preventDefault();
const totpCode = form.getValues("totpCode");
const password = passwordRef.current.value;
deleteMeMutation.mutate({ password, totpCode });
};
const onConfirm = ({ totpCode }: DeleteAccountValues, e: BaseSyntheticEvent | undefined) => {
e?.preventDefault();
const password = passwordRef.current.value;
deleteMeMutation.mutate({ password, totpCode });
};
async function updateProfileHandler(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@ -487,8 +537,26 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
{t("confirm_delete_account")}
</Button>
}
onConfirm={() => deleteAccount()}>
{t("delete_account_confirmation_message")}
onConfirm={onConfirmButton}>
<p className="mb-7">{t("delete_account_confirmation_message")}</p>
<PasswordField
data-testid="password"
name="password"
id="password"
type="password"
autoComplete="current-password"
required
label="Password"
ref={passwordRef}
/>
{user.twoFactorEnabled && (
<Form handleSubmit={onConfirm} className="pb-4" form={form}>
<TwoFactor center={false} />
</Form>
)}
{hasDeleteErrors && <Alert severity="error" title={deleteErrorMessage} />}
</ConfirmationDialogContent>
</Dialog>
</div>
@ -547,6 +615,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
brandColor: true,
darkBrandColor: true,
metadata: true,
twoFactorEnabled: true,
timeFormat: true,
allowDynamicBooking: true,
},

View File

@ -12,8 +12,9 @@ test("Can delete user account", async ({ page, users }) => {
await page.goto(`/settings/profile`);
await page.click("[data-testid=delete-account]");
await expect(page.locator(`[data-testid=delete-account-confirm]`)).toBeVisible();
if (!user.username) throw Error(`Test user doesn't have a username`);
await page.fill("[data-testid=password]", user.username);
await Promise.all([
page.waitForNavigation({ url: "/auth/logout" }),

View File

@ -1068,6 +1068,8 @@
"select_which_cal":"Select which calendar to add bookings to",
"custom_event_name":"Custom event name",
"custom_event_name_description":"Create customised event names to display on calendar event",
"2fa_required": "Two factor authentication required",
"incorrect_2fa": "Incorrect two factor authentication code",
"which_event_type_apply": "Which event type will this apply to?",
"no_workflows_description": "Workflows enable simple automation to send notifications & reminders enabling you to build processes around your events.",
"create_workflow": "Create a workflow",

View File

@ -1,9 +1,11 @@
import { AppCategories, BookingStatus, MembershipRole, Prisma } from "@prisma/client";
import { AppCategories, BookingStatus, IdentityProvider, MembershipRole, Prisma } from "@prisma/client";
import _ from "lodash";
import { authenticator } from "otplib";
import { JSONObject } from "superjson/dist/types";
import { z } from "zod";
import app_RoutingForms from "@calcom/app-store/ee/routing_forms/trpc-router";
import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer";
import stripe, { closePayments } from "@calcom/app-store/stripepayment/lib/server";
import getApps, { getLocationOptions } from "@calcom/app-store/utils";
import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler";
@ -12,6 +14,8 @@ import { DailyLocationType } from "@calcom/core/location";
import dayjs from "@calcom/dayjs";
import { sendCancelledEmails, sendFeedbackEmail } from "@calcom/emails";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { ErrorCode, verifyPassword } from "@calcom/lib/auth";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import jackson from "@calcom/lib/jackson";
import {
@ -28,8 +32,8 @@ import { getTranslation } from "@calcom/lib/server/i18n";
import { isTeamOwner } from "@calcom/lib/server/queries/teams";
import slugify from "@calcom/lib/slugify";
import {
updateWebUser as syncServicesUpdateWebUser,
deleteWebUser as syncServicesDeleteWebUser,
updateWebUser as syncServicesUpdateWebUser,
} from "@calcom/lib/sync/SyncServiceManager";
import prisma, { baseEventTypeSelect, bookingMinimalSelect } from "@calcom/prisma";
import { resizeBase64Image } from "@calcom/web/server/lib/resizeBase64Image";
@ -108,18 +112,76 @@ const loggedInViewerRouter = createProtectedRouter()
},
})
.mutation("deleteMe", {
async resolve({ ctx }) {
// Remove me from Stripe
// Remove my account
const deletedUser = await ctx.prisma.user.delete({
input: z.object({
password: z.string(),
totpCode: z.string().optional(),
}),
async resolve({ input, ctx }) {
// Check if input.password is correct
const user = await prisma.user.findUnique({
where: {
id: ctx.user.id,
email: ctx.user.email.toLowerCase(),
},
});
if (!user) {
throw new Error(ErrorCode.UserNotFound);
}
// Sync Services
syncServicesDeleteWebUser(deletedUser);
if (user.identityProvider !== IdentityProvider.CAL) {
throw new Error(ErrorCode.ThirdPartyIdentityProviderEnabled);
}
if (!user.password) {
throw new Error(ErrorCode.UserMissingPassword);
}
const isCorrectPassword = await verifyPassword(input.password, user.password);
if (!isCorrectPassword) {
throw new Error(ErrorCode.IncorrectPassword);
}
if (user.twoFactorEnabled) {
if (!input.totpCode) {
throw new Error(ErrorCode.SecondFactorRequired);
}
if (!user.twoFactorSecret) {
console.error(`Two factor is enabled for user ${user.id} but they have no secret`);
throw new Error(ErrorCode.InternalServerError);
}
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
console.error(`"Missing encryption key; cannot proceed with two factor login."`);
throw new Error(ErrorCode.InternalServerError);
}
const secret = symmetricDecrypt(user.twoFactorSecret, process.env.CALENDSO_ENCRYPTION_KEY);
if (secret.length !== 32) {
console.error(
`Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}`
);
throw new Error(ErrorCode.InternalServerError);
}
const isValidToken = authenticator.check(input.totpCode, secret);
if (!isValidToken) {
throw new Error(ErrorCode.IncorrectTwoFactorCode);
}
// If user has 2fa enabled, check if input.totpCode is correct
// If it is, delete the user from stripe and database
// Remove me from Stripe
await deleteStripeCustomer(user).catch(console.warn);
// Remove my account
const deletedUser = await ctx.prisma.user.delete({
where: {
id: ctx.user.id,
},
});
// Sync Services
syncServicesDeleteWebUser(deletedUser);
}
return;
},
@ -1280,6 +1342,7 @@ const loggedInViewerRouter = createProtectedRouter()
export const viewerRouter = createRouter()
.merge("public.", publicViewerRouter)
.merge(loggedInViewerRouter)
.merge("auth.", authRouter)
.merge("bookings.", bookingsRouter)
.merge("eventTypes.", eventTypesRouter)
.merge("availability.", availabilityRouter)
@ -1288,7 +1351,6 @@ export const viewerRouter = createRouter()
.merge("apiKeys.", apiKeysRouter)
.merge("slots.", slotsRouter)
.merge("workflows.", workflowsRouter)
.merge("auth.", authRouter)
// NOTE: Add all app related routes in the bottom till the problem described in @calcom/app-store/trpc-routers.ts is solved.
// After that there would just one merge call here for all the apps.

View File

@ -1,7 +1,7 @@
import { IdentityProvider } from "@prisma/client";
import { z } from "zod";
import { hashPassword, verifyPassword, validPassword } from "@calcom/lib/auth";
import { hashPassword, validPassword, verifyPassword } from "@calcom/lib/auth";
import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";

View File

@ -104,7 +104,9 @@ export const PasswordField = forwardRef<HTMLInputElement, InputFieldProps>(funct
props,
ref
) {
return <InputField type="password" placeholder="•••••••••••••" ref={ref} {...props} />;
return (
<InputField data-testid="password" type="password" placeholder="•••••••••••••" ref={ref} {...props} />
);
});
export const EmailInput = forwardRef<HTMLInputElement, InputFieldProps>(function EmailInput(props, ref) {