Skip to content

Commit 42b7557

Browse files
committed
frontend: Add indicator for password strength
fix #104
1 parent 009376f commit 42b7557

File tree

18 files changed

+514
-136
lines changed

18 files changed

+514
-136
lines changed

frontend/package-lock.json

Lines changed: 8 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
"react-bootstrap": "^2.10.10",
3333
"react-feather": "^2.0.10",
3434
"react-i18next": "^15.4.1",
35-
"wg-webclient": "file:../wg-webclient/pkg"
35+
"wg-webclient": "file:../wg-webclient/pkg",
36+
"zxcvbn": "^4.4.2"
3637
},
3738
"devDependencies": {
3839
"@playwright/test": "^1.56.1",

frontend/src/components/PasswordComponent.tsx

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,49 @@ import { useState } from "preact/hooks";
22
import { Button, Form, InputGroup } from "react-bootstrap";
33
import { Eye, EyeOff } from "react-feather";
44
import { useTranslation } from "react-i18next";
5+
import { PasswordStrengthIndicator } from "./PasswordStrengthIndicator";
56

67
interface PasswordComponentProps {
78
onChange: (e: string) => void,
89
isInvalid?: boolean,
910
invalidMessage?: string,
11+
showStrength?: boolean,
12+
value: string,
1013
}
1114

1215
export function PasswordComponent(props: PasswordComponentProps) {
1316
const [showPassword, setShowPassword] = useState(false);
1417
const {t} = useTranslation("", {useSuspense: false, keyPrefix: "login"})
15-
return <InputGroup hasValidation>
16-
<Form.Control
17-
placeholder={t("password")}
18-
type={showPassword ? "text" : "password"}
19-
onChange={(e) => props.onChange((e.target as HTMLInputElement).value)}
20-
isInvalid={props.isInvalid} />
21-
<Button
22-
variant="outline-primary"
23-
onClick={(e: Event) => {
24-
e.preventDefault();
25-
setShowPassword(!showPassword);
26-
}}>
27-
{!showPassword ? <Eye /> : <EyeOff />}
28-
</Button>
29-
<Form.Control.Feedback type="invalid">
30-
{props.invalidMessage}
31-
</Form.Control.Feedback>
32-
</InputGroup>
18+
19+
const handleChange = (e: Event) => {
20+
const value = (e.target as HTMLInputElement).value;
21+
props.onChange(value);
22+
};
23+
24+
return <>
25+
<InputGroup hasValidation>
26+
<Form.Control
27+
placeholder={t("password")}
28+
type={showPassword ? "text" : "password"}
29+
onChange={handleChange}
30+
value={props.value}
31+
isInvalid={props.isInvalid} />
32+
<Button
33+
variant="outline-primary"
34+
onClick={(e: Event) => {
35+
e.preventDefault();
36+
setShowPassword(!showPassword);
37+
}}>
38+
{!showPassword ? <Eye /> : <EyeOff />}
39+
</Button>
40+
<Form.Control.Feedback type="invalid">
41+
{props.invalidMessage}
42+
</Form.Control.Feedback>
43+
</InputGroup>
44+
{props.showStrength && (
45+
<PasswordStrengthIndicator
46+
password={props.value}
47+
/>
48+
)}
49+
</>
3350
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { useTranslation } from "react-i18next";
2+
import { evaluatePasswordStrength, PasswordStrength } from "../utils/passwordStrength";
3+
4+
interface PasswordStrengthIndicatorProps {
5+
password: string;
6+
show?: boolean;
7+
}
8+
9+
export function PasswordStrengthIndicator(props: PasswordStrengthIndicatorProps) {
10+
const { t } = useTranslation("", { useSuspense: false, keyPrefix: "password_strength" });
11+
12+
// Don't show anything if explicitly hidden or password is empty
13+
if (props.show === false || !props.password || props.password.length === 0) {
14+
return null;
15+
}
16+
17+
const strengthInfo = evaluatePasswordStrength(props.password);
18+
19+
const getStrengthLabel = (strength: PasswordStrength): string => {
20+
switch (strength) {
21+
case PasswordStrength.VeryWeak:
22+
return t("very_weak");
23+
case PasswordStrength.Weak:
24+
return t("weak");
25+
case PasswordStrength.Fair:
26+
return t("fair");
27+
case PasswordStrength.Strong:
28+
return t("strong");
29+
case PasswordStrength.VeryStrong:
30+
return t("very_strong");
31+
default:
32+
return "";
33+
}
34+
};
35+
36+
return (
37+
<div className="mt-2">
38+
<div className="d-flex justify-content-between align-items-center mb-1">
39+
<small className="text-muted">
40+
{t("strength")}: <strong style={{ color: strengthInfo.color }}>
41+
{getStrengthLabel(strengthInfo.strength)}
42+
</strong>
43+
</small>
44+
</div>
45+
<div
46+
style={{
47+
height: '6px',
48+
backgroundColor: '#e9ecef',
49+
borderRadius: '3px',
50+
overflow: 'hidden'
51+
}}
52+
>
53+
<div
54+
style={{
55+
width: `${strengthInfo.percentage}%`,
56+
height: '100%',
57+
backgroundColor: strengthInfo.color,
58+
transition: 'all 0.3s ease'
59+
}}
60+
/>
61+
</div>
62+
</div>
63+
);
64+
}

frontend/src/components/Register.tsx

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Component } from "preact";
22
import { Button, Form } from "react-bootstrap"
33
import { showAlert } from "./Alert";
4-
import { PASSWORD_PATTERN, fetchClient, generate_hash, generate_random_bytes, get_salt } from "../utils";
4+
import { fetchClient, generate_hash, generate_random_bytes, get_salt } from "../utils";
55
import sodium from "libsodium-wrappers";
66
import { Trans, useTranslation } from "react-i18next";
77
import i18n from "../i18n";
@@ -24,7 +24,6 @@ interface RegisterSchema {
2424
interface RegisterState {
2525
accepted: boolean,
2626
password: string,
27-
passwordValid: boolean,
2827
confirmPassword: string,
2928
confirmPasswordValid: boolean,
3029
name: string,
@@ -47,7 +46,6 @@ export class Register extends Component<{}, RegisterState> {
4746
this.state = {
4847
accepted: false,
4948
password: "",
50-
passwordValid: true,
5149
confirmPassword: "",
5250
confirmPasswordValid: true,
5351
name: "",
@@ -73,13 +71,6 @@ export class Register extends Component<{}, RegisterState> {
7371
let res = true;
7472

7573
const state = this.state as RegisterState;
76-
const passwordPatternValid = PASSWORD_PATTERN.test(this.state.password);
77-
if (!passwordPatternValid) {
78-
state.passwordValid = false;
79-
res = false;
80-
} else {
81-
state.passwordValid = true;
82-
}
8374

8475
const passwordsMatch = this.state.password === this.state.confirmPassword;
8576
if (!passwordsMatch) {
@@ -237,20 +228,24 @@ export class Register extends Component<{}, RegisterState> {
237228
</Form.Group>
238229
<Form.Group className="mb-3" controlId="registerPassword">
239230
<Form.Label>{t("password")}</Form.Label>
240-
<PasswordComponent isInvalid={!this.state.passwordValid} onChange={(e) => {
241-
this.setState({password: e}, async () => {
242-
if (!this.state.confirmPasswordValid || !this.state.passwordValid) {
243-
await this.checkPassword();
244-
}
245-
});
246-
}}
247-
invalidMessage={t("password_error_message")} />
231+
<PasswordComponent
232+
value={this.state.password}
233+
showStrength={true}
234+
onChange={(e) => {
235+
this.setState({password: e}, async () => {
236+
if (!this.state.confirmPasswordValid) {
237+
await this.checkPassword();
238+
}
239+
});
240+
}}
241+
isInvalid={this.state.password.length < 8}
242+
invalidMessage={t("password_error_message")} />
248243
</Form.Group>
249244
<Form.Group className="mb-3" controlId="registerConfirmPassword">
250245
<Form.Label>{t("confirm_password")}</Form.Label>
251-
<PasswordComponent isInvalid={!this.state.confirmPasswordValid} onChange={(e) => {
246+
<PasswordComponent value={this.state.confirmPassword} isInvalid={!this.state.confirmPasswordValid} onChange={(e) => {
252247
this.setState({confirmPassword: e}, () => {
253-
if (!this.state.confirmPasswordValid || !this.state.passwordValid) {
248+
if (!this.state.confirmPasswordValid) {
254249
this.checkPassword();
255250
}
256251
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { beforeAll, describe, expect, it, vi } from 'vitest';
2+
import { render, screen } from '@testing-library/preact';
3+
4+
let PasswordStrengthIndicator: any;
5+
6+
// Mock react-i18next
7+
vi.mock('react-i18next', () => ({
8+
useTranslation: () => ({
9+
t: (key: string) => {
10+
const translations: Record<string, string> = {
11+
'strength': 'Strength',
12+
'entropy': 'Entropy',
13+
'bits': 'bits',
14+
'very_weak': 'Very Weak',
15+
'weak': 'Weak',
16+
'fair': 'Fair',
17+
'strong': 'Strong',
18+
'very_strong': 'Very Strong',
19+
};
20+
return translations[key] || key;
21+
},
22+
}),
23+
}));
24+
25+
beforeAll(async () => {
26+
// Bypass the global mock and import the real component
27+
({ PasswordStrengthIndicator } = await vi.importActual<typeof import('../PasswordStrengthIndicator')>('../PasswordStrengthIndicator'));
28+
});
29+
30+
describe('PasswordStrengthIndicator', () => {
31+
it('does not render when password is empty', () => {
32+
const { container } = render(<PasswordStrengthIndicator password="" />);
33+
expect(container.firstChild).toBeNull();
34+
});
35+
36+
it('does not render when show is false', () => {
37+
const { container } = render(<PasswordStrengthIndicator password="test123" show={false} />);
38+
expect(container.firstChild).toBeNull();
39+
});
40+
41+
it('renders strength indicator for weak password', () => {
42+
render(<PasswordStrengthIndicator password="abc123" />);
43+
44+
expect(screen.getByText('Strength:')).toBeInTheDocument();
45+
expect(screen.getByText('Very Weak')).toBeInTheDocument();
46+
});
47+
48+
it('renders strength indicator for strong password', () => {
49+
render(<PasswordStrengthIndicator password="MyP@ssw0rd!2024" />);
50+
51+
expect(screen.getByText('Strength:')).toBeInTheDocument();
52+
expect(screen.getByText('Strong')).toBeInTheDocument();
53+
});
54+
55+
it('shows very strong for complex passwords', () => {
56+
render(<PasswordStrengthIndicator password="MyVery$ecureP@ssw0rd!WithM@nyChar$2024" />);
57+
58+
expect(screen.getByText('Very Strong')).toBeInTheDocument();
59+
});
60+
});

frontend/src/components/__tests__/login.test.tsx

Lines changed: 0 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,8 @@
11
import { render, fireEvent, waitFor, screen } from '@testing-library/preact';
22
import { describe, it, expect, vi, beforeEach } from 'vitest';
3-
import { h } from 'preact';
4-
5-
// Mock utils before importing component to avoid side effects
6-
vi.mock('../../utils', () => ({
7-
fetchClient: { POST: vi.fn(), GET: vi.fn() },
8-
get_salt_for_user: vi.fn(),
9-
generate_hash: vi.fn(),
10-
storeSecretKeyInServiceWorker: vi.fn(),
11-
AppState: { Loading: 0, LoggedIn: 1, LoggedOut: 2, Recovery: 3 },
12-
loggedIn: { value: 0 },
13-
bc: { postMessage: vi.fn() },
14-
}));
15-
16-
// Subpath mock for Form used as default import replicating global test-setup structure
17-
vi.mock('react-bootstrap/Form', () => {
18-
const Form = ({ children, onSubmit }: { children?: any; onSubmit?: (e: Event) => void }) =>
19-
h('form', { onSubmit }, children as any);
20-
Form.Group = ({ children, controlId }: { children?: any; controlId?: string }) => {
21-
if (children && Array.isArray(children)) {
22-
children = children.map((child) => {
23-
if (typeof child !== 'object') return child;
24-
child.props = { ...child.props, controlId };
25-
return child;
26-
});
27-
}
28-
return h('div', {}, children as any);
29-
};
30-
Form.Label = ({ children, controlId }: { children?: any; controlId?: string }) =>
31-
h('label', { htmlFor: controlId }, children as any);
32-
Form.Control = ({
33-
type,
34-
value,
35-
onChange,
36-
isInvalid,
37-
controlId,
38-
}: {
39-
type: string;
40-
value?: string;
41-
onChange?: (e: Event) => void;
42-
isInvalid?: boolean;
43-
controlId?: string;
44-
}) =>
45-
h('input', {
46-
id: controlId,
47-
type,
48-
value,
49-
onChange,
50-
'data-testid': `${type}-input`,
51-
className: isInvalid ? 'invalid' : '',
52-
} as any);
53-
return { default: Form };
54-
});
553

564
import { Login } from '../Login';
575

58-
// Mock Alert directly to capture calls (in addition to global test-setup mock)
59-
vi.mock('../Alert', () => ({
60-
showAlert: vi.fn(),
61-
}));
62-
63-
// i18n mock
64-
vi.mock('react-i18next', () => ({
65-
useTranslation: () => ({
66-
t: (key: string) => key,
67-
}),
68-
}));
69-
706

717
describe('Login Component', () => {
728
// eslint-disable-next-line @typescript-eslint/no-explicit-any

frontend/src/components/__tests__/register.test.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ vi.mock('../Alert', () => ({
99
}));
1010

1111
vi.mock('../../utils', () => ({
12-
PASSWORD_PATTERN: /(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}/,
1312
fetchClient: {
1413
POST: vi.fn(),
1514
},

0 commit comments

Comments
 (0)