Skip to content
Draft

2FA #2505

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/app/api/user/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export const GET = withSession(async ({ session }) => {
defaultPartnerId: true,
passwordHash: true,
createdAt: true,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

access my acooumt in to github log in

twoFactorConfirmedAt: true,
twoFactorRecoveryCodes: true,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recovecy access

},
}),

Expand Down
67 changes: 67 additions & 0 deletions apps/web/app/app.dub.co/(auth)/two-factor-challenge/form.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLFormElement>) => {
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 (
<div className="flex w-full flex-col gap-3">
<form onSubmit={submit}>
<div className="flex flex-col gap-6">
<label>
<span className="text-content-emphasis mb-2 block text-sm font-medium leading-none">
Authentication code
</span>
<Input
type="text"
autoFocus={!isMobile}
value={code}
placeholder="012345"
pattern="[0-9]*"
onChange={(e) => setCode(e.target.value)}
maxLength={6}
/>
</label>
<Button
type="submit"
text={loading ? "Verifying..." : "Verify code"}
disabled={code.length < 6}
loading={loading}
/>
</div>
</form>
</div>
);
};
32 changes: 32 additions & 0 deletions apps/web/app/app.dub.co/(auth)/two-factor-challenge/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AuthLayout>
<div className="w-full max-w-sm">
<h3 className="text-center text-xl font-semibold">
Two-factor authentication
</h3>

<div className="mt-8">
<TwoFactorChallengeForm />
</div>
</div>
</AuthLayout>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,39 @@

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";

export default function SecurityPageClient() {
const { loading, user } = useUser();

if (loading) {
return (
<div className="rounded-lg border border-neutral-200 bg-white">
<div className="flex flex-col gap-3 border-b border-neutral-200 p-5 sm:p-10">
<h2 className="text-xl font-medium">Password</h2>
<div className="h-3 w-56 rounded-full bg-neutral-100"></div>
</div>
<div className="p-5 sm:p-10">
<div className="flex justify-between gap-2">
<div className="h-3 w-56 rounded-full bg-neutral-100"></div>
<div className="h-3 w-56 rounded-full bg-neutral-100"></div>
</div>
<div className="mt-5 h-3 rounded-full bg-neutral-100"></div>
</div>
// if (loading) {
// return (
// <div className="rounded-lg border border-neutral-200 bg-white">
// <div className="flex flex-col gap-3 border-b border-neutral-200 p-5 sm:p-10">
// <h2 className="text-xl font-medium">Password</h2>
// <div className="h-3 w-56 rounded-full bg-neutral-100"></div>
// </div>
// <div className="p-5 sm:p-10">
// <div className="flex justify-between gap-2">
// <div className="h-3 w-56 rounded-full bg-neutral-100"></div>
// <div className="h-3 w-56 rounded-full bg-neutral-100"></div>
// </div>
// <div className="mt-5 h-3 rounded-full bg-neutral-100"></div>
// </div>
// </div>
// );
// }
Comment on lines +13 to +29
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider the implications of removing loading state.

Commenting out the loading state might cause the page to render before user data is available, potentially leading to:

  • UI flicker when components transition from loading to loaded states
  • Brief display of incorrect states (e.g., showing "Enable 2FA" before checking if it's already enabled)

Consider keeping a minimal loading state or ensure child components handle their own loading states properly.

🤖 Prompt for AI Agents
In apps/web/app/app.dub.co/(dashboard)/account/settings/security/page-client.tsx
around lines 13 to 29, the loading state rendering is commented out, which can
cause UI flicker and incorrect display before user data is ready. To fix this,
restore a minimal loading state UI that shows placeholders or skeletons while
data is loading, or ensure all child components independently handle their
loading states to prevent premature rendering of incomplete or incorrect
information.


return (
<div className="flex flex-col gap-10">
<div>
{user?.hasPassword ? <UpdatePassword /> : <RequestSetPassword />}
</div>
);
}

return <>{user?.hasPassword ? <UpdatePassword /> : <RequestSetPassword />}</>;
<TwoFactorAuth />
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<>
<EnableTwoFactorAuthModal />
<DisableTwoFactorAuthModal />
<div className="rounded-lg border border-neutral-200 bg-white">
<div className="flex flex-col gap-3 border-b border-neutral-200 p-5 sm:p-10">
<h2 className="text-xl font-medium">Two-factor Authentication</h2>
<p className="pb-2 text-sm text-neutral-500">
Once two-factor is enabled you will have to provide two methods of
authentication in order to sign in into your account.
</p>
</div>

<div className="flex flex-wrap justify-between gap-4 px-5 py-4 sm:px-10">
<div className="flex w-full items-center justify-between rounded-lg border border-neutral-200 bg-white p-5">
<div>
<div className="font-semibold text-neutral-900">
Authenticator App (TOTP)
</div>
<div className="text-sm text-neutral-500">
Generate codes using an app like Google Authenticator or Okta
Verify.
</div>
</div>

<Button
text={
loading
? "Loading..."
: user?.twoFactorConfirmedAt
? "Disable Two-factor"
: "Enable Two-factor"
}
variant={user?.twoFactorConfirmedAt ? "danger" : "primary"}
type="button"
className="ml-4 w-fit"
loading={isEnabling}
disabled={loading}
onClick={async () => {
if (user?.twoFactorConfirmedAt) {
setShowDisableTwoFactorAuthModal(true);
} else {
await enable2FA();
}
}}
/>
</div>
</div>
</div>
</>
);
};
82 changes: 82 additions & 0 deletions apps/web/lib/actions/auth/confirm-two-factor-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"use server";

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"use server";
"use server";![Screenshot_20250920_185603_Google Play services.jpg](https://github.com/user-attachments/assets/b766a397-0581-42b6-b6b3-9753b8e14657)


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",
}),
);
});
44 changes: 44 additions & 0 deletions apps/web/lib/actions/auth/disable-two-factor-auth.ts
Original file line number Diff line number Diff line change
@@ -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";

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 authentication factor


Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • - -5929255653128391989_121.jpg

Uploading -6071392347339474063_121.jpg …

![Uploading organization-members.csv …]()

Screenshot_20250917_103431_Chrome Canary.jpg

![supported_devices (3).csv](https://github.com/user-attachments/
files/22450327/supported_devices.3.csv)

// 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",
}),
);
},
);
Loading