[READ-ONLY] One Calendar is a privacy-first calendar web app built with Next.js. It has modern security features, including e2ee, password-protected sharing, and self-destructing share links 馃搮
calendar.xyehr.cn
nextjs
6.9 kB
198 lines
1'use client'
2
3import { Turnstile } from '@marsidev/react-turnstile'
4import { useSearchParams } from 'next/navigation'
5import { useState } from 'react'
6import type React from 'react'
7
8import { Button } from '@/components/ui/button'
9import {
10 Card,
11 CardContent,
12 CardDescription,
13 CardHeader,
14 CardTitle,
15} from '@/components/ui/card'
16import { Input } from '@/components/ui/input'
17import { Label } from '@/components/ui/label'
18import { authClient } from '@/lib/auth/client'
19import { cn } from '@/lib/utils'
20
21export function ResetPasswordForm({
22 className,
23 ...props
24}: React.ComponentPropsWithoutRef<'div'>) {
25 const searchParams = useSearchParams()
26 const token = searchParams.get('token')
27 const [email, setEmail] = useState('')
28 const [password, setPassword] = useState('')
29 const [confirmPassword, setConfirmPassword] = useState('')
30 const [isLoading, setIsLoading] = useState(false)
31 const [error, setError] = useState('')
32 const [done, setDone] = useState(false)
33 const [notice, setNotice] = useState('')
34 const [isCaptchaCompleted, setIsCaptchaCompleted] = useState(
35 process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? false : true,
36 )
37
38 const handleSubmit = async (e: React.FormEvent) => {
39 e.preventDefault()
40 if (process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY && !isCaptchaCompleted)
41 return setError('Please complete the CAPTCHA verification.')
42 setIsLoading(true)
43 setError('')
44 setNotice('')
45 const res = await authClient.requestPasswordReset({
46 email,
47 redirectTo: '/reset-password',
48 } as never)
49 if (!res.error) {
50 setDone(true)
51 setNotice('Reset email sent. Please check your inbox.')
52 setIsLoading(false)
53 return
54 }
55 const fallbackBody = JSON.stringify({
56 email,
57 redirectTo: '/reset-password',
58 })
59 const fallbackEndpoints = [
60 '/api/auth/forget-password',
61 '/api/auth/forgot-password',
62 '/api/auth/request-password-reset',
63 ]
64 let fallbackSucceeded = false
65 for (const endpoint of fallbackEndpoints) {
66 try {
67 const fallback = await fetch(endpoint, {
68 method: 'POST',
69 headers: { 'content-type': 'application/json' },
70 body: fallbackBody,
71 })
72 if (fallback.ok) {
73 fallbackSucceeded = true
74 break
75 }
76 } catch {}
77 }
78 if (fallbackSucceeded) {
79 setDone(true)
80 setNotice('Reset email sent. Please check your inbox.')
81 } else setError(res.error.message || 'An error occurred. Please try again.')
82 setIsLoading(false)
83 }
84
85 const handleResetPassword = async (e: React.FormEvent) => {
86 e.preventDefault()
87 if (process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY && !isCaptchaCompleted)
88 return setError('Please complete the CAPTCHA verification.')
89 if (password !== confirmPassword) return setError('Passwords do not match.')
90 setIsLoading(true)
91 setError('')
92 const { error } = await authClient.resetPassword({
93 newPassword: password,
94 token,
95 } as never)
96 if (error)
97 setError(error.message || 'Failed to reset password. Please try again.')
98 else setDone(true)
99 setIsLoading(false)
100 }
101
102 const isTokenFlow = Boolean(token)
103
104 return (
105 <div className={cn('flex flex-col gap-6', className)} {...props}>
106 <Card>
107 <CardHeader className="text-center">
108 <CardTitle className="text-xl">Reset your password</CardTitle>
109 <CardDescription>
110 {isTokenFlow
111 ? done
112 ? 'Password updated successfully. You can sign in now.'
113 : 'Enter your new password to complete reset.'
114 : done
115 ? 'Reset email sent. Please check your inbox.'
116 : "Enter your email and we'll send a reset link"}
117 </CardDescription>
118 </CardHeader>
119 <CardContent>
120 <form onSubmit={isTokenFlow ? handleResetPassword : handleSubmit}>
121 <div className="grid gap-6">
122 {isTokenFlow ? (
123 <>
124 <div className="grid gap-2">
125 <Label htmlFor="password">New password</Label>
126 <Input
127 id="password"
128 type="password"
129 required
130 value={password}
131 onChange={(e) => setPassword(e.target.value)}
132 />
133 </div>
134 <div className="grid gap-2">
135 <Label htmlFor="confirmPassword">Confirm password</Label>
136 <Input
137 id="confirmPassword"
138 type="password"
139 required
140 value={confirmPassword}
141 onChange={(e) => setConfirmPassword(e.target.value)}
142 />
143 </div>
144 </>
145 ) : (
146 <div className="grid gap-2">
147 <Label htmlFor="email">Email</Label>
148 <Input
149 id="email"
150 type="email"
151 required
152 value={email}
153 onChange={(e) => setEmail(e.target.value)}
154 />
155 </div>
156 )}
157 {process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY && (
158 <Turnstile
159 siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY}
160 options={{ size: 'flexible' }}
161 onSuccess={() => setIsCaptchaCompleted(true)}
162 onExpire={() => setIsCaptchaCompleted(false)}
163 onError={() => {
164 setIsCaptchaCompleted(false)
165 setError('CAPTCHA initialization failed. Please try again.')
166 }}
167 />
168 )}
169 {!isTokenFlow && notice ? (
170 <div className="text-sm text-emerald-600">{notice}</div>
171 ) : null}
172 {error && <div className="text-sm text-red-500">{error}</div>}
173 <Button
174 type="submit"
175 className="w-full bg-[#0066ff] text-white hover:bg-[#0047cc]"
176 disabled={isLoading || !isCaptchaCompleted}
177 >
178 {isLoading
179 ? isTokenFlow
180 ? 'Updating...'
181 : 'Sending...'
182 : isTokenFlow
183 ? 'Update password'
184 : 'Send reset email'}
185 </Button>
186 </div>
187 </form>
188 </CardContent>
189 </Card>
190
191 <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 [&_a]:hover:text-primary">
192 By clicking continue, you agree to our{' '}
193 <a href="/terms">Terms of Service</a> and{' '}
194 <a href="/privacy">Privacy Policy</a>.
195 </div>
196 </div>
197 )
198}