mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
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>
591 lines
20 KiB
TypeScript
591 lines
20 KiB
TypeScript
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>
|
||
)
|
||
}
|