mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
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>
This commit is contained in:
co-authored by
Cursor
parent
656fc57494
commit
aeaad0f645
@@ -83,9 +83,8 @@
|
||||
.profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-block: 6px;
|
||||
padding-inline: 6px 12px;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 50px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border: 1px solid var(--glass-border);
|
||||
|
||||
@@ -1046,6 +1046,8 @@ const en = {
|
||||
'login.signUp': 'Sign up',
|
||||
'login.error.signIn': 'Unable to sign in. Check your connection and try again.',
|
||||
'login.error.sendCode': 'Unable to send verification code.',
|
||||
'login.error.cellEnglishDigits': 'Please use English digits for the mobile number.',
|
||||
'login.error.smsProvider': 'SMS service is temporarily unavailable. Please try again later.',
|
||||
'login.error.access': 'You do not have access to this business dashboard.',
|
||||
|
||||
'signup.title': 'Create account',
|
||||
@@ -1072,11 +1074,25 @@ const en = {
|
||||
'forgot.code': 'SMS verification code',
|
||||
'forgot.newPassword': 'New password',
|
||||
'forgot.newPasswordPlaceholder': 'Enter new password',
|
||||
'forgot.confirm': 'Confirm password',
|
||||
'forgot.confirmPlaceholder': 'Repeat new password',
|
||||
'forgot.reset': 'Reset password',
|
||||
'forgot.verifying': 'Verifying...',
|
||||
'forgot.error.length': 'Password must be at least 8 characters.',
|
||||
'forgot.error.strength': 'Please choose a stronger password.',
|
||||
'forgot.error.match': 'Passwords do not match.',
|
||||
'forgot.error.verify': 'Unable to reset password. Check the code and try again.',
|
||||
'forgot.info.success': 'Password updated. You can sign in with your new password.',
|
||||
'password.strength': 'Password strength',
|
||||
'password.strength.empty': 'Enter a password',
|
||||
'password.strength.weak': 'Weak',
|
||||
'password.strength.fair': 'Fair',
|
||||
'password.strength.good': 'Good',
|
||||
'password.strength.strong': 'Strong',
|
||||
'password.rule.length': 'At least 8 characters',
|
||||
'password.rule.upper': 'One uppercase letter',
|
||||
'password.rule.number': 'One number',
|
||||
'password.rule.special': 'One special character',
|
||||
|
||||
'otp.back': 'Back to sign in',
|
||||
'otp.title': 'One-time login',
|
||||
@@ -2144,6 +2160,8 @@ const fa: Record<MessageKey, string> = {
|
||||
'login.signUp': 'ثبتنام',
|
||||
'login.error.signIn': 'ورود ممکن نشد. اتصال را بررسی کنید و دوباره تلاش کنید.',
|
||||
'login.error.sendCode': 'ارسال کد تأیید ممکن نشد.',
|
||||
'login.error.cellEnglishDigits': 'خواهشمندیم از زبان انگلیسی برای شماره همراه استفاده کنید',
|
||||
'login.error.smsProvider': 'سرویس پیامک موقتاً در دسترس نیست. لطفاً بعداً دوباره تلاش کنید.',
|
||||
'login.error.access': 'به این پنل کسبوکار دسترسی ندارید.',
|
||||
|
||||
'signup.title': 'ایجاد حساب',
|
||||
@@ -2170,11 +2188,25 @@ const fa: Record<MessageKey, string> = {
|
||||
'forgot.code': 'کد تأیید پیامکی',
|
||||
'forgot.newPassword': 'رمز عبور جدید',
|
||||
'forgot.newPasswordPlaceholder': 'رمز عبور جدید را وارد کنید',
|
||||
'forgot.confirm': 'تأیید رمز عبور',
|
||||
'forgot.confirmPlaceholder': 'رمز عبور جدید را تکرار کنید',
|
||||
'forgot.reset': 'بازنشانی رمز عبور',
|
||||
'forgot.verifying': 'در حال تأیید...',
|
||||
'forgot.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
|
||||
'forgot.error.strength': 'لطفاً رمز عبور قویتری انتخاب کنید.',
|
||||
'forgot.error.match': 'رمزهای عبور یکسان نیستند.',
|
||||
'forgot.error.verify': 'بازنشانی رمز ممکن نشد. کد را بررسی کنید و دوباره تلاش کنید.',
|
||||
'forgot.info.success': 'رمز عبور بهروز شد. میتوانید با رمز جدید وارد شوید.',
|
||||
'password.strength': 'قدرت رمز عبور',
|
||||
'password.strength.empty': 'رمز عبور را وارد کنید',
|
||||
'password.strength.weak': 'ضعیف',
|
||||
'password.strength.fair': 'متوسط',
|
||||
'password.strength.good': 'خوب',
|
||||
'password.strength.strong': 'قوی',
|
||||
'password.rule.length': 'حداقل ۸ کاراکتر',
|
||||
'password.rule.upper': 'یک حرف بزرگ انگلیسی',
|
||||
'password.rule.number': 'یک عدد',
|
||||
'password.rule.special': 'یک کاراکتر ویژه',
|
||||
|
||||
'otp.back': 'بازگشت به ورود',
|
||||
'otp.title': 'ورود یکبارمصرف',
|
||||
|
||||
@@ -104,6 +104,10 @@
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
:global([dir='rtl']) .backBtn svg {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -152,6 +156,11 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.inputWrap input[dir='ltr'] {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.inputWrap input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
@@ -203,6 +212,10 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
button.submitBtn {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.submitBtn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px rgba(var(--primary-rgb) / 0.4);
|
||||
@@ -247,6 +260,11 @@
|
||||
|
||||
.footerText {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } from 'lucide-react'
|
||||
import { LanguageSelect } from '@meshkee/dashboard-ui'
|
||||
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 { setActiveBusiness } from '../lib/businessContext'
|
||||
import {
|
||||
logout as logoutRequest,
|
||||
register,
|
||||
resetPassword,
|
||||
sendOtp,
|
||||
} from '../services/authService'
|
||||
import { resetPassword, sendOtp } from '../services/authService'
|
||||
import { useT } from '../i18n/useT'
|
||||
import styles from './LoginPage.module.css'
|
||||
|
||||
type AuthView = 'login' | 'signup' | 'forgot' | 'otp'
|
||||
type AuthView = 'login' | 'forgot' | 'otp'
|
||||
type SmsStep = 'phone' | 'code'
|
||||
|
||||
export function LoginPage() {
|
||||
@@ -32,11 +26,9 @@ export function LoginPage() {
|
||||
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('')
|
||||
@@ -51,11 +43,9 @@ export function LoginPage() {
|
||||
function resetForm() {
|
||||
setPhone('')
|
||||
setPassword('')
|
||||
setConfirmPassword('')
|
||||
setFirstName('')
|
||||
setLastName('')
|
||||
setSmsCode('')
|
||||
setNewPassword('')
|
||||
setConfirmNewPassword('')
|
||||
setSmsStep('phone')
|
||||
setCodeSent(false)
|
||||
setShowPassword(false)
|
||||
@@ -84,6 +74,18 @@ export function LoginPage() {
|
||||
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)
|
||||
}
|
||||
@@ -92,13 +94,28 @@ export function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
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 cellNumber = toE164CellNumber(phone)
|
||||
const result = await sendOtp(cellNumber)
|
||||
const result = await sendOtp(cellNumber, businessDomain)
|
||||
|
||||
if (!result.enabled) {
|
||||
setInfo(result.message)
|
||||
@@ -117,10 +134,12 @@ export function LoginPage() {
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
clearMessages()
|
||||
const cellNumber = resolveCellNumber()
|
||||
if (!cellNumber) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const cellNumber = toE164CellNumber(phone)
|
||||
await login(cellNumber, password)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
@@ -130,60 +149,26 @@ export function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
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: businessDomain,
|
||||
})
|
||||
|
||||
if (data.user.dashboard !== 'business' || data.user.businesses.length === 0) {
|
||||
logoutRequest()
|
||||
setError(t('login.error.access'))
|
||||
return
|
||||
}
|
||||
|
||||
setActiveBusiness(data.user)
|
||||
navigate('/')
|
||||
} 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'))
|
||||
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 {
|
||||
const cellNumber = toE164CellNumber(phone)
|
||||
await resetPassword(cellNumber, smsCode, newPassword)
|
||||
setInfo(t('forgot.info.success'))
|
||||
setTimeout(() => switchView('login'), 2000)
|
||||
@@ -197,10 +182,12 @@ export function LoginPage() {
|
||||
async function handleOtpLogin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
clearMessages()
|
||||
const cellNumber = resolveCellNumber()
|
||||
if (!cellNumber) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const cellNumber = toE164CellNumber(phone)
|
||||
await loginWithOtp(cellNumber, smsCode)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
@@ -261,6 +248,8 @@ export function LoginPage() {
|
||||
<input
|
||||
id="login-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
dir="ltr"
|
||||
lang="en"
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -309,144 +298,6 @@ export function LoginPage() {
|
||||
<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: businessDomain })}</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleSignup}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -534,12 +385,61 @@ export function LoginPage() {
|
||||
<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>
|
||||
@@ -561,7 +461,11 @@ export function LoginPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.submitBtn}
|
||||
disabled={isSubmitting || !isPasswordStrong(newPassword)}
|
||||
>
|
||||
{isSubmitting ? t('forgot.verifying') : t('forgot.reset')}
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
MeResponse,
|
||||
OtpSendResponse,
|
||||
OtpVerifyResponse,
|
||||
RegisterResponse,
|
||||
} from '../types/auth'
|
||||
|
||||
export async function login(cellNumber: string, password: string) {
|
||||
@@ -19,31 +18,14 @@ export async function login(cellNumber: string, password: string) {
|
||||
return data
|
||||
}
|
||||
|
||||
export async function register(input: {
|
||||
cellNumber: string
|
||||
password: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email?: string
|
||||
domain: string
|
||||
}) {
|
||||
const data = await apiRequest<RegisterResponse>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
})
|
||||
|
||||
setTokens(data.accessToken, data.refreshToken)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchCurrentUser(signal?: AbortSignal) {
|
||||
return apiRequest<MeResponse>('/auth/me', { auth: true, signal })
|
||||
}
|
||||
|
||||
export async function sendOtp(cellNumber: string) {
|
||||
export async function sendOtp(cellNumber: string, domain?: string) {
|
||||
return apiRequest<OtpSendResponse>('/auth/send-otp', {
|
||||
method: 'POST',
|
||||
body: { cellNumber },
|
||||
body: domain ? { cellNumber, domain } : { cellNumber },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -48,11 +48,6 @@ export interface MeResponse {
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
export interface RegisterResponse extends LoginResponse {
|
||||
smsEnabled?: boolean
|
||||
registeredBusiness?: { id: string; name: string; slug: string }
|
||||
}
|
||||
|
||||
export interface OtpSendResponse {
|
||||
enabled: boolean
|
||||
message: string
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { DashboardLocale } from '@meshkee/dashboard-core'
|
||||
|
||||
const en = {
|
||||
'app.dashboardName': 'Customer Dashboard',
|
||||
'app.role.customer': 'Customer',
|
||||
'app.role.customer': 'Website user',
|
||||
'app.storeFallback': 'Store',
|
||||
'app.poweredBy': 'powered by Meshkee.app',
|
||||
|
||||
@@ -180,6 +180,8 @@ const en = {
|
||||
'login.signUp': 'Sign up',
|
||||
'login.error.signIn': 'Unable to sign in. Check your connection and try again.',
|
||||
'login.error.sendCode': 'Unable to send verification code.',
|
||||
'login.error.cellEnglishDigits': 'Please use English digits for the mobile number.',
|
||||
'login.error.smsProvider': 'SMS service is temporarily unavailable. Please try again later.',
|
||||
|
||||
'signup.title': 'Create account',
|
||||
'signup.subtitle': 'Register as a customer of {domain}',
|
||||
@@ -195,6 +197,8 @@ const en = {
|
||||
'signup.error.match': 'Passwords do not match.',
|
||||
'signup.error.length': 'Password must be at least 8 characters.',
|
||||
'signup.error.create': 'Unable to create account.',
|
||||
'signup.error.cellExists':
|
||||
'This mobile number is already registered. Please sign in or reset your password.',
|
||||
'signup.info.verify': 'Account created. Enter the SMS code to verify your number and sign in.',
|
||||
|
||||
'forgot.back': 'Back to sign in',
|
||||
@@ -206,11 +210,25 @@ const en = {
|
||||
'forgot.code': 'SMS verification code',
|
||||
'forgot.newPassword': 'New password',
|
||||
'forgot.newPasswordPlaceholder': 'Enter new password',
|
||||
'forgot.confirm': 'Confirm password',
|
||||
'forgot.confirmPlaceholder': 'Repeat new password',
|
||||
'forgot.reset': 'Reset password',
|
||||
'forgot.verifying': 'Verifying...',
|
||||
'forgot.error.length': 'Password must be at least 8 characters.',
|
||||
'forgot.error.strength': 'Please choose a stronger password.',
|
||||
'forgot.error.match': 'Passwords do not match.',
|
||||
'forgot.error.verify': 'Unable to reset password. Check the code and try again.',
|
||||
'forgot.info.success': 'Password updated. You can sign in with your new password.',
|
||||
'password.strength': 'Password strength',
|
||||
'password.strength.empty': 'Enter a password',
|
||||
'password.strength.weak': 'Weak',
|
||||
'password.strength.fair': 'Fair',
|
||||
'password.strength.good': 'Good',
|
||||
'password.strength.strong': 'Strong',
|
||||
'password.rule.length': 'At least 8 characters',
|
||||
'password.rule.upper': 'One uppercase letter',
|
||||
'password.rule.number': 'One number',
|
||||
'password.rule.special': 'One special character',
|
||||
|
||||
'otp.back': 'Back to sign in',
|
||||
'otp.title': 'One-time login',
|
||||
@@ -233,7 +251,7 @@ type MessageKey = keyof typeof en
|
||||
|
||||
const fa: Record<MessageKey, string> = {
|
||||
'app.dashboardName': 'پنل مشتری',
|
||||
'app.role.customer': 'مشتری',
|
||||
'app.role.customer': 'کاربر وبسایت',
|
||||
'app.storeFallback': 'فروشگاه',
|
||||
'app.poweredBy': 'قدرتگرفته از Meshkee.app',
|
||||
|
||||
@@ -411,6 +429,8 @@ const fa: Record<MessageKey, string> = {
|
||||
'login.signUp': 'ثبتنام',
|
||||
'login.error.signIn': 'ورود ممکن نشد. اتصال خود را بررسی کنید و دوباره تلاش کنید.',
|
||||
'login.error.sendCode': 'ارسال کد تأیید ممکن نشد.',
|
||||
'login.error.cellEnglishDigits': 'خواهشمندیم از زبان انگلیسی برای شماره همراه استفاده کنید',
|
||||
'login.error.smsProvider': 'سرویس پیامک موقتاً در دسترس نیست. لطفاً بعداً دوباره تلاش کنید.',
|
||||
|
||||
'signup.title': 'ایجاد حساب',
|
||||
'signup.subtitle': 'بهعنوان مشتری {domain} ثبتنام کنید',
|
||||
@@ -426,6 +446,8 @@ const fa: Record<MessageKey, string> = {
|
||||
'signup.error.match': 'رمزهای عبور یکسان نیستند.',
|
||||
'signup.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
|
||||
'signup.error.create': 'ایجاد حساب ممکن نشد.',
|
||||
'signup.error.cellExists':
|
||||
'این شماره موبایل قبلاً ثبت شده است. لطفاً وارد شوید یا رمز عبور را بازیابی کنید.',
|
||||
'signup.info.verify': 'حساب ایجاد شد. کد پیامکی را وارد کنید تا شماره تأیید شود و وارد شوید.',
|
||||
|
||||
'forgot.back': 'بازگشت به ورود',
|
||||
@@ -437,11 +459,25 @@ const fa: Record<MessageKey, string> = {
|
||||
'forgot.code': 'کد تأیید پیامکی',
|
||||
'forgot.newPassword': 'رمز عبور جدید',
|
||||
'forgot.newPasswordPlaceholder': 'رمز عبور جدید را وارد کنید',
|
||||
'forgot.confirm': 'تأیید رمز عبور',
|
||||
'forgot.confirmPlaceholder': 'رمز عبور جدید را تکرار کنید',
|
||||
'forgot.reset': 'بازیابی رمز عبور',
|
||||
'forgot.verifying': 'در حال تأیید...',
|
||||
'forgot.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
|
||||
'forgot.error.strength': 'لطفاً رمز عبور قویتری انتخاب کنید.',
|
||||
'forgot.error.match': 'رمزهای عبور یکسان نیستند.',
|
||||
'forgot.error.verify': 'بازنشانی رمز ممکن نشد. کد را بررسی کنید و دوباره تلاش کنید.',
|
||||
'forgot.info.success': 'رمز عبور بهروز شد. میتوانید با رمز جدید وارد شوید.',
|
||||
'password.strength': 'قدرت رمز عبور',
|
||||
'password.strength.empty': 'رمز عبور را وارد کنید',
|
||||
'password.strength.weak': 'ضعیف',
|
||||
'password.strength.fair': 'متوسط',
|
||||
'password.strength.good': 'خوب',
|
||||
'password.strength.strong': 'قوی',
|
||||
'password.rule.length': 'حداقل ۸ کاراکتر',
|
||||
'password.rule.upper': 'یک حرف بزرگ انگلیسی',
|
||||
'password.rule.number': 'یک عدد',
|
||||
'password.rule.special': 'یک کاراکتر ویژه',
|
||||
|
||||
'otp.back': 'بازگشت به ورود',
|
||||
'otp.title': 'ورود یکبارمصرف',
|
||||
|
||||
@@ -104,6 +104,10 @@
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
:global([dir='rtl']) .backBtn svg {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -152,6 +156,11 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.inputWrap input[dir='ltr'] {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.inputWrap input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
@@ -195,6 +204,7 @@
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35);
|
||||
@@ -202,6 +212,10 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
button.submitBtn {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.submitBtn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px rgba(var(--primary-rgb) / 0.4);
|
||||
@@ -246,11 +260,20 @@
|
||||
|
||||
.footerText {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
p.footerText {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.codeHint {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
resetPassword,
|
||||
sendOtp,
|
||||
} from '../services/authService'
|
||||
import { LanguageSelect } from '@meshkee/dashboard-ui'
|
||||
import { LanguageSelect, PasswordStrengthMeter, isPasswordStrong } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import meshkeeLogo from '../assets/meshkee-logo.png'
|
||||
import styles from './LoginPage.module.css'
|
||||
@@ -49,6 +49,7 @@ export function LoginPage() {
|
||||
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('')
|
||||
@@ -68,6 +69,7 @@ export function LoginPage() {
|
||||
setLastName('')
|
||||
setSmsCode('')
|
||||
setNewPassword('')
|
||||
setConfirmNewPassword('')
|
||||
setSmsStep('phone')
|
||||
setCodeSent(false)
|
||||
setShowPassword(false)
|
||||
@@ -94,19 +96,50 @@ export function LoginPage() {
|
||||
|
||||
function handleApiError(err: unknown, fallback: string) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
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('Cell number exists on another account')) {
|
||||
setError(t('signup.error.cellExists'))
|
||||
} 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 cellNumber = toE164CellNumber(phone)
|
||||
const result = await sendOtp(cellNumber)
|
||||
const result = await sendOtp(cellNumber, tenantDomain)
|
||||
|
||||
if (!result.enabled) {
|
||||
setInfo(result.message)
|
||||
@@ -125,10 +158,12 @@ export function LoginPage() {
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
clearMessages()
|
||||
const cellNumber = resolveCellNumber()
|
||||
if (!cellNumber) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const cellNumber = toE164CellNumber(phone)
|
||||
await login(cellNumber, password)
|
||||
navigate(redirectTo)
|
||||
} catch (err) {
|
||||
@@ -147,15 +182,17 @@ export function LoginPage() {
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError(t('signup.error.length'))
|
||||
if (!isPasswordStrong(password)) {
|
||||
setError(t('forgot.error.strength'))
|
||||
return
|
||||
}
|
||||
|
||||
const cellNumber = resolveCellNumber()
|
||||
if (!cellNumber) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const cellNumber = toE164CellNumber(phone)
|
||||
const data = await register({
|
||||
cellNumber,
|
||||
password,
|
||||
@@ -179,7 +216,7 @@ export function LoginPage() {
|
||||
setSmsCode('')
|
||||
setInfo(t('signup.info.verify'))
|
||||
try {
|
||||
const otpResult = await sendOtp(cellNumber)
|
||||
const otpResult = await sendOtp(cellNumber, tenantDomain)
|
||||
if (!otpResult.enabled) {
|
||||
setInfo(otpResult.message)
|
||||
}
|
||||
@@ -205,15 +242,22 @@ export function LoginPage() {
|
||||
e.preventDefault()
|
||||
clearMessages()
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError(t('forgot.error.length'))
|
||||
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 {
|
||||
const cellNumber = toE164CellNumber(phone)
|
||||
await resetPassword(cellNumber, smsCode, newPassword)
|
||||
setInfo(t('forgot.info.success'))
|
||||
setTimeout(() => switchView('login'), 2000)
|
||||
@@ -227,10 +271,12 @@ export function LoginPage() {
|
||||
async function handleOtpLogin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
clearMessages()
|
||||
const cellNumber = resolveCellNumber()
|
||||
if (!cellNumber) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const cellNumber = toE164CellNumber(phone)
|
||||
await loginWithOtp(cellNumber, smsCode)
|
||||
navigate(redirectTo)
|
||||
} catch (err) {
|
||||
@@ -291,6 +337,8 @@ export function LoginPage() {
|
||||
<input
|
||||
id="login-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
dir="ltr"
|
||||
lang="en"
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -341,7 +389,7 @@ export function LoginPage() {
|
||||
</button>
|
||||
|
||||
<p className={styles.footerText}>
|
||||
{t('login.noAccount')}{' '}
|
||||
<span>{t('login.noAccount')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkBtn}
|
||||
@@ -357,7 +405,6 @@ export function LoginPage() {
|
||||
{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 && (
|
||||
@@ -426,6 +473,8 @@ export function LoginPage() {
|
||||
<input
|
||||
id="signup-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
dir="ltr"
|
||||
lang="en"
|
||||
placeholder={t('signup.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -443,6 +492,24 @@ export function LoginPage() {
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<PasswordStrengthMeter
|
||||
password={password}
|
||||
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'),
|
||||
special: t('password.rule.special'),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
@@ -452,6 +519,8 @@ export function LoginPage() {
|
||||
<input
|
||||
id="signup-confirm"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
dir="ltr"
|
||||
lang="en"
|
||||
placeholder={t('signup.confirmPlaceholder')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
@@ -462,13 +531,17 @@ export function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.submitBtn}
|
||||
disabled={isSubmitting || !isPasswordStrong(password)}
|
||||
>
|
||||
{isSubmitting ? t('signup.creating') : t('signup.create')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className={styles.footerText}>
|
||||
{t('signup.hasAccount')}{' '}
|
||||
<span>{t('signup.hasAccount')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkBtn}
|
||||
@@ -567,12 +640,61 @@ export function LoginPage() {
|
||||
<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>
|
||||
@@ -592,7 +714,11 @@ export function LoginPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.submitBtn}
|
||||
disabled={isSubmitting || !isPasswordStrong(newPassword)}
|
||||
>
|
||||
{isSubmitting ? t('forgot.verifying') : t('forgot.reset')}
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -36,7 +36,15 @@ export function CheckoutLoginStep() {
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
if (/[۰-۹٠-٩]/.test(phone)) {
|
||||
setError('خواهشمندیم از زبان انگلیسی برای شماره همراه استفاده کنید')
|
||||
return
|
||||
}
|
||||
const cellNumber = toE164CellNumber(phone)
|
||||
if (!/^\+[1-9]\d{6,14}$/.test(cellNumber)) {
|
||||
setError('خواهشمندیم از زبان انگلیسی برای شماره همراه استفاده کنید')
|
||||
return
|
||||
}
|
||||
await login(cellNumber, password)
|
||||
try {
|
||||
await syncGuestCartToServer()
|
||||
@@ -45,7 +53,12 @@ export function CheckoutLoginStep() {
|
||||
}
|
||||
navigate(resolveRedirectPath(), { replace: true })
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'ورود ممکن نشد.')
|
||||
const message = err instanceof ApiError ? err.message : 'ورود ممکن نشد.'
|
||||
setError(
|
||||
message.includes('E.164') || message.includes('cellNumber must be')
|
||||
? 'خواهشمندیم از زبان انگلیسی برای شماره همراه استفاده کنید'
|
||||
: message,
|
||||
)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -92,6 +105,8 @@ export function CheckoutLoginStep() {
|
||||
<input
|
||||
id="checkout-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
dir="ltr"
|
||||
lang="en"
|
||||
placeholder="رمز عبور خود را وارد کنید"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
|
||||
@@ -772,6 +772,11 @@
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.inputWrap input[dir='ltr'] {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.inputWrap input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
|
||||
@@ -65,10 +65,10 @@ export async function changePassword(currentPassword: string, newPassword: strin
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendOtp(cellNumber: string) {
|
||||
export async function sendOtp(cellNumber: string, domain?: string) {
|
||||
return apiRequest<OtpSendResponse>('/auth/send-otp', {
|
||||
method: 'POST',
|
||||
body: { cellNumber },
|
||||
body: domain ? { cellNumber, domain } : { cellNumber },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -54,3 +54,10 @@
|
||||
.deployFail {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Rocket,
|
||||
RotateCcw,
|
||||
Search,
|
||||
ShieldPlus,
|
||||
Unlock,
|
||||
} from 'lucide-react'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
@@ -20,6 +21,8 @@ import type { ListDomainsParams } from '../services/domainService'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
deployDomain,
|
||||
issueDomainSsl,
|
||||
issueWebsiteSsl,
|
||||
listDomains,
|
||||
removeDomain,
|
||||
setDomainActive,
|
||||
@@ -105,6 +108,8 @@ export function WebsitesPage() {
|
||||
const [togglingSslId, setTogglingSslId] = useState<number | null>(null)
|
||||
const [deployingId, setDeployingId] = useState<DomainListItem['id'] | null>(null)
|
||||
const [syncingSsl, setSyncingSsl] = useState(false)
|
||||
const [issuingWebsiteSsl, setIssuingWebsiteSsl] = useState(false)
|
||||
const [issuingSslId, setIssuingSslId] = useState<DomainListItem['id'] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
@@ -264,17 +269,17 @@ export function WebsitesPage() {
|
||||
}
|
||||
|
||||
async function handleSyncSsl() {
|
||||
if (syncingSsl) return
|
||||
if (syncingSsl || issuingWebsiteSsl) return
|
||||
|
||||
flushSync(() => {
|
||||
setSyncingSsl(true)
|
||||
})
|
||||
setError('')
|
||||
showToast('Starting SSL sync…', 'info')
|
||||
showToast('Starting dashboard SSL sync…', 'info')
|
||||
|
||||
try {
|
||||
const result = await syncSsl()
|
||||
showToast(result.message || 'SSL sync started.', 'success')
|
||||
showToast(result.message || 'Dashboard SSL sync started.', 'success')
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError ? err.message : 'Unable to start SSL sync.'
|
||||
setError(message)
|
||||
@@ -284,6 +289,77 @@ export function WebsitesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIssueWebsiteSsl() {
|
||||
if (issuingWebsiteSsl || syncingSsl) return
|
||||
|
||||
flushSync(() => {
|
||||
setIssuingWebsiteSsl(true)
|
||||
})
|
||||
setError('')
|
||||
showToast('Issuing SSL for storefront domains…', 'info')
|
||||
|
||||
try {
|
||||
const result = await issueWebsiteSsl()
|
||||
if (result.failed.length > 0) {
|
||||
const detail = result.failed
|
||||
.map((f) => `${f.host}: ${f.error}`)
|
||||
.join(' · ')
|
||||
setError(detail)
|
||||
showToast(result.message, result.issued.length > 0 ? 'info' : 'error')
|
||||
} else {
|
||||
showToast(result.message || 'Website SSL updated.', 'success')
|
||||
}
|
||||
if (result.issued.length > 0) {
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
const issued = new Set(result.issued)
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
issued.has(item.host) ? { ...item, sslEnabled: true } : item,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError ? err.message : 'Unable to issue website SSL.'
|
||||
setError(message)
|
||||
showToast(message, 'error')
|
||||
} finally {
|
||||
setIssuingWebsiteSsl(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIssueDomainSsl(domain: DomainListItem) {
|
||||
if (!domain.deploySlug || issuingSslId != null) return
|
||||
|
||||
flushSync(() => {
|
||||
setIssuingSslId(domain.id)
|
||||
})
|
||||
setError('')
|
||||
showToast(`Issuing SSL for "${domain.host}"…`, 'info')
|
||||
|
||||
try {
|
||||
const result = await issueDomainSsl(domain.id)
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === domain.id ? { ...item, sslEnabled: result.sslEnabled } : item,
|
||||
),
|
||||
}
|
||||
})
|
||||
showToast(result.message || `SSL issued for "${domain.host}".`, 'success')
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError ? err.message : 'Unable to issue SSL.'
|
||||
setError(message)
|
||||
showToast(message, 'error')
|
||||
} finally {
|
||||
setIssuingSslId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeploy(domain: DomainListItem) {
|
||||
if (!domain.deploySlug) return
|
||||
if (deployingId != null) return
|
||||
@@ -366,22 +442,42 @@ export function WebsitesPage() {
|
||||
Monitor and manage all domains across the platform.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnPrimary} ${
|
||||
syncingSsl ? styles.deployBusy : ''
|
||||
}`}
|
||||
onClick={() => void handleSyncSsl()}
|
||||
disabled={syncingSsl}
|
||||
aria-busy={syncingSsl}
|
||||
>
|
||||
{syncingSsl ? (
|
||||
<Loader2 size={16} className={styles.spin} aria-hidden />
|
||||
) : (
|
||||
<RefreshCw size={16} aria-hidden />
|
||||
)}
|
||||
{syncingSsl ? 'Syncing SSL…' : 'Sync SSL now'}
|
||||
</button>
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnGhost} ${
|
||||
syncingSsl ? styles.deployBusy : ''
|
||||
}`}
|
||||
onClick={() => void handleSyncSsl()}
|
||||
disabled={syncingSsl || issuingWebsiteSsl}
|
||||
aria-busy={syncingSsl}
|
||||
title="Expand Let's Encrypt cert for manage / business.* / customer.* dashboards"
|
||||
>
|
||||
{syncingSsl ? (
|
||||
<Loader2 size={16} className={styles.spin} aria-hidden />
|
||||
) : (
|
||||
<RefreshCw size={16} aria-hidden />
|
||||
)}
|
||||
{syncingSsl ? 'Syncing dashboard SSL…' : 'Sync dashboard SSL'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.btn} ${tableStyles.btnPrimary} ${
|
||||
issuingWebsiteSsl ? styles.deployBusy : ''
|
||||
}`}
|
||||
onClick={() => void handleIssueWebsiteSsl()}
|
||||
disabled={issuingWebsiteSsl || syncingSsl}
|
||||
aria-busy={issuingWebsiteSsl}
|
||||
title="Issue Let's Encrypt certs for storefront domains missing SSL"
|
||||
>
|
||||
{issuingWebsiteSsl ? (
|
||||
<Loader2 size={16} className={styles.spin} aria-hidden />
|
||||
) : (
|
||||
<ShieldPlus size={16} aria-hidden />
|
||||
)}
|
||||
{issuingWebsiteSsl ? 'Issuing website SSL…' : 'Issue website SSL'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={tableStyles.filtersPanel}>
|
||||
@@ -569,16 +665,46 @@ export function WebsitesPage() {
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.controlBtn}
|
||||
onClick={() => void handleToggleSsl(domain, !domain.sslEnabled)}
|
||||
disabled={togglingSslId === domain.id}
|
||||
title={domain.sslEnabled ? 'Disable SSL' : 'Enable SSL'}
|
||||
aria-label={domain.sslEnabled ? 'Disable SSL' : 'Enable SSL'}
|
||||
>
|
||||
{domain.sslEnabled ? <Lock size={16} /> : <Unlock size={16} />}
|
||||
</button>
|
||||
{domain.deploySlug && !domain.sslEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.controlBtn} ${
|
||||
issuingSslId === domain.id ? styles.deployBusy : ''
|
||||
}`}
|
||||
onClick={() => void handleIssueDomainSsl(domain)}
|
||||
disabled={issuingSslId === domain.id || issuingWebsiteSsl}
|
||||
title={
|
||||
issuingSslId === domain.id
|
||||
? 'Issuing SSL…'
|
||||
: 'Issue SSL certificate'
|
||||
}
|
||||
aria-label={
|
||||
issuingSslId === domain.id
|
||||
? 'Issuing SSL'
|
||||
: 'Issue SSL certificate'
|
||||
}
|
||||
aria-busy={issuingSslId === domain.id}
|
||||
>
|
||||
{issuingSslId === domain.id ? (
|
||||
<Loader2 size={16} className={styles.spin} aria-hidden />
|
||||
) : (
|
||||
<ShieldPlus size={16} />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.controlBtn}
|
||||
onClick={() => void handleToggleSsl(domain, !domain.sslEnabled)}
|
||||
disabled={togglingSslId === domain.id}
|
||||
title={domain.sslEnabled ? 'Mark SSL invalid' : 'Mark SSL valid'}
|
||||
aria-label={
|
||||
domain.sslEnabled ? 'Mark SSL invalid' : 'Mark SSL valid'
|
||||
}
|
||||
>
|
||||
{domain.sslEnabled ? <Lock size={16} /> : <Unlock size={16} />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.controlBtn}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { apiRequest } from '../lib/api'
|
||||
import type { DeployDomainResponse, DomainsListResponse, SyncSslResponse } from '../types/domain'
|
||||
import type {
|
||||
DeployDomainResponse,
|
||||
DomainsListResponse,
|
||||
IssueDomainSslResponse,
|
||||
IssueWebsiteSslResponse,
|
||||
SyncSslResponse,
|
||||
} from '../types/domain'
|
||||
|
||||
export interface ListDomainsParams {
|
||||
page?: number
|
||||
@@ -67,3 +73,19 @@ export async function syncSsl() {
|
||||
auth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** Batch-issue Let's Encrypt for storefronts missing SSL (websites VM). */
|
||||
export async function issueWebsiteSsl() {
|
||||
return apiRequest<IssueWebsiteSslResponse>('/domains/website-ssl', {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** Issue Let's Encrypt for one storefront domain. */
|
||||
export async function issueDomainSsl(domainId: number | string) {
|
||||
return apiRequest<IssueDomainSslResponse>(`/domains/${domainId}/issue-ssl`, {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,3 +35,17 @@ export interface SyncSslResponse {
|
||||
status: 'accepted'
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface IssueWebsiteSslResponse {
|
||||
status: 'ok'
|
||||
message: string
|
||||
issued: string[]
|
||||
failed: Array<{ host: string; error: string }>
|
||||
}
|
||||
|
||||
export interface IssueDomainSslResponse {
|
||||
status: 'issued'
|
||||
host: string
|
||||
sslEnabled: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user