mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-24 03:14:03 +03:00
Added sender email verification flow for newsletters
refs https://github.com/TryGhost/Team/issues/584 refs https://github.com/TryGhost/Team/issues/1498 - updated newsletter save routine in `edit-newsletter` modal to open an email confirmation modal if the API indicates one was sent - modal indicates that the previously set or default email will continue to be used until verified - response from API when saving looks like `{newsletters: [{...}], meta: {sent_email_verification: ['sender_name]}}` - added custom newsletter serializer and updated model so that the `meta` property returned in the response when saving posts is exposed - Ember Data only exposes meta on array-response find/query methods - https://github.com/emberjs/data/issues/2905 - added `/settings/members-email-labs/?verifyEmail=xyz` query param handling - opens email verification modal if param is set and instantly clears the query param to avoid problems with sticky params - when the modal opens it makes a `PUT /newsletters/verify-email/` request with the token in the body params, on the API side this works the same as a newsletter update request returning the fully updated newsletter record which is then pushed into the store - removed unused from/reply address code from `<Settings::MembersEmailLabs>` component and controller - setting the values now handled per-newsletter in the edit-newsletter modal - verifying email change is handled in the members-email-labs controller - fixed mirage not outputting pluralized root for "singular" endpoints such as POST/PUT requests to better match our API behaviour
This commit is contained in:
parent
cc6c0acf55
commit
e398557a75
@ -1,9 +1,13 @@
|
||||
import Component from '@glimmer/component';
|
||||
import ConfirmNewsletterEmailModal from './edit-newsletter/confirm-newsletter-email';
|
||||
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 EditNewsletterModal extends Component {
|
||||
@service modals;
|
||||
|
||||
static modalOptions = {
|
||||
className: 'fullscreen-modal-full-overlay fullscreen-modal-portal-settings'
|
||||
};
|
||||
@ -31,8 +35,17 @@ export default class EditNewsletterModal extends Component {
|
||||
@task
|
||||
*saveTask() {
|
||||
try {
|
||||
const newEmail = this.args.data.newsletter.senderEmail;
|
||||
|
||||
const result = yield this.args.data.newsletter.save();
|
||||
|
||||
if (result._meta?.sent_email_verification) {
|
||||
yield this.modals.open(ConfirmNewsletterEmailModal, {
|
||||
newEmail,
|
||||
currentEmail: this.args.data.newsletter.senderEmail
|
||||
});
|
||||
}
|
||||
|
||||
this.args.data.afterSave?.(result);
|
||||
|
||||
return result;
|
||||
|
@ -0,0 +1,26 @@
|
||||
<div class="modal-content">
|
||||
<header class="modal-header" data-test-modal="confirm-newsletter-email">
|
||||
<h1>Confirm newsletter email address</h1>
|
||||
</header>
|
||||
<button type="button" class="close" role="button" title="Close" {{on "click" @close}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
|
||||
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
We've sent a confirmation email to <strong>{{@data.newEmail}}</strong>.
|
||||
Until the address has been verified newsletters will be sent from the
|
||||
{{if @data.currentEmail "previous" "default"}} email address
|
||||
({{full-email-address (or @data.currentEmail "noreply")}}).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="gh-btn"
|
||||
{{on "click" @close}}
|
||||
{{on-key "Enter"}}
|
||||
>
|
||||
<span>Ok</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,34 @@
|
||||
<div class="modal-content">
|
||||
<header class="modal-header" data-test-modal="verify-newsletter-email">
|
||||
<h1>Verifying newsletter email address</h1>
|
||||
</header>
|
||||
<button type="button" class="close" role="button" title="Close" {{on "click" @close}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
|
||||
|
||||
<div class="modal-body">
|
||||
{{#if this.verifyEmailTask.isRunning}}
|
||||
<div class="flex justify-center flex-auto">
|
||||
<div class="gh-loading-spinner"></div>
|
||||
</div>
|
||||
{{else if this.newsletter}}
|
||||
<p>
|
||||
Success! From address for newsletter
|
||||
"<LinkTo @route="settings.members-email-labs.edit-newsletter" @model={{this.newsletter.id}}>{{this.newsletter.name}}</LinkTo>"
|
||||
changed to <strong>{{this.newsletter.senderEmail}}</strong>
|
||||
</p>
|
||||
{{else if this.error}}
|
||||
<p>Verification failed:</p>
|
||||
<p>{{this.error}}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="gh-btn"
|
||||
{{on "click" @close}}
|
||||
{{on-key "Enter"}}
|
||||
>
|
||||
<span>Ok</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,49 @@
|
||||
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 VerifyNewsletterEmail extends Component {
|
||||
@service ajax;
|
||||
@service ghostPaths;
|
||||
@service router;
|
||||
@service store;
|
||||
|
||||
@tracked error = null;
|
||||
@tracked newsletter = 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('newsletters', 'verify-email');
|
||||
|
||||
const response = yield this.ajax.put(url, {data: {token}});
|
||||
|
||||
if (response.newsletters) {
|
||||
this.store.pushPayload('newsletter', response);
|
||||
const newsletter = this.store.peekRecord('newsletter', response.newsletters[0].id);
|
||||
this.newsletter = newsletter;
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleRouteChange() {
|
||||
this.args.close();
|
||||
}
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
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';
|
||||
|
||||
const US = {flag: '🇺🇸', name: 'US', baseUrl: 'https://api.mailgun.net/v3'};
|
||||
@ -9,8 +8,6 @@ const EU = {flag: '🇪🇺', name: 'EU', baseUrl: 'https://api.eu.mailgun.net/v
|
||||
|
||||
export default class MembersEmailLabs extends Component {
|
||||
@service config;
|
||||
@service ghostPaths;
|
||||
@service ajax;
|
||||
@service settings;
|
||||
|
||||
// set recipientsSelectValue as a static property because within this
|
||||
@ -19,41 +16,12 @@ export default class MembersEmailLabs extends Component {
|
||||
// from settings as it would equate to "none"
|
||||
@tracked recipientsSelectValue = this._getDerivedRecipientsSelectValue();
|
||||
|
||||
@tracked showFromAddressConfirmation = false;
|
||||
|
||||
mailgunRegions = [US, EU];
|
||||
|
||||
replyAddresses = [
|
||||
{
|
||||
label: 'Newsletter email address (' + this.fromAddress + ')',
|
||||
value: 'newsletter'
|
||||
},
|
||||
{
|
||||
label: 'Support email address (' + this.supportAddress + ')',
|
||||
value: 'support'
|
||||
}
|
||||
];
|
||||
|
||||
get emailNewsletterEnabled() {
|
||||
return this.settings.get('editorDefaultEmailRecipients') !== 'disabled';
|
||||
}
|
||||
|
||||
get emailPreviewVisible() {
|
||||
return this.recipientsSelectValue !== 'none';
|
||||
}
|
||||
|
||||
get selectedReplyAddress() {
|
||||
return this.replyAddresses.findBy('value', this.settings.get('membersReplyAddress'));
|
||||
}
|
||||
|
||||
get disableUpdateFromAddressButton() {
|
||||
const savedFromAddress = this.settings.get('membersFromAddress') || '';
|
||||
if (!savedFromAddress.includes('@') && this.config.emailDomain) {
|
||||
return !this.fromAddress || (this.fromAddress === `${savedFromAddress}@${this.config.emailDomain}`);
|
||||
}
|
||||
return !this.fromAddress || (this.fromAddress === savedFromAddress);
|
||||
}
|
||||
|
||||
get mailgunRegion() {
|
||||
if (!this.settings.get('mailgunBaseUrl')) {
|
||||
return US;
|
||||
@ -72,11 +40,6 @@ export default class MembersEmailLabs extends Component {
|
||||
};
|
||||
}
|
||||
|
||||
@action
|
||||
toggleFromAddressConfirmation() {
|
||||
this.showFromAddressConfirmation = !this.showFromAddressConfirmation;
|
||||
}
|
||||
|
||||
@action
|
||||
setMailgunDomain(event) {
|
||||
this.settings.set('mailgunDomain', event.target.value);
|
||||
@ -98,11 +61,6 @@ export default class MembersEmailLabs extends Component {
|
||||
this.settings.set('mailgunBaseUrl', region.baseUrl);
|
||||
}
|
||||
|
||||
@action
|
||||
setFromAddress(fromAddress) {
|
||||
this.setEmailAddress('fromAddress', fromAddress);
|
||||
}
|
||||
|
||||
@action
|
||||
toggleEmailTrackOpens(event) {
|
||||
if (event) {
|
||||
@ -129,13 +87,6 @@ export default class MembersEmailLabs extends Component {
|
||||
this.recipientsSelectValue = this._getDerivedRecipientsSelectValue();
|
||||
}
|
||||
|
||||
@action
|
||||
setReplyAddress(event) {
|
||||
const newReplyAddress = event.value;
|
||||
|
||||
this.settings.set('membersReplyAddress', newReplyAddress);
|
||||
}
|
||||
|
||||
@action
|
||||
setDefaultEmailRecipients(value) {
|
||||
// Update the underlying setting properties to match the selected recipients option
|
||||
@ -169,24 +120,6 @@ export default class MembersEmailLabs extends Component {
|
||||
this.settings.set('editorDefaultEmailRecipientsFilter', filter);
|
||||
}
|
||||
|
||||
@task({drop: true})
|
||||
*updateFromAddress() {
|
||||
let url = this.ghostPaths.url.api('/settings/members/email');
|
||||
try {
|
||||
const response = yield this.ajax.post(url, {
|
||||
data: {
|
||||
email: this.fromAddress,
|
||||
type: 'fromAddressUpdate'
|
||||
}
|
||||
});
|
||||
this.toggleFromAddressConfirmation();
|
||||
return response;
|
||||
} catch (e) {
|
||||
// Failed to send email, retry
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_getDerivedRecipientsSelectValue() {
|
||||
const defaultEmailRecipients = this.settings.get('editorDefaultEmailRecipients');
|
||||
const defaultEmailRecipientsFilter = this.settings.get('editorDefaultEmailRecipientsFilter');
|
||||
|
@ -1,37 +1,14 @@
|
||||
import Controller from '@ember/controller';
|
||||
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 MembersEmailLabsController extends Controller {
|
||||
@service config;
|
||||
@service session;
|
||||
@service settings;
|
||||
|
||||
// from/supportAddress are set here so that they can be reset to saved values on save
|
||||
// to avoid it looking like they've been saved when they have a separate update process
|
||||
@tracked fromAddress = '';
|
||||
@tracked supportAddress = '';
|
||||
queryParams = ['verifyEmail'];
|
||||
|
||||
@action
|
||||
setEmailAddress(property, email) {
|
||||
this[property] = email;
|
||||
}
|
||||
|
||||
parseEmailAddress(address) {
|
||||
const emailAddress = address || 'noreply';
|
||||
// Adds default domain as site domain
|
||||
if (emailAddress.indexOf('@') < 0 && this.config.emailDomain) {
|
||||
return `${emailAddress}@${this.config.emailDomain}`;
|
||||
}
|
||||
return emailAddress;
|
||||
}
|
||||
|
||||
resetEmailAddresses() {
|
||||
this.fromAddress = this.parseEmailAddress(this.settings.get('membersFromAddress'));
|
||||
this.supportAddress = this.parseEmailAddress(this.settings.get('membersSupportAddress'));
|
||||
}
|
||||
@tracked verifyEmail = null;
|
||||
|
||||
@task({drop: true})
|
||||
*saveSettings() {
|
||||
|
@ -27,4 +27,8 @@ export default class Newsletter extends Model.extend(ValidationEngine) {
|
||||
@attr({defaultValue: 'sans_serif'}) bodyFontCategory;
|
||||
@attr() footerContent;
|
||||
@attr({defaultValue: true}) showBadge;
|
||||
|
||||
// HACK - not a real model attribute but a workaround for Ember Data not
|
||||
// exposing meta from save responses
|
||||
@attr _meta;
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import AdminRoute from 'ghost-admin/routes/admin';
|
||||
import ConfirmUnsavedChangesModal from '../../components/modals/confirm-unsaved-changes';
|
||||
import VerifyNewsletterEmail from '../../components/modals/edit-newsletter/verify-newsletter-email';
|
||||
import {action} from '@ember/object';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
@ -9,30 +10,37 @@ export default class MembersEmailLabsRoute extends AdminRoute {
|
||||
@service notifications;
|
||||
@service settings;
|
||||
|
||||
queryParams = {
|
||||
verifyEmail: {
|
||||
replace: true
|
||||
}
|
||||
};
|
||||
|
||||
confirmModal = null;
|
||||
hasConfirmed = false;
|
||||
|
||||
beforeModel(transition) {
|
||||
beforeModel() {
|
||||
super.beforeModel(...arguments);
|
||||
|
||||
if (!this.feature.multipleNewsletters) {
|
||||
return this.transitionTo('settings.members-email');
|
||||
}
|
||||
|
||||
if (transition.to.queryParams?.fromAddressUpdate === 'success') {
|
||||
this.notifications.showAlert(
|
||||
`Newsletter email address has been updated`,
|
||||
{type: 'success', key: 'members.settings.from-address.updated'}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
model() {
|
||||
return this.settings.reload();
|
||||
}
|
||||
|
||||
setupController(controller) {
|
||||
controller.resetEmailAddresses();
|
||||
afterModel(model, transition) {
|
||||
if (transition.to.queryParams.verifyEmail) {
|
||||
this.modals.open(VerifyNewsletterEmail, {
|
||||
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.members-email-labs', {queryParams: {verifyEmail: null}});
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
|
27
ghost/admin/app/serializers/newsletter.js
Normal file
27
ghost/admin/app/serializers/newsletter.js
Normal file
@ -0,0 +1,27 @@
|
||||
/* eslint-disable camelcase */
|
||||
import ApplicationSerializer from './application';
|
||||
|
||||
export default class MemberSerializer extends ApplicationSerializer {
|
||||
// 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.
|
||||
normalizeResponse() {
|
||||
const json = super.normalizeResponse(...arguments);
|
||||
|
||||
if (json.meta && json.data.attributes) {
|
||||
json.data.attributes._meta = json.meta;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
serialize() {
|
||||
const json = super.serialize(...arguments);
|
||||
|
||||
delete json._meta;
|
||||
|
||||
return json;
|
||||
}
|
||||
}
|
@ -20,11 +20,7 @@
|
||||
|
||||
<section class="view-container settings-debug">
|
||||
<div class="gh-setting-liquid-section">
|
||||
<Settings::MembersEmailLabs
|
||||
@fromAddress={{this.fromAddress}}
|
||||
@supportAddress={{this.supportAddress}}
|
||||
@setEmailAddress={{this.setEmailAddress}}
|
||||
/>
|
||||
<Settings::MembersEmailLabs />
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
@ -1,8 +1,77 @@
|
||||
import {camelize} from '@ember/string';
|
||||
import {paginatedResponse} from '../utils';
|
||||
|
||||
export default function mockNewsletters(server) {
|
||||
server.post('/newsletters/');
|
||||
server.get('/newsletters/', paginatedResponse('newsletters'));
|
||||
server.get('/newsletters/:id/');
|
||||
server.put('/newsletters/:id/');
|
||||
|
||||
server.post('/newsletters/', function ({newsletters}) {
|
||||
const attrs = this.normalizedRequestAttrs();
|
||||
|
||||
// sender email can't be set without verification
|
||||
const senderEmail = attrs.senderEmail;
|
||||
attrs.senderEmail = null;
|
||||
|
||||
const newsletter = newsletters.create(attrs);
|
||||
|
||||
// workaround for mirage output of meta
|
||||
const collection = newsletters.where({id: newsletter.id});
|
||||
|
||||
if (senderEmail) {
|
||||
collection.meta = {
|
||||
sent_email_verification: ['sender_email']
|
||||
};
|
||||
}
|
||||
|
||||
return collection;
|
||||
});
|
||||
|
||||
server.put('/newsletters/:id/', function ({newsletters}, {params}) {
|
||||
const attrs = this.normalizedRequestAttrs();
|
||||
const newsletter = newsletters.find(params.id);
|
||||
|
||||
const previousSenderEmail = newsletter.senderEmail;
|
||||
const newSenderEmail = attrs.senderEmail;
|
||||
|
||||
// sender email can't be changed without verification
|
||||
if (newSenderEmail && newSenderEmail !== previousSenderEmail) {
|
||||
attrs.senderEmail = previousSenderEmail;
|
||||
}
|
||||
|
||||
newsletter.update(attrs);
|
||||
|
||||
// workaround for mirage output of meta
|
||||
const collection = newsletters.where({id: newsletter.id});
|
||||
|
||||
if (newSenderEmail && newSenderEmail !== previousSenderEmail) {
|
||||
collection.meta = {
|
||||
sent_email_verification: ['sender_email']
|
||||
};
|
||||
|
||||
const tokenData = {
|
||||
id: newsletter.id,
|
||||
email: newSenderEmail,
|
||||
type: 'sender_email'
|
||||
};
|
||||
const token = btoa(JSON.stringify(tokenData));
|
||||
const baseUrl = window.location.href.replace(window.location.hash, '');
|
||||
const verifyUrl = `${baseUrl}settings/members-email-labs/?verifyEmail=${token}`;
|
||||
// eslint-disable-next-line
|
||||
console.warn('Verification email sent. Mocked verification URL:', verifyUrl);
|
||||
}
|
||||
|
||||
return collection;
|
||||
});
|
||||
|
||||
// verify email update
|
||||
server.put('/newsletters/verify-email/', function ({newsletters}, request) {
|
||||
const requestBody = JSON.parse(request.requestBody);
|
||||
const tokenData = JSON.parse(atob(requestBody.token));
|
||||
|
||||
const newsletter = newsletters.find(tokenData.id);
|
||||
|
||||
newsletter[camelize(tokenData.type)] = tokenData.email;
|
||||
|
||||
return newsletter.save();
|
||||
});
|
||||
}
|
||||
|
@ -9,4 +9,30 @@ export default function (server) {
|
||||
server.create('integration', {name: 'Demo'});
|
||||
|
||||
server.createList('member', 125);
|
||||
|
||||
// sites always have a default newsletter
|
||||
server.create('newsletter', {
|
||||
name: 'Site title',
|
||||
slug: 'site-title',
|
||||
description: 'Default newsletter created during setup',
|
||||
|
||||
senderName: 'Site title',
|
||||
senderEmail: null,
|
||||
senderReplyTo: 'newsletter',
|
||||
|
||||
status: 'active',
|
||||
recipientFilter: null,
|
||||
subscribeOnSignup: true,
|
||||
sortOrder: 0,
|
||||
|
||||
headerImage: null,
|
||||
showHeaderIcon: true,
|
||||
showHeaderTitle: true,
|
||||
titleFontCategory: 'sans_serif',
|
||||
titleAlignment: 'center',
|
||||
showFeatureImage: true,
|
||||
bodyFontCategory: 'sans_serif',
|
||||
footerContent: null,
|
||||
showBadge: true
|
||||
});
|
||||
}
|
||||
|
@ -24,9 +24,7 @@ export default RestSerializer.extend({
|
||||
},
|
||||
|
||||
serialize(object, request) {
|
||||
// Ember expects pluralized responses for the post, user, and invite models,
|
||||
// and this shortcut will ensure that those models are pluralized
|
||||
if (this.isModel(object) && ['post', 'user', 'invite'].includes(object.modelName)) {
|
||||
if (this.isModel(object)) {
|
||||
object = new Collection(object.modelName, [object]);
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user