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:
Alireza Hassani
2026-08-08 23:29:55 +03:30
co-authored by Cursor
parent 656fc57494
commit aeaad0f645
22 changed files with 847 additions and 285 deletions
@@ -83,9 +83,8 @@
.profile { .profile {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 8px;
padding-block: 6px; padding: 8px 14px;
padding-inline: 6px 12px;
border-radius: 50px; border-radius: 50px;
background: rgba(255, 255, 255, 0.5); background: rgba(255, 255, 255, 0.5);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
+32
View File
@@ -1046,6 +1046,8 @@ const en = {
'login.signUp': 'Sign up', 'login.signUp': 'Sign up',
'login.error.signIn': 'Unable to sign in. Check your connection and try again.', 'login.error.signIn': 'Unable to sign in. Check your connection and try again.',
'login.error.sendCode': 'Unable to send verification code.', '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.', 'login.error.access': 'You do not have access to this business dashboard.',
'signup.title': 'Create account', 'signup.title': 'Create account',
@@ -1072,11 +1074,25 @@ const en = {
'forgot.code': 'SMS verification code', 'forgot.code': 'SMS verification code',
'forgot.newPassword': 'New password', 'forgot.newPassword': 'New password',
'forgot.newPasswordPlaceholder': 'Enter new password', 'forgot.newPasswordPlaceholder': 'Enter new password',
'forgot.confirm': 'Confirm password',
'forgot.confirmPlaceholder': 'Repeat new password',
'forgot.reset': 'Reset password', 'forgot.reset': 'Reset password',
'forgot.verifying': 'Verifying...', 'forgot.verifying': 'Verifying...',
'forgot.error.length': 'Password must be at least 8 characters.', '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.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.', '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.back': 'Back to sign in',
'otp.title': 'One-time login', 'otp.title': 'One-time login',
@@ -2144,6 +2160,8 @@ const fa: Record<MessageKey, string> = {
'login.signUp': 'ثبت‌نام', 'login.signUp': 'ثبت‌نام',
'login.error.signIn': 'ورود ممکن نشد. اتصال را بررسی کنید و دوباره تلاش کنید.', 'login.error.signIn': 'ورود ممکن نشد. اتصال را بررسی کنید و دوباره تلاش کنید.',
'login.error.sendCode': 'ارسال کد تأیید ممکن نشد.', 'login.error.sendCode': 'ارسال کد تأیید ممکن نشد.',
'login.error.cellEnglishDigits': 'خواهشمندیم از زبان انگلیسی برای شماره همراه استفاده کنید',
'login.error.smsProvider': 'سرویس پیامک موقتاً در دسترس نیست. لطفاً بعداً دوباره تلاش کنید.',
'login.error.access': 'به این پنل کسب‌وکار دسترسی ندارید.', 'login.error.access': 'به این پنل کسب‌وکار دسترسی ندارید.',
'signup.title': 'ایجاد حساب', 'signup.title': 'ایجاد حساب',
@@ -2170,11 +2188,25 @@ const fa: Record<MessageKey, string> = {
'forgot.code': 'کد تأیید پیامکی', 'forgot.code': 'کد تأیید پیامکی',
'forgot.newPassword': 'رمز عبور جدید', 'forgot.newPassword': 'رمز عبور جدید',
'forgot.newPasswordPlaceholder': 'رمز عبور جدید را وارد کنید', 'forgot.newPasswordPlaceholder': 'رمز عبور جدید را وارد کنید',
'forgot.confirm': 'تأیید رمز عبور',
'forgot.confirmPlaceholder': 'رمز عبور جدید را تکرار کنید',
'forgot.reset': 'بازنشانی رمز عبور', 'forgot.reset': 'بازنشانی رمز عبور',
'forgot.verifying': 'در حال تأیید...', 'forgot.verifying': 'در حال تأیید...',
'forgot.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.', 'forgot.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
'forgot.error.strength': 'لطفاً رمز عبور قوی‌تری انتخاب کنید.',
'forgot.error.match': 'رمزهای عبور یکسان نیستند.',
'forgot.error.verify': 'بازنشانی رمز ممکن نشد. کد را بررسی کنید و دوباره تلاش کنید.', 'forgot.error.verify': 'بازنشانی رمز ممکن نشد. کد را بررسی کنید و دوباره تلاش کنید.',
'forgot.info.success': 'رمز عبور به‌روز شد. می‌توانید با رمز جدید وارد شوید.', '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.back': 'بازگشت به ورود',
'otp.title': 'ورود یک‌بارمصرف', 'otp.title': 'ورود یک‌بارمصرف',
@@ -104,6 +104,10 @@
color: var(--primary); color: var(--primary);
} }
:global([dir='rtl']) .backBtn svg {
transform: scaleX(-1);
}
.form { .form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -152,6 +156,11 @@
box-sizing: border-box; box-sizing: border-box;
} }
.inputWrap input[dir='ltr'] {
direction: ltr;
text-align: left;
}
.inputWrap input:focus { .inputWrap input:focus {
border-color: var(--primary); border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12); box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
@@ -203,6 +212,10 @@
margin-top: 4px; margin-top: 4px;
} }
button.submitBtn {
text-align: center;
}
.submitBtn:hover { .submitBtn:hover {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(var(--primary-rgb) / 0.4); box-shadow: 0 6px 18px rgba(var(--primary-rgb) / 0.4);
@@ -247,6 +260,11 @@
.footerText { .footerText {
margin-top: 24px; margin-top: 24px;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 4px;
text-align: center; text-align: center;
font-size: 14px; font-size: 14px;
color: var(--text-secondary); color: var(--text-secondary);
+107 -203
View File
@@ -1,23 +1,17 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } from 'lucide-react' import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft } from 'lucide-react'
import { LanguageSelect } from '@meshkee/dashboard-ui' import { LanguageSelect, PasswordStrengthMeter, isPasswordStrong } from '@meshkee/dashboard-ui'
import { useAuth, BUSINESS_ACCESS_MESSAGE } from '../context/AuthContext' import { useAuth, BUSINESS_ACCESS_MESSAGE } from '../context/AuthContext'
import { useTenantBranding } from '../context/TenantBrandingContext' import { useTenantBranding } from '../context/TenantBrandingContext'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { toE164CellNumber } from '../lib/cellNumber' import { toE164CellNumber } from '../lib/cellNumber'
import { getBusinessDomain } from '../lib/config' import { getBusinessDomain } from '../lib/config'
import { setActiveBusiness } from '../lib/businessContext' import { resetPassword, sendOtp } from '../services/authService'
import {
logout as logoutRequest,
register,
resetPassword,
sendOtp,
} from '../services/authService'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import styles from './LoginPage.module.css' import styles from './LoginPage.module.css'
type AuthView = 'login' | 'signup' | 'forgot' | 'otp' type AuthView = 'login' | 'forgot' | 'otp'
type SmsStep = 'phone' | 'code' type SmsStep = 'phone' | 'code'
export function LoginPage() { export function LoginPage() {
@@ -32,11 +26,9 @@ export function LoginPage() {
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [phone, setPhone] = useState('') const [phone, setPhone] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [smsCode, setSmsCode] = useState('') const [smsCode, setSmsCode] = useState('')
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
const [confirmNewPassword, setConfirmNewPassword] = useState('')
const [codeSent, setCodeSent] = useState(false) const [codeSent, setCodeSent] = useState(false)
const [countdown, setCountdown] = useState(0) const [countdown, setCountdown] = useState(0)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -51,11 +43,9 @@ export function LoginPage() {
function resetForm() { function resetForm() {
setPhone('') setPhone('')
setPassword('') setPassword('')
setConfirmPassword('')
setFirstName('')
setLastName('')
setSmsCode('') setSmsCode('')
setNewPassword('') setNewPassword('')
setConfirmNewPassword('')
setSmsStep('phone') setSmsStep('phone')
setCodeSent(false) setCodeSent(false)
setShowPassword(false) setShowPassword(false)
@@ -84,6 +74,18 @@ export function LoginPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
if (err.message === BUSINESS_ACCESS_MESSAGE) { if (err.message === BUSINESS_ACCESS_MESSAGE) {
setError(t('login.error.access')) 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 { } else {
setError(err.message) 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() { async function handleSendCode() {
clearMessages() clearMessages()
const cellNumber = resolveCellNumber()
if (!cellNumber) return
setIsSubmitting(true) setIsSubmitting(true)
try { try {
const cellNumber = toE164CellNumber(phone) const result = await sendOtp(cellNumber, businessDomain)
const result = await sendOtp(cellNumber)
if (!result.enabled) { if (!result.enabled) {
setInfo(result.message) setInfo(result.message)
@@ -117,10 +134,12 @@ export function LoginPage() {
async function handleLogin(e: React.FormEvent) { async function handleLogin(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
clearMessages() clearMessages()
const cellNumber = resolveCellNumber()
if (!cellNumber) return
setIsSubmitting(true) setIsSubmitting(true)
try { try {
const cellNumber = toE164CellNumber(phone)
await login(cellNumber, password) await login(cellNumber, password)
navigate('/') navigate('/')
} catch (err) { } 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) { async function handleResetPassword(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
clearMessages() clearMessages()
if (newPassword.length < 8) { if (!isPasswordStrong(newPassword)) {
setError(t('forgot.error.length')) setError(t('forgot.error.strength'))
return return
} }
if (newPassword !== confirmNewPassword) {
setError(t('forgot.error.match'))
return
}
const cellNumber = resolveCellNumber()
if (!cellNumber) return
setIsSubmitting(true) setIsSubmitting(true)
try { try {
const cellNumber = toE164CellNumber(phone)
await resetPassword(cellNumber, smsCode, newPassword) await resetPassword(cellNumber, smsCode, newPassword)
setInfo(t('forgot.info.success')) setInfo(t('forgot.info.success'))
setTimeout(() => switchView('login'), 2000) setTimeout(() => switchView('login'), 2000)
@@ -197,10 +182,12 @@ export function LoginPage() {
async function handleOtpLogin(e: React.FormEvent) { async function handleOtpLogin(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
clearMessages() clearMessages()
const cellNumber = resolveCellNumber()
if (!cellNumber) return
setIsSubmitting(true) setIsSubmitting(true)
try { try {
const cellNumber = toE164CellNumber(phone)
await loginWithOtp(cellNumber, smsCode) await loginWithOtp(cellNumber, smsCode)
navigate('/') navigate('/')
} catch (err) { } catch (err) {
@@ -261,6 +248,8 @@ export function LoginPage() {
<input <input
id="login-password" id="login-password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
dir="ltr"
lang="en"
placeholder={t('login.passwordPlaceholder')} placeholder={t('login.passwordPlaceholder')}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
@@ -309,144 +298,6 @@ export function LoginPage() {
<KeyRound size={18} /> <KeyRound size={18} />
{t('login.otp')} {t('login.otp')}
</button> </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 <input
id="forgot-new-password" id="forgot-new-password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
dir="ltr"
lang="en"
placeholder={t('forgot.newPasswordPlaceholder')} placeholder={t('forgot.newPasswordPlaceholder')}
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
required required
minLength={8} minLength={8}
disabled={isSubmitting} disabled={isSubmitting}
aria-describedby="forgot-password-strength"
/>
<button
type="button"
className={styles.togglePassword}
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
disabled={isSubmitting}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
<PasswordStrengthMeter
password={newPassword}
id="forgot-password-strength"
labels={{
title: t('password.strength'),
empty: t('password.strength.empty'),
weak: t('password.strength.weak'),
fair: t('password.strength.fair'),
good: t('password.strength.good'),
strong: t('password.strength.strong'),
rules: {
length: t('password.rule.length'),
upper: t('password.rule.upper'),
number: t('password.rule.number'),
special: t('password.rule.special'),
},
}}
/>
</div>
<div className={styles.field}>
<label htmlFor="forgot-confirm-password">{t('forgot.confirm')}</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="forgot-confirm-password"
type={showPassword ? 'text' : 'password'}
dir="ltr"
lang="en"
placeholder={t('forgot.confirmPlaceholder')}
value={confirmNewPassword}
onChange={(e) => setConfirmNewPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
/> />
</div> </div>
</div> </div>
@@ -561,7 +461,11 @@ export function LoginPage() {
)} )}
</div> </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')} {isSubmitting ? t('forgot.verifying') : t('forgot.reset')}
</button> </button>
</> </>
+2 -20
View File
@@ -6,7 +6,6 @@ import type {
MeResponse, MeResponse,
OtpSendResponse, OtpSendResponse,
OtpVerifyResponse, OtpVerifyResponse,
RegisterResponse,
} from '../types/auth' } from '../types/auth'
export async function login(cellNumber: string, password: string) { export async function login(cellNumber: string, password: string) {
@@ -19,31 +18,14 @@ export async function login(cellNumber: string, password: string) {
return data 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) { export async function fetchCurrentUser(signal?: AbortSignal) {
return apiRequest<MeResponse>('/auth/me', { auth: true, signal }) 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', { return apiRequest<OtpSendResponse>('/auth/send-otp', {
method: 'POST', method: 'POST',
body: { cellNumber }, body: domain ? { cellNumber, domain } : { cellNumber },
}) })
} }
-5
View File
@@ -48,11 +48,6 @@ export interface MeResponse {
user: AuthUser user: AuthUser
} }
export interface RegisterResponse extends LoginResponse {
smsEnabled?: boolean
registeredBusiness?: { id: string; name: string; slug: string }
}
export interface OtpSendResponse { export interface OtpSendResponse {
enabled: boolean enabled: boolean
message: string message: string
+38 -2
View File
@@ -2,7 +2,7 @@ import type { DashboardLocale } from '@meshkee/dashboard-core'
const en = { const en = {
'app.dashboardName': 'Customer Dashboard', 'app.dashboardName': 'Customer Dashboard',
'app.role.customer': 'Customer', 'app.role.customer': 'Website user',
'app.storeFallback': 'Store', 'app.storeFallback': 'Store',
'app.poweredBy': 'powered by Meshkee.app', 'app.poweredBy': 'powered by Meshkee.app',
@@ -180,6 +180,8 @@ const en = {
'login.signUp': 'Sign up', 'login.signUp': 'Sign up',
'login.error.signIn': 'Unable to sign in. Check your connection and try again.', 'login.error.signIn': 'Unable to sign in. Check your connection and try again.',
'login.error.sendCode': 'Unable to send verification code.', '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.title': 'Create account',
'signup.subtitle': 'Register as a customer of {domain}', 'signup.subtitle': 'Register as a customer of {domain}',
@@ -195,6 +197,8 @@ const en = {
'signup.error.match': 'Passwords do not match.', 'signup.error.match': 'Passwords do not match.',
'signup.error.length': 'Password must be at least 8 characters.', 'signup.error.length': 'Password must be at least 8 characters.',
'signup.error.create': 'Unable to create account.', '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.', 'signup.info.verify': 'Account created. Enter the SMS code to verify your number and sign in.',
'forgot.back': 'Back to sign in', 'forgot.back': 'Back to sign in',
@@ -206,11 +210,25 @@ const en = {
'forgot.code': 'SMS verification code', 'forgot.code': 'SMS verification code',
'forgot.newPassword': 'New password', 'forgot.newPassword': 'New password',
'forgot.newPasswordPlaceholder': 'Enter new password', 'forgot.newPasswordPlaceholder': 'Enter new password',
'forgot.confirm': 'Confirm password',
'forgot.confirmPlaceholder': 'Repeat new password',
'forgot.reset': 'Reset password', 'forgot.reset': 'Reset password',
'forgot.verifying': 'Verifying...', 'forgot.verifying': 'Verifying...',
'forgot.error.length': 'Password must be at least 8 characters.', '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.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.', '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.back': 'Back to sign in',
'otp.title': 'One-time login', 'otp.title': 'One-time login',
@@ -233,7 +251,7 @@ type MessageKey = keyof typeof en
const fa: Record<MessageKey, string> = { const fa: Record<MessageKey, string> = {
'app.dashboardName': 'پنل مشتری', 'app.dashboardName': 'پنل مشتری',
'app.role.customer': 'مشتری', 'app.role.customer': 'کاربر وبسایت',
'app.storeFallback': 'فروشگاه', 'app.storeFallback': 'فروشگاه',
'app.poweredBy': 'قدرت‌گرفته از Meshkee.app', 'app.poweredBy': 'قدرت‌گرفته از Meshkee.app',
@@ -411,6 +429,8 @@ const fa: Record<MessageKey, string> = {
'login.signUp': 'ثبت‌نام', 'login.signUp': 'ثبت‌نام',
'login.error.signIn': 'ورود ممکن نشد. اتصال خود را بررسی کنید و دوباره تلاش کنید.', 'login.error.signIn': 'ورود ممکن نشد. اتصال خود را بررسی کنید و دوباره تلاش کنید.',
'login.error.sendCode': 'ارسال کد تأیید ممکن نشد.', 'login.error.sendCode': 'ارسال کد تأیید ممکن نشد.',
'login.error.cellEnglishDigits': 'خواهشمندیم از زبان انگلیسی برای شماره همراه استفاده کنید',
'login.error.smsProvider': 'سرویس پیامک موقتاً در دسترس نیست. لطفاً بعداً دوباره تلاش کنید.',
'signup.title': 'ایجاد حساب', 'signup.title': 'ایجاد حساب',
'signup.subtitle': 'به‌عنوان مشتری {domain} ثبت‌نام کنید', 'signup.subtitle': 'به‌عنوان مشتری {domain} ثبت‌نام کنید',
@@ -426,6 +446,8 @@ const fa: Record<MessageKey, string> = {
'signup.error.match': 'رمزهای عبور یکسان نیستند.', 'signup.error.match': 'رمزهای عبور یکسان نیستند.',
'signup.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.', 'signup.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
'signup.error.create': 'ایجاد حساب ممکن نشد.', 'signup.error.create': 'ایجاد حساب ممکن نشد.',
'signup.error.cellExists':
'این شماره موبایل قبلاً ثبت شده است. لطفاً وارد شوید یا رمز عبور را بازیابی کنید.',
'signup.info.verify': 'حساب ایجاد شد. کد پیامکی را وارد کنید تا شماره تأیید شود و وارد شوید.', 'signup.info.verify': 'حساب ایجاد شد. کد پیامکی را وارد کنید تا شماره تأیید شود و وارد شوید.',
'forgot.back': 'بازگشت به ورود', 'forgot.back': 'بازگشت به ورود',
@@ -437,11 +459,25 @@ const fa: Record<MessageKey, string> = {
'forgot.code': 'کد تأیید پیامکی', 'forgot.code': 'کد تأیید پیامکی',
'forgot.newPassword': 'رمز عبور جدید', 'forgot.newPassword': 'رمز عبور جدید',
'forgot.newPasswordPlaceholder': 'رمز عبور جدید را وارد کنید', 'forgot.newPasswordPlaceholder': 'رمز عبور جدید را وارد کنید',
'forgot.confirm': 'تأیید رمز عبور',
'forgot.confirmPlaceholder': 'رمز عبور جدید را تکرار کنید',
'forgot.reset': 'بازیابی رمز عبور', 'forgot.reset': 'بازیابی رمز عبور',
'forgot.verifying': 'در حال تأیید...', 'forgot.verifying': 'در حال تأیید...',
'forgot.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.', 'forgot.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
'forgot.error.strength': 'لطفاً رمز عبور قوی‌تری انتخاب کنید.',
'forgot.error.match': 'رمزهای عبور یکسان نیستند.',
'forgot.error.verify': 'بازنشانی رمز ممکن نشد. کد را بررسی کنید و دوباره تلاش کنید.', 'forgot.error.verify': 'بازنشانی رمز ممکن نشد. کد را بررسی کنید و دوباره تلاش کنید.',
'forgot.info.success': 'رمز عبور به‌روز شد. می‌توانید با رمز جدید وارد شوید.', '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.back': 'بازگشت به ورود',
'otp.title': 'ورود یک‌بارمصرف', 'otp.title': 'ورود یک‌بارمصرف',
@@ -104,6 +104,10 @@
color: var(--primary); color: var(--primary);
} }
:global([dir='rtl']) .backBtn svg {
transform: scaleX(-1);
}
.form { .form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -152,6 +156,11 @@
box-sizing: border-box; box-sizing: border-box;
} }
.inputWrap input[dir='ltr'] {
direction: ltr;
text-align: left;
}
.inputWrap input:focus { .inputWrap input:focus {
border-color: var(--primary); border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12); box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
@@ -195,6 +204,7 @@
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: white; color: white;
text-align: center;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35); box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35);
@@ -202,6 +212,10 @@
margin-top: 4px; margin-top: 4px;
} }
button.submitBtn {
text-align: center;
}
.submitBtn:hover { .submitBtn:hover {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(var(--primary-rgb) / 0.4); box-shadow: 0 6px 18px rgba(var(--primary-rgb) / 0.4);
@@ -246,11 +260,20 @@
.footerText { .footerText {
margin-top: 24px; margin-top: 24px;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 4px;
text-align: center; text-align: center;
font-size: 14px; font-size: 14px;
color: var(--text-secondary); color: var(--text-secondary);
} }
p.footerText {
text-align: center;
}
.codeHint { .codeHint {
font-size: 13px; font-size: 13px;
color: var(--text-secondary); color: var(--text-secondary);
+143 -17
View File
@@ -15,7 +15,7 @@ import {
resetPassword, resetPassword,
sendOtp, sendOtp,
} from '../services/authService' } from '../services/authService'
import { LanguageSelect } from '@meshkee/dashboard-ui' import { LanguageSelect, PasswordStrengthMeter, isPasswordStrong } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import meshkeeLogo from '../assets/meshkee-logo.png' import meshkeeLogo from '../assets/meshkee-logo.png'
import styles from './LoginPage.module.css' import styles from './LoginPage.module.css'
@@ -49,6 +49,7 @@ export function LoginPage() {
const [lastName, setLastName] = useState('') const [lastName, setLastName] = useState('')
const [smsCode, setSmsCode] = useState('') const [smsCode, setSmsCode] = useState('')
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
const [confirmNewPassword, setConfirmNewPassword] = useState('')
const [codeSent, setCodeSent] = useState(false) const [codeSent, setCodeSent] = useState(false)
const [countdown, setCountdown] = useState(0) const [countdown, setCountdown] = useState(0)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -68,6 +69,7 @@ export function LoginPage() {
setLastName('') setLastName('')
setSmsCode('') setSmsCode('')
setNewPassword('') setNewPassword('')
setConfirmNewPassword('')
setSmsStep('phone') setSmsStep('phone')
setCodeSent(false) setCodeSent(false)
setShowPassword(false) setShowPassword(false)
@@ -94,19 +96,50 @@ export function LoginPage() {
function handleApiError(err: unknown, fallback: string) { function handleApiError(err: unknown, fallback: string) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
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) setError(err.message)
}
} else { } else {
setError(fallback) 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() { async function handleSendCode() {
clearMessages() clearMessages()
const cellNumber = resolveCellNumber()
if (!cellNumber) return
setIsSubmitting(true) setIsSubmitting(true)
try { try {
const cellNumber = toE164CellNumber(phone) const result = await sendOtp(cellNumber, tenantDomain)
const result = await sendOtp(cellNumber)
if (!result.enabled) { if (!result.enabled) {
setInfo(result.message) setInfo(result.message)
@@ -125,10 +158,12 @@ export function LoginPage() {
async function handleLogin(e: React.FormEvent) { async function handleLogin(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
clearMessages() clearMessages()
const cellNumber = resolveCellNumber()
if (!cellNumber) return
setIsSubmitting(true) setIsSubmitting(true)
try { try {
const cellNumber = toE164CellNumber(phone)
await login(cellNumber, password) await login(cellNumber, password)
navigate(redirectTo) navigate(redirectTo)
} catch (err) { } catch (err) {
@@ -147,15 +182,17 @@ export function LoginPage() {
return return
} }
if (password.length < 8) { if (!isPasswordStrong(password)) {
setError(t('signup.error.length')) setError(t('forgot.error.strength'))
return return
} }
const cellNumber = resolveCellNumber()
if (!cellNumber) return
setIsSubmitting(true) setIsSubmitting(true)
try { try {
const cellNumber = toE164CellNumber(phone)
const data = await register({ const data = await register({
cellNumber, cellNumber,
password, password,
@@ -179,7 +216,7 @@ export function LoginPage() {
setSmsCode('') setSmsCode('')
setInfo(t('signup.info.verify')) setInfo(t('signup.info.verify'))
try { try {
const otpResult = await sendOtp(cellNumber) const otpResult = await sendOtp(cellNumber, tenantDomain)
if (!otpResult.enabled) { if (!otpResult.enabled) {
setInfo(otpResult.message) setInfo(otpResult.message)
} }
@@ -205,15 +242,22 @@ export function LoginPage() {
e.preventDefault() e.preventDefault()
clearMessages() clearMessages()
if (newPassword.length < 8) { if (!isPasswordStrong(newPassword)) {
setError(t('forgot.error.length')) setError(t('forgot.error.strength'))
return return
} }
if (newPassword !== confirmNewPassword) {
setError(t('forgot.error.match'))
return
}
const cellNumber = resolveCellNumber()
if (!cellNumber) return
setIsSubmitting(true) setIsSubmitting(true)
try { try {
const cellNumber = toE164CellNumber(phone)
await resetPassword(cellNumber, smsCode, newPassword) await resetPassword(cellNumber, smsCode, newPassword)
setInfo(t('forgot.info.success')) setInfo(t('forgot.info.success'))
setTimeout(() => switchView('login'), 2000) setTimeout(() => switchView('login'), 2000)
@@ -227,10 +271,12 @@ export function LoginPage() {
async function handleOtpLogin(e: React.FormEvent) { async function handleOtpLogin(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
clearMessages() clearMessages()
const cellNumber = resolveCellNumber()
if (!cellNumber) return
setIsSubmitting(true) setIsSubmitting(true)
try { try {
const cellNumber = toE164CellNumber(phone)
await loginWithOtp(cellNumber, smsCode) await loginWithOtp(cellNumber, smsCode)
navigate(redirectTo) navigate(redirectTo)
} catch (err) { } catch (err) {
@@ -291,6 +337,8 @@ export function LoginPage() {
<input <input
id="login-password" id="login-password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
dir="ltr"
lang="en"
placeholder={t('login.passwordPlaceholder')} placeholder={t('login.passwordPlaceholder')}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
@@ -341,7 +389,7 @@ export function LoginPage() {
</button> </button>
<p className={styles.footerText}> <p className={styles.footerText}>
{t('login.noAccount')}{' '} <span>{t('login.noAccount')}</span>
<button <button
type="button" type="button"
className={styles.linkBtn} className={styles.linkBtn}
@@ -357,7 +405,6 @@ export function LoginPage() {
{view === 'signup' && ( {view === 'signup' && (
<> <>
<h1 className={styles.title}>{t('signup.title')}</h1> <h1 className={styles.title}>{t('signup.title')}</h1>
<p className={styles.subtitle}>{t('signup.subtitle', { domain: tenantDomain })}</p>
<form className={styles.form} onSubmit={handleSignup}> <form className={styles.form} onSubmit={handleSignup}>
{error && ( {error && (
@@ -426,6 +473,8 @@ export function LoginPage() {
<input <input
id="signup-password" id="signup-password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
dir="ltr"
lang="en"
placeholder={t('signup.passwordPlaceholder')} placeholder={t('signup.passwordPlaceholder')}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
@@ -443,6 +492,24 @@ export function LoginPage() {
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />} {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button> </button>
</div> </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>
<div className={styles.field}> <div className={styles.field}>
@@ -452,6 +519,8 @@ export function LoginPage() {
<input <input
id="signup-confirm" id="signup-confirm"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
dir="ltr"
lang="en"
placeholder={t('signup.confirmPlaceholder')} placeholder={t('signup.confirmPlaceholder')}
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
@@ -462,13 +531,17 @@ export function LoginPage() {
</div> </div>
</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')} {isSubmitting ? t('signup.creating') : t('signup.create')}
</button> </button>
</form> </form>
<p className={styles.footerText}> <p className={styles.footerText}>
{t('signup.hasAccount')}{' '} <span>{t('signup.hasAccount')}</span>
<button <button
type="button" type="button"
className={styles.linkBtn} className={styles.linkBtn}
@@ -567,12 +640,61 @@ export function LoginPage() {
<input <input
id="forgot-new-password" id="forgot-new-password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
dir="ltr"
lang="en"
placeholder={t('forgot.newPasswordPlaceholder')} placeholder={t('forgot.newPasswordPlaceholder')}
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
required required
minLength={8} minLength={8}
disabled={isSubmitting} disabled={isSubmitting}
aria-describedby="forgot-password-strength"
/>
<button
type="button"
className={styles.togglePassword}
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
disabled={isSubmitting}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
<PasswordStrengthMeter
password={newPassword}
id="forgot-password-strength"
labels={{
title: t('password.strength'),
empty: t('password.strength.empty'),
weak: t('password.strength.weak'),
fair: t('password.strength.fair'),
good: t('password.strength.good'),
strong: t('password.strength.strong'),
rules: {
length: t('password.rule.length'),
upper: t('password.rule.upper'),
number: t('password.rule.number'),
special: t('password.rule.special'),
},
}}
/>
</div>
<div className={styles.field}>
<label htmlFor="forgot-confirm-password">{t('forgot.confirm')}</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="forgot-confirm-password"
type={showPassword ? 'text' : 'password'}
dir="ltr"
lang="en"
placeholder={t('forgot.confirmPlaceholder')}
value={confirmNewPassword}
onChange={(e) => setConfirmNewPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
/> />
</div> </div>
</div> </div>
@@ -592,7 +714,11 @@ export function LoginPage() {
)} )}
</div> </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')} {isSubmitting ? t('forgot.verifying') : t('forgot.reset')}
</button> </button>
</> </>
@@ -36,7 +36,15 @@ export function CheckoutLoginStep() {
setIsSubmitting(true) setIsSubmitting(true)
try { try {
if (/[۰-۹٠-٩]/.test(phone)) {
setError('خواهشمندیم از زبان انگلیسی برای شماره همراه استفاده کنید')
return
}
const cellNumber = toE164CellNumber(phone) const cellNumber = toE164CellNumber(phone)
if (!/^\+[1-9]\d{6,14}$/.test(cellNumber)) {
setError('خواهشمندیم از زبان انگلیسی برای شماره همراه استفاده کنید')
return
}
await login(cellNumber, password) await login(cellNumber, password)
try { try {
await syncGuestCartToServer() await syncGuestCartToServer()
@@ -45,7 +53,12 @@ export function CheckoutLoginStep() {
} }
navigate(resolveRedirectPath(), { replace: true }) navigate(resolveRedirectPath(), { replace: true })
} catch (err) { } 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 { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
@@ -92,6 +105,8 @@ export function CheckoutLoginStep() {
<input <input
id="checkout-password" id="checkout-password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
dir="ltr"
lang="en"
placeholder="رمز عبور خود را وارد کنید" placeholder="رمز عبور خود را وارد کنید"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
@@ -772,6 +772,11 @@
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s, box-shadow 0.2s;
} }
.inputWrap input[dir='ltr'] {
direction: ltr;
text-align: left;
}
.inputWrap input:focus { .inputWrap input:focus {
border-color: var(--primary); border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12); box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
+2 -2
View File
@@ -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', { return apiRequest<OtpSendResponse>('/auth/send-otp', {
method: 'POST', method: 'POST',
body: { cellNumber }, body: domain ? { cellNumber, domain } : { cellNumber },
}) })
} }
@@ -54,3 +54,10 @@
.deployFail { .deployFail {
color: #b91c1c; color: #b91c1c;
} }
.headerActions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
+134 -8
View File
@@ -9,6 +9,7 @@ import {
Rocket, Rocket,
RotateCcw, RotateCcw,
Search, Search,
ShieldPlus,
Unlock, Unlock,
} from 'lucide-react' } from 'lucide-react'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
@@ -20,6 +21,8 @@ import type { ListDomainsParams } from '../services/domainService'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
import { import {
deployDomain, deployDomain,
issueDomainSsl,
issueWebsiteSsl,
listDomains, listDomains,
removeDomain, removeDomain,
setDomainActive, setDomainActive,
@@ -105,6 +108,8 @@ export function WebsitesPage() {
const [togglingSslId, setTogglingSslId] = useState<number | null>(null) const [togglingSslId, setTogglingSslId] = useState<number | null>(null)
const [deployingId, setDeployingId] = useState<DomainListItem['id'] | null>(null) const [deployingId, setDeployingId] = useState<DomainListItem['id'] | null>(null)
const [syncingSsl, setSyncingSsl] = useState(false) const [syncingSsl, setSyncingSsl] = useState(false)
const [issuingWebsiteSsl, setIssuingWebsiteSsl] = useState(false)
const [issuingSslId, setIssuingSslId] = useState<DomainListItem['id'] | null>(null)
useEffect(() => { useEffect(() => {
const controller = new AbortController() const controller = new AbortController()
@@ -264,17 +269,17 @@ export function WebsitesPage() {
} }
async function handleSyncSsl() { async function handleSyncSsl() {
if (syncingSsl) return if (syncingSsl || issuingWebsiteSsl) return
flushSync(() => { flushSync(() => {
setSyncingSsl(true) setSyncingSsl(true)
}) })
setError('') setError('')
showToast('Starting SSL sync…', 'info') showToast('Starting dashboard SSL sync…', 'info')
try { try {
const result = await syncSsl() const result = await syncSsl()
showToast(result.message || 'SSL sync started.', 'success') showToast(result.message || 'Dashboard SSL sync started.', 'success')
} catch (err) { } catch (err) {
const message = err instanceof ApiError ? err.message : 'Unable to start SSL sync.' const message = err instanceof ApiError ? err.message : 'Unable to start SSL sync.'
setError(message) 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) { async function handleDeploy(domain: DomainListItem) {
if (!domain.deploySlug) return if (!domain.deploySlug) return
if (deployingId != null) return if (deployingId != null) return
@@ -366,22 +442,42 @@ export function WebsitesPage() {
Monitor and manage all domains across the platform. Monitor and manage all domains across the platform.
</p> </p>
</div> </div>
<div className={styles.headerActions}>
<button <button
type="button" type="button"
className={`${tableStyles.btn} ${tableStyles.btnPrimary} ${ className={`${tableStyles.btn} ${tableStyles.btnGhost} ${
syncingSsl ? styles.deployBusy : '' syncingSsl ? styles.deployBusy : ''
}`} }`}
onClick={() => void handleSyncSsl()} onClick={() => void handleSyncSsl()}
disabled={syncingSsl} disabled={syncingSsl || issuingWebsiteSsl}
aria-busy={syncingSsl} aria-busy={syncingSsl}
title="Expand Let's Encrypt cert for manage / business.* / customer.* dashboards"
> >
{syncingSsl ? ( {syncingSsl ? (
<Loader2 size={16} className={styles.spin} aria-hidden /> <Loader2 size={16} className={styles.spin} aria-hidden />
) : ( ) : (
<RefreshCw size={16} aria-hidden /> <RefreshCw size={16} aria-hidden />
)} )}
{syncingSsl ? 'Syncing SSL…' : 'Sync SSL now'} {syncingSsl ? 'Syncing dashboard SSL…' : 'Sync dashboard SSL'}
</button> </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>
<div className={tableStyles.filtersPanel}> <div className={tableStyles.filtersPanel}>
@@ -569,16 +665,46 @@ export function WebsitesPage() {
)} )}
</button> </button>
) : null} ) : null}
{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 <button
type="button" type="button"
className={tableStyles.controlBtn} className={tableStyles.controlBtn}
onClick={() => void handleToggleSsl(domain, !domain.sslEnabled)} onClick={() => void handleToggleSsl(domain, !domain.sslEnabled)}
disabled={togglingSslId === domain.id} disabled={togglingSslId === domain.id}
title={domain.sslEnabled ? 'Disable SSL' : 'Enable SSL'} title={domain.sslEnabled ? 'Mark SSL invalid' : 'Mark SSL valid'}
aria-label={domain.sslEnabled ? 'Disable SSL' : 'Enable SSL'} aria-label={
domain.sslEnabled ? 'Mark SSL invalid' : 'Mark SSL valid'
}
> >
{domain.sslEnabled ? <Lock size={16} /> : <Unlock size={16} />} {domain.sslEnabled ? <Lock size={16} /> : <Unlock size={16} />}
</button> </button>
)}
<button <button
type="button" type="button"
className={tableStyles.controlBtn} className={tableStyles.controlBtn}
+23 -1
View File
@@ -1,5 +1,11 @@
import { apiRequest } from '../lib/api' 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 { export interface ListDomainsParams {
page?: number page?: number
@@ -67,3 +73,19 @@ export async function syncSsl() {
auth: true, 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,
})
}
+14
View File
@@ -35,3 +35,17 @@ export interface SyncSslResponse {
status: 'accepted' status: 'accepted'
message: string 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
}
+1 -1
View File
@@ -514,7 +514,7 @@ Migration: `database/migrations/033_business_favicon.sql`
| API | `https://api.meshkee.com/api/v1` (host `185.164.72.119`) | | API | `https://api.meshkee.com/api/v1` (host `185.164.72.119`) |
| Docs | `docs/DEPLOY.md` | | Docs | `docs/DEPLOY.md` |
SSL: Certbot cert `meshkee-dashboards` + cron `ssl-sync.sh` every 2h + Super Admin **Sync SSL now** (`POST /domains/ssl-sync` → dashboards agent). SSL: Certbot cert `meshkee-dashboards` + cron `ssl-sync.sh` every 2h + Super Admin **Sync dashboard SSL** (`POST /domains/ssl-sync` → dashboards agent). Storefront SSL: **Issue website SSL** (`POST /domains/website-ssl`) + per-row issue (`POST /domains/:id/issue-ssl`) → websites agent `/ssl` (certbot).
--- ---
@@ -71,6 +71,11 @@
box-sizing: border-box; box-sizing: border-box;
} }
.field input[dir='ltr'] {
direction: ltr;
text-align: left;
}
.field input:focus { .field input:focus {
border-color: var(--primary); border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12); box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
@@ -192,6 +192,8 @@ export function PasswordResetModal({
<input <input
id="password-reset-current" id="password-reset-current"
type="password" type="password"
dir="ltr"
lang="en"
name="current-password-field" name="current-password-field"
autoComplete="current-password" autoComplete="current-password"
value={currentPassword} value={currentPassword}
@@ -206,6 +208,8 @@ export function PasswordResetModal({
<input <input
id="password-reset-new" id="password-reset-new"
type="password" type="password"
dir="ltr"
lang="en"
name="new-password-field" name="new-password-field"
autoComplete="new-password" autoComplete="new-password"
value={newPassword} value={newPassword}
@@ -255,6 +259,8 @@ export function PasswordResetModal({
<input <input
id="password-reset-confirm" id="password-reset-confirm"
type="password" type="password"
dir="ltr"
lang="en"
name="confirm-password-field" name="confirm-password-field"
autoComplete="new-password" autoComplete="new-password"
value={confirmPassword} value={confirmPassword}
@@ -0,0 +1,103 @@
.strength {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 6px;
}
.strengthHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.strengthTitle {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
}
.strengthLabel {
font-size: 11px;
font-weight: 600;
}
.tone_empty {
color: var(--text-muted);
}
.tone_weak {
color: #dc2626;
}
.tone_fair {
color: #d97706;
}
.tone_good {
color: #2563eb;
}
.tone_strong {
color: #16a34a;
}
.strengthTrack {
height: 6px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.28);
overflow: hidden;
}
.strengthFill {
height: 100%;
border-radius: inherit;
transition: width 0.22s ease, background-color 0.22s ease;
}
.fill_empty {
background: transparent;
}
.fill_weak {
background: #ef4444;
}
.fill_fair {
background: #f59e0b;
}
.fill_good {
background: #3b82f6;
}
.fill_strong {
background: #22c55e;
}
.rules {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 10px;
margin: 0;
padding: 0;
list-style: none;
}
.rule {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-muted);
transition: color 0.15s ease;
}
.ruleMet {
color: #15803d;
}
.ruleDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(148, 163, 184, 0.55);
flex-shrink: 0;
}
.ruleMet .ruleDot {
background: #22c55e;
}
@@ -0,0 +1,135 @@
import styles from './PasswordStrengthMeter.module.css'
const PASSWORD_RULES = [
{
id: 'length',
test: (value: string) => value.length >= 8,
},
{
id: 'upper',
test: (value: string) => /[A-Z]/.test(value),
},
{
id: 'number',
test: (value: string) => /\d/.test(value),
},
{
id: 'special',
test: (value: string) => /[^A-Za-z0-9]/.test(value),
},
] as const
export type PasswordRuleId = (typeof PASSWORD_RULES)[number]['id']
export type StrengthTone = 'empty' | 'weak' | 'fair' | 'good' | 'strong'
export type PasswordStrengthLabels = {
title: string
empty: string
weak: string
fair: string
good: string
strong: string
rules: Record<PasswordRuleId, string>
}
const DEFAULT_LABELS: PasswordStrengthLabels = {
title: 'Password strength',
empty: 'Enter a password',
weak: 'Weak',
fair: 'Fair',
good: 'Good',
strong: 'Strong',
rules: {
length: 'At least 8 characters',
upper: 'One uppercase letter',
number: 'One number',
special: 'One special character',
},
}
export function getPasswordChecks(password: string, labels: PasswordStrengthLabels = DEFAULT_LABELS) {
return PASSWORD_RULES.map((rule) => ({
id: rule.id,
label: labels.rules[rule.id],
met: rule.test(password),
}))
}
export function getStrengthMeta(
metCount: number,
hasInput: boolean,
labels: PasswordStrengthLabels = DEFAULT_LABELS,
): {
tone: StrengthTone
label: string
percent: number
} {
if (!hasInput || metCount === 0) {
return { tone: 'empty', label: labels.empty, percent: 0 }
}
if (metCount === 1) return { tone: 'weak', label: labels.weak, percent: 25 }
if (metCount === 2) return { tone: 'fair', label: labels.fair, percent: 50 }
if (metCount === 3) return { tone: 'good', label: labels.good, percent: 75 }
return { tone: 'strong', label: labels.strong, percent: 100 }
}
export function isPasswordStrong(password: string): boolean {
return PASSWORD_RULES.every((rule) => rule.test(password))
}
export function getPasswordValidationError(
password: string,
labels: PasswordStrengthLabels = DEFAULT_LABELS,
): string | null {
const unmet = PASSWORD_RULES.filter((rule) => !rule.test(password))
if (unmet.length === 0) return null
return unmet.map((rule) => labels.rules[rule.id]).join(', ')
}
export interface PasswordStrengthMeterProps {
password: string
labels?: PasswordStrengthLabels
id?: string
}
export function PasswordStrengthMeter({
password,
labels = DEFAULT_LABELS,
id = 'password-strength',
}: PasswordStrengthMeterProps) {
const checks = getPasswordChecks(password, labels)
const metCount = checks.filter((check) => check.met).length
const strength = getStrengthMeta(metCount, password.length > 0, labels)
return (
<div className={styles.strength} id={id}>
<div className={styles.strengthHeader}>
<span className={styles.strengthTitle}>{labels.title}</span>
<span className={`${styles.strengthLabel} ${styles[`tone_${strength.tone}`]}`}>
{strength.label}
</span>
</div>
<div
className={styles.strengthTrack}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={strength.percent}
aria-label={labels.title}
>
<div
className={`${styles.strengthFill} ${styles[`fill_${strength.tone}`]}`}
style={{ width: `${strength.percent}%` }}
/>
</div>
<ul className={styles.rules} id={`${id}-rules`}>
{checks.map((check) => (
<li key={check.id} className={`${styles.rule} ${check.met ? styles.ruleMet : ''}`}>
<span className={styles.ruleDot} aria-hidden="true" />
{check.label}
</li>
))}
</ul>
</div>
)
}
+9
View File
@@ -21,6 +21,15 @@ export {
type ChangePasswordHandler, type ChangePasswordHandler,
type PasswordResetModalProps, type PasswordResetModalProps,
} from './components/PasswordResetModal' } from './components/PasswordResetModal'
export {
PasswordStrengthMeter,
getPasswordChecks,
getPasswordValidationError,
getStrengthMeta,
isPasswordStrong,
type PasswordStrengthLabels,
type PasswordStrengthMeterProps,
} from './components/PasswordStrengthMeter'
export { LanguageSelect } from './components/LanguageSelect' export { LanguageSelect } from './components/LanguageSelect'
export { LocaleProvider, useLocale } from './context/LocaleContext' export { LocaleProvider, useLocale } from './context/LocaleContext'
export { useDashboardDocumentTitle } from './hooks/useDashboardDocumentTitle' export { useDashboardDocumentTitle } from './hooks/useDashboardDocumentTitle'