- {t('customerProducts.subtitle', { count: total })} + {t('customerProducts.subtitle', { count: totalFiltered })}
+{t('customerProducts.loading')}
: null} - {!loading && !error && products.length === 0 ? ( -{t('customerProducts.empty')}
- - {t('customerProducts.add')} - -{t('categories.loading')}
- ) : categories.length === 0 ? ( -{t('instruction.categories.empty')}
- ) : ( +{t('instruction.list.empty')}
-{t('instruction.list.emptyHint')}
- -{heading.title}
+ {heading.subtitle ?{heading.subtitle}
: null}{t('login.welcome')}
-{t('login.subtitle')}
-{t('otp.title')}
-- {smsStep === 'phone' ? t('otp.subtitlePhone') : t('otp.subtitleCode')} -
-{t('products.list.loading')}
) : products.length === 0 ? ( -- {appliedName ? t('products.list.emptyFiltered') : t('products.list.empty')} -
+{t('categories.loading')}
- ) : categories.length === 0 ? ( -{t('portfolio.categories.empty')}
- ) : ( +{t('portfolio.list.empty')}
+{t('storeItems.loading')}
) : listings.length === 0 ? ( -{t('storeItems.empty')}
-{t('storeItems.emptyHint')}
- -{t('storeItems.emptyFiltered')}
+{t('website.specialItems.loading')}
) : specials.length === 0 ? ( -{t('website.specialItems.empty')}
+{t('categories.loading')}
- ) : categories.length === 0 ? ( -{t('video.categories.empty')}
- ) : ( +{t('video.list.empty')}
-{t('video.list.emptyHint')}
- -{t('website.specialBrands.loading')}
) : groups.length === 0 ? ( -{t('website.specialBrands.empty')}
+{t('website.specialCategories.loading')}
) : groups.length === 0 ? ( -{t('website.specialCategories.empty')}
+{t('website.staticImages.loading')}
) : visibleSlots.length === 0 ? ( -- {slots.length === 0 - ? t('website.staticImages.empty') - : t('website.staticImages.emptyPage')} -
+-
{filtered.length === 0 ? (
-
- {t('myProducts.fields.noCities')} +
- {emptyLabel} ) : ( filtered.map((option) => (
-
diff --git a/apps/customer/src/components/Header.tsx b/apps/customer/src/components/Header.tsx
index b99d56b..5183543 100644
--- a/apps/customer/src/components/Header.tsx
+++ b/apps/customer/src/components/Header.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from 'react'
+import { useEffect, useRef, useState, type MouseEvent } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Menu, Bell, MessageSquare, ChevronDown, User, KeyRound, LogOut, LayoutDashboard } from 'lucide-react'
import { LanguageSelect, PasswordResetModal, useLocale } from '@meshkee/dashboard-ui'
@@ -7,7 +7,7 @@ import { useTenantBranding } from '../context/TenantBrandingContext'
import { useT } from '../i18n/useT'
import { getActiveBusinessId } from '../lib/businessContext'
import { getBusinessDashboardUrl } from '../lib/config'
-import { changePassword } from '../services/authService'
+import { changePassword, createHandoff } from '../services/authService'
import meshkeeLogo from '../assets/meshkee-logo.png'
import styles from './Header.module.css'
@@ -56,6 +56,7 @@ export function Header({
const t = useT()
const [menuOpen, setMenuOpen] = useState(false)
const [passwordModalOpen, setPasswordModalOpen] = useState(false)
+ const [handoffBusy, setHandoffBusy] = useState(false)
const menuRef = useRef
(null) const displayName = displayUserName(user, locale, t('app.role.customer')) @@ -117,6 +118,38 @@ export function Header({ setPasswordModalOpen(true) } + async function openBusinessDashboard(event: MouseEvent ) { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) { + return + } + + event.preventDefault() + if (handoffBusy) return + + const baseUrl = getBusinessDashboardUrl() + const popup = window.open('about:blank', '_blank') + setHandoffBusy(true) + try { + const { ticket } = await createHandoff() + const url = new URL(baseUrl) + url.searchParams.set('handoff', ticket) + if (popup) { + popup.opener = null + popup.location.replace(url.toString()) + } else { + window.open(url.toString(), '_blank', 'noopener,noreferrer') + } + } catch { + if (popup) { + popup.location.replace(baseUrl) + } else { + window.open(baseUrl, '_blank', 'noopener,noreferrer') + } + } finally { + setHandoffBusy(false) + } + } + return ( <> @@ -171,6 +204,10 @@ export function Header({ rel="noopener noreferrer" aria-label={t('header.openBusinessDashboard')} title={t('header.openBusinessDashboard')} + aria-busy={handoffBusy} + onClick={(event) => { + void openBusinessDashboard(event) + }} > diff --git a/apps/customer/src/components/Sidebar.module.css b/apps/customer/src/components/Sidebar.module.css index e4dd746..b8f116a 100644 --- a/apps/customer/src/components/Sidebar.module.css +++ b/apps/customer/src/components/Sidebar.module.css @@ -29,8 +29,9 @@ height: 40px; object-fit: contain; flex-shrink: 0; - background: transparent; - border-radius: 8px; + background: none; + border-radius: 0; + mix-blend-mode: multiply; } .brandFallback { @@ -49,9 +50,9 @@ } .brandLogoUploaded { - border-radius: 8px; - object-fit: cover; - background: transparent; + border-radius: 0; + object-fit: contain; + background: none; } .brandText { @@ -118,7 +119,6 @@ .navItem.active { background: rgba(var(--primary-rgb) / 0.1); color: var(--primary); - border-color: var(--primary); } .navItem.active:hover { @@ -177,7 +177,6 @@ .subNavActive { background: rgba(var(--primary-rgb) / 0.1); color: var(--primary); - border-color: var(--primary); } .subNavActive:hover { diff --git a/apps/customer/src/i18n/messages.ts b/apps/customer/src/i18n/messages.ts index c832e67..56a5d5e 100644 --- a/apps/customer/src/i18n/messages.ts +++ b/apps/customer/src/i18n/messages.ts @@ -231,6 +231,9 @@ const en = { 'myProducts.fields.location': 'Location', 'myProducts.fields.country': 'Country', 'myProducts.fields.selectCountry': 'Select country', + 'myProducts.fields.searchCountry': 'Search country…', + 'myProducts.fields.clearCountry': 'Clear country', + 'myProducts.fields.noCountries': 'No countries found', 'myProducts.fields.province': 'Province', 'myProducts.fields.selectProvince': 'Select province', 'myProducts.fields.city': 'City', @@ -316,6 +319,7 @@ const en = { '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.invalidCredentials': 'Invalid mobile number or password.', 'signup.title': 'Create account', 'signup.subtitle': 'Register as a customer of {domain}', @@ -368,7 +372,7 @@ const en = { 'password.rule.length': 'At least 8 characters', 'password.rule.upper': 'One uppercase letter', 'password.rule.number': 'One number', - 'password.rule.special': 'One special character', + 'password.rule.match': 'Passwords match', 'otp.back': 'Back to sign in', 'otp.title': 'One-time login', @@ -620,6 +624,9 @@ const fa: Record = { 'myProducts.fields.location': 'موقعیت', 'myProducts.fields.country': 'کشور', 'myProducts.fields.selectCountry': 'انتخاب کشور', + 'myProducts.fields.searchCountry': 'جستجوی کشور…', + 'myProducts.fields.clearCountry': 'پاک کردن کشور', + 'myProducts.fields.noCountries': 'کشوری پیدا نشد', 'myProducts.fields.province': 'استان', 'myProducts.fields.selectProvince': 'انتخاب استان', 'myProducts.fields.city': 'شهر', @@ -705,6 +712,7 @@ const fa: Record = { 'login.error.sendCode': 'ارسال کد تأیید ممکن نشد.', 'login.error.cellEnglishDigits': 'خواهشمندیم از زبان انگلیسی برای شماره همراه استفاده کنید', 'login.error.smsProvider': 'سرویس پیامک موقتاً در دسترس نیست. لطفاً بعداً دوباره تلاش کنید.', + 'login.error.invalidCredentials': 'شماره موبایل یا رمز عبور نادرست است.', 'signup.title': 'ایجاد حساب', 'signup.subtitle': 'بهعنوان مشتری {domain} ثبتنام کنید', @@ -757,7 +765,7 @@ const fa: Record = { 'password.rule.length': 'حداقل ۸ کاراکتر', 'password.rule.upper': 'یک حرف بزرگ انگلیسی', 'password.rule.number': 'یک عدد', - 'password.rule.special': 'یک کاراکتر ویژه', + 'password.rule.match': 'یکسان بودن رمز عبور', 'otp.back': 'بازگشت به ورود', 'otp.title': 'ورود یکبارمصرف', diff --git a/apps/customer/src/pages/AddMyProductPage.tsx b/apps/customer/src/pages/AddMyProductPage.tsx index 69d6834..719d885 100644 --- a/apps/customer/src/pages/AddMyProductPage.tsx +++ b/apps/customer/src/pages/AddMyProductPage.tsx @@ -2,8 +2,6 @@ import { useEffect, useRef, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { Check, ClipboardList, Images, SlidersHorizontal } from 'lucide-react' import { - getLocationOptionLabel, - useLocale, useToast, type CityOption, } from '@meshkee/dashboard-ui' @@ -94,7 +92,6 @@ function isPriceCurrency( export function AddMyProductPage() { const t = useT() - const { locale } = useLocale() const navigate = useNavigate() const { id } = useParams<{ id?: string }>() const isEdit = Boolean(id) @@ -117,7 +114,6 @@ export function AddMyProductPage() { const [countries, setCountries] = useState ([]) const [cities, setCities] = useState ([]) - const [countrySlug, setCountrySlug] = useState('') const [countryId, setCountryId] = useState('') const [cityId, setCityId] = useState('') const [deliveryNote, setDeliveryNote] = useState('') @@ -218,7 +214,6 @@ export function AddMyProductPage() { : 'IRT', ) setPriceByExpert(product.priceByExpert === true) - setCountrySlug(product.countrySlug) setCountryId(product.countryId) setCityId(product.cityId) setDeliveryNote(product.deliveryNote ?? '') @@ -280,9 +275,10 @@ export function AddMyProductPage() { return () => controller.abort() }, [categoryId, t]) - async function handleCountryChange(nextSlug: string) { - setCountrySlug(nextSlug) - setCountryId(countries.find((item) => item.slug === nextSlug)?.id ?? '') + async function handleCountryChange(nextCountryId: string) { + setCountryId(nextCountryId) + const country = countries.find((item) => item.id === nextCountryId) + const nextSlug = country?.slug ?? '' setCityId('') setCities([]) if (!nextSlug) return @@ -657,19 +653,16 @@ export function AddMyProductPage() { - + placeholder={t('myProducts.fields.searchCountry')} + noResultsLabel={t('myProducts.fields.noCountries')} + clearLabel={t('myProducts.fields.clearCountry')} + />@@ -679,7 +672,7 @@ export function AddMyProductPage() { options={cities} value={cityId} onChange={setCityId} - disabled={!countrySlug || loadingLocations} + disabled={!countryId || loadingLocations} placeholder={t('myProducts.fields.searchCity')} />diff --git a/apps/customer/src/pages/LoginPage.module.css b/apps/customer/src/pages/LoginPage.module.css index e6e8206..e72241c 100644 --- a/apps/customer/src/pages/LoginPage.module.css +++ b/apps/customer/src/pages/LoginPage.module.css @@ -6,7 +6,7 @@ justify-content: center; padding: 24px; position: relative; - gap: 20px; + gap: 16px; } .card { @@ -14,7 +14,7 @@ z-index: 1; width: 100%; max-width: 420px; - padding: 36px 32px 32px; + padding: 24px 32px 28px; background: var(--glass-bg); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); @@ -26,40 +26,64 @@ .brand { display: flex; align-items: center; - justify-content: flex-start; + justify-content: center; + gap: 16px; + position: relative; + z-index: 1; + width: 100%; + max-width: 420px; +} + +.cardHeader { + display: flex; + align-items: flex-start; gap: 12px; margin-bottom: 28px; } +.heading { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; + gap: 2px; +} + .langSelect { + flex-shrink: 0; margin-inline-start: auto; } .logo { display: block; - width: 48px; - height: 48px; + width: 64px; + height: 64px; object-fit: contain; background: transparent; + flex-shrink: 0; + border-radius: var(--radius-sm); } .brandText { display: flex; flex-direction: column; + text-align: start; } .domain { - font-size: 15px; - font-weight: 600; + font-size: 20px; + font-weight: 700; color: var(--text-primary); - line-height: 1.2; + line-height: 1.25; } .appName { - font-size: 12px; + font-size: 13px; font-weight: 500; color: var(--text-muted); margin-top: 2px; + text-transform: uppercase; + letter-spacing: 0.14em; } .poweredBy { @@ -77,18 +101,20 @@ } .title { - font-size: 24px; + font-size: 20px; font-weight: 700; color: var(--text-primary); - text-align: center; - margin-bottom: 6px; + text-align: start; + margin: 0; + line-height: 1.2; } .subtitle { - font-size: 14px; + font-size: 13px; color: var(--text-secondary); - text-align: center; - margin-bottom: 28px; + text-align: start; + margin: 0; + line-height: 1.3; } .backBtn { @@ -98,7 +124,7 @@ font-size: 13px; font-weight: 500; color: var(--text-secondary); - margin-bottom: 16px; + margin-bottom: 12px; transition: color 0.2s; } diff --git a/apps/customer/src/pages/LoginPage.tsx b/apps/customer/src/pages/LoginPage.tsx index 66144fd..c3ee5bd 100644 --- a/apps/customer/src/pages/LoginPage.tsx +++ b/apps/customer/src/pages/LoginPage.tsx @@ -140,6 +140,10 @@ export function LoginPage() { err.message.includes('Unable to send SMS') ) { setError(t('login.error.smsProvider')) + } else if ( + err.message.includes('Invalid cell number or password') + ) { + setError(t('login.error.invalidCredentials')) } else { setError(err.message) } @@ -371,15 +375,51 @@ export function LoginPage() { } } + const heading = + view === 'login' + ? { title: t('login.welcome'), subtitle: t('login.subtitle') } + : view === 'signup' + ? { title: t('signup.title'), subtitle: null } + : view === 'forgot' + ? { + title: t('forgot.title'), + subtitle: smsStep === 'phone' ? t('forgot.subtitlePhone') : t('forgot.subtitleCode'), + } + : { + title: t('otp.title'), + subtitle: smsStep === 'phone' ? t('otp.subtitlePhone') : t('otp.subtitleCode'), + } + return (+ ++
+ + {businessName || tenantDomain} + + + {businessNameEn || tenantDomain} + ++---
- {businessName || tenantDomain} - {businessNameEn || tenantDomain} + {(view === 'forgot' || view === 'otp') && ( + + )} +++{heading.title}
+ {heading.subtitle ?{heading.subtitle}
: null}@@ -642,21 +681,6 @@ export function LoginPage() { {view === 'forgot' && ( <> - - -@@ -388,9 +428,6 @@ export function LoginPage() { {view === 'login' && ( <> - {t('login.welcome')}
-{t('login.subtitle')}
-{error && ( @@ -490,8 +527,6 @@ export function LoginPage() { {view === 'signup' && ( <> -{t('signup.title')}
-{error && ( @@ -578,24 +613,6 @@ export function LoginPage() { {showPassword ?-: } @@ -613,14 +630,36 @@ export function LoginPage() { required minLength={8} disabled={isSubmitting} + aria-describedby="signup-password-strength signup-password-strength-rules" />+{t('forgot.title')}
-- {smsStep === 'phone' ? t('forgot.subtitlePhone') : t('forgot.subtitleCode')} -
-{error && ( @@ -746,24 +770,6 @@ export function LoginPage() { {showPassword ?-: } @@ -781,8 +787,28 @@ export function LoginPage() { required minLength={8} disabled={isSubmitting} + aria-describedby="forgot-password-strength" />+@@ -803,7 +829,7 @@ export function LoginPage() { @@ -815,21 +841,6 @@ export function LoginPage() { {view === 'otp' && ( <> - - -{t('otp.title')}
-- {smsStep === 'phone' ? t('otp.subtitlePhone') : t('otp.subtitleCode')} -
-{error && ( diff --git a/apps/customer/src/services/authService.ts b/apps/customer/src/services/authService.ts index cfcfd1d..e5af56e 100644 --- a/apps/customer/src/services/authService.ts +++ b/apps/customer/src/services/authService.ts @@ -7,6 +7,7 @@ import type { OtpSendResponse, OtpVerifyResponse, RegisterResponse, + HandoffCreateResponse, UserProfile, } from '../types/auth' @@ -105,6 +106,13 @@ export async function resetPassword(cellNumber: string, code: string, newPasswor }) } +export async function createHandoff() { + return apiRequest('/auth/handoff', { + method: 'POST', + auth: true, + }) +} + export function logout() { clearTokens() clearActiveBusiness() diff --git a/apps/customer/src/types/auth.ts b/apps/customer/src/types/auth.ts index 4c6ade7..7ec25b8 100644 --- a/apps/customer/src/types/auth.ts +++ b/apps/customer/src/types/auth.ts @@ -6,5 +6,6 @@ export type { OtpSendResponse, OtpVerifyResponse, RegisterResponse, + HandoffCreateResponse, UserProfile, } from '@meshkee/dashboard-core' diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 9e8e17d..210db83 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -3,7 +3,7 @@ > **For AI agents:** Read this file at the start of a new chat before making changes. > Update this document when a major feature is completed or architecture changes. -Last updated: August 18, 2026 +Last updated: August 19, 2026 --- @@ -236,8 +236,9 @@ Business and customer login pages share the same SMS-backed flows (super-admin s | One-time login | `POST /auth/login-otp` | Code only → tokens + marks cell verified | | Forgot password | `POST /auth/reset-password` | Code + new password | | Verify only | `POST /auth/verify-otp` | Marks verified; no tokens | +| Dashboard SSO | `POST /auth/handoff` + `POST /auth/handoff/consume` | Admin icon on customer dashboard; 60s one-time ticket | -Auth helpers: `apps/*/src/services/authService.ts` (`login`, `loginWithOtp`, `resetPassword`, `sendOtp`, `register`). `AuthContext` exposes `login` + `loginWithOtp`. +Auth helpers: `apps/*/src/services/authService.ts` (`login`, `loginWithOtp`, `resetPassword`, `sendOtp`, `register`, `createHandoff`). `AuthContext` exposes `login` + `loginWithOtp`. Customer header “open business dashboard” uses a one-time SSO ticket so staff do not log in again. --- diff --git a/packages/dashboard-core/src/index.ts b/packages/dashboard-core/src/index.ts index 5b3705f..88f0c6d 100644 --- a/packages/dashboard-core/src/index.ts +++ b/packages/dashboard-core/src/index.ts @@ -9,6 +9,7 @@ export type { OtpSendResponse, OtpVerifyResponse, RegisterResponse, + HandoffCreateResponse, UserProfile, } from './types/auth' diff --git a/packages/dashboard-core/src/types/auth.ts b/packages/dashboard-core/src/types/auth.ts index 20ebd9c..b413c42 100644 --- a/packages/dashboard-core/src/types/auth.ts +++ b/packages/dashboard-core/src/types/auth.ts @@ -65,3 +65,8 @@ export interface OtpVerifyResponse { verified: boolean message: string } + +export interface HandoffCreateResponse { + ticket: string + expiresInSeconds: number +} diff --git a/packages/dashboard-ui/src/components/PasswordResetModal.module.css b/packages/dashboard-ui/src/components/PasswordResetModal.module.css index 711b5f6..43d5ba3 100644 --- a/packages/dashboard-ui/src/components/PasswordResetModal.module.css +++ b/packages/dashboard-ui/src/components/PasswordResetModal.module.css @@ -22,6 +22,7 @@ border: 1px solid var(--glass-border); border-radius: var(--radius); box-shadow: 0 20px 60px rgba(var(--primary-rgb) / 0.16); + font-family: var(--font-ui); } .title { @@ -29,6 +30,7 @@ font-weight: 700; color: var(--text-primary); margin-bottom: 6px; + padding-inline-end: 36px; } .subtitle { @@ -85,90 +87,6 @@ opacity: 0.7; } -.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; -} - .error, .success { padding: 10px 12px; @@ -234,7 +152,7 @@ .closeBtn { position: absolute; top: 14px; - right: 14px; + inset-inline-end: 14px; width: 32px; height: 32px; display: flex; diff --git a/packages/dashboard-ui/src/components/PasswordResetModal.tsx b/packages/dashboard-ui/src/components/PasswordResetModal.tsx index d59776b..c111ba4 100644 --- a/packages/dashboard-ui/src/components/PasswordResetModal.tsx +++ b/packages/dashboard-ui/src/components/PasswordResetModal.tsx @@ -1,6 +1,14 @@ -import { useEffect, useState, type FormEvent } from 'react' +import { useContext, useEffect, useState, type FormEvent } from 'react' import { createPortal } from 'react-dom' import { X } from 'lucide-react' +import type { DashboardLocale } from '@meshkee/dashboard-core' +import { LocaleContext } from '../context/LocaleContext' +import { + PasswordStrengthMeter, + getPasswordValidationError, + isPasswordStrong, + type PasswordStrengthLabels, +} from './PasswordStrengthMeter' import styles from './PasswordResetModal.module.css' export type ChangePasswordHandler = ( @@ -18,67 +26,94 @@ export interface PasswordResetModalProps { const ANIMATION_MS = 220 -const PASSWORD_RULES = [ - { - id: 'length', - label: 'At least 8 characters', - test: (value: string) => value.length >= 8, - }, - { - id: 'upper', - label: 'One uppercase letter', - test: (value: string) => /[A-Z]/.test(value), - }, - { - id: 'number', - label: 'One number', - test: (value: string) => /\d/.test(value), - }, - { - id: 'special', - label: 'One special character', - test: (value: string) => /[^A-Za-z0-9]/.test(value), - }, -] as const - -type StrengthTone = 'empty' | 'weak' | 'fair' | 'good' | 'strong' - -function getPasswordChecks(password: string) { - return PASSWORD_RULES.map((rule) => ({ - id: rule.id, - label: rule.label, - met: rule.test(password), - })) +type Copy = { + title: string + subtitle: string + currentPassword: string + newPassword: string + confirmPassword: string + cancel: string + submit: string + saving: string + close: string + mismatch: string + unable: string + mustInclude: (list: string) => string + strength: PasswordStrengthLabels } -function getStrengthMeta(metCount: number, hasInput: boolean): { - tone: StrengthTone - label: string - percent: number -} { - if (!hasInput || metCount === 0) { - return { tone: 'empty', label: 'Enter a password', percent: 0 } - } - - if (metCount === 1) return { tone: 'weak', label: 'Weak', percent: 25 } - if (metCount === 2) return { tone: 'fair', label: 'Fair', percent: 50 } - if (metCount === 3) return { tone: 'good', label: 'Good', percent: 75 } - return { tone: 'strong', label: 'Strong', percent: 100 } -} - -function getPasswordValidationError(password: string): string | null { - const unmet = PASSWORD_RULES.filter((rule) => !rule.test(password)) - if (unmet.length === 0) return null - return `Password must include: ${unmet.map((rule) => rule.label.toLowerCase()).join(', ')}.` +const COPY: Record = { + en: { + title: 'Change password', + subtitle: 'Enter your current password and choose a new one.', + currentPassword: 'Current password', + newPassword: 'New password', + confirmPassword: 'Confirm password', + cancel: 'Cancel', + submit: 'Update password', + saving: 'Saving...', + close: 'Close', + mismatch: 'New password and confirmation do not match.', + unable: 'Unable to change password.', + mustInclude: (list) => `Password must include: ${list}.`, + strength: { + 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', + match: 'Passwords match', + }, + }, + }, + fa: { + title: 'تغییر رمز عبور', + subtitle: 'رمز عبور فعلی را وارد کنید و رمز جدیدی انتخاب کنید.', + currentPassword: 'رمز عبور فعلی', + newPassword: 'رمز عبور جدید', + confirmPassword: 'تأیید رمز عبور', + cancel: 'انصراف', + submit: 'بهروزرسانی رمز عبور', + saving: 'در حال ذخیره...', + close: 'بستن', + mismatch: 'رمزهای عبور یکسان نیستند.', + unable: 'تغییر رمز عبور ممکن نشد.', + mustInclude: (list) => `رمز عبور باید شامل این موارد باشد: ${list}.`, + strength: { + title: 'قدرت رمز عبور', + empty: 'رمز عبور را وارد کنید', + weak: 'ضعیف', + fair: 'متوسط', + good: 'خوب', + strong: 'قوی', + rules: { + length: 'حداقل ۸ کاراکتر', + upper: 'یک حرف بزرگ انگلیسی', + number: 'یک عدد', + match: 'یکسان بودن رمز عبور', + }, + }, + }, } export function PasswordResetModal({ open, onClose, onChangePassword, - title = 'Change password', - subtitle = 'Enter your current password and choose a new one.', + title, + subtitle, }: PasswordResetModalProps) { + const locale = useContext(LocaleContext)?.locale ?? 'en' + const copy = COPY[locale] + const dir = locale === 'fa' ? 'rtl' : 'ltr' + const heading = title ?? copy.title + const description = subtitle ?? copy.subtitle + const [mounted, setMounted] = useState(open) const [closing, setClosing] = useState(false) const [currentPassword, setCurrentPassword] = useState('') @@ -88,10 +123,8 @@ export function PasswordResetModal({ const [success, setSuccess] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) - const checks = getPasswordChecks(newPassword) - const metCount = checks.filter((check) => check.met).length - const strength = getStrengthMeta(metCount, newPassword.length > 0) - const passwordValid = metCount === PASSWORD_RULES.length + const passwordValid = + isPasswordStrong(newPassword) && confirmPassword.length > 0 && newPassword === confirmPassword useEffect(() => { if (open) { @@ -126,14 +159,14 @@ export function PasswordResetModal({ setError('') setSuccess('') - const validationError = getPasswordValidationError(newPassword) - if (validationError) { - setError(validationError) + const unmet = getPasswordValidationError(newPassword, copy.strength) + if (unmet) { + setError(copy.mustInclude(unmet)) return } if (newPassword !== confirmPassword) { - setError('New password and confirmation do not match.') + setError(copy.mismatch) return } @@ -146,7 +179,7 @@ export function PasswordResetModal({ setNewPassword('') setConfirmPassword('') } catch (err) { - setError(err instanceof Error ? err.message : 'Unable to change password.') + setError(err instanceof Error ? err.message : copy.unable) } finally { setIsSubmitting(false) } @@ -165,15 +198,17 @@ export function PasswordResetModal({ role="dialog" aria-modal="true" aria-labelledby="password-reset-title" + lang={locale} + dir={dir} > -