feat[password]: added forgot password, reset password, and email templates (#144)

* added forgot password & reset password

* added react-email, added templates and styles that can be reused for all company emails

* docs: updated CONTRIBUTING.md

* consolidated icons into an email-icons file

* fix build issue by wrapping reset password page in suspense boundary since we use useSearchParams
This commit is contained in:
Waleed Latif
2025-03-22 12:55:01 -07:00
committed by GitHub
parent 15534c8079
commit bca29b4ef2
15 changed files with 2778 additions and 21 deletions

View File

@@ -239,6 +239,24 @@ If you prefer not to use Docker or Dev Containers:
6. **Make Your Changes and Test Locally.**
### Email Template Development
When working on email templates, you can preview them using a local email preview server:
1. **Run the Email Preview Server:**
```bash
npm run email:dev
```
2. **Access the Preview:**
- Open `http://localhost:3000` in your browser
- You'll see a list of all email templates
- Click on any template to view and test it with various parameters
3. **Templates Location:**
- Email templates are located in `sim/app/emails/`
- After making changes to templates, they will automatically update in the preview
---
## License

View File

@@ -0,0 +1,185 @@
'use client'
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
interface RequestResetFormProps {
email: string
onEmailChange: (email: string) => void
onSubmit: (email: string) => Promise<void>
isSubmitting: boolean
statusType: 'success' | 'error' | null
statusMessage: string
className?: string
}
export function RequestResetForm({
email,
onEmailChange,
onSubmit,
isSubmitting,
statusType,
statusMessage,
className,
}: RequestResetFormProps) {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
onSubmit(email)
}
return (
<form onSubmit={handleSubmit} className={className}>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="reset-email">Email</Label>
<Input
id="reset-email"
value={email}
onChange={(e) => onEmailChange(e.target.value)}
placeholder="your@email.com"
type="email"
disabled={isSubmitting}
required
/>
<p className="text-sm text-muted-foreground">
We'll send a password reset link to this email address.
</p>
</div>
{/* Status message display */}
{statusType && (
<div
className={cn(
'p-3 rounded-md text-sm border',
statusType === 'success'
? 'bg-green-50 text-green-700 border-green-200'
: 'bg-red-50 text-red-700 border-red-200'
)}
>
{statusMessage}
</div>
)}
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
'Send Reset Link'
)}
</Button>
</div>
</form>
)
}
interface SetNewPasswordFormProps {
token: string | null
onSubmit: (password: string) => Promise<void>
isSubmitting: boolean
statusType: 'success' | 'error' | null
statusMessage: string
className?: string
}
export function SetNewPasswordForm({
token,
onSubmit,
isSubmitting,
statusType,
statusMessage,
className,
}: SetNewPasswordFormProps) {
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [validationMessage, setValidationMessage] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// Simple validation
if (password.length < 8) {
setValidationMessage('Password must be at least 8 characters long')
return
}
if (password !== confirmPassword) {
setValidationMessage('Passwords do not match')
return
}
setValidationMessage('')
onSubmit(password)
}
return (
<form onSubmit={handleSubmit} className={className}>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="password">New Password</Label>
<Input
id="password"
type="password"
autoCapitalize="none"
autoComplete="new-password"
autoCorrect="off"
disabled={isSubmitting || !token}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
autoCapitalize="none"
autoComplete="new-password"
autoCorrect="off"
disabled={isSubmitting || !token}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</div>
{validationMessage && (
<div className="p-3 rounded-md text-sm border bg-red-50 text-red-700 border-red-200">
{validationMessage}
</div>
)}
{statusType && (
<div
className={cn(
'p-3 rounded-md text-sm border',
statusType === 'success'
? 'bg-green-50 text-green-700 border-green-200'
: 'bg-red-50 text-red-700 border-red-200'
)}
>
{statusMessage}
</div>
)}
<Button disabled={isSubmitting || !token} type="submit" className="w-full">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Resetting...
</>
) : (
'Reset Password'
)}
</Button>
</div>
</form>
)
}

View File

@@ -12,13 +12,18 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { client } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console-logger'
import { useNotificationStore } from '@/stores/notifications/store'
import { RequestResetForm } from '@/app/(auth)/components/reset-password-form'
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
import { NotificationList } from '@/app/w/[id]/components/notifications/notifications'
const logger = createLogger('LoginForm')
export default function LoginPage({
githubAvailable,
googleAvailable,
@@ -33,6 +38,15 @@ export default function LoginPage({
const [mounted, setMounted] = useState(false)
const { addNotification } = useNotificationStore()
// Forgot password states
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
const [isSubmittingReset, setIsSubmittingReset] = useState(false)
const [resetStatus, setResetStatus] = useState<{
type: 'success' | 'error' | null
message: string
}>({ type: null, message: '' })
useEffect(() => {
setMounted(true)
}, [])
@@ -95,6 +109,55 @@ export default function LoginPage({
}
}
const handleForgotPassword = async () => {
if (!forgotPasswordEmail) {
setResetStatus({
type: 'error',
message: 'Please enter your email address',
})
return
}
try {
setIsSubmittingReset(true)
setResetStatus({ type: null, message: '' })
const response = await fetch('/api/auth/forget-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: forgotPasswordEmail,
redirectTo: `${window.location.origin}/reset-password`,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || 'Failed to request password reset')
}
setResetStatus({
type: 'success',
message: 'Password reset link sent to your email',
})
setTimeout(() => {
setForgotPasswordOpen(false)
setResetStatus({ type: null, message: '' })
}, 2000)
} catch (error) {
logger.error('Error requesting password reset:', { error })
setResetStatus({
type: 'error',
message: error instanceof Error ? error.message : 'Failed to request password reset',
})
} finally {
setIsSubmittingReset(false)
}
}
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50">
{mounted && <NotificationList />}
@@ -134,7 +197,20 @@ export default function LoginPage({
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<button
type="button"
onClick={() => {
const emailInput = document.getElementById('email') as HTMLInputElement
setForgotPasswordEmail(emailInput?.value || '')
setForgotPasswordOpen(true)
}}
className="text-xs text-primary hover:underline"
>
Forgot password?
</button>
</div>
<Input
id="password"
name="password"
@@ -160,6 +236,24 @@ export default function LoginPage({
</CardFooter>
</Card>
</div>
{/* Forgot Password Dialog */}
<Dialog open={forgotPasswordOpen} onOpenChange={setForgotPasswordOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Reset Password</DialogTitle>
</DialogHeader>
<RequestResetForm
email={forgotPasswordEmail}
onEmailChange={setForgotPasswordEmail}
onSubmit={handleForgotPassword}
isSubmitting={isSubmittingReset}
statusType={resetStatus.type}
statusMessage={resetStatus.message}
className="py-4"
/>
</DialogContent>
</Dialog>
</main>
)
}

View File

@@ -0,0 +1,123 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { createLogger } from '@/lib/logs/console-logger'
import { SetNewPasswordForm } from '../components/reset-password-form'
const logger = createLogger('ResetPasswordPage')
function ResetPasswordContent() {
const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams.get('token')
const [isSubmitting, setIsSubmitting] = useState(false)
const [statusMessage, setStatusMessage] = useState<{
type: 'success' | 'error' | null
text: string
}>({
type: null,
text: '',
})
// Validate token presence
useEffect(() => {
if (!token) {
setStatusMessage({
type: 'error',
text: 'Invalid or missing reset token. Please request a new password reset link.',
})
}
}, [token])
const handleResetPassword = async (password: string) => {
try {
setIsSubmitting(true)
setStatusMessage({ type: null, text: '' })
const response = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token,
newPassword: password,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || 'Failed to reset password')
}
setStatusMessage({
type: 'success',
text: 'Password reset successful! Redirecting to login...',
})
// Redirect to login page after 1.5 seconds
setTimeout(() => {
router.push('/login?resetSuccess=true')
}, 1500)
} catch (error) {
logger.error('Error resetting password:', { error })
setStatusMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Failed to reset password',
})
} finally {
setIsSubmitting(false)
}
}
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h1 className="text-2xl font-bold text-center mb-8">Sim Studio</h1>
<Card className="w-full">
<CardHeader>
<CardTitle>Reset your password</CardTitle>
<CardDescription>Enter a new password for your account</CardDescription>
</CardHeader>
<CardContent>
<SetNewPasswordForm
token={token}
onSubmit={handleResetPassword}
isSubmitting={isSubmitting}
statusType={statusMessage.type}
statusMessage={statusMessage.text}
/>
</CardContent>
<CardFooter>
<p className="text-sm text-gray-500 text-center w-full">
<Link href="/login" className="text-primary hover:underline">
Back to login
</Link>
</p>
</CardFooter>
</Card>
</div>
</main>
)
}
export default function ResetPasswordPage() {
return (
<Suspense
fallback={<div className="flex min-h-screen items-center justify-center">Loading...</div>}
>
<ResetPasswordContent />
</Suspense>
)
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
const logger = createLogger('ForgetPassword')
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { email, redirectTo } = body
if (!email) {
return NextResponse.json({ message: 'Email is required' }, { status: 400 })
}
await auth.api.forgetPassword({
body: {
email,
redirectTo,
},
method: 'POST',
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error requesting password reset:', { error })
return NextResponse.json(
{
message:
error instanceof Error
? error.message
: 'Failed to send password reset email. Please try again later.',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
const logger = createLogger('PasswordReset')
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { token, newPassword } = body
if (!token || !newPassword) {
return NextResponse.json({ message: 'Token and new password are required' }, { status: 400 })
}
await auth.api.resetPassword({
body: {
newPassword,
token,
},
method: 'POST',
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error during password reset:', { error })
return NextResponse.json(
{
message:
error instanceof Error
? error.message
: 'Failed to reset password. Please try again or request a new reset link.',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,85 @@
// Base styles for all email templates
export const baseStyles = {
fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif',
main: {
backgroundColor: '#f5f5f7',
fontFamily: 'HelveticaNeue, Helvetica, Arial, sans-serif',
},
container: {
maxWidth: '580px',
margin: '30px auto',
backgroundColor: '#ffffff',
borderRadius: '5px',
overflow: 'hidden',
},
header: {
padding: '30px 0',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#ffffff',
},
content: {
padding: '5px 30px 20px 30px',
},
paragraph: {
fontSize: '16px',
lineHeight: '1.5',
color: '#333333',
margin: '16px 0',
},
button: {
display: 'inline-block',
backgroundColor: '#7e22ce',
color: '#ffffff',
fontWeight: 'bold',
fontSize: '16px',
padding: '12px 30px',
borderRadius: '5px',
textDecoration: 'none',
textAlign: 'center' as const,
margin: '20px 0',
},
link: {
color: '#7e22ce',
textDecoration: 'underline',
},
footer: {
maxWidth: '580px',
margin: '0 auto',
padding: '20px 0',
textAlign: 'center' as const,
},
footerText: {
fontSize: '12px',
color: '#666666',
margin: '0',
},
codeContainer: {
margin: '20px 0',
padding: '16px',
backgroundColor: '#f8f9fa',
borderRadius: '5px',
border: '1px solid #eee',
textAlign: 'center' as const,
},
code: {
fontSize: '28px',
fontWeight: 'bold',
letterSpacing: '4px',
color: '#333333',
},
sectionsBorders: {
width: '100%',
display: 'flex',
},
sectionBorder: {
borderBottom: '1px solid #eeeeee',
width: '249px',
},
sectionCenter: {
borderBottom: '1px solid #7e22ce',
width: '102px',
},
}

View File

@@ -0,0 +1,21 @@
import * as React from 'react'
export const XIcon: React.FC<{ color?: string }> = ({ color = '#666666' }) => (
<svg width="24" height="24" viewBox="0 0 16 16" style={{ color }}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.5 0.5H5.75L9.48421 5.71053L14 0.5H16L10.3895 6.97368L16.5 15.5H11.25L7.51579 10.2895L3 15.5H1L6.61053 9.02632L0.5 0.5ZM12.0204 14L3.42043 2H4.97957L13.5796 14H12.0204Z"
fill="currentColor"
/>
</svg>
)
export const DiscordIcon: React.FC<{ color?: string }> = ({ color = '#666666' }) => (
<svg width="24" height="24" viewBox="0 0 16 16" style={{ color }}>
<path
d="M13.5535 3.01557C12.5023 2.5343 11.3925 2.19287 10.2526 2C10.0966 2.27886 9.95547 2.56577 9.82976 2.85952C8.6155 2.67655 7.38067 2.67655 6.16641 2.85952C6.04063 2.5658 5.89949 2.27889 5.74357 2C4.60289 2.1945 3.4924 2.53674 2.44013 3.01809C0.351096 6.10885 -0.215207 9.12285 0.0679444 12.0941C1.29133 12.998 2.66066 13.6854 4.11639 14.1265C4.44417 13.6856 4.73422 13.2179 4.98346 12.7283C4.51007 12.5515 4.05317 12.3334 3.61804 12.0764C3.73256 11.9934 3.84456 11.9078 3.95279 11.8248C5.21891 12.4202 6.60083 12.7289 7.99997 12.7289C9.39912 12.7289 10.781 12.4202 12.0472 11.8248C12.1566 11.9141 12.2686 11.9997 12.3819 12.0764C11.9459 12.3338 11.4882 12.5524 11.014 12.7296C11.2629 13.2189 11.553 13.6862 11.881 14.1265C13.338 13.6872 14.7084 13.0001 15.932 12.0953C16.2642 8.64968 15.3644 5.66336 13.5535 3.01557ZM5.34212 10.2668C4.55307 10.2668 3.90119 9.55073 3.90119 8.66981C3.90119 7.78889 4.53042 7.06654 5.3396 7.06654C6.14879 7.06654 6.79563 7.78889 6.78179 8.66981C6.76795 9.55073 6.14627 10.2668 5.34212 10.2668ZM10.6578 10.2668C9.86752 10.2668 9.21815 9.55073 9.21815 8.66981C9.21815 7.78889 9.84738 7.06654 10.6578 7.06654C11.4683 7.06654 12.1101 7.78889 12.0962 8.66981C12.0824 9.55073 11.462 10.2668 10.6578 10.2668Z"
fill="currentColor"
/>
</svg>
)

View File

@@ -0,0 +1,119 @@
import * as React from 'react'
import {
Body,
Column,
Container,
Head,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { baseStyles } from './base-styles'
import { DiscordIcon, XIcon } from './email-icons'
interface OTPVerificationEmailProps {
otp: string
email?: string
type?: 'sign-in' | 'email-verification' | 'forget-password'
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
const getSubjectByType = (type: string) => {
switch (type) {
case 'sign-in':
return 'Sign in to Sim Studio'
case 'email-verification':
return 'Verify your email for Sim Studio'
case 'forget-password':
return 'Reset your Sim Studio password'
default:
return 'Verification code for Sim Studio'
}
}
export const OTPVerificationEmail = ({
otp,
email = '',
type = 'email-verification',
}: OTPVerificationEmailProps) => {
return (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>{getSubjectByType(type)}</Preview>
<Container style={baseStyles.container}>
<Section style={baseStyles.header}>
<Img
src={`${baseUrl}/sim.png`}
width="120"
height="40"
alt="Sim Studio"
style={{ display: 'block', objectFit: 'contain' }}
/>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>
{type === 'sign-in'
? 'Sign in to'
: type === 'forget-password'
? 'Reset your password for'
: 'Welcome to'}{' '}
Sim Studio!
</Text>
<Text style={baseStyles.paragraph}>Your verification code is:</Text>
<Section style={baseStyles.codeContainer}>
<Text style={baseStyles.code}>{otp}</Text>
</Section>
<Text style={baseStyles.paragraph}>This code will expire in 15 minutes.</Text>
<Text style={baseStyles.paragraph}>
If you didn't request this code, you can safely ignore this email.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Studio Team
</Text>
</Section>
</Container>
<Section style={baseStyles.footer}>
<Row style={{ marginBottom: '10px' }}>
<Column align="center">
<Link
href="https://x.com/simstudioai"
style={{ textDecoration: 'none', margin: '0 8px' }}
>
<XIcon />
</Link>
<Link
href="https://discord.gg/crdsGfGk"
style={{ textDecoration: 'none', margin: '0 8px' }}
>
<DiscordIcon />
</Link>
</Column>
</Row>
<Text style={baseStyles.footerText}>
© {new Date().getFullYear()} Sim Studio, All Rights Reserved
<br />
If you have any questions, please contact us at support@simstudio.ai
</Text>
</Section>
</Body>
</Html>
)
}
export default OTPVerificationEmail

View File

@@ -0,0 +1,44 @@
import { renderAsync } from '@react-email/components'
import { OTPVerificationEmail } from './otp-verification-email'
import { ResetPasswordEmail } from './reset-password-email'
/**
* Renders the OTP verification email to HTML
*/
export async function renderOTPEmail(
otp: string,
email: string,
type: 'sign-in' | 'email-verification' | 'forget-password' = 'email-verification'
): Promise<string> {
return await renderAsync(OTPVerificationEmail({ otp, email, type }))
}
/**
* Renders the password reset email to HTML
*/
export async function renderPasswordResetEmail(
username: string,
resetLink: string
): Promise<string> {
return await renderAsync(ResetPasswordEmail({ username, resetLink, updatedDate: new Date() }))
}
/**
* Gets the appropriate email subject based on email type
*/
export function getEmailSubject(
type: 'sign-in' | 'email-verification' | 'forget-password' | 'reset-password'
): string {
switch (type) {
case 'sign-in':
return 'Sign in to Sim Studio'
case 'email-verification':
return 'Verify your email for Sim Studio'
case 'forget-password':
return 'Reset your Sim Studio password'
case 'reset-password':
return 'Reset your Sim Studio password'
default:
return 'Sim Studio'
}
}

View File

@@ -0,0 +1,112 @@
import * as React from 'react'
import {
Body,
Column,
Container,
Head,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { baseStyles } from './base-styles'
import { DiscordIcon, XIcon } from './email-icons'
interface ResetPasswordEmailProps {
username?: string
resetLink?: string
updatedDate?: Date
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://simstudio.ai'
export const ResetPasswordEmail = ({
username = '',
resetLink = 'https://simstudio.ai/reset-password',
updatedDate = new Date(),
}: ResetPasswordEmailProps) => {
const formattedDate = new Intl.DateTimeFormat('en', {
dateStyle: 'medium',
timeStyle: 'medium',
}).format(updatedDate)
return (
<Html>
<Head />
<Body style={baseStyles.main}>
<Preview>Reset your Sim Studio password</Preview>
<Container style={baseStyles.container}>
<Section style={baseStyles.header}>
<Img
src={`${baseUrl}/sim.png`}
width="120"
height="40"
alt="Sim Studio"
style={{ display: 'block', objectFit: 'contain' }}
/>
</Section>
<Section style={baseStyles.sectionsBorders}>
<Row>
<Column style={baseStyles.sectionBorder} />
<Column style={baseStyles.sectionCenter} />
<Column style={baseStyles.sectionBorder} />
</Row>
</Section>
<Section style={baseStyles.content}>
<Text style={baseStyles.paragraph}>Hello {username},</Text>
<Text style={baseStyles.paragraph}>
We received a request to reset your Sim Studio password. Click the button below to set
a new password:
</Text>
<Section style={{ textAlign: 'center' }}>
<Link style={baseStyles.button} href={resetLink}>
Reset Password
</Link>
</Section>
<Text style={baseStyles.paragraph}>
If you did not request a password reset, please ignore this email or contact support
if you have concerns.
</Text>
<Text style={baseStyles.paragraph}>
For security reasons, this password reset link will expire in 24 hours.
</Text>
<Text style={baseStyles.paragraph}>
Best regards,
<br />
The Sim Studio Team
</Text>
</Section>
</Container>
<Section style={baseStyles.footer}>
<Row style={{ marginBottom: '10px' }}>
<Column align="center">
<Link
href="https://x.com/simstudioai"
style={{ textDecoration: 'none', margin: '0 8px' }}
>
<XIcon />
</Link>
<Link
href="https://discord.gg/crdsGfGk"
style={{ textDecoration: 'none', margin: '0 8px' }}
>
<DiscordIcon />
</Link>
</Column>
</Row>
<Text style={baseStyles.footerText}>
© {new Date().getFullYear()} Sim Studio, All Rights Reserved
<br />
If you have any questions, please contact us at support@simstudio.ai
</Text>
</Section>
</Body>
</Html>
)
}
export default ResetPasswordEmail

View File

@@ -2,8 +2,9 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { ChevronDown, LogOut, Plus, User, UserPlus } from 'lucide-react'
import { ChevronDown, Lock, LogOut, User, UserPlus } from 'lucide-react'
import { AgentIcon } from '@/components/icons'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
@@ -14,6 +15,7 @@ import {
import { signOut, useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { RequestResetForm } from '@/app/(auth)/components/reset-password-form'
import { clearUserData } from '@/stores'
const logger = createLogger('Account')
@@ -50,6 +52,15 @@ export function Account({ onOpenChange }: AccountProps) {
const { data: session, isPending, error } = useSession()
const [isLoadingUserData, setIsLoadingUserData] = useState(false)
// Reset password states
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false)
const [resetPasswordEmail, setResetPasswordEmail] = useState('')
const [isSubmittingResetPassword, setIsSubmittingResetPassword] = useState(false)
const [resetPasswordStatus, setResetPasswordStatus] = useState<{
type: 'success' | 'error' | null
message: string
}>({ type: null, message: '' })
// Mock accounts for the multi-account UI
const [accounts, setAccounts] = useState<AccountData[]>([])
const [open, setOpen] = useState(false)
@@ -73,6 +84,9 @@ export function Account({ onOpenChange }: AccountProps) {
isActive: true,
},
])
// Pre-fill the reset password email with the current user's email
setResetPasswordEmail(session.user.email)
} else if (!isPending) {
// User is not logged in
setUserData({
@@ -118,6 +132,56 @@ export function Account({ onOpenChange }: AccountProps) {
}
}
const handleResetPassword = async () => {
if (!resetPasswordEmail) {
setResetPasswordStatus({
type: 'error',
message: 'Please enter your email address',
})
return
}
try {
setIsSubmittingResetPassword(true)
setResetPasswordStatus({ type: null, message: '' })
const response = await fetch('/api/auth/forget-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: resetPasswordEmail,
redirectTo: `${window.location.origin}/reset-password`,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || 'Failed to request password reset')
}
setResetPasswordStatus({
type: 'success',
message: 'Password reset link sent to your email',
})
// Close dialog after successful submission with a small delay for user to see success message
setTimeout(() => {
setResetPasswordDialogOpen(false)
setResetPasswordStatus({ type: null, message: '' })
}, 2000)
} catch (error) {
logger.error('Error requesting password reset:', { error })
setResetPasswordStatus({
type: 'error',
message: error instanceof Error ? error.message : 'Failed to request password reset',
})
} finally {
setIsSubmittingResetPassword(false)
}
}
const activeAccount = accounts.find((acc) => acc.isActive) || accounts[0]
// Loading animation component
@@ -182,10 +246,10 @@ export function Account({ onOpenChange }: AccountProps) {
)}
</div>
<div className="flex flex-col gap-1 mb-[-2px]">
<h3 className="font-medium leading-none truncate max-w-[160px]">
<h3 className="font-medium leading-none truncate max-w-[200px]">
{userData.isLoggedIn ? activeAccount?.name : 'Sign in'}
</h3>
<p className="text-sm text-muted-foreground truncate max-w-[160px]">
<p className="text-sm text-muted-foreground truncate max-w-[200px]">
{userData.isLoggedIn ? activeAccount?.email : 'Click to sign in'}
</p>
</div>
@@ -200,7 +264,7 @@ export function Account({ onOpenChange }: AccountProps) {
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-[240px] max-h-[350px] overflow-y-auto"
className="w-[280px] max-h-[350px] overflow-y-auto"
sideOffset={8}
>
{userData.isLoggedIn ? (
@@ -230,6 +294,17 @@ export function Account({ onOpenChange }: AccountProps) {
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
className="flex items-center gap-2 pl-3 py-2.5 cursor-pointer"
onClick={() => {
setResetPasswordDialogOpen(true)
setOpen(false)
}}
>
<Lock className="h-4 w-4" />
<span>Reset Password</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex items-center gap-2 pl-3 py-2.5 cursor-pointer text-destructive focus:text-destructive"
onClick={handleSignOut}
@@ -254,6 +329,24 @@ export function Account({ onOpenChange }: AccountProps) {
)}
</div>
</div>
{/* Reset Password Dialog */}
<Dialog open={resetPasswordDialogOpen} onOpenChange={setResetPasswordDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Reset Password</DialogTitle>
</DialogHeader>
<RequestResetForm
email={resetPasswordEmail}
onEmailChange={setResetPasswordEmail}
onSubmit={handleResetPassword}
isSubmitting={isSubmittingResetPassword}
statusType={resetPasswordStatus.type}
statusMessage={resetPasswordStatus.message}
className="py-4"
/>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -5,6 +5,11 @@ import { nextCookies } from 'better-auth/next-js'
import { emailOTP, genericOAuth } from 'better-auth/plugins'
import { Resend } from 'resend'
import { createLogger } from '@/lib/logs/console-logger'
import {
getEmailSubject,
renderOTPEmail,
renderPasswordResetEmail,
} from '@/app/emails/render-email'
import { db } from '@/db'
import * as schema from '@/db/schema'
@@ -72,16 +77,15 @@ export const auth = betterAuth({
throwOnMissingCredentials: true,
throwOnInvalidCredentials: true,
sendResetPassword: async ({ user, url, token }, request) => {
const username = user.name || ''
const html = await renderPasswordResetEmail(username, url)
const result = await resend.emails.send({
from: 'Sim Studio <team@simstudio.ai>',
to: user.email,
subject: 'Reset your password',
html: `
<h2>Reset Your Password</h2>
<p>Click the link below to reset your password:</p>
<a href="${url}">${url}</a>
<p>If you didn't request this, you can safely ignore this email.</p>
`,
subject: getEmailSubject('reset-password'),
html,
})
if (!result) {
@@ -112,18 +116,14 @@ export const auth = betterAuth({
return
}
const html = await renderOTPEmail(data.otp, data.email, data.type)
// In production, send an actual email
const result = await resend.emails.send({
from: 'Sim Studio <onboarding@simstudio.ai>',
to: data.email,
subject: 'Verify your email',
html: `
<h2>Welcome to Sim Studio!</h2>
<p>Your verification code is:</p>
<h1 style="font-size: 32px; letter-spacing: 2px; text-align: center; padding: 16px; background-color: #f8f9fa; border-radius: 4px;">${data.otp}</h1>
<p>This code will expire in 15 minutes.</p>
<p>If you didn't create an account, you can safely ignore this email.</p>
`,
subject: getEmailSubject(data.type),
html,
})
if (!result) {

1786
sim/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"email:dev": "email dev --dir app/emails",
"cli:build": "npm run build -w packages/simstudio",
"cli:dev": "npm run build -w packages/simstudio && cd packages/simstudio && node ./dist/index.js",
"cli:publish": "cd packages/simstudio && npm publish",
@@ -44,6 +45,7 @@
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.6",
"@react-email/components": "^0.0.34",
"@vercel/og": "^0.6.5",
"@webcontainer/api": "^1.5.1-internal.9",
"better-auth": "^1.2.5-beta.5",
@@ -97,6 +99,7 @@
"postcss": "^8",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"react-email": "^3.0.7",
"tailwindcss": "^3.4.1",
"typescript": "^5.7.3",
"vite-tsconfig-paths": "^5.1.4",