mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improve UX/DX of auth flow (#127)
* fix: docker setup * improve UX/DX of auth flow * refactor(auth): clean up verification page logic
This commit is contained in:
@@ -6,6 +6,9 @@ WORKDIR /app
|
||||
# Copy the entire sim directory
|
||||
COPY sim/ ./
|
||||
|
||||
# Create the .env file if it doesn't exist
|
||||
RUN touch .env
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
|
||||
@@ -17,6 +17,14 @@ services:
|
||||
- POSTGRES_URL=postgresql://postgres:postgres@db:5432/simstudio
|
||||
- BETTER_AUTH_URL=http://localhost:3000
|
||||
- NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
- BETTER_AUTH_SECRET=your_auth_secret_here
|
||||
- ENCRYPTION_KEY=your_encryption_key_here
|
||||
- GOOGLE_CLIENT_ID=placeholder
|
||||
- GOOGLE_CLIENT_SECRET=placeholder
|
||||
- GITHUB_CLIENT_ID=placeholder
|
||||
- GITHUB_CLIENT_SECRET=placeholder
|
||||
- RESEND_API_KEY=placeholder
|
||||
- WEBCONTAINER_CLIENT_ID=placeholder
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
'use server'
|
||||
|
||||
export async function getOAuthProviderStatus() {
|
||||
const githubAvailable = !!(
|
||||
process.env.GITHUB_CLIENT_ID &&
|
||||
process.env.GITHUB_CLIENT_SECRET &&
|
||||
process.env.GITHUB_CLIENT_ID !== 'placeholder' &&
|
||||
process.env.GITHUB_CLIENT_SECRET !== 'placeholder'
|
||||
)
|
||||
|
||||
const googleAvailable = !!(
|
||||
process.env.GOOGLE_CLIENT_ID &&
|
||||
process.env.GOOGLE_CLIENT_SECRET &&
|
||||
process.env.GOOGLE_CLIENT_ID !== 'placeholder' &&
|
||||
process.env.GOOGLE_CLIENT_SECRET !== 'placeholder'
|
||||
)
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production'
|
||||
|
||||
return { githubAvailable, googleAvailable, isProduction }
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { GithubIcon, GoogleIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
|
||||
interface SocialLoginButtonsProps {
|
||||
githubAvailable: boolean
|
||||
googleAvailable: boolean
|
||||
callbackURL?: string
|
||||
isProduction: boolean
|
||||
}
|
||||
|
||||
export function SocialLoginButtons({
|
||||
githubAvailable,
|
||||
googleAvailable,
|
||||
callbackURL = '/w',
|
||||
isProduction,
|
||||
}: SocialLoginButtonsProps) {
|
||||
const [isGithubLoading, setIsGithubLoading] = useState(false)
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false)
|
||||
const { addNotification } = useNotificationStore()
|
||||
|
||||
async function signInWithGithub() {
|
||||
if (!githubAvailable) return
|
||||
|
||||
setIsGithubLoading(true)
|
||||
try {
|
||||
await client.signIn.social({ provider: 'github', callbackURL })
|
||||
} catch (err: any) {
|
||||
let errorMessage = 'Failed to sign in with GitHub'
|
||||
|
||||
if (err.message?.includes('account exists')) {
|
||||
errorMessage = 'An account with this email already exists. Please sign in instead.'
|
||||
} else if (err.message?.includes('cancelled')) {
|
||||
errorMessage = 'GitHub sign in was cancelled. Please try again.'
|
||||
} 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 attempts. Please try again later.'
|
||||
}
|
||||
|
||||
addNotification('error', errorMessage, null)
|
||||
} finally {
|
||||
setIsGithubLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function signInWithGoogle() {
|
||||
if (!googleAvailable) return
|
||||
|
||||
setIsGoogleLoading(true)
|
||||
try {
|
||||
await client.signIn.social({ provider: 'google', callbackURL })
|
||||
} catch (err: any) {
|
||||
let errorMessage = 'Failed to sign in with Google'
|
||||
|
||||
if (err.message?.includes('account exists')) {
|
||||
errorMessage = 'An account with this email already exists. Please sign in instead.'
|
||||
} else if (err.message?.includes('cancelled')) {
|
||||
errorMessage = 'Google sign in was cancelled. Please try again.'
|
||||
} 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 attempts. Please try again later.'
|
||||
}
|
||||
|
||||
addNotification('error', errorMessage, null)
|
||||
} finally {
|
||||
setIsGoogleLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const githubButton = (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={!githubAvailable || isGithubLoading}
|
||||
onClick={signInWithGithub}
|
||||
>
|
||||
<GithubIcon className="mr-2 h-4 w-4" />
|
||||
{isGithubLoading ? 'Connecting...' : 'Continue with GitHub'}
|
||||
</Button>
|
||||
)
|
||||
|
||||
const googleButton = (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={!googleAvailable || isGoogleLoading}
|
||||
onClick={signInWithGoogle}
|
||||
>
|
||||
<GoogleIcon className="mr-2 h-4 w-4" />
|
||||
{isGoogleLoading ? 'Connecting...' : 'Continue with Google'}
|
||||
</Button>
|
||||
)
|
||||
|
||||
// Early return for production mode
|
||||
if (isProduction) return null
|
||||
|
||||
const renderGithubButton = () => {
|
||||
if (githubAvailable) return githubButton
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{githubButton}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
GitHub login requires OAuth credentials to be configured. Add the following
|
||||
environment variables:
|
||||
</p>
|
||||
<ul className="mt-2 text-xs space-y-1">
|
||||
<li>• GITHUB_CLIENT_ID</li>
|
||||
<li>• GITHUB_CLIENT_SECRET</li>
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const renderGoogleButton = () => {
|
||||
if (googleAvailable) return googleButton
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>{googleButton}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Google login requires OAuth credentials to be configured. Add the following
|
||||
environment variables:
|
||||
</p>
|
||||
<ul className="mt-2 text-xs space-y-1">
|
||||
<li>• GOOGLE_CLIENT_ID</li>
|
||||
<li>• GOOGLE_CLIENT_SECRET</li>
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{renderGithubButton()}
|
||||
{renderGoogleButton()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||
import { NotificationList } from '@/app/w/[id]/components/notifications/notifications'
|
||||
|
||||
export default function LoginPage({
|
||||
githubAvailable,
|
||||
googleAvailable,
|
||||
isProduction,
|
||||
}: {
|
||||
githubAvailable: boolean
|
||||
googleAvailable: boolean
|
||||
isProduction: boolean
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { addNotification } = useNotificationStore()
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
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
|
||||
|
||||
try {
|
||||
const result = await client.signIn.email({
|
||||
email,
|
||||
password,
|
||||
callbackURL: '/w',
|
||||
})
|
||||
|
||||
if (!result || result.error) {
|
||||
throw new Error(result?.error?.message || 'Authentication failed')
|
||||
}
|
||||
} catch (err: any) {
|
||||
let errorMessage = 'Invalid email or password'
|
||||
|
||||
if (err.message?.includes('not verified')) {
|
||||
// Redirect to verification page directly without asking for confirmation
|
||||
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')) {
|
||||
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
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50">
|
||||
{mounted && <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">
|
||||
<CardHeader>
|
||||
<CardTitle>Welcome back</CardTitle>
|
||||
<CardDescription>Enter your credentials to access your account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-6">
|
||||
<SocialLoginButtons
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
callbackURL="/w"
|
||||
isProduction={isProduction}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="name@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p className="text-sm text-gray-500 text-center w-full">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/signup" className="text-primary hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,194 +1,14 @@
|
||||
'use client'
|
||||
import { getOAuthProviderStatus } from '../components/oauth-provider-checker'
|
||||
import LoginForm from './login-form'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { GithubIcon, GoogleIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import { NotificationList } from '@/app/w/[id]/components/notifications/notifications'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { addNotification } = useNotificationStore()
|
||||
|
||||
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
|
||||
|
||||
try {
|
||||
const result = await client.signIn.email({
|
||||
email,
|
||||
password,
|
||||
callbackURL: '/w',
|
||||
})
|
||||
|
||||
if (!result || result.error) {
|
||||
throw new Error(result?.error?.message || 'Authentication failed')
|
||||
}
|
||||
} catch (err: any) {
|
||||
let errorMessage = 'Invalid email or password'
|
||||
|
||||
if (err.message?.includes('not verified')) {
|
||||
// Redirect to verification page directly without asking for confirmation
|
||||
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')) {
|
||||
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
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function signInWithGithub() {
|
||||
try {
|
||||
await client.signIn.social({ provider: 'github', callbackURL: '/w' })
|
||||
} catch (err: any) {
|
||||
let errorMessage = 'Failed to sign in with GitHub'
|
||||
|
||||
if (err.message?.includes('account exists')) {
|
||||
errorMessage =
|
||||
'An account with this email already exists. Please sign in with email instead.'
|
||||
} else if (err.message?.includes('cancelled')) {
|
||||
errorMessage = 'GitHub sign in was cancelled. Please try again.'
|
||||
} else if (err.message?.includes('network')) {
|
||||
errorMessage = 'Network error. Please check your connection and try again.'
|
||||
}
|
||||
|
||||
addNotification('error', errorMessage, null)
|
||||
}
|
||||
}
|
||||
|
||||
async function signInWithGoogle() {
|
||||
try {
|
||||
await client.signIn.social({ provider: 'google', callbackURL: '/w' })
|
||||
} catch (err: any) {
|
||||
let errorMessage = 'Failed to sign in with Google'
|
||||
|
||||
if (err.message?.includes('account exists')) {
|
||||
errorMessage =
|
||||
'An account with this email already exists. Please sign in with email instead.'
|
||||
} else if (err.message?.includes('cancelled')) {
|
||||
errorMessage = 'Google sign in was cancelled. Please try again.'
|
||||
} else if (err.message?.includes('network')) {
|
||||
errorMessage = 'Network error. Please check your connection and try again.'
|
||||
}
|
||||
|
||||
addNotification('error', errorMessage, null)
|
||||
}
|
||||
}
|
||||
export default async function LoginPage() {
|
||||
const { githubAvailable, googleAvailable, isProduction } = await getOAuthProviderStatus()
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50">
|
||||
<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">
|
||||
<CardHeader>
|
||||
<CardTitle>Welcome back</CardTitle>
|
||||
<CardDescription>Enter your credentials to access your account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Button variant="outline" onClick={signInWithGithub} className="w-full">
|
||||
<GithubIcon className="mr-2 h-4 w-4" />
|
||||
Continue with GitHub
|
||||
</Button>
|
||||
<Button variant="outline" onClick={signInWithGoogle} className="w-full">
|
||||
<GoogleIcon className="mr-2 h-4 w-4" />
|
||||
Continue with Google
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="name@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p className="text-sm text-gray-500 text-center w-full">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/signup" className="text-primary hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
<LoginForm
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
isProduction={isProduction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,175 +1,14 @@
|
||||
'use client'
|
||||
import { getOAuthProviderStatus } from '../components/oauth-provider-checker'
|
||||
import SignupForm from './signup-form'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { GithubIcon, GoogleIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import { NotificationList } from '@/app/w/[id]/components/notifications/notifications'
|
||||
|
||||
export default function SignupPage() {
|
||||
const router = useRouter()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { addNotification } = useNotificationStore()
|
||||
|
||||
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 name = formData.get('name') as string
|
||||
|
||||
try {
|
||||
await client.signUp.email({ email, password, name })
|
||||
|
||||
// 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'
|
||||
|
||||
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)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function signUpWithGithub() {
|
||||
try {
|
||||
await client.signIn.social({ provider: 'github', callbackURL: '/w' })
|
||||
} catch (err: any) {
|
||||
let errorMessage = 'Failed to sign up with GitHub'
|
||||
|
||||
if (err.message?.includes('account exists')) {
|
||||
errorMessage = 'An account with this email already exists. Please sign in instead.'
|
||||
} else if (err.message?.includes('cancelled')) {
|
||||
errorMessage = 'GitHub sign up was cancelled. Please try again.'
|
||||
} 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 attempts. Please try again later.'
|
||||
}
|
||||
|
||||
addNotification('error', errorMessage, null)
|
||||
}
|
||||
}
|
||||
|
||||
async function signUpWithGoogle() {
|
||||
try {
|
||||
await client.signIn.social({ provider: 'google', callbackURL: '/w' })
|
||||
} catch (err: any) {
|
||||
let errorMessage = 'Failed to sign up with Google'
|
||||
|
||||
if (err.message?.includes('account exists')) {
|
||||
errorMessage = 'An account with this email already exists. Please sign in instead.'
|
||||
} else if (err.message?.includes('cancelled')) {
|
||||
errorMessage = 'Google sign up was cancelled. Please try again.'
|
||||
} 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 attempts. Please try again later.'
|
||||
}
|
||||
|
||||
addNotification('error', errorMessage, null)
|
||||
}
|
||||
}
|
||||
export default async function SignupPage() {
|
||||
const { githubAvailable, googleAvailable, isProduction } = await getOAuthProviderStatus()
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50">
|
||||
<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">
|
||||
<CardHeader>
|
||||
<CardTitle>Create an account</CardTitle>
|
||||
<CardDescription>Enter your details to get started</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Button variant="outline" onClick={signUpWithGithub} className="w-full">
|
||||
<GithubIcon className="mr-2 h-4 w-4" />
|
||||
Continue with GitHub
|
||||
</Button>
|
||||
<Button variant="outline" onClick={signUpWithGoogle} className="w-full">
|
||||
<GoogleIcon className="mr-2 h-4 w-4" />
|
||||
Continue with Google
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" name="name" type="text" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="name@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" name="password" type="password" required />
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Creating account...' : 'Create account'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p className="text-sm text-gray-500 text-center w-full">
|
||||
Already have an account?{' '}
|
||||
<Link href="/login" className="text-primary hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
<SignupForm
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
isProduction={isProduction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||
import { NotificationList } from '@/app/w/[id]/components/notifications/notifications'
|
||||
|
||||
export default function SignupPage({
|
||||
githubAvailable,
|
||||
googleAvailable,
|
||||
isProduction,
|
||||
}: {
|
||||
githubAvailable: boolean
|
||||
googleAvailable: boolean
|
||||
isProduction: boolean
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { addNotification } = useNotificationStore()
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
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 name = formData.get('name') as string
|
||||
|
||||
try {
|
||||
// Validate password length before attempting signup
|
||||
if (password.length < 8) {
|
||||
addNotification('error', 'Password must be at least 8 characters long', null)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
await client.signUp.email({ email, password, name })
|
||||
|
||||
// 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'
|
||||
|
||||
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)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50">
|
||||
{mounted && <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">
|
||||
<CardHeader>
|
||||
<CardTitle>Create an account</CardTitle>
|
||||
<CardDescription>Enter your details to get started</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-6">
|
||||
<SocialLoginButtons
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
callbackURL="/w"
|
||||
isProduction={isProduction}
|
||||
/>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input id="name" name="name" type="text" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="name@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input id="password" name="password" type="password" required />
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? 'Creating account...' : 'Create account'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p className="text-sm text-gray-500 text-center w-full">
|
||||
Already have an account?{' '}
|
||||
<Link href="/login" className="text-primary hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
+16
-191
@@ -1,200 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
|
||||
const logger = createLogger('VerifyPage')
|
||||
|
||||
// Extract the content into a separate component
|
||||
function VerifyContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
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 emailParam = searchParams.get('email')
|
||||
if (emailParam) {
|
||||
setEmail(decodeURIComponent(emailParam))
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
// Send initial OTP code if this is the first load
|
||||
useEffect(() => {
|
||||
if (email && !isSendingInitialOtp) {
|
||||
setIsSendingInitialOtp(true)
|
||||
|
||||
// Only send verification OTP if we're coming from login page
|
||||
// Skip this if coming from signup since the OTP is already sent
|
||||
if (!searchParams.get('fromSignup')) {
|
||||
client.emailOtp
|
||||
.sendVerificationOtp({
|
||||
email,
|
||||
type: 'email-verification',
|
||||
})
|
||||
.then(() => {})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to send initial verification code:', error)
|
||||
addNotification?.(
|
||||
'error',
|
||||
'Failed to send verification code. Please use the resend button.',
|
||||
null
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [email, isSendingInitialOtp, addNotification, searchParams])
|
||||
|
||||
// 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,
|
||||
})
|
||||
|
||||
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>{isVerified ? 'Email Verified!' : 'Verify your email'}</CardTitle>
|
||||
<CardDescription>
|
||||
{isVerified
|
||||
? 'Your email has been verified. Redirecting to dashboard...'
|
||||
: `A verification code has been sent to ${email || 'your email'}`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{!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>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
)
|
||||
}
|
||||
import { VerifyContent } from './verify-content'
|
||||
|
||||
export default function VerifyPage() {
|
||||
const protocol = process.env.NODE_ENV === 'development' ? 'http' : 'https'
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'localhost:3000'
|
||||
const baseUrl = `${protocol}://${appUrl}`
|
||||
|
||||
const hasResendKey = Boolean(
|
||||
process.env.RESEND_API_KEY &&
|
||||
process.env.RESEND_API_KEY !== 'placeholder'
|
||||
)
|
||||
const isProduction = process.env.NODE_ENV === 'production'
|
||||
|
||||
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>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<VerifyContent />
|
||||
</Suspense>
|
||||
<VerifyContent
|
||||
hasResendKey={hasResendKey}
|
||||
baseUrl={baseUrl}
|
||||
isProduction={isProduction}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
|
||||
const logger = createLogger('useVerification')
|
||||
|
||||
interface UseVerificationParams {
|
||||
hasResendKey: boolean
|
||||
isProduction: boolean
|
||||
}
|
||||
|
||||
interface UseVerificationReturn {
|
||||
otp: string
|
||||
email: string
|
||||
isLoading: boolean
|
||||
isVerified: boolean
|
||||
isInvalidOtp: boolean
|
||||
errorMessage: string
|
||||
isOtpComplete: boolean
|
||||
hasResendKey: boolean
|
||||
isProduction: boolean
|
||||
verifyCode: () => Promise<void>
|
||||
resendCode: () => void
|
||||
handleOtpChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function useVerification({ hasResendKey, isProduction }: UseVerificationParams): UseVerificationReturn {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
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)
|
||||
const [isInvalidOtp, setIsInvalidOtp] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
|
||||
// Debug notification store
|
||||
useEffect(() => {
|
||||
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))
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
// Send initial OTP code if this is the first load
|
||||
useEffect(() => {
|
||||
if (email && !isSendingInitialOtp && hasResendKey) {
|
||||
setIsSendingInitialOtp(true)
|
||||
|
||||
// Only send verification OTP if we're coming from login page
|
||||
// Skip this if coming from signup since the OTP is already sent
|
||||
if (!searchParams.get('fromSignup')) {
|
||||
client.emailOtp
|
||||
.sendVerificationOtp({
|
||||
email,
|
||||
type: 'email-verification',
|
||||
})
|
||||
.then(() => {})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to send initial verification code:', error)
|
||||
setErrorMessage('Failed to send verification code. Please use the resend button.')
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [email, isSendingInitialOtp, searchParams, hasResendKey])
|
||||
|
||||
// Enable the verify button when all 6 digits are entered
|
||||
const isOtpComplete = otp.length === 6
|
||||
|
||||
async function verifyCode() {
|
||||
if (!isOtpComplete || !email) return
|
||||
|
||||
setIsLoading(true)
|
||||
setIsInvalidOtp(false)
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
// Call the verification API with the OTP code
|
||||
const response = await client.emailOtp.verifyEmail({
|
||||
email,
|
||||
otp,
|
||||
})
|
||||
|
||||
// Check if verification was successful
|
||||
if (response && !response.error) {
|
||||
setIsVerified(true)
|
||||
// Redirect to dashboard after a short delay
|
||||
setTimeout(() => router.push('/w'), 2000)
|
||||
} else {
|
||||
logger.info('Setting invalid OTP state - API error response')
|
||||
const message = 'Invalid verification code. Please check and try again.'
|
||||
// Set both state variables to ensure the error shows
|
||||
setIsInvalidOtp(true)
|
||||
setErrorMessage(message)
|
||||
logger.info('Error state after API error:', { isInvalidOtp: true, errorMessage: message })
|
||||
// Clear the OTP input on invalid code
|
||||
setOtp('')
|
||||
}
|
||||
} catch (error: any) {
|
||||
let message = 'Verification failed. Please check your code and try again.'
|
||||
|
||||
if (error.message?.includes('expired')) {
|
||||
message = 'The verification code has expired. Please request a new one.'
|
||||
} else if (error.message?.includes('invalid')) {
|
||||
logger.info('Setting invalid OTP state - caught error')
|
||||
message = 'Invalid verification code. Please check and try again.'
|
||||
} else if (error.message?.includes('attempts')) {
|
||||
message = 'Too many failed attempts. Please request a new code.'
|
||||
}
|
||||
|
||||
// Set both state variables to ensure the error shows
|
||||
setIsInvalidOtp(true)
|
||||
setErrorMessage(message)
|
||||
logger.info('Error state after caught error:', { isInvalidOtp: true, errorMessage: message })
|
||||
|
||||
// Clear the OTP input on error
|
||||
setOtp('')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function resendCode() {
|
||||
if (!email || !hasResendKey) return
|
||||
|
||||
setIsLoading(true)
|
||||
setErrorMessage('')
|
||||
|
||||
client.emailOtp
|
||||
.sendVerificationOtp({
|
||||
email,
|
||||
type: 'email-verification',
|
||||
})
|
||||
.then(() => {})
|
||||
.catch(() => {
|
||||
setErrorMessage('Failed to resend verification code. Please try again later.')
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
function handleOtpChange(value: string) {
|
||||
// Only clear error when user is actively typing a new code
|
||||
if (value.length === 6) {
|
||||
setIsInvalidOtp(false)
|
||||
setErrorMessage('')
|
||||
}
|
||||
setOtp(value)
|
||||
}
|
||||
|
||||
return {
|
||||
otp,
|
||||
email,
|
||||
isLoading,
|
||||
isVerified,
|
||||
isInvalidOtp,
|
||||
errorMessage,
|
||||
isOtpComplete,
|
||||
hasResendKey,
|
||||
isProduction,
|
||||
verifyCode,
|
||||
resendCode,
|
||||
handleOtpChange
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useVerification } from './use-verification'
|
||||
|
||||
interface VerifyContentProps {
|
||||
hasResendKey: boolean
|
||||
baseUrl: string
|
||||
isProduction: boolean
|
||||
}
|
||||
|
||||
function VerificationForm({ hasResendKey, isProduction }: { hasResendKey: boolean, isProduction: boolean }) {
|
||||
const {
|
||||
otp,
|
||||
email,
|
||||
isLoading,
|
||||
isVerified,
|
||||
isInvalidOtp,
|
||||
errorMessage,
|
||||
isOtpComplete,
|
||||
verifyCode,
|
||||
resendCode,
|
||||
handleOtpChange
|
||||
} = useVerification({ hasResendKey, isProduction })
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle>{isVerified ? 'Email Verified!' : 'Verify your email'}</CardTitle>
|
||||
<CardDescription>
|
||||
{isVerified ? (
|
||||
'Your email has been verified. Redirecting to dashboard...'
|
||||
) : hasResendKey ? (
|
||||
<p>A verification code has been sent to {email || 'your email'}</p>
|
||||
) : !isProduction ? (
|
||||
<div className="space-y-1">
|
||||
<p>Development mode: No Resend API key configured</p>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Check your console logs for the verification code
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p>Error: Invalid API key configuration</p>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{/* Add debug output for error state */}
|
||||
<div className="hidden">
|
||||
Debug - isInvalidOtp: {String(isInvalidOtp)}, errorMessage: {errorMessage || 'none'}
|
||||
</div>
|
||||
|
||||
{!isVerified && (
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Enter the 6-digit code to verify your account.
|
||||
{hasResendKey ? " If you don't see it in your email, check your spam folder." : ''}
|
||||
</p>
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="flex justify-center py-4">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={otp}
|
||||
onChange={handleOtpChange}
|
||||
disabled={isLoading}
|
||||
className={cn(isInvalidOtp && 'border-red-500 focus-visible:ring-red-500')}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message - moved above the button for better visibility */}
|
||||
{errorMessage && (
|
||||
<div className="mt-2 mb-2 text-center border border-red-200 rounded-md py-2 bg-red-50">
|
||||
<p className="text-sm font-semibold text-red-600">{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={verifyCode} className="w-full" disabled={!isOtpComplete || isLoading}>
|
||||
{isLoading ? 'Verifying...' : 'Verify Email'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{!isVerified && hasResendKey && (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback component while the verification form is loading
|
||||
function VerificationFormFallback() {
|
||||
return (
|
||||
<CardHeader>
|
||||
<CardTitle>Loading verification...</CardTitle>
|
||||
<CardDescription>
|
||||
Please wait while we load your verification details...
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
)
|
||||
}
|
||||
|
||||
export function VerifyContent({ hasResendKey, baseUrl, isProduction }: VerifyContentProps) {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<Suspense fallback={<VerificationFormFallback />}>
|
||||
<VerificationForm hasResendKey={hasResendKey} isProduction={isProduction} />
|
||||
</Suspense>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Resend } from 'resend'
|
||||
import { z } from 'zod'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null
|
||||
const logger = createLogger('HelpAPI')
|
||||
|
||||
// Define schema for validation
|
||||
@@ -18,6 +18,18 @@ export async function POST(req: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
// Check if Resend API key is configured
|
||||
if (!resend) {
|
||||
logger.error(`[${requestId}] RESEND_API_KEY not configured`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Email service not configured. Please set RESEND_API_KEY in environment variables.',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Handle multipart form data
|
||||
const formData = await req.formData()
|
||||
|
||||
@@ -132,6 +144,15 @@ The Sim Studio Team
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error) {
|
||||
// Check if error is related to missing API key
|
||||
if (error instanceof Error && error.message.includes('API key')) {
|
||||
logger.error(`[${requestId}] API key configuration error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Email service configuration error. Please check your RESEND_API_KEY.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error processing help request`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
|
||||
+16
-11
@@ -12,17 +12,22 @@ const logger = createLogger('Auth')
|
||||
|
||||
// If there is no resend key, it might be a local dev environment
|
||||
// In that case, we don't want to send emails and just log them
|
||||
const resend =
|
||||
process.env.RESEND_API_KEY && process.env.RESEND_API_KEY.trim() !== ''
|
||||
? new Resend(process.env.RESEND_API_KEY)
|
||||
: {
|
||||
emails: {
|
||||
send: async (...args: any[]) => {
|
||||
logger.info('Email would have been sent in production. Details:', args)
|
||||
return { id: 'local-dev-mode' }
|
||||
},
|
||||
|
||||
const validResendAPIKEY =
|
||||
process.env.RESEND_API_KEY &&
|
||||
process.env.RESEND_API_KEY.trim() !== '' &&
|
||||
process.env.RESEND_API_KEY !== 'placeholder'
|
||||
|
||||
const resend = validResendAPIKEY
|
||||
? new Resend(process.env.RESEND_API_KEY)
|
||||
: {
|
||||
emails: {
|
||||
send: async (...args: any[]) => {
|
||||
logger.info('Email would have been sent in production. Details:', args)
|
||||
return { id: 'local-dev-mode' }
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
@@ -95,7 +100,7 @@ export const auth = betterAuth({
|
||||
}
|
||||
|
||||
// In development with no RESEND_API_KEY, log verification code
|
||||
if (!process.env.RESEND_API_KEY || process.env.RESEND_API_KEY.trim() === '') {
|
||||
if (!validResendAPIKEY) {
|
||||
logger.info('🔑 VERIFICATION CODE FOR LOGIN/SIGNUP', {
|
||||
email: data.email,
|
||||
otp: data.otp,
|
||||
|
||||
Generated
+2
@@ -5759,6 +5759,8 @@
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
|
||||
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
|
||||
Reference in New Issue
Block a user