Skip to content

Commit 9e11ef9

Browse files
registration and email verification using otp (without smtp)
1 parent 690c9a7 commit 9e11ef9

File tree

7 files changed

+396
-9
lines changed

7 files changed

+396
-9
lines changed
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
'use client'
2+
3+
import { useEffect, useState } from 'react'
4+
import { useRouter } from 'next/navigation'
5+
import { z } from 'zod'
6+
import { useForm } from 'react-hook-form'
7+
import { zodResolver } from '@hookform/resolvers/zod'
8+
import { Loader2 } from 'lucide-react'
9+
10+
import { Button } from '@/components/ui/button'
11+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
12+
import {
13+
Form,
14+
FormControl,
15+
FormField,
16+
FormItem,
17+
FormLabel,
18+
FormMessage,
19+
} from '@/components/ui/form'
20+
import { Input } from '@/components/ui/input'
21+
import authClient from '@/lib/auth-client'
22+
23+
// --- Configuration Constants ---
24+
const COOLDOWN_SECONDS = 60
25+
const COOLDOWN_STORAGE_KEY = 'verification_cooldown_timestamp'
26+
const EMAIL_STORAGE_KEY = 'email_for_verification'
27+
28+
// --- Zod Schema for the Form ---
29+
const formSchema = z.object({
30+
otp: z.string().length(6, { message: 'Your code must be 6 digits.' }),
31+
})
32+
33+
export default function EmailVerificationPage() {
34+
const [loading, setLoading] = useState(false) // For the main OTP submission
35+
const [isSending, setIsSending] = useState(false) // For the "Resend" button
36+
const [cooldown, setCooldown] = useState(0)
37+
const [email, setEmail] = useState('')
38+
const router = useRouter()
39+
40+
const form = useForm<z.infer<typeof formSchema>>({
41+
resolver: zodResolver(formSchema),
42+
defaultValues: { otp: '' },
43+
})
44+
45+
useEffect(() => {
46+
const storedEmail = sessionStorage.getItem(EMAIL_STORAGE_KEY)
47+
if (storedEmail) {
48+
setEmail(storedEmail)
49+
} else {
50+
router.push('/auth/login')
51+
}
52+
}, [])
53+
54+
// --- Cooldown Logic ---
55+
useEffect(() => {
56+
// On initial page load, check if a cooldown is already active in localStorage
57+
const cooldownEndTime = parseInt(localStorage.getItem(COOLDOWN_STORAGE_KEY) || '0')
58+
if (cooldownEndTime > Date.now()) {
59+
const remainingSeconds = Math.ceil((cooldownEndTime - Date.now()) / 1000)
60+
setCooldown(remainingSeconds)
61+
}
62+
63+
// Set up an interval to tick down the cooldown every second
64+
const timer = setInterval(() => {
65+
setCooldown((prev) => (prev > 0 ? prev - 1 : 0))
66+
}, 1000)
67+
68+
// Clean up the interval when the component unmounts
69+
return () => clearInterval(timer)
70+
}, [])
71+
72+
// --- Handlers ---
73+
const handleResend = async () => {
74+
if (cooldown > 0 || isSending) return
75+
setIsSending(true)
76+
77+
// The backend knows who the user is from their secure session cookie.
78+
// We do NOT need to send the email address from the client.
79+
const { error } = await authClient.emailOtp.sendVerificationOtp({
80+
email: email,
81+
type: 'email-verification',
82+
})
83+
84+
if (!error) {
85+
// On success, set the cooldown timestamp in localStorage for persistence
86+
const cooldownEndTime = Date.now() + COOLDOWN_SECONDS * 1000
87+
localStorage.setItem(COOLDOWN_STORAGE_KEY, cooldownEndTime.toString())
88+
setCooldown(COOLDOWN_SECONDS)
89+
}
90+
setIsSending(false)
91+
}
92+
93+
const onSubmit = async (values: z.infer<typeof formSchema>) => {
94+
setLoading(true)
95+
await authClient.emailOtp.verifyEmail(
96+
{ email: email, otp: values.otp },
97+
{
98+
onSuccess: async () => {
99+
localStorage.removeItem(COOLDOWN_STORAGE_KEY)
100+
sessionStorage.removeItem(EMAIL_STORAGE_KEY)
101+
102+
router.push('/feeds')
103+
},
104+
onError: (error) => {
105+
form.setError('otp', { type: 'server', message: error.error.message })
106+
setLoading(false)
107+
},
108+
}
109+
)
110+
}
111+
112+
return (
113+
<div className="flex h-screen w-screen items-center justify-center bg-muted">
114+
<Card className="w-full max-w-md">
115+
<CardHeader className="text-center">
116+
<CardTitle className="text-2xl">Check Your Email</CardTitle>
117+
<CardDescription>
118+
{email ? `We've sent a code to ${email}.` : 'Please wait...'}
119+
</CardDescription>
120+
</CardHeader>
121+
<CardContent>
122+
<Form {...form}>
123+
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
124+
<FormField
125+
control={form.control}
126+
name="otp"
127+
render={({ field }) => (
128+
<FormItem>
129+
<FormLabel className="sr-only">Verification Code</FormLabel>
130+
<FormControl>
131+
<Input
132+
placeholder="6-digit code"
133+
className="text-center text-lg tracking-widest"
134+
{...field}
135+
/>
136+
</FormControl>
137+
<FormMessage />
138+
</FormItem>
139+
)}
140+
/>
141+
<Button type="submit" className="w-full" disabled={loading}>
142+
{loading ? <Loader2 className="animate-spin" /> : 'Verify Account'}
143+
</Button>
144+
</form>
145+
</Form>
146+
147+
<div className="mt-4 text-center text-sm text-muted-foreground">
148+
<span>Didn&apos;t receive the code?</span>
149+
<Button
150+
variant="link"
151+
className="px-1 font-semibold"
152+
disabled={cooldown > 0 || isSending}
153+
onClick={handleResend}
154+
>
155+
{isSending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
156+
{cooldown > 0 ? `Resend in ${cooldown}s` : 'Click to resend'}
157+
</Button>
158+
</div>
159+
</CardContent>
160+
</Card>
161+
</div>
162+
)
163+
}

src/app/auth/login/_components/login-form.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from '@/components/ui/form'
2929
import { cn } from '@/lib/utils'
3030
import authClient from '@/lib/auth-client'
31+
import { useRouter } from 'next/navigation'
3132

3233
const formSchema = z.object({
3334
email: z.string().email({
@@ -41,6 +42,7 @@ const formSchema = z.object({
4142
export function LoginForm() {
4243
const [loading, setLoading] = useState(false)
4344
const [rememberMe, setRememberMe] = useState(false)
45+
const router = useRouter()
4446
// No longer need the serverError state: const [serverError, setServerError] = useState('')
4547

4648
const form = useForm<z.infer<typeof formSchema>>({
@@ -67,15 +69,18 @@ export function LoginForm() {
6769
},
6870
onError: (error) => {
6971
setLoading(false)
72+
73+
if (error.error.code === 'EMAIL_NOT_VERIFIED') {
74+
sessionStorage.setItem('email_for_verification', values.email)
75+
return router.push(`/auth/email-verification?email=${values.email}`)
76+
}
77+
7078
const errorMessage = error.error.message || 'An unexpected error occurred.'
7179

72-
// --- CHANGE IS HERE ---
73-
// Instead of a general alert, set the error message directly on the input fields.
74-
// This makes the UI feel more responsive and directs the user to the problem area.
75-
form.setError('email', { type: 'server' }) // Mark field as invalid
80+
form.setError('email', { type: 'server' })
7681
form.setError('password', {
7782
type: 'server',
78-
message: errorMessage, // Show the server message under the password field
83+
message: errorMessage,
7984
})
8085
},
8186
}

src/app/auth/login/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export default async function LoginPage() {
99
})
1010

1111
if (session?.user) {
12-
redirect('/dashboard')
12+
redirect('/feeds')
1313
}
1414

1515
return (

src/app/auth/register/page.tsx

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { z } from 'zod'
5+
import { useForm } from 'react-hook-form'
6+
import { zodResolver } from '@hookform/resolvers/zod'
7+
import { Loader2 } from 'lucide-react'
8+
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
9+
import { Input } from '@/components/ui/input'
10+
import { Button } from '@/components/ui/button'
11+
import {
12+
Form,
13+
FormControl,
14+
FormField,
15+
FormItem,
16+
FormLabel,
17+
FormMessage,
18+
} from '@/components/ui/form'
19+
import authClient from '@/lib/auth-client'
20+
import { useRouter } from 'next/navigation'
21+
22+
const formSchema = z
23+
.object({
24+
fullName: z.string().min(1, { message: 'Full name is required.' }),
25+
email: z.string().email({
26+
message: 'Please enter a valid email address.',
27+
}),
28+
password: z.string().min(8, {
29+
message: 'Password must be at least 8 characters long.',
30+
}),
31+
confirmPassword: z.string().min(8, {
32+
message: 'Confirm password must be at least 8 characters long.',
33+
}),
34+
})
35+
.refine((data) => data.password === data.confirmPassword, {
36+
message: 'Passwords do not match.',
37+
path: ['confirmPassword'],
38+
})
39+
40+
export default function RegisterPage() {
41+
const [loading, setLoading] = useState(false)
42+
const router = useRouter()
43+
44+
const form = useForm<z.infer<typeof formSchema>>({
45+
resolver: zodResolver(formSchema),
46+
defaultValues: {
47+
fullName: '',
48+
email: '',
49+
password: '',
50+
confirmPassword: '',
51+
},
52+
})
53+
54+
async function onSubmit(values: z.infer<typeof formSchema>) {
55+
setLoading(true)
56+
await authClient.signUp.email(
57+
{
58+
email: values.email,
59+
password: values.password,
60+
name: values.fullName,
61+
},
62+
{
63+
onSuccess: () => {
64+
setLoading(false)
65+
sessionStorage.setItem('email_for_verification', values.email)
66+
router.push('/auth/email-verification')
67+
},
68+
onError: (error) => {
69+
setLoading(false)
70+
form.setError('email', { type: 'server', message: error.error.message })
71+
},
72+
}
73+
)
74+
}
75+
76+
return (
77+
<div className="w-screen h-screen flex items-center justify-center">
78+
<Card className="w-full max-w-md">
79+
<CardHeader>
80+
<CardTitle className="text-lg md:text-xl">Register</CardTitle>
81+
<CardDescription>Enter your information to create an account</CardDescription>
82+
</CardHeader>
83+
<CardContent>
84+
<Form {...form}>
85+
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
86+
<FormField
87+
control={form.control}
88+
name="fullName"
89+
render={({ field }) => (
90+
<FormItem>
91+
<FormLabel>Full name</FormLabel>
92+
<FormControl>
93+
<Input placeholder="John Doe" {...field} />
94+
</FormControl>
95+
<FormMessage />
96+
</FormItem>
97+
)}
98+
/>
99+
<FormField
100+
control={form.control}
101+
name="email"
102+
render={({ field }) => (
103+
<FormItem>
104+
<FormLabel>Email</FormLabel>
105+
<FormControl>
106+
<Input
107+
type="email"
108+
placeholder="[email protected]"
109+
autoComplete="off"
110+
{...field}
111+
/>
112+
</FormControl>
113+
<FormMessage />
114+
</FormItem>
115+
)}
116+
/>
117+
<FormField
118+
control={form.control}
119+
name="password"
120+
render={({ field }) => (
121+
<FormItem>
122+
<FormLabel>Password</FormLabel>
123+
<FormControl>
124+
<Input
125+
type="password"
126+
placeholder="••••••••"
127+
autoComplete="new-password"
128+
{...field}
129+
/>
130+
</FormControl>
131+
<FormMessage />
132+
</FormItem>
133+
)}
134+
/>
135+
<FormField
136+
control={form.control}
137+
name="confirmPassword"
138+
render={({ field }) => (
139+
<FormItem>
140+
<FormLabel>Confirm Password</FormLabel>
141+
<FormControl>
142+
<Input type="password" placeholder="••••••••" {...field} />
143+
</FormControl>
144+
<FormMessage />
145+
</FormItem>
146+
)}
147+
/>
148+
<Button type="submit" className="w-full" disabled={loading}>
149+
{loading ? <Loader2 size={16} className="animate-spin" /> : 'Register'}
150+
</Button>
151+
</form>
152+
</Form>
153+
</CardContent>
154+
</Card>
155+
</div>
156+
)
157+
}

src/lib/auth-client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { inferAdditionalFields } from 'better-auth/client/plugins'
2+
import { emailOTPClient } from 'better-auth/client/plugins'
23
import { createAuthClient } from 'better-auth/react'
34
import type { auth } from './auth'
45

56
const authClient = createAuthClient({
6-
plugins: [inferAdditionalFields<typeof auth>()],
7+
plugins: [inferAdditionalFields<typeof auth>(), emailOTPClient()],
78
})
89
export default authClient

0 commit comments

Comments
 (0)