diff --git a/ghost/admin/app/components/modal-portal-settings.hbs b/ghost/admin/app/components/modal-portal-settings.hbs index b5267fe159..19796fd88a 100644 --- a/ghost/admin/app/components/modal-portal-settings.hbs +++ b/ghost/admin/app/components/modal-portal-settings.hbs @@ -275,23 +275,16 @@ {{#liquid-if isOpen}} diff --git a/ghost/admin/app/components/modal-portal-settings.js b/ghost/admin/app/components/modal-portal-settings.js index 59b08ecb05..2ee7bac5d5 100644 --- a/ghost/admin/app/components/modal-portal-settings.js +++ b/ghost/admin/app/components/modal-portal-settings.js @@ -1,3 +1,4 @@ +import ConfirmEmailModal from './modals/settings/confirm-email'; import ModalComponent from 'ghost-admin/components/modal-base'; import copyTextToClipboard from 'ghost-admin/utils/copy-text-to-clipboard'; import {action, computed} from '@ember/object'; @@ -8,6 +9,7 @@ const ICON_EXTENSIONS = ['gif', 'jpg', 'jpeg', 'png', 'svg']; export default ModalComponent.extend({ config: service(), + modals: service(), membersUtils: service(), settings: service(), store: service(), @@ -34,14 +36,6 @@ export default ModalComponent.extend({ return htmlSafe(`background-color: ${color}`); }), - disableUpdateSupportAddressButton: computed('supportAddress', function () { - const savedSupportAddress = this.get('settings.membersSupportAddress') || ''; - if (!savedSupportAddress.includes('@') && this.config.emailDomain) { - return !this.supportAddress || (this.supportAddress === `${savedSupportAddress}@${this.config.emailDomain}`); - } - return !this.supportAddress || (this.supportAddress === savedSupportAddress); - }), - showModalLinkOrAttribute: computed('isShowModalLink', function () { if (this.isShowModalLink) { return `#/portal`; @@ -256,6 +250,12 @@ export default ModalComponent.extend({ setSupportAddress(supportAddress) { this.set('supportAddress', supportAddress); + + if (this.config.emailDomain && supportAddress === `noreply@${this.config.emailDomain}`) { + this.settings.set('membersSupportAddress', 'noreply'); + } else { + this.settings.set('membersSupportAddress', supportAddress); + } } }, @@ -355,6 +355,10 @@ export default ModalComponent.extend({ saveTask: task(function* () { this.send('validateFreeSignupRedirect'); this.send('validatePaidSignupRedirect'); + + this.settings.errors.remove('members_support_address'); + this.settings.hasValidated.removeObject('members_support_address'); + if (this.settings.get('errors').length !== 0) { return; } @@ -369,25 +373,30 @@ export default ModalComponent.extend({ }) ); - yield this.settings.save(); + const newEmail = this.settings.get('membersSupportAddress'); - this.closeModal(); - }).drop(), - - updateSupportAddress: task(function* () { - let url = this.get('ghostPaths.url').api('/settings/members/email'); try { - yield this.ajax.post(url, { - data: { - email: this.supportAddress, - type: 'supportAddressUpdate' - } - }); + const result = yield this.settings.save(); + if (result._meta?.sent_email_verification) { + yield this.modals.open(ConfirmEmailModal, { + newEmail, + currentEmail: this.settings.get('membersSupportAddress') + }); + } - return true; - } catch (e) { - // Failed to send email, retry - return false; + this.closeModal(); + } catch (error) { + // Do we have an error that we can show inline? + if (error.payload && error.payload.errors) { + for (const payloadError of error.payload.errors) { + if (payloadError.type === 'ValidationError' && payloadError.property && (payloadError.context || payloadError.message)) { + // Context has a better error message for validation errors + this.settings.errors.add(payloadError.property, payloadError.context || payloadError.message); + this.settings.hasValidated.pushObject(payloadError.property); + } + } + } + throw error; } }).drop() }); diff --git a/ghost/admin/app/components/modals/settings/confirm-email.hbs b/ghost/admin/app/components/modals/settings/confirm-email.hbs new file mode 100644 index 0000000000..6ca001db4b --- /dev/null +++ b/ghost/admin/app/components/modals/settings/confirm-email.hbs @@ -0,0 +1,21 @@ + diff --git a/ghost/admin/app/components/modals/settings/verify-email.hbs b/ghost/admin/app/components/modals/settings/verify-email.hbs new file mode 100644 index 0000000000..f547a5b455 --- /dev/null +++ b/ghost/admin/app/components/modals/settings/verify-email.hbs @@ -0,0 +1,32 @@ + diff --git a/ghost/admin/app/components/modals/settings/verify-email.js b/ghost/admin/app/components/modals/settings/verify-email.js new file mode 100644 index 0000000000..b09f671a83 --- /dev/null +++ b/ghost/admin/app/components/modals/settings/verify-email.js @@ -0,0 +1,46 @@ +import Component from '@glimmer/component'; +import {action} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; +import {tracked} from '@glimmer/tracking'; + +export default class VerifyEmail extends Component { + @service ajax; + @service ghostPaths; + @service router; + @service store; + @service settings; + + @tracked error = null; + @tracked email = null; + + constructor() { + super(...arguments); + this.verifyEmailTask.perform(this.args.data.token); + + this.router.on('routeDidChange', this.handleRouteChange); + } + + willDestroy() { + super.willDestroy(...arguments); + this.router.off('routeDidChange', this.handleRouteChange); + } + + @task + *verifyEmailTask(token) { + try { + const url = this.ghostPaths.url.api('settings', 'verifications'); + + yield this.ajax.put(url, {data: {token}}); + yield this.settings.reload(); + this.email = this.settings.get('membersSupportAddress'); + } catch (e) { + this.error = e.message; + } + } + + @action + handleRouteChange() { + this.args.close(); + } +} diff --git a/ghost/admin/app/controllers/settings/membership.js b/ghost/admin/app/controllers/settings/membership.js index fbe089d091..42269d810d 100644 --- a/ghost/admin/app/controllers/settings/membership.js +++ b/ghost/admin/app/controllers/settings/membership.js @@ -43,7 +43,8 @@ export default class MembersAccessController extends Controller { portalPreviewGuid = Date.now().valueOf(); - queryParams = ['showPortalSettings']; + queryParams = ['showPortalSettings', 'verifyEmail']; + @tracked verifyEmail = null; get freeTier() { return this.tiers?.find(tier => tier.type === 'free'); diff --git a/ghost/admin/app/models/setting.js b/ghost/admin/app/models/setting.js index 401c4f8192..19e7d3aa7d 100644 --- a/ghost/admin/app/models/setting.js +++ b/ghost/admin/app/models/setting.js @@ -82,5 +82,9 @@ export default Model.extend(ValidationEngine, { editorDefaultEmailRecipientsFilter: attr('members-segment-string'), emailVerificationRequired: attr('boolean'), - mailgunIsConfigured: and('mailgunApiKey', 'mailgunDomain', 'mailgunBaseUrl') + mailgunIsConfigured: and('mailgunApiKey', 'mailgunDomain', 'mailgunBaseUrl'), + + // HACK - not a real model attribute but a workaround for Ember Data not + // exposing meta from save responses + _meta: attr() }); diff --git a/ghost/admin/app/routes/settings/membership.js b/ghost/admin/app/routes/settings/membership.js index cedc4652e4..9f4510ea77 100644 --- a/ghost/admin/app/routes/settings/membership.js +++ b/ghost/admin/app/routes/settings/membership.js @@ -1,13 +1,22 @@ import AdminRoute from 'ghost-admin/routes/admin'; +import VerifyEmail from '../../components/modals/settings/verify-email'; import {inject as service} from '@ember/service'; export default class MembershipSettingsRoute extends AdminRoute { @service notifications; @service settings; + @service modals; + + queryParams = { + verifyEmail: { + replace: true + } + }; beforeModel(transition) { super.beforeModel(...arguments); + // @todo: remove in the future, but keep it for now because we might still have some old verification urls in emails if (transition.to.queryParams?.supportAddressUpdate === 'success') { this.notifications.showAlert( `Support email address has been updated`, @@ -20,6 +29,18 @@ export default class MembershipSettingsRoute extends AdminRoute { this.settings.reload(); } + afterModel(model, transition) { + if (transition.to.queryParams.verifyEmail) { + this.modals.open(VerifyEmail, { + token: transition.to.queryParams.verifyEmail + }); + + // clear query param so it doesn't linger and cause problems re-entering route + transition.abort(); + return this.transitionTo('settings.membership', {queryParams: {verifyEmail: null}}); + } + } + actions = { willTransition(transition) { return this.controller.leaveRoute(transition); diff --git a/ghost/admin/app/serializers/setting.js b/ghost/admin/app/serializers/setting.js index 3a59ee11b6..2886fcad17 100644 --- a/ghost/admin/app/serializers/setting.js +++ b/ghost/admin/app/serializers/setting.js @@ -12,6 +12,7 @@ export default class Setting extends ApplicationSerializer { let payload = []; delete data.id; + delete data._meta; Object.keys(data).forEach((k) => { payload.push({key: k, value: data[k]}); @@ -37,6 +38,15 @@ export default class Setting extends ApplicationSerializer { payload[setting.key] = setting.value; }); + // HACK: Ember Data doesn't expose `meta` properties consistently + // - https://github.com/emberjs/data/issues/2905 + // + // We need the `meta` data returned when saving so we extract it and dump + // it onto the model as an attribute then delete it again when serializing. + if (_payload.meta) { + payload._meta = _payload.meta; + } + return payload; } }