diff options
Diffstat (limited to 'apps/settings/src/components/PersonalInfo/EmailSection')
-rw-r--r-- | apps/settings/src/components/PersonalInfo/EmailSection/Email.vue | 420 | ||||
-rw-r--r-- | apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue | 189 |
2 files changed, 609 insertions, 0 deletions
diff --git a/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue b/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue new file mode 100644 index 00000000000..6a6baef8817 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue @@ -0,0 +1,420 @@ +<!-- + - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <div> + <div class="email" :class="{ 'email--additional': !primary }"> + <div v-if="!primary" class="email__label-container"> + <label :for="inputIdWithDefault">{{ inputPlaceholder }}</label> + <FederationControl v-if="!federationDisabled && !primary" + :readable="propertyReadable" + :additional="true" + :additional-value="email" + :disabled="federationDisabled" + :handle-additional-scope-change="saveAdditionalEmailScope" + :scope.sync="localScope" + @update:scope="onScopeChange" /> + </div> + <div class="email__input-container"> + <NcTextField :id="inputIdWithDefault" + ref="email" + class="email__input" + autocapitalize="none" + autocomplete="email" + :error="hasError || !!helperText" + :helper-text="helperTextWithNonConfirmed" + label-outside + :placeholder="inputPlaceholder" + spellcheck="false" + :success="isSuccess" + type="email" + :value.sync="emailAddress" /> + + <div class="email__actions"> + <NcActions :aria-label="actionsLabel"> + <NcActionButton v-if="!primary || !isNotificationEmail" + close-after-click + :disabled="!isConfirmedAddress" + @click="setNotificationMail"> + <template #icon> + <NcIconSvgWrapper v-if="isNotificationEmail" :path="mdiStar" /> + <NcIconSvgWrapper v-else :path="mdiStarOutline" /> + </template> + {{ setNotificationMailLabel }} + </NcActionButton> + <NcActionButton close-after-click + :disabled="deleteDisabled" + @click="deleteEmail"> + <template #icon> + <NcIconSvgWrapper :path="mdiTrashCanOutline" /> + </template> + {{ deleteEmailLabel }} + </NcActionButton> + </NcActions> + </div> + </div> + </div> + + <em v-if="isNotificationEmail"> + {{ t('settings', 'Primary email for password reset and notifications') }} + </em> + </div> +</template> + +<script> +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcTextField from '@nextcloud/vue/components/NcTextField' + +import debounce from 'debounce' + +import { mdiArrowLeft, mdiLockOutline, mdiStar, mdiStarOutline, mdiTrashCanOutline } from '@mdi/js' + +import FederationControl from '../shared/FederationControl.vue' +import { handleError } from '../../../utils/handlers.ts' + +import { ACCOUNT_PROPERTY_READABLE_ENUM, VERIFICATION_ENUM } from '../../../constants/AccountPropertyConstants.js' +import { + removeAdditionalEmail, + saveAdditionalEmail, + saveAdditionalEmailScope, + saveNotificationEmail, + savePrimaryEmail, + updateAdditionalEmail, +} from '../../../service/PersonalInfo/EmailService.js' +import { validateEmail } from '../../../utils/validate.js' + +export default { + name: 'Email', + + components: { + NcActions, + NcActionButton, + NcIconSvgWrapper, + NcTextField, + FederationControl, + }, + + props: { + email: { + type: String, + required: true, + }, + index: { + type: Number, + default: 0, + }, + primary: { + type: Boolean, + default: false, + }, + scope: { + type: String, + required: true, + }, + activeNotificationEmail: { + type: String, + default: '', + }, + localVerificationState: { + type: Number, + default: VERIFICATION_ENUM.NOT_VERIFIED, + }, + inputId: { + type: String, + required: false, + default: '', + }, + }, + + setup() { + return { + mdiArrowLeft, + mdiLockOutline, + mdiStar, + mdiStarOutline, + mdiTrashCanOutline, + saveAdditionalEmailScope, + } + }, + + data() { + return { + hasError: false, + helperText: null, + initialEmail: this.email, + isSuccess: false, + localScope: this.scope, + propertyReadable: ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL, + showFederationSettings: false, + } + }, + + computed: { + actionsLabel() { + if (this.primary) { + return t('settings', 'Email options') + } else { + return t('settings', 'Options for additional email address {index}', { index: this.index + 1 }) + } + }, + + deleteDisabled() { + if (this.primary) { + // 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 false + }, + + deleteEmailLabel() { + if (this.primary) { + return t('settings', 'Remove primary email') + } + return t('settings', 'Delete email') + }, + + isConfirmedAddress() { + return this.primary || this.localVerificationState === VERIFICATION_ENUM.VERIFIED + }, + + isNotConfirmedHelperText() { + if (!this.isConfirmedAddress) { + return t('settings', 'This address is not confirmed') + } + return '' + }, + + helperTextWithNonConfirmed() { + if (this.helperText || this.hasError || this.isSuccess) { + return this.helperText || '' + } + return this.isNotConfirmedHelperText + }, + + setNotificationMailLabel() { + if (this.isNotificationEmail) { + return t('settings', 'Unset as primary email') + } + return t('settings', 'Set as primary email') + }, + + federationDisabled() { + return !this.initialEmail + }, + + inputIdWithDefault() { + return this.inputId || `account-property-email--${this.index}` + }, + + inputPlaceholder() { + // Primary email has implicit linked <label> + return !this.primary ? t('settings', 'Additional email address {index}', { index: this.index + 1 }) : undefined + }, + + isNotificationEmail() { + return (this.email && this.email === this.activeNotificationEmail) + || (this.primary && this.activeNotificationEmail === '') + }, + + emailAddress: { + get() { + return this.email + }, + set(value) { + this.$emit('update:email', value) + this.debounceEmailChange(value.trim()) + }, + }, + }, + + 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()) + } + }, + + methods: { + debounceEmailChange: debounce(async function(email) { + // TODO: provide method to get native input in NcTextField + this.helperText = this.$refs.email.$refs.inputField.$refs.input.validationMessage || null + if (this.helperText !== null) { + return + } + if (validateEmail(email) || email === '') { + if (this.primary) { + await this.updatePrimaryEmail(email) + } else { + if (email) { + if (this.initialEmail === '') { + await this.addAdditionalEmail(email) + } else { + await this.updateAdditionalEmail(email) + } + } + } + } + }, 1000), + + async deleteEmail() { + if (this.primary) { + this.$emit('update:email', '') + await this.updatePrimaryEmail('') + } else { + await this.deleteAdditionalEmail() + } + }, + + async updatePrimaryEmail(email) { + try { + const responseData = await savePrimaryEmail(email) + this.handleResponse({ + email, + status: responseData.ocs?.meta?.status, + }) + } catch (e) { + if (email === '') { + this.handleResponse({ + errorMessage: t('settings', 'Unable to delete primary email address'), + error: e, + }) + } else { + this.handleResponse({ + errorMessage: t('settings', 'Unable to update primary email address'), + error: e, + }) + } + } + }, + + async addAdditionalEmail(email) { + try { + const responseData = await saveAdditionalEmail(email) + this.handleResponse({ + email, + status: responseData.ocs?.meta?.status, + }) + } catch (e) { + this.handleResponse({ + errorMessage: t('settings', 'Unable to add additional email address'), + error: e, + }) + } + }, + + async setNotificationMail() { + try { + const newNotificationMailValue = (this.primary || this.isNotificationEmail) ? '' : this.initialEmail + const responseData = await saveNotificationEmail(newNotificationMailValue) + this.handleResponse({ + notificationEmail: newNotificationMailValue, + status: responseData.ocs?.meta?.status, + }) + } catch (e) { + this.handleResponse({ + errorMessage: 'Unable to choose this email for notifications', + error: e, + }) + } + }, + + async updateAdditionalEmail(email) { + try { + const responseData = await updateAdditionalEmail(this.initialEmail, email) + this.handleResponse({ + email, + status: responseData.ocs?.meta?.status, + }) + } catch (e) { + this.handleResponse({ + errorMessage: t('settings', 'Unable to update additional email address'), + error: e, + }) + } + }, + + async deleteAdditionalEmail() { + try { + const responseData = await removeAdditionalEmail(this.initialEmail) + this.handleDeleteAdditionalEmail(responseData.ocs?.meta?.status) + } catch (e) { + this.handleResponse({ + errorMessage: t('settings', 'Unable to delete additional email address'), + error: e, + }) + } + }, + + handleDeleteAdditionalEmail(status) { + if (status === 'ok') { + this.$emit('delete-additional-email') + if (this.isNotificationEmail) { + this.$emit('update:notification-email', '') + } + } else { + this.handleResponse({ + errorMessage: t('settings', 'Unable to delete additional email address'), + }) + } + }, + + handleResponse({ email, notificationEmail, status, errorMessage, error }) { + if (status === 'ok') { + // Ensure that local state reflects server state + if (email) { + this.initialEmail = email + } else if (notificationEmail !== undefined) { + this.$emit('update:notification-email', notificationEmail) + } + this.isSuccess = true + setTimeout(() => { this.isSuccess = false }, 2000) + } else { + handleError(error, errorMessage) + this.hasError = true + setTimeout(() => { this.hasError = false }, 2000) + } + }, + + onScopeChange(scope) { + this.$emit('update:scope', scope) + }, + }, +} +</script> + +<style lang="scss" scoped> +.email { + &__label-container { + height: var(--default-clickable-area); + display: flex; + flex-direction: row; + align-items: center; + gap: calc(var(--default-grid-baseline) * 2); + } + + &__input-container { + position: relative; + } + + &__input { + // TODO: provide a way to hide status icon or combine it with trailing button in NcInputField + :deep(.input-field__icon--trailing) { + display: none; + } + } + + &__actions { + position: absolute; + inset-block-start: 0; + inset-inline-end: 0; + } +} +</style> diff --git a/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue b/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue new file mode 100644 index 00000000000..f9674a3163b --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue @@ -0,0 +1,189 @@ +<!-- + - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <section class="section-emails"> + <HeaderBar :input-id="inputId" + :readable="primaryEmail.readable" + :is-editable="true" + :is-multi-value-supported="true" + :is-valid-section="isValidSection" + :scope.sync="primaryEmail.scope" + @add-additional="onAddAdditionalEmail" /> + + <template v-if="emailChangeSupported"> + <Email :input-id="inputId" + :primary="true" + :scope.sync="primaryEmail.scope" + :email.sync="primaryEmail.value" + :active-notification-email.sync="notificationEmail" + @update:email="onUpdateEmail" + @update:notification-email="onUpdateNotificationEmail" /> + </template> + + <span v-else> + {{ primaryEmail.value || t('settings', 'No email address set') }} + </span> + + <template v-if="additionalEmails.length"> + <!-- TODO use unique key for additional email when uniqueness can be guaranteed, see https://github.com/nextcloud/server/issues/26866 --> + <Email v-for="(additionalEmail, index) in additionalEmails" + :key="additionalEmail.key" + class="section-emails__additional-email" + :index="index" + :scope.sync="additionalEmail.scope" + :email.sync="additionalEmail.value" + :local-verification-state="parseInt(additionalEmail.locallyVerified, 10)" + :active-notification-email.sync="notificationEmail" + @update:email="onUpdateEmail" + @update:notification-email="onUpdateNotificationEmail" + @delete-additional-email="onDeleteAdditionalEmail(index)" /> + </template> + </section> +</template> + +<script> +import { loadState } from '@nextcloud/initial-state' + +import Email from './Email.vue' +import HeaderBar from '../shared/HeaderBar.vue' + +import { ACCOUNT_PROPERTY_READABLE_ENUM, DEFAULT_ADDITIONAL_EMAIL_SCOPE, NAME_READABLE_ENUM } from '../../../constants/AccountPropertyConstants.js' +import { savePrimaryEmail, removeAdditionalEmail } from '../../../service/PersonalInfo/EmailService.js' +import { validateEmail } from '../../../utils/validate.js' +import { handleError } from '../../../utils/handlers.ts' + +const { emailMap: { additionalEmails, primaryEmail, notificationEmail } } = loadState('settings', 'personalInfoParameters', {}) +const { emailChangeSupported } = loadState('settings', 'accountParameters', {}) + +export default { + name: 'EmailSection', + + components: { + HeaderBar, + Email, + }, + + data() { + return { + accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL, + additionalEmails: additionalEmails.map(properties => ({ ...properties, key: this.generateUniqueKey() })), + emailChangeSupported, + primaryEmail: { ...primaryEmail, readable: NAME_READABLE_ENUM[primaryEmail.name] }, + notificationEmail, + } + }, + + computed: { + firstAdditionalEmail() { + if (this.additionalEmails.length) { + return this.additionalEmails[0].value + } + return null + }, + + inputId() { + return `account-property-${this.primaryEmail.name}` + }, + + isValidSection() { + return validateEmail(this.primaryEmail.value) + && this.additionalEmails.map(({ value }) => value).every(validateEmail) + }, + + primaryEmailValue: { + get() { + return this.primaryEmail.value + }, + set(value) { + this.primaryEmail.value = value + }, + }, + }, + + methods: { + onAddAdditionalEmail() { + if (this.isValidSection) { + this.additionalEmails.push({ value: '', scope: DEFAULT_ADDITIONAL_EMAIL_SCOPE, key: this.generateUniqueKey() }) + } + }, + + onDeleteAdditionalEmail(index) { + this.$delete(this.additionalEmails, index) + }, + + async onUpdateEmail() { + if (this.primaryEmailValue === '' && this.firstAdditionalEmail) { + const deletedEmail = this.firstAdditionalEmail + await this.deleteFirstAdditionalEmail() + this.primaryEmailValue = deletedEmail + await this.updatePrimaryEmail() + } + }, + + async onUpdateNotificationEmail(email) { + this.notificationEmail = email + }, + + async updatePrimaryEmail() { + try { + const responseData = await savePrimaryEmail(this.primaryEmailValue) + this.handleResponse(responseData.ocs?.meta?.status) + } catch (e) { + this.handleResponse( + 'error', + t('settings', 'Unable to update primary email address'), + e, + ) + } + }, + + async deleteFirstAdditionalEmail() { + try { + const responseData = await removeAdditionalEmail(this.firstAdditionalEmail) + this.handleDeleteFirstAdditionalEmail(responseData.ocs?.meta?.status) + } catch (e) { + this.handleResponse( + 'error', + t('settings', 'Unable to delete additional email address'), + e, + ) + } + }, + + handleDeleteFirstAdditionalEmail(status) { + if (status === 'ok') { + this.$delete(this.additionalEmails, 0) + } else { + this.handleResponse( + 'error', + t('settings', 'Unable to delete additional email address'), + {}, + ) + } + }, + + handleResponse(status, errorMessage, error) { + if (status !== 'ok') { + handleError(error, errorMessage) + } + }, + + generateUniqueKey() { + return Math.random().toString(36).substring(2) + }, + }, +} +</script> + +<style lang="scss" scoped> +.section-emails { + padding: 10px 10px; + + &__additional-email { + margin-top: calc(var(--default-grid-baseline) * 3); + } +} +</style> |