Make emails Vuetiful

Signed-off-by: Christopher Ng <chrng8@gmail.com>
pull/27379/head
Christopher Ng 5 years ago committed by John Molakvoæ (skjnldsv)
parent de6e55075b
commit 44763576b1
No known key found for this signature in database
GPG Key ID: 60C25B8C072916CF
  1. 5
      apps/settings/js/federationsettingsview.js
  2. 78
      apps/settings/src/components/PersonalInfo/EmailSection/AddButton.vue
  3. 323
      apps/settings/src/components/PersonalInfo/EmailSection/Email.vue
  4. 117
      apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue
  5. 160
      apps/settings/src/components/PersonalInfo/EmailSection/FederationControl.vue
  6. 94
      apps/settings/src/components/PersonalInfo/EmailSection/HeaderBar.vue
  7. 38
      apps/settings/src/main-personal-info.js
  8. 52
      apps/settings/templates/settings/personal/personal.info.php
  9. 14
      apps/settings/webpack.js

@ -119,7 +119,10 @@
_registerEvents: function() {
var self = this;
_.each(this._inputFields, function(field) {
if (field === 'avatar') {
if (
field === 'avatar' ||
field === 'email'
) {
return;
}
self.$('#' + field).keyUpDelayedOrEnter(_.bind(self._onInputChanged, self), true);

@ -0,0 +1,78 @@
<!--
- @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/>.
-->
<template>
<button
:disabled="disabled"
@click.stop.prevent="onClick">
<span class="icon icon-add" />
{{ t('settings', 'Add') }}
</button>
</template>
<script>
export default {
name: 'AddButton',
props: {
disabled: {
type: Boolean,
default: true,
},
},
methods: {
onClick(e) {
this.$emit('click', e)
},
},
}
</script>
<style lang="scss" scoped>
button {
height: 44px;
padding: 0 16px;
border: none;
background-color: transparent;
&:hover {
background-color: rgba(127, 127, 127, .15);
}
&:enabled {
opacity: 0.4 !important;
.icon {
opacity: 0.8 !important;
}
}
&:enabled:hover {
background-color: rgba(127, 127, 127, .25);
opacity: 0.8 !important;
}
.icon {
margin-right: 8px;
}
}
</style>

@ -0,0 +1,323 @@
<!--
- @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/>.
-->
<template>
<div>
<div class="email-container">
<input
ref="email"
type="email"
:name="inputName"
:placeholder="inputPlaceholder"
:value="email"
autocapitalize="none"
autocomplete="on"
autocorrect="off"
required="true"
@input="onEmailChange">
<div class="email-actions-container">
<transition name="fade">
<span v-if="showCheckmarkIcon" class="icon-checkmark" />
<span v-else-if="showErrorIcon" class="icon-error" />
</transition>
<FederationControl v-if="!primary"
class="federation-control"
:disabled="federationDisabled"
:email="email"
:scope.sync="localScope"
@update:scope="onScopeChange" />
<Actions
class="actions-email"
:aria-label="t('settings', 'Email options')"
:disabled="deleteDisabled"
:force-menu="true">
<ActionButton
:aria-label="deleteEmailLabel"
:close-after-click="true"
icon="icon-delete"
@click.stop.prevent="deleteEmail">
{{ deleteEmailLabel }}
</ActionButton>
</Actions>
</div>
</div>
<em v-if="primary">
{{ t('settings', 'Primary email for password reset and notifications') }}
</em>
</div>
</template>
<script>
import Actions from '@nextcloud/vue/dist/Components/Actions'
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import { showError } from '@nextcloud/dialogs'
import debounce from 'debounce'
import FederationControl from './FederationControl'
import { savePrimaryEmail, saveAdditionalEmail, updateAdditionalEmail, removeAdditionalEmail } from '../../../service/PersonalInfoService'
export default {
name: 'Email',
components: {
Actions,
ActionButton,
FederationControl,
},
props: {
email: {
type: String,
required: true,
},
scope: {
type: String,
required: true,
},
primary: {
type: Boolean,
default: false,
},
index: {
type: Number,
default: 0,
},
},
data() {
return {
initialEmail: this.email,
localScope: this.scope,
showCheckmarkIcon: false,
showErrorIcon: false,
}
},
computed: {
inputName() {
if (this.primary) {
return 'email'
}
return 'additionalEmail[]'
},
inputPlaceholder() {
if (this.primary) {
return t('settings', 'Your email address')
}
return t('settings', 'Additional email address {index}', { index: this.index + 1 })
},
federationDisabled() {
return !this.initialEmail
},
deleteDisabled() {
return !this.containsNoWhitespace(this.email)
},
deleteEmailLabel() {
if (this.primary) {
return t('settings', 'Remove primary email')
}
return t('settings', 'Delete email')
},
},
methods: {
onEmailChange(e) {
this.$emit('update:email', e.target.value)
// $nextTick() ensures that references to this.email further down the chain give the correct non-outdated value
this.$nextTick(() => this.debounceEmailChange())
},
debounceEmailChange: debounce(async function() {
if ((this.$refs.email?.checkValidity() && this.containsNoWhitespace(this.email)) || this.email === '') {
if (this.primary) {
await this.updatePrimaryEmail()
} else {
if (this.initialEmail && this.email === '') {
await this.deleteAdditionalEmail()
} else if (this.initialEmail === '') {
await this.addAdditionalEmail()
} else {
await this.updateAdditionalEmail()
}
}
}
}, 500),
async deleteEmail() {
if (this.primary) {
this.$emit('update:email', '')
this.$nextTick(async() => await this.updatePrimaryEmail())
} else {
await this.deleteAdditionalEmail()
}
},
async updatePrimaryEmail() {
try {
const responseData = await savePrimaryEmail(this.email)
this.handleResponse(responseData.ocs?.meta?.status)
} catch (e) {
if (this.email === '') {
this.handleResponse('error', 'Unable to delete primary email address', e)
} else {
this.handleResponse('error', 'Unable to update primary email address', e)
}
}
},
async addAdditionalEmail() {
try {
const responseData = await saveAdditionalEmail(this.email)
this.handleResponse(responseData.ocs?.meta?.status)
} catch (e) {
this.handleResponse('error', 'Unable to add additional email address', e)
}
},
async updateAdditionalEmail() {
try {
const responseData = await updateAdditionalEmail(this.initialEmail, this.email)
this.handleResponse(responseData.ocs?.meta?.status)
} catch (e) {
this.handleResponse('error', 'Unable to update additional email address', e)
}
},
async deleteAdditionalEmail() {
try {
const responseData = await removeAdditionalEmail(this.initialEmail)
this.handleDeleteAdditionalEmail(responseData.ocs?.meta?.status)
} catch (e) {
this.handleResponse('error', 'Unable to delete additional email address', e)
}
},
containsNoWhitespace(string) {
return /^\S+$/.test(string)
},
handleDeleteAdditionalEmail(status) {
if (status === 'ok') {
this.$emit('deleteAdditionalEmail')
} else {
this.handleResponse('error', 'Unable to delete additional email address', {})
}
},
handleResponse(status, errorMessage, error) {
if (status === 'ok') {
// Ensure that local initialEmail state reflects server state
this.initialEmail = this.email
this.showCheckmarkIcon = true
setTimeout(() => { this.showCheckmarkIcon = false }, 2000)
} else {
showError(t('settings', errorMessage))
this.logger.error(errorMessage, error)
this.showErrorIcon = true
setTimeout(() => { this.showErrorIcon = false }, 2000)
}
},
onScopeChange(scope) {
this.$emit('update:scope', scope)
},
},
}
</script>
<style lang="scss" scoped>
.email-container {
display: grid;
align-items: center;
input[type=email] {
grid-area: 1 / 1;
}
.email-actions-container {
grid-area: 1 / 1;
justify-self: flex-end;
height: 30px;
display: flex;
gap: 0 2px;
margin-right: 5px;
.actions-email {
opacity: 0.4 !important;
&:hover {
opacity: 0.8 !important;
}
&::v-deep button {
height: 30px !important;
min-height: 30px !important;
width: 30px !important;
min-width: 30px !important;
}
}
.federation-control {
&::v-deep button {
// TODO remove this hack
padding-bottom: 7px;
height: 30px !important;
min-height: 30px !important;
width: 30px !important;
min-width: 30px !important;
}
}
.icon-checkmark,
.icon-error {
height: 30px !important;
min-height: 30px !important;
width: 30px !important;
min-width: 30px !important;
top: 0;
right: 0;
float: none;
}
}
}
.fade-enter-active {
transition: opacity 200ms ease-out;
}
.fade-leave-active {
transition: opacity 300ms ease-out;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
</style>

@ -0,0 +1,117 @@
<!--
- @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/>.
-->
<template>
<form
ref="form"
class="section"
@submit.stop.prevent="() => {}">
<HeaderBar
:can-edit-emails="isDisplayNameChangeSupported"
:is-valid-form="isValidForm"
:scope.sync="primaryEmail.scope"
@addAdditionalEmail="onAddAdditionalEmail" />
<template v-if="isDisplayNameChangeSupported">
<Email
:primary="true"
:scope.sync="primaryEmail.scope"
:email.sync="primaryEmail.value"
@update:email="updateFormValidity" />
<Email v-for="(additionalEmail, index) in additionalEmails"
:key="index"
:index="index"
:scope.sync="additionalEmail.scope"
:email.sync="additionalEmail.value"
@update:email="updateFormValidity"
@deleteAdditionalEmail="onDeleteAdditionalEmail(index)" />
</template>
<span v-else>
{{ primaryEmail.value || t('settings', 'No email address set') }}
</span>
</form>
</template>
<script>
import { loadState } from '@nextcloud/initial-state'
import '@nextcloud/dialogs/styles/toast.scss'
import HeaderBar from './HeaderBar'
import Email from './Email'
import { DEFAULT_ADDITIONAL_EMAIL_SCOPE } from '../../../constants/AccountPropertyConstants'
const { additionalEmails, primaryEmail } = loadState('settings', 'emails', {})
const accountParams = loadState('settings', 'accountParameters', {})
export default {
name: 'EmailSection',
components: {
HeaderBar,
Email,
},
data() {
return {
accountParams,
additionalEmails,
primaryEmail,
isValidForm: true,
}
},
computed: {
isDisplayNameChangeSupported() {
return this.accountParams.displayNameChangeSupported
},
},
mounted() {
this.$nextTick(() => this.updateFormValidity())
},
methods: {
onAddAdditionalEmail() {
if (this.$refs.form?.checkValidity()) {
this.additionalEmails.push({ value: '', scope: DEFAULT_ADDITIONAL_EMAIL_SCOPE })
this.$nextTick(() => this.updateFormValidity())
}
},
onDeleteAdditionalEmail(index) {
this.$delete(this.additionalEmails, index)
},
updateFormValidity() {
this.isValidForm = this.$refs.form?.checkValidity()
},
},
}
</script>
<style lang="scss" scoped>
form::v-deep button {
&:disabled {
cursor: default;
}
}
</style>

@ -0,0 +1,160 @@
<!--
- @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/>.
-->
<template>
<Actions
class="actions-federation"
:aria-label="t('settings', 'Change privacy level of email')"
:default-icon="scopeIcon"
:disabled="disabled">
<ActionButton v-for="federationScope in federationScopes"
:key="federationScope.name"
class="forced-action"
:class="{ 'forced-active': scope === federationScope.name }"
:aria-label="federationScope.tooltip"
:close-after-click="true"
:icon="federationScope.iconClass"
:title="federationScope.displayName"
@click.stop.prevent="changeScope(federationScope.name)">
{{ federationScope.tooltip }}
</ActionButton>
</Actions>
</template>
<script>
import Actions from '@nextcloud/vue/dist/Components/Actions'
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import { showError } from '@nextcloud/dialogs'
import { SCOPE_ENUM, SCOPE_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants'
import { savePrimaryEmailScope, saveAdditionalEmailScope } from '../../../service/PersonalInfoService'
// TODO hardcoded for email, should abstract this for other sections
const excludedScopes = [SCOPE_ENUM.PRIVATE]
export default {
name: 'FederationControl',
components: {
Actions,
ActionButton,
},
props: {
primary: {
type: Boolean,
default: false,
},
email: {
type: String,
default: '',
},
scope: {
type: String,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
},
data() {
return {
initialScope: this.scope,
federationScopes: Object.values(SCOPE_PROPERTY_ENUM).filter(({ name }) => !excludedScopes.includes(name)),
}
},
computed: {
scopeIcon() {
return SCOPE_PROPERTY_ENUM[this.scope].iconClass
},
},
methods: {
async changeScope(scope) {
this.$emit('update:scope', scope)
this.$nextTick(async() => {
if (this.primary) {
await this.updatePrimaryEmailScope()
} else {
await this.updateAdditionalEmailScope()
}
})
},
async updatePrimaryEmailScope() {
try {
const responseData = await savePrimaryEmailScope(this.scope)
this.handleResponse(responseData.ocs?.meta?.status)
} catch (e) {
this.handleResponse('error', 'Unable to update federation scope of the primary email', e)
}
},
async updateAdditionalEmailScope() {
try {
const responseData = await saveAdditionalEmailScope(this.email, this.scope)
this.handleResponse(responseData.ocs?.meta?.status)
} catch (e) {
this.handleResponse('error', 'Unable to update federation scope of additional email', e)
}
},
handleResponse(status, errorMessage, error) {
if (status === 'ok') {
this.initialScope = this.scope
} else {
this.$emit('update:scope', this.initialScope)
showError(t('settings', errorMessage))
this.logger.error(errorMessage, error)
}
},
},
}
</script>
<style lang="scss" scoped>
.actions-federation {
opacity: 0.4 !important;
&:hover {
opacity: 0.8 !important;
}
}
.forced-active {
background-color: var(--color-primary-light) !important;
box-shadow: inset 2px 0 var(--color-primary) !important;
}
.forced-action {
&::v-deep p {
width: 150px !important;
padding: 8px 0 !important;
color: var(--color-main-text) !important;
font-size: 12.8px !important;
line-height: 1.5em !important;
}
}
</style>

@ -0,0 +1,94 @@
<!--
- @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/>.
-->
<template>
<h3>
<label for="email">
{{ t('settings', 'Email') }}
</label>
<FederationControl
class="federation-control"
:primary="true"
:scope.sync="localScope"
@update:scope="onScopeChange" />
<AddButton v-if="canEditEmails"
class="add-button"
:disabled="!isValidForm"
@click.stop.prevent="addAdditionalEmail" />
</h3>
</template>
<script>
import FederationControl from './FederationControl'
import AddButton from './AddButton'
export default {
name: 'HeaderBar',
components: {
FederationControl,
AddButton,
},
props: {
canEditEmails: {
type: Boolean,
default: true,
},
isValidForm: {
type: Boolean,
default: true,
},
scope: {
type: String,
required: true,
},
},
data() {
return {
localScope: this.scope,
}
},
methods: {
addAdditionalEmail() {
this.$emit('addAdditionalEmail')
},
onScopeChange(scope) {
this.$emit('update:scope', scope)
},
},
}
</script>
<style lang="scss" scoped>
.federation-control {
margin: -12px 0 0 8px;
}
.add-button {
margin: -12px 0 0 auto !important;
}
</style>

@ -0,0 +1,38 @@
/**
* @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 Vue from 'vue'
import logger from './logger'
import EmailSection from './components/PersonalInfo/EmailSection/EmailSection'
// eslint-disable-next-line camelcase
__webpack_nonce__ = btoa(OC.requestToken)
Vue.prototype.t = t
Vue.prototype.logger = logger
const View = Vue.extend(EmailSection)
export default new View({
el: '#vue-emailsection',
})

@ -31,6 +31,7 @@ script('settings', [
'federationsettingsview',
'federationscopemenu',
'settings/personalInfo',
'vue-settings-personal-info',
]);
?>
@ -126,52 +127,7 @@ script('settings', [
</form>
</div>
<div class="personal-settings-setting-box">
<form id="emailform" class="section">
<h3>
<label for="email"><?php p($l->t('Email')); ?></label>
<a href="#" class="federation-menu" aria-label="<?php p($l->t('Change privacy level of email')); ?>">
<span class="icon-federation-menu icon-password">
<span class="icon-triangle-s"></span>
</span>
</a>
</h3>
<div class="verify <?php if ($_['email'] === '' || $_['emailScope'] !== 'public') {
p('hidden');
} ?>">
<img id="verify-email" title="<?php p($_['emailMessage']); ?>" data-status="<?php p($_['emailVerification']) ?>" src="
<?php
switch ($_['emailVerification']) {
case \OC\Accounts\AccountManager::VERIFICATION_IN_PROGRESS:
p(image_path('core', 'actions/verifying.svg'));
break;
case \OC\Accounts\AccountManager::VERIFIED:
p(image_path('core', 'actions/verified.svg'));
break;
default:
p(image_path('core', 'actions/verify.svg'));
}
?>">
</div>
<input type="email" name="email" id="email" value="<?php p($_['email']); ?>"
<?php if (!$_['displayNameChangeSupported']) {
print_unescaped('class="hidden"');
} ?>
placeholder="<?php p($l->t('Your email address')); ?>"
autocomplete="on" autocapitalize="none" autocorrect="off" />
<span class="icon-checkmark hidden"></span>
<span class="icon-error hidden" ></span>
<?php if (!$_['displayNameChangeSupported']) { ?>
<span><?php if (isset($_['email']) && !empty($_['email'])) {
p($_['email']);
} else {
p($l->t('No email address set'));
}?></span>
<?php } ?>
<?php if ($_['displayNameChangeSupported']) { ?>
<em><?php p($l->t('For password reset and notifications')); ?></em>
<?php } ?>
<input type="hidden" id="emailscope" value="<?php p($_['emailScope']) ?>">
</form>
<div id="vue-emailsection" class="section"></div>
</div>
<div class="personal-settings-setting-box">
<form id="phoneform" class="section">
@ -223,8 +179,8 @@ script('settings', [
</h3>
<?php if ($_['lookupServerUploadEnabled']) { ?>
<div class="verify <?php if ($_['website'] === '' || $_['websiteScope'] !== 'public') {
p('hidden');
} ?>">
p('hidden');
} ?>">
<img id="verify-website" title="<?php p($_['websiteMessage']); ?>" data-status="<?php p($_['websiteVerification']) ?>" src="
<?php
switch ($_['websiteVerification']) {

@ -2,6 +2,7 @@
* @copyright Copyright (c) 2016 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author Christopher Ng <chrng8@gmail.com>
* @author Jan C. Borchardt <hey@jancborchardt.net>
* @author John Molakvoæ <skjnldsv@protonmail.com>
* @author Roeland Jago Douma <roeland@famdouma.nl>
@ -25,13 +26,26 @@
const path = require('path')
// TODO use @nextcloud/webpack-vue-config
module.exports = {
module: {
rules: [
{
test: /\.(png|jpe?g|gif|svg|woff2?|eot|ttf)$/,
loader: 'url-loader',
options: {
name: '[name].[ext]?[hash]',
},
},
]
},
entry: {
'settings-apps-users-management': path.join(__dirname, 'src', 'main-apps-users-management'),
'settings-admin-security': path.join(__dirname, 'src', 'main-admin-security'),
'settings-personal-security': path.join(__dirname, 'src', 'main-personal-security'),
'settings-personal-webauthn': path.join(__dirname, 'src', 'main-personal-webauth'),
'settings-nextcloud-pdf': path.join(__dirname, 'src', 'main-nextcloud-pdf'),
'settings-personal-info': path.join(__dirname, 'src', 'main-personal-info'),
},
output: {
path: path.resolve(__dirname, './js'),

Loading…
Cancel
Save