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 useDigitInput from "react-digit-input";
|
||||||
import { useFormContext } from "react-hook-form";
|
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({ center = true }) {
|
||||||
|
|
||||||
export default function TwoFactor() {
|
|
||||||
const [value, onChange] = useState("");
|
const [value, onChange] = useState("");
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const methods = useFormContext();
|
const methods = useFormContext();
|
||||||
|
@ -26,7 +25,9 @@ export default function TwoFactor() {
|
||||||
const className = "h-12 w-12 !text-xl text-center";
|
const className = "h-12 w-12 !text-xl text-center";
|
||||||
|
|
||||||
return (
|
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>
|
<p className="mb-4 text-sm text-gray-500">{t("2fa_enabled_instructions")}</p>
|
||||||
<input hidden type="hidden" value={value} {...methods.register("totpCode")} />
|
<input hidden type="hidden" value={value} {...methods.register("totpCode")} />
|
||||||
<div className="flex flex-row justify-between">
|
<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 Button from "@calcom/ui/Button";
|
||||||
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
|
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 TwoFactor from "@components/auth/TwoFactor";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
|
||||||
|
|
||||||
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
||||||
import TwoFactorModalHeader from "./TwoFactorModalHeader";
|
import TwoFactorModalHeader from "./TwoFactorModalHeader";
|
||||||
|
@ -16,15 +20,17 @@ interface DisableTwoFactorAuthModalProps {
|
||||||
onDisable: () => void;
|
onDisable: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DisableTwoFactorValues {
|
||||||
|
totpCode: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuthModalProps) => {
|
const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuthModalProps) => {
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [isDisabling, setIsDisabling] = useState(false);
|
const [isDisabling, setIsDisabling] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
const form = useForm<DisableTwoFactorValues>();
|
||||||
async function handleDisable(e: SyntheticEvent) {
|
async function handleDisable({ totpCode, password }: DisableTwoFactorValues) {
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (isDisabling) {
|
if (isDisabling) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -32,7 +38,7 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await TwoFactorAuthAPI.disable(password);
|
const response = await TwoFactorAuthAPI.disable(password, totpCode);
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
onDisable();
|
onDisable();
|
||||||
return;
|
return;
|
||||||
|
@ -41,6 +47,12 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
if (body.error === ErrorCode.IncorrectPassword) {
|
if (body.error === ErrorCode.IncorrectPassword) {
|
||||||
setErrorMessage(t("incorrect_password"));
|
setErrorMessage(t("incorrect_password"));
|
||||||
|
}
|
||||||
|
if (body.error === ErrorCode.SecondFactorRequired) {
|
||||||
|
setErrorMessage(t("2fa_required"));
|
||||||
|
}
|
||||||
|
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
|
||||||
|
setErrorMessage(t("incorrect_2fa"));
|
||||||
} else {
|
} else {
|
||||||
setErrorMessage(t("something_went_wrong"));
|
setErrorMessage(t("something_went_wrong"));
|
||||||
}
|
}
|
||||||
|
@ -55,41 +67,32 @@ const DisableTwoFactorAuthModal = ({ onDisable, onCancel }: DisableTwoFactorAuth
|
||||||
return (
|
return (
|
||||||
<Dialog open={true}>
|
<Dialog open={true}>
|
||||||
<DialogContent>
|
<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">
|
<div className="mb-4">
|
||||||
<label htmlFor="password" className="mt-4 block text-sm font-medium text-gray-700">
|
<PasswordField
|
||||||
{t("password")}
|
labelProps={{
|
||||||
</label>
|
className: "block text-sm font-medium text-gray-700",
|
||||||
<div className="mt-1">
|
}}
|
||||||
<input
|
{...form.register("password")}
|
||||||
type="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"
|
||||||
name="password"
|
/>
|
||||||
id="password"
|
<Label className="mt-4"> {t("2fa_code")}</Label>
|
||||||
required
|
|
||||||
value={password}
|
|
||||||
onInput={(e) => setPassword(e.currentTarget.value)}
|
|
||||||
className="block w-full rounded-sm border-gray-300 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<TwoFactor center={false} />
|
||||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||||
<Button
|
<Button type="submit" className="ltr:ml-2 rtl:mr-2" disabled={isDisabling}>
|
||||||
type="submit"
|
{t("disable")}
|
||||||
className="ltr:ml-2 rtl:mr-2"
|
</Button>
|
||||||
onClick={handleDisable}
|
<Button color="secondary" onClick={onCancel}>
|
||||||
disabled={password.length === 0 || isDisabling}>
|
{t("cancel")}
|
||||||
{t("disable")}
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
<Button color="secondary" onClick={onCancel}>
|
</Form>
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 Button from "@calcom/ui/Button";
|
||||||
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
|
import { Dialog, DialogContent } from "@calcom/ui/Dialog";
|
||||||
|
import { Form } from "@calcom/ui/v2/core/form/fields";
|
||||||
|
|
||||||
import { ErrorCode } from "@lib/auth";
|
import TwoFactor from "@components/auth/TwoFactor";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
|
||||||
|
|
||||||
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
|
||||||
import TwoFactorModalHeader from "./TwoFactorModalHeader";
|
import TwoFactorModalHeader from "./TwoFactorModalHeader";
|
||||||
|
@ -39,8 +42,14 @@ const WithStep = ({
|
||||||
return step === current ? children : null;
|
return step === current ? children : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface EnableTwoFactorValues {
|
||||||
|
totpCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps) => {
|
const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
const form = useForm<EnableTwoFactorValues>();
|
||||||
|
|
||||||
const setupDescriptions = {
|
const setupDescriptions = {
|
||||||
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
|
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
|
||||||
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
|
[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 [step, setStep] = useState(SetupStep.ConfirmPassword);
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [totpCode, setTotpCode] = useState("");
|
|
||||||
const [dataUri, setDataUri] = useState("");
|
const [dataUri, setDataUri] = useState("");
|
||||||
const [secret, setSecret] = useState("");
|
const [secret, setSecret] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
async function handleSetup(e: SyntheticEvent) {
|
async function handleSetup(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (isSubmitting) {
|
if (isSubmitting) {
|
||||||
|
@ -88,10 +96,10 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEnable(e: SyntheticEvent) {
|
async function handleEnable({ totpCode }: EnableTwoFactorValues, e: BaseSyntheticEvent | undefined) {
|
||||||
e.preventDefault();
|
e?.preventDefault();
|
||||||
|
|
||||||
if (isSubmitting || totpCode.length !== 6) {
|
if (isSubmitting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -158,64 +166,43 @@ const EnableTwoFactorModal = ({ onEnable, onCancel }: EnableTwoFactorModalProps)
|
||||||
<p className="text-center font-mono text-xs">{secret}</p>
|
<p className="text-center font-mono text-xs">{secret}</p>
|
||||||
</>
|
</>
|
||||||
</WithStep>
|
</WithStep>
|
||||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
<Form handleSubmit={handleEnable} form={form}>
|
||||||
<form onSubmit={handleEnable}>
|
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label htmlFor="code" className="mt-4 block text-sm font-medium text-gray-700">
|
<TwoFactor center />
|
||||||
{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>
|
|
||||||
|
|
||||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</WithStep>
|
||||||
</WithStep>
|
|
||||||
|
|
||||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||||
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
<WithStep step={SetupStep.ConfirmPassword} current={step}>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="ltr:ml-2 rtl:mr-2"
|
className="ltr:ml-2 rtl:mr-2"
|
||||||
onClick={handleSetup}
|
onClick={handleSetup}
|
||||||
disabled={password.length === 0 || isSubmitting}>
|
disabled={password.length === 0 || isSubmitting}>
|
||||||
{t("continue")}
|
{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>
|
</Button>
|
||||||
</WithStep>
|
</div>
|
||||||
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
</Form>
|
||||||
<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>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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", {
|
return fetch("/api/auth/two-factor/totp/disable", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ password, code }),
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
|
|
@ -6,7 +6,7 @@ const TwoFactorModalHeader = ({ title, description }: { title: string; descripti
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 sm:flex sm:items-start">
|
<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">
|
<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>
|
||||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
<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">
|
<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 { Session } from "next-auth";
|
||||||
import { getSession as getSessionInner, GetSessionParams } from "next-auth/react";
|
import { getSession as getSessionInner, GetSessionParams } from "next-auth/react";
|
||||||
|
|
||||||
|
/** @deprecated use the one from `@calcom/lib/auth` */
|
||||||
export async function hashPassword(password: string) {
|
export async function hashPassword(password: string) {
|
||||||
const hashedPassword = await hash(password, 12);
|
const hashedPassword = await hash(password, 12);
|
||||||
return hashedPassword;
|
return hashedPassword;
|
||||||
}
|
}
|
||||||
|
/** @deprecated use the one from `@calcom/lib/auth` */
|
||||||
export async function verifyPassword(password: string, hashedPassword: string) {
|
export async function verifyPassword(password: string, hashedPassword: string) {
|
||||||
const isValid = await compare(password, hashedPassword);
|
const isValid = await compare(password, hashedPassword);
|
||||||
return isValid;
|
return isValid;
|
||||||
}
|
}
|
||||||
|
/** @deprecated use the one from `@calcom/lib/auth` */
|
||||||
export async function getSession(options: GetSessionParams): Promise<Session | null> {
|
export async function getSession(options: GetSessionParams): Promise<Session | null> {
|
||||||
const session = await getSessionInner(options);
|
const session = await getSessionInner(options);
|
||||||
|
|
||||||
// that these are equal are ensured in `[...nextauth]`'s callback
|
// that these are equal are ensured in `[...nextauth]`'s callback
|
||||||
return session as Session | null;
|
return session as Session | null;
|
||||||
}
|
}
|
||||||
|
/** @deprecated use the one from `@calcom/lib/auth` */
|
||||||
export enum ErrorCode {
|
export enum ErrorCode {
|
||||||
UserNotFound = "user-not-found",
|
UserNotFound = "user-not-found",
|
||||||
IncorrectPassword = "incorrect-password",
|
IncorrectPassword = "incorrect-password",
|
||||||
|
@ -35,7 +36,7 @@ export enum ErrorCode {
|
||||||
RateLimitExceeded = "rate-limit-exceeded",
|
RateLimitExceeded = "rate-limit-exceeded",
|
||||||
InvalidPassword = "invalid-password",
|
InvalidPassword = "invalid-password",
|
||||||
}
|
}
|
||||||
|
/** @deprecated use the one from `@calcom/lib/auth` */
|
||||||
export const identityProviderNameMap: { [key in IdentityProvider]: string } = {
|
export const identityProviderNameMap: { [key in IdentityProvider]: string } = {
|
||||||
[IdentityProvider.CAL]: "Cal",
|
[IdentityProvider.CAL]: "Cal",
|
||||||
[IdentityProvider.GOOGLE]: "Google",
|
[IdentityProvider.GOOGLE]: "Google",
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { NextApiRequest, NextApiResponse } from "next";
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { authenticator } from "otplib";
|
||||||
|
|
||||||
|
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import { ErrorCode, getSession, verifyPassword } from "@lib/auth";
|
import { ErrorCode, getSession, verifyPassword } from "@lib/auth";
|
||||||
|
@ -37,7 +39,40 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
if (!isCorrectPassword) {
|
if (!isCorrectPassword) {
|
||||||
return res.status(400).json({ error: ErrorCode.IncorrectPassword });
|
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({
|
await prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
id: session.user.id,
|
id: session.user.id,
|
||||||
|
|
|
@ -161,7 +161,7 @@ export default function Login({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{twoFactorRequired && <TwoFactor />}
|
{twoFactorRequired && <TwoFactor center />}
|
||||||
|
|
||||||
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
{errorMessage && <Alert severity="error" title={errorMessage} />}
|
||||||
<div className="flex space-y-2">
|
<div className="flex space-y-2">
|
||||||
|
|
|
@ -2,7 +2,17 @@ import crypto from "crypto";
|
||||||
import { GetServerSidePropsContext } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
import { useRouter } from "next/router";
|
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 TimezoneSelect, { ITimezone } from "react-timezone-select";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
@ -17,16 +27,19 @@ import Button from "@calcom/ui/Button";
|
||||||
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
||||||
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
import { Dialog, DialogTrigger } from "@calcom/ui/Dialog";
|
||||||
import { Icon } from "@calcom/ui/Icon";
|
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 { withQuery } from "@lib/QueryCell";
|
||||||
import { asStringOrNull, asStringOrUndefined } from "@lib/asStringOrNull";
|
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 { nameOfDay } from "@lib/core/i18n/weekday";
|
||||||
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
import ImageUploader from "@components/ImageUploader";
|
import ImageUploader from "@components/ImageUploader";
|
||||||
import SettingsShell from "@components/SettingsShell";
|
import SettingsShell from "@components/SettingsShell";
|
||||||
|
import TwoFactor from "@components/auth/TwoFactor";
|
||||||
import Avatar from "@components/ui/Avatar";
|
import Avatar from "@components/ui/Avatar";
|
||||||
import InfoBadge from "@components/ui/InfoBadge";
|
import InfoBadge from "@components/ui/InfoBadge";
|
||||||
import { UsernameAvailability } from "@components/ui/UsernameAvailability";
|
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 }) {
|
function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: string }) {
|
||||||
const { user } = props;
|
const { user } = props;
|
||||||
|
const form = useForm<DeleteAccountValues>();
|
||||||
|
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
|
@ -93,15 +111,11 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteAccount = async () => {
|
const onDeleteMeSuccessMutation = async () => {
|
||||||
await fetch("/api/user/me", {
|
await utils.invalidateQueries(["viewer.me"]);
|
||||||
method: "DELETE",
|
showToast(t("Your account was deleted"), "success");
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
setHasDeleteErrors(false); // dismiss any open errors
|
||||||
},
|
|
||||||
}).catch((e) => {
|
|
||||||
console.error(`Error Removing user: ${user.id}, email: ${user.email} :`, e);
|
|
||||||
});
|
|
||||||
if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") {
|
if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") {
|
||||||
signOut({ callbackUrl: "/auth/logout?survey=true" });
|
signOut({ callbackUrl: "/auth/logout?survey=true" });
|
||||||
} else {
|
} 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(() => {
|
const localeOptions = useMemo(() => {
|
||||||
return (router.locales || []).map((locale) => ({
|
return (router.locales || []).map((locale) => ({
|
||||||
value: locale,
|
value: locale,
|
||||||
|
@ -126,6 +152,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
{ value: 24, label: t("24_hour") },
|
{ value: 24, label: t("24_hour") },
|
||||||
];
|
];
|
||||||
const usernameRef = useRef<HTMLInputElement>(null!);
|
const usernameRef = useRef<HTMLInputElement>(null!);
|
||||||
|
const passwordRef = useRef<HTMLInputElement>(null!);
|
||||||
const nameRef = useRef<HTMLInputElement>(null!);
|
const nameRef = useRef<HTMLInputElement>(null!);
|
||||||
const emailRef = useRef<HTMLInputElement>(null!);
|
const emailRef = useRef<HTMLInputElement>(null!);
|
||||||
const descriptionRef = useRef<HTMLTextAreaElement>(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 [imageSrc, setImageSrc] = useState<string>(user.avatar || "");
|
||||||
const [hasErrors, setHasErrors] = useState(false);
|
const [hasErrors, setHasErrors] = useState(false);
|
||||||
|
const [hasDeleteErrors, setHasDeleteErrors] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
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 [brandColor, setBrandColor] = useState(user.brandColor);
|
||||||
const [darkBrandColor, setDarkBrandColor] = useState(user.darkBrandColor);
|
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
|
// 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>) {
|
async function updateProfileHandler(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
|
@ -487,8 +537,26 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
|
||||||
{t("confirm_delete_account")}
|
{t("confirm_delete_account")}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
onConfirm={() => deleteAccount()}>
|
onConfirm={onConfirmButton}>
|
||||||
{t("delete_account_confirmation_message")}
|
<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>
|
</ConfirmationDialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
@ -547,6 +615,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||||
brandColor: true,
|
brandColor: true,
|
||||||
darkBrandColor: true,
|
darkBrandColor: true,
|
||||||
metadata: true,
|
metadata: true,
|
||||||
|
twoFactorEnabled: true,
|
||||||
timeFormat: true,
|
timeFormat: true,
|
||||||
allowDynamicBooking: true,
|
allowDynamicBooking: true,
|
||||||
},
|
},
|
||||||
|
|
|
@ -12,8 +12,9 @@ test("Can delete user account", async ({ page, users }) => {
|
||||||
|
|
||||||
await page.goto(`/settings/profile`);
|
await page.goto(`/settings/profile`);
|
||||||
await page.click("[data-testid=delete-account]");
|
await page.click("[data-testid=delete-account]");
|
||||||
|
|
||||||
await expect(page.locator(`[data-testid=delete-account-confirm]`)).toBeVisible();
|
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([
|
await Promise.all([
|
||||||
page.waitForNavigation({ url: "/auth/logout" }),
|
page.waitForNavigation({ url: "/auth/logout" }),
|
||||||
|
|
|
@ -1068,6 +1068,8 @@
|
||||||
"select_which_cal":"Select which calendar to add bookings to",
|
"select_which_cal":"Select which calendar to add bookings to",
|
||||||
"custom_event_name":"Custom event name",
|
"custom_event_name":"Custom event name",
|
||||||
"custom_event_name_description":"Create customised event names to display on calendar event",
|
"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?",
|
"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.",
|
"no_workflows_description": "Workflows enable simple automation to send notifications & reminders enabling you to build processes around your events.",
|
||||||
"create_workflow": "Create a workflow",
|
"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 _ from "lodash";
|
||||||
|
import { authenticator } from "otplib";
|
||||||
import { JSONObject } from "superjson/dist/types";
|
import { JSONObject } from "superjson/dist/types";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import app_RoutingForms from "@calcom/app-store/ee/routing_forms/trpc-router";
|
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 stripe, { closePayments } from "@calcom/app-store/stripepayment/lib/server";
|
||||||
import getApps, { getLocationOptions } from "@calcom/app-store/utils";
|
import getApps, { getLocationOptions } from "@calcom/app-store/utils";
|
||||||
import { cancelScheduledJobs } from "@calcom/app-store/zapier/lib/nodeScheduler";
|
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 dayjs from "@calcom/dayjs";
|
||||||
import { sendCancelledEmails, sendFeedbackEmail } from "@calcom/emails";
|
import { sendCancelledEmails, sendFeedbackEmail } from "@calcom/emails";
|
||||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
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 hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||||
import jackson from "@calcom/lib/jackson";
|
import jackson from "@calcom/lib/jackson";
|
||||||
import {
|
import {
|
||||||
|
@ -28,8 +32,8 @@ import { getTranslation } from "@calcom/lib/server/i18n";
|
||||||
import { isTeamOwner } from "@calcom/lib/server/queries/teams";
|
import { isTeamOwner } from "@calcom/lib/server/queries/teams";
|
||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
import {
|
import {
|
||||||
updateWebUser as syncServicesUpdateWebUser,
|
|
||||||
deleteWebUser as syncServicesDeleteWebUser,
|
deleteWebUser as syncServicesDeleteWebUser,
|
||||||
|
updateWebUser as syncServicesUpdateWebUser,
|
||||||
} from "@calcom/lib/sync/SyncServiceManager";
|
} from "@calcom/lib/sync/SyncServiceManager";
|
||||||
import prisma, { baseEventTypeSelect, bookingMinimalSelect } from "@calcom/prisma";
|
import prisma, { baseEventTypeSelect, bookingMinimalSelect } from "@calcom/prisma";
|
||||||
import { resizeBase64Image } from "@calcom/web/server/lib/resizeBase64Image";
|
import { resizeBase64Image } from "@calcom/web/server/lib/resizeBase64Image";
|
||||||
|
@ -108,18 +112,76 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.mutation("deleteMe", {
|
.mutation("deleteMe", {
|
||||||
async resolve({ ctx }) {
|
input: z.object({
|
||||||
// Remove me from Stripe
|
password: z.string(),
|
||||||
|
totpCode: z.string().optional(),
|
||||||
// Remove my account
|
}),
|
||||||
const deletedUser = await ctx.prisma.user.delete({
|
async resolve({ input, ctx }) {
|
||||||
|
// Check if input.password is correct
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: ctx.user.id,
|
email: ctx.user.email.toLowerCase(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new Error(ErrorCode.UserNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
// Sync Services
|
if (user.identityProvider !== IdentityProvider.CAL) {
|
||||||
syncServicesDeleteWebUser(deletedUser);
|
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;
|
return;
|
||||||
},
|
},
|
||||||
|
@ -1280,6 +1342,7 @@ const loggedInViewerRouter = createProtectedRouter()
|
||||||
export const viewerRouter = createRouter()
|
export const viewerRouter = createRouter()
|
||||||
.merge("public.", publicViewerRouter)
|
.merge("public.", publicViewerRouter)
|
||||||
.merge(loggedInViewerRouter)
|
.merge(loggedInViewerRouter)
|
||||||
|
.merge("auth.", authRouter)
|
||||||
.merge("bookings.", bookingsRouter)
|
.merge("bookings.", bookingsRouter)
|
||||||
.merge("eventTypes.", eventTypesRouter)
|
.merge("eventTypes.", eventTypesRouter)
|
||||||
.merge("availability.", availabilityRouter)
|
.merge("availability.", availabilityRouter)
|
||||||
|
@ -1288,7 +1351,6 @@ export const viewerRouter = createRouter()
|
||||||
.merge("apiKeys.", apiKeysRouter)
|
.merge("apiKeys.", apiKeysRouter)
|
||||||
.merge("slots.", slotsRouter)
|
.merge("slots.", slotsRouter)
|
||||||
.merge("workflows.", workflowsRouter)
|
.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.
|
// 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.
|
// After that there would just one merge call here for all the apps.
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { IdentityProvider } from "@prisma/client";
|
import { IdentityProvider } from "@prisma/client";
|
||||||
import { z } from "zod";
|
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 prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
|
@ -104,7 +104,9 @@ export const PasswordField = forwardRef<HTMLInputElement, InputFieldProps>(funct
|
||||||
props,
|
props,
|
||||||
ref
|
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) {
|
export const EmailInput = forwardRef<HTMLInputElement, InputFieldProps>(function EmailInput(props, ref) {
|
||||||
|
|
Loading…
Reference in New Issue
Block a user