mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
718 lines
24 KiB
TypeScript
718 lines
24 KiB
TypeScript
import { useState } from 'react'
|
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } 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 } from '@meshkee/dashboard-ui'
|
|
import { useT } from '../i18n/useT'
|
|
import meshkeeLogo from '../assets/meshkee-logo.png'
|
|
import styles from './LoginPage.module.css'
|
|
|
|
type AuthView = 'login' | 'signup' | 'forgot' | 'otp'
|
|
type SmsStep = 'phone' | 'code'
|
|
|
|
function safeRedirectPath(value: string | null) {
|
|
if (!value || !value.startsWith('/') || value.startsWith('//')) {
|
|
return '/'
|
|
}
|
|
return value
|
|
}
|
|
|
|
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 [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 [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('')
|
|
setConfirmPassword('')
|
|
setFirstName('')
|
|
setLastName('')
|
|
setSmsCode('')
|
|
setNewPassword('')
|
|
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) {
|
|
setError(err.message)
|
|
} else {
|
|
setError(fallback)
|
|
}
|
|
}
|
|
|
|
async function handleSendCode() {
|
|
clearMessages()
|
|
setIsSubmitting(true)
|
|
|
|
try {
|
|
const cellNumber = toE164CellNumber(phone)
|
|
const result = await sendOtp(cellNumber)
|
|
|
|
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()
|
|
setIsSubmitting(true)
|
|
|
|
try {
|
|
const cellNumber = toE164CellNumber(phone)
|
|
await login(cellNumber, password)
|
|
navigate(redirectTo)
|
|
} catch (err) {
|
|
handleApiError(err, t('login.error.signIn'))
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}
|
|
|
|
async function handleSignup(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
clearMessages()
|
|
|
|
if (password !== confirmPassword) {
|
|
setError(t('signup.error.match'))
|
|
return
|
|
}
|
|
|
|
if (password.length < 8) {
|
|
setError(t('signup.error.length'))
|
|
return
|
|
}
|
|
|
|
setIsSubmitting(true)
|
|
|
|
try {
|
|
const cellNumber = toE164CellNumber(phone)
|
|
const data = await register({
|
|
cellNumber,
|
|
password,
|
|
firstName: firstName.trim(),
|
|
lastName: lastName.trim(),
|
|
domain: tenantDomain,
|
|
})
|
|
|
|
if (data.user.customerBusinesses.length === 0) {
|
|
logoutRequest()
|
|
setError(CUSTOMER_ACCESS_MESSAGE)
|
|
return
|
|
}
|
|
|
|
if (!data.user.cellVerifiedAt) {
|
|
logoutRequest()
|
|
setView('otp')
|
|
setSmsStep('phone')
|
|
setPassword('')
|
|
setConfirmPassword('')
|
|
setSmsCode('')
|
|
setInfo(t('signup.info.verify'))
|
|
try {
|
|
const otpResult = await sendOtp(cellNumber)
|
|
if (!otpResult.enabled) {
|
|
setInfo(otpResult.message)
|
|
}
|
|
setCodeSent(true)
|
|
setSmsStep('code')
|
|
startCountdown()
|
|
} catch (otpErr) {
|
|
handleApiError(otpErr, t('login.error.sendCode'))
|
|
}
|
|
return
|
|
}
|
|
|
|
await login(cellNumber, password)
|
|
navigate(redirectTo)
|
|
} catch (err) {
|
|
handleApiError(err, t('signup.error.create'))
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}
|
|
|
|
async function handleResetPassword(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
clearMessages()
|
|
|
|
if (newPassword.length < 8) {
|
|
setError(t('forgot.error.length'))
|
|
return
|
|
}
|
|
|
|
setIsSubmitting(true)
|
|
|
|
try {
|
|
const cellNumber = toE164CellNumber(phone)
|
|
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()
|
|
setIsSubmitting(true)
|
|
|
|
try {
|
|
const cellNumber = toE164CellNumber(phone)
|
|
await loginWithOtp(cellNumber, smsCode)
|
|
navigate(redirectTo)
|
|
} catch (err) {
|
|
handleApiError(err, t('otp.error.signIn'))
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className={styles.page}>
|
|
<div className={styles.card}>
|
|
<div className={styles.brand}>
|
|
<img src={logoUrl || meshkeeLogo} alt="" className={styles.logo} />
|
|
<div className={styles.brandText}>
|
|
<span className={styles.domain}>{businessName || tenantDomain}</span>
|
|
<span className={styles.appName}>{businessNameEn || tenantDomain}</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'}
|
|
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}>
|
|
{t('login.noAccount')}{' '}
|
|
<button
|
|
type="button"
|
|
className={styles.linkBtn}
|
|
onClick={() => switchView('signup')}
|
|
disabled={isSubmitting}
|
|
>
|
|
{t('login.signUp')}
|
|
</button>
|
|
</p>
|
|
</>
|
|
)}
|
|
|
|
{view === 'signup' && (
|
|
<>
|
|
<h1 className={styles.title}>{t('signup.title')}</h1>
|
|
<p className={styles.subtitle}>{t('signup.subtitle', { domain: tenantDomain })}</p>
|
|
|
|
<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'}
|
|
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'}
|
|
placeholder={t('signup.confirmPlaceholder')}
|
|
value={confirmPassword}
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
required
|
|
minLength={8}
|
|
disabled={isSubmitting}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
|
{isSubmitting ? t('signup.creating') : t('signup.create')}
|
|
</button>
|
|
</form>
|
|
|
|
<p className={styles.footerText}>
|
|
{t('signup.hasAccount')}{' '}
|
|
<button
|
|
type="button"
|
|
className={styles.linkBtn}
|
|
onClick={() => switchView('login')}
|
|
disabled={isSubmitting}
|
|
>
|
|
{t('signup.signIn')}
|
|
</button>
|
|
</p>
|
|
</>
|
|
)}
|
|
|
|
{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'}
|
|
placeholder={t('forgot.newPasswordPlaceholder')}
|
|
value={newPassword}
|
|
onChange={(e) => setNewPassword(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}>
|
|
{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>
|
|
)
|
|
}
|