MT#60689 Dynamic password requirements

Password requirements have been edited so that are not
hardcoded. They are stored in the config.yaml file
and passed to the frontend through the
platforminfo.security.password object.
We adapt the frontend to:
- pick this info
- display the precise requirements in a tooltip that appears
  when users hoover on a password field
- adapt the relevant inputs validations accordingly
- update the password components to use the centralised
  $errMsg() function to handle error messages.
- fix weird behaviour of retype password where was not
  showing validation errors
- remove "score > 2" as a necessary condition to enable the
  retype field

Change-Id: I0feea3d7c5c2ae977402c3576e883899117f8864
mr13.1
Debora Crescenzo 8 months ago
parent 20ba314c3a
commit 0b0846d7e7

@ -11,7 +11,7 @@
:label="$t('Password')"
:disable="loading"
:error="v$.password.$errors.length > 0"
:error-message="errorMessagePass"
:error-message="$errMsg(v$.password.$errors)"
@blur="v$.password.$touch()"
/>
<password-meter
@ -30,7 +30,7 @@
:label="$t('Password Retype')"
:disable="loading"
:error="v$.passwordRetype.$errors.length > 0"
:error-message="errorMessagePassRetype"
:error-message="$errMsg(v$.passwordRetype.$errors)"
@blur="v$.passwordRetype.$touch();onRetypeBlur()"
/>
</div>
@ -38,11 +38,10 @@
<script>
import PasswordMeter from 'vue-simple-password-meter'
import {
required
} from '@vuelidate/validators'
import { maxLength, minLength, required } from '@vuelidate/validators'
import CscInputPassword from 'components/form/CscInputPassword'
import useValidate from '@vuelidate/core'
import { mapGetters } from 'vuex'
export default {
name: 'CscChangePasswordForm',
components: {
@ -66,40 +65,25 @@ export default {
passwordRetype: '',
passwordScored: '',
passwordStrengthScore: null,
v$: useValidate()
}
},
validations: {
password: {
required,
passwordStrength () {
return this.passwordStrengthScore >= 2
v$: useValidate(),
messages: []
}
},
validations () {
return {
password: { ...this.getPasswordValidations() },
passwordRetype: {
required,
sameAsPassword (val) {
return val === this.password
}
}
},
computed: {
errorMessagePass () {
const errorsTab = this.v$.password.$errors
if (errorsTab && errorsTab.length > 0 && errorsTab[0].$validator === 'passwordStrength') {
return this.$t('Password is not strong enough')
} else {
return ''
}
},
errorMessagePassRetype () {
const errorsTab = this.v$.passwordRetype.$errors
if (errorsTab && errorsTab.length > 0 && errorsTab[0].$validator === 'sameAsPassword') {
return this.$t('Passwords must be equal')
} else {
return ''
}
}
computed: {
...mapGetters('user', [
'passwordRequirements'
])
},
watch: {
password (value) {
@ -110,7 +94,63 @@ export default {
}
}
},
mounted () {
this.messages = this.getPasswordRequirementsMessages()
},
methods: {
getPasswordRequirementsMessages () {
if (!this.passwordRequirements?.web_validate) {
return
}
const lengthMessage = this.passwordRequirements.min_length > 0
? `must be between ${this.passwordRequirements.min_length} and ${this.passwordRequirements.max_length} characters long`
: null
const digitsMessage = this.passwordRequirements.musthave_digit > 0
? `must contain at least ${this.passwordRequirements.musthave_digit} digits`
: null
const lowercaseMessage = this.passwordRequirements.musthave_lowercase > 0
? `must contain at least ${this.passwordRequirements.musthave_lowercase} lowercase`
: null
const uppercaseReq = this.passwordRequirements.musthave_uppercase > 0
? `must contain at least ${this.passwordRequirements.musthave_uppercase} uppercase`
: null
const specialCharReq = this.passwordRequirements.musthave_specialchar > 0
? `must contain at least ${this.passwordRequirements.musthave_specialchar} special characters`
: null
return [lengthMessage, digitsMessage, lowercaseMessage, uppercaseReq, specialCharReq].filter((message) => message !== null)
},
getPasswordValidations () {
if (this.passwordRequirements?.web_validate) {
return {
required,
passwordMaxLength: maxLength(this.passwordRequirements.max_length),
passwordMinLength: minLength(this.passwordRequirements.min_length),
passwordDigits () {
const digitPattern = /\d/g
return (this.password.match(digitPattern) || []).length >= this.passwordRequirements.musthave_digit
},
passwordLowercase () {
const lowercasePattern = /[a-z]/g
return (this.password.match(lowercasePattern) || []).length >= this.passwordRequirements.musthave_lowercase
},
passwordUppercase () {
const uppercasePattern = /[A-Z]/g
return (this.password.match(uppercasePattern) || []).length >= this.passwordRequirements.musthave_uppercase
},
passwordChars () {
const specialCharPattern = /[\W_]/g
return (this.password.match(specialCharPattern) || []).length >= this.passwordRequirements.musthave_specialchar
},
passwordStrength () {
return this.passwordScore >= 2
}
}
}
return { required }
},
strengthMeterScoreUpdate (evt) {
this.passwordStrengthScore = evt.score
},

@ -2,14 +2,37 @@
<div
class="csc-input-password-retype"
>
<q-tooltip v-if="messages.length > 0">
<div class="tooltip-message q-pa-md text-body2">
Password requirements:
<q-item
v-for="(message, index) in messages"
:key="index"
dense
>
<q-item-section>
<span>
<q-icon
name="lock"
size="1em"
class="q-pa-xs"
/> {{ message }}
</span>
</q-item-section>
</q-item>
</div>
</q-tooltip>
<csc-input-password
ref="password"
v-model="password"
v-bind="$attrs"
generate
clearable
:error="v$.password.$errors.length > 0"
:error-message="$errMsg(v$.password.$errors)"
:label="passwordLabel"
@update:model-value="inputPassword"
@blur="v$.password.$touch()"
@generated="passwordGenerated"
@clear="passwordClear"
/>
@ -29,9 +52,9 @@
v-bind="$attrs"
:label="passwordConfirmLabel"
:error="v$.passwordRetype.$errors.length > 0"
:error-message="errorMessagePasswordRetype"
:error-message="$errMsg(v$.passwordRetype.$errors)"
clearable
:disable="passwordScore < 2 || $attrs.disable"
:disable="$attrs.disable"
@clear="v$.passwordRetype.$reset()"
@blur="passwordRetypeBlur"
@update:model-value="inputRetypePassword"
@ -39,13 +62,12 @@
</div>
</template>
<script>
import {
sameAs,
required
} from '@vuelidate/validators'
import CscInputPassword from 'components/form/CscInputPassword'
import PasswordMeter from 'vue-simple-password-meter'
import useValidate from '@vuelidate/core'
import { sameAs, required, maxLength, minLength } from '@vuelidate/validators'
import { mapGetters } from 'vuex'
export default {
name: 'CscInputPasswordRetype',
components: {
@ -75,6 +97,12 @@ export default {
// eslint-disable-next-line vue/no-deprecated-props-default-this
return this.$t('Password Retype')
}
},
passwordType: {
type: String,
default () {
return 'web'
}
}
},
emits: ['validation-failed', 'validation-succeeded', 'update:modelValue', 'score'],
@ -83,14 +111,13 @@ export default {
password: this.modelValue.password,
passwordRetype: this.modelValue.passwordRetype,
passwordScore: null,
v$: useValidate()
v$: useValidate(),
messages: []
}
},
validations () {
return {
password: {
required
},
password: { ...this.getPasswordValidations() },
passwordRetype: {
required,
sameAsPassword: sameAs(this.password)
@ -98,13 +125,11 @@ export default {
}
},
computed: {
errorMessagePasswordRetype () {
const errorsTab = this.v$.passwordRetype.$errors
if (errorsTab && errorsTab.length > 0 && errorsTab[0].$validator === 'sameAsPassword') {
return this.$t('Passwords must be equal')
} else {
return ''
}
...mapGetters('user', [
'passwordRequirements'
]),
areValidationsActive () {
return this.passwordType === 'web' ? this.passwordRequirements.web_validate : this.passwordRequirements.sip_validate
},
passwordScoreMappedValue () {
if (this.passwordScore === null || this.passwordScore === undefined) {
@ -138,12 +163,66 @@ export default {
this.v$.$reset()
this.$refs.passwordRetype.clear()
this.$refs.password.clear()
this.messages = this.getPasswordRequirementsMessages()
},
methods: {
strengthMeterScoreUpdate (evt) {
this.passwordScore = evt.score
this.$emit('score', evt.score)
},
getPasswordRequirementsMessages () {
if (!this.areValidationsActive) {
return []
}
const lengthMessage = this.passwordRequirements.minLength > 0
? `must be between ${this.passwordRequirements.min_length} and ${this.passwordRequirements.max_length} characters long`
: null
const digitsMessage = this.passwordRequirements.musthave_digit > 0
? `must contain at least ${this.passwordRequirements.musthave_digit} digits`
: null
const lowercaseMessage = this.passwordRequirements.musthave_lowercase > 0
? `must contain at least ${this.passwordRequirements.musthave_lowercase} lowercase`
: null
const uppercaseReq = this.passwordRequirements.musthave_uppercase > 0
? `must contain at least ${this.passwordRequirements.musthave_uppercase} uppercase`
: null
const specialCharReq = this.passwordRequirements.musthave_specialchar > 0
? `must contain at least ${this.passwordRequirements.musthave_specialchar} special characters`
: null
return [lengthMessage, digitsMessage, lowercaseMessage, uppercaseReq, specialCharReq].filter((message) => message !== null)
},
getPasswordValidations () {
if (this.areValidationsActive) {
return {
required,
passwordMaxLength: maxLength(this.passwordRequirements.max_length),
passwordMinLength: minLength(this.passwordRequirements.min_length),
passwordDigits () {
const digitPattern = /\d/g
return (this.password.match(digitPattern) || []).length >= this.passwordRequirements.musthave_digit
},
passwordLowercase () {
const lowercasePattern = /[a-z]/g
return (this.password.match(lowercasePattern) || []).length >= this.passwordRequirements.musthave_lowercase
},
passwordUppercase () {
const uppercasePattern = /[A-Z]/g
return (this.password.match(uppercasePattern) || []).length >= this.passwordRequirements.musthave_uppercase
},
passwordChars () {
const specialCharPattern = /[\W_]/g
return (this.password.match(specialCharPattern) || []).length >= this.passwordRequirements.musthave_specialchar
},
passwordStrength () {
return this.passwordScore >= 2
}
}
}
return { required }
},
inputPassword () {
this.$emit('update:modelValue', {
password: this.password,
@ -151,7 +230,6 @@ export default {
})
},
inputRetypePassword () {
this.validate()
this.inputPassword()
},
passwordGenerated (password) {

@ -17,6 +17,7 @@
:disable="loading"
:readonly="loading"
:label="$t('Display Name')"
@blur="v$.data.displayName.$touch()"
@update:model-value="v$.data.displayName.$touch()"
>
<template
@ -38,6 +39,7 @@
:readonly="loading"
:label="$t('Extension')"
:hint="getExtensionHint"
@blur="v$.data.extension.$touch()"
@update:model-value="v$.data.extension.$touch()"
>
<template
@ -130,6 +132,7 @@
:disable="loading"
:readonly="loading"
:label="$t('Web Username')"
@blur="v$.data.webUsername.$touch()"
@update:model-value="v$.data.webUsername.$touch()"
>
<template
@ -147,6 +150,8 @@
:disable="loading"
hide-bottom-space
dense
@validation-failed="webPasswordReady=false"
@validation-succeeded="webPasswordReady=true"
/>
<csc-input
v-model="data.sipUsername"
@ -158,6 +163,7 @@
:disable="loading"
:readonly="loading"
:label="$t('SIP Username')"
@blur="v$.data.sipUsername.$touch()"
@update:model-value="v$.data.sipUsername.$touch()"
>
<template
@ -172,8 +178,11 @@
v-model="data.sipPassword"
:password-label="$t('SIP Password')"
:password-confirm-label="$t('SIP Password confirm')"
:password-type="'sip'"
:disable="loading"
dense
@validation-failed="sipPasswordReady=false"
@validation-succeeded="sipPasswordReady=true"
/>
</div>
</div>
@ -193,7 +202,7 @@
color="primary"
icon="person"
:loading="loading"
:disable="v$.data.$invalid || loading"
:disable="disableSaveButton()"
:label="$t('Create seat')"
@click="save()"
/>
@ -203,11 +212,7 @@
<script>
import { mapGetters } from 'vuex'
import {
required,
maxLength,
numeric
} from '@vuelidate/validators'
import { required, maxLength, numeric } from '@vuelidate/validators'
import { inRange } from 'src/helpers/validation'
import CscInput from 'components/form/CscInput'
import CscInputPasswordRetype from 'components/form/CscInputPasswordRetype'
@ -262,27 +267,13 @@ export default {
isInRange: function (value) {
return inRange(value, this.getMinAllowedExtension, this.getMaxAllowedExtension)
}
},
password: {
password: {
required
},
passwordRetype: {
required
}
},
sipPassword: {
password: {
required
},
passwordRetype: {
required
}
}
}
},
data () {
return {
webPasswordReady: false,
sipPasswordReady: false,
data: this.getDefaults(),
v$: useValidate()
}
@ -363,21 +354,6 @@ export default {
} else {
return ''
}
},
webPasswordErrorMessage () {
const errorsTab = this.v$.data.webPassword.$errors
if (errorsTab && errorsTab.length > 0 && errorsTab[0].$validator === 'required') {
return this.$t('{field} is required', {
field: this.$t('Password')
})
} else if (errorsTab && errorsTab.length > 0 && errorsTab[0].$validator === 'maxLength') {
return this.$t('{field} must have at most {maxLength} letters', {
field: this.$t('Password'),
maxLength: this.v$.data.webPassword.maxLength.$params.max
})
} else {
return ''
}
}
},
created () {
@ -410,6 +386,12 @@ export default {
cancel () {
this.$emit('cancel')
},
arePasswordsValid () {
return this.webPasswordReady && this.sipPasswordReady
},
disableSaveButton () {
return this.v$.data.$invalid || this.loading || !this.arePasswordsValid()
},
save () {
this.$emit('save', {
displayName: this.data.displayName,

@ -21,6 +21,7 @@
v-model="passwordConfirmed"
:password-label="passLabel"
:password-confirm-label="passConfirmLabel"
:password-type="passwordType"
@validation-failed="isValid=false"
@validation-succeeded="isValid=true"
/>
@ -98,6 +99,10 @@ export default {
password: {
type: String,
default: ''
},
passwordType: {
type: String,
default: 'web'
}
},
emits: ['change'],

@ -352,7 +352,7 @@
"Password changed successfully": "Password changed successfully",
"Password confirm": "Password confirm",
"Password is not strong enough": "Password is not strong enough",
"Passwords must be equal": "Passwords must be equal",
"Passwords must be equal": "Le passwords devono essere identiche",
"Phone model": "Modello telefono",
"Phone number": "Numero di telefono",
"Pilot": "Pilota",

@ -23,6 +23,7 @@
:btn-label="$t('Change SIP Password')"
:password-label="$t('New SIP Password')"
:password-confirm-label="$t('New SIP Password confirm')"
:password-type="'sip'"
:loading="processingChangeSIPPassword"
@change="requestSIPPasswordChange"
/>
@ -62,13 +63,8 @@
</template>
<script>
import {
showGlobalError,
showToast
} from 'src/helpers/ui'
import {
mapGetters
} from 'vuex'
import { showGlobalError, showToast } from 'src/helpers/ui'
import { mapGetters } from 'vuex'
import CscPage from 'components/CscPage'
import CscChangePasswordEmbedded from 'components/pages/UserSettings/CscChangePasswordEmbeded'
import { mapWaitingActions, mapWaitingGetters } from 'vue-wait'
@ -91,7 +87,8 @@ export default {
},
computed: {
...mapGetters('user', [
'getSubscriber'
'getSubscriber',
'passwordRequirements'
]),
...mapWaitingGetters({
processingChangeSIPPassword: WAIT_CHANGE_SIP_PASSWORD,

@ -137,6 +137,9 @@ export default {
loginError (state) {
return state.loginError
},
passwordRequirements (state) {
return state.platformInfo.security.password
},
userDataRequesting (state) {
return state.userDataRequesting
},

@ -8,5 +8,29 @@ export const errorMessages = {
},
required () {
return i18n.global.tc('Input is required')
},
passwordDigits () {
return i18n.global.tc('Password is not strong enough, add more digits')
},
passwordLowercase () {
return i18n.global.tc('Password is not strong enough, add more lowercase letters')
},
passwordMaxLength (param) {
return i18n.global.tc('Password must be at least {max} characters long', param)
},
passwordMinLength (param) {
return i18n.global.tc('Password must be at least {min} characters long', param)
},
passwordUppercase () {
return i18n.global.tc('Password is not strong enough, add more uppercase letters')
},
passwordChars () {
return i18n.global.tc('Password is not strong enough, add more special characters')
},
passwordStrength () {
return i18n.global.tc('Password is considered weak')
},
sameAsPassword () {
return i18n.global.tc('Passwords must be equal')
}
}

Loading…
Cancel
Save