diff options
Diffstat (limited to 'apps/settings/src/components/PersonalInfo/EmailSection/Email.vue')
-rw-r--r-- | apps/settings/src/components/PersonalInfo/EmailSection/Email.vue | 420 |
1 files changed, 420 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> |