Skip to content

Commit bca29b4

Browse files
authored
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
1 parent 15534c8 commit bca29b4

File tree

15 files changed

+2892
-135
lines changed

15 files changed

+2892
-135
lines changed

.github/CONTRIBUTING.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,24 @@ If you prefer not to use Docker or Dev Containers:
239239
240240
6. **Make Your Changes and Test Locally.**
241241
242+
### Email Template Development
243+
244+
When working on email templates, you can preview them using a local email preview server:
245+
246+
1. **Run the Email Preview Server:**
247+
```bash
248+
npm run email:dev
249+
```
250+
251+
2. **Access the Preview:**
252+
- Open `http://localhost:3000` in your browser
253+
- You'll see a list of all email templates
254+
- Click on any template to view and test it with various parameters
255+
256+
3. **Templates Location:**
257+
- Email templates are located in `sim/app/emails/`
258+
- After making changes to templates, they will automatically update in the preview
259+
242260
---
243261
244262
## License
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { Loader2 } from 'lucide-react'
5+
import { Button } from '@/components/ui/button'
6+
import { Input } from '@/components/ui/input'
7+
import { Label } from '@/components/ui/label'
8+
import { cn } from '@/lib/utils'
9+
10+
interface RequestResetFormProps {
11+
email: string
12+
onEmailChange: (email: string) => void
13+
onSubmit: (email: string) => Promise<void>
14+
isSubmitting: boolean
15+
statusType: 'success' | 'error' | null
16+
statusMessage: string
17+
className?: string
18+
}
19+
20+
export function RequestResetForm({
21+
email,
22+
onEmailChange,
23+
onSubmit,
24+
isSubmitting,
25+
statusType,
26+
statusMessage,
27+
className,
28+
}: RequestResetFormProps) {
29+
const handleSubmit = async (e: React.FormEvent) => {
30+
e.preventDefault()
31+
onSubmit(email)
32+
}
33+
34+
return (
35+
<form onSubmit={handleSubmit} className={className}>
36+
<div className="grid gap-4">
37+
<div className="grid gap-2">
38+
<Label htmlFor="reset-email">Email</Label>
39+
<Input
40+
id="reset-email"
41+
value={email}
42+
onChange={(e) => onEmailChange(e.target.value)}
43+
placeholder="your@email.com"
44+
type="email"
45+
disabled={isSubmitting}
46+
required
47+
/>
48+
<p className="text-sm text-muted-foreground">
49+
We'll send a password reset link to this email address.
50+
</p>
51+
</div>
52+
53+
{/* Status message display */}
54+
{statusType && (
55+
<div
56+
className={cn(
57+
'p-3 rounded-md text-sm border',
58+
statusType === 'success'
59+
? 'bg-green-50 text-green-700 border-green-200'
60+
: 'bg-red-50 text-red-700 border-red-200'
61+
)}
62+
>
63+
{statusMessage}
64+
</div>
65+
)}
66+
67+
<Button type="submit" disabled={isSubmitting} className="w-full">
68+
{isSubmitting ? (
69+
<>
70+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
71+
Sending...
72+
</>
73+
) : (
74+
'Send Reset Link'
75+
)}
76+
</Button>
77+
</div>
78+
</form>
79+
)
80+
}
81+
82+
interface SetNewPasswordFormProps {
83+
token: string | null
84+
onSubmit: (password: string) => Promise<void>
85+
isSubmitting: boolean
86+
statusType: 'success' | 'error' | null
87+
statusMessage: string
88+
className?: string
89+
}
90+
91+
export function SetNewPasswordForm({
92+
token,
93+
onSubmit,
94+
isSubmitting,
95+
statusType,
96+
statusMessage,
97+
className,
98+
}: SetNewPasswordFormProps) {
99+
const [password, setPassword] = useState('')
100+
const [confirmPassword, setConfirmPassword] = useState('')
101+
const [validationMessage, setValidationMessage] = useState('')
102+
103+
const handleSubmit = async (e: React.FormEvent) => {
104+
e.preventDefault()
105+
106+
// Simple validation
107+
if (password.length < 8) {
108+
setValidationMessage('Password must be at least 8 characters long')
109+
return
110+
}
111+
112+
if (password !== confirmPassword) {
113+
setValidationMessage('Passwords do not match')
114+
return
115+
}
116+
117+
setValidationMessage('')
118+
onSubmit(password)
119+
}
120+
121+
return (
122+
<form onSubmit={handleSubmit} className={className}>
123+
<div className="grid gap-4">
124+
<div className="grid gap-2">
125+
<Label htmlFor="password">New Password</Label>
126+
<Input
127+
id="password"
128+
type="password"
129+
autoCapitalize="none"
130+
autoComplete="new-password"
131+
autoCorrect="off"
132+
disabled={isSubmitting || !token}
133+
value={password}
134+
onChange={(e) => setPassword(e.target.value)}
135+
required
136+
/>
137+
</div>
138+
<div className="grid gap-2">
139+
<Label htmlFor="confirmPassword">Confirm Password</Label>
140+
<Input
141+
id="confirmPassword"
142+
type="password"
143+
autoCapitalize="none"
144+
autoComplete="new-password"
145+
autoCorrect="off"
146+
disabled={isSubmitting || !token}
147+
value={confirmPassword}
148+
onChange={(e) => setConfirmPassword(e.target.value)}
149+
required
150+
/>
151+
</div>
152+
153+
{validationMessage && (
154+
<div className="p-3 rounded-md text-sm border bg-red-50 text-red-700 border-red-200">
155+
{validationMessage}
156+
</div>
157+
)}
158+
159+
{statusType && (
160+
<div
161+
className={cn(
162+
'p-3 rounded-md text-sm border',
163+
statusType === 'success'
164+
? 'bg-green-50 text-green-700 border-green-200'
165+
: 'bg-red-50 text-red-700 border-red-200'
166+
)}
167+
>
168+
{statusMessage}
169+
</div>
170+
)}
171+
172+
<Button disabled={isSubmitting || !token} type="submit" className="w-full">
173+
{isSubmitting ? (
174+
<>
175+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
176+
Resetting...
177+
</>
178+
) : (
179+
'Reset Password'
180+
)}
181+
</Button>
182+
</div>
183+
</form>
184+
)
185+
}

sim/app/(auth)/login/login-form.tsx

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,18 @@ import {
1212
CardHeader,
1313
CardTitle,
1414
} from '@/components/ui/card'
15+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
1516
import { Input } from '@/components/ui/input'
1617
import { Label } from '@/components/ui/label'
1718
import { client } from '@/lib/auth-client'
19+
import { createLogger } from '@/lib/logs/console-logger'
1820
import { useNotificationStore } from '@/stores/notifications/store'
21+
import { RequestResetForm } from '@/app/(auth)/components/reset-password-form'
1922
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
2023
import { NotificationList } from '@/app/w/[id]/components/notifications/notifications'
2124

25+
const logger = createLogger('LoginForm')
26+
2227
export default function LoginPage({
2328
githubAvailable,
2429
googleAvailable,
@@ -33,6 +38,15 @@ export default function LoginPage({
3338
const [mounted, setMounted] = useState(false)
3439
const { addNotification } = useNotificationStore()
3540

41+
// Forgot password states
42+
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
43+
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
44+
const [isSubmittingReset, setIsSubmittingReset] = useState(false)
45+
const [resetStatus, setResetStatus] = useState<{
46+
type: 'success' | 'error' | null
47+
message: string
48+
}>({ type: null, message: '' })
49+
3650
useEffect(() => {
3751
setMounted(true)
3852
}, [])
@@ -95,6 +109,55 @@ export default function LoginPage({
95109
}
96110
}
97111

112+
const handleForgotPassword = async () => {
113+
if (!forgotPasswordEmail) {
114+
setResetStatus({
115+
type: 'error',
116+
message: 'Please enter your email address',
117+
})
118+
return
119+
}
120+
121+
try {
122+
setIsSubmittingReset(true)
123+
setResetStatus({ type: null, message: '' })
124+
125+
const response = await fetch('/api/auth/forget-password', {
126+
method: 'POST',
127+
headers: {
128+
'Content-Type': 'application/json',
129+
},
130+
body: JSON.stringify({
131+
email: forgotPasswordEmail,
132+
redirectTo: `${window.location.origin}/reset-password`,
133+
}),
134+
})
135+
136+
if (!response.ok) {
137+
const errorData = await response.json()
138+
throw new Error(errorData.message || 'Failed to request password reset')
139+
}
140+
141+
setResetStatus({
142+
type: 'success',
143+
message: 'Password reset link sent to your email',
144+
})
145+
146+
setTimeout(() => {
147+
setForgotPasswordOpen(false)
148+
setResetStatus({ type: null, message: '' })
149+
}, 2000)
150+
} catch (error) {
151+
logger.error('Error requesting password reset:', { error })
152+
setResetStatus({
153+
type: 'error',
154+
message: error instanceof Error ? error.message : 'Failed to request password reset',
155+
})
156+
} finally {
157+
setIsSubmittingReset(false)
158+
}
159+
}
160+
98161
return (
99162
<main className="flex min-h-screen flex-col items-center justify-center bg-gray-50">
100163
{mounted && <NotificationList />}
@@ -134,7 +197,20 @@ export default function LoginPage({
134197
/>
135198
</div>
136199
<div className="space-y-2">
137-
<Label htmlFor="password">Password</Label>
200+
<div className="flex items-center justify-between">
201+
<Label htmlFor="password">Password</Label>
202+
<button
203+
type="button"
204+
onClick={() => {
205+
const emailInput = document.getElementById('email') as HTMLInputElement
206+
setForgotPasswordEmail(emailInput?.value || '')
207+
setForgotPasswordOpen(true)
208+
}}
209+
className="text-xs text-primary hover:underline"
210+
>
211+
Forgot password?
212+
</button>
213+
</div>
138214
<Input
139215
id="password"
140216
name="password"
@@ -160,6 +236,24 @@ export default function LoginPage({
160236
</CardFooter>
161237
</Card>
162238
</div>
239+
240+
{/* Forgot Password Dialog */}
241+
<Dialog open={forgotPasswordOpen} onOpenChange={setForgotPasswordOpen}>
242+
<DialogContent className="sm:max-w-[425px]">
243+
<DialogHeader>
244+
<DialogTitle>Reset Password</DialogTitle>
245+
</DialogHeader>
246+
<RequestResetForm
247+
email={forgotPasswordEmail}
248+
onEmailChange={setForgotPasswordEmail}
249+
onSubmit={handleForgotPassword}
250+
isSubmitting={isSubmittingReset}
251+
statusType={resetStatus.type}
252+
statusMessage={resetStatus.message}
253+
className="py-4"
254+
/>
255+
</DialogContent>
256+
</Dialog>
163257
</main>
164258
)
165259
}

0 commit comments

Comments
 (0)