diff --git a/apps/web/app/(org)/login/form.tsx b/apps/web/app/(org)/login/form.tsx index 0601ded15d..3e1370b41a 100644 --- a/apps/web/app/(org)/login/form.tsx +++ b/apps/web/app/(org)/login/form.tsx @@ -157,7 +157,7 @@ export function LoginForm() { - + {!oauthError && ( - <> - - Google - Login with Google - - + + Google + Login with Google + )} {oauthError && ( @@ -481,8 +479,7 @@ const NormalLogin = ({ />

It looks like you've previously used this email to sign up via - email login. Please enter your email below to receive a sign in - link. + email login. Please enter your email.

)} diff --git a/apps/web/app/(org)/verify-otp/form.tsx b/apps/web/app/(org)/verify-otp/form.tsx index f2effe76cf..4c7ff058aa 100644 --- a/apps/web/app/(org)/verify-otp/form.tsx +++ b/apps/web/app/(org)/verify-otp/form.tsx @@ -156,19 +156,19 @@ export function VerifyOTPForm({ - +
-

+

Enter verification code

-

+

We sent a 6-digit code to {email}

-
+
{code.map((digit, index) => ( ))} @@ -221,7 +221,7 @@ export function VerifyOTPForm({

- By verifying your email, you acknowledge that you have both read and + By entering your email, you acknowledge that you have both read and agree to Cap's{" "} = ({ const [email, setEmail] = useState(""); const [loading, setLoading] = useState(false); const [emailSent, setEmailSent] = useState(false); + const [step, setStep] = useState(1); + const [code, setCode] = useState(["", "", "", "", "", ""]); - const handleGoogleSignIn = () => { - signIn("google"); - }; + const [lastResendTime, setLastResendTime] = useState(null); + const emailId = useId(); return (

- + + {emailSent && ( +
{ + setEmailSent(false); + setEmail(""); + setLoading(false); + setStep(1); + setCode(["", "", "", "", "", ""]); + setLastResendTime(null); + }} + className="absolute top-5 left-5 cursor-pointer 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" + > + +

Back

+
+ )}
- + -
-

Sign in to comment

-

Join the conversation.

+
+

+ {step === 1 ? "Sign in to comment" : "Email sent"} +

+

+ {step === 1 + ? "Join the conversation." + : "We sent a 6-digit code to your email."} +

-
- {NODE_ENV === "development" && ( - <> - -
- -

OR

- -
- +
+ + {step === 1 ? ( + + ) : ( + )} - -
{ - e.preventDefault(); - if (!email) return; - - setLoading(true); - signIn("email", { - email, - redirect: false, - }) - .then((res) => { - setLoading(false); - if (res?.ok && !res?.error) { - setEmail(""); - setEmailSent(true); - toast.success("Email sent - check your inbox!"); - } else { - toast.error("Error sending email - try again?"); - } - }) - .catch(() => { - setEmailSent(false); - setLoading(false); - toast.error("Error sending email - try again?"); - }); - }} - className="flex flex-col space-y-3" - > -
- { - setEmail(e.target.value); - }} - /> - {NODE_ENV === "development" && ( -
-

- - Development mode: - {" "} - Auth URL will be logged to your dev console. -

-
- )} -
- -

- By typing your email and clicking continue, you acknowledge that - you have both read and agree to Cap's{" "} - - Terms of Service - {" "} - and{" "} - - Privacy Policy - - . -

-
-
- - {emailSent && ( -
- -
- )} + Privacy Policy + + . +

+
); }; + +const StepOne = ({ + email, + emailSent, + setEmail, + loading, + setEmailSent, + setLoading, + setStep, + setLastResendTime, + emailId, +}: { + email: string; + emailSent: boolean; + setEmail: (email: string) => void; + loading: boolean; + setEmailSent: (emailSent: boolean) => void; + setLoading: (loading: boolean) => void; + setStep: (step: number) => void; + setLastResendTime: (time: number | null) => void; + emailId: string; +}) => { + return ( +
{ + e.preventDefault(); + if (!email) return; + + setLoading(true); + signIn("email", { + email, + redirect: false, + }) + .then((res) => { + setLoading(false); + if (res?.ok && !res?.error) { + setEmail(""); + setEmailSent(true); + setStep(2); + setLastResendTime(Date.now()); + toast.success("Email sent - check your inbox!"); + } else { + toast.error("Error sending email - try again?"); + } + }) + .catch(() => { + setEmailSent(false); + setLoading(false); + toast.error("Error sending email - try again?"); + }); + }} + className="flex flex-col gap-3" + > +
+ { + setEmail(e.target.value); + }} + /> +
+ +
+ ); +}; diff --git a/apps/web/app/s/[videoId]/_components/OtpForm.tsx b/apps/web/app/s/[videoId]/_components/OtpForm.tsx new file mode 100644 index 0000000000..56d3855c3e --- /dev/null +++ b/apps/web/app/s/[videoId]/_components/OtpForm.tsx @@ -0,0 +1,203 @@ +import { Button } from "@cap/ui"; +import { useMutation } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { signIn } from "next-auth/react"; +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; + +const OtpForm = ({ + email, + step, + onClose, + code, + setCode, + lastResendTime, + setLastResendTime, +}: { + email: string; + step: number; + onClose: () => void; + code: string[]; + setCode: (code: string[]) => void; + lastResendTime: number | null; + setLastResendTime: (time: number | null) => void; +}) => { + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + const router = useRouter(); + + useEffect(() => { + if (step === 2) { + inputRefs.current[0]?.focus(); + } + }, [step]); + + const handleOTPChange = (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(); + } + + if (index + value.length >= 5) handleVerify.mutate(undefined); + } else { + const newCode = [...code]; + newCode[index] = value; + setCode(newCode); + + if (value && index < 5) { + inputRefs.current[index + 1]?.focus(); + } + } + }; + + const handleOTPKeyDown = ( + index: number, + e: React.KeyboardEvent, + ) => { + if (e.key === "Backspace" && !code[index] && index > 0) { + inputRefs.current[index - 1]?.focus(); + } + }; + + const handleVerify = useMutation({ + mutationFn: async () => { + const otpCode = code.join(""); + if (otpCode.length !== 6) throw "Please enter a complete 6-digit code"; + + const res = await fetch( + `/api/auth/callback/email?email=${encodeURIComponent(email)}&token=${encodeURIComponent(otpCode)}&callbackUrl=${encodeURIComponent("/login-success")}`, + ); + + if (!res.url.includes("/login-success")) { + setCode(["", "", "", "", "", ""]); + inputRefs.current[0]?.focus(); + throw "Invalid code. Please try again."; + } + }, + onSuccess: () => { + router.refresh(); + toast.success("Sign in successful!"); + onClose(); + }, + onError: (e) => { + if (typeof e === "string") { + toast.error(e); + } else { + toast.error("An error occurred. Please try again."); + } + }, + }); + + const handleResend = useMutation({ + mutationFn: 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, + ); + throw `Please wait ${remainingSeconds} seconds before requesting a new code`; + } + } + + const result = await signIn("email", { + email, + redirect: false, + }); + + if (result?.error) { + throw "Please wait 30 seconds before requesting a new code"; + } + }, + onSuccess: () => { + toast.success("A new code has been sent to your email!"); + setCode(["", "", "", "", "", ""]); + inputRefs.current[0]?.focus(); + setLastResendTime(Date.now()); + }, + onError: (e) => { + if (typeof e === "string") { + toast.error(e); + } else { + toast.error("An error occurred. Please try again."); + } + }, + }); + + const isVerifying = handleVerify.isPending || handleVerify.isSuccess; + + return ( +
+
+ {code.map((digit, index) => ( + { + inputRefs.current[index] = el; + }} + type="text" + inputMode="numeric" + pattern="[0-9]*" + maxLength={1} + value={digit} + onChange={(e) => + handleOTPChange(index, e.target.value.replace(/\D/g, "")) + } + onKeyDown={(e) => handleOTPKeyDown(index, e)} + onPaste={(e) => { + e.preventDefault(); + const pastedData = e.clipboardData + .getData("text") + .replace(/\D/g, ""); + handleOTPChange(0, pastedData); + }} + className="flex-1 h-[52px] text-lg font-semibold text-center rounded-lg border transition-all bg-gray-1 border-gray-5 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + disabled={isVerifying} + /> + ))} +
+ + + +
+ +
+
+ ); +}; + +export default OtpForm;