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)} Meshkee.app {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('login') const [smsStep, setSmsStep] = useState('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(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 (
{logoUrl ? : null}
{businessName || tenantDomain} {businessNameEn || tenantDomain}
{(view === 'forgot' || view === 'otp') && ( )}

{heading.title}

{heading.subtitle ?

{heading.subtitle}

: null}
{view === 'login' && ( <>
{error && (
{error}
)} {info &&
{info}
}
setPhone(e.target.value)} required disabled={isSubmitting} />
setPassword(e.target.value)} required minLength={8} disabled={isSubmitting} />
{t('login.or')}

{t('login.noAccount')}

)} {view === 'signup' && ( <>
{error && (
{error}
)} {info &&
{info}
}
setFirstName(e.target.value)} required minLength={2} disabled={isSubmitting} />
setLastName(e.target.value)} required minLength={2} disabled={isSubmitting} />
setPhone(e.target.value)} required disabled={isSubmitting} />
setPassword(e.target.value)} required minLength={8} disabled={isSubmitting} />
setConfirmPassword(e.target.value)} required minLength={8} disabled={isSubmitting} aria-describedby="signup-password-strength signup-password-strength-rules" />

{t('signup.hasAccount')}

)} {view === 'forgot' && ( <>
{error && (
{error}
)} {info &&
{info}
} {smsStep === 'phone' ? ( <>
setPhone(e.target.value)} required disabled={isSubmitting} />
) : ( <> {codeSent && (

{t('common.codeSent', { phone })}

)}
setSmsCode(e.target.value.replace(/\D/g, ''))} required disabled={isSubmitting} />
setNewPassword(e.target.value)} required minLength={8} disabled={isSubmitting} aria-describedby="forgot-password-strength" />
setConfirmNewPassword(e.target.value)} required minLength={8} disabled={isSubmitting} aria-describedby="forgot-password-strength" />
{countdown > 0 ? ( {t('common.resendIn', { seconds: countdown })} ) : ( )}
)}
)} {view === 'otp' && ( <>
{error && (
{error}
)} {info &&
{info}
} {smsStep === 'phone' ? ( <>
setPhone(e.target.value)} required disabled={isSubmitting} />
) : ( <> {codeSent && (

{t('common.codeSent', { phone })}

)}
setSmsCode(e.target.value.replace(/\D/g, ''))} required disabled={isSubmitting} />
{countdown > 0 ? ( {t('common.resendIn', { seconds: countdown })} ) : ( )}
)}
)}
Powered by Meshkee.app {cellExistsSites !== null && createPortal(
!isSubmitting && setCellExistsSites(null)} >
e.stopPropagation()} >

{t('signup.cellExists.title')}

{renderWithMeshkeeLink( cellExistsSites ? t('signup.error.cellExists', { sites: cellExistsSites }) : t('signup.error.cellExistsGeneric'), styles.noticeLink, )}

, document.body, )}
) }