mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-18 02:21:47 -05:00
Updated support email address confirmation flow (#2426)
refs https://github.com/TryGhost/Team/issues/584 - No longer uses a API URL + redirect inside verification emails. This is replaced by a new route (`/settings/members/?verifyEmail=token`) that does the API request and shows a modal. - Removed update button when changing support email address in the Portal settings - Added `_meta` attribute to settings (uses same pattern as newsletters model) - When updating the `membersSupportAddress` setting (via the normal edit endpoint), the `sent_email_verification` meta property will get set by the API. When this new property is present, we'll show a warning that the support address requires verification.
This commit is contained in:
parent
5d827ad1a9
commit
9e7727752c
9 changed files with 173 additions and 36 deletions
|
@ -275,23 +275,16 @@
|
|||
</button>
|
||||
{{#liquid-if isOpen}}
|
||||
<div class="modal-fullsettings-tab-expanded" onclick={{action "switchPreviewPage" "accountHome"}}>
|
||||
<GhFormGroup @classNames="space-l mt5">
|
||||
<GhFormGroup @classNames="space-l mt5" @errors={{this.settings.errors}} @hasValidated={{this.settings.hasValidated}} @property="members_support_address">
|
||||
<h4 class="gh-portal-setting-title">Support email address</h4>
|
||||
<div class="mt2">
|
||||
<GhTextInput
|
||||
@value={{readonly this.supportAddress}}
|
||||
@input={{action "setSupportAddress" value="target.value"}}
|
||||
/>
|
||||
<GhTaskButton
|
||||
@buttonText="Update support address"
|
||||
@runningText="Sending..."
|
||||
@successText="Confirmation email sent"
|
||||
@disabled={{this.disableUpdateSupportAddressButton}}
|
||||
@task={{this.updateSupportAddress}}
|
||||
@class="gh-btn gh-btn-green gh-btn-icon gh-btn-textfield-group gh-portal-emailupdate-button"
|
||||
data-test-button="update-support-address"
|
||||
@name="members_support_address"
|
||||
/>
|
||||
</div>
|
||||
<GhErrorMessage @errors={{this.settings.errors}} @property="members_support_address" />
|
||||
<p>How members can reach you for help with their account (public)</p>
|
||||
</GhFormGroup>
|
||||
</div>
|
||||
|
|
|
@ -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()
|
||||
});
|
||||
|
|
21
ghost/admin/app/components/modals/settings/confirm-email.hbs
Normal file
21
ghost/admin/app/components/modals/settings/confirm-email.hbs
Normal file
|
@ -0,0 +1,21 @@
|
|||
<div class="modal-content" data-test-modal="confirm-email">
|
||||
<header class="modal-header">
|
||||
<h1>Confirm 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 verified, your support address will remain {{full-email-address (or @data.currentEmail
|
||||
"noreply")}}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="gh-btn" data-test-button="confirm-email" {{on "click" @close}} {{on-key "Enter" }}>
|
||||
<span>Ok</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
32
ghost/admin/app/components/modals/settings/verify-email.hbs
Normal file
32
ghost/admin/app/components/modals/settings/verify-email.hbs
Normal file
|
@ -0,0 +1,32 @@
|
|||
<div class="modal-content">
|
||||
<header class="modal-header" data-test-modal="verify-email">
|
||||
<h1>Verifying 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.email}}
|
||||
<p>
|
||||
Success! The support email address has changed to <strong>{{this.email}}</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>
|
46
ghost/admin/app/components/modals/settings/verify-email.js
Normal file
46
ghost/admin/app/components/modals/settings/verify-email.js
Normal file
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
|
|
|
@ -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()
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue