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')" :label="$t('Password')"
:disable="loading" :disable="loading"
:error="v$.password.$errors.length > 0" :error="v$.password.$errors.length > 0"
:error-message="errorMessagePass" :error-message="$errMsg(v$.password.$errors)"
@blur="v$.password.$touch()" @blur="v$.password.$touch()"
/> />
<password-meter <password-meter
@ -30,7 +30,7 @@
:label="$t('Password Retype')" :label="$t('Password Retype')"
:disable="loading" :disable="loading"
:error="v$.passwordRetype.$errors.length > 0" :error="v$.passwordRetype.$errors.length > 0"
:error-message="errorMessagePassRetype" :error-message="$errMsg(v$.passwordRetype.$errors)"
@blur="v$.passwordRetype.$touch();onRetypeBlur()" @blur="v$.passwordRetype.$touch();onRetypeBlur()"
/> />
</div> </div>
@ -38,11 +38,10 @@
<script> <script>
import PasswordMeter from 'vue-simple-password-meter' import PasswordMeter from 'vue-simple-password-meter'
import { import { maxLength, minLength, required } from '@vuelidate/validators'
required
} from '@vuelidate/validators'
import CscInputPassword from 'components/form/CscInputPassword' import CscInputPassword from 'components/form/CscInputPassword'
import useValidate from '@vuelidate/core' import useValidate from '@vuelidate/core'
import { mapGetters } from 'vuex'
export default { export default {
name: 'CscChangePasswordForm', name: 'CscChangePasswordForm',
components: { components: {
@ -66,40 +65,25 @@ export default {
passwordRetype: '', passwordRetype: '',
passwordScored: '', passwordScored: '',
passwordStrengthScore: null, passwordStrengthScore: null,
v$: useValidate() v$: useValidate(),
messages: []
} }
}, },
validations: { validations () {
password: { return {
required, password: { ...this.getPasswordValidations() },
passwordStrength () { passwordRetype: {
return this.passwordStrengthScore >= 2 required,
} sameAsPassword (val) {
}, return val === this.password
passwordRetype: { }
required,
sameAsPassword (val) {
return val === this.password
} }
} }
}, },
computed: { computed: {
errorMessagePass () { ...mapGetters('user', [
const errorsTab = this.v$.password.$errors 'passwordRequirements'
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 ''
}
}
}, },
watch: { watch: {
password (value) { password (value) {
@ -110,7 +94,63 @@ export default {
} }
} }
}, },
mounted () {
this.messages = this.getPasswordRequirementsMessages()
},
methods: { 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) { strengthMeterScoreUpdate (evt) {
this.passwordStrengthScore = evt.score this.passwordStrengthScore = evt.score
}, },

@ -2,14 +2,37 @@
<div <div
class="csc-input-password-retype" 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 <csc-input-password
ref="password" ref="password"
v-model="password" v-model="password"
v-bind="$attrs" v-bind="$attrs"
generate generate
clearable clearable
:error="v$.password.$errors.length > 0"
:error-message="$errMsg(v$.password.$errors)"
:label="passwordLabel" :label="passwordLabel"
@update:model-value="inputPassword" @update:model-value="inputPassword"
@blur="v$.password.$touch()"
@generated="passwordGenerated" @generated="passwordGenerated"
@clear="passwordClear" @clear="passwordClear"
/> />
@ -29,9 +52,9 @@
v-bind="$attrs" v-bind="$attrs"
:label="passwordConfirmLabel" :label="passwordConfirmLabel"
:error="v$.passwordRetype.$errors.length > 0" :error="v$.passwordRetype.$errors.length > 0"
:error-message="errorMessagePasswordRetype" :error-message="$errMsg(v$.passwordRetype.$errors)"
clearable clearable
:disable="passwordScore < 2 || $attrs.disable" :disable="$attrs.disable"
@clear="v$.passwordRetype.$reset()" @clear="v$.passwordRetype.$reset()"
@blur="passwordRetypeBlur" @blur="passwordRetypeBlur"
@update:model-value="inputRetypePassword" @update:model-value="inputRetypePassword"
@ -39,13 +62,12 @@
</div> </div>
</template> </template>
<script> <script>
import {
sameAs,
required
} from '@vuelidate/validators'
import CscInputPassword from 'components/form/CscInputPassword' import CscInputPassword from 'components/form/CscInputPassword'
import PasswordMeter from 'vue-simple-password-meter' import PasswordMeter from 'vue-simple-password-meter'
import useValidate from '@vuelidate/core' import useValidate from '@vuelidate/core'
import { sameAs, required, maxLength, minLength } from '@vuelidate/validators'
import { mapGetters } from 'vuex'
export default { export default {
name: 'CscInputPasswordRetype', name: 'CscInputPasswordRetype',
components: { components: {
@ -75,6 +97,12 @@ export default {
// eslint-disable-next-line vue/no-deprecated-props-default-this // eslint-disable-next-line vue/no-deprecated-props-default-this
return this.$t('Password Retype') return this.$t('Password Retype')
} }
},
passwordType: {
type: String,
default () {
return 'web'
}
} }
}, },
emits: ['validation-failed', 'validation-succeeded', 'update:modelValue', 'score'], emits: ['validation-failed', 'validation-succeeded', 'update:modelValue', 'score'],
@ -83,14 +111,13 @@ export default {
password: this.modelValue.password, password: this.modelValue.password,
passwordRetype: this.modelValue.passwordRetype, passwordRetype: this.modelValue.passwordRetype,
passwordScore: null, passwordScore: null,
v$: useValidate() v$: useValidate(),
messages: []
} }
}, },
validations () { validations () {
return { return {
password: { password: { ...this.getPasswordValidations() },
required
},
passwordRetype: { passwordRetype: {
required, required,
sameAsPassword: sameAs(this.password) sameAsPassword: sameAs(this.password)
@ -98,13 +125,11 @@ export default {
} }
}, },
computed: { computed: {
errorMessagePasswordRetype () { ...mapGetters('user', [
const errorsTab = this.v$.passwordRetype.$errors 'passwordRequirements'
if (errorsTab && errorsTab.length > 0 && errorsTab[0].$validator === 'sameAsPassword') { ]),
return this.$t('Passwords must be equal') areValidationsActive () {
} else { return this.passwordType === 'web' ? this.passwordRequirements.web_validate : this.passwordRequirements.sip_validate
return ''
}
}, },
passwordScoreMappedValue () { passwordScoreMappedValue () {
if (this.passwordScore === null || this.passwordScore === undefined) { if (this.passwordScore === null || this.passwordScore === undefined) {
@ -138,12 +163,66 @@ export default {
this.v$.$reset() this.v$.$reset()
this.$refs.passwordRetype.clear() this.$refs.passwordRetype.clear()
this.$refs.password.clear() this.$refs.password.clear()
this.messages = this.getPasswordRequirementsMessages()
}, },
methods: { methods: {
strengthMeterScoreUpdate (evt) { strengthMeterScoreUpdate (evt) {
this.passwordScore = evt.score this.passwordScore = evt.score
this.$emit('score', 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 () { inputPassword () {
this.$emit('update:modelValue', { this.$emit('update:modelValue', {
password: this.password, password: this.password,
@ -151,7 +230,6 @@ export default {
}) })
}, },
inputRetypePassword () { inputRetypePassword () {
this.validate()
this.inputPassword() this.inputPassword()
}, },
passwordGenerated (password) { passwordGenerated (password) {

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

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

@ -352,7 +352,7 @@
"Password changed successfully": "Password changed successfully", "Password changed successfully": "Password changed successfully",
"Password confirm": "Password confirm", "Password confirm": "Password confirm",
"Password is not strong enough": "Password is not strong enough", "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 model": "Modello telefono",
"Phone number": "Numero di telefono", "Phone number": "Numero di telefono",
"Pilot": "Pilota", "Pilot": "Pilota",

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

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

@ -8,5 +8,29 @@ export const errorMessages = {
}, },
required () { required () {
return i18n.global.tc('Input is 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