0
Fork 0
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:
Simon Backx 2022-07-15 14:44:26 +02:00 committed by GitHub
parent 5d827ad1a9
commit 9e7727752c
9 changed files with 173 additions and 36 deletions

View file

@ -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>

View file

@ -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()
});

View 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>

View 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>

View 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();
}
}

View file

@ -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');

View file

@ -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()
});

View file

@ -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);

View file

@ -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;
}
}