Skip to content

Commit a4bd7d3

Browse files
feat: Implement OTP code, replacing magic auth (#832)
1 parent 21568c8 commit a4bd7d3

File tree

8 files changed

+528
-162
lines changed

8 files changed

+528
-162
lines changed

apps/web/app/(org)/login/form.tsx

Lines changed: 68 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,12 @@ import {
88
faExclamationCircle,
99
} from "@fortawesome/free-solid-svg-icons";
1010
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
11-
import { useMutation } from "@tanstack/react-query";
1211
import { AnimatePresence, motion } from "framer-motion";
1312
import Cookies from "js-cookie";
1413
import { LucideArrowUpRight } from "lucide-react";
1514
import Image from "next/image";
1615
import Link from "next/link";
17-
import { useSearchParams } from "next/navigation";
16+
import { useRouter, useSearchParams } from "next/navigation";
1817
import { signIn } from "next-auth/react";
1918
import { Suspense, useEffect, useState } from "react";
2019
import { toast } from "sonner";
@@ -28,46 +27,20 @@ const MotionButton = motion(Button);
2827

2928
export function LoginForm() {
3029
const searchParams = useSearchParams();
30+
const router = useRouter();
3131
const next = searchParams?.get("next");
3232
const [email, setEmail] = useState("");
33+
const [loading, setLoading] = useState(false);
34+
const [emailSent, setEmailSent] = useState(false);
3335
const [oauthError, setOauthError] = useState(false);
3436
const [showOrgInput, setShowOrgInput] = useState(false);
3537
const [organizationId, setOrganizationId] = useState("");
3638
const [organizationName, setOrganizationName] = useState<string | null>(null);
39+
const [lastEmailSentTime, setLastEmailSentTime] = useState<number | null>(
40+
null,
41+
);
3742
const theme = Cookies.get("theme") || "light";
3843

39-
const emailSignInMutation = useMutation({
40-
mutationFn: async (email: string) => {
41-
trackEvent("auth_started", {
42-
method: "email",
43-
is_signup: !oauthError,
44-
});
45-
46-
const result = await signIn("email", {
47-
email,
48-
redirect: false,
49-
...(next && next.length > 0 ? { callbackUrl: next } : {}),
50-
});
51-
52-
if (!result?.ok || result?.error) {
53-
throw new Error("Failed to send email");
54-
}
55-
56-
return result;
57-
},
58-
onSuccess: () => {
59-
trackEvent("auth_email_sent", {
60-
email_domain: email.split("@")[1],
61-
});
62-
toast.success("Email sent - check your inbox!");
63-
},
64-
onError: () => {
65-
toast.error("Error sending email - try again?");
66-
},
67-
});
68-
69-
const emailSent = emailSignInMutation.isSuccess;
70-
7144
useEffect(() => {
7245
theme === "dark"
7346
? (document.body.className = "dark")
@@ -123,8 +96,6 @@ export function LoginForm() {
12396
});
12497
const data = await response.json();
12598

126-
console.log(data);
127-
12899
if (data.url) {
129100
window.location.href = data.url;
130101
}
@@ -274,7 +245,64 @@ export function LoginForm() {
274245
e.preventDefault();
275246
if (!email) return;
276247

277-
emailSignInMutation.mutate(email);
248+
// Check if we're rate limited on the client side
249+
if (lastEmailSentTime) {
250+
const timeSinceLastRequest =
251+
Date.now() - lastEmailSentTime;
252+
const waitTime = 30000; // 30 seconds
253+
if (timeSinceLastRequest < waitTime) {
254+
const remainingSeconds = Math.ceil(
255+
(waitTime - timeSinceLastRequest) / 1000,
256+
);
257+
toast.error(
258+
`Please wait ${remainingSeconds} seconds before requesting a new code`,
259+
);
260+
return;
261+
}
262+
}
263+
264+
setLoading(true);
265+
trackEvent("auth_started", {
266+
method: "email",
267+
is_signup: !oauthError,
268+
});
269+
signIn("email", {
270+
email,
271+
redirect: false,
272+
...(next && next.length > 0
273+
? { callbackUrl: next }
274+
: {}),
275+
})
276+
.then((res) => {
277+
setLoading(false);
278+
279+
if (res?.ok && !res?.error) {
280+
setEmailSent(true);
281+
setLastEmailSentTime(Date.now());
282+
trackEvent("auth_email_sent", {
283+
email_domain: email.split("@")[1],
284+
});
285+
const params = new URLSearchParams({
286+
email,
287+
...(next && { next }),
288+
lastSent: Date.now().toString(),
289+
});
290+
router.push(`/verify-otp?${params.toString()}`);
291+
} else {
292+
// NextAuth always returns "EmailSignin" for all email provider errors
293+
// Since we already check rate limiting on the client side before sending,
294+
// if we get an error here, it's likely rate limiting from the server
295+
toast.error(
296+
"Please wait 30 seconds before requesting a new code",
297+
);
298+
}
299+
})
300+
.catch((error) => {
301+
setEmailSent(false);
302+
setLoading(false);
303+
// Catch block is rarely triggered with NextAuth
304+
toast.error("Error sending email - try again?");
305+
});
278306
}}
279307
className="flex flex-col space-y-3"
280308
>
@@ -283,7 +311,7 @@ export function LoginForm() {
283311
email={email}
284312
emailSent={emailSent}
285313
setEmail={setEmail}
286-
loading={emailSignInMutation.isPending}
314+
loading={loading}
287315
oauthError={oauthError}
288316
handleGoogleSignIn={handleGoogleSignIn}
289317
/>
@@ -320,8 +348,9 @@ export function LoginForm() {
320348
layout
321349
className="pt-3 mx-auto text-sm underline text-gray-10 hover:text-gray-8"
322350
onClick={() => {
351+
setEmailSent(false);
323352
setEmail("");
324-
emailSignInMutation.reset();
353+
setLoading(false);
325354
}}
326355
>
327356
Click to restart sign in process

0 commit comments

Comments
 (0)