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());
+    });
+  }
+});