Add shared empty states, customer product filters, and SSO handoff across dashboards.

Standardize list empty UI, searchable country fields, customer-products filter row, password/login polish, and one-time business dashboard handoff from the customer app.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-19 19:46:57 +03:30
co-authored by Cursor
parent 832ac32a09
commit f8a9a28346
55 changed files with 1264 additions and 907 deletions
+1
View File
@@ -9,6 +9,7 @@ export type {
OtpSendResponse,
OtpVerifyResponse,
RegisterResponse,
HandoffCreateResponse,
UserProfile,
} from './types/auth'
@@ -65,3 +65,8 @@ export interface OtpVerifyResponse {
verified: boolean
message: string
}
export interface HandoffCreateResponse {
ticket: string
expiresInSeconds: number
}
@@ -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;
@@ -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<DashboardLocale, Copy> = {
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}
>
<button type="button" className={styles.closeBtn} onClick={onClose} aria-label="Close">
<button type="button" className={styles.closeBtn} onClick={onClose} aria-label={copy.close}>
<X size={18} />
</button>
<h3 id="password-reset-title" className={styles.title}>
{title}
{heading}
</h3>
<p className={styles.subtitle}>{subtitle}</p>
<p className={styles.subtitle}>{description}</p>
<form className={styles.form} onSubmit={(e) => void handleSubmit(e)} autoComplete="off">
{error && (
@@ -188,7 +223,7 @@ export function PasswordResetModal({
)}
<div className={styles.field}>
<label htmlFor="password-reset-current">Current password</label>
<label htmlFor="password-reset-current">{copy.currentPassword}</label>
<input
id="password-reset-current"
type="password"
@@ -204,7 +239,7 @@ export function PasswordResetModal({
</div>
<div className={styles.field}>
<label htmlFor="password-reset-new">New password</label>
<label htmlFor="password-reset-new">{copy.newPassword}</label>
<input
id="password-reset-new"
type="password"
@@ -217,45 +252,11 @@ export function PasswordResetModal({
required
minLength={8}
disabled={isSubmitting}
aria-describedby="password-reset-strength password-reset-rules"
/>
<div className={styles.strength} id="password-reset-strength">
<div className={styles.strengthHeader}>
<span className={styles.strengthTitle}>Password strength</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="Password strength"
>
<div
className={`${styles.strengthFill} ${styles[`fill_${strength.tone}`]}`}
style={{ width: `${strength.percent}%` }}
/>
</div>
<ul className={styles.rules} id="password-reset-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>
</div>
<div className={styles.field}>
<label htmlFor="password-reset-confirm">Confirm password</label>
<label htmlFor="password-reset-confirm">{copy.confirmPassword}</label>
<input
id="password-reset-confirm"
type="password"
@@ -268,19 +269,27 @@ export function PasswordResetModal({
required
minLength={8}
disabled={isSubmitting}
aria-describedby="password-reset-strength password-reset-strength-rules"
/>
<PasswordStrengthMeter
password={newPassword}
confirmPassword={confirmPassword}
labels={copy.strength}
id="password-reset-strength"
/>
</div>
<div className={styles.actions}>
<button type="button" className={styles.cancelBtn} onClick={onClose} disabled={isSubmitting}>
Cancel
{copy.cancel}
</button>
<button
type="submit"
className={styles.submitBtn}
disabled={isSubmitting || !passwordValid}
>
{isSubmitting ? 'Saving...' : 'Update password'}
{isSubmitting ? copy.saving : copy.submit}
</button>
</div>
</form>
@@ -13,13 +13,9 @@ const PASSWORD_RULES = [
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 PasswordRuleId = (typeof PASSWORD_RULES)[number]['id'] | 'match'
export type StrengthTone = 'empty' | 'weak' | 'fair' | 'good' | 'strong'
export type PasswordStrengthLabels = {
@@ -43,16 +39,31 @@ const DEFAULT_LABELS: PasswordStrengthLabels = {
length: 'At least 8 characters',
upper: 'One uppercase letter',
number: 'One number',
special: 'One special character',
match: 'Passwords match',
},
}
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),
}))
function passwordsMatch(password: string, confirmPassword: string) {
return confirmPassword.length > 0 && password === confirmPassword
}
export function getPasswordChecks(
password: string,
labels: PasswordStrengthLabels = DEFAULT_LABELS,
confirmPassword = '',
) {
return [
...PASSWORD_RULES.map((rule) => ({
id: rule.id,
label: labels.rules[rule.id],
met: rule.test(password),
})),
{
id: 'match' as const,
label: labels.rules.match,
met: passwordsMatch(password, confirmPassword),
},
]
}
export function getStrengthMeta(
@@ -88,16 +99,18 @@ export function getPasswordValidationError(
export interface PasswordStrengthMeterProps {
password: string
confirmPassword?: string
labels?: PasswordStrengthLabels
id?: string
}
export function PasswordStrengthMeter({
password,
confirmPassword = '',
labels = DEFAULT_LABELS,
id = 'password-strength',
}: PasswordStrengthMeterProps) {
const checks = getPasswordChecks(password, labels)
const checks = getPasswordChecks(password, labels, confirmPassword)
const metCount = checks.filter((check) => check.met).length
const strength = getStrengthMeta(metCount, password.length > 0, labels)