Skip to content

Commit

Permalink
Implement password policy (#9634)
Browse files Browse the repository at this point in the history
  • Loading branch information
Jan authored and AlexAndBear committed Dec 13, 2023
1 parent f0a06a5 commit 07d136f
Show file tree
Hide file tree
Showing 32 changed files with 703 additions and 53 deletions.
3 changes: 2 additions & 1 deletion packages/design-system/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@
"vue-inline-svg": "^3.1.0",
"vue-select": "^3.12.0",
"web-client": "workspace:@ownclouders/web-client@*",
"webfontloader": "^1.6.28"
"webfontloader": "^1.6.28",
"portal-vue": "*"
},
"engines": {
"node": ">= 14.0.0",
Expand Down
37 changes: 36 additions & 1 deletion packages/design-system/src/components/OcModal/OcModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,16 @@
v-model="userInputValue"
class="oc-modal-body-input"
:error-message="inputError"
:placeholder="inputPlaceholder"
:label="inputLabel"
:type="inputType"
:password-policy="inputPasswordPolicy"
:description-message="inputDescription"
:disabled="inputDisabled"
:fix-message-line="true"
:selection-range="inputSelectionRange"
@password-challenge-completed="$emit('passwordChallengeCompleted')"
@password-challenge-failed="$emit('passwordChallengeFailed')"
@update:model-value="inputOnInput"
@keydown.enter.prevent="confirm"
/>
Expand Down Expand Up @@ -99,6 +103,7 @@ import OcIcon from '../OcIcon/OcIcon.vue'
import OcTextInput from '../OcTextInput/OcTextInput.vue'
import { FocusTrap } from 'focus-trap-vue'
import { FocusTargetOrFalse, FocusTargetValueOrFalse } from 'focus-trap'
import { PasswordPolicy } from '../_OcTextInputPassword/_OcTextInputPassword.vue'
/**
* Modals are generally used to force the user to focus on confirming or completing a single action.
Expand Down Expand Up @@ -323,6 +328,14 @@ export default defineComponent({
required: false,
default: null
},
/**
* Placeholder of the text input field
*/
inputPlaceholder: {
type: String,
required: false,
default: null
},
/**
* Additional description message for the input field
*/
Expand All @@ -347,6 +360,14 @@ export default defineComponent({
required: false,
default: false
},
/**
* Password policy for the input
*/
inputPasswordPolicy: {
type: Object as PropType<PasswordPolicy>,
required: false,
default: () => ({})
},
/**
* Overwrite default focused element
* Can be `#id, .class`.
Expand All @@ -357,7 +378,15 @@ export default defineComponent({
default: null
}
},
emits: ['cancel', 'confirm', 'confirm-secondary', 'input', 'checkbox-changed'],
emits: [
'cancel',
'confirm',
'confirm-secondary',
'input',
'checkbox-changed',
'passwordChallengeCompleted',
'passwordChallengeFailed'
],
data() {
return {
userInputValue: null,
Expand Down Expand Up @@ -560,6 +589,12 @@ export default defineComponent({
}
}
}
.oc-text-input-password-wrapper {
button {
background-color: var(--oc-color-background-highlight) !important;
}
}
}
</style>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ exports[`OcModal displays input 1`] = `
<div class="oc-modal-body">
<!--v-if-->
<!--v-if-->
<oc-text-input-stub class="oc-modal-body-input" clearbuttonaccessiblelabel="" clearbuttonenabled="false" disabled="false" fixmessageline="true" id="oc-textinput-1" label="Folder name" modelvalue="New folder" readonly="false" type="text"></oc-text-input-stub>
<oc-text-input-stub class="oc-modal-body-input" clearbuttonaccessiblelabel="" clearbuttonenabled="false" disabled="false" fixmessageline="true" id="oc-textinput-1" label="Folder name" modelvalue="New folder" passwordpolicy="[object Object]" readonly="false" type="text"></oc-text-input-stub>
<!--v-if-->
</div>
<div class="oc-modal-body-actions oc-flex oc-flex-right">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ describe('OcTextInput', () => {
it.each(['text', 'number', 'email', 'password'])(
'should set the provided type for the input',
(type) => {
const wrapper = getShallowWrapper({ type: type })
const wrapper = getMountedWrapper({ props: { type: type } })
expect(wrapper.find('input').attributes('type')).toBe(type)
}
)
Expand Down
43 changes: 40 additions & 3 deletions packages/design-system/src/components/OcTextInput/OcTextInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
size="small"
class="oc-mt-s oc-ml-s oc-position-absolute"
/>
<input
<component
:is="inputComponent"
:id="id"
v-bind="additionalAttributes"
ref="input"
Expand All @@ -24,6 +25,8 @@
:disabled="disabled || readOnly"
@change="onChange(($event.target as HTMLInputElement).value)"
@input="onInput(($event.target as HTMLInputElement).value)"
@password-challenge-completed="$emit('passwordChallengeCompleted')"
@password-challenge-failed="$emit('passwordChallengeFailed')"
@focus="onFocus($event.target)"
/>
<oc-button
Expand Down Expand Up @@ -64,6 +67,7 @@
v-text="messageText"
/>
</div>
<portal-target name="app.design-system.password-policy" />
</div>
</template>

Expand All @@ -73,6 +77,9 @@ import { defineComponent, HTMLAttributes, PropType } from 'vue'
import uniqueId from '../../utils/uniqueId'
import OcButton from '../OcButton/OcButton.vue'
import OcIcon from '../OcIcon/OcIcon.vue'
import OcTextInputPassword, {
PasswordPolicy
} from '../_OcTextInputPassword/_OcTextInputPassword.vue'
/**
* Form Inputs are used to allow users to provide text input when the expected
Expand All @@ -89,7 +96,7 @@ import OcIcon from '../OcIcon/OcIcon.vue'
*/
export default defineComponent({
name: 'OcTextInput',
components: { OcIcon, OcButton },
components: { OcIcon, OcButton, OcTextInputPassword },
status: 'ready',
release: '1.0.0',
inheritAttrs: false,
Expand Down Expand Up @@ -214,9 +221,27 @@ export default defineComponent({
readOnly: {
type: Boolean,
default: false
},
/**
* Array of password policy rules, if type is password and password policy is given,
* the entered value will be checked against these rules.
*
* Password policy rules must be compliant with auth0/password-sheriff
* https://github.com/auth0/password-sheriff
*
*/
passwordPolicy: {
type: Object as PropType<PasswordPolicy>,
default: () => ({})
}
},
emits: ['change', 'update:modelValue', 'focus'],
emits: [
'change',
'update:modelValue',
'focus',
'passwordChallengeCompleted',
'passwordChallengeFailed'
],
computed: {
showMessageLine() {
return (
Expand All @@ -238,6 +263,9 @@ export default defineComponent({
if (this.defaultValue) {
additionalAttrs['placeholder'] = this.defaultValue
}
if (this.type === 'password') {
additionalAttrs['password-policy'] = this.passwordPolicy
}
// Exclude listeners for events which are handled via methods in this component
// eslint-disable-next-line no-unused-vars
const { change, input, focus, class: classes, ...attrs } = this.$attrs
Expand Down Expand Up @@ -266,6 +294,9 @@ export default defineComponent({
},
displayValue() {
return this.modelValue || ''
},
inputComponent() {
return this.type === 'password' ? 'oc-text-input-password' : 'input'
}
},
methods: {
Expand Down Expand Up @@ -331,6 +362,12 @@ export default defineComponent({
color: var(--oc-color-text-muted);
}
&-success,
&-success:focus {
border-color: var(--oc-color-swatch-success-default) !important;
color: var(--oc-color-swatch-success-default) !important;
}
&-warning,
&-warning:focus {
border-color: var(--oc-color-swatch-warning-default) !important;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<template>
<div ref="inputPasswordWrapper" class="oc-text-input-password-wrapper">
<input v-bind="$attrs" :type="showPassword ? 'text' : 'password'" @input="onInput" />
<oc-button
v-if="password"
class="oc-text-input-copy-password-button oc-px-s oc-background-default"
appearance="raw"
size="small"
@click="copyPasswordToClipboard"
>
<oc-icon size="small" :name="copyPasswordIcon" />
</oc-button>
<oc-button
v-if="password"
class="oc-text-input-show-password-toggle oc-px-s oc-background-default"
appearance="raw"
size="small"
@click="showPassword = !showPassword"
>
<oc-icon size="small" :name="showPassword ? 'eye-off' : 'eye'" />
</oc-button>
</div>
<portal v-if="showPasswordPolicyInformation" to="app.design-system.password-policy">
<div class="oc-text-small oc-flex oc-flex-column">
<span v-text="$gettext('Please enter a password that meets the following criteria:')" />
<div
v-for="(testedRule, index) in testedPasswordPolicy.rules"
:key="index"
class="oc-flex oc-flex-middle"
>
<oc-icon
size="small"
:name="testedRule.verified ? 'check' : 'close'"
:variation="testedRule.verified ? 'success' : 'danger'"
/>
<span
:class="[
{ 'oc-text-input-success': testedRule.verified },
{ 'oc-text-input-danger': !testedRule.verified }
]"
v-text="getPasswordPolicyRuleMessage(testedRule)"
></span>
</div>
</div>
</portal>
</template>

<script lang="ts">
import { computed, defineComponent, PropType, ref, unref, watch } from 'vue'
import OcIcon from '../OcIcon/OcIcon.vue'
import OcButton from '../OcButton/OcButton.vue'
import { useGettext } from 'vue3-gettext'
export interface PasswordPolicy {
rules: unknown[]
check(password: string): boolean
missing(password: string): {
rules: {
code: string
message: string
format: (number | string)[]
verified: boolean
}[]
}
}
export default defineComponent({
name: 'OCTextInputPassword',
components: { OcButton, OcIcon },
status: 'ready',
release: '1.0.0',
inheritAttrs: true,
props: {
passwordPolicy: {
type: Object as PropType<PasswordPolicy>,
default: () => ({})
}
},
emits: ['passwordChallengeCompleted', 'passwordChallengeFailed'],
setup(props, { emit }) {
const { $gettext } = useGettext()
const showPassword = ref(false)
const passwordEntered = ref(false)
const password = ref('')
const copyPasswordIconInitial = 'file-copy'
const copyPasswordIcon = ref(copyPasswordIconInitial)
const showPasswordPolicyInformation = computed(() => {
return !!(Object.keys(props.passwordPolicy?.rules || {}).length && unref(passwordEntered))
})
const testedPasswordPolicy = computed(() => {
return props.passwordPolicy.missing(unref(password))
})
const onInput = (event) => {
passwordEntered.value = true
password.value = event.target.value
}
const getPasswordPolicyRuleMessage = (rule) => {
const paramObj = {}
for (let formatKey = 0; formatKey < rule.format.length; formatKey++) {
paramObj[`param${formatKey + 1}`] = rule.format[formatKey]
}
return $gettext(rule.message, paramObj, true)
}
const copyPasswordToClipboard = () => {
navigator.clipboard.writeText(unref(password))
copyPasswordIcon.value = 'check'
setTimeout(() => (copyPasswordIcon.value = copyPasswordIconInitial), 500)
}
watch(password, (value) => {
if (!Object.keys(props.passwordPolicy).length) {
return
}
if (!props.passwordPolicy.check(value)) {
return emit('passwordChallengeFailed')
}
emit('passwordChallengeCompleted')
})
return {
$gettext,
onInput,
password,
showPassword,
showPasswordPolicyInformation,
testedPasswordPolicy,
getPasswordPolicyRuleMessage,
copyPasswordToClipboard,
copyPasswordIcon
}
}
})
</script>
<style lang="scss">
.oc-text-input-password-wrapper {
display: flex;
flex-direction: row;
padding: 0;
border-radius: 5px;
border: 1px solid var(--oc-color-input-border);
background-color: var(--oc-color-background-highlight);
input {
flex-grow: 2;
border: none;
}
input:focus {
outline: none;
}
}
.oc-text-input-password-wrapper:focus-within {
border-color: var(--oc-color-swatch-passive-default);
}
</style>
Loading

0 comments on commit 07d136f

Please sign in to comment.