Improve 2fa: ask for code before account removal and 2fa disabling (#3817)
* fix conflicts * fix remove separate function and call mutation directly * feat: add new react-otp-input to enable 2fa flow * fix: comment out * fix: remove next-auth 4.9.0 from yarn.lock * fix: delete account test fill password before submit * fix: test delete accc * fix typo in delete acc test * Update apps/web/components/security/EnableTwoFactorModal.tsx Co-authored-by: Omar López <zomars@me.com> * feat: remove react-otp-input reuse TwoFactor * feat: add center props to TwoFactor * fix: no v2 * feat: disable 2fa requires 2fa api * feat: make 2fa required to disable 2fa * fix: FormEvent instead of SyntheticEvent * fix: types * fix: move disable 2fa form to fully use RHF * fix if (e) e.preventDefault(); * feat: fix remove account * fix: remove react-otp-input types * fix: separate onConfirm to add to form handleSubmit * fix: types e:SyntethicEvent * fix: types * fix: import packages lib not web lib * Update apps/web/components/security/EnableTwoFactorModal.tsx Co-authored-by: Omar López <zomars@me.com> * Update apps/web/components/security/EnableTwoFactorModal.tsx Co-authored-by: Omar López <zomars@me.com> * fix: no import from web * fix: import * fix: remove duplicate FormEvent * fix: upgrade ErrorCode imports * fix profile types totpCode not optional * fix: build pass * fix: dont touch test delete-account * fix: type * fix: add data-testid to password field * fix: conflicts w syncServices * Build fixes * Fixes delete account e2e test Co-authored-by: Agusti Fernandez Pardo <git@agusti.me> Co-authored-by: Omar López <zomars@me.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
parent
58c4c894fd
commit
f4fe91396f
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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" }),
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in New Issue
Block a user