From aeaad0f645a6fe3b96645c7c20f57e8be335351a Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sat, 8 Aug 2026 23:29:55 +0330 Subject: [PATCH] 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 --- .../business/src/components/Header.module.css | 5 +- apps/business/src/i18n/messages.ts | 32 ++ apps/business/src/pages/LoginPage.module.css | 18 + apps/business/src/pages/LoginPage.tsx | 310 ++++++------------ apps/business/src/services/authService.ts | 22 +- apps/business/src/types/auth.ts | 5 - apps/customer/src/i18n/messages.ts | 40 ++- apps/customer/src/pages/LoginPage.module.css | 23 ++ apps/customer/src/pages/LoginPage.tsx | 162 ++++++++- .../src/pages/checkout/CheckoutLoginStep.tsx | 17 +- .../pages/checkout/CheckoutSteps.module.css | 5 + apps/customer/src/services/authService.ts | 4 +- .../src/pages/WebsitesPage.module.css | 7 + apps/super-admin/src/pages/WebsitesPage.tsx | 184 +++++++++-- .../super-admin/src/services/domainService.ts | 24 +- apps/super-admin/src/types/domain.ts | 14 + docs/PROJECT_CONTEXT.md | 2 +- .../components/PasswordResetModal.module.css | 5 + .../src/components/PasswordResetModal.tsx | 6 + .../PasswordStrengthMeter.module.css | 103 ++++++ .../src/components/PasswordStrengthMeter.tsx | 135 ++++++++ packages/dashboard-ui/src/index.ts | 9 + 22 files changed, 847 insertions(+), 285 deletions(-) create mode 100644 packages/dashboard-ui/src/components/PasswordStrengthMeter.module.css create mode 100644 packages/dashboard-ui/src/components/PasswordStrengthMeter.tsx diff --git a/apps/business/src/components/Header.module.css b/apps/business/src/components/Header.module.css index c959da9..646905b 100644 --- a/apps/business/src/components/Header.module.css +++ b/apps/business/src/components/Header.module.css @@ -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); diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index 1db0af9..41729ea 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -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 = { '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 = { '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': 'ورود یک‌بارمصرف', diff --git a/apps/business/src/pages/LoginPage.module.css b/apps/business/src/pages/LoginPage.module.css index 969236f..5577c73 100644 --- a/apps/business/src/pages/LoginPage.module.css +++ b/apps/business/src/pages/LoginPage.module.css @@ -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); diff --git a/apps/business/src/pages/LoginPage.tsx b/apps/business/src/pages/LoginPage.tsx index 62d351e..ec6278b 100644 --- a/apps/business/src/pages/LoginPage.tsx +++ b/apps/business/src/pages/LoginPage.tsx @@ -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() { setPassword(e.target.value)} @@ -309,144 +298,6 @@ export function LoginPage() { {t('login.otp')} - -

- {t('login.noAccount')}{' '} - -

- - )} - - {view === 'signup' && ( - <> -

{t('signup.title')}

-

{t('signup.subtitle', { domain: businessDomain })}

- -
- {error && ( -
- {error} -
- )} - -
-
- -
- - setFirstName(e.target.value)} - required - minLength={2} - disabled={isSubmitting} - /> -
-
-
- -
- - setLastName(e.target.value)} - required - minLength={2} - disabled={isSubmitting} - /> -
-
-
- -
- -
- - setPhone(e.target.value)} - required - disabled={isSubmitting} - /> -
-
- -
- -
- - setPassword(e.target.value)} - required - minLength={8} - disabled={isSubmitting} - /> - -
-
- -
- -
- - setConfirmPassword(e.target.value)} - required - minLength={8} - disabled={isSubmitting} - /> -
-
- - -
- -

- {t('signup.hasAccount')}{' '} - -

)} @@ -534,12 +385,61 @@ export function LoginPage() { setNewPassword(e.target.value)} required minLength={8} disabled={isSubmitting} + aria-describedby="forgot-password-strength" + /> + + + + + +
+ +
+ + setConfirmNewPassword(e.target.value)} + required + minLength={8} + disabled={isSubmitting} />
@@ -561,7 +461,11 @@ export function LoginPage() { )} - diff --git a/apps/business/src/services/authService.ts b/apps/business/src/services/authService.ts index 6f6cf6e..ed56adf 100644 --- a/apps/business/src/services/authService.ts +++ b/apps/business/src/services/authService.ts @@ -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('/auth/register', { - method: 'POST', - body: input, - }) - - setTokens(data.accessToken, data.refreshToken) - return data -} - export async function fetchCurrentUser(signal?: AbortSignal) { return apiRequest('/auth/me', { auth: true, signal }) } -export async function sendOtp(cellNumber: string) { +export async function sendOtp(cellNumber: string, domain?: string) { return apiRequest('/auth/send-otp', { method: 'POST', - body: { cellNumber }, + body: domain ? { cellNumber, domain } : { cellNumber }, }) } diff --git a/apps/business/src/types/auth.ts b/apps/business/src/types/auth.ts index d1298e1..a7c4ad9 100644 --- a/apps/business/src/types/auth.ts +++ b/apps/business/src/types/auth.ts @@ -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 diff --git a/apps/customer/src/i18n/messages.ts b/apps/customer/src/i18n/messages.ts index a08028c..7eaa915 100644 --- a/apps/customer/src/i18n/messages.ts +++ b/apps/customer/src/i18n/messages.ts @@ -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 = { 'app.dashboardName': 'پنل مشتری', - 'app.role.customer': 'مشتری', + 'app.role.customer': 'کاربر وبسایت', 'app.storeFallback': 'فروشگاه', 'app.poweredBy': 'قدرت‌گرفته از Meshkee.app', @@ -411,6 +429,8 @@ const fa: Record = { '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 = { 'signup.error.match': 'رمزهای عبور یکسان نیستند.', 'signup.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.', 'signup.error.create': 'ایجاد حساب ممکن نشد.', + 'signup.error.cellExists': + 'این شماره موبایل قبلاً ثبت شده است. لطفاً وارد شوید یا رمز عبور را بازیابی کنید.', 'signup.info.verify': 'حساب ایجاد شد. کد پیامکی را وارد کنید تا شماره تأیید شود و وارد شوید.', 'forgot.back': 'بازگشت به ورود', @@ -437,11 +459,25 @@ const fa: Record = { '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': 'ورود یک‌بارمصرف', diff --git a/apps/customer/src/pages/LoginPage.module.css b/apps/customer/src/pages/LoginPage.module.css index 6b88696..330bf58 100644 --- a/apps/customer/src/pages/LoginPage.module.css +++ b/apps/customer/src/pages/LoginPage.module.css @@ -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); diff --git a/apps/customer/src/pages/LoginPage.tsx b/apps/customer/src/pages/LoginPage.tsx index ef801f9..950942f 100644 --- a/apps/customer/src/pages/LoginPage.tsx +++ b/apps/customer/src/pages/LoginPage.tsx @@ -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() { setPassword(e.target.value)} @@ -341,7 +389,7 @@ export function LoginPage() {

- {t('login.noAccount')}{' '} + {t('login.noAccount')} +

@@ -452,6 +519,8 @@ export function LoginPage() { setConfirmPassword(e.target.value)} @@ -462,13 +531,17 @@ export function LoginPage() {
-

- {t('signup.hasAccount')}{' '} + {t('signup.hasAccount')} + + + + +

+ +
+ + setConfirmNewPassword(e.target.value)} + required + minLength={8} + disabled={isSubmitting} />
@@ -592,7 +714,11 @@ export function LoginPage() { )} - diff --git a/apps/customer/src/pages/checkout/CheckoutLoginStep.tsx b/apps/customer/src/pages/checkout/CheckoutLoginStep.tsx index ea25b14..0a77d17 100644 --- a/apps/customer/src/pages/checkout/CheckoutLoginStep.tsx +++ b/apps/customer/src/pages/checkout/CheckoutLoginStep.tsx @@ -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() { setPassword(e.target.value)} diff --git a/apps/customer/src/pages/checkout/CheckoutSteps.module.css b/apps/customer/src/pages/checkout/CheckoutSteps.module.css index 245382e..9c8aab3 100644 --- a/apps/customer/src/pages/checkout/CheckoutSteps.module.css +++ b/apps/customer/src/pages/checkout/CheckoutSteps.module.css @@ -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); diff --git a/apps/customer/src/services/authService.ts b/apps/customer/src/services/authService.ts index 92545ae..f0c8390 100644 --- a/apps/customer/src/services/authService.ts +++ b/apps/customer/src/services/authService.ts @@ -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('/auth/send-otp', { method: 'POST', - body: { cellNumber }, + body: domain ? { cellNumber, domain } : { cellNumber }, }) } diff --git a/apps/super-admin/src/pages/WebsitesPage.module.css b/apps/super-admin/src/pages/WebsitesPage.module.css index 7f1e44e..97c4db9 100644 --- a/apps/super-admin/src/pages/WebsitesPage.module.css +++ b/apps/super-admin/src/pages/WebsitesPage.module.css @@ -54,3 +54,10 @@ .deployFail { color: #b91c1c; } + +.headerActions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} diff --git a/apps/super-admin/src/pages/WebsitesPage.tsx b/apps/super-admin/src/pages/WebsitesPage.tsx index 842e97e..91da1d2 100644 --- a/apps/super-admin/src/pages/WebsitesPage.tsx +++ b/apps/super-admin/src/pages/WebsitesPage.tsx @@ -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(null) const [deployingId, setDeployingId] = useState(null) const [syncingSsl, setSyncingSsl] = useState(false) + const [issuingWebsiteSsl, setIssuingWebsiteSsl] = useState(false) + const [issuingSslId, setIssuingSslId] = useState(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.

- +
+ + +
@@ -569,16 +665,46 @@ export function WebsitesPage() { )} ) : null} - + {domain.deploySlug && !domain.sslEnabled ? ( + + ) : ( + + )}