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>
149 lines
4.0 KiB
TypeScript
149 lines
4.0 KiB
TypeScript
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),
|
|
},
|
|
] as const
|
|
|
|
export type PasswordRuleId = (typeof PASSWORD_RULES)[number]['id'] | 'match'
|
|
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',
|
|
match: 'Passwords match',
|
|
},
|
|
}
|
|
|
|
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(
|
|
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
|
|
confirmPassword?: string
|
|
labels?: PasswordStrengthLabels
|
|
id?: string
|
|
}
|
|
|
|
export function PasswordStrengthMeter({
|
|
password,
|
|
confirmPassword = '',
|
|
labels = DEFAULT_LABELS,
|
|
id = 'password-strength',
|
|
}: PasswordStrengthMeterProps) {
|
|
const checks = getPasswordChecks(password, labels, confirmPassword)
|
|
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>
|
|
)
|
|
}
|