Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion app/assets/stylesheets/components/_index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
@forward 'modal';
@forward 'nav';
@forward 'page-heading';
@forward 'password';
@forward 'profile-section';
@forward 'personal-key';
@forward 'radio-button';
Expand Down
1 change: 1 addition & 0 deletions app/components/password_confirmation_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,5 @@
>
<%= t('components.password_confirmation.toggle_label') %>
</label>
<%= render PasswordStrengthComponent.new(input_id:, forbidden_passwords:) %>
<% end %>
4 changes: 3 additions & 1 deletion app/components/password_confirmation_component.rb
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
class PasswordConfirmationComponent < BaseComponent
attr_reader :form, :field_options, :tag_options
attr_reader :form, :field_options, :forbidden_passwords, :tag_options

def initialize(
form:,
password_label: nil,
confirmation_label: nil,
field_options: {},
forbidden_passwords: [],
**tag_options
)
@form = form
@password_label = password_label
@confirmation_label = confirmation_label
@field_options = field_options
@forbidden_passwords = forbidden_passwords
@tag_options = tag_options
end

Expand Down
18 changes: 18 additions & 0 deletions app/components/password_strength_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<%= content_tag(
:'lg-password-strength',
'input-id': input_id,
'minimum-length': minimum_length,
'forbidden-passwords': forbidden_passwords.to_json,
**tag_options,
class: [*tag_options[:class], 'display-none'],
) do %>
<div class="password-strength__meter">
<div class="password-strength__meter-bar"></div>
<div class="password-strength__meter-bar"></div>
<div class="password-strength__meter-bar"></div>
<div class="password-strength__meter-bar"></div>
</div>
<%= t('instructions.password.strength.intro') %>
<span class="password-strength__strength"></span>
<div class="password-strength__feedback"></div>
<% end %>
15 changes: 15 additions & 0 deletions app/components/password_strength_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
class PasswordStrengthComponent < BaseComponent
attr_reader :input_id, :forbidden_passwords, :minimum_length, :tag_options

def initialize(
input_id:,
minimum_length: Devise.password_length.min,
forbidden_passwords: [],
**tag_options
)
@input_id = input_id
@minimum_length = minimum_length
@forbidden_passwords = forbidden_passwords
@tag_options = tag_options
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,27 @@
margin-left: units(1);
}

.pw-weak &:nth-child(-n + 1) {
lg-password-strength[score='1'] &:nth-child(-n + 1) {
background-color: color('error');
}

.pw-average &:nth-child(-n + 2) {
lg-password-strength[score='2'] &:nth-child(-n + 2) {
background-color: color('warning');
}

.pw-good &:nth-child(-n + 3) {
lg-password-strength[score='3'] &:nth-child(-n + 3) {
background-color: color('success-light');
}

.pw-great &:nth-child(-n + 4) {
lg-password-strength[score='4'] &:nth-child(-n + 4) {
background-color: color('success');
}
}

.password-strength__strength {
@include u-text(bold);
}

.password-strength__feedback {
@include u-text(italic);
}
1 change: 1 addition & 0 deletions app/components/password_strength_component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import '@18f/identity-password-strength/password-strength-element';
2 changes: 1 addition & 1 deletion app/components/password_toggle_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ def toggle_id
end

def input_id
"password-toggle-input-#{unique_id}"
field_options.dig(:input_html, :id) || "password-toggle-input-#{unique_id}"
end
end
32 changes: 32 additions & 0 deletions app/javascript/packages/password-strength/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# `@18f/password-strength`

Custom element implementation that displays a strength meter and feedback for an associated password input element.

## Usage

Importing the element will register the `<lg-password-strength>` custom element:

```ts
import '@18f/password-strength/password-strength-element';
```

The custom element will implement interactive behaviors, but all markup must already exist.

```html
<lg-password-strength
input-id="password-input"
minimum-length="12"
forbidden-passwords="[]"
class="display-none"
>
<div class="password-strength__meter">
<div class="password-strength__meter-bar"></div>
<div class="password-strength__meter-bar"></div>
<div class="password-strength__meter-bar"></div>
<div class="password-strength__meter-bar"></div>
</div>
Password strength:
<span class="password-strength__strength"></span>
<div class="password-strength__feedback"></div>
</lg-password-strength>
```
8 changes: 8 additions & 0 deletions app/javascript/packages/password-strength/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@18f/identity-password-strength",
"private": true,
"version": "1.0.0",
"dependencies": {
"zxcvbn": "^4.4.2"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { screen } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import './password-strength-element';

describe('PasswordStrengthElement', () => {
function createElement() {
document.body.innerHTML = `
<input id="password-input">
<lg-password-strength
input-id="password-input"
minimum-length="12"
forbidden-passwords="[&quot;password&quot;]"
class="display-none"
>
<div class="password-strength__meter">
<div class="password-strength__meter-bar"></div>
<div class="password-strength__meter-bar"></div>
<div class="password-strength__meter-bar"></div>
<div class="password-strength__meter-bar"></div>
</div>
Password strength:
<span class="password-strength__strength"></span>
<div class="password-strength__feedback"></div>
</lg-password-strength>
`;

return document.querySelector('lg-password-strength')!;
}

it('is shown when a value is entered', async () => {
const element = createElement();

const input = screen.getByRole('textbox');
await userEvent.type(input, 'p');

expect(element.classList.contains('display-none')).to.be.false();
});

it('is hidden when a value is removed', async () => {
const element = createElement();

const input = screen.getByRole('textbox');
await userEvent.type(input, 'p');
await userEvent.clear(input);

expect(element.classList.contains('display-none')).to.be.true();
});

it('displays strength and feedback for a given password', async () => {
const element = createElement();

const input = screen.getByRole('textbox');
await userEvent.type(input, 'p');

expect(element.getAttribute('score')).to.equal('0');
expect(screen.getByText('instructions.password.strength.0')).to.exist();
expect(
screen.getByText('zxcvbn.feedback.add_another_word_or_two_uncommon_words_are_better'),
).to.exist();
});

it('invalidates input when value is not strong enough', async () => {
createElement();

const input: HTMLInputElement = screen.getByRole('textbox');
await userEvent.type(input, 'p');

expect(input.validity.valid).to.be.false();
});

it('shows custom feedback for forbidden password', async () => {
const element = createElement();

const input: HTMLInputElement = screen.getByRole('textbox');
await userEvent.type(input, 'password');

expect(element.getAttribute('score')).to.equal('0');
expect(screen.getByText('instructions.password.strength.0')).to.exist();
expect(
screen.getByText('errors.attributes.password.avoid_using_phrases_that_are_easily_guessed'),
).to.exist();
expect(input.validity.valid).to.be.false();
});

it('shows concatenated suggestions from zxcvbn if there is no specific warning', async () => {
createElement();

const input: HTMLInputElement = screen.getByRole('textbox');
await userEvent.type(input, 'PASSWORD');

expect(
screen.getByText(
'zxcvbn.feedback.add_another_word_or_two_uncommon_words_are_better. ' +
'zxcvbn.feedback.all_uppercase_is_almost_as_easy_to_guess_as_all_lowercase',
),
).to.exist();
expect(input.validity.valid).to.be.false();
});

it('shows feedback for a password that satisfies zxcvbn but is too short', async () => {
const element = createElement();

const input: HTMLInputElement = screen.getByRole('textbox');
await userEvent.type(input, 'mRd@fX!f&G');

expect(element.getAttribute('score')).to.equal('2');
expect(screen.getByText('instructions.password.strength.2')).to.exist();
expect(screen.getByText('errors.attributes.password.too_short.other')).to.exist();
expect(input.validity.valid).to.be.false();
});

it('shows feedback for a password that is valid', async () => {
const element = createElement();

const input: HTMLInputElement = screen.getByRole('textbox');
await userEvent.type(input, 'mRd@fX!f&G?_*');

expect(element.getAttribute('score')).to.equal('4');
expect(screen.getByText('instructions.password.strength.4')).to.exist();
expect(
element.querySelector('.password-strength__feedback')!.textContent!.trim(),
).to.be.empty();
expect(input.validity.valid).to.be.true();
});
});
Loading