From 880e691b6da08144ab0c5ce6b956b6c8d567b285 Mon Sep 17 00:00:00 2001 From: Matthew White <matthew-white@users.noreply.github.com> Date: Wed, 1 Mar 2023 18:22:13 -0500 Subject: [PATCH] Add back password strength meter Closes #528. --- src/components/form-group.vue | 19 +++- src/components/password-strength.vue | 109 ++++++++++++++++++++++ test/components/form-group.spec.js | 46 ++++++--- test/components/password-strength.spec.js | 24 +++++ 4 files changed, 186 insertions(+), 12 deletions(-) create mode 100644 src/components/password-strength.vue create mode 100644 test/components/password-strength.spec.js diff --git a/src/components/form-group.vue b/src/components/form-group.vue index 1dd550d79..9de25e9cc 100644 --- a/src/components/form-group.vue +++ b/src/components/form-group.vue @@ -10,12 +10,14 @@ including this file, may be copied, modified, propagated, or distributed except according to the terms contained in the LICENSE file. --> <template> - <label class="form-group" :class="{ 'has-error': hasError }"> + <label class="form-group" :class="htmlClass"> <slot name="before"></slot> <input ref="input" v-bind="$attrs" class="form-control" :value="modelValue" :placeholder="`${placeholder}${star}`" :required="required" :autocomplete="autocomplete" @input="$emit('update:modelValue', $event.target.value)"> + <password-strength v-if="autocomplete === 'new-password'" + :password="modelValue"/> <span class="form-label">{{ placeholder }}{{ star }}</span> <slot name="after"></slot> </label> @@ -29,6 +31,8 @@ export default { <script setup> import { computed, ref } from 'vue'; +import PasswordStrength from './password-strength.vue'; + const props = defineProps({ modelValue: { type: String, @@ -47,9 +51,22 @@ const props = defineProps({ }); defineEmits(['update:modelValue']); +const htmlClass = computed(() => ({ + 'new-password': props.autocomplete === 'new-password', + 'has-error': props.hasError +})); const star = computed(() => (props.required ? ' *' : '')); const input = ref(null); const focus = () => { input.value.focus(); }; defineExpose({ focus }); </script> + +<style lang="scss"> +.form-group { + // Hide a password strength meter for password confirmation. + &.new-password ~ .form-group.new-password .password-strength { + display: none; + } +} +</style> diff --git a/src/components/password-strength.vue b/src/components/password-strength.vue new file mode 100644 index 000000000..e4a18cec4 --- /dev/null +++ b/src/components/password-strength.vue @@ -0,0 +1,109 @@ +<!-- +Copyright 2023 ODK Central Developers +See the NOTICE file at the top-level directory of this distribution and at +https://github.com/getodk/central-frontend/blob/master/NOTICE. + +This file is part of ODK Central. It is subject to the license terms in +the LICENSE file found in the top-level directory of this distribution and at +https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central, +including this file, may be copied, modified, propagated, or distributed +except according to the terms contained in the LICENSE file. +--> + +<!-- Parts of this component are based on the npm package +vue-password-strength-meter 1.7.2, which uses the MIT license. +https://github.com/apertureless/vue-password-strength-meter --> +<template> + <div class="password-strength"> + <div :data-score="score"></div> + </div> +</template> + +<script setup> +import { computed } from 'vue'; + +const props = defineProps({ + password: { + type: String, + required: true + } +}); + +const score = computed(() => { + const { length } = props.password; + if (length === 0) return 0; + if (length < 8) return 1; + if (length < 10) return 2; + if (length < 12) return 3; + if (length < 14) return 4; + return 5; +}); +</script> + +<style lang="scss"> +@use 'sass:color'; +@import '../assets/scss/mixins'; + +.password-strength { + background-color: #ddd; + float: right; + height: 2px; + margin-bottom: 20px; + margin-top: 10px; + position: relative; + width: 50%; + + // Use the borders of two pseduo-elements to create 4 blank spaces (gaps), + // resulting in 5 bars. + $between-bars: 5px; + $bar-width: calc(20% - #{4 * $between-bars / 5}); + &::before, &::after { + background-color: transparent; + border-color: #fff; + border-style: solid; + border-width: 0 $between-bars 0 $between-bars; + box-sizing: content-box; + content: ''; + display: block; + height: 100%; + position: absolute; + top: 0; + width: $bar-width; + z-index: 1; + } + &::before { left: $bar-width; } + &::after { right: $bar-width; } + + [data-score] { + height: 100%; + transition: width 0.5s ease-in-out, background-color 0.25s; + } + [data-score="0"] { + width: 0; + } + [data-score="1"] { + background-color: $color-danger; + // 20% is somewhere between the two bars (it is greater than $bar-width and + // less than $bar-width + $between-bars). But since the pseudo-elements have + // a higher z-index, only $bar-width of the background color will be + // visible (as desired). + width: 20%; + } + [data-score="2"] { + background-color: color.mix($color-danger, $color-warning); + width: 40%; + } + [data-score="3"] { + background-color: $color-warning; + width: 60%; + } + [data-score="4"] { + background-color: color.mix($color-warning, $color-success); + width: 80%; + } + [data-score="5"] { + background-color: $color-success; + width: 100%; + } +} +</style> diff --git a/test/components/form-group.spec.js b/test/components/form-group.spec.js index 8f9e6314f..4442356cb 100644 --- a/test/components/form-group.spec.js +++ b/test/components/form-group.spec.js @@ -1,9 +1,8 @@ import FormGroup from '../../src/components/form-group.vue'; +import PasswordStrength from '../../src/components/password-strength.vue'; import TestUtilSpan from '../util/components/span.vue'; -import { loadAsync } from '../../src/util/load-async'; - import { mergeMountOptions, mount } from '../util/lifecycle'; const mountComponent = (options = undefined) => @@ -61,15 +60,6 @@ describe('FormGroup', () => { formGroup.get('input').attributes().autocomplete.should.equal('name'); }); - it.skip("shows password strength meter if autocomplete prop equals 'new-password'", async () => { - const formGroup = mountComponent({ - props: { modelValue: 'foo', autocomplete: 'new-password' } - }); - const Password = await loadAsync('Password')(); - await formGroup.vm.$nextTick(); - formGroup.getComponent(Password).props().value.should.equal('foo'); - }); - it('passes attributes to the input', () => { const formGroup = mountComponent({ attrs: { type: 'email' } @@ -101,4 +91,38 @@ describe('FormGroup', () => { formGroup.vm.focus(); formGroup.get('input').should.be.focused(); }); + + describe('password strength meter', () => { + it('renders a password strength meter if autocomplete is new-password', () => { + const formGroup = mountComponent({ + props: { modelValue: 'supersecret', autocomplete: 'new-password' } + }); + const passwordStrength = formGroup.getComponent(PasswordStrength); + passwordStrength.props().password.should.equal('supersecret'); + }); + + it('does not render a password strength meter otherwise', () => { + const formGroup = mountComponent({ + props: { autocomplete: 'off' } + }); + formGroup.findComponent(PasswordStrength).exists().should.be.false(); + }); + + it('hides the strength meter for a password confirmation input', () => { + const Form = { + template: `<form> + <form-group model-value="" type="password" + placeholder="New password" autocomplete="new-password"/> + <form-group model-value="" type="password" + placeholder="New password (confirm)" autocomplete="new-password"/> + </form>`, + components: { FormGroup } + }; + const form = mount(Form, { attachTo: document.body }); + const passwordStrength = form.findAllComponents(PasswordStrength); + passwordStrength.length.should.equal(2); + passwordStrength[0].should.be.visible(true); + passwordStrength[1].should.be.hidden(true); + }); + }); }); diff --git a/test/components/password-strength.spec.js b/test/components/password-strength.spec.js new file mode 100644 index 000000000..b7c900c45 --- /dev/null +++ b/test/components/password-strength.spec.js @@ -0,0 +1,24 @@ +import PasswordStrength from '../../src/components/password-strength.vue'; + +import { mount } from '../util/lifecycle'; + +describe('PasswordStrength', () => { + const cases = [ + ['', 0], + ['a', 1], + ['aaaaaaa', 1], + ['aaaaaaaa', 2], + ['aaaaaaaaaa', 3], + ['aaaaaaaaaaaa', 4], + ['aaaaaaaaaaaaaa', 5] + ]; + for (const [password, score] of cases) { + it(`sets data-score to ${score} if the password is '${password}'`, () => { + const component = mount(PasswordStrength, { + props: { password } + }); + const data = component.get('[data-score]').attributes('data-score'); + data.should.equal(score.toString()); + }); + } +});