Also ship pending add-customer name flexibility, store-gated e-payment settings, and logo-only customer login branding. Co-authored-by: Cursor <cursoragent@cursor.com>
991 lines
33 KiB
TypeScript
991 lines
33 KiB
TypeScript
import { useState, type ReactNode } from 'react'
|
||
import { createPortal } from 'react-dom'
|
||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User, X } from 'lucide-react'
|
||
import {
|
||
useAuth,
|
||
CUSTOMER_ACCESS_MESSAGE,
|
||
} from '../context/AuthContext'
|
||
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||
import { ApiError } from '../lib/api'
|
||
import { toE164CellNumber } from '../lib/cellNumber'
|
||
import { getTenantDomain } from '../lib/config'
|
||
import {
|
||
logout as logoutRequest,
|
||
register,
|
||
resetPassword,
|
||
sendOtp,
|
||
} from '../services/authService'
|
||
import { LanguageSelect, PasswordStrengthMeter, isPasswordStrong, useLocale, useToast } from '@meshkee/dashboard-ui'
|
||
import { useT } from '../i18n/useT'
|
||
import { safeRedirectPath } from '../lib/redirect'
|
||
import { BackgroundOrbs } from '../components/BackgroundOrbs'
|
||
import styles from './LoginPage.module.css'
|
||
|
||
type AuthView = 'login' | 'signup' | 'forgot' | 'otp'
|
||
type SmsStep = 'phone' | 'code'
|
||
|
||
const MESHKEE_APP_MARKER = '__MESHKEE_APP__'
|
||
const MESHKEE_SITE_URL = 'https://meshkee.com'
|
||
|
||
function parseCellExistsSites(message: string): string | null {
|
||
if (message.startsWith('CELL_EXISTS_OTHER_SITE:')) {
|
||
return message.slice('CELL_EXISTS_OTHER_SITE:'.length).trim()
|
||
}
|
||
if (message.includes('Cell number exists on another account')) {
|
||
return ''
|
||
}
|
||
return null
|
||
}
|
||
|
||
function renderWithMeshkeeLink(message: string, linkClassName: string): ReactNode {
|
||
const idx = message.indexOf(MESHKEE_APP_MARKER)
|
||
if (idx === -1) return message
|
||
return (
|
||
<>
|
||
{message.slice(0, idx)}
|
||
<a
|
||
href={MESHKEE_SITE_URL}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className={linkClassName}
|
||
>
|
||
Meshkee.app
|
||
</a>
|
||
{message.slice(idx + MESHKEE_APP_MARKER.length)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
export function LoginPage() {
|
||
const navigate = useNavigate()
|
||
const [searchParams] = useSearchParams()
|
||
const redirectTo = safeRedirectPath(searchParams.get('redirect'))
|
||
const { login, loginWithOtp } = useAuth()
|
||
const { businessName, businessNameEn, logoUrl } = useTenantBranding()
|
||
const tenantDomain = getTenantDomain()
|
||
const t = useT()
|
||
const { showToast } = useToast()
|
||
const { locale, dir } = useLocale()
|
||
|
||
const [view, setView] = useState<AuthView>('login')
|
||
const [smsStep, setSmsStep] = useState<SmsStep>('phone')
|
||
const [showPassword, setShowPassword] = useState(false)
|
||
const [phone, setPhone] = useState('')
|
||
const [password, setPassword] = useState('')
|
||
const [confirmPassword, setConfirmPassword] = useState('')
|
||
const [firstName, setFirstName] = useState('')
|
||
const [lastName, setLastName] = useState('')
|
||
const [smsCode, setSmsCode] = useState('')
|
||
const [newPassword, setNewPassword] = useState('')
|
||
const [confirmNewPassword, setConfirmNewPassword] = useState('')
|
||
const [codeSent, setCodeSent] = useState(false)
|
||
const [countdown, setCountdown] = useState(0)
|
||
const [error, setError] = useState('')
|
||
const [info, setInfo] = useState('')
|
||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||
/** `null` closed; `''` generic notice; otherwise site names */
|
||
const [cellExistsSites, setCellExistsSites] = useState<string | null>(null)
|
||
|
||
function clearMessages() {
|
||
setError('')
|
||
setInfo('')
|
||
}
|
||
|
||
function resetForm() {
|
||
setPhone('')
|
||
setPassword('')
|
||
setConfirmPassword('')
|
||
setFirstName('')
|
||
setLastName('')
|
||
setSmsCode('')
|
||
setNewPassword('')
|
||
setConfirmNewPassword('')
|
||
setSmsStep('phone')
|
||
setCodeSent(false)
|
||
setShowPassword(false)
|
||
setCellExistsSites(null)
|
||
clearMessages()
|
||
}
|
||
|
||
function switchView(next: AuthView) {
|
||
resetForm()
|
||
setView(next)
|
||
}
|
||
|
||
function startCountdown() {
|
||
setCountdown(60)
|
||
const timer = setInterval(() => {
|
||
setCountdown((prev) => {
|
||
if (prev <= 1) {
|
||
clearInterval(timer)
|
||
return 0
|
||
}
|
||
return prev - 1
|
||
})
|
||
}, 1000)
|
||
}
|
||
|
||
function handleApiError(err: unknown, fallback: string) {
|
||
if (err instanceof ApiError) {
|
||
if (
|
||
err.message.includes('E.164') ||
|
||
err.message.includes('cellNumber must be')
|
||
) {
|
||
setError(t('login.error.cellEnglishDigits'))
|
||
} else if (
|
||
err.message.includes('SMS provider is not configured') ||
|
||
err.message.includes('SMS provider is unreachable') ||
|
||
err.message.includes('SMS provider returned') ||
|
||
err.message.includes('Unable to send SMS')
|
||
) {
|
||
setError(t('login.error.smsProvider'))
|
||
} else if (
|
||
err.message.includes('Invalid cell number or password')
|
||
) {
|
||
setError(t('login.error.invalidCredentials'))
|
||
} else if (
|
||
err.message.includes('Cell number is not verified') ||
|
||
err.message.includes('complete OTP verification')
|
||
) {
|
||
setError(t('login.error.cellNotVerified'))
|
||
} else {
|
||
setError(err.message)
|
||
}
|
||
} else {
|
||
setError(fallback)
|
||
}
|
||
}
|
||
|
||
function resolveCellNumber(): string | null {
|
||
if (/[۰-۹٠-٩]/.test(phone)) {
|
||
setError(t('login.error.cellEnglishDigits'))
|
||
return null
|
||
}
|
||
const cellNumber = toE164CellNumber(phone)
|
||
if (!/^\+[1-9]\d{6,14}$/.test(cellNumber)) {
|
||
setError(t('login.error.cellEnglishDigits'))
|
||
return null
|
||
}
|
||
return cellNumber
|
||
}
|
||
|
||
async function handleSendCode() {
|
||
clearMessages()
|
||
const cellNumber = resolveCellNumber()
|
||
if (!cellNumber) return
|
||
|
||
setIsSubmitting(true)
|
||
|
||
try {
|
||
const result = await sendOtp(cellNumber, tenantDomain)
|
||
|
||
if (!result.enabled) {
|
||
setInfo(result.message)
|
||
}
|
||
|
||
setCodeSent(true)
|
||
setSmsStep('code')
|
||
startCountdown()
|
||
} catch (err) {
|
||
handleApiError(err, t('login.error.sendCode'))
|
||
} finally {
|
||
setIsSubmitting(false)
|
||
}
|
||
}
|
||
|
||
async function handleLogin(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
clearMessages()
|
||
const cellNumber = resolveCellNumber()
|
||
if (!cellNumber) return
|
||
|
||
setIsSubmitting(true)
|
||
|
||
try {
|
||
await login(cellNumber, password)
|
||
navigate(redirectTo)
|
||
} catch (err) {
|
||
handleApiError(err, t('login.error.signIn'))
|
||
} finally {
|
||
setIsSubmitting(false)
|
||
}
|
||
}
|
||
|
||
async function runSignup(acknowledgeExistingAccount = false) {
|
||
const cellNumber = resolveCellNumber()
|
||
if (!cellNumber) return
|
||
|
||
setIsSubmitting(true)
|
||
|
||
try {
|
||
const data = await register({
|
||
cellNumber,
|
||
password,
|
||
firstName: firstName.trim(),
|
||
lastName: lastName.trim(),
|
||
domain: tenantDomain,
|
||
...(acknowledgeExistingAccount ? { acknowledgeExistingAccount: true } : {}),
|
||
})
|
||
|
||
setCellExistsSites(null)
|
||
|
||
// Password is applied only after OTP when passwordPending; toast then.
|
||
if (data.passwordUpdated) {
|
||
showToast(t('signup.toast.passwordUpdated'), 'info')
|
||
}
|
||
|
||
if (data.requiresOtp || data.passwordPending || !data.user.cellVerifiedAt) {
|
||
if (data.accessToken) {
|
||
logoutRequest()
|
||
}
|
||
await continueToSmsVerify(
|
||
cellNumber,
|
||
data.passwordPending
|
||
? t('signup.info.verifyExistingPasswordUpdated')
|
||
: t('signup.info.verify'),
|
||
)
|
||
return
|
||
}
|
||
|
||
if (data.user.customerBusinesses.length === 0) {
|
||
logoutRequest()
|
||
setError(CUSTOMER_ACCESS_MESSAGE)
|
||
return
|
||
}
|
||
|
||
await login(cellNumber, password)
|
||
navigate(redirectTo)
|
||
} catch (err) {
|
||
if (!acknowledgeExistingAccount && err instanceof ApiError) {
|
||
const sites = parseCellExistsSites(err.message)
|
||
if (sites !== null) {
|
||
setCellExistsSites(sites)
|
||
return
|
||
}
|
||
}
|
||
setCellExistsSites(null)
|
||
handleApiError(err, t('signup.error.create'))
|
||
} finally {
|
||
setIsSubmitting(false)
|
||
}
|
||
}
|
||
|
||
async function continueToSmsVerify(cellNumber: string, infoMessage?: string) {
|
||
setView('otp')
|
||
setSmsStep('phone')
|
||
setPassword('')
|
||
setConfirmPassword('')
|
||
setSmsCode('')
|
||
setInfo(infoMessage ?? t('signup.info.verify'))
|
||
try {
|
||
const otpResult = await sendOtp(cellNumber, tenantDomain)
|
||
if (!otpResult.enabled) {
|
||
setInfo(otpResult.message)
|
||
}
|
||
setCodeSent(true)
|
||
setSmsStep('code')
|
||
startCountdown()
|
||
} catch (otpErr) {
|
||
handleApiError(otpErr, t('login.error.sendCode'))
|
||
}
|
||
}
|
||
|
||
async function handleSignup(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
clearMessages()
|
||
setCellExistsSites(null)
|
||
|
||
if (password !== confirmPassword) {
|
||
setError(t('signup.error.match'))
|
||
return
|
||
}
|
||
|
||
if (!isPasswordStrong(password)) {
|
||
setError(t('forgot.error.strength'))
|
||
return
|
||
}
|
||
|
||
await runSignup()
|
||
}
|
||
|
||
/**
|
||
* Existing Meshkee account on another site: user admits the notice, then we link
|
||
* them with the new password and prove phone ownership via SMS.
|
||
*/
|
||
async function handleAdmitCellExists() {
|
||
clearMessages()
|
||
const cellNumber = resolveCellNumber()
|
||
if (!cellNumber) {
|
||
setCellExistsSites(null)
|
||
return
|
||
}
|
||
|
||
await runSignup(true)
|
||
}
|
||
|
||
async function handleResetPassword(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
clearMessages()
|
||
|
||
if (!isPasswordStrong(newPassword)) {
|
||
setError(t('forgot.error.strength'))
|
||
return
|
||
}
|
||
|
||
if (newPassword !== confirmNewPassword) {
|
||
setError(t('forgot.error.match'))
|
||
return
|
||
}
|
||
|
||
const cellNumber = resolveCellNumber()
|
||
if (!cellNumber) return
|
||
|
||
setIsSubmitting(true)
|
||
|
||
try {
|
||
await resetPassword(cellNumber, smsCode, newPassword)
|
||
setInfo(t('forgot.info.success'))
|
||
setTimeout(() => switchView('login'), 2000)
|
||
} catch (err) {
|
||
handleApiError(err, t('forgot.error.verify'))
|
||
} finally {
|
||
setIsSubmitting(false)
|
||
}
|
||
}
|
||
|
||
async function handleOtpLogin(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
clearMessages()
|
||
const cellNumber = resolveCellNumber()
|
||
if (!cellNumber) return
|
||
|
||
setIsSubmitting(true)
|
||
|
||
try {
|
||
const result = await loginWithOtp(cellNumber, smsCode)
|
||
if (result.passwordUpdated) {
|
||
showToast(t('signup.toast.passwordUpdated'), 'info')
|
||
}
|
||
navigate(redirectTo)
|
||
} catch (err) {
|
||
handleApiError(err, t('otp.error.signIn'))
|
||
} finally {
|
||
setIsSubmitting(false)
|
||
}
|
||
}
|
||
|
||
const heading =
|
||
view === 'login'
|
||
? { title: t('login.welcome'), subtitle: t('login.subtitle') }
|
||
: view === 'signup'
|
||
? { title: t('signup.title'), subtitle: null }
|
||
: view === 'forgot'
|
||
? {
|
||
title: t('forgot.title'),
|
||
subtitle: smsStep === 'phone' ? t('forgot.subtitlePhone') : t('forgot.subtitleCode'),
|
||
}
|
||
: {
|
||
title: t('otp.title'),
|
||
subtitle: smsStep === 'phone' ? t('otp.subtitlePhone') : t('otp.subtitleCode'),
|
||
}
|
||
|
||
return (
|
||
<div className={styles.page}>
|
||
<BackgroundOrbs />
|
||
<div className={styles.brand}>
|
||
{logoUrl ? <img src={logoUrl} alt="" className={styles.logo} /> : null}
|
||
<div className={styles.brandText}>
|
||
<span className={styles.domain} lang={locale === 'fa' ? 'fa' : undefined}>
|
||
{businessName || tenantDomain}
|
||
</span>
|
||
<span className={styles.appName} lang="en" dir="ltr">
|
||
{businessNameEn || tenantDomain}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className={styles.card}>
|
||
{(view === 'forgot' || view === 'otp') && (
|
||
<button
|
||
type="button"
|
||
className={styles.backBtn}
|
||
onClick={() => switchView('login')}
|
||
disabled={isSubmitting}
|
||
>
|
||
<ArrowLeft size={18} />
|
||
{view === 'forgot' ? t('forgot.back') : t('otp.back')}
|
||
</button>
|
||
)}
|
||
<div className={styles.cardHeader}>
|
||
<div className={styles.heading}>
|
||
<h1 className={styles.title}>{heading.title}</h1>
|
||
{heading.subtitle ? <p className={styles.subtitle}>{heading.subtitle}</p> : null}
|
||
</div>
|
||
<div className={styles.langSelect}>
|
||
<LanguageSelect />
|
||
</div>
|
||
</div>
|
||
|
||
{view === 'login' && (
|
||
<>
|
||
<form className={styles.form} onSubmit={handleLogin}>
|
||
{error && (
|
||
<div className={styles.error} role="alert">
|
||
{error}
|
||
</div>
|
||
)}
|
||
{info && <div className={styles.info}>{info}</div>}
|
||
|
||
<div className={styles.field}>
|
||
<label htmlFor="login-phone">{t('login.mobile')}</label>
|
||
<div className={styles.inputWrap}>
|
||
<Smartphone size={18} className={styles.inputIcon} />
|
||
<input
|
||
id="login-phone"
|
||
type="tel"
|
||
inputMode="tel"
|
||
placeholder="09122222222"
|
||
value={phone}
|
||
onChange={(e) => setPhone(e.target.value)}
|
||
required
|
||
disabled={isSubmitting}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.field}>
|
||
<label htmlFor="login-password">{t('login.password')}</label>
|
||
<div className={styles.inputWrap}>
|
||
<Lock size={18} className={styles.inputIcon} />
|
||
<input
|
||
id="login-password"
|
||
type={showPassword ? 'text' : 'password'}
|
||
dir="ltr"
|
||
lang="en"
|
||
placeholder={t('login.passwordPlaceholder')}
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
required
|
||
minLength={8}
|
||
disabled={isSubmitting}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className={styles.togglePassword}
|
||
onClick={() => setShowPassword((v) => !v)}
|
||
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
|
||
disabled={isSubmitting}
|
||
>
|
||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.formActions}>
|
||
<button
|
||
type="button"
|
||
className={styles.linkBtn}
|
||
onClick={() => switchView('forgot')}
|
||
disabled={isSubmitting}
|
||
>
|
||
{t('login.forgot')}
|
||
</button>
|
||
</div>
|
||
|
||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||
{isSubmitting ? t('login.signingIn') : t('login.signIn')}
|
||
</button>
|
||
</form>
|
||
|
||
<div className={styles.divider}>
|
||
<span>{t('login.or')}</span>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
className={styles.secondaryBtn}
|
||
onClick={() => switchView('otp')}
|
||
disabled={isSubmitting}
|
||
>
|
||
<KeyRound size={18} />
|
||
{t('login.otp')}
|
||
</button>
|
||
|
||
<p className={styles.footerText}>
|
||
<span>{t('login.noAccount')}</span>
|
||
<button
|
||
type="button"
|
||
className={styles.linkBtn}
|
||
onClick={() => switchView('signup')}
|
||
disabled={isSubmitting}
|
||
>
|
||
{t('login.signUp')}
|
||
</button>
|
||
</p>
|
||
</>
|
||
)}
|
||
|
||
{view === 'signup' && (
|
||
<>
|
||
<form className={styles.form} onSubmit={handleSignup}>
|
||
{error && (
|
||
<div className={styles.error} role="alert">
|
||
{error}
|
||
</div>
|
||
)}
|
||
{info && <div className={styles.info}>{info}</div>}
|
||
|
||
<div className={styles.fieldRow}>
|
||
<div className={styles.field}>
|
||
<label htmlFor="signup-first">{t('signup.firstName')}</label>
|
||
<div className={styles.inputWrap}>
|
||
<User size={18} className={styles.inputIcon} />
|
||
<input
|
||
id="signup-first"
|
||
type="text"
|
||
placeholder={t('signup.firstName')}
|
||
value={firstName}
|
||
onChange={(e) => setFirstName(e.target.value)}
|
||
required
|
||
minLength={2}
|
||
disabled={isSubmitting}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className={styles.field}>
|
||
<label htmlFor="signup-last">{t('signup.lastName')}</label>
|
||
<div className={styles.inputWrap}>
|
||
<User size={18} className={styles.inputIcon} />
|
||
<input
|
||
id="signup-last"
|
||
type="text"
|
||
placeholder={t('signup.lastName')}
|
||
value={lastName}
|
||
onChange={(e) => setLastName(e.target.value)}
|
||
required
|
||
minLength={2}
|
||
disabled={isSubmitting}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.field}>
|
||
<label htmlFor="signup-phone">{t('login.mobile')}</label>
|
||
<div className={styles.inputWrap}>
|
||
<Smartphone size={18} className={styles.inputIcon} />
|
||
<input
|
||
id="signup-phone"
|
||
type="tel"
|
||
inputMode="tel"
|
||
placeholder="09123456789"
|
||
value={phone}
|
||
onChange={(e) => setPhone(e.target.value)}
|
||
required
|
||
disabled={isSubmitting}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.field}>
|
||
<label htmlFor="signup-password">{t('login.password')}</label>
|
||
<div className={styles.inputWrap}>
|
||
<Lock size={18} className={styles.inputIcon} />
|
||
<input
|
||
id="signup-password"
|
||
type={showPassword ? 'text' : 'password'}
|
||
dir="ltr"
|
||
lang="en"
|
||
placeholder={t('signup.passwordPlaceholder')}
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
required
|
||
minLength={8}
|
||
disabled={isSubmitting}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className={styles.togglePassword}
|
||
onClick={() => setShowPassword((v) => !v)}
|
||
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
|
||
disabled={isSubmitting}
|
||
>
|
||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.field}>
|
||
<label htmlFor="signup-confirm">{t('signup.confirm')}</label>
|
||
<div className={styles.inputWrap}>
|
||
<Lock size={18} className={styles.inputIcon} />
|
||
<input
|
||
id="signup-confirm"
|
||
type={showPassword ? 'text' : 'password'}
|
||
dir="ltr"
|
||
lang="en"
|
||
placeholder={t('signup.confirmPlaceholder')}
|
||
value={confirmPassword}
|
||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||
required
|
||
minLength={8}
|
||
disabled={isSubmitting}
|
||
aria-describedby="signup-password-strength signup-password-strength-rules"
|
||
/>
|
||
</div>
|
||
<PasswordStrengthMeter
|
||
password={password}
|
||
confirmPassword={confirmPassword}
|
||
id="signup-password-strength"
|
||
labels={{
|
||
title: t('password.strength'),
|
||
empty: t('password.strength.empty'),
|
||
weak: t('password.strength.weak'),
|
||
fair: t('password.strength.fair'),
|
||
good: t('password.strength.good'),
|
||
strong: t('password.strength.strong'),
|
||
rules: {
|
||
length: t('password.rule.length'),
|
||
upper: t('password.rule.upper'),
|
||
number: t('password.rule.number'),
|
||
match: t('password.rule.match'),
|
||
},
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
className={styles.submitBtn}
|
||
disabled={
|
||
isSubmitting || !isPasswordStrong(password) || password !== confirmPassword
|
||
}
|
||
>
|
||
{isSubmitting ? t('signup.creating') : t('signup.create')}
|
||
</button>
|
||
</form>
|
||
|
||
<p className={styles.footerText}>
|
||
<span>{t('signup.hasAccount')}</span>
|
||
<button
|
||
type="button"
|
||
className={styles.linkBtn}
|
||
onClick={() => switchView('login')}
|
||
disabled={isSubmitting}
|
||
>
|
||
{t('signup.signIn')}
|
||
</button>
|
||
</p>
|
||
</>
|
||
)}
|
||
|
||
{view === 'forgot' && (
|
||
<>
|
||
<form className={styles.form} onSubmit={handleResetPassword}>
|
||
{error && (
|
||
<div className={styles.error} role="alert">
|
||
{error}
|
||
</div>
|
||
)}
|
||
{info && <div className={styles.info}>{info}</div>}
|
||
|
||
{smsStep === 'phone' ? (
|
||
<>
|
||
<div className={styles.field}>
|
||
<label htmlFor="forgot-phone">{t('login.mobile')}</label>
|
||
<div className={styles.inputWrap}>
|
||
<Smartphone size={18} className={styles.inputIcon} />
|
||
<input
|
||
id="forgot-phone"
|
||
type="tel"
|
||
inputMode="tel"
|
||
placeholder="09122222222"
|
||
value={phone}
|
||
onChange={(e) => setPhone(e.target.value)}
|
||
required
|
||
disabled={isSubmitting}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
className={styles.submitBtn}
|
||
onClick={() => void handleSendCode()}
|
||
disabled={isSubmitting}
|
||
>
|
||
{isSubmitting ? t('forgot.sending') : t('forgot.sendCode')}
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
{codeSent && (
|
||
<p className={styles.codeHint}>
|
||
{t('common.codeSent', { phone })}
|
||
</p>
|
||
)}
|
||
|
||
<div className={styles.field}>
|
||
<label htmlFor="forgot-code">{t('forgot.code')}</label>
|
||
<div className={styles.inputWrap}>
|
||
<KeyRound size={18} className={styles.inputIcon} />
|
||
<input
|
||
id="forgot-code"
|
||
type="text"
|
||
inputMode="numeric"
|
||
placeholder="123456"
|
||
maxLength={6}
|
||
value={smsCode}
|
||
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, ''))}
|
||
required
|
||
disabled={isSubmitting}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.field}>
|
||
<label htmlFor="forgot-new-password">{t('forgot.newPassword')}</label>
|
||
<div className={styles.inputWrap}>
|
||
<Lock size={18} className={styles.inputIcon} />
|
||
<input
|
||
id="forgot-new-password"
|
||
type={showPassword ? 'text' : 'password'}
|
||
dir="ltr"
|
||
lang="en"
|
||
placeholder={t('forgot.newPasswordPlaceholder')}
|
||
value={newPassword}
|
||
onChange={(e) => setNewPassword(e.target.value)}
|
||
required
|
||
minLength={8}
|
||
disabled={isSubmitting}
|
||
aria-describedby="forgot-password-strength"
|
||
/>
|
||
<button
|
||
type="button"
|
||
className={styles.togglePassword}
|
||
onClick={() => setShowPassword((v) => !v)}
|
||
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
|
||
disabled={isSubmitting}
|
||
>
|
||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.field}>
|
||
<label htmlFor="forgot-confirm-password">{t('forgot.confirm')}</label>
|
||
<div className={styles.inputWrap}>
|
||
<Lock size={18} className={styles.inputIcon} />
|
||
<input
|
||
id="forgot-confirm-password"
|
||
type={showPassword ? 'text' : 'password'}
|
||
dir="ltr"
|
||
lang="en"
|
||
placeholder={t('forgot.confirmPlaceholder')}
|
||
value={confirmNewPassword}
|
||
onChange={(e) => setConfirmNewPassword(e.target.value)}
|
||
required
|
||
minLength={8}
|
||
disabled={isSubmitting}
|
||
aria-describedby="forgot-password-strength"
|
||
/>
|
||
</div>
|
||
<PasswordStrengthMeter
|
||
password={newPassword}
|
||
confirmPassword={confirmNewPassword}
|
||
id="forgot-password-strength"
|
||
labels={{
|
||
title: t('password.strength'),
|
||
empty: t('password.strength.empty'),
|
||
weak: t('password.strength.weak'),
|
||
fair: t('password.strength.fair'),
|
||
good: t('password.strength.good'),
|
||
strong: t('password.strength.strong'),
|
||
rules: {
|
||
length: t('password.rule.length'),
|
||
upper: t('password.rule.upper'),
|
||
number: t('password.rule.number'),
|
||
match: t('password.rule.match'),
|
||
},
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div className={styles.resendRow}>
|
||
{countdown > 0 ? (
|
||
<span className={styles.countdown}>{t('common.resendIn', { seconds: countdown })}</span>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className={styles.linkBtn}
|
||
onClick={() => void handleSendCode()}
|
||
disabled={isSubmitting}
|
||
>
|
||
{t('common.resend')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
className={styles.submitBtn}
|
||
disabled={isSubmitting || !isPasswordStrong(newPassword) || newPassword !== confirmNewPassword}
|
||
>
|
||
{isSubmitting ? t('forgot.verifying') : t('forgot.reset')}
|
||
</button>
|
||
</>
|
||
)}
|
||
</form>
|
||
</>
|
||
)}
|
||
|
||
{view === 'otp' && (
|
||
<>
|
||
<form className={styles.form} onSubmit={handleOtpLogin}>
|
||
{error && (
|
||
<div className={styles.error} role="alert">
|
||
{error}
|
||
</div>
|
||
)}
|
||
{info && <div className={styles.info}>{info}</div>}
|
||
|
||
{smsStep === 'phone' ? (
|
||
<>
|
||
<div className={styles.field}>
|
||
<label htmlFor="otp-phone">{t('login.mobile')}</label>
|
||
<div className={styles.inputWrap}>
|
||
<Smartphone size={18} className={styles.inputIcon} />
|
||
<input
|
||
id="otp-phone"
|
||
type="tel"
|
||
inputMode="tel"
|
||
placeholder="09122222222"
|
||
value={phone}
|
||
onChange={(e) => setPhone(e.target.value)}
|
||
required
|
||
disabled={isSubmitting}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
className={styles.submitBtn}
|
||
onClick={() => void handleSendCode()}
|
||
disabled={isSubmitting}
|
||
>
|
||
{isSubmitting ? t('otp.sending') : t('otp.sendCode')}
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
{codeSent && (
|
||
<p className={styles.codeHint}>
|
||
{t('common.codeSent', { phone })}
|
||
</p>
|
||
)}
|
||
|
||
<div className={styles.field}>
|
||
<label htmlFor="otp-code">{t('otp.code')}</label>
|
||
<div className={styles.inputWrap}>
|
||
<KeyRound size={18} className={styles.inputIcon} />
|
||
<input
|
||
id="otp-code"
|
||
type="text"
|
||
inputMode="numeric"
|
||
placeholder="123456"
|
||
maxLength={6}
|
||
value={smsCode}
|
||
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, ''))}
|
||
required
|
||
disabled={isSubmitting}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.resendRow}>
|
||
{countdown > 0 ? (
|
||
<span className={styles.countdown}>{t('common.resendIn', { seconds: countdown })}</span>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className={styles.linkBtn}
|
||
onClick={() => void handleSendCode()}
|
||
disabled={isSubmitting}
|
||
>
|
||
{t('common.resend')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||
{isSubmitting ? t('otp.signingIn') : t('otp.signIn')}
|
||
</button>
|
||
</>
|
||
)}
|
||
</form>
|
||
</>
|
||
)}
|
||
</div>
|
||
<a
|
||
className={styles.poweredBy}
|
||
href="https://meshkee.com"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
Powered by Meshkee.app
|
||
</a>
|
||
|
||
{cellExistsSites !== null &&
|
||
createPortal(
|
||
<div
|
||
className={styles.noticeOverlay}
|
||
onClick={() => !isSubmitting && setCellExistsSites(null)}
|
||
>
|
||
<div
|
||
className={styles.noticeModal}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="cell-exists-title"
|
||
lang={locale}
|
||
dir={dir}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className={styles.noticeHeader}>
|
||
<h2 id="cell-exists-title" className={styles.noticeTitle}>
|
||
{t('signup.cellExists.title')}
|
||
</h2>
|
||
<button
|
||
type="button"
|
||
className={styles.noticeClose}
|
||
onClick={() => setCellExistsSites(null)}
|
||
disabled={isSubmitting}
|
||
aria-label={t('common.close')}
|
||
>
|
||
<X size={20} />
|
||
</button>
|
||
</div>
|
||
<p className={styles.noticeBody}>
|
||
{renderWithMeshkeeLink(
|
||
cellExistsSites
|
||
? t('signup.error.cellExists', { sites: cellExistsSites })
|
||
: t('signup.error.cellExistsGeneric'),
|
||
styles.noticeLink,
|
||
)}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
className={styles.noticeAdmit}
|
||
onClick={() => void handleAdmitCellExists()}
|
||
disabled={isSubmitting}
|
||
>
|
||
{isSubmitting ? t('signup.creating') : t('signup.cellExists.admit')}
|
||
</button>
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
)}
|
||
</div>
|
||
)
|
||
}
|