Files
dashboards/apps/business/src/pages/LoginPage.tsx
T
Alireza HassaniandCursor aeaad0f645 Improve auth UX and password strength across dashboards.
Remove business self-registration, localize SMS/auth errors, add password strength + confirm on reset/signup, and brand customer role as website user. Also include websites SSL sync UI updates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 23:29:55 +03:30

591 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft } from 'lucide-react'
import { LanguageSelect, PasswordStrengthMeter, isPasswordStrong } from '@meshkee/dashboard-ui'
import { useAuth, BUSINESS_ACCESS_MESSAGE } from '../context/AuthContext'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { ApiError } from '../lib/api'
import { toE164CellNumber } from '../lib/cellNumber'
import { getBusinessDomain } from '../lib/config'
import { resetPassword, sendOtp } from '../services/authService'
import { useT } from '../i18n/useT'
import styles from './LoginPage.module.css'
type AuthView = 'login' | 'forgot' | 'otp'
type SmsStep = 'phone' | 'code'
export function LoginPage() {
const navigate = useNavigate()
const { login, loginWithOtp } = useAuth()
const { businessName, businessNameEn, logoUrl } = useTenantBranding()
const businessDomain = getBusinessDomain()
const t = useT()
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 [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)
function clearMessages() {
setError('')
setInfo('')
}
function resetForm() {
setPhone('')
setPassword('')
setSmsCode('')
setNewPassword('')
setConfirmNewPassword('')
setSmsStep('phone')
setCodeSent(false)
setShowPassword(false)
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 === BUSINESS_ACCESS_MESSAGE) {
setError(t('login.error.access'))
} else 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 {
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, businessDomain)
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('/')
} catch (err) {
handleApiError(err, t('login.error.signIn'))
} finally {
setIsSubmitting(false)
}
}
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 {
await loginWithOtp(cellNumber, smsCode)
navigate('/')
} catch (err) {
handleApiError(err, t('otp.error.signIn'))
} finally {
setIsSubmitting(false)
}
}
return (
<div className={styles.page}>
<div className={styles.card}>
<div className={styles.brand}>
{logoUrl ? <img src={logoUrl} alt="" className={styles.logo} /> : null}
<div className={styles.brandText}>
<span className={styles.businessName}>{businessName || businessDomain}</span>
<span className={styles.appName}>{businessNameEn || businessDomain}</span>
</div>
<div className={styles.langSelect}>
<LanguageSelect />
</div>
</div>
{view === 'login' && (
<>
<h1 className={styles.title}>{t('login.welcome')}</h1>
<p className={styles.subtitle}>{t('login.subtitle')}</p>
<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>
</>
)}
{view === 'forgot' && (
<>
<button
type="button"
className={styles.backBtn}
onClick={() => switchView('login')}
disabled={isSubmitting}
>
<ArrowLeft size={18} />
{t('forgot.back')}
</button>
<h1 className={styles.title}>{t('forgot.title')}</h1>
<p className={styles.subtitle}>
{smsStep === 'phone' ? t('forgot.subtitlePhone') : t('forgot.subtitleCode')}
</p>
<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>
<PasswordStrengthMeter
password={newPassword}
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'),
special: t('password.rule.special'),
},
}}
/>
</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}
/>
</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 || !isPasswordStrong(newPassword)}
>
{isSubmitting ? t('forgot.verifying') : t('forgot.reset')}
</button>
</>
)}
</form>
</>
)}
{view === 'otp' && (
<>
<button
type="button"
className={styles.backBtn}
onClick={() => switchView('login')}
disabled={isSubmitting}
>
<ArrowLeft size={18} />
{t('otp.back')}
</button>
<h1 className={styles.title}>{t('otp.title')}</h1>
<p className={styles.subtitle}>
{smsStep === 'phone' ? t('otp.subtitlePhone') : t('otp.subtitleCode')}
</p>
<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>
</div>
)
}