Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
32 changes: 28 additions & 4 deletions apps/web/app/(org)/login/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { LucideArrowUpRight } from "lucide-react";
import { signIn } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useSearchParams, useRouter } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import { toast } from "sonner";

Expand All @@ -27,6 +27,7 @@ const MotionButton = motion(Button);

export function LoginForm() {
const searchParams = useSearchParams();
const router = useRouter();
const next = searchParams?.get("next");
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
Expand All @@ -35,6 +36,7 @@ export function LoginForm() {
const [showOrgInput, setShowOrgInput] = useState(false);
const [organizationId, setOrganizationId] = useState("");
const [organizationName, setOrganizationName] = useState<string | null>(null);
const [lastEmailSentTime, setLastEmailSentTime] = useState<number | null>(null);
const theme = Cookies.get("theme") || "light";

useEffect(() => {
Expand Down Expand Up @@ -218,6 +220,17 @@ export function LoginForm() {
e.preventDefault();
if (!email) return;

// Check if we're rate limited on the client side
if (lastEmailSentTime) {
const timeSinceLastRequest = Date.now() - lastEmailSentTime;
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;
}
}

setLoading(true);
trackEvent("auth_started", {
method: "email",
Expand All @@ -230,19 +243,30 @@ export function LoginForm() {
})
.then((res) => {
setLoading(false);

if (res?.ok && !res?.error) {
setEmailSent(true);
setLastEmailSentTime(Date.now());
trackEvent("auth_email_sent", {
email_domain: email.split("@")[1],
});
toast.success("Email sent - check your inbox!");
const params = new URLSearchParams({
email,
...(next && { next }),
lastSent: Date.now().toString(),
});
router.push(`/verify-otp?${params.toString()}`);
} else {
toast.error("Error sending email - try again?");
// NextAuth always returns "EmailSignin" for all email provider errors
// Since we already check rate limiting on the client side before sending,
// if we get an error here, it's likely rate limiting from the server
toast.error("Please wait 30 seconds before requesting a new code");
}
})
.catch(() => {
.catch((error) => {
setEmailSent(false);
setLoading(false);
// Catch block is rarely triggered with NextAuth
toast.error("Error sending email - try again?");
});
}}
Expand Down
22 changes: 22 additions & 0 deletions apps/web/app/(org)/verify-otp/actions.ts
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" };
}
}
213 changes: 213 additions & 0 deletions apps/web/app/(org)/verify-otp/form.tsx
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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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 () => {
Comment thread
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",
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Potential security issue: Missing error handling for signIn

The signIn call after successful OTP verification doesn't handle potential failures, which could leave users in a confused state.

       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
In apps/web/app/(org)/verify-otp/form.tsx around lines 70 to 74, the signIn call
lacks error handling, which may cause user confusion if sign-in fails. Modify
the code to capture the result of the signIn call, check for errors or
unsuccessful sign-in, and handle these cases appropriately by showing an error
message or preventing redirection. This ensures users receive clear feedback on
sign-in failures.

} 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}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={1}
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="one-time-code"
aria-label={`Digit ${index + 1} of 6`}
maxLength={1}
🤖 Prompt for AI Agents
In apps/web/app/(org)/verify-otp/form.tsx around lines 134 to 137, the input
fields lack important HTML attributes that improve accessibility and mobile user
experience. Add the attribute aria-label or aria-labelledby to describe the
input purpose for screen readers, and include autoComplete="one-time-code" to
enable better OTP autofill on supported devices. Also, consider adding
inputMode="numeric" and pattern="[0-9]*" if not already present to optimize
mobile keyboard display.

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);
}}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Redundant paste handler on first input

The paste event handler on line 141-145 is redundant because handleChange already handles pasted content. This could cause issues with the paste functionality.

-            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);
+              }
+            }}

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/web/app/(org)/verify-otp/form.tsx around lines 141 to 145, remove the
onPaste event handler from the first input element because handleChange already
processes pasted content. This will prevent redundant handling and potential
conflicts with paste functionality.

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>
);
}
32 changes: 32 additions & 0 deletions apps/web/app/(org)/verify-otp/page.tsx
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>
);
}
Loading
Loading