feat: password strength bar
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { isPasswordStrong } from './password';
|
import { getPasswordStrength, isPasswordStrong } from './password';
|
||||||
|
|
||||||
describe('isPasswordStrong', () => {
|
describe('isPasswordStrong', () => {
|
||||||
it('rejects passwords shorter than eight characters', () => {
|
it('rejects passwords shorter than eight characters', () => {
|
||||||
@@ -13,4 +13,8 @@ describe('isPasswordStrong', () => {
|
|||||||
it('accepts passwords with a zxcvbn score above two', () => {
|
it('accepts passwords with a zxcvbn score above two', () => {
|
||||||
expect(isPasswordStrong('correct horse battery staple')).toBe(true);
|
expect(isPasswordStrong('correct horse battery staple')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns a display label and the zxcvbn score', () => {
|
||||||
|
expect(getPasswordStrength('password1')).toMatchObject({ score: 0, isStrong: false });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,28 @@ import zxcvbn from 'zxcvbn';
|
|||||||
const MIN_PASSWORD_LENGTH = 8;
|
const MIN_PASSWORD_LENGTH = 8;
|
||||||
const MIN_PASSWORD_SCORE = 3;
|
const MIN_PASSWORD_SCORE = 3;
|
||||||
|
|
||||||
|
export interface PasswordStrength {
|
||||||
|
score: number;
|
||||||
|
isStrong: boolean;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const strengthLabels = ['Слишком слабый', 'Слабый', 'Ненадёжный', 'Надёжный', 'Очень надёжный'];
|
||||||
|
|
||||||
|
export function getPasswordStrength(password: string): PasswordStrength | null {
|
||||||
|
if (!password) return null;
|
||||||
|
|
||||||
|
const score = zxcvbn(password).score;
|
||||||
|
return {
|
||||||
|
score,
|
||||||
|
isStrong: password.length >= MIN_PASSWORD_LENGTH && score >= MIN_PASSWORD_SCORE,
|
||||||
|
label:
|
||||||
|
password.length < MIN_PASSWORD_LENGTH ? 'Минимум 8 символов' : (strengthLabels[score] ?? 'Слишком слабый'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function isPasswordStrong(password: string): boolean {
|
export function isPasswordStrong(password: string): boolean {
|
||||||
return password.length >= MIN_PASSWORD_LENGTH && zxcvbn(password).score >= MIN_PASSWORD_SCORE;
|
return getPasswordStrength(password)?.isStrong ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const passwordStrengthMessage =
|
export const passwordStrengthMessage =
|
||||||
|
|||||||
@@ -124,6 +124,18 @@ describe('LoginPage', () => {
|
|||||||
expect(api.signup).not.toHaveBeenCalled();
|
expect(api.signup).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows a dynamic password strength meter during signup', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderLogin();
|
||||||
|
await user.click(screen.getByRole('tab', { name: /Регистрация/i }));
|
||||||
|
await user.type(screen.getByLabelText('Пароль'), 'password1');
|
||||||
|
|
||||||
|
expect(screen.getByRole('progressbar', { name: /надёжность пароля/i })).toHaveAttribute(
|
||||||
|
'aria-valuetext',
|
||||||
|
'Слишком слабый',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('shows backend error on login failure', async () => {
|
it('shows backend error on login failure', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
vi.mocked(api.login).mockRejectedValue(new ApiError('неверные данные', 401));
|
vi.mocked(api.login).mockRejectedValue(new ApiError('неверные данные', 401));
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Emblem } from '../components/Emblem';
|
|||||||
import { Logo } from '../components/Logo';
|
import { Logo } from '../components/Logo';
|
||||||
import { Reveal } from '../components/Reveal';
|
import { Reveal } from '../components/Reveal';
|
||||||
import { getReferralCode } from '../lib/referral';
|
import { getReferralCode } from '../lib/referral';
|
||||||
import { isPasswordStrong, passwordStrengthMessage } from '../lib/password';
|
import { getPasswordStrength, isPasswordStrong, passwordStrengthMessage } from '../lib/password';
|
||||||
|
|
||||||
type Mode = 'signin' | 'signup';
|
type Mode = 'signin' | 'signup';
|
||||||
|
|
||||||
@@ -54,6 +54,7 @@ export function LoginPage() {
|
|||||||
const queryReturnTo = new URLSearchParams(location.search).get('returnTo');
|
const queryReturnTo = new URLSearchParams(location.search).get('returnTo');
|
||||||
const returnTo =
|
const returnTo =
|
||||||
returnPath(queryReturnTo) ?? returnPath((location.state as LocationState | null)?.returnTo) ?? '/account';
|
returnPath(queryReturnTo) ?? returnPath((location.state as LocationState | null)?.returnTo) ?? '/account';
|
||||||
|
const passwordStrength = mode === 'signup' ? getPasswordStrength(password) : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) navigate(returnTo, { replace: true });
|
if (isAuthenticated) navigate(returnTo, { replace: true });
|
||||||
@@ -205,6 +206,28 @@ export function LoginPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{mode === 'signup' && passwordStrength && (
|
||||||
|
<div className="password-strength" aria-live="polite">
|
||||||
|
<div
|
||||||
|
className={`password-strength-meter ${passwordStrength.isStrong ? 'is-strong' : ''}`.trim()}
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Надёжность пароля"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={4}
|
||||||
|
aria-valuenow={passwordStrength.score}
|
||||||
|
aria-valuetext={passwordStrength.label}
|
||||||
|
>
|
||||||
|
{[0, 1, 2, 3].map((segment) => (
|
||||||
|
<span
|
||||||
|
key={segment}
|
||||||
|
className={segment < passwordStrength.score ? 'is-filled' : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="password-strength-label">{passwordStrength.label}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{mode === 'signin' && (
|
{mode === 'signin' && (
|
||||||
<div className="auth-row">
|
<div className="auth-row">
|
||||||
<label className="check">
|
<label className="check">
|
||||||
|
|||||||
@@ -227,6 +227,37 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
.password-strength {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: -10px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.password-strength-meter {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
flex: 1;
|
||||||
|
gap: 3px;
|
||||||
|
height: 3px;
|
||||||
|
}
|
||||||
|
.password-strength-meter span {
|
||||||
|
background: var(--hairline-strong);
|
||||||
|
transition: background 0.25s;
|
||||||
|
}
|
||||||
|
.password-strength-meter.is-strong span.is-filled {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
.password-strength-meter:not(.is-strong) span.is-filled {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
.password-strength-label {
|
||||||
|
min-width: 138px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
.auth-divider {
|
.auth-divider {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user