improvement(auth): updated email template for auth, general login improvements with pw validation and better error handling

This commit is contained in:
Waleed Latif
2025-03-22 15:44:02 -07:00
parent 6c669438e6
commit 0f94d83b27
6 changed files with 303 additions and 83 deletions
+93 -39
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Card,
@@ -35,8 +36,10 @@ export default function LoginPage({
}) {
const router = useRouter()
const [isLoading, setIsLoading] = useState(false)
const [mounted, setMounted] = useState(false)
const [, setMounted] = useState(false)
const { addNotification } = useNotificationStore()
const [showPassword, setShowPassword] = useState(false)
const [password, setPassword] = useState('')
// Forgot password states
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
@@ -57,53 +60,91 @@ export default function LoginPage({
const formData = new FormData(e.currentTarget)
const email = formData.get('email') as string
const password = formData.get('password') as string
try {
const result = await client.signIn.email({
email,
password,
callbackURL: '/w',
})
const result = await client.signIn.email(
{
email,
password,
callbackURL: '/w',
},
{
onError: (ctx) => {
console.error('Login error:', ctx.error)
let errorMessage = 'Invalid email or password'
// Handle all possible error cases from Better Auth
if (ctx.error.message?.includes('EMAIL_NOT_VERIFIED')) {
return
} else if (
ctx.error.message?.includes('BAD_REQUEST') ||
ctx.error.message?.includes('Email and password sign in is not enabled')
) {
errorMessage = 'Email sign in is currently disabled.'
} else if (
ctx.error.message?.includes('INVALID_CREDENTIALS') ||
ctx.error.message?.includes('invalid password')
) {
errorMessage = 'Invalid email or password. Please try again.'
} else if (
ctx.error.message?.includes('USER_NOT_FOUND') ||
ctx.error.message?.includes('not found')
) {
errorMessage = 'No account found with this email. Please sign up first.'
} else if (ctx.error.message?.includes('MISSING_CREDENTIALS')) {
errorMessage = 'Please enter both email and password.'
} else if (ctx.error.message?.includes('EMAIL_PASSWORD_DISABLED')) {
errorMessage = 'Email and password login is disabled.'
} else if (ctx.error.message?.includes('FAILED_TO_CREATE_SESSION')) {
errorMessage = 'Failed to create session. Please try again later.'
} else if (ctx.error.message?.includes('too many attempts')) {
errorMessage =
'Too many login attempts. Please try again later or reset your password.'
} else if (ctx.error.message?.includes('account locked')) {
errorMessage =
'Your account has been locked for security. Please reset your password.'
} else if (ctx.error.message?.includes('network')) {
errorMessage = 'Network error. Please check your connection and try again.'
} else if (ctx.error.message?.includes('rate limit')) {
errorMessage = 'Too many requests. Please wait a moment before trying again.'
}
addNotification('error', errorMessage, null)
},
}
)
if (!result || result.error) {
throw new Error(result?.error?.message || 'Authentication failed')
setIsLoading(false)
return
}
} catch (err: any) {
let errorMessage = 'Invalid email or password'
if (err.message?.includes('not verified')) {
// Redirect to verification page directly without asking for confirmation
// Handle only the special verification case that requires a redirect
if (err.message?.includes('not verified') || err.message?.includes('EMAIL_NOT_VERIFIED')) {
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)}`)
if (typeof window !== 'undefined') {
sessionStorage.setItem('verificationEmail', email)
}
router.push(`/verify`)
return
} catch (verifyErr) {
errorMessage = 'Failed to send verification code. Please try again later.'
addNotification(
'error',
'Failed to send verification code. Please try again later.',
null
)
setIsLoading(false)
return
}
} 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')) {
errorMessage = 'Invalid password. Please try again or use the forgot password link.'
} else if (err.message?.includes('too many attempts')) {
errorMessage = 'Too many login attempts. Please try again later or reset your password.'
} else if (err.message?.includes('account locked')) {
errorMessage = 'Your account has been locked for security. Please reset your password.'
} else if (err.message?.includes('network')) {
errorMessage = 'Network error. Please check your connection and try again.'
} else if (err.message?.includes('rate limit')) {
errorMessage = 'Too many requests. Please wait a moment before trying again.'
}
addNotification('error', errorMessage, null)
// Prevent navigation on error
return
console.error('Uncaught login error:', err)
} finally {
setIsLoading(false)
}
@@ -160,7 +201,8 @@ export default function LoginPage({
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50">
{mounted && <NotificationList />}
{/* Ensure NotificationList is always rendered */}
<NotificationList />
<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">
@@ -211,13 +253,25 @@ export default function LoginPage({
Forgot password?
</button>
</div>
<Input
id="password"
name="password"
type="password"
placeholder="Enter your password"
required
/>
<div className="relative">
<Input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
placeholder="Enter your password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
type="button"
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign in'}
+152 -27
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Card,
@@ -19,6 +20,23 @@ import { useNotificationStore } from '@/stores/notifications/store'
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
import { NotificationList } from '@/app/w/[id]/components/notifications/notifications'
const PASSWORD_VALIDATIONS = {
minLength: { regex: /.{8,}/, message: 'Password must be at least 8 characters long.' },
uppercase: {
regex: /(?=.*?[A-Z])/,
message: 'Password must include at least one uppercase letter.',
},
lowercase: {
regex: /(?=.*?[a-z])/,
message: 'Password must include at least one lowercase letter.',
},
number: { regex: /(?=.*?[0-9])/, message: 'Password must include at least one number.' },
special: {
regex: /(?=.*?[#?!@$%^&*-])/,
message: 'Password must include at least one special character.',
},
}
export default function SignupPage({
githubAvailable,
googleAvailable,
@@ -30,54 +48,133 @@ export default function SignupPage({
}) {
const router = useRouter()
const [isLoading, setIsLoading] = useState(false)
const [mounted, setMounted] = useState(false)
const [, setMounted] = useState(false)
const { addNotification } = useNotificationStore()
const [showPassword, setShowPassword] = useState(false)
const [password, setPassword] = useState('')
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
const [showValidationError, setShowValidationError] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
// Validate password and return array of error messages
const validatePassword = (passwordValue: string): string[] => {
const errors: string[] = []
// Check each validation criteria
if (!PASSWORD_VALIDATIONS.minLength.regex.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.minLength.message)
}
if (!PASSWORD_VALIDATIONS.uppercase.regex.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.uppercase.message)
}
if (!PASSWORD_VALIDATIONS.lowercase.regex.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.lowercase.message)
}
if (!PASSWORD_VALIDATIONS.number.regex.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.number.message)
}
if (!PASSWORD_VALIDATIONS.special.regex.test(passwordValue)) {
errors.push(PASSWORD_VALIDATIONS.special.message)
}
return errors
}
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newPassword = e.target.value
setPassword(newPassword)
// Silently validate but don't show errors
validatePassword(newPassword)
}
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsLoading(true)
const formData = new FormData(e.currentTarget)
const email = formData.get('email') as string
const password = formData.get('password') as string
const passwordValue = formData.get('password') as string
const name = formData.get('name') as string
// Validate password on submit
const errors = validatePassword(passwordValue)
setPasswordErrors(errors)
// Only show validation errors if there are any
setShowValidationError(errors.length > 0)
try {
// Validate password length before attempting signup
if (password.length < 8) {
addNotification('error', 'Password must be at least 8 characters long', null)
if (errors.length > 0) {
// Show first error as notification
addNotification('error', errors[0], null)
setIsLoading(false)
return
}
await client.signUp.email({ email, password, name })
const response = await client.signUp.email(
{
email,
password: passwordValue,
name,
},
{
onError: (ctx) => {
console.error('Signup error:', ctx.error)
let errorMessage = 'Failed to create account'
// Pass fromSignup=true to indicate we're coming from signup
router.push(`/verify?email=${encodeURIComponent(email)}&fromSignup=true`)
} catch (err: any) {
let errorMessage = 'Failed to create account'
// Handle all possible error cases from Better Auth
if (ctx.error.status === 422 || ctx.error.message?.includes('already exists')) {
errorMessage = 'An account with this email already exists. Please sign in instead.'
} else if (
ctx.error.message?.includes('BAD_REQUEST') ||
ctx.error.message?.includes('Email and password sign up is not enabled')
) {
errorMessage = 'Email signup is currently disabled.'
} else if (ctx.error.message?.includes('INVALID_EMAIL')) {
errorMessage = 'Please enter a valid email address.'
} else if (ctx.error.message?.includes('PASSWORD_TOO_SHORT')) {
errorMessage = 'Password must be at least 8 characters long.'
} else if (ctx.error.message?.includes('PASSWORD_TOO_LONG')) {
errorMessage = 'Password must be less than 128 characters.'
} else if (ctx.error.message?.includes('USER_ALREADY_EXISTS')) {
errorMessage = 'An account with this email already exists. Please sign in instead.'
} else if (ctx.error.message?.includes('FAILED_TO_CREATE_USER')) {
errorMessage = 'Failed to create account. Please try again later.'
} else if (ctx.error.message?.includes('FAILED_TO_CREATE_SESSION')) {
errorMessage = 'Failed to create session. Please try again later.'
} else if (ctx.error.message?.includes('rate limit')) {
errorMessage = 'Too many signup attempts. Please try again later.'
} else if (ctx.error.message?.includes('network')) {
errorMessage = 'Network error. Please check your connection and try again.'
} else if (ctx.error.message?.includes('invalid name')) {
errorMessage = 'Please enter a valid name.'
}
if (err.message?.includes('Password is too short')) {
errorMessage = 'Password must be at least 8 characters long'
} else if (err.message?.includes('existing email')) {
errorMessage = 'An account with this email already exists. Please sign in instead.'
} else if (err.message?.includes('invalid email')) {
errorMessage = 'Please enter a valid email address'
} else if (err.message?.includes('password too long')) {
errorMessage = 'Password must be less than 128 characters'
} else if (err.message?.includes('rate limit')) {
errorMessage = 'Too many signup attempts. Please try again later.'
} else if (err.message?.includes('network')) {
errorMessage = 'Network error. Please check your connection and try again.'
} else if (err.message?.includes('invalid name')) {
errorMessage = 'Please enter a valid name'
addNotification('error', errorMessage, null)
},
}
)
if (!response || response.error) {
setIsLoading(false)
return
}
addNotification('error', errorMessage, null)
if (typeof window !== 'undefined') {
sessionStorage.setItem('verificationEmail', email)
}
router.push(`/verify?fromSignup=true`)
} catch (err: any) {
console.error('Uncaught signup error:', err)
} finally {
setIsLoading(false)
}
@@ -85,7 +182,8 @@ export default function SignupPage({
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50">
{mounted && <NotificationList />}
{/* Ensure NotificationList is always rendered */}
<NotificationList />
<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">
@@ -127,7 +225,34 @@ export default function SignupPage({
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input id="password" name="password" type="password" required />
<div className="relative">
<Input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
required
value={password}
onChange={handlePasswordChange}
/>
<button
type="button"
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
{showValidationError && passwordErrors.length > 0 && (
<div className="text-sm text-red-500 mt-1">
<p>Password must:</p>
<ul className="list-disc pl-5 mt-1">
{passwordErrors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Creating account...' : 'Create account'}
+12 -4
View File
@@ -48,11 +48,13 @@ export function useVerification({
logger.info('Notification store state:', { addNotification: !!addNotification })
}, [addNotification])
// Get email from URL query param
useEffect(() => {
const emailParam = searchParams.get('email')
if (emailParam) {
setEmail(decodeURIComponent(emailParam))
if (typeof window !== 'undefined') {
const storedEmail = sessionStorage.getItem('verificationEmail')
if (storedEmail) {
setEmail(storedEmail)
return
}
}
}, [searchParams])
@@ -98,6 +100,12 @@ export function useVerification({
// Check if verification was successful
if (response && !response.error) {
setIsVerified(true)
// Clear email from sessionStorage after successful verification
if (typeof window !== 'undefined') {
sessionStorage.removeItem('verificationEmail')
}
// Redirect to dashboard after a short delay
setTimeout(() => router.push('/w'), 2000)
} else {
+32 -8
View File
@@ -1,6 +1,6 @@
'use client'
import { Suspense } from 'react'
import { Suspense, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Card,
@@ -40,6 +40,24 @@ function VerificationForm({
handleOtpChange,
} = useVerification({ hasResendKey, isProduction })
const [countdown, setCountdown] = useState(0)
const [isResendDisabled, setIsResendDisabled] = useState(false)
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
return () => clearTimeout(timer)
} else if (countdown === 0 && isResendDisabled) {
setIsResendDisabled(false)
}
}, [countdown, isResendDisabled])
const handleResend = () => {
resendCode()
setIsResendDisabled(true)
setCountdown(30)
}
return (
<>
<CardHeader>
@@ -111,13 +129,19 @@ function VerificationForm({
<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>
{countdown > 0 ? (
<span className="text-muted-foreground">
Resend in <span className="font-medium text-primary">{countdown}s</span>
</span>
) : (
<button
className="text-primary hover:underline font-medium"
onClick={handleResend}
disabled={isLoading || isResendDisabled}
>
Resend
</button>
)}
</p>
</CardFooter>
)}
+8 -3
View File
@@ -12,8 +12,9 @@ import {
Section,
Text,
} from '@react-email/components'
import { XIcon } from 'lucide-react'
import { DiscordIcon } from '@/components/icons'
import { baseStyles } from './base-styles'
import { DiscordIcon, XIcon } from './email-icons'
interface OTPVerificationEmailProps {
otp: string
@@ -47,13 +48,17 @@ export const OTPVerificationEmail = ({
<Body style={baseStyles.main}>
<Preview>{getSubjectByType(type)}</Preview>
<Container style={baseStyles.container}>
<Section style={baseStyles.header}>
<Section style={{ ...baseStyles.header, textAlign: 'center' }}>
<Img
src={`${baseUrl}/sim.png`}
width="120"
height="40"
alt="Sim Studio"
style={{ display: 'block', objectFit: 'contain' }}
style={{
display: 'block',
objectFit: 'contain',
margin: '0 auto',
}}
/>
</Section>
<Section style={baseStyles.sectionsBorders}>
+6 -2
View File
@@ -39,13 +39,17 @@ export const ResetPasswordEmail = ({
<Body style={baseStyles.main}>
<Preview>Reset your Sim Studio password</Preview>
<Container style={baseStyles.container}>
<Section style={baseStyles.header}>
<Section style={{ ...baseStyles.header, textAlign: 'center' }}>
<Img
src={`${baseUrl}/sim.png`}
width="120"
height="40"
alt="Sim Studio"
style={{ display: 'block', objectFit: 'contain' }}
style={{
display: 'block',
objectFit: 'contain',
margin: '0 auto',
}}
/>
</Section>
<Section style={baseStyles.sectionsBorders}>