mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Improve auth UX and password strength across dashboards.
Remove business self-registration, localize SMS/auth errors, add password strength + confirm on reset/signup, and brand customer role as website user. Also include websites SSL sync UI updates. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
656fc57494
commit
aeaad0f645
@@ -83,9 +83,8 @@
|
|||||||
.profile {
|
.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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user