Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement password policy #9634

Merged
merged 29 commits into from
Sep 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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