|
1 | 1 | 'use client' |
2 | 2 |
|
3 | | -import { useEffect, useRef, useState } from 'react' |
| 3 | +import { useMemo, useRef, useState } from 'react' |
| 4 | +import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' |
4 | 5 | import { createLogger } from '@sim/logger' |
5 | 6 | import { Eye, EyeOff } from 'lucide-react' |
6 | 7 | import Link from 'next/link' |
@@ -86,6 +87,9 @@ export default function LoginPage({ |
86 | 87 | const [password, setPassword] = useState('') |
87 | 88 | const [passwordErrors, setPasswordErrors] = useState<string[]>([]) |
88 | 89 | const [showValidationError, setShowValidationError] = useState(false) |
| 90 | + const [formError, setFormError] = useState<string | null>(null) |
| 91 | + const turnstileRef = useRef<TurnstileInstance>(null) |
| 92 | + const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), []) |
89 | 93 | const buttonClass = useBrandedButtonClass() |
90 | 94 |
|
91 | 95 | const callbackUrlParam = searchParams?.get('callbackUrl') |
@@ -115,19 +119,6 @@ export default function LoginPage({ |
115 | 119 | : null |
116 | 120 | ) |
117 | 121 |
|
118 | | - useEffect(() => { |
119 | | - const handleKeyDown = (event: KeyboardEvent) => { |
120 | | - if (event.key === 'Enter' && forgotPasswordOpen) { |
121 | | - handleForgotPassword() |
122 | | - } |
123 | | - } |
124 | | - |
125 | | - window.addEventListener('keydown', handleKeyDown) |
126 | | - return () => { |
127 | | - window.removeEventListener('keydown', handleKeyDown) |
128 | | - } |
129 | | - }, [forgotPasswordEmail, forgotPasswordOpen]) |
130 | | - |
131 | 122 | const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
132 | 123 | const newEmail = e.target.value |
133 | 124 | setEmail(newEmail) |
@@ -178,13 +169,33 @@ export default function LoginPage({ |
178 | 169 | const safeCallbackUrl = callbackUrl |
179 | 170 | let errorHandled = false |
180 | 171 |
|
| 172 | + // Execute Turnstile challenge on submit and get a fresh token |
| 173 | + let token: string | undefined |
| 174 | + if (turnstileSiteKey && turnstileRef.current) { |
| 175 | + try { |
| 176 | + turnstileRef.current.reset() |
| 177 | + turnstileRef.current.execute() |
| 178 | + token = await turnstileRef.current.getResponsePromise(15_000) |
| 179 | + } catch { |
| 180 | + setFormError('Captcha verification failed. Please try again.') |
| 181 | + setIsLoading(false) |
| 182 | + return |
| 183 | + } |
| 184 | + } |
| 185 | + |
| 186 | + setFormError(null) |
181 | 187 | const result = await client.signIn.email( |
182 | 188 | { |
183 | 189 | email, |
184 | 190 | password, |
185 | 191 | callbackURL: safeCallbackUrl, |
186 | 192 | }, |
187 | 193 | { |
| 194 | + fetchOptions: { |
| 195 | + headers: { |
| 196 | + ...(token ? { 'x-captcha-response': token } : {}), |
| 197 | + }, |
| 198 | + }, |
188 | 199 | onError: (ctx) => { |
189 | 200 | logger.error('Login error:', ctx.error) |
190 | 201 |
|
@@ -460,6 +471,20 @@ export default function LoginPage({ |
460 | 471 | </div> |
461 | 472 | </div> |
462 | 473 |
|
| 474 | + {turnstileSiteKey && ( |
| 475 | + <Turnstile |
| 476 | + ref={turnstileRef} |
| 477 | + siteKey={turnstileSiteKey} |
| 478 | + options={{ size: 'invisible', execution: 'execute' }} |
| 479 | + /> |
| 480 | + )} |
| 481 | + |
| 482 | + {formError && ( |
| 483 | + <div className='text-red-400 text-xs'> |
| 484 | + <p>{formError}</p> |
| 485 | + </div> |
| 486 | + )} |
| 487 | + |
463 | 488 | <BrandedButton |
464 | 489 | type='submit' |
465 | 490 | disabled={isLoading} |
@@ -540,45 +565,51 @@ export default function LoginPage({ |
540 | 565 | <ModalContent className='dark' size='sm'> |
541 | 566 | <ModalHeader>Reset Password</ModalHeader> |
542 | 567 | <ModalBody> |
543 | | - <ModalDescription className='mb-4 text-[var(--text-muted)] text-sm'> |
544 | | - Enter your email address and we'll send you a link to reset your password if your |
545 | | - account exists. |
546 | | - </ModalDescription> |
547 | | - <div className='space-y-4'> |
548 | | - <div className='space-y-2'> |
549 | | - <Label htmlFor='reset-email'>Email</Label> |
550 | | - <Input |
551 | | - id='reset-email' |
552 | | - value={forgotPasswordEmail} |
553 | | - onChange={(e) => setForgotPasswordEmail(e.target.value)} |
554 | | - placeholder='Enter your email' |
555 | | - required |
556 | | - type='email' |
557 | | - className={cn( |
558 | | - resetStatus.type === 'error' && 'border-red-500 focus:border-red-500' |
| 568 | + <form |
| 569 | + onSubmit={(e) => { |
| 570 | + e.preventDefault() |
| 571 | + handleForgotPassword() |
| 572 | + }} |
| 573 | + > |
| 574 | + <ModalDescription className='mb-4 text-[var(--text-muted)] text-sm'> |
| 575 | + Enter your email address and we'll send you a link to reset your password if your |
| 576 | + account exists. |
| 577 | + </ModalDescription> |
| 578 | + <div className='space-y-4'> |
| 579 | + <div className='space-y-2'> |
| 580 | + <Label htmlFor='reset-email'>Email</Label> |
| 581 | + <Input |
| 582 | + id='reset-email' |
| 583 | + value={forgotPasswordEmail} |
| 584 | + onChange={(e) => setForgotPasswordEmail(e.target.value)} |
| 585 | + placeholder='Enter your email' |
| 586 | + required |
| 587 | + type='email' |
| 588 | + className={cn( |
| 589 | + resetStatus.type === 'error' && 'border-red-500 focus:border-red-500' |
| 590 | + )} |
| 591 | + /> |
| 592 | + {resetStatus.type === 'error' && ( |
| 593 | + <div className='mt-1 text-red-400 text-xs'> |
| 594 | + <p>{resetStatus.message}</p> |
| 595 | + </div> |
559 | 596 | )} |
560 | | - /> |
561 | | - {resetStatus.type === 'error' && ( |
562 | | - <div className='mt-1 text-red-400 text-xs'> |
| 597 | + </div> |
| 598 | + {resetStatus.type === 'success' && ( |
| 599 | + <div className='mt-1 text-[#4CAF50] text-xs'> |
563 | 600 | <p>{resetStatus.message}</p> |
564 | 601 | </div> |
565 | 602 | )} |
| 603 | + <BrandedButton |
| 604 | + type='submit' |
| 605 | + disabled={isSubmittingReset} |
| 606 | + loading={isSubmittingReset} |
| 607 | + loadingText='Sending' |
| 608 | + > |
| 609 | + Send Reset Link |
| 610 | + </BrandedButton> |
566 | 611 | </div> |
567 | | - {resetStatus.type === 'success' && ( |
568 | | - <div className='mt-1 text-[#4CAF50] text-xs'> |
569 | | - <p>{resetStatus.message}</p> |
570 | | - </div> |
571 | | - )} |
572 | | - <BrandedButton |
573 | | - type='button' |
574 | | - onClick={handleForgotPassword} |
575 | | - disabled={isSubmittingReset} |
576 | | - loading={isSubmittingReset} |
577 | | - loadingText='Sending' |
578 | | - > |
579 | | - Send Reset Link |
580 | | - </BrandedButton> |
581 | | - </div> |
| 612 | + </form> |
582 | 613 | </ModalBody> |
583 | 614 | </ModalContent> |
584 | 615 | </Modal> |
|
0 commit comments