mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-04 17:04:59 +03:00
🐛 Updated support email verification flow (#15029)
refs https://github.com/TryGhost/Team/issues/584 The current support email verification flow uses an API endpoint as verification URL inside the emails. This is a bad pattern, and also has the side effect that it shows a JSON error if something goes wrong. To fix this, this commit updates the whole flow to use the same pattern as newsletters: - You can update the `members_support_address` setting directly via the edit endpoint of settings. - Changes to that (and future 'guarded' email properties) are blocked and generate verification emails automatically. - When an email verification has been sent, the meta property `sent_email_verification` is set. Other changes: - Underlying, the implementation of email verificaton has moved from the (old) members service to the settings BREAD service. This makes it easier to add extra email addresses in settings later on that are not related to 'members'. - Now you can update the `members_support_address` by updating the settings directly, so the `updateMembersEmail` endpoint has been deprecated and is mapped to the new behaviour. - The SingleUseTokenProvider threw a `UnauthorizedError` error if a token was expired or invalid. Those errors are caught by the admin app, and causes it to do a page reload (making the error message and modals invisible). To fix that, I've swapped it with a validation error. Future changes: - Existing emails that have been sent 24h before this change is applied, still use the `validateMembersEmailUpdate` API endpoint. This endpoint has not been removed for now, to not break those emails. In a future release, we should remove this. Changes to admin: https://github.com/TryGhost/Admin/pull/2426
This commit is contained in:
parent
7b6bf4cf67
commit
c6621dc17d
@ -2,17 +2,15 @@ const Promise = require('bluebird');
|
||||
const _ = require('lodash');
|
||||
const models = require('../../models');
|
||||
const routeSettings = require('../../services/route-settings');
|
||||
const tpl = require('@tryghost/tpl');
|
||||
const {BadRequestError} = require('@tryghost/errors');
|
||||
const settingsService = require('../../services/settings/settings-service');
|
||||
const membersService = require('../../services/members');
|
||||
const stripeService = require('../../services/stripe');
|
||||
|
||||
const tpl = require('@tryghost/tpl');
|
||||
const settingsBREADService = settingsService.getSettingsBREADServiceInstance();
|
||||
|
||||
const messages = {
|
||||
failedSendingEmail: 'Failed Sending Email'
|
||||
|
||||
};
|
||||
|
||||
async function getStripeConnectData(frame) {
|
||||
@ -59,6 +57,65 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
|
||||
verifyKeyUpdate: {
|
||||
headers: {
|
||||
cacheInvalidate: true
|
||||
},
|
||||
permissions: {
|
||||
method: 'edit'
|
||||
},
|
||||
data: [
|
||||
'token'
|
||||
],
|
||||
async query(frame) {
|
||||
await settingsBREADService.verifyKeyUpdate(frame.data.token);
|
||||
|
||||
// We need to return all settings here, because we have calculated settings that might change
|
||||
const browse = await settingsBREADService.browse(frame.options.context);
|
||||
|
||||
return browse;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
updateMembersEmail: {
|
||||
statusCode: 204,
|
||||
permissions: {
|
||||
method: 'edit'
|
||||
},
|
||||
data: [
|
||||
'email',
|
||||
'type'
|
||||
],
|
||||
async query(frame) {
|
||||
const {email, type} = frame.data;
|
||||
|
||||
try {
|
||||
// Mapped internally to the newer method of changing emails
|
||||
const actionToKeyMapping = {
|
||||
supportAddressUpdate: 'members_support_address'
|
||||
};
|
||||
const edit = {
|
||||
key: actionToKeyMapping[type],
|
||||
value: email
|
||||
};
|
||||
|
||||
await settingsBREADService.edit([edit], frame.options, null);
|
||||
} catch (err) {
|
||||
throw new BadRequestError({
|
||||
err,
|
||||
message: tpl(messages.failedSendingEmail)
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @todo can get removed, since this is moved to verifyKeyUpdate
|
||||
* @deprecated: keep to not break existing email verification links, but remove after 1 - 2 releases
|
||||
*/
|
||||
validateMembersEmailUpdate: {
|
||||
options: [
|
||||
'token',
|
||||
@ -108,33 +165,6 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
|
||||
updateMembersEmail: {
|
||||
statusCode: 204,
|
||||
permissions: {
|
||||
method: 'edit'
|
||||
},
|
||||
data: [
|
||||
'email',
|
||||
'type'
|
||||
],
|
||||
async query(frame) {
|
||||
const {email, type} = frame.data;
|
||||
|
||||
try {
|
||||
// Send magic link to update fromAddress
|
||||
await membersService.settings.sendEmailAddressUpdateMagicLink({
|
||||
email,
|
||||
type
|
||||
});
|
||||
} catch (err) {
|
||||
throw new BadRequestError({
|
||||
err,
|
||||
message: tpl(messages.failedSendingEmail)
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
disconnectStripeConnectIntegration: {
|
||||
statusCode: 204,
|
||||
permissions: {
|
||||
@ -197,6 +227,8 @@ module.exports = {
|
||||
|
||||
// We need to return all settings here, because we have calculated settings that might change
|
||||
const browse = await settingsBREADService.browse(frame.options.context);
|
||||
browse.meta = result.meta || {};
|
||||
|
||||
return browse;
|
||||
}
|
||||
},
|
||||
|
@ -31,6 +31,7 @@ const EDITABLE_SETTINGS = [
|
||||
'default_content_visibility',
|
||||
'default_content_visibility_tiers',
|
||||
'members_signup_access',
|
||||
'members_support_address',
|
||||
'stripe_secret_key',
|
||||
'stripe_publishable_key',
|
||||
'stripe_connect_integration_token',
|
||||
|
@ -52,7 +52,7 @@ function serializeSettings(models, apiConfig, frame) {
|
||||
|
||||
frame.response = {
|
||||
settings: mappers.settings(filteredSettings),
|
||||
meta: {}
|
||||
meta: models.meta ?? {}
|
||||
};
|
||||
|
||||
if (frame.options.group) {
|
||||
@ -89,6 +89,7 @@ module.exports = {
|
||||
browse: serializeSettings,
|
||||
read: serializeSettings,
|
||||
edit: serializeSettings,
|
||||
verifyKeyUpdate: serializeSettings,
|
||||
|
||||
download: serializeData,
|
||||
upload: serializeData,
|
||||
|
@ -2,11 +2,12 @@ const Promise = require('bluebird');
|
||||
const _ = require('lodash');
|
||||
const {ValidationError, BadRequestError} = require('@tryghost/errors');
|
||||
const validator = require('@tryghost/validator');
|
||||
const tpl = require('@tryghost/tpl');
|
||||
|
||||
const messages = {
|
||||
invalidEmailReceived: 'Please send a valid email',
|
||||
invalidEmailTypeReceived: 'Invalid email type received',
|
||||
problemFindingSetting: 'Problem finding setting: {key}'
|
||||
invalidEmailValueReceived: 'Please enter a valid email address.',
|
||||
invalidEmailTypeReceived: 'Invalid email type received'
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
@ -22,6 +23,10 @@ module.exports = {
|
||||
'secondary_navigation'
|
||||
];
|
||||
|
||||
const emailTypeSettings = [
|
||||
'members_support_address'
|
||||
];
|
||||
|
||||
if (arrayTypeSettings.includes(setting.key)) {
|
||||
const typeError = new ValidationError({
|
||||
message: `Value in ${setting.key} should be an array.`,
|
||||
@ -43,6 +48,18 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (emailTypeSettings.includes(setting.key)) {
|
||||
const email = setting.value;
|
||||
|
||||
if (typeof email !== 'string' || (!validator.isEmail(email) && email !== 'noreply')) {
|
||||
const typeError = new ValidationError({
|
||||
message: tpl(messages.invalidEmailValueReceived),
|
||||
property: setting.key
|
||||
});
|
||||
errors.push(typeError);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent setting icon to the resized one when sending all settings received from browse again in the edit endpoint
|
||||
@ -56,6 +73,9 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
updateMembersEmail(apiConfig, frame) {
|
||||
const {email, type} = frame.data;
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
// @ts-check
|
||||
const {UnauthorizedError} = require('@tryghost/errors');
|
||||
const {ValidationError} = require('@tryghost/errors');
|
||||
|
||||
class SingleUseTokenProvider {
|
||||
/**
|
||||
@ -41,7 +41,7 @@ class SingleUseTokenProvider {
|
||||
const model = await this.model.findOne({token});
|
||||
|
||||
if (!model) {
|
||||
throw new UnauthorizedError({
|
||||
throw new ValidationError({
|
||||
message: 'Invalid token provided'
|
||||
});
|
||||
}
|
||||
@ -51,7 +51,7 @@ class SingleUseTokenProvider {
|
||||
const tokenLifetimeMilliseconds = Date.now() - createdAtEpoch;
|
||||
|
||||
if (tokenLifetimeMilliseconds > this.validity) {
|
||||
throw new UnauthorizedError({
|
||||
throw new ValidationError({
|
||||
message: 'Token expired'
|
||||
});
|
||||
}
|
||||
|
@ -1,99 +1,14 @@
|
||||
const MagicLink = require('@tryghost/magic-link');
|
||||
const {URL} = require('url');
|
||||
const path = require('path');
|
||||
const urlUtils = require('../../../shared/url-utils');
|
||||
const settingsCache = require('../../../shared/settings-cache');
|
||||
const logging = require('@tryghost/logging');
|
||||
const mail = require('../mail');
|
||||
const updateEmailTemplate = require('./emails/updateEmail');
|
||||
const SingleUseTokenProvider = require('./SingleUseTokenProvider');
|
||||
const models = require('../../models');
|
||||
const MAGIC_LINK_TOKEN_VALIDITY = 24 * 60 * 60 * 1000;
|
||||
|
||||
const ghostMailer = new mail.GhostMailer();
|
||||
|
||||
function createSettingsInstance(config) {
|
||||
const {transporter, getSubject, getText, getHTML, getSigninURL} = {
|
||||
transporter: {
|
||||
sendMail(message) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logging.warn(message.text);
|
||||
}
|
||||
let msg = Object.assign({
|
||||
from: config.getAuthEmailFromAddress(),
|
||||
subject: 'Update email address',
|
||||
forceTextContent: true
|
||||
}, message);
|
||||
|
||||
return ghostMailer.send(msg);
|
||||
}
|
||||
},
|
||||
getSubject() {
|
||||
return `Confirm your email address`;
|
||||
},
|
||||
getText(url, type, email) {
|
||||
return `
|
||||
Hey there,
|
||||
|
||||
Please confirm your email address with this link:
|
||||
|
||||
${url}
|
||||
|
||||
For your security, the link will expire in 24 hours time.
|
||||
|
||||
---
|
||||
|
||||
Sent to ${email}
|
||||
If you did not make this request, you can simply delete this message. This email address will not be used.
|
||||
`;
|
||||
},
|
||||
getHTML(url, type, email) {
|
||||
const siteTitle = settingsCache.get('title');
|
||||
return updateEmailTemplate({url, email, siteTitle});
|
||||
},
|
||||
getSigninURL(token, type) {
|
||||
const signinURL = new URL(urlUtils.urlFor('api', {type: 'admin'}, true));
|
||||
signinURL.pathname = path.join(signinURL.pathname, '/settings/members/email/');
|
||||
signinURL.searchParams.set('token', token);
|
||||
signinURL.searchParams.set('action', type);
|
||||
return signinURL.href;
|
||||
}
|
||||
};
|
||||
|
||||
const magicLinkService = new MagicLink({
|
||||
transporter,
|
||||
tokenProvider: new SingleUseTokenProvider(models.SingleUseToken, MAGIC_LINK_TOKEN_VALIDITY),
|
||||
getSigninURL,
|
||||
getText,
|
||||
getHTML,
|
||||
getSubject
|
||||
});
|
||||
|
||||
const sendEmailAddressUpdateMagicLink = ({email, type = 'supportAddressUpdate'}) => {
|
||||
const [,toDomain] = email.split('@');
|
||||
let fromEmail = `noreply@${toDomain}`;
|
||||
if (fromEmail === email) {
|
||||
fromEmail = `no-reply@${toDomain}`;
|
||||
}
|
||||
magicLinkService.transporter = {
|
||||
sendMail(message) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logging.warn(message.text);
|
||||
}
|
||||
let msg = Object.assign({
|
||||
from: fromEmail,
|
||||
subject: 'Update email address',
|
||||
forceTextContent: true
|
||||
}, message);
|
||||
|
||||
return ghostMailer.send(msg);
|
||||
}
|
||||
};
|
||||
return magicLinkService.sendMagicLink({email, tokenData: {email}, subject: email, type});
|
||||
};
|
||||
// @todo: can get removed, since this is moved to the settings bread service
|
||||
function createSettingsInstance() {
|
||||
const oldTokenProvider = new SingleUseTokenProvider(models.SingleUseToken, MAGIC_LINK_TOKEN_VALIDITY);
|
||||
|
||||
const getEmailFromToken = async ({token}) => {
|
||||
const data = await magicLinkService.getDataFromToken(token);
|
||||
const data = await oldTokenProvider.validate(token);
|
||||
return data.email;
|
||||
};
|
||||
|
||||
@ -107,7 +22,6 @@ function createSettingsInstance(config) {
|
||||
};
|
||||
|
||||
return {
|
||||
sendEmailAddressUpdateMagicLink,
|
||||
getEmailFromToken,
|
||||
getAdminRedirectLink
|
||||
};
|
||||
|
166
core/server/services/settings/emails/verify-email.js
Normal file
166
core/server/services/settings/emails/verify-email.js
Normal file
@ -0,0 +1,166 @@
|
||||
module.exports = ({email, url}) => `
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<title>Confirm your email address</title>
|
||||
<style>
|
||||
/* -------------------------------------
|
||||
RESPONSIVE AND MOBILE FRIENDLY STYLES
|
||||
------------------------------------- */
|
||||
@media only screen and (max-width: 620px) {
|
||||
table[class=body] h1 {
|
||||
font-size: 28px !important;
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
table[class=body] p,
|
||||
table[class=body] ul,
|
||||
table[class=body] ol,
|
||||
table[class=body] td,
|
||||
table[class=body] span,
|
||||
table[class=body] a {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
table[class=body] .wrapper,
|
||||
table[class=body] .article {
|
||||
padding: 10px !important;
|
||||
}
|
||||
table[class=body] .content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
table[class=body] .container {
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
table[class=body] .main {
|
||||
border-left-width: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
border-right-width: 0 !important;
|
||||
}
|
||||
table[class=body] .btn table {
|
||||
width: 100% !important;
|
||||
}
|
||||
table[class=body] .btn a {
|
||||
width: 100% !important;
|
||||
}
|
||||
table[class=body] .img-responsive {
|
||||
height: auto !important;
|
||||
max-width: 100% !important;
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
/* -------------------------------------
|
||||
PRESERVE THESE STYLES IN THE HEAD
|
||||
------------------------------------- */
|
||||
@media all {
|
||||
.ExternalClass {
|
||||
width: 100%;
|
||||
}
|
||||
.ExternalClass,
|
||||
.ExternalClass p,
|
||||
.ExternalClass span,
|
||||
.ExternalClass font,
|
||||
.ExternalClass td,
|
||||
.ExternalClass div {
|
||||
line-height: 100%;
|
||||
}
|
||||
.recipient-link a {
|
||||
color: inherit !important;
|
||||
font-family: inherit !important;
|
||||
font-size: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
line-height: inherit !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
}
|
||||
hr {
|
||||
border-width: 0;
|
||||
height: 0;
|
||||
margin-top: 34px;
|
||||
margin-bottom: 34px;
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: #EEF5F8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="background-color: #F4F8FB; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background-color: #F4F8FB;">
|
||||
<tr>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;"> </td>
|
||||
<td class="container" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 600px; padding: 10px; width: 600px;">
|
||||
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">
|
||||
|
||||
<!-- START CENTERED WHITE CONTAINER -->
|
||||
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;">
|
||||
|
||||
<!-- START MAIN CONTENT AREA -->
|
||||
<tr>
|
||||
<td class="wrapper" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 40px 50px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
|
||||
<tr>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">
|
||||
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 20px; color: #15212A; font-weight: bold; line-height: 25px; margin: 0; margin-bottom: 15px;">Hey there,</p>
|
||||
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 32px;">Please confirm your email address with this link:</p>
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; padding-bottom: 35px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; background-color: #15212A; border-radius: 5px; text-align: center;"> <a href="${url}" target="_blank" style="display: inline-block; color: #ffffff; background-color: #15212A; border: solid 1px #15212A; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: #15212A;" data-test-verify-link>Confirm email address</a> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 25px;">For your security, the link will expire in 24 hours time.</p>
|
||||
<hr/>
|
||||
<p style="word-break: break-all; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 5px;">You can also copy & paste this URL into your browser:</p>
|
||||
<p style="word-break: break-all; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; line-height: 21px; margin-top: 0; color: #738A94;">${url}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- END MAIN CONTENT AREA -->
|
||||
</table>
|
||||
|
||||
<!-- START FOOTER -->
|
||||
<div class="footer" style="clear: both; Margin-top: 10px; text-align: center; width: 100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
|
||||
<tr>
|
||||
<td class="content-block" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; padding-bottom: 5px; padding-top: 15px; font-size: 13px; line-height: 21px; color: #738A94; text-align: center;">
|
||||
If you did not make this request, you can simply delete this message.<br/>This email address will not be used.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="content-block" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; padding-bottom: 10px; padding-top: 10px; font-size: 13px; color: #738A94; text-align: center;">
|
||||
<span class="recipient-link" style="color: #738A94; font-size: 13px; text-align: center;">Sent to <a href="mailto:${email}" style="text-decoration: underline; color: #738A94; font-size: 13px; text-align: center;">${email}</a></span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<!-- END FOOTER -->
|
||||
|
||||
<!-- END CENTERED WHITE CONTAINER -->
|
||||
</div>
|
||||
</td>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
@ -1,8 +1,12 @@
|
||||
const _ = require('lodash');
|
||||
const tpl = require('@tryghost/tpl');
|
||||
const {NotFoundError, NoPermissionError, BadRequestError} = require('@tryghost/errors');
|
||||
const {NotFoundError, NoPermissionError, BadRequestError, IncorrectUsageError} = require('@tryghost/errors');
|
||||
const {obfuscatedSetting, isSecretSetting, hideValueIfSecret} = require('./settings-utils');
|
||||
const logging = require('@tryghost/logging');
|
||||
const MagicLink = require('@tryghost/magic-link');
|
||||
const verifyEmailTemplate = require('./emails/verify-email');
|
||||
|
||||
const EMAIL_KEYS = ['members_support_address'];
|
||||
const messages = {
|
||||
problemFindingSetting: 'Problem finding setting: {key}',
|
||||
accessCoreSettingFromExtReq: 'Attempted to access core setting from external request'
|
||||
@ -13,13 +17,67 @@ class SettingsBREADService {
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {Object} options.SettingsModel
|
||||
* @param {Object} options.mail
|
||||
* @param {Object} options.settingsCache - SettingsCache instance
|
||||
* @param {Object} options.singleUseTokenProvider
|
||||
* @param {Object} options.urlUtils
|
||||
* @param {Object} options.labsService - labs service instance
|
||||
*/
|
||||
constructor({SettingsModel, settingsCache, labsService}) {
|
||||
constructor({SettingsModel, settingsCache, labsService, mail, singleUseTokenProvider, urlUtils}) {
|
||||
this.SettingsModel = SettingsModel;
|
||||
this.settingsCache = settingsCache;
|
||||
this.labs = labsService;
|
||||
|
||||
/* email verification setup */
|
||||
|
||||
this.ghostMailer = new mail.GhostMailer();
|
||||
|
||||
const {transporter, getSubject, getText, getHTML, getSigninURL} = {
|
||||
transporter: {
|
||||
sendMail() {
|
||||
// noop - overridden in `sendEmailVerificationMagicLink`
|
||||
}
|
||||
},
|
||||
getSubject() {
|
||||
// not used - overridden in `sendEmailVerificationMagicLink`
|
||||
return `Verify email address`;
|
||||
},
|
||||
getText(url, type, email) {
|
||||
return `
|
||||
Hey there,
|
||||
|
||||
Please confirm your email address with this link:
|
||||
|
||||
${url}
|
||||
|
||||
For your security, the link will expire in 24 hours time.
|
||||
|
||||
---
|
||||
|
||||
Sent to ${email}
|
||||
If you did not make this request, you can simply delete this message. This email address will not be used.
|
||||
`;
|
||||
},
|
||||
getHTML(url, type, email) {
|
||||
return verifyEmailTemplate({url, email});
|
||||
},
|
||||
getSigninURL(token) {
|
||||
// @todo: need to make this more generic?
|
||||
const adminUrl = urlUtils.urlFor('admin', true);
|
||||
const signinURL = new URL(adminUrl);
|
||||
signinURL.hash = `/settings/members/?verifyEmail=${token}`;
|
||||
return signinURL.href;
|
||||
}
|
||||
};
|
||||
|
||||
this.magicLinkService = new MagicLink({
|
||||
transporter,
|
||||
tokenProvider: singleUseTokenProvider,
|
||||
getSigninURL,
|
||||
getText,
|
||||
getHTML,
|
||||
getSubject
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -93,7 +151,7 @@ class SettingsBREADService {
|
||||
* @returns
|
||||
*/
|
||||
async edit(settings, options, stripeConnectData) {
|
||||
const filteredSettings = settings.filter((setting) => {
|
||||
let filteredSettings = settings.filter((setting) => {
|
||||
// The `stripe_connect_integration_token` "setting" is only used to set the `stripe_connect_*` settings.
|
||||
return ![
|
||||
'stripe_connect_integration_token',
|
||||
@ -152,9 +210,31 @@ class SettingsBREADService {
|
||||
});
|
||||
}
|
||||
|
||||
return this.SettingsModel.edit(filteredSettings, options).then((result) => {
|
||||
// remove any email properties that are not allowed to be set without verification
|
||||
const {filteredSettings: refilteredSettings, emailsToVerify} = await this.prepSettingsForEmailVerification(filteredSettings, getSetting);
|
||||
|
||||
const modelArray = await this.SettingsModel.edit(refilteredSettings, options).then((result) => {
|
||||
return this._formatBrowse(_.keyBy(_.invokeMap(result, 'toJSON'), 'key'), options.context);
|
||||
});
|
||||
|
||||
return this.respondWithEmailVerification(modelArray, emailsToVerify);
|
||||
}
|
||||
|
||||
async verifyKeyUpdate(token) {
|
||||
const data = await this.magicLinkService.getDataFromToken(token);
|
||||
const {key, value} = data;
|
||||
|
||||
// Verify keys (in case they ever change and we have old tokens)
|
||||
if (!EMAIL_KEYS.includes(key)) {
|
||||
throw new IncorrectUsageError({
|
||||
message: 'Not allowed to update this setting key via tokens'
|
||||
});
|
||||
}
|
||||
|
||||
return this.SettingsModel.edit({
|
||||
key,
|
||||
value
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -205,6 +285,92 @@ class SettingsBREADService {
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
async prepSettingsForEmailVerification(settings, getSetting) {
|
||||
const filteredSettings = [];
|
||||
const emailsToVerify = [];
|
||||
|
||||
for (const setting of settings) {
|
||||
if (EMAIL_KEYS.includes(setting.key)) {
|
||||
const email = setting.value;
|
||||
const key = setting.key;
|
||||
const hasChanged = getSetting(setting) !== email;
|
||||
|
||||
if (await this.requiresEmailVerification({email, hasChanged})) {
|
||||
emailsToVerify.push({email, key});
|
||||
} else {
|
||||
filteredSettings.push(setting);
|
||||
}
|
||||
} else {
|
||||
filteredSettings.push(setting);
|
||||
}
|
||||
}
|
||||
|
||||
return {filteredSettings, emailsToVerify};
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
async requiresEmailVerification({email, hasChanged}) {
|
||||
if (!email || !hasChanged || email === 'noreply') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: check for known/verified email
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
async respondWithEmailVerification(settings, emailsToVerify) {
|
||||
if (emailsToVerify.length > 0) {
|
||||
for (const {email, key} of emailsToVerify) {
|
||||
await this.sendEmailVerificationMagicLink({email, key});
|
||||
}
|
||||
|
||||
settings.meta = settings.meta || {};
|
||||
settings.meta.sent_email_verification = emailsToVerify.map(v => v.key);
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
async sendEmailVerificationMagicLink({email, key}) {
|
||||
const [,toDomain] = email.split('@');
|
||||
|
||||
let fromEmail = `noreply@${toDomain}`;
|
||||
if (fromEmail === email) {
|
||||
fromEmail = `no-reply@${toDomain}`;
|
||||
}
|
||||
|
||||
const {ghostMailer} = this;
|
||||
|
||||
this.magicLinkService.transporter = {
|
||||
sendMail(message) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logging.warn(message.text);
|
||||
}
|
||||
let msg = Object.assign({
|
||||
from: fromEmail,
|
||||
subject: 'Verify email address',
|
||||
forceTextContent: true
|
||||
}, message);
|
||||
|
||||
return ghostMailer.send(msg);
|
||||
}
|
||||
};
|
||||
|
||||
return this.magicLinkService.sendMagicLink({email, tokenData: {key, value: email}});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SettingsBREADService;
|
||||
|
@ -12,6 +12,9 @@ const config = require('../../../shared/config');
|
||||
const SettingsCache = require('../../../shared/settings-cache');
|
||||
const SettingsBREADService = require('./settings-bread-service');
|
||||
const {obfuscatedSetting, isSecretSetting, hideValueIfSecret} = require('./settings-utils');
|
||||
const mail = require('../mail');
|
||||
const SingleUseTokenProvider = require('../members/SingleUseTokenProvider');
|
||||
const urlUtils = require('../../../shared/url-utils');
|
||||
|
||||
const ObjectId = require('bson-objectid');
|
||||
|
||||
@ -19,6 +22,8 @@ const messages = {
|
||||
incorrectKeyType: 'type must be one of "direct" or "connect".'
|
||||
};
|
||||
|
||||
const MAGIC_LINK_TOKEN_VALIDITY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* @returns {SettingsBREADService} instance of the PostsService
|
||||
*/
|
||||
@ -26,7 +31,10 @@ const getSettingsBREADServiceInstance = () => {
|
||||
return new SettingsBREADService({
|
||||
SettingsModel: models.Settings,
|
||||
settingsCache: SettingsCache,
|
||||
labsService: labs
|
||||
labsService: labs,
|
||||
mail,
|
||||
singleUseTokenProvider: new SingleUseTokenProvider(models.SingleUseToken, MAGIC_LINK_TOKEN_VALIDITY),
|
||||
urlUtils
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -64,8 +64,14 @@ module.exports = function apiRoutes() {
|
||||
|
||||
router.get('/settings', mw.authAdminApi, http(api.settings.browse));
|
||||
router.put('/settings', mw.authAdminApi, http(api.settings.edit));
|
||||
router.put('/settings/verifications/', mw.authAdminApi, http(api.settings.verifyKeyUpdate));
|
||||
|
||||
/** @deprecated This endpoint is part of the old email verification flow for the support email */
|
||||
router.get('/settings/members/email', http(api.settings.validateMembersEmailUpdate));
|
||||
|
||||
/** @deprecated This endpoint is part of the old email verification flow for the support email */
|
||||
router.post('/settings/members/email', mw.authAdminApi, http(api.settings.updateMembersEmail));
|
||||
|
||||
router.del('/settings/stripe/connect', mw.authAdminApi, http(api.settings.disconnectStripeConnectIntegration));
|
||||
|
||||
// ## Users
|
||||
|
@ -916,6 +916,298 @@ Object {
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Settings API Edit editing members_support_address triggers email verification flow 1: [body] 1`] = `
|
||||
Object {
|
||||
"meta": Object {
|
||||
"sent_email_verification": Array [
|
||||
"members_support_address",
|
||||
],
|
||||
},
|
||||
"settings": Array [
|
||||
Object {
|
||||
"key": "title",
|
||||
"value": "[]",
|
||||
},
|
||||
Object {
|
||||
"key": "description",
|
||||
"value": "Thoughts, stories and ideas",
|
||||
},
|
||||
Object {
|
||||
"key": "logo",
|
||||
"value": "",
|
||||
},
|
||||
Object {
|
||||
"key": "cover_image",
|
||||
"value": "https://static.ghost.org/v4.0.0/images/publication-cover.jpg",
|
||||
},
|
||||
Object {
|
||||
"key": "icon",
|
||||
"value": "http://127.0.0.1:2369/content/images/size/w256h256/2019/07/icon.png",
|
||||
},
|
||||
Object {
|
||||
"key": "accent_color",
|
||||
"value": "#FF1A75",
|
||||
},
|
||||
Object {
|
||||
"key": "locale",
|
||||
"value": "ua",
|
||||
},
|
||||
Object {
|
||||
"key": "timezone",
|
||||
"value": "Pacific/Auckland",
|
||||
},
|
||||
Object {
|
||||
"key": "codeinjection_head",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "codeinjection_foot",
|
||||
"value": "",
|
||||
},
|
||||
Object {
|
||||
"key": "facebook",
|
||||
"value": "ghost",
|
||||
},
|
||||
Object {
|
||||
"key": "twitter",
|
||||
"value": "@ghost",
|
||||
},
|
||||
Object {
|
||||
"key": "navigation",
|
||||
"value": "[{\\"label\\":\\"label1\\"}]",
|
||||
},
|
||||
Object {
|
||||
"key": "secondary_navigation",
|
||||
"value": "[{\\"label\\":\\"Data & privacy\\",\\"url\\":\\"/privacy/\\"},{\\"label\\":\\"Contact\\",\\"url\\":\\"/contact/\\"},{\\"label\\":\\"Contribute →\\",\\"url\\":\\"/contribute/\\"}]",
|
||||
},
|
||||
Object {
|
||||
"key": "meta_title",
|
||||
"value": "SEO title",
|
||||
},
|
||||
Object {
|
||||
"key": "meta_description",
|
||||
"value": "SEO description",
|
||||
},
|
||||
Object {
|
||||
"key": "og_image",
|
||||
"value": "http://127.0.0.1:2369/content/images/2019/07/facebook.png",
|
||||
},
|
||||
Object {
|
||||
"key": "og_title",
|
||||
"value": "facebook title",
|
||||
},
|
||||
Object {
|
||||
"key": "og_description",
|
||||
"value": "facebook description",
|
||||
},
|
||||
Object {
|
||||
"key": "twitter_image",
|
||||
"value": "http://127.0.0.1:2369/content/images/2019/07/twitter.png",
|
||||
},
|
||||
Object {
|
||||
"key": "twitter_title",
|
||||
"value": "twitter title",
|
||||
},
|
||||
Object {
|
||||
"key": "twitter_description",
|
||||
"value": "twitter description",
|
||||
},
|
||||
Object {
|
||||
"key": "active_theme",
|
||||
"value": "casper",
|
||||
},
|
||||
Object {
|
||||
"key": "is_private",
|
||||
"value": false,
|
||||
},
|
||||
Object {
|
||||
"key": "password",
|
||||
"value": "",
|
||||
},
|
||||
Object {
|
||||
"key": "public_hash",
|
||||
"value": StringMatching /\\[a-z0-9\\]\\{30\\}/,
|
||||
},
|
||||
Object {
|
||||
"key": "default_content_visibility",
|
||||
"value": "public",
|
||||
},
|
||||
Object {
|
||||
"key": "default_content_visibility_tiers",
|
||||
"value": "[]",
|
||||
},
|
||||
Object {
|
||||
"key": "members_signup_access",
|
||||
"value": "all",
|
||||
},
|
||||
Object {
|
||||
"key": "members_support_address",
|
||||
"value": "noreply",
|
||||
},
|
||||
Object {
|
||||
"key": "stripe_secret_key",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "stripe_publishable_key",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "stripe_plans",
|
||||
"value": "[]",
|
||||
},
|
||||
Object {
|
||||
"key": "stripe_connect_publishable_key",
|
||||
"value": "pk_test_for_stripe",
|
||||
},
|
||||
Object {
|
||||
"key": "stripe_connect_secret_key",
|
||||
"value": "••••••••",
|
||||
},
|
||||
Object {
|
||||
"key": "stripe_connect_livemode",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "stripe_connect_display_name",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "stripe_connect_account_id",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "members_monthly_price_id",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "members_yearly_price_id",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "portal_name",
|
||||
"value": true,
|
||||
},
|
||||
Object {
|
||||
"key": "portal_button",
|
||||
"value": true,
|
||||
},
|
||||
Object {
|
||||
"key": "portal_plans",
|
||||
"value": "[\\"free\\"]",
|
||||
},
|
||||
Object {
|
||||
"key": "portal_products",
|
||||
"value": "[]",
|
||||
},
|
||||
Object {
|
||||
"key": "portal_button_style",
|
||||
"value": "icon-and-text",
|
||||
},
|
||||
Object {
|
||||
"key": "portal_button_icon",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "portal_button_signup_text",
|
||||
"value": "Subscribe",
|
||||
},
|
||||
Object {
|
||||
"key": "mailgun_domain",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "mailgun_api_key",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "mailgun_base_url",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "email_track_opens",
|
||||
"value": true,
|
||||
},
|
||||
Object {
|
||||
"key": "email_verification_required",
|
||||
"value": false,
|
||||
},
|
||||
Object {
|
||||
"key": "amp",
|
||||
"value": false,
|
||||
},
|
||||
Object {
|
||||
"key": "amp_gtag_id",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "firstpromoter",
|
||||
"value": false,
|
||||
},
|
||||
Object {
|
||||
"key": "firstpromoter_id",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "labs",
|
||||
"value": "{\\"members\\":true}",
|
||||
},
|
||||
Object {
|
||||
"key": "slack_url",
|
||||
"value": "",
|
||||
},
|
||||
Object {
|
||||
"key": "slack_username",
|
||||
"value": "New Slack Username",
|
||||
},
|
||||
Object {
|
||||
"key": "unsplash",
|
||||
"value": false,
|
||||
},
|
||||
Object {
|
||||
"key": "shared_views",
|
||||
"value": "[]",
|
||||
},
|
||||
Object {
|
||||
"key": "editor_default_email_recipients",
|
||||
"value": "visibility",
|
||||
},
|
||||
Object {
|
||||
"key": "editor_default_email_recipients_filter",
|
||||
"value": "all",
|
||||
},
|
||||
Object {
|
||||
"key": "members_enabled",
|
||||
"value": true,
|
||||
},
|
||||
Object {
|
||||
"key": "members_invite_only",
|
||||
"value": false,
|
||||
},
|
||||
Object {
|
||||
"key": "paid_members_enabled",
|
||||
"value": true,
|
||||
},
|
||||
Object {
|
||||
"key": "firstpromoter_account",
|
||||
"value": null,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Settings API Edit editing members_support_address triggers email verification flow 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "3417",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"vary": "Origin, Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Settings API Edit removes image size prefixes when setting the icon 1: [body] 1`] = `
|
||||
Object {
|
||||
"meta": Object {},
|
||||
@ -1266,3 +1558,322 @@ Object {
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Settings API verify key update can update members_support_address via token 1: [body] 1`] = `
|
||||
Object {
|
||||
"meta": Object {},
|
||||
"settings": Array [
|
||||
Object {
|
||||
"key": "title",
|
||||
"value": "[]",
|
||||
},
|
||||
Object {
|
||||
"key": "description",
|
||||
"value": "Thoughts, stories and ideas",
|
||||
},
|
||||
Object {
|
||||
"key": "logo",
|
||||
"value": "",
|
||||
},
|
||||
Object {
|
||||
"key": "cover_image",
|
||||
"value": "https://static.ghost.org/v4.0.0/images/publication-cover.jpg",
|
||||
},
|
||||
Object {
|
||||
"key": "icon",
|
||||
"value": "http://127.0.0.1:2369/content/images/size/w256h256/2019/07/icon.png",
|
||||
},
|
||||
Object {
|
||||
"key": "accent_color",
|
||||
"value": "#FF1A75",
|
||||
},
|
||||
Object {
|
||||
"key": "locale",
|
||||
"value": "ua",
|
||||
},
|
||||
Object {
|
||||
"key": "timezone",
|
||||
"value": "Pacific/Auckland",
|
||||
},
|
||||
Object {
|
||||
"key": "codeinjection_head",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "codeinjection_foot",
|
||||
"value": "",
|
||||
},
|
||||
Object {
|
||||
"key": "facebook",
|
||||
"value": "ghost",
|
||||
},
|
||||
Object {
|
||||
"key": "twitter",
|
||||
"value": "@ghost",
|
||||
},
|
||||
Object {
|
||||
"key": "navigation",
|
||||
"value": "[{\\"label\\":\\"label1\\"}]",
|
||||
},
|
||||
Object {
|
||||
"key": "secondary_navigation",
|
||||
"value": "[{\\"label\\":\\"Data & privacy\\",\\"url\\":\\"/privacy/\\"},{\\"label\\":\\"Contact\\",\\"url\\":\\"/contact/\\"},{\\"label\\":\\"Contribute →\\",\\"url\\":\\"/contribute/\\"}]",
|
||||
},
|
||||
Object {
|
||||
"key": "meta_title",
|
||||
"value": "SEO title",
|
||||
},
|
||||
Object {
|
||||
"key": "meta_description",
|
||||
"value": "SEO description",
|
||||
},
|
||||
Object {
|
||||
"key": "og_image",
|
||||
"value": "http://127.0.0.1:2369/content/images/2019/07/facebook.png",
|
||||
},
|
||||
Object {
|
||||
"key": "og_title",
|
||||
"value": "facebook title",
|
||||
},
|
||||
Object {
|
||||
"key": "og_description",
|
||||
"value": "facebook description",
|
||||
},
|
||||
Object {
|
||||
"key": "twitter_image",
|
||||
"value": "http://127.0.0.1:2369/content/images/2019/07/twitter.png",
|
||||
},
|
||||
Object {
|
||||
"key": "twitter_title",
|
||||
"value": "twitter title",
|
||||
},
|
||||
Object {
|
||||
"key": "twitter_description",
|
||||
"value": "twitter description",
|
||||
},
|
||||
Object {
|
||||
"key": "active_theme",
|
||||
"value": "casper",
|
||||
},
|
||||
Object {
|
||||
"key": "is_private",
|
||||
"value": false,
|
||||
},
|
||||
Object {
|
||||
"key": "password",
|
||||
"value": "",
|
||||
},
|
||||
Object {
|
||||
"key": "public_hash",
|
||||
"value": StringMatching /\\[a-z0-9\\]\\{30\\}/,
|
||||
},
|
||||
Object {
|
||||
"key": "default_content_visibility",
|
||||
"value": "public",
|
||||
},
|
||||
Object {
|
||||
"key": "default_content_visibility_tiers",
|
||||
"value": "[]",
|
||||
},
|
||||
Object {
|
||||
"key": "members_signup_access",
|
||||
"value": "all",
|
||||
},
|
||||
Object {
|
||||
"key": "members_support_address",
|
||||
"value": "support@example.com",
|
||||
},
|
||||
Object {
|
||||
"key": "stripe_secret_key",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "stripe_publishable_key",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "stripe_plans",
|
||||
"value": "[]",
|
||||
},
|
||||
Object {
|
||||
"key": "stripe_connect_publishable_key",
|
||||
"value": "pk_test_for_stripe",
|
||||
},
|
||||
Object {
|
||||
"key": "stripe_connect_secret_key",
|
||||
"value": "••••••••",
|
||||
},
|
||||
Object {
|
||||
"key": "stripe_connect_livemode",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "stripe_connect_display_name",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "stripe_connect_account_id",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "members_monthly_price_id",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "members_yearly_price_id",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "portal_name",
|
||||
"value": true,
|
||||
},
|
||||
Object {
|
||||
"key": "portal_button",
|
||||
"value": true,
|
||||
},
|
||||
Object {
|
||||
"key": "portal_plans",
|
||||
"value": "[\\"free\\"]",
|
||||
},
|
||||
Object {
|
||||
"key": "portal_products",
|
||||
"value": "[]",
|
||||
},
|
||||
Object {
|
||||
"key": "portal_button_style",
|
||||
"value": "icon-and-text",
|
||||
},
|
||||
Object {
|
||||
"key": "portal_button_icon",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "portal_button_signup_text",
|
||||
"value": "Subscribe",
|
||||
},
|
||||
Object {
|
||||
"key": "mailgun_domain",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "mailgun_api_key",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "mailgun_base_url",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "email_track_opens",
|
||||
"value": true,
|
||||
},
|
||||
Object {
|
||||
"key": "email_verification_required",
|
||||
"value": false,
|
||||
},
|
||||
Object {
|
||||
"key": "amp",
|
||||
"value": false,
|
||||
},
|
||||
Object {
|
||||
"key": "amp_gtag_id",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "firstpromoter",
|
||||
"value": false,
|
||||
},
|
||||
Object {
|
||||
"key": "firstpromoter_id",
|
||||
"value": null,
|
||||
},
|
||||
Object {
|
||||
"key": "labs",
|
||||
"value": "{\\"members\\":true}",
|
||||
},
|
||||
Object {
|
||||
"key": "slack_url",
|
||||
"value": "",
|
||||
},
|
||||
Object {
|
||||
"key": "slack_username",
|
||||
"value": "New Slack Username",
|
||||
},
|
||||
Object {
|
||||
"key": "unsplash",
|
||||
"value": false,
|
||||
},
|
||||
Object {
|
||||
"key": "shared_views",
|
||||
"value": "[]",
|
||||
},
|
||||
Object {
|
||||
"key": "editor_default_email_recipients",
|
||||
"value": "visibility",
|
||||
},
|
||||
Object {
|
||||
"key": "editor_default_email_recipients_filter",
|
||||
"value": "all",
|
||||
},
|
||||
Object {
|
||||
"key": "members_enabled",
|
||||
"value": true,
|
||||
},
|
||||
Object {
|
||||
"key": "members_invite_only",
|
||||
"value": false,
|
||||
},
|
||||
Object {
|
||||
"key": "paid_members_enabled",
|
||||
"value": true,
|
||||
},
|
||||
Object {
|
||||
"key": "firstpromoter_account",
|
||||
"value": null,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Settings API verify key update can update members_support_address via token 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "3376",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"vary": "Origin, Accept-Encoding",
|
||||
"x-cache-invalidate": "/*",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Settings API verify key update cannot update invalid keys via token 1: [body] 1`] = `
|
||||
Object {
|
||||
"errors": Array [
|
||||
Object {
|
||||
"code": null,
|
||||
"context": null,
|
||||
"details": null,
|
||||
"ghostErrorCode": null,
|
||||
"help": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
|
||||
"message": "Not allowed to update this setting key via tokens",
|
||||
"property": null,
|
||||
"type": "IncorrectUsageError",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Settings API verify key update cannot update invalid keys via token 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "241",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"vary": "Origin, Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
@ -1,7 +1,11 @@
|
||||
const assert = require('assert');
|
||||
const SingleUseTokenProvider = require('../../../core/server/services/members/SingleUseTokenProvider');
|
||||
const settingsService = require('../../../core/server/services/settings/settings-service');
|
||||
const settingsCache = require('../../../core/shared/settings-cache');
|
||||
const {agentProvider, fixtureManager, mockManager, matchers} = require('../../utils/e2e-framework');
|
||||
const {stringMatching, anyEtag, anyUuid} = matchers;
|
||||
const models = require('../../../core/server/models');
|
||||
const {anyErrorId} = matchers;
|
||||
|
||||
const CURRENT_SETTINGS_COUNT = 67;
|
||||
|
||||
@ -212,6 +216,73 @@ describe('Settings API', function () {
|
||||
assert.strictEqual(emailVerificationRequired.value, false);
|
||||
});
|
||||
});
|
||||
|
||||
it('editing members_support_address triggers email verification flow', async function () {
|
||||
await agent.put('settings/')
|
||||
.body({
|
||||
settings: [{key: 'members_support_address', value: 'support@example.com'}]
|
||||
})
|
||||
.expectStatus(200)
|
||||
.matchBodySnapshot({
|
||||
settings: matchSettingsArray(CURRENT_SETTINGS_COUNT)
|
||||
})
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag
|
||||
})
|
||||
.expect(({body}) => {
|
||||
const membersSupportAddress = body.settings.find(setting => setting.key === 'members_support_address');
|
||||
assert.strictEqual(membersSupportAddress.value, 'noreply');
|
||||
|
||||
assert.deepEqual(body.meta, {
|
||||
sent_email_verification: ['members_support_address']
|
||||
});
|
||||
});
|
||||
|
||||
mockManager.assert.sentEmail({
|
||||
subject: 'Verify email address',
|
||||
to: 'support@example.com'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify key update', function () {
|
||||
it('can update members_support_address via token', async function () {
|
||||
const token = await (new SingleUseTokenProvider(models.SingleUseToken, 24 * 60 * 60 * 1000)).create({key: 'members_support_address', value: 'support@example.com'});
|
||||
await agent.put('settings/verifications/')
|
||||
.body({
|
||||
token
|
||||
})
|
||||
.expectStatus(200)
|
||||
.matchBodySnapshot({
|
||||
settings: matchSettingsArray(CURRENT_SETTINGS_COUNT)
|
||||
})
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag
|
||||
})
|
||||
.expect(({body}) => {
|
||||
const membersSupportAddress = body.settings.find(setting => setting.key === 'members_support_address');
|
||||
assert.strictEqual(membersSupportAddress.value, 'support@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
it('cannot update invalid keys via token', async function () {
|
||||
const token = await (new SingleUseTokenProvider(models.SingleUseToken, 24 * 60 * 60 * 1000)).create({key: 'members_support_address_invalid', value: 'support@example.com'});
|
||||
await agent.put('settings/verifications/')
|
||||
.body({
|
||||
token
|
||||
})
|
||||
.expectStatus(400)
|
||||
.matchBodySnapshot({
|
||||
errors: [
|
||||
{
|
||||
id: anyErrorId
|
||||
}
|
||||
]
|
||||
})
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripe connect', function () {
|
||||
@ -277,7 +348,7 @@ describe('Settings API', function () {
|
||||
});
|
||||
});
|
||||
|
||||
// @TODO Fixing https://github.com/TryGhost/Team/issues/584 should result in thes tests changing
|
||||
// @TODO We can drop these tests once we removed the deprecated endpoints
|
||||
describe('deprecated', function () {
|
||||
it('can do updateMembersEmail', async function () {
|
||||
await agent
|
||||
@ -292,9 +363,12 @@ describe('Settings API', function () {
|
||||
etag: anyEtag
|
||||
});
|
||||
|
||||
mockManager.assert.sentEmail({to: 'test@test.com'});
|
||||
mockManager.assert.sentEmail({
|
||||
subject: 'Verify email address',
|
||||
to: 'test@test.com'
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('can do validateMembersEmailUpdate', async function () {
|
||||
const magicLink = await membersService.api.getMagicLink('test@test.com');
|
||||
const magicLinkUrl = new URL(magicLink);
|
||||
|
@ -1,10 +1,18 @@
|
||||
const sinon = require('sinon');
|
||||
const assert = require('assert');
|
||||
|
||||
const mail = require('../../../../../core/server/services/mail');
|
||||
const SettingsBreadService = require('../../../../../core/server/services/settings/settings-bread-service');
|
||||
const urlUtils = require('../../../../../core/shared/url-utils.js');
|
||||
const {mockManager} = require('../../../../utils/e2e-framework');
|
||||
const should = require('should');
|
||||
|
||||
describe('UNIT > Settings BREAD Service:', function () {
|
||||
beforeEach(function () {
|
||||
mockManager.mockMail();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
mockManager.restore();
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
@ -22,6 +30,9 @@ describe('UNIT > Settings BREAD Service:', function () {
|
||||
group: 'portal'
|
||||
})
|
||||
},
|
||||
mail,
|
||||
urlUtils,
|
||||
singleUseTokenProvider: {},
|
||||
labsService: {}
|
||||
});
|
||||
|
||||
@ -49,6 +60,9 @@ describe('UNIT > Settings BREAD Service:', function () {
|
||||
group: 'core'
|
||||
})
|
||||
},
|
||||
mail,
|
||||
urlUtils,
|
||||
singleUseTokenProvider: {},
|
||||
labsService: {}
|
||||
});
|
||||
|
||||
@ -76,6 +90,9 @@ describe('UNIT > Settings BREAD Service:', function () {
|
||||
group: 'core'
|
||||
})
|
||||
},
|
||||
mail,
|
||||
urlUtils,
|
||||
singleUseTokenProvider: {},
|
||||
labsService: {}
|
||||
});
|
||||
|
||||
@ -97,6 +114,9 @@ describe('UNIT > Settings BREAD Service:', function () {
|
||||
.withArgs('unknown_setting', {resolve: false})
|
||||
.returns(null)
|
||||
},
|
||||
mail,
|
||||
urlUtils,
|
||||
singleUseTokenProvider: {},
|
||||
labsService: {}
|
||||
});
|
||||
|
||||
@ -109,4 +129,133 @@ describe('UNIT > Settings BREAD Service:', function () {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit', function () {
|
||||
it('cannot set stripe_connect_secret_key ', async function () {
|
||||
const defaultSettingsManager = new SettingsBreadService({
|
||||
SettingsModel: {
|
||||
async edit(changes) {
|
||||
assert.equal(changes.length, 0);
|
||||
return changes;
|
||||
}
|
||||
},
|
||||
settingsCache: {},
|
||||
mail,
|
||||
urlUtils,
|
||||
singleUseTokenProvider: {},
|
||||
labsService: {}
|
||||
});
|
||||
|
||||
const settings = await defaultSettingsManager.edit([
|
||||
{
|
||||
key: 'stripe_connect_secret_key',
|
||||
value: 'test'
|
||||
}
|
||||
], {}, null);
|
||||
|
||||
assert.equal(settings.length, 0);
|
||||
});
|
||||
|
||||
it('setting members_support_address triggers email verification', async function () {
|
||||
const defaultSettingsManager = new SettingsBreadService({
|
||||
SettingsModel: {
|
||||
async edit(changes) {
|
||||
assert.equal(changes.length, 0);
|
||||
return changes;
|
||||
}
|
||||
},
|
||||
settingsCache: {
|
||||
get: sinon
|
||||
.stub()
|
||||
.withArgs('version_notifications', {resolve: false})
|
||||
.returns({
|
||||
key: 'portal_button_signup_text',
|
||||
value: 'Subscribe',
|
||||
group: 'portal'
|
||||
})
|
||||
},
|
||||
mail,
|
||||
urlUtils,
|
||||
singleUseTokenProvider: {
|
||||
create() {
|
||||
return 'test';
|
||||
}
|
||||
},
|
||||
labsService: {}
|
||||
});
|
||||
|
||||
const settings = await defaultSettingsManager.edit([
|
||||
{
|
||||
key: 'members_support_address',
|
||||
value: 'support@example.com'
|
||||
}
|
||||
], {}, null);
|
||||
|
||||
assert.equal(settings.length, 0);
|
||||
assert.deepEqual(settings.meta.sent_email_verification, ['members_support_address']);
|
||||
|
||||
mockManager.assert.sentEmail({
|
||||
subject: 'Verify email address',
|
||||
to: 'support@example.com'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyKeyUpdate', function () {
|
||||
it('can set members_support_address', async function () {
|
||||
const defaultSettingsManager = new SettingsBreadService({
|
||||
SettingsModel: {
|
||||
async edit(changes) {
|
||||
assert.deepEqual(changes, {
|
||||
key: 'members_support_address',
|
||||
value: 'support@example.com'
|
||||
});
|
||||
return changes;
|
||||
}
|
||||
},
|
||||
settingsCache: {},
|
||||
mail,
|
||||
urlUtils,
|
||||
singleUseTokenProvider: {
|
||||
validate(token) {
|
||||
assert.equal(token, 'test');
|
||||
|
||||
return {
|
||||
key: 'members_support_address',
|
||||
value: 'support@example.com'
|
||||
};
|
||||
}
|
||||
},
|
||||
labsService: {}
|
||||
});
|
||||
|
||||
const settings = await defaultSettingsManager.verifyKeyUpdate('test');
|
||||
assert.deepEqual(settings, {
|
||||
key: 'members_support_address',
|
||||
value: 'support@example.com'
|
||||
});
|
||||
});
|
||||
|
||||
it('can not set other fields', async function () {
|
||||
const defaultSettingsManager = new SettingsBreadService({
|
||||
SettingsModel: {},
|
||||
settingsCache: {},
|
||||
mail,
|
||||
urlUtils,
|
||||
singleUseTokenProvider: {
|
||||
validate(token) {
|
||||
assert.equal(token, 'test');
|
||||
|
||||
return {
|
||||
key: 'members_support_address_invalid',
|
||||
value: 'support@example.com'
|
||||
};
|
||||
}
|
||||
},
|
||||
labsService: {}
|
||||
});
|
||||
|
||||
await should(defaultSettingsManager.verifyKeyUpdate('test')).rejectedWith(/Not allowed to update this setting key via tokens/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user