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:
co-authored by
Cursor
parent
832ac32a09
commit
f8a9a28346
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user