diff --git a/apps/web/app/api/user/route.ts b/apps/web/app/api/user/route.ts index ae527225c2..2f4ae1d697 100644 --- a/apps/web/app/api/user/route.ts +++ b/apps/web/app/api/user/route.ts @@ -39,6 +39,8 @@ export const GET = withSession(async ({ session }) => { defaultPartnerId: true, passwordHash: true, createdAt: true, + twoFactorConfirmedAt: true, + twoFactorRecoveryCodes: true, }, }), diff --git a/apps/web/app/app.dub.co/(auth)/two-factor-challenge/form.tsx b/apps/web/app/app.dub.co/(auth)/two-factor-challenge/form.tsx new file mode 100644 index 0000000000..30ceb1f090 --- /dev/null +++ b/apps/web/app/app.dub.co/(auth)/two-factor-challenge/form.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { errorCodes } from "@/ui/auth/login/login-form"; +import { Button, Input, useMediaQuery } from "@dub/ui"; +import { signIn } from "next-auth/react"; +import { FormEvent, useState } from "react"; +import { toast } from "sonner"; + +export const TwoFactorChallengeForm = () => { + const { isMobile } = useMediaQuery(); + const [code, setCode] = useState(""); + const [loading, setLoading] = useState(false); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + setLoading(true); + + const response = await signIn("two-factor-challenge", { + code, + redirect: false, + callbackUrl: "/", + }); + + setLoading(false); + + if (!response) { + return; + } + + if (!response.ok && response.error) { + if (errorCodes[response.error]) { + toast.error(errorCodes[response.error]); + } else { + toast.error(response.error); + } + } + }; + + return ( +
+
+
+ +
+
+
+ ); +}; diff --git a/apps/web/app/app.dub.co/(auth)/two-factor-challenge/page.tsx b/apps/web/app/app.dub.co/(auth)/two-factor-challenge/page.tsx new file mode 100644 index 0000000000..21da1fa25a --- /dev/null +++ b/apps/web/app/app.dub.co/(auth)/two-factor-challenge/page.tsx @@ -0,0 +1,32 @@ +import { TWO_FA_COOKIE_NAME } from "@/lib/auth/constants"; +import { AuthLayout } from "@/ui/layout/auth-layout"; +import { constructMetadata } from "@dub/utils"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { TwoFactorChallengeForm } from "./form"; + +export const metadata = constructMetadata({ + title: `Two-factor challenge for ${process.env.NEXT_PUBLIC_APP_NAME}`, +}); + +export default function TwoFactorChallengePage() { + const cookie = cookies().get(TWO_FA_COOKIE_NAME); + + if (!cookie) { + redirect("/login"); + } + + return ( + +
+

+ Two-factor authentication +

+ +
+ +
+
+
+ ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/account/settings/security/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/account/settings/security/page-client.tsx index c5b4f97629..14c52b0d2f 100644 --- a/apps/web/app/app.dub.co/(dashboard)/account/settings/security/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/account/settings/security/page-client.tsx @@ -2,6 +2,7 @@ import useUser from "@/lib/swr/use-user"; import { RequestSetPassword } from "./request-set-password"; +import { TwoFactorAuth } from "./two-factor-auth"; import { UpdatePassword } from "./update-password"; export const dynamic = "force-dynamic"; @@ -9,23 +10,31 @@ export const dynamic = "force-dynamic"; export default function SecurityPageClient() { const { loading, user } = useUser(); - if (loading) { - return ( -
-
-

Password

-
-
-
-
-
-
-
-
-
+ // if (loading) { + // return ( + //
+ //
+ //

Password

+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ //
+ // ); + // } + + return ( +
+
+ {user?.hasPassword ? : }
- ); - } - return <>{user?.hasPassword ? : }; + +
+ ); } diff --git a/apps/web/app/app.dub.co/(dashboard)/account/settings/security/two-factor-auth.tsx b/apps/web/app/app.dub.co/(dashboard)/account/settings/security/two-factor-auth.tsx new file mode 100644 index 0000000000..86c19f98c6 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/account/settings/security/two-factor-auth.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { enableTwoFactorAuthAction } from "@/lib/actions/auth/enable-two-factor-auth"; +import useUser from "@/lib/swr/use-user"; +import { useDisableTwoFactorAuthModal } from "@/ui/modals/disable-two-factor-auth-modal"; +import { useEnableTwoFactorAuthModal } from "@/ui/modals/enable-two-factor-auth-modal"; +import { Button } from "@dub/ui"; +import { useAction } from "next-safe-action/hooks"; +import { useState } from "react"; +import { toast } from "sonner"; + +export const TwoFactorAuth = () => { + const { user, loading, mutate } = useUser(); + const [secret, setSecret] = useState(""); + const [qrCodeUrl, setQrCodeUrl] = useState(""); + + const { EnableTwoFactorAuthModal, setShowEnableTwoFactorAuthModal } = + useEnableTwoFactorAuthModal({ + secret, + qrCodeUrl, + onSuccess: () => { + setSecret(""); + setQrCodeUrl(""); + mutate(); + }, + }); + + const { DisableTwoFactorAuthModal, setShowDisableTwoFactorAuthModal } = + useDisableTwoFactorAuthModal(); + + const { executeAsync: enable2FA, isPending: isEnabling } = useAction( + enableTwoFactorAuthAction, + { + onSuccess: async ({ data }) => { + if (!data) { + toast.error("Failed to enable 2FA. Please try again."); + return; + } + + setSecret(data.secret); + setQrCodeUrl(data.qrCodeUrl); + setShowEnableTwoFactorAuthModal(true); + }, + onError({ error }) { + toast.error(error.serverError); + }, + }, + ); + + return ( + <> + + +
+
+

Two-factor Authentication

+

+ Once two-factor is enabled you will have to provide two methods of + authentication in order to sign in into your account. +

+
+ +
+
+
+
+ Authenticator App (TOTP) +
+
+ Generate codes using an app like Google Authenticator or Okta + Verify. +
+
+ +
+
+
+ + ); +}; diff --git a/apps/web/lib/actions/auth/confirm-two-factor-auth.ts b/apps/web/lib/actions/auth/confirm-two-factor-auth.ts new file mode 100644 index 0000000000..e8f1dae7a9 --- /dev/null +++ b/apps/web/lib/actions/auth/confirm-two-factor-auth.ts @@ -0,0 +1,82 @@ +"use server"; + +import { getTOTPInstance } from "@/lib/auth/totp"; +import { ratelimit } from "@/lib/upstash/ratelimit"; +import { sendEmail } from "@dub/email"; +import TwoFactorEnabled from "@dub/email/templates/two-factor-enabled"; +import { prisma } from "@dub/prisma"; +import { waitUntil } from "@vercel/functions"; +import { z } from "zod"; +import { authUserActionClient } from "../safe-action"; + +const schema = z.object({ + token: z.string().length(6, "Code must be 6 digits"), +}); + +// Confirm 2FA for an user +export const confirmTwoFactorAuthAction = authUserActionClient + .schema(schema) + .action(async ({ ctx, parsedInput }) => { + const { token } = parsedInput; + const { user } = ctx; + + const currentUser = await prisma.user.findUniqueOrThrow({ + where: { + id: user.id, + }, + select: { + twoFactorSecret: true, + twoFactorConfirmedAt: true, + }, + }); + + if (currentUser.twoFactorConfirmedAt) { + throw new Error("2FA is already enabled for your account."); + } + + if (!currentUser.twoFactorSecret) { + throw new Error("No 2FA secret found. Please try enabling 2FA again."); + } + + const { success } = await ratelimit(5, "1 h").limit( + `2fa-confirm:${user.id}`, + ); + + if (!success) { + throw new Error("Too many 2FA attempts. Please try again later."); + } + + const totp = getTOTPInstance({ + secret: currentUser.twoFactorSecret, + }); + + const delta = totp.validate({ + token, + window: 1, + }); + + // If delta is null, the token is invalid + // If delta is a number, the token is valid (0 = current step, 1 = next step, -1 = previous step) + if (delta === null) { + throw new Error("Invalid 2FA code entered. Please try again."); + } + + // Update the user's record to confirm 2FA is enabled + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + twoFactorConfirmedAt: new Date(), + }, + }); + + waitUntil( + sendEmail({ + subject: "Two Factor authentication enabled", + email: user.email, + react: TwoFactorEnabled({ email: user.email }), + variant: "notifications", + }), + ); + }); diff --git a/apps/web/lib/actions/auth/disable-two-factor-auth.ts b/apps/web/lib/actions/auth/disable-two-factor-auth.ts new file mode 100644 index 0000000000..f5b1c5e5a2 --- /dev/null +++ b/apps/web/lib/actions/auth/disable-two-factor-auth.ts @@ -0,0 +1,44 @@ +"use server"; + +import { sendEmail } from "@dub/email"; +import TwoFactorDisabled from "@dub/email/templates/two-factor-disabled"; +import { prisma } from "@dub/prisma"; +import { waitUntil } from "@vercel/functions"; +import { authUserActionClient } from "../safe-action"; + +// Disable 2FA for an user +export const disableTwoFactorAuthAction = authUserActionClient.action( + async ({ ctx }) => { + const { user } = ctx; + + const currentUser = await prisma.user.findUniqueOrThrow({ + where: { + id: user.id, + }, + }); + + if (!currentUser.twoFactorConfirmedAt) { + throw new Error("2FA is not enabled for your account."); + } + + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + twoFactorSecret: null, + twoFactorConfirmedAt: null, + twoFactorRecoveryCodes: null, + }, + }); + + waitUntil( + sendEmail({ + subject: "Two Factor authentication disabled", + email: user.email, + react: TwoFactorDisabled({ email: user.email }), + variant: "notifications", + }), + ); + }, +); diff --git a/apps/web/lib/actions/auth/enable-two-factor-auth.ts b/apps/web/lib/actions/auth/enable-two-factor-auth.ts new file mode 100644 index 0000000000..00d8594109 --- /dev/null +++ b/apps/web/lib/actions/auth/enable-two-factor-auth.ts @@ -0,0 +1,55 @@ +"use server"; + +import { generateTOTPSecret, getTOTPInstance } from "@/lib/auth/totp"; +import { prisma } from "@dub/prisma"; +import { authUserActionClient } from "../safe-action"; + +// Enable 2FA for an user +export const enableTwoFactorAuthAction = authUserActionClient.action( + async ({ ctx }) => { + const { user } = ctx; + + const currentUser = await prisma.user.findUniqueOrThrow({ + where: { + id: user.id, + }, + }); + + if (currentUser.twoFactorConfirmedAt) { + throw new Error("2FA is already enabled for your account."); + } + + const secret = generateTOTPSecret(); + + if (!secret) { + throw new Error("Failed to generate 2FA secret."); + } + + // This doesn't enable the 2FA for the user, it just adds the secret to the user's account + // the user needs to confirm the 2FA by entering the code from the app + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + twoFactorSecret: secret, + }, + }); + + const totp = getTOTPInstance({ + secret, + label: user.email, + }); + + const qrCodeUrl = totp.toString(); + + if (!qrCodeUrl) { + throw new Error("Failed to generate 2FA QR code URL."); + } + + return { + secret, + qrCodeUrl, + }; + }, +); diff --git a/apps/web/lib/auth/constants.ts b/apps/web/lib/auth/constants.ts index 8290f67bde..13b0c3c13a 100644 --- a/apps/web/lib/auth/constants.ts +++ b/apps/web/lib/auth/constants.ts @@ -8,3 +8,5 @@ export const FRAMER_API_HOST = process.env.NODE_ENV === "production" ? "https://api.framer.com" : "https://api.development.framer.com"; + +export const TWO_FA_COOKIE_NAME = "dub_2fa_token"; diff --git a/apps/web/lib/auth/options.ts b/apps/web/lib/auth/options.ts index 815ddd3dbd..ae6fc2f401 100644 --- a/apps/web/lib/auth/options.ts +++ b/apps/web/lib/auth/options.ts @@ -7,30 +7,54 @@ import { sendEmail } from "@dub/email"; import { LoginLink } from "@dub/email/templates/login-link"; import { prisma } from "@dub/prisma"; import { PrismaClient } from "@dub/prisma/client"; +import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { waitUntil } from "@vercel/functions"; import { User, type NextAuthOptions } from "next-auth"; import { AdapterUser } from "next-auth/adapters"; -import { JWT } from "next-auth/jwt"; +import { decode, encode, JWT } from "next-auth/jwt"; import CredentialsProvider from "next-auth/providers/credentials"; import EmailProvider from "next-auth/providers/email"; import GithubProvider from "next-auth/providers/github"; import GoogleProvider from "next-auth/providers/google"; - -import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; +import { cookies } from "next/headers"; import { createId } from "../api/create-id"; import { qstash } from "../cron"; import { completeProgramApplications } from "../partners/complete-program-applications"; -import { FRAMER_API_HOST } from "./constants"; +import { FRAMER_API_HOST, TWO_FA_COOKIE_NAME } from "./constants"; import { exceededLoginAttemptsThreshold, incrementLoginAttempts, } from "./lock-account"; import { validatePassword } from "./password"; +import { getTOTPInstance } from "./totp"; import { trackLead } from "./track-lead"; const VERCEL_DEPLOYMENT = !!process.env.VERCEL_URL; +const setTwoFactorAuthCookie = async (user: Pick) => { + const token = await encode({ + secret: process.env.NEXTAUTH_SECRET as string, + maxAge: 2 * 60, + token: { + sub: user.id, + email: user.email, + purpose: "2fa", + iat: Math.floor(Date.now() / 1000), + }, + }); + + cookies().set({ + name: TWO_FA_COOKIE_NAME, + value: token, + path: "/", + httpOnly: true, + secure: VERCEL_DEPLOYMENT, + expires: new Date(Date.now() + 2 * 60 * 1000), + sameSite: "lax", + }); +}; + const CustomPrismaAdapter = (p: PrismaClient) => { return { ...PrismaAdapter(p), @@ -231,6 +255,9 @@ export const authOptions: NextAuthOptions = { image: true, invalidLoginAttempts: true, emailVerified: true, + twoFactorConfirmedAt: true, + twoFactorRecoveryCodes: true, + twoFactorSecret: true, }, }); @@ -271,6 +298,110 @@ export const authOptions: NextAuthOptions = { }, }); + if (user.twoFactorConfirmedAt) { + await setTwoFactorAuthCookie(user); + throw new Error("two-factor-required"); + } + + return { + id: user.id, + name: user.name, + email: user.email, + image: user.image, + }; + }, + }), + + // Two-factor challenge + CredentialsProvider({ + id: "two-factor-challenge", + name: "Two-factor challenge", + type: "credentials", + credentials: { + code: { type: "text" }, + }, + async authorize(credentials, req) { + if (!credentials) { + throw new Error("no-credentials"); + } + + const { code } = credentials; + + if (!code) { + throw new Error("no-credentials"); + } + + const cookie = cookies().get(TWO_FA_COOKIE_NAME); + + if (!cookie) { + throw new Error("no-2fa-token"); + } + + const { success } = await ratelimit(5, "24 h").limit( + `2fa-challenge:${cookie.value}`, + ); + + if (!success) { + throw new Error("too-many-2fa-attempts"); // TODO: add to errorCodes + } + + const decoded = await decode({ + token: cookie.value, + secret: process.env.NEXTAUTH_SECRET as string, + }); + + if (!decoded) { + throw new Error("invalid-2fa-token"); + } + + cookies().delete(TWO_FA_COOKIE_NAME); + + const { sub, email } = decoded; + + const user = await prisma.user.findUnique({ + where: { + id: sub, + email: email as string, + }, + select: { + id: true, + name: true, + email: true, + image: true, + twoFactorConfirmedAt: true, + twoFactorSecret: true, + }, + }); + + if (!user) { + console.error("User not found", { sub, email }); + throw new Error("invalid-credentials"); + } + + if (!user.twoFactorConfirmedAt) { + console.error("Two-factor not confirmed", { sub, email }); + throw new Error("invalid-credentials"); + } + + if (!user.twoFactorSecret) { + console.error("Two-factor secret not found", { sub, email }); + throw new Error("invalid-credentials"); + } + + const totp = getTOTPInstance({ + secret: user.twoFactorSecret, + }); + + const delta = totp.validate({ + token: code, + window: 1, + }); + + if (delta === null) { + console.error("Invalid 2FA code entered", { sub, email }); + throw new Error("invalid-2fa-code"); + } + return { id: user.id, name: user.name, diff --git a/apps/web/lib/auth/totp.ts b/apps/web/lib/auth/totp.ts new file mode 100644 index 0000000000..ddb4e95ae2 --- /dev/null +++ b/apps/web/lib/auth/totp.ts @@ -0,0 +1,30 @@ +import * as OTPAuth from "otpauth"; + +const options = { + issuer: "Dub", + algorithm: "SHA1", + digits: 6, + period: 30, +}; + +export const generateTOTPSecret = () => { + const secret = new OTPAuth.Secret({ + size: 20, // 160 bits = 32 characters + }); + + return secret.base32; +}; + +export const getTOTPInstance = ({ + secret, + label, +}: { + secret: string; + label?: string; +}) => { + return new OTPAuth.TOTP({ + ...options, + secret, + label, + }); +}; diff --git a/apps/web/lib/middleware/app.ts b/apps/web/lib/middleware/app.ts index 114f7f47de..374a4538c9 100644 --- a/apps/web/lib/middleware/app.ts +++ b/apps/web/lib/middleware/app.ts @@ -27,6 +27,7 @@ export default async function AppMiddleware(req: NextRequest) { path !== "/forgot-password" && path !== "/register" && path !== "/auth/saml" && + path !== "/two-factor-challenge" && !path.startsWith("/auth/reset-password/") && !path.startsWith("/share/") ) { diff --git a/apps/web/lib/swr/use-user.ts b/apps/web/lib/swr/use-user.ts index 1025abbad3..ca35aedbf4 100644 --- a/apps/web/lib/swr/use-user.ts +++ b/apps/web/lib/swr/use-user.ts @@ -1,12 +1,13 @@ import { fetcher } from "@dub/utils"; -import useSWRImmutable from "swr/immutable"; +import useSWR from "swr"; import { UserProps } from "../types"; export default function useUser() { - const { data, isLoading } = useSWRImmutable("/api/user", fetcher); + const { data, isLoading, mutate } = useSWR("/api/user", fetcher); return { user: data, loading: isLoading, + mutate, }; } diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index 3379bc91bf..38fe703da5 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -187,6 +187,8 @@ export interface UserProps { isMachine: boolean; hasPassword: boolean; provider: string | null; + twoFactorConfirmedAt: Date | null; + twoFactorRecoveryCodes: string | null; } export interface WorkspaceUserProps extends UserProps { diff --git a/apps/web/package.json b/apps/web/package.json index eb770817e8..e05f95e869 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -92,6 +92,7 @@ "node-html-parser": "^6.1.4", "openapi-types": "^12.1.3", "openapi3-ts": "^4.2.1", + "otpauth": "^9.4.0", "posthog-js": "^1.164.1", "react": "^18.2.0", "react-colorful": "^5.6.1", diff --git a/apps/web/ui/auth/login/email-sign-in.tsx b/apps/web/ui/auth/login/email-sign-in.tsx index d688888029..3871520de6 100644 --- a/apps/web/ui/auth/login/email-sign-in.tsx +++ b/apps/web/ui/auth/login/email-sign-in.tsx @@ -92,6 +92,11 @@ export const EmailSignIn = ({ next }: { next?: string }) => { } if (!response.ok && response.error) { + if (response.error === "two-factor-required") { + router.push("/two-factor-challenge"); + return; + } + if (errorCodes[response.error]) { toast.error(errorCodes[response.error]); } else { diff --git a/apps/web/ui/auth/login/login-form.tsx b/apps/web/ui/auth/login/login-form.tsx index dfbe0399e0..81092e71b4 100644 --- a/apps/web/ui/auth/login/login-form.tsx +++ b/apps/web/ui/auth/login/login-form.tsx @@ -43,6 +43,13 @@ export const errorCodes = { "There was an issue signing you in. Please ensure your provider settings are correct.", OAuthCallback: "We faced a problem while processing the response from the OAuth provider. Please try again.", + + // 2FA + "two-factor-required": "Please enter your two-factor code.", + "no-2fa-token": "Auth session expired. Please start the login process again.", + "invalid-2fa-token": + "Auth session expired. Please start the login process again.", + "invalid-2fa-code": "The code you entered is incorrect. Please try again.", }; export const LoginFormContext = createContext<{ diff --git a/apps/web/ui/modals/disable-two-factor-auth-modal.tsx b/apps/web/ui/modals/disable-two-factor-auth-modal.tsx new file mode 100644 index 0000000000..425e74ed21 --- /dev/null +++ b/apps/web/ui/modals/disable-two-factor-auth-modal.tsx @@ -0,0 +1,97 @@ +import { disableTwoFactorAuthAction } from "@/lib/actions/auth/disable-two-factor-auth"; +import useUser from "@/lib/swr/use-user"; +import { Button, Modal } from "@dub/ui"; +import { useAction } from "next-safe-action/hooks"; +import { useCallback, useMemo, useState } from "react"; +import { toast } from "sonner"; + +interface DisableTwoFactorAuthModalProps { + showModal: boolean; + setShowModal: (showModal: boolean) => void; +} + +const DisableTwoFactorAuthModal = ({ + showModal, + setShowModal, +}: DisableTwoFactorAuthModalProps) => { + const { mutate } = useUser(); + + const { executeAsync: disable2FA, isPending: isDisabling } = useAction( + disableTwoFactorAuthAction, + { + onSuccess: () => { + toast.success("Two-factor authentication disabled successfully!"); + setShowModal(false); + mutate(); + }, + onError({ error }) { + toast.error(error.serverError); + }, + }, + ); + + return ( + +
+

+ Disable Two-factor Authentication +

+

+ Are you sure you want to disable two-factor authentication? Your + one-time codes will no longer be valid and your recovery codes will be + deleted. +

+
+ +
+
{ + e.preventDefault(); + disable2FA(); + }} + > +
+
+
+
+
+ ); +}; + +export function useDisableTwoFactorAuthModal() { + const [showDisableTwoFactorAuthModal, setShowDisableTwoFactorAuthModal] = + useState(false); + + const DisableTwoFactorAuthModalCallback = useCallback(() => { + return ( + + ); + }, [showDisableTwoFactorAuthModal, setShowDisableTwoFactorAuthModal]); + + return useMemo( + () => ({ + setShowDisableTwoFactorAuthModal, + DisableTwoFactorAuthModal: DisableTwoFactorAuthModalCallback, + }), + [setShowDisableTwoFactorAuthModal, DisableTwoFactorAuthModalCallback], + ); +} diff --git a/apps/web/ui/modals/enable-two-factor-auth-modal.tsx b/apps/web/ui/modals/enable-two-factor-auth-modal.tsx new file mode 100644 index 0000000000..276871d443 --- /dev/null +++ b/apps/web/ui/modals/enable-two-factor-auth-modal.tsx @@ -0,0 +1,156 @@ +import { confirmTwoFactorAuthAction } from "@/lib/actions/auth/confirm-two-factor-auth"; +import { QRCode } from "@/ui/shared/qr-code"; +import { Button, CopyButton, Modal } from "@dub/ui"; +import { OTPInput } from "input-otp"; +import { useAction } from "next-safe-action/hooks"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +interface EnableTwoFactorAuthModalProps { + showModal: boolean; + setShowModal: (show: boolean) => void; + onSuccess?: () => void; + secret: string; + qrCodeUrl: string; +} + +const EnableTwoFactorAuthModal = ({ + showModal, + setShowModal, + onSuccess, + secret, + qrCodeUrl, +}: EnableTwoFactorAuthModalProps) => { + const [token, setToken] = useState(""); + const [touched, setTouched] = useState(false); + const [error, setError] = useState(undefined); + + useEffect(() => { + if (showModal) { + setToken(""); + setTouched(false); + setError(undefined); + } + }, [showModal]); + + const { executeAsync, isPending } = useAction(confirmTwoFactorAuthAction, { + onSuccess: () => { + toast.success("Two-factor authentication enabled successfully!"); + setShowModal(false); + onSuccess?.(); + }, + onError: (error) => { + setError( + error.error.serverError || "Failed to validate code. Please try again.", + ); + }, + }); + + const confirmTwoFactorAuth = async (e: React.FormEvent) => { + e.preventDefault(); + setTouched(true); + setError(undefined); + + await executeAsync({ + token, + }); + }; + + return ( + +
+

Enable Authenticator App

+

+ Scan the QR code below with your preferred authenticator app. Then, + enter the 6 digit code that the app provides to continue. You can also + copy the secret below and paste it into your app. +

+ +
+ +
+ +
+ {secret} + +
+ +
+ ( +
+ {slots.map(({ char, isActive, hasFakeCaret }, idx) => ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ))} +
+ )} + /> + + {error && ( +

+ {error} +

+ )} + +
+ + ); +}; + +export function useEnableTwoFactorAuthModal({ + onSuccess, + secret, + qrCodeUrl, +}: { + onSuccess?: () => void; + secret: string; + qrCodeUrl: string; +}) { + const [showModal, setShowModal] = useState(false); + + const ModalCallback = useCallback( + () => ( + + ), + [showModal, onSuccess, secret, qrCodeUrl], + ); + + return useMemo( + () => ({ + setShowEnableTwoFactorAuthModal: setShowModal, + EnableTwoFactorAuthModal: ModalCallback, + }), + [setShowModal, ModalCallback], + ); +} diff --git a/packages/email/src/templates/two-factor-disabled.tsx b/packages/email/src/templates/two-factor-disabled.tsx new file mode 100644 index 0000000000..013b1b4a66 --- /dev/null +++ b/packages/email/src/templates/two-factor-disabled.tsx @@ -0,0 +1,49 @@ +import { DUB_WORDMARK } from "@dub/utils"; +import { + Body, + Container, + Head, + Heading, + Html, + Img, + Link, + Preview, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import { Footer } from "../components/footer"; + +export default function TwoFactorDisabled({ + email = "panic@thedis.co", +}: { + email: string; +}) { + return ( + + + Two Factor authentication disabled + + + +
+ Dub +
+ + + Two Factor authentication disabled + + + + Two-factor authentication (2FA) was successfully disabled. If you + did not make this change, contact{" "} + Support immediately. + + +