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 {
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);
+32
View File
@@ -1046,6 +1046,8 @@ const en = {
'login.signUp': 'Sign up',
'login.error.signIn': 'Unable to sign in. Check your connection and try again.',
'login.error.sendCode': 'Unable to send verification code.',
'login.error.cellEnglishDigits': 'Please use English digits for the mobile number.',
'login.error.smsProvider': 'SMS service is temporarily unavailable. Please try again later.',
'login.error.access': 'You do not have access to this business dashboard.',
'signup.title': 'Create account',
@@ -1072,11 +1074,25 @@ const en = {
'forgot.code': 'SMS verification code',
'forgot.newPassword': 'New password',
'forgot.newPasswordPlaceholder': 'Enter new password',
'forgot.confirm': 'Confirm password',
'forgot.confirmPlaceholder': 'Repeat new password',
'forgot.reset': 'Reset password',
'forgot.verifying': 'Verifying...',
'forgot.error.length': 'Password must be at least 8 characters.',
'forgot.error.strength': 'Please choose a stronger password.',
'forgot.error.match': 'Passwords do not match.',
'forgot.error.verify': 'Unable to reset password. Check the code and try again.',
'forgot.info.success': 'Password updated. You can sign in with your new password.',
'password.strength': 'Password strength',
'password.strength.empty': 'Enter a password',
'password.strength.weak': 'Weak',
'password.strength.fair': 'Fair',
'password.strength.good': 'Good',
'password.strength.strong': 'Strong',
'password.rule.length': 'At least 8 characters',
'password.rule.upper': 'One uppercase letter',
'password.rule.number': 'One number',
'password.rule.special': 'One special character',
'otp.back': 'Back to sign in',
'otp.title': 'One-time login',
@@ -2144,6 +2160,8 @@ const fa: Record<MessageKey, string> = {
'login.signUp': 'ثبت‌نام',
'login.error.signIn': 'ورود ممکن نشد. اتصال را بررسی کنید و دوباره تلاش کنید.',
'login.error.sendCode': 'ارسال کد تأیید ممکن نشد.',
'login.error.cellEnglishDigits': 'خواهشمندیم از زبان انگلیسی برای شماره همراه استفاده کنید',
'login.error.smsProvider': 'سرویس پیامک موقتاً در دسترس نیست. لطفاً بعداً دوباره تلاش کنید.',
'login.error.access': 'به این پنل کسب‌وکار دسترسی ندارید.',
'signup.title': 'ایجاد حساب',
@@ -2170,11 +2188,25 @@ const fa: Record<MessageKey, string> = {
'forgot.code': 'کد تأیید پیامکی',
'forgot.newPassword': 'رمز عبور جدید',
'forgot.newPasswordPlaceholder': 'رمز عبور جدید را وارد کنید',
'forgot.confirm': 'تأیید رمز عبور',
'forgot.confirmPlaceholder': 'رمز عبور جدید را تکرار کنید',
'forgot.reset': 'بازنشانی رمز عبور',
'forgot.verifying': 'در حال تأیید...',
'forgot.error.length': 'رمز عبور باید حداقل ۸ کاراکتر باشد.',
'forgot.error.strength': 'لطفاً رمز عبور قوی‌تری انتخاب کنید.',
'forgot.error.match': 'رمزهای عبور یکسان نیستند.',
'forgot.error.verify': 'بازنشانی رمز ممکن نشد. کد را بررسی کنید و دوباره تلاش کنید.',
'forgot.info.success': 'رمز عبور به‌روز شد. می‌توانید با رمز جدید وارد شوید.',
'password.strength': 'قدرت رمز عبور',
'password.strength.empty': 'رمز عبور را وارد کنید',
'password.strength.weak': 'ضعیف',
'password.strength.fair': 'متوسط',
'password.strength.good': 'خوب',
'password.strength.strong': 'قوی',
'password.rule.length': 'حداقل ۸ کاراکتر',
'password.rule.upper': 'یک حرف بزرگ انگلیسی',
'password.rule.number': 'یک عدد',
'password.rule.special': 'یک کاراکتر ویژه',
'otp.back': 'بازگشت به ورود',
'otp.title': 'ورود یک‌بارمصرف',
@@ -104,6 +104,10 @@
color: var(--primary);
}
:global([dir='rtl']) .backBtn svg {
transform: scaleX(-1);
}
.form {
display: flex;
flex-direction: column;
@@ -152,6 +156,11 @@
box-sizing: border-box;
}
.inputWrap input[dir='ltr'] {
direction: ltr;
text-align: left;
}
.inputWrap input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
@@ -203,6 +212,10 @@
margin-top: 4px;
}
button.submitBtn {
text-align: center;
}
.submitBtn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(var(--primary-rgb) / 0.4);
@@ -247,6 +260,11 @@
.footerText {
margin-top: 24px;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 4px;
text-align: center;
font-size: 14px;
color: var(--text-secondary);
+107 -203
View File
@@ -1,23 +1,17 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } from 'lucide-react'
import { LanguageSelect } from '@meshkee/dashboard-ui'
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft } from 'lucide-react'
import { LanguageSelect, PasswordStrengthMeter, isPasswordStrong } from '@meshkee/dashboard-ui'
import { useAuth, BUSINESS_ACCESS_MESSAGE } from '../context/AuthContext'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { ApiError } from '../lib/api'
import { toE164CellNumber } from '../lib/cellNumber'
import { getBusinessDomain } from '../lib/config'
import { setActiveBusiness } from '../lib/businessContext'
import {
logout as logoutRequest,
register,
resetPassword,
sendOtp,
} from '../services/authService'
import { resetPassword, sendOtp } from '../services/authService'
import { useT } from '../i18n/useT'
import styles from './LoginPage.module.css'
type AuthView = 'login' | 'signup' | 'forgot' | 'otp'
type AuthView = 'login' | 'forgot' | 'otp'
type SmsStep = 'phone' | 'code'
export function LoginPage() {
@@ -32,11 +26,9 @@ export function LoginPage() {
const [showPassword, setShowPassword] = useState(false)
const [phone, setPhone] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [smsCode, setSmsCode] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmNewPassword, setConfirmNewPassword] = useState('')
const [codeSent, setCodeSent] = useState(false)
const [countdown, setCountdown] = useState(0)
const [error, setError] = useState('')
@@ -51,11 +43,9 @@ export function LoginPage() {
function resetForm() {
setPhone('')
setPassword('')
setConfirmPassword('')
setFirstName('')
setLastName('')
setSmsCode('')
setNewPassword('')
setConfirmNewPassword('')
setSmsStep('phone')
setCodeSent(false)
setShowPassword(false)
@@ -84,6 +74,18 @@ export function LoginPage() {
if (err instanceof ApiError) {
if (err.message === BUSINESS_ACCESS_MESSAGE) {
setError(t('login.error.access'))
} else if (
err.message.includes('E.164') ||
err.message.includes('cellNumber must be')
) {
setError(t('login.error.cellEnglishDigits'))
} else if (
err.message.includes('SMS provider is not configured') ||
err.message.includes('SMS provider is unreachable') ||
err.message.includes('SMS provider returned') ||
err.message.includes('Unable to send SMS')
) {
setError(t('login.error.smsProvider'))
} else {
setError(err.message)
}
@@ -92,13 +94,28 @@ export function LoginPage() {
}
}
function resolveCellNumber(): string | null {
if (/[۰-۹٠-٩]/.test(phone)) {
setError(t('login.error.cellEnglishDigits'))
return null
}
const cellNumber = toE164CellNumber(phone)
if (!/^\+[1-9]\d{6,14}$/.test(cellNumber)) {
setError(t('login.error.cellEnglishDigits'))
return null
}
return cellNumber
}
async function handleSendCode() {
clearMessages()
const cellNumber = resolveCellNumber()
if (!cellNumber) return
setIsSubmitting(true)
try {
const cellNumber = toE164CellNumber(phone)
const result = await sendOtp(cellNumber)
const result = await sendOtp(cellNumber, businessDomain)
if (!result.enabled) {
setInfo(result.message)
@@ -117,10 +134,12 @@ export function LoginPage() {
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
clearMessages()
const cellNumber = resolveCellNumber()
if (!cellNumber) return
setIsSubmitting(true)
try {
const cellNumber = toE164CellNumber(phone)
await login(cellNumber, password)
navigate('/')
} catch (err) {
@@ -130,60 +149,26 @@ export function LoginPage() {
}
}
async function handleSignup(e: React.FormEvent) {
e.preventDefault()
clearMessages()
if (password !== confirmPassword) {
setError(t('signup.error.match'))
return
}
if (password.length < 8) {
setError(t('signup.error.length'))
return
}
setIsSubmitting(true)
try {
const cellNumber = toE164CellNumber(phone)
const data = await register({
cellNumber,
password,
firstName: firstName.trim(),
lastName: lastName.trim(),
domain: businessDomain,
})
if (data.user.dashboard !== 'business' || data.user.businesses.length === 0) {
logoutRequest()
setError(t('login.error.access'))
return
}
setActiveBusiness(data.user)
navigate('/')
} catch (err) {
handleApiError(err, t('signup.error.create'))
} finally {
setIsSubmitting(false)
}
}
async function handleResetPassword(e: React.FormEvent) {
e.preventDefault()
clearMessages()
if (newPassword.length < 8) {
setError(t('forgot.error.length'))
if (!isPasswordStrong(newPassword)) {
setError(t('forgot.error.strength'))
return
}
if (newPassword !== confirmNewPassword) {
setError(t('forgot.error.match'))
return
}
const cellNumber = resolveCellNumber()
if (!cellNumber) return
setIsSubmitting(true)
try {
const cellNumber = toE164CellNumber(phone)
await resetPassword(cellNumber, smsCode, newPassword)
setInfo(t('forgot.info.success'))
setTimeout(() => switchView('login'), 2000)
@@ -197,10 +182,12 @@ export function LoginPage() {
async function handleOtpLogin(e: React.FormEvent) {
e.preventDefault()
clearMessages()
const cellNumber = resolveCellNumber()
if (!cellNumber) return
setIsSubmitting(true)
try {
const cellNumber = toE164CellNumber(phone)
await loginWithOtp(cellNumber, smsCode)
navigate('/')
} catch (err) {
@@ -261,6 +248,8 @@ export function LoginPage() {
<input
id="login-password"
type={showPassword ? 'text' : 'password'}
dir="ltr"
lang="en"
placeholder={t('login.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
@@ -309,144 +298,6 @@ export function LoginPage() {
<KeyRound size={18} />
{t('login.otp')}
</button>
<p className={styles.footerText}>
{t('login.noAccount')}{' '}
<button
type="button"
className={styles.linkBtn}
onClick={() => switchView('signup')}
disabled={isSubmitting}
>
{t('login.signUp')}
</button>
</p>
</>
)}
{view === 'signup' && (
<>
<h1 className={styles.title}>{t('signup.title')}</h1>
<p className={styles.subtitle}>{t('signup.subtitle', { domain: businessDomain })}</p>
<form className={styles.form} onSubmit={handleSignup}>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div className={styles.fieldRow}>
<div className={styles.field}>
<label htmlFor="signup-first">{t('signup.firstName')}</label>
<div className={styles.inputWrap}>
<User size={18} className={styles.inputIcon} />
<input
id="signup-first"
type="text"
placeholder={t('signup.firstName')}
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
minLength={2}
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="signup-last">{t('signup.lastName')}</label>
<div className={styles.inputWrap}>
<User size={18} className={styles.inputIcon} />
<input
id="signup-last"
type="text"
placeholder={t('signup.lastName')}
value={lastName}
onChange={(e) => setLastName(e.target.value)}
required
minLength={2}
disabled={isSubmitting}
/>
</div>
</div>
</div>
<div className={styles.field}>
<label htmlFor="signup-phone">{t('login.mobile')}</label>
<div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} />
<input
id="signup-phone"
type="tel"
inputMode="tel"
placeholder="09123456789"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="signup-password">{t('login.password')}</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="signup-password"
type={showPassword ? 'text' : 'password'}
placeholder={t('signup.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
/>
<button
type="button"
className={styles.togglePassword}
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
disabled={isSubmitting}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<div className={styles.field}>
<label htmlFor="signup-confirm">{t('signup.confirm')}</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="signup-confirm"
type={showPassword ? 'text' : 'password'}
placeholder={t('signup.confirmPlaceholder')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
/>
</div>
</div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? t('signup.creating') : t('signup.create')}
</button>
</form>
<p className={styles.footerText}>
{t('signup.hasAccount')}{' '}
<button
type="button"
className={styles.linkBtn}
onClick={() => switchView('login')}
disabled={isSubmitting}
>
{t('signup.signIn')}
</button>
</p>
</>
)}
@@ -534,12 +385,61 @@ export function LoginPage() {
<input
id="forgot-new-password"
type={showPassword ? 'text' : 'password'}
dir="ltr"
lang="en"
placeholder={t('forgot.newPasswordPlaceholder')}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
aria-describedby="forgot-password-strength"
/>
<button
type="button"
className={styles.togglePassword}
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t('login.hidePassword') : t('login.showPassword')}
disabled={isSubmitting}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
<PasswordStrengthMeter
password={newPassword}
id="forgot-password-strength"
labels={{
title: t('password.strength'),
empty: t('password.strength.empty'),
weak: t('password.strength.weak'),
fair: t('password.strength.fair'),
good: t('password.strength.good'),
strong: t('password.strength.strong'),
rules: {
length: t('password.rule.length'),
upper: t('password.rule.upper'),
number: t('password.rule.number'),
special: t('password.rule.special'),
},
}}
/>
</div>
<div className={styles.field}>
<label htmlFor="forgot-confirm-password">{t('forgot.confirm')}</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="forgot-confirm-password"
type={showPassword ? 'text' : 'password'}
dir="ltr"
lang="en"
placeholder={t('forgot.confirmPlaceholder')}
value={confirmNewPassword}
onChange={(e) => setConfirmNewPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
/>
</div>
</div>
@@ -561,7 +461,11 @@ export function LoginPage() {
)}
</div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
<button
type="submit"
className={styles.submitBtn}
disabled={isSubmitting || !isPasswordStrong(newPassword)}
>
{isSubmitting ? t('forgot.verifying') : t('forgot.reset')}
</button>
</>
+2 -20
View File
@@ -6,7 +6,6 @@ import type {
MeResponse,
OtpSendResponse,
OtpVerifyResponse,
RegisterResponse,
} from '../types/auth'
export async function login(cellNumber: string, password: string) {
@@ -19,31 +18,14 @@ export async function login(cellNumber: string, password: string) {
return data
}
export async function register(input: {
cellNumber: string
password: string
firstName: string
lastName: string
email?: string
domain: string
}) {
const data = await apiRequest<RegisterResponse>('/auth/register', {
method: 'POST',
body: input,
})
setTokens(data.accessToken, data.refreshToken)
return data
}
export async function fetchCurrentUser(signal?: AbortSignal) {
return apiRequest<MeResponse>('/auth/me', { auth: true, signal })
}
export async function sendOtp(cellNumber: string) {
export async function sendOtp(cellNumber: string, domain?: string) {
return apiRequest<OtpSendResponse>('/auth/send-otp', {
method: 'POST',
body: { cellNumber },
body: domain ? { cellNumber, domain } : { cellNumber },
})
}
-5
View File
@@ -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