-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat: Implement OTP code, replacing magic auth #832
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
2e3d331
a081562
3b3f87d
ae79a17
b2033f9
f8bb64f
8ec7bb7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| "use server"; | ||
|
|
||
| import { validateOTP } from "@cap/database/auth/otp"; | ||
|
|
||
| export async function verifyOTPAction(email: string, code: string) { | ||
| try { | ||
| if (!email || !code) { | ||
| return { success: false, error: "Email and code are required" }; | ||
| } | ||
|
|
||
| const result = await validateOTP(email, code); | ||
|
|
||
| if (result.valid) { | ||
| return { success: true }; | ||
| } else { | ||
| return { success: false, error: result.error || "Invalid code" }; | ||
| } | ||
| } catch (error) { | ||
| console.error("OTP verification error:", error); | ||
| return { success: false, error: "An error occurred during verification" }; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,213 @@ | ||||||||||||||||||||||
| "use client"; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| import { useState, useRef, useEffect } from "react"; | ||||||||||||||||||||||
| import { Button, LogoBadge } from "@cap/ui"; | ||||||||||||||||||||||
| import { motion } from "framer-motion"; | ||||||||||||||||||||||
| import { signIn } from "next-auth/react"; | ||||||||||||||||||||||
| import Link from "next/link"; | ||||||||||||||||||||||
| import { toast } from "sonner"; | ||||||||||||||||||||||
| import { faArrowLeft } from "@fortawesome/free-solid-svg-icons"; | ||||||||||||||||||||||
| import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; | ||||||||||||||||||||||
| import { verifyOTPAction } from "./actions"; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| export function VerifyOTPForm({ email, next, lastSent }: { email: string; next?: string; lastSent?: string }) { | ||||||||||||||||||||||
| const [code, setCode] = useState(["", "", "", "", "", ""]); | ||||||||||||||||||||||
| const [loading, setLoading] = useState(false); | ||||||||||||||||||||||
| const [resending, setResending] = useState(false); | ||||||||||||||||||||||
| const [lastResendTime, setLastResendTime] = useState<number | null>( | ||||||||||||||||||||||
| lastSent ? parseInt(lastSent) : null | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| const inputRefs = useRef<(HTMLInputElement | null)[]>([]); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||
| inputRefs.current[0]?.focus(); | ||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const handleChange = (index: number, value: string) => { | ||||||||||||||||||||||
| if (value.length > 1) { | ||||||||||||||||||||||
| const pastedCode = value.slice(0, 6).split(""); | ||||||||||||||||||||||
| const newCode = [...code]; | ||||||||||||||||||||||
| pastedCode.forEach((digit, i) => { | ||||||||||||||||||||||
| if (index + i < 6) { | ||||||||||||||||||||||
| newCode[index + i] = digit; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| setCode(newCode); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const nextEmptyIndex = newCode.findIndex((digit) => digit === ""); | ||||||||||||||||||||||
| if (nextEmptyIndex !== -1) { | ||||||||||||||||||||||
| inputRefs.current[nextEmptyIndex]?.focus(); | ||||||||||||||||||||||
| } else { | ||||||||||||||||||||||
| inputRefs.current[5]?.focus(); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| return; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const newCode = [...code]; | ||||||||||||||||||||||
| newCode[index] = value; | ||||||||||||||||||||||
| setCode(newCode); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (value && index < 5) { | ||||||||||||||||||||||
| inputRefs.current[index + 1]?.focus(); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => { | ||||||||||||||||||||||
| if (e.key === "Backspace" && !code[index] && index > 0) { | ||||||||||||||||||||||
| inputRefs.current[index - 1]?.focus(); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const handleVerify = async () => { | ||||||||||||||||||||||
|
Brendonovich marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||
| const otpCode = code.join(""); | ||||||||||||||||||||||
| if (otpCode.length !== 6) { | ||||||||||||||||||||||
| toast.error("Please enter a complete 6-digit code"); | ||||||||||||||||||||||
| return; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| setLoading(true); | ||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| const result = await verifyOTPAction(email, otpCode); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (result.success) { | ||||||||||||||||||||||
| await signIn("otp", { | ||||||||||||||||||||||
| email, | ||||||||||||||||||||||
| redirect: true, | ||||||||||||||||||||||
| callbackUrl: next || "/dashboard", | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential security issue: Missing error handling for signIn The if (result.success) {
- await signIn("otp", {
+ const signInResult = await signIn("otp", {
email,
redirect: true,
callbackUrl: next || "/dashboard",
});
+
+ if (signInResult?.error) {
+ toast.error("Authentication failed. Please try again.");
+ setCode(["", "", "", "", "", ""]);
+ inputRefs.current[0]?.focus();
+ }
} else {🤖 Prompt for AI Agents |
||||||||||||||||||||||
| } else { | ||||||||||||||||||||||
| toast.error(result.error || "Invalid code. Please try again."); | ||||||||||||||||||||||
| setCode(["", "", "", "", "", ""]); | ||||||||||||||||||||||
| inputRefs.current[0]?.focus(); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||
| toast.error("An error occurred. Please try again."); | ||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||
| setLoading(false); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const handleResend = async () => { | ||||||||||||||||||||||
| // Check client-side rate limiting | ||||||||||||||||||||||
| if (lastResendTime) { | ||||||||||||||||||||||
| const timeSinceLastRequest = Date.now() - lastResendTime; | ||||||||||||||||||||||
| const waitTime = 30000; // 30 seconds | ||||||||||||||||||||||
| if (timeSinceLastRequest < waitTime) { | ||||||||||||||||||||||
| const remainingSeconds = Math.ceil((waitTime - timeSinceLastRequest) / 1000); | ||||||||||||||||||||||
| toast.error(`Please wait ${remainingSeconds} seconds before requesting a new code`); | ||||||||||||||||||||||
| return; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| setResending(true); | ||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| const result = await signIn("email", { | ||||||||||||||||||||||
| email, | ||||||||||||||||||||||
| redirect: false, | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (result?.ok && !result?.error) { | ||||||||||||||||||||||
| toast.success("A new code has been sent to your email!"); | ||||||||||||||||||||||
| setCode(["", "", "", "", "", ""]); | ||||||||||||||||||||||
| inputRefs.current[0]?.focus(); | ||||||||||||||||||||||
| setLastResendTime(Date.now()); | ||||||||||||||||||||||
| } else { | ||||||||||||||||||||||
| // NextAuth returns generic "EmailSignin" error for all email errors | ||||||||||||||||||||||
| toast.error("Please wait 30 seconds before requesting a new code"); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||
| toast.error("Failed to resend code. Please try again."); | ||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||
| setResending(false); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return ( | ||||||||||||||||||||||
| <motion.div | ||||||||||||||||||||||
| initial={{ opacity: 0, y: 20 }} | ||||||||||||||||||||||
| animate={{ opacity: 1, y: 0 }} | ||||||||||||||||||||||
| className="relative w-[calc(100%-5%)] p-[28px] max-w-[432px] bg-gray-3 border border-gray-5 rounded-2xl" | ||||||||||||||||||||||
| > | ||||||||||||||||||||||
| <Link | ||||||||||||||||||||||
| href="/login" | ||||||||||||||||||||||
| className="absolute top-5 left-5 z-20 flex gap-2 items-center py-1.5 px-3 text-gray-12 bg-transparent border border-gray-4 rounded-full hover:bg-gray-1 transition-colors duration-300" | ||||||||||||||||||||||
| > | ||||||||||||||||||||||
| <FontAwesomeIcon className="w-2" icon={faArrowLeft} /> | ||||||||||||||||||||||
| <p className="text-xs">Back</p> | ||||||||||||||||||||||
| </Link> | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| <Link className="flex mx-auto size-fit" href="/"> | ||||||||||||||||||||||
| <LogoBadge className="w-[72px] h-[72px]" /> | ||||||||||||||||||||||
| </Link> | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| <div className="flex flex-col justify-center items-center my-7 text-center"> | ||||||||||||||||||||||
| <h1 className="text-2xl font-semibold text-gray-12">Enter verification code</h1> | ||||||||||||||||||||||
| <p className="text-[16px] text-gray-10 mt-2"> | ||||||||||||||||||||||
| We sent a 6-digit code to {email} | ||||||||||||||||||||||
| </p> | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| <div className="flex justify-center gap-2 mb-6"> | ||||||||||||||||||||||
| {code.map((digit, index) => ( | ||||||||||||||||||||||
| <input | ||||||||||||||||||||||
| key={index} | ||||||||||||||||||||||
| ref={(el) => (inputRefs.current[index] = el)} | ||||||||||||||||||||||
| type="text" | ||||||||||||||||||||||
| inputMode="numeric" | ||||||||||||||||||||||
| pattern="[0-9]*" | ||||||||||||||||||||||
| maxLength={1} | ||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Add proper HTML attributes for better accessibility and UX The input fields are missing important attributes for accessibility and mobile UX. type="text"
inputMode="numeric"
pattern="[0-9]*"
+ autoComplete="one-time-code"
+ aria-label={`Digit ${index + 1} of 6`}
maxLength={1}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| value={digit} | ||||||||||||||||||||||
| onChange={(e) => handleChange(index, e.target.value.replace(/\D/g, ""))} | ||||||||||||||||||||||
| onKeyDown={(e) => handleKeyDown(index, e)} | ||||||||||||||||||||||
| onPaste={(e) => { | ||||||||||||||||||||||
| e.preventDefault(); | ||||||||||||||||||||||
| const pastedData = e.clipboardData.getData("text").replace(/\D/g, ""); | ||||||||||||||||||||||
| handleChange(0, pastedData); | ||||||||||||||||||||||
| }} | ||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Redundant paste handler on first input The paste event handler on line 141-145 is redundant because - onPaste={(e) => {
- e.preventDefault();
- const pastedData = e.clipboardData.getData("text").replace(/\D/g, "");
- handleChange(0, pastedData);
- }}
+ onPaste={(e) => {
+ // Only prevent default on non-first inputs to avoid double handling
+ if (index !== 0) {
+ e.preventDefault();
+ const pastedData = e.clipboardData.getData("text").replace(/\D/g, "");
+ handleChange(index, pastedData);
+ }
+ }}
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| className="w-12 h-14 text-center text-xl font-semibold bg-gray-1 border border-gray-5 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all" | ||||||||||||||||||||||
| disabled={loading} | ||||||||||||||||||||||
| /> | ||||||||||||||||||||||
| ))} | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| <Button | ||||||||||||||||||||||
| variant="primary" | ||||||||||||||||||||||
| className="w-full" | ||||||||||||||||||||||
| onClick={handleVerify} | ||||||||||||||||||||||
| disabled={loading || code.some((digit) => !digit)} | ||||||||||||||||||||||
| > | ||||||||||||||||||||||
| {loading ? "Verifying..." : "Verify Code"} | ||||||||||||||||||||||
| </Button> | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| <div className="mt-4 text-center"> | ||||||||||||||||||||||
| <button | ||||||||||||||||||||||
| onClick={handleResend} | ||||||||||||||||||||||
| disabled={resending} | ||||||||||||||||||||||
| className="text-sm text-gray-10 hover:text-gray-12 underline transition-colors" | ||||||||||||||||||||||
| > | ||||||||||||||||||||||
| {resending ? "Sending..." : "Didn't receive the code? Resend"} | ||||||||||||||||||||||
| </button> | ||||||||||||||||||||||
| </div> | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| <p className="mt-6 text-xs text-center text-gray-9"> | ||||||||||||||||||||||
| By verifying your email, you acknowledge that you have both read and agree to Cap's{" "} | ||||||||||||||||||||||
| <Link | ||||||||||||||||||||||
| href="/terms" | ||||||||||||||||||||||
| target="_blank" | ||||||||||||||||||||||
| className="text-xs font-semibold text-gray-12 hover:text-blue-300" | ||||||||||||||||||||||
| > | ||||||||||||||||||||||
| Terms of Service | ||||||||||||||||||||||
| </Link>{" "} | ||||||||||||||||||||||
| and{" "} | ||||||||||||||||||||||
| <Link | ||||||||||||||||||||||
| href="/privacy" | ||||||||||||||||||||||
| target="_blank" | ||||||||||||||||||||||
| className="text-xs font-semibold text-gray-12 hover:text-blue-300" | ||||||||||||||||||||||
| > | ||||||||||||||||||||||
| Privacy Policy | ||||||||||||||||||||||
| </Link> | ||||||||||||||||||||||
| . | ||||||||||||||||||||||
| </p> | ||||||||||||||||||||||
| </motion.div> | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| import { Suspense } from "react"; | ||
| import { VerifyOTPForm } from "./form"; | ||
| import { redirect } from "next/navigation"; | ||
| import { getSession } from "@cap/database/auth/session"; | ||
|
|
||
| export const metadata = { | ||
| title: "Verify Code | Cap", | ||
| }; | ||
|
|
||
| export default async function VerifyOTPPage({ | ||
| searchParams, | ||
| }: { | ||
| searchParams: { email?: string; next?: string; lastSent?: string }; | ||
| }) { | ||
| const session = await getSession(); | ||
|
|
||
| if (session?.user) { | ||
| redirect(searchParams.next || "/dashboard"); | ||
| } | ||
|
|
||
| if (!searchParams.email) { | ||
| redirect("/login"); | ||
| } | ||
|
|
||
| return ( | ||
| <div className="flex h-screen w-full items-center justify-center"> | ||
| <Suspense fallback={null}> | ||
| <VerifyOTPForm email={searchParams.email} next={searchParams.next} lastSent={searchParams.lastSent} /> | ||
| </Suspense> | ||
| </div> | ||
| ); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.