From 6985b2e17464c7369e27e2d19751937a98cbd6e7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 11 Mar 2025 00:28:58 -0700 Subject: [PATCH] feat: use OTP instead of email verification on signup --- app/(auth)/login/page.tsx | 23 ++- app/(auth)/signup/page.tsx | 5 +- app/(auth)/verify-request/page.tsx | 32 ----- app/(auth)/verify/page.tsx | 178 +++++++++++++++++++---- components/ui/input-otp.tsx | 70 +++++++++ lib/auth-client.ts | 4 +- lib/auth.ts | 76 +++++----- package-lock.json | 221 +++++++++++++++-------------- package.json | 1 + tailwind.config.ts | 5 + 10 files changed, 410 insertions(+), 205 deletions(-) delete mode 100644 app/(auth)/verify-request/page.tsx create mode 100644 components/ui/input-otp.tsx diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 0815cbfab..de566a6c5 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -43,7 +43,28 @@ export default function LoginPage() { if (err.message?.includes('not verified')) { errorMessage = - 'Please verify your email before signing in. Check your inbox for the verification link.' + 'Please verify your email before signing in. Would you like to resend the verification code?' + + // Offer to send a verification code and redirect to verification page + const resendVerification = window.confirm( + 'Your email is not verified. Would you like to resend the verification code and go to the verification page?' + ) + + if (resendVerification) { + try { + // Send a new verification OTP + await client.emailOtp.sendVerificationOtp({ + email, + type: 'email-verification', + }) + + // Redirect to the verify page + router.push(`/verify?email=${encodeURIComponent(email)}`) + return + } catch (verifyErr) { + errorMessage = 'Failed to send verification code. Please try again later.' + } + } } else if (err.message?.includes('not found')) { errorMessage = 'No account found with this email. Please sign up first.' } else if (err.message?.includes('invalid password')) { diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx index c7074ab63..1c66d7c03 100644 --- a/app/(auth)/signup/page.tsx +++ b/app/(auth)/signup/page.tsx @@ -35,7 +35,10 @@ export default function SignupPage() { try { await client.signUp.email({ email, password, name }) - router.push('/verify-request') + + // No need to manually send OTP as it's handled by sendVerificationOnSignUp + // Redirect directly to the verify page instead of verify-request + router.push(`/verify?email=${encodeURIComponent(email)}`) } catch (err: any) { let errorMessage = 'Failed to create account' diff --git a/app/(auth)/verify-request/page.tsx b/app/(auth)/verify-request/page.tsx deleted file mode 100644 index af241bf44..000000000 --- a/app/(auth)/verify-request/page.tsx +++ /dev/null @@ -1,32 +0,0 @@ -'use client' - -import Link from 'next/link' -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' - -export default function VerifyRequestPage() { - return ( -
-
-

Sim Studio

- - - Check your email - - A verification link has been sent to your email address. - - - -

- Click the link in the email to verify your account. If you don't see it, check your - spam folder. -

- -
-
-
-
- ) -} diff --git a/app/(auth)/verify/page.tsx b/app/(auth)/verify/page.tsx index 911b0d8c4..edfd6050d 100644 --- a/app/(auth)/verify/page.tsx +++ b/app/(auth)/verify/page.tsx @@ -3,52 +3,167 @@ import { Suspense, useEffect, useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp' import { client } from '@/lib/auth-client' +import { useNotificationStore } from '@/stores/notifications/store' +// Extract the content into a separate component function VerifyContent() { const router = useRouter() const searchParams = useSearchParams() - const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading') + const { addNotification } = useNotificationStore() + const [otp, setOtp] = useState('') + const [email, setEmail] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [isVerified, setIsVerified] = useState(false) + const [isSendingInitialOtp, setIsSendingInitialOtp] = useState(false) + // Get email from URL query param useEffect(() => { - const token = searchParams.get('token') - if (!token) { - setStatus('error') - return + const emailParam = searchParams.get('email') + if (emailParam) { + setEmail(decodeURIComponent(emailParam)) } + }, [searchParams]) - client - .verifyEmail({ query: { token } }) - .then(() => { - setStatus('success') - // Redirect to workflow page after a short delay - setTimeout(() => router.push('/w'), 2000) + // Send initial OTP code if this is the first load + useEffect(() => { + if (email && !isSendingInitialOtp) { + setIsSendingInitialOtp(true) + // Send verification OTP on initial page load + client.emailOtp + .sendVerificationOtp({ + email, + type: 'email-verification', + }) + .then(() => {}) + .catch((error) => { + console.error('Failed to send initial verification code:', error) + addNotification?.( + 'error', + 'Failed to send verification code. Please use the resend button.', + null + ) + }) + } + }, [email, isSendingInitialOtp, addNotification]) + + // Enable the verify button when all 6 digits are entered + const isOtpComplete = otp.length === 6 + + async function verifyCode() { + if (!isOtpComplete || !email) return + + setIsLoading(true) + + try { + // Call the verification API with the OTP code + await client.emailOtp.verifyEmail({ + email, + otp, }) - .catch(() => setStatus('error')) - }, [searchParams, router]) + + setIsVerified(true) + + // Redirect to dashboard after a short delay + setTimeout(() => router.push('/w'), 2000) + } catch (error: any) { + let errorMessage = 'Verification failed. Please check your code and try again.' + + if (error.message?.includes('expired')) { + errorMessage = 'The verification code has expired. Please request a new one.' + } else if (error.message?.includes('invalid')) { + errorMessage = 'Invalid verification code. Please check and try again.' + } else if (error.message?.includes('attempts')) { + errorMessage = 'Too many failed attempts. Please request a new code.' + } + + addNotification?.('error', errorMessage, null) + } finally { + setIsLoading(false) + } + } + + function resendCode() { + if (!email) return + + setIsLoading(true) + + client.emailOtp + .sendVerificationOtp({ + email, + type: 'email-verification', + }) + .then(() => {}) + .catch(() => { + addNotification?.( + 'error', + 'Failed to resend verification code. Please try again later.', + null + ) + }) + .finally(() => { + setIsLoading(false) + }) + } return ( - - {status === 'loading' && 'Verifying your email...'} - {status === 'success' && 'Email verified!'} - {status === 'error' && 'Verification failed'} - + {isVerified ? 'Email Verified!' : 'Verify your email'} - {status === 'loading' && 'Please wait while we verify your email address.'} - {status === 'success' && 'You will be redirected to the dashboard shortly.'} - {status === 'error' && 'The verification link is invalid or has expired.'} + {isVerified + ? 'Your email has been verified. Redirecting to dashboard...' + : `A verification code has been sent to ${email || 'your email'}`} - - {status === 'error' && ( - - )} - + + )} + + {!isVerified && ( + +

+ Didn't receive a code?{' '} + +

+
+ )}
) } @@ -62,9 +177,12 @@ export default function VerifyPage() { fallback={ - Loading... - Please wait while we load the verification page. + Verify your email + Loading verification page... + +
+
} > diff --git a/components/ui/input-otp.tsx b/components/ui/input-otp.tsx new file mode 100644 index 000000000..b430595f2 --- /dev/null +++ b/components/ui/input-otp.tsx @@ -0,0 +1,70 @@ +'use client' + +import * as React from 'react' +import { OTPInput, OTPInputContext } from 'input-otp' +import { Minus } from 'lucide-react' +import { cn } from '@/lib/utils' + +const InputOTP = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, containerClassName, ...props }, ref) => ( + +)) +InputOTP.displayName = 'InputOTP' + +const InputOTPGroup = React.forwardRef< + React.ElementRef<'div'>, + React.ComponentPropsWithoutRef<'div'> +>(({ className, ...props }, ref) => ( +
+)) +InputOTPGroup.displayName = 'InputOTPGroup' + +const InputOTPSlot = React.forwardRef< + React.ElementRef<'div'>, + React.ComponentPropsWithoutRef<'div'> & { index: number } +>(({ index, className, ...props }, ref) => { + const inputOTPContext = React.useContext(OTPInputContext) + const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ) +}) +InputOTPSlot.displayName = 'InputOTPSlot' + +const InputOTPSeparator = React.forwardRef< + React.ElementRef<'div'>, + React.ComponentPropsWithoutRef<'div'> +>(({ ...props }, ref) => ( +
+ +
+)) +InputOTPSeparator.displayName = 'InputOTPSeparator' + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } diff --git a/lib/auth-client.ts b/lib/auth-client.ts index 451e4c47a..92395c729 100644 --- a/lib/auth-client.ts +++ b/lib/auth-client.ts @@ -1,8 +1,8 @@ -import { genericOAuthClient } from 'better-auth/client/plugins' +import { emailOTPClient, genericOAuthClient } from 'better-auth/client/plugins' import { createAuthClient } from 'better-auth/react' export const client = createAuthClient({ - plugins: [genericOAuthClient()], + plugins: [genericOAuthClient(), emailOTPClient()], }) export const { useSession } = client diff --git a/lib/auth.ts b/lib/auth.ts index 1f5d5f4a9..c7cf6b4e2 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -2,7 +2,7 @@ import { headers } from 'next/headers' import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { nextCookies } from 'better-auth/next-js' -import { genericOAuth } from 'better-auth/plugins' +import { emailOTP, genericOAuth } from 'better-auth/plugins' import { Resend } from 'resend' import { db } from '@/db' import * as schema from '@/db/schema' @@ -42,6 +42,7 @@ export const auth = betterAuth({ emailAndPassword: { enabled: true, requireEmailVerification: true, + sendVerificationOnSignUp: false, throwOnMissingCredentials: true, throwOnInvalidCredentials: true, sendResetPassword: async ({ user, url, token }, request) => { @@ -62,41 +63,48 @@ export const auth = betterAuth({ } }, }, - emailVerification: { - sendVerificationEmail: async ({ user, url, token }, request) => { - try { - if (!user.email) { - throw new Error('User email is required') - } - - const result = await resend.emails.send({ - from: 'Sim Studio ', - to: user.email, - subject: 'Verify your email', - html: ` -

Welcome to Sim Studio!

-

Click the link below to verify your email:

- ${url} -

If you didn't create an account, you can safely ignore this email.

- `, - }) - - if (!result) { - throw new Error('Failed to send verification email') - } - } catch (error) { - console.error('Error sending verification email:', { - error, - user: user.email, - url, - token, - }) - throw error - } - }, - }, plugins: [ nextCookies(), + emailOTP({ + sendVerificationOTP: async (data: { + email: string + otp: string + type: 'sign-in' | 'email-verification' | 'forget-password' + }) => { + try { + if (!data.email) { + throw new Error('Email is required') + } + + const result = await resend.emails.send({ + from: 'Sim Studio ', + to: data.email, + subject: 'Verify your email', + html: ` +

Welcome to Sim Studio!

+

Your verification code is:

+

${data.otp}

+

This code will expire in 15 minutes.

+

If you didn't create an account, you can safely ignore this email.

+ `, + }) + + if (!result) { + throw new Error('Failed to send verification code') + } + } catch (error) { + console.error('Error sending verification code:', { + error, + email: data.email, + otp: data.otp, + }) + throw error + } + }, + sendVerificationOnSignUp: true, + otpLength: 6, // Explicitly set the OTP length + expiresIn: 15 * 60, // 15 minutes in seconds + }), genericOAuth({ config: [ { diff --git a/package-lock.json b/package-lock.json index 598ec08c9..368d8eada 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "date-fns": "^3.6.0", "drizzle-orm": "^0.39.3", "groq-sdk": "^0.15.0", + "input-otp": "^1.4.2", "jwt-decode": "^4.0.0", "lodash.debounce": "^4.0.8", "lucide-react": "^0.469.0", @@ -1258,6 +1259,111 @@ "node": ">= 10" } }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.0.tgz", + "integrity": "sha512-DiU85EqSHogCz80+sgsx90/ecygfCSGl5P3b4XDRVZpgujBm5lp4ts7YaHru7eVTyZMjHInzKr+w0/7+qDrvMA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.0.tgz", + "integrity": "sha512-VnpoMaGukiNWVxeqKHwi8MN47yKGyki5q+7ql/7p/3ifuU2341i/gDwGK1rivk0pVYbdv5D8z63uu9yMw0QhpQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.0.tgz", + "integrity": "sha512-ka97/ssYE5nPH4Qs+8bd8RlYeNeUVBhcnsNUmFM6VWEob4jfN9FTr0NBhXVi1XEJpj3cMfgSRW+LdE3SUZbPrw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.0.tgz", + "integrity": "sha512-zY1JduE4B3q0k2ZCE+DAF/1efjTXUsKP+VXRtrt/rJCTgDlUyyryx7aOgYXNc1d8gobys/Lof9P9ze8IyRDn7Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.0.tgz", + "integrity": "sha512-QqvLZpurBD46RhaVaVBepkVQzh8xtlUN00RlG4Iq1sBheNugamUNPuZEH1r9X1YGQo1KqAe1iiShF0acva3jHQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.0.tgz", + "integrity": "sha512-ODZ0r9WMyylTHAN6pLtvUtQlGXBL9voljv6ujSlcsjOxhtXPI1Ag6AhZK0SE8hEpR1374WZZ5w33ChpJd5fsjw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.0.tgz", + "integrity": "sha512-8+4Z3Z7xa13NdUuUAcpVNA6o76lNPniBd9Xbo02bwXQXnZgFvEopwY2at5+z7yHl47X9qbZpvwatZ2BRo3EdZw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@noble/ciphers": { "version": "0.6.0", "license": "MIT", @@ -6559,6 +6665,16 @@ "version": "1.3.8", "license": "ISC" }, + "node_modules/input-otp": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", + "integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/inquirer": { "version": "8.2.6", "license": "MIT", @@ -11516,111 +11632,6 @@ "engines": { "node": ">=16" } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "15.2.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.0.tgz", - "integrity": "sha512-DiU85EqSHogCz80+sgsx90/ecygfCSGl5P3b4XDRVZpgujBm5lp4ts7YaHru7eVTyZMjHInzKr+w0/7+qDrvMA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.2.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.0.tgz", - "integrity": "sha512-VnpoMaGukiNWVxeqKHwi8MN47yKGyki5q+7ql/7p/3ifuU2341i/gDwGK1rivk0pVYbdv5D8z63uu9yMw0QhpQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.2.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.0.tgz", - "integrity": "sha512-ka97/ssYE5nPH4Qs+8bd8RlYeNeUVBhcnsNUmFM6VWEob4jfN9FTr0NBhXVi1XEJpj3cMfgSRW+LdE3SUZbPrw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.2.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.0.tgz", - "integrity": "sha512-zY1JduE4B3q0k2ZCE+DAF/1efjTXUsKP+VXRtrt/rJCTgDlUyyryx7aOgYXNc1d8gobys/Lof9P9ze8IyRDn7Q==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "15.2.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.0.tgz", - "integrity": "sha512-QqvLZpurBD46RhaVaVBepkVQzh8xtlUN00RlG4Iq1sBheNugamUNPuZEH1r9X1YGQo1KqAe1iiShF0acva3jHQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.2.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.0.tgz", - "integrity": "sha512-ODZ0r9WMyylTHAN6pLtvUtQlGXBL9voljv6ujSlcsjOxhtXPI1Ag6AhZK0SE8hEpR1374WZZ5w33ChpJd5fsjw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.2.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.0.tgz", - "integrity": "sha512-8+4Z3Z7xa13NdUuUAcpVNA6o76lNPniBd9Xbo02bwXQXnZgFvEopwY2at5+z7yHl47X9qbZpvwatZ2BRo3EdZw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } } } } diff --git a/package.json b/package.json index e34de9726..919adb526 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "date-fns": "^3.6.0", "drizzle-orm": "^0.39.3", "groq-sdk": "^0.15.0", + "input-otp": "^1.4.2", "jwt-decode": "^4.0.0", "lodash.debounce": "^4.0.8", "lucide-react": "^0.469.0", diff --git a/tailwind.config.ts b/tailwind.config.ts index 6519db183..c3db14905 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -107,6 +107,10 @@ export default { filter: 'opacity(0.7)', }, }, + 'caret-blink': { + '0%,70%,100%': { opacity: '1' }, + '20%,50%': { opacity: '0' }, + }, }, animation: { 'slide-down': 'slide-down 0.3s ease-out', @@ -115,6 +119,7 @@ export default { 'fade-up': 'fade-up 0.5s ease-out forwards', 'rocket-pulse': 'rocket-pulse 1.5s ease-in-out infinite', 'run-glow': 'run-glow 2s ease-in-out infinite', + 'caret-blink': 'caret-blink 1.25s ease-out infinite', }, }, },