mirror of
https://github.com/nextcloud/server.git
synced 2026-04-25 16:19:06 -04:00
Refine input validation
- Remove usage of JS core checkValidity() in favour of custom backend compliant validation - Rewrite and refactor with removal of form tag in favour of section - Scope styles - Remove many uses of $nextTick - Refine disabled state logic - Translate account property constants Signed-off-by: Christopher Ng <chrng8@gmail.com>
This commit is contained in:
parent
5e67677d94
commit
d738ca48b2
8 changed files with 157 additions and 82 deletions
|
|
@ -48,6 +48,7 @@ import { showError } from '@nextcloud/dialogs'
|
|||
import debounce from 'debounce'
|
||||
|
||||
import { savePrimaryDisplayName } from '../../../service/PersonalInfo/DisplayNameService'
|
||||
import { validateDisplayName } from '../../../utils/validate'
|
||||
|
||||
// TODO Global avatar updating on events (e.g. updating the displayname) is currently being handled by global js, investigate using https://github.com/nextcloud/nextcloud-event-bus for global avatar updating
|
||||
|
||||
|
|
@ -81,7 +82,7 @@ export default {
|
|||
},
|
||||
|
||||
debounceDisplayNameChange: debounce(async function(displayName) {
|
||||
if (this.$refs.displayName?.checkValidity() && this.isValid(displayName)) {
|
||||
if (validateDisplayName(displayName)) {
|
||||
await this.updatePrimaryDisplayName(displayName)
|
||||
}
|
||||
}, 500),
|
||||
|
|
@ -115,10 +116,6 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
isValid(displayName) {
|
||||
return displayName !== ''
|
||||
},
|
||||
|
||||
onScopeChange(scope) {
|
||||
this.$emit('update:scope', scope)
|
||||
},
|
||||
|
|
@ -131,8 +128,18 @@ export default {
|
|||
display: grid;
|
||||
align-items: center;
|
||||
|
||||
input[type=text] {
|
||||
input {
|
||||
grid-area: 1 / 1;
|
||||
height: 34px;
|
||||
width: 100%;
|
||||
margin: 3px 3px 3px 0;
|
||||
padding: 7px 6px;
|
||||
cursor: text;
|
||||
font-family: var(--font-face);
|
||||
border: 1px solid var(--color-border-dark);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--color-main-background);
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
|
||||
.displayname__actions-container {
|
||||
|
|
|
|||
|
|
@ -20,29 +20,25 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<form
|
||||
ref="form"
|
||||
class="section"
|
||||
@submit.stop.prevent="() => {}">
|
||||
<section>
|
||||
<HeaderBar
|
||||
:account-property="accountProperty"
|
||||
label-for="displayname"
|
||||
:is-editable="displayNameChangeSupported"
|
||||
:is-valid-form="isValidForm"
|
||||
:is-valid-section="isValidSection"
|
||||
:handle-scope-change="savePrimaryDisplayNameScope"
|
||||
:scope.sync="primaryDisplayName.scope" />
|
||||
|
||||
<template v-if="displayNameChangeSupported">
|
||||
<DisplayName
|
||||
:scope.sync="primaryDisplayName.scope"
|
||||
:display-name.sync="primaryDisplayName.value"
|
||||
@update:display-name="onUpdateDisplayName" />
|
||||
:scope.sync="primaryDisplayName.scope" />
|
||||
</template>
|
||||
|
||||
<span v-else>
|
||||
{{ primaryDisplayName.value || t('settings', 'No full name set') }}
|
||||
</span>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
|
@ -53,6 +49,7 @@ import HeaderBar from '../shared/HeaderBar'
|
|||
|
||||
import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
|
||||
import { savePrimaryDisplayNameScope } from '../../../service/PersonalInfo/DisplayNameService'
|
||||
import { validateDisplayName } from '../../../utils/validate'
|
||||
|
||||
const { displayNames: { primaryDisplayName } } = loadState('settings', 'personalInfoParameters', {})
|
||||
const { displayNameChangeSupported } = loadState('settings', 'accountParameters', {})
|
||||
|
|
@ -69,31 +66,24 @@ export default {
|
|||
return {
|
||||
accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.DISPLAYNAME,
|
||||
displayNameChangeSupported,
|
||||
isValidForm: true,
|
||||
primaryDisplayName,
|
||||
savePrimaryDisplayNameScope,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$nextTick(() => this.updateFormValidity())
|
||||
},
|
||||
|
||||
methods: {
|
||||
onUpdateDisplayName() {
|
||||
this.$nextTick(() => this.updateFormValidity())
|
||||
},
|
||||
|
||||
updateFormValidity() {
|
||||
this.isValidForm = this.$refs.form?.checkValidity()
|
||||
computed: {
|
||||
isValidSection() {
|
||||
return validateDisplayName(this.primaryDisplayName.value)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
form::v-deep button {
|
||||
&:disabled {
|
||||
section {
|
||||
padding: 10px 10px;
|
||||
|
||||
&::v-deep button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@
|
|||
<ActionButton
|
||||
:aria-label="deleteEmailLabel"
|
||||
:close-after-click="true"
|
||||
:disabled="deleteDisabled"
|
||||
icon="icon-delete"
|
||||
@click.stop.prevent="deleteEmail">
|
||||
{{ deleteEmailLabel }}
|
||||
|
|
@ -83,6 +84,7 @@ import FederationControl from '../shared/FederationControl'
|
|||
|
||||
import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants'
|
||||
import { savePrimaryEmail, saveAdditionalEmail, saveAdditionalEmailScope, updateAdditionalEmail, removeAdditionalEmail } from '../../../service/PersonalInfo/EmailService'
|
||||
import { validateEmail } from '../../../utils/validate'
|
||||
|
||||
export default {
|
||||
name: 'Email',
|
||||
|
|
@ -126,9 +128,13 @@ export default {
|
|||
computed: {
|
||||
deleteDisabled() {
|
||||
if (this.primary) {
|
||||
return this.email === ''
|
||||
// Disable for empty primary email as there is nothing to delete
|
||||
// OR when initialEmail (reflects server state) and email (current input) are not the same
|
||||
return this.email === '' || this.initialEmail !== this.email
|
||||
} else if (this.initialEmail !== '') {
|
||||
return this.initialEmail !== this.email
|
||||
}
|
||||
return this.email !== '' && !this.isValid(this.email)
|
||||
return false
|
||||
},
|
||||
|
||||
deleteEmailLabel() {
|
||||
|
|
@ -159,6 +165,7 @@ export default {
|
|||
|
||||
mounted() {
|
||||
if (!this.primary && this.initialEmail === '') {
|
||||
// $nextTick is needed here, otherwise it may not always work https://stackoverflow.com/questions/51922767/autofocus-input-on-mount-vue-ios/63485725#63485725
|
||||
this.$nextTick(() => this.$refs.email?.focus())
|
||||
}
|
||||
},
|
||||
|
|
@ -170,7 +177,7 @@ export default {
|
|||
},
|
||||
|
||||
debounceEmailChange: debounce(async function(email) {
|
||||
if (this.$refs.email?.checkValidity() || email === '') {
|
||||
if (validateEmail(email) || email === '') {
|
||||
if (this.primary) {
|
||||
await this.updatePrimaryEmail(email)
|
||||
} else {
|
||||
|
|
@ -282,10 +289,6 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
isValid(email) {
|
||||
return /^\S+$/.test(email)
|
||||
},
|
||||
|
||||
onScopeChange(scope) {
|
||||
this.$emit('update:scope', scope)
|
||||
},
|
||||
|
|
@ -298,8 +301,18 @@ export default {
|
|||
display: grid;
|
||||
align-items: center;
|
||||
|
||||
input[type=email] {
|
||||
input {
|
||||
grid-area: 1 / 1;
|
||||
height: 34px;
|
||||
width: 100%;
|
||||
margin: 3px 3px 3px 0;
|
||||
padding: 7px 6px;
|
||||
cursor: text;
|
||||
font-family: var(--font-face);
|
||||
border: 1px solid var(--color-border-dark);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--color-main-background);
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
|
||||
.email__actions-container {
|
||||
|
|
|
|||
|
|
@ -20,17 +20,14 @@
|
|||
-->
|
||||
|
||||
<template>
|
||||
<form
|
||||
ref="form"
|
||||
class="section"
|
||||
@submit.stop.prevent="() => {}">
|
||||
<section>
|
||||
<HeaderBar
|
||||
:account-property="accountProperty"
|
||||
label-for="email"
|
||||
:handle-scope-change="savePrimaryEmailScope"
|
||||
:is-editable="displayNameChangeSupported"
|
||||
:is-multi-value-supported="true"
|
||||
:is-valid-form="isValidForm"
|
||||
:is-valid-section="isValidSection"
|
||||
:scope.sync="primaryEmail.scope"
|
||||
@add-additional="onAddAdditionalEmail" />
|
||||
|
||||
|
|
@ -52,7 +49,7 @@
|
|||
<span v-else>
|
||||
{{ primaryEmail.value || t('settings', 'No email address set') }}
|
||||
</span>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
|
@ -64,6 +61,7 @@ import HeaderBar from '../shared/HeaderBar'
|
|||
|
||||
import { ACCOUNT_PROPERTY_READABLE_ENUM, DEFAULT_ADDITIONAL_EMAIL_SCOPE } from '../../../constants/AccountPropertyConstants'
|
||||
import { savePrimaryEmail, savePrimaryEmailScope, removeAdditionalEmail } from '../../../service/PersonalInfo/EmailService'
|
||||
import { validateEmail } from '../../../utils/validate'
|
||||
|
||||
const { emails: { additionalEmails, primaryEmail } } = loadState('settings', 'personalInfoParameters', {})
|
||||
const { displayNameChangeSupported } = loadState('settings', 'accountParameters', {})
|
||||
|
|
@ -81,13 +79,24 @@ export default {
|
|||
accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL,
|
||||
additionalEmails,
|
||||
displayNameChangeSupported,
|
||||
isValidForm: true,
|
||||
primaryEmail,
|
||||
savePrimaryEmailScope,
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
firstAdditionalEmail() {
|
||||
if (this.additionalEmails.length) {
|
||||
return this.additionalEmails[0].value
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
isValidSection() {
|
||||
return validateEmail(this.primaryEmail.value)
|
||||
&& this.additionalEmails.map(({ value }) => value).every(validateEmail)
|
||||
},
|
||||
|
||||
primaryEmailValue: {
|
||||
get() {
|
||||
return this.primaryEmail.value
|
||||
|
|
@ -96,41 +105,25 @@ export default {
|
|||
this.primaryEmail.value = value
|
||||
},
|
||||
},
|
||||
|
||||
firstAdditionalEmail() {
|
||||
if (this.additionalEmails.length) {
|
||||
return this.additionalEmails[0].value
|
||||
}
|
||||
return null
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$nextTick(() => this.updateFormValidity())
|
||||
},
|
||||
|
||||
methods: {
|
||||
onAddAdditionalEmail() {
|
||||
if (this.$refs.form?.checkValidity()) {
|
||||
if (this.isValidSection) {
|
||||
this.additionalEmails.push({ value: '', scope: DEFAULT_ADDITIONAL_EMAIL_SCOPE })
|
||||
this.$nextTick(() => this.updateFormValidity())
|
||||
}
|
||||
},
|
||||
|
||||
onDeleteAdditionalEmail(index) {
|
||||
this.$delete(this.additionalEmails, index)
|
||||
this.$nextTick(() => this.updateFormValidity())
|
||||
},
|
||||
|
||||
async onUpdateEmail() {
|
||||
this.$nextTick(() => this.updateFormValidity())
|
||||
|
||||
if (this.primaryEmailValue === '' && this.firstAdditionalEmail) {
|
||||
const deletedEmail = this.firstAdditionalEmail
|
||||
await this.deleteFirstAdditionalEmail()
|
||||
this.primaryEmailValue = deletedEmail
|
||||
await this.updatePrimaryEmail()
|
||||
this.$nextTick(() => this.updateFormValidity())
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -166,17 +159,15 @@ export default {
|
|||
this.logger.error(errorMessage, error)
|
||||
}
|
||||
},
|
||||
|
||||
updateFormValidity() {
|
||||
this.isValidForm = this.$refs.form?.checkValidity()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
form::v-deep button {
|
||||
&:disabled {
|
||||
section {
|
||||
padding: 10px 10px;
|
||||
|
||||
&::v-deep button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export default {
|
|||
|
||||
data() {
|
||||
return {
|
||||
accountPropertyLowerCase: this.accountProperty.toLowerCase(),
|
||||
accountPropertyLowerCase: this.accountProperty.toLocaleLowerCase(),
|
||||
initialScope: this.scope,
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@
|
|||
<template>
|
||||
<h3>
|
||||
<label :for="labelFor">
|
||||
{{ t('settings', accountProperty) }}
|
||||
<!-- Already translated as required by prop validator -->
|
||||
{{ accountProperty }}
|
||||
</label>
|
||||
|
||||
<FederationControl
|
||||
|
|
@ -35,7 +36,7 @@
|
|||
<template v-if="isEditable && isMultiValueSupported">
|
||||
<AddButton
|
||||
class="add-button"
|
||||
:disabled="!isValidForm"
|
||||
:disabled="!isValidSection"
|
||||
@click.stop.prevent="onAddAdditional" />
|
||||
</template>
|
||||
</h3>
|
||||
|
|
@ -73,7 +74,7 @@ export default {
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isValidForm: {
|
||||
isValidSection: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
|
|
@ -106,6 +107,18 @@ export default {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
h3 {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
margin: 12px 0 0 0;
|
||||
font-size: 16px;
|
||||
color: var(--color-text-light);
|
||||
|
||||
label {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.federation-control {
|
||||
margin: -12px 0 0 8px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@
|
|||
* SYNC to be kept in sync with lib/public/Accounts/IAccountManager.php
|
||||
*/
|
||||
|
||||
import { translate as t } from '@nextcloud/l10n'
|
||||
|
||||
/** Enum of account properties */
|
||||
export const ACCOUNT_PROPERTY_ENUM = Object.freeze({
|
||||
ADDRESS: 'address',
|
||||
|
|
@ -36,16 +38,16 @@ export const ACCOUNT_PROPERTY_ENUM = Object.freeze({
|
|||
WEBSITE: 'website',
|
||||
})
|
||||
|
||||
/** Enum of account properties to human readable account properties */
|
||||
/** Enum of account properties to human readable account property names */
|
||||
export const ACCOUNT_PROPERTY_READABLE_ENUM = Object.freeze({
|
||||
ADDRESS: 'Address',
|
||||
AVATAR: 'Avatar',
|
||||
DISPLAYNAME: 'Full name',
|
||||
EMAIL: 'Email',
|
||||
EMAIL_COLLECTION: 'Additional Email',
|
||||
PHONE: 'Phone',
|
||||
TWITTER: 'Twitter',
|
||||
WEBSITE: 'Website',
|
||||
ADDRESS: t('settings', 'Address'),
|
||||
AVATAR: t('settings', 'Avatar'),
|
||||
DISPLAYNAME: t('settings', 'Full name'),
|
||||
EMAIL: t('settings', 'Email'),
|
||||
EMAIL_COLLECTION: t('settings', 'Additional email'),
|
||||
PHONE: t('settings', 'Phone number'),
|
||||
TWITTER: t('settings', 'Twitter'),
|
||||
WEBSITE: t('settings', 'Website'),
|
||||
})
|
||||
|
||||
/** Enum of scopes */
|
||||
|
|
@ -71,9 +73,6 @@ export const PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM = Object.freeze({
|
|||
/** Scope suffix */
|
||||
export const SCOPE_SUFFIX = 'Scope'
|
||||
|
||||
/** Default additional email scope */
|
||||
export const DEFAULT_ADDITIONAL_EMAIL_SCOPE = SCOPE_ENUM.LOCAL
|
||||
|
||||
/**
|
||||
* Enum of scope names to properties
|
||||
*
|
||||
|
|
@ -105,3 +104,14 @@ export const SCOPE_PROPERTY_ENUM = Object.freeze({
|
|||
iconClass: 'icon-link',
|
||||
},
|
||||
})
|
||||
|
||||
/** Default additional email scope */
|
||||
export const DEFAULT_ADDITIONAL_EMAIL_SCOPE = SCOPE_ENUM.LOCAL
|
||||
|
||||
/**
|
||||
* Email validation regex
|
||||
*
|
||||
* *Sourced from https://github.com/mpyw/FILTER_VALIDATE_EMAIL.js/blob/71e62ca48841d2246a1b531e7e84f5a01f15e615/src/regexp/ascii.ts*
|
||||
*/
|
||||
// eslint-disable-next-line no-control-regex
|
||||
export const VALIDATE_EMAIL_REGEX = /^(?!(?:(?:\x22?\x5C[\x00-\x7E]\x22?)|(?:\x22?[^\x5C\x22]\x22?)){255,})(?!(?:(?:\x22?\x5C[\x00-\x7E]\x22?)|(?:\x22?[^\x5C\x22]\x22?)){65,}@)(?:(?:[\x21\x23-\x27\x2A\x2B\x2D\x2F-\x39\x3D\x3F\x5E-\x7E]+)|(?:\x22(?:[\x01-\x08\x0B\x0C\x0E-\x1F\x21\x23-\x5B\x5D-\x7F]|(?:\x5C[\x00-\x7F]))*\x22))(?:\.(?:(?:[\x21\x23-\x27\x2A\x2B\x2D\x2F-\x39\x3D\x3F\x5E-\x7E]+)|(?:\x22(?:[\x01-\x08\x0B\x0C\x0E-\x1F\x21\x23-\x5B\x5D-\x7F]|(?:\x5C[\x00-\x7F]))*\x22)))*@(?:(?:(?!.*[^.]{64,})(?:(?:(?:xn--)?[a-z0-9]+(?:-+[a-z0-9]+)*\.){1,126}){1,}(?:(?:[a-z][a-z0-9]*)|(?:(?:xn--)[a-z0-9]+))(?:-+[a-z0-9]+)*)|(?:\[(?:(?:IPv6:(?:(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){7})|(?:(?!(?:.*[a-f0-9][:\]]){7,})(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,5})?::(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,5})?)))|(?:(?:IPv6:(?:(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){5}:)|(?:(?!(?:.*[a-f0-9]:){5,})(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,3})?::(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,3}:)?)))?(?:(?:25[0-5])|(?:2[0-4][0-9])|(?:1[0-9]{2})|(?:[1-9]?[0-9]))(?:\.(?:(?:25[0-5])|(?:2[0-4][0-9])|(?:1[0-9]{2})|(?:[1-9]?[0-9]))){3}))\]))$/i
|
||||
|
|
|
|||
51
apps/settings/src/utils/validate.js
Normal file
51
apps/settings/src/utils/validate.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* @copyright 2021, Christopher Ng <chrng8@gmail.com>
|
||||
*
|
||||
* @author Christopher Ng <chrng8@gmail.com>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
import { VALIDATE_EMAIL_REGEX } from '../constants/AccountPropertyConstants'
|
||||
|
||||
/**
|
||||
* Validate the display name input
|
||||
*
|
||||
* @param {string} input the input
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validateDisplayName(input) {
|
||||
return input !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the email input
|
||||
*
|
||||
* *Compliant with PHP core FILTER_VALIDATE_EMAIL validator*
|
||||
*
|
||||
* *Reference implementation https://github.com/mpyw/FILTER_VALIDATE_EMAIL.js/blob/71e62ca48841d2246a1b531e7e84f5a01f15e615/src/index.ts*
|
||||
*
|
||||
* @param {string} input the input
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validateEmail(input) {
|
||||
return typeof input === 'string'
|
||||
&& VALIDATE_EMAIL_REGEX.test(input)
|
||||
&& input.slice(-1) !== '\n'
|
||||
&& input.length <= 320
|
||||
&& encodeURIComponent(input).replace(/%../g, 'x').length <= 320
|
||||
}
|
||||
Loading…
Reference in a new issue