aboutsummaryrefslogtreecommitdiffstats
path: root/apps/settings/src/components/PersonalInfo/EmailSection
diff options
context:
space:
mode:
Diffstat (limited to 'apps/settings/src/components/PersonalInfo/EmailSection')
-rw-r--r--apps/settings/src/components/PersonalInfo/EmailSection/Email.vue420
-rw-r--r--apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue189
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>