Public Access
feat: add resend verification email functionality and related schemas
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
1 parent
25e590639a
commit
1a37209f72
8 files changed
+134
-2
No files matched your search
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { ApiError } from '../utils/api'
|
||||
import * as authApi from '../api/authApi'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
@@ -10,6 +11,9 @@ interface Props {
|
||||
|
||||
type Tab = 'login' | 'register'
|
||||
|
||||
const VERIFICATION_RESEND_DELAY_MS = 60_000
|
||||
const VERIFICATION_REQUIRED_MESSAGE = 'Please verify your email before logging in'
|
||||
|
||||
export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>) {
|
||||
const { t } = useTranslation()
|
||||
const { login, register } = useAuth()
|
||||
@@ -20,22 +24,50 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [resendingVerification, setResendingVerification] = useState(false)
|
||||
const [verificationEmail, setVerificationEmail] = useState('')
|
||||
const [verificationResendAvailableAt, setVerificationResendAvailableAt] = useState<number | null>(null)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
if (!verificationResendAvailableAt) return undefined
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setNow(Date.now())
|
||||
}, 1000)
|
||||
|
||||
return () => window.clearInterval(timer)
|
||||
}, [verificationResendAvailableAt])
|
||||
|
||||
const verificationResendSeconds = verificationResendAvailableAt
|
||||
? Math.max(0, Math.ceil((verificationResendAvailableAt - now) / 1000))
|
||||
: 0
|
||||
const canResendVerification = verificationEmail.length > 0 && verificationResendSeconds === 0 && !resendingVerification
|
||||
|
||||
const switchTab = (t: Tab) => {
|
||||
setTab(t)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
setNotice(null)
|
||||
setVerificationEmail('')
|
||||
setVerificationResendAvailableAt(null)
|
||||
}
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await login(email, password)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 403 && err.message === VERIFICATION_REQUIRED_MESSAGE) {
|
||||
setVerificationEmail(email)
|
||||
setVerificationResendAvailableAt(Date.now() + VERIFICATION_RESEND_DELAY_MS)
|
||||
}
|
||||
setError(err instanceof ApiError ? err.message : t('auth.loginFailed'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
@@ -45,6 +77,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
if (password !== confirmPassword) {
|
||||
setError(t('auth.passwordsDoNotMatch'))
|
||||
return
|
||||
@@ -60,6 +93,23 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
}
|
||||
}
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
if (!canResendVerification) return
|
||||
|
||||
setResendingVerification(true)
|
||||
setNotice(null)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await authApi.resendVerification(verificationEmail)
|
||||
setNotice(response.message)
|
||||
setVerificationResendAvailableAt(Date.now() + VERIFICATION_RESEND_DELAY_MS)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : t('auth.requestFailed'))
|
||||
} finally {
|
||||
setResendingVerification(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-overlay" onClick={onClose}>
|
||||
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -94,6 +144,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
|
||||
{error && <div className="auth-error">{error}</div>}
|
||||
{success && <div className="auth-success">{success}</div>}
|
||||
{notice && <div className="auth-success">{notice}</div>}
|
||||
|
||||
{!success && tab === 'login' && (
|
||||
<form onSubmit={(e) => void handleLogin(e)} className="auth-form">
|
||||
@@ -133,6 +184,25 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
|
||||
>
|
||||
{t('auth.forgotPassword')}
|
||||
</button>
|
||||
{verificationEmail && error === VERIFICATION_REQUIRED_MESSAGE && (
|
||||
<div className="auth-field" style={{ marginTop: 8 }}>
|
||||
<p className="auth-subtitle" style={{ marginBottom: 8 }}>
|
||||
{t('auth.verificationResendHint')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => void handleResendVerification()}
|
||||
disabled={!canResendVerification}
|
||||
>
|
||||
{resendingVerification
|
||||
? t('auth.sending')
|
||||
: verificationResendSeconds > 0
|
||||
? t('auth.resendVerificationLinkIn', { seconds: verificationResendSeconds })
|
||||
: t('auth.resendVerificationLink')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
|
||||
Reference in new issue
Block a user