implemented unsubscribe button

This commit is contained in:
Wyatt Johnson
2018-02-23 17:01:19 -07:00
parent f0572d1e34
commit 9437c5f5a3
12 changed files with 213 additions and 16 deletions
+9 -1
View File
@@ -1,10 +1,16 @@
const debug = require('debug')('talk:graph:connectors');
const merge = require('lodash/merge');
// Config.
const config = require('../config');
// Secrets.
const secrets = require('../secrets');
// Errors.
const errors = require('../errors');
// Graph
// Graph.
const { getBroker } = require('./subscriptions/broker');
const { getPubsub } = require('./subscriptions/pubsub');
const resolvers = require('./resolvers');
@@ -50,6 +56,8 @@ const Wordlist = require('../services/wordlist');
// Connectors.
const defaultConnectors = {
errors,
config,
secrets,
models: {
Action,
Asset,
@@ -1,5 +1,7 @@
const { groupBy, forEach } = require('lodash');
const debug = require('debug')('talk-plugin-notifications');
const uuid = require('uuid/v4');
const { UNSUBSCRIBE_SUBJECT } = require('./config');
class NotificationManager {
constructor(context) {
@@ -73,7 +75,11 @@ class NotificationManager {
async send(ctx, userID, date, handler, context) {
const {
connectors: { services: { Mailer, I18n: { t } } },
connectors: {
secrets: { jwt },
config: { JWT_ISSUER, JWT_AUDIENCE },
services: { Mailer, I18n: { t } },
},
loaders: { Settings },
} = ctx;
const { category } = handler;
@@ -90,6 +96,16 @@ class NotificationManager {
return;
}
// unsubscribeToken is the token used to perform the one-click
// unsubscribe.
const unsubscribeToken = jwt.sign({
jti: uuid(),
iss: JWT_ISSUER,
aud: JWT_AUDIENCE,
sub: UNSUBSCRIBE_SUBJECT,
user: userID,
});
// Compose the subject for the email.
const subject = t(
`talk-plugin-notifications.categories.${category}.subject`,
@@ -102,7 +118,7 @@ class NotificationManager {
// Send the notification to the user.
const task = await Mailer.send({
template: 'notification',
locals: { body, organizationName },
locals: { body, organizationName, unsubscribeToken },
subject,
user: userID,
});
@@ -0,0 +1,3 @@
module.exports = {
UNSUBSCRIBE_SUBJECT: 'nunsub',
};
@@ -13,12 +13,12 @@ module.exports = connectors => {
// notification template by passing the same name + format for the template
// registration.
Mailer.templates.register(
path.join(__dirname, 'templates', 'notification.html.ejs'),
path.join(__dirname, 'emails', 'notification.html.ejs'),
'notification',
'html'
);
Mailer.templates.register(
path.join(__dirname, 'templates', 'notification.txt.ejs'),
path.join(__dirname, 'emails', 'notification.txt.ejs'),
'notification',
'txt'
);
@@ -0,0 +1,3 @@
<p><%= linkify(body, {nl2br: true}) %></p>
<p><%= t('talk-plugin-notifications.templates.footer', organizationName) %></p>
<p><a href="<%= BASE_URL %>account/unsubscribe-notifications#<%= unsubscribeToken %>" target="_blank"><%= t('talk-plugin-notifications.templates.links.unsubscribe') %></a></p>
@@ -4,4 +4,4 @@
<%= t('talk-plugin-notifications.templates.links.unsubscribe') %>
<%= BASE_URL %>account/unsubscribe-notifications
<%= BASE_URL %>account/unsubscribe-notifications#<%= unsubscribeToken %>
@@ -1,6 +1,95 @@
const path = require('path');
const { UNSUBSCRIBE_SUBJECT } = require('./config');
const { get, isEmpty, reduce } = require('lodash');
module.exports = router => {
router.get('/account/unsubscribe-notifications', async (req, res) => {
// TODO: implement
res.json({ ok: true });
router.get('/account/unsubscribe-notifications', (req, res) => {
res.render(path.join(__dirname, 'views/unsubscribe-notifications'));
});
/**
* Verifies that the token is valid.
*/
const verifyToken = (req, res, next) => {
const {
connectors: { secrets: { jwt }, config: { JWT_ISSUER, JWT_AUDIENCE } },
} = req.context;
const { token: tokenString = '' } = req.body;
if (!tokenString) {
return res.status(400).end();
}
jwt.verify(
tokenString,
{
issuer: JWT_ISSUER,
subject: UNSUBSCRIBE_SUBJECT,
audience: JWT_AUDIENCE,
},
(err, token) => {
if (err) {
return res.status(400).end();
}
req.token = token;
next();
}
);
};
// Verifies that a token is valid.
router.post(
'/api/v1/account/unsubscribe-notifications/verify',
verifyToken,
(req, res) => {
res.status(204).end();
}
);
router.post(
'/api/v1/account/unsubscribe-notifications',
verifyToken,
async (req, res, next) => {
const { connectors: { models: { User } } } = req.context;
const { user: userID } = req.token;
try {
const user = await User.findOne({ id: userID });
if (!user) {
return res.status(400).end();
}
// Get the notification settings.
const settings = get(user, 'metadata.notifications.settings', {});
// If they have no notification settings set to true, then we're done.
if (isEmpty(settings)) {
return res.status(204).end();
}
const update = reduce(
settings,
(updates, value, key) => {
if (value) {
updates[`metadata.notifications.settings.${key}`] = false;
}
return updates;
},
{}
);
if (isEmpty(update)) {
return res.status(204).end();
}
// Save the user.
await User.update({ id: userID }, { $set: update });
res.status(204).end();
} catch (err) {
res.status(400).end();
}
}
);
};
@@ -1,3 +0,0 @@
<p><%= linkify(body, {nl2br: true}) %></p>
<p><%= t('talk-plugin-notifications.templates.footer', organizationName) %></p>
<p><a href="<%= BASE_URL %>account/unsubscribe-notifications" target="_blank"><%= t('talk-plugin-notifications.templates.links.unsubscribe') %></a></p>
@@ -3,4 +3,10 @@ en:
templates:
footer: "You received this notification because you are a commenter on {0} and you opted in to receive notifications."
links:
unsubscribe: "Unsubscribe from comment notifications"
unsubscribe: "Unsubscribe from comment notifications"
unsubscribe_page:
unsubscribe: "Unsubscribe from comment notifications"
click_to_confirm: "Click below to confirm that you would like to unsubscribe from all notifications"
confirm: "Confirm"
are_unsubscribed: "You are now unsubscribed from all notifications."
token_invalid: "Unsubscribe link is invalid, click the link from a more recent email or visit a comment stream and login to change your notification preferences"
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
<title><%= t('talk-plugin-notifications.unsubscribe_page.unsubscribe') %></title>
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.indigo-pink.min.css">
<link rel="stylesheet" href="<%= BASE_PATH %>public/css/admin.css">
<%- include(root + '/partials/head') %>
</head>
<body class="confirm-email-page">
<div id="root">
<div class="error-console container"><%= t('talk-plugin-notifications.unsubscribe_page.token_invalid') %></div>
<div id="success" style="display:none;" class="legend container"><%= t('talk-plugin-notifications.unsubscribe_page.are_unsubscribed') %></div>
<form id="unsubscribe-form" class="container">
<legend class="legend"><%= t('talk-plugin-notifications.unsubscribe_page.click_to_confirm') %></legend>
<button type="submit"><%= t('talk-plugin-notifications.unsubscribe_page.confirm') %></button>
</form>
</div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
<script type="text/javascript">
$(function() {
var submitting = false;
var payload = JSON.stringify({token: location.hash.replace('#', '')});
function handleSubmit(e) {
e.preventDefault();
if (submitting) {
return;
}
submitting = true;
$('.error-console').removeClass('active');
$.ajax({
url: '<%= BASE_PATH %>api/v1/account/unsubscribe-notifications',
contentType: 'application/json',
method: 'POST',
data: payload,
}).then(function (success) {
$('#unsubscribe-form').fadeOut(function () {
$('#success').fadeIn();
});
}).catch(function () {
submitting = false;
$('.error-console').addClass('active');
});
}
$.ajax({
url: '<%= BASE_PATH %>api/v1/account/unsubscribe-notifications/verify',
contentType: 'application/json',
method: 'POST',
data: payload,
})
.then(function () {
$('#unsubscribe-form').fadeIn().on('submit', handleSubmit);
})
.catch(function () {
$('.error-console').addClass('active');
});
});
</script>
</body>
</html>
+8
View File
@@ -1,9 +1,17 @@
const express = require('express');
const debug = require('debug')('talk:routes:plugins');
const plugins = require('../services/plugins');
const staticTemplate = require('../middleware/staticTemplate');
const router = express.Router();
// Routes mounted from plugins won't have access to our internal partials
// directory, so we should make that available.
router.use(staticTemplate, (req, res, next) => {
res.locals.root = res.app.get('views');
next();
});
// Inject server route plugins.
plugins.get('server', 'router').forEach(plugin => {
debug(`added plugin '${plugin.plugin.name}'`);
+6 -3
View File
@@ -101,9 +101,12 @@ class Secret {
jwt.verify(
token,
this.verifiyingKey,
Object.assign({}, options, {
algorithms: [this.algorithm],
}),
omitBy(
merge({}, options, {
algorithms: [this.algorithm],
}),
isUndefined
),
callback
);
}