feat: use OTP instead of email verification on signup

This commit is contained in:
Waleed Latif
2025-03-11 00:28:58 -07:00
parent 9c2c4e3384
commit 6985b2e174
10 changed files with 410 additions and 205 deletions

View File

@@ -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')) {

View File

@@ -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'

View File

@@ -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 (
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h1 className="text-2xl font-bold text-center mb-8">Sim Studio</h1>
<Card className="w-full">
<CardHeader>
<CardTitle>Check your email</CardTitle>
<CardDescription>
A verification link has been sent to your email address.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-gray-500">
Click the link in the email to verify your account. If you don't see it, check your
spam folder.
</p>
<Button asChild className="w-full">
<Link href="/login">Back to login</Link>
</Button>
</CardContent>
</Card>
</div>
</main>
)
}

View File

@@ -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 (
<Card className="w-full">
<CardHeader>
<CardTitle>
{status === 'loading' && 'Verifying your email...'}
{status === 'success' && 'Email verified!'}
{status === 'error' && 'Verification failed'}
</CardTitle>
<CardTitle>{isVerified ? 'Email Verified!' : 'Verify your email'}</CardTitle>
<CardDescription>
{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'}`}
</CardDescription>
</CardHeader>
<CardContent>
{status === 'error' && (
<Button onClick={() => router.push('/login')} className="w-full">
Back to login
{!isVerified && (
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground mb-2">
Enter the 6-digit code from your email to verify your account. If you don't see it,
check your spam folder.
</p>
<div className="flex justify-center py-4">
<InputOTP maxLength={6} value={otp} onChange={setOtp} disabled={isLoading}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
<Button onClick={verifyCode} className="w-full" disabled={!isOtpComplete || isLoading}>
{isLoading ? 'Verifying...' : 'Verify Email'}
</Button>
)}
</CardContent>
</CardContent>
)}
{!isVerified && (
<CardFooter className="flex justify-center">
<p className="text-sm text-muted-foreground">
Didn't receive a code?{' '}
<button
className="text-primary hover:underline font-medium"
onClick={resendCode}
disabled={isLoading}
>
Resend
</button>
</p>
</CardFooter>
)}
</Card>
)
}
@@ -62,9 +177,12 @@ export default function VerifyPage() {
fallback={
<Card className="w-full">
<CardHeader>
<CardTitle>Loading...</CardTitle>
<CardDescription>Please wait while we load the verification page.</CardDescription>
<CardTitle>Verify your email</CardTitle>
<CardDescription>Loading verification page...</CardDescription>
</CardHeader>
<CardContent>
<div className="h-10 animate-pulse bg-gray-200 rounded"></div>
</CardContent>
</Card>
}
>

View File

@@ -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<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
'flex items-center gap-2 has-[:disabled]:opacity-50',
containerClassName
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
))
InputOTP.displayName = 'InputOTP'
const InputOTPGroup = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center', className)} {...props} />
))
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 (
<div
ref={ref}
className={cn(
'relative flex h-12 w-12 items-center justify-center border-y border-r border-input text-lg font-semibold shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md',
isActive && 'z-10 ring-2 ring-primary ring-offset-1',
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-6 w-px animate-caret-blink bg-primary duration-1000" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = 'InputOTPSlot'
const InputOTPSeparator = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ ...props }, ref) => (
<div ref={ref} role="separator" className="text-muted-foreground" {...props}>
<Minus />
</div>
))
InputOTPSeparator.displayName = 'InputOTPSeparator'
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@@ -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

View File

@@ -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 <onboarding@simstudio.ai>',
to: user.email,
subject: 'Verify your email',
html: `
<h2>Welcome to Sim Studio!</h2>
<p>Click the link below to verify your email:</p>
<a href="${url}">${url}</a>
<p>If you didn't create an account, you can safely ignore this email.</p>
`,
})
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 <onboarding@simstudio.ai>',
to: data.email,
subject: 'Verify your email',
html: `
<h2>Welcome to Sim Studio!</h2>
<p>Your verification code is:</p>
<h1 style="font-size: 32px; letter-spacing: 2px; text-align: center; padding: 16px; background-color: #f8f9fa; border-radius: 4px;">${data.otp}</h1>
<p>This code will expire in 15 minutes.</p>
<p>If you didn't create an account, you can safely ignore this email.</p>
`,
})
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: [
{

221
package-lock.json generated
View File

@@ -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"
}
}
}
}

View File

@@ -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",

View File

@@ -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',
},
},
},