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:
Faizan Ahmed
2025-03-19 14:36:14 -07:00
committed by GitHub
parent 5e11c75cf0
commit 3d71370e39
14 changed files with 899 additions and 562 deletions
+3
View File
@@ -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
+8
View File
@@ -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>
)
}
+165
View File
@@ -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>
)
}
+9 -189
View File
@@ -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}
/>
)
}
+9 -170
View File
@@ -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}
/>
)
}
+151
View File
@@ -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
View File
@@ -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>
)
+177
View File
@@ -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
}
}
+142
View File
@@ -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>
)
}
+22 -1
View File
@@ -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
View File
@@ -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,
+2
View File
@@ -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": {