mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -05:00
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:
18
.github/CONTRIBUTING.md
vendored
18
.github/CONTRIBUTING.md
vendored
@@ -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
|
||||
|
||||
185
sim/app/(auth)/components/reset-password-form.tsx
Normal file
185
sim/app/(auth)/components/reset-password-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
123
sim/app/(auth)/reset-password/page.tsx
Normal file
123
sim/app/(auth)/reset-password/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
sim/app/api/auth/forget-password/route.ts
Normal file
38
sim/app/api/auth/forget-password/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
38
sim/app/api/auth/reset-password/route.ts
Normal file
38
sim/app/api/auth/reset-password/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
85
sim/app/emails/base-styles.ts
Normal file
85
sim/app/emails/base-styles.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
21
sim/app/emails/email-icons.tsx
Normal file
21
sim/app/emails/email-icons.tsx
Normal 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>
|
||||
)
|
||||
119
sim/app/emails/otp-verification-email.tsx
Normal file
119
sim/app/emails/otp-verification-email.tsx
Normal 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
|
||||
44
sim/app/emails/render-email.ts
Normal file
44
sim/app/emails/render-email.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
112
sim/app/emails/reset-password-email.tsx
Normal file
112
sim/app/emails/reset-password-email.tsx
Normal 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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
1786
sim/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user