Skip to content

Commit

Permalink
Add back password strength meter
Browse files Browse the repository at this point in the history
Closes #528.
  • Loading branch information
matthew-white committed Mar 1, 2023
1 parent d4f1241 commit 880e691
Show file tree
Hide file tree
Showing 4 changed files with 186 additions and 12 deletions.
19 changes: 18 additions & 1 deletion src/components/form-group.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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,
Expand All @@ -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>
109 changes: 109 additions & 0 deletions src/components/password-strength.vue
Original file line number Diff line number Diff line change
@@ -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>
46 changes: 35 additions & 11 deletions test/components/form-group.spec.js
Original file line number Diff line number Diff line change
@@ -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) =>
Expand Down Expand Up @@ -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' }
Expand Down Expand Up @@ -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);
});
});
});
24 changes: 24 additions & 0 deletions test/components/password-strength.spec.js
Original file line number Diff line number Diff line change
@@ -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());
});
}
});

0 comments on commit 880e691

Please sign in to comment.