diff --git a/client/coral-ui/components/Checkbox.css b/client/coral-ui/components/Checkbox.css
index a25ea150f..36e6a73ba 100644
--- a/client/coral-ui/components/Checkbox.css
+++ b/client/coral-ui/components/Checkbox.css
@@ -50,6 +50,11 @@
color: #00a291;
}
+.input:disabled + .checkbox:before {
+ color: #e5e5e5;
+ cursor: default;
+}
+
.input:focus + .checkbox:before {
color: #00a291;
}
diff --git a/client/coral-ui/components/Spinner.js b/client/coral-ui/components/Spinner.js
index 69d805354..3a6e7d30b 100644
--- a/client/coral-ui/components/Spinner.js
+++ b/client/coral-ui/components/Spinner.js
@@ -1,8 +1,10 @@
import React from 'react';
+import PropTypes from 'prop-types';
import styles from './Spinner.css';
+import cn from 'classnames';
-const Spinner = () => (
-
+const Spinner = ({ className }) => (
+
);
+Spinner.propTypes = {
+ className: PropTypes.string,
+};
+
export default Spinner;
diff --git a/config.js b/config.js
index 9b7c7adcc..956e7ac8f 100644
--- a/config.js
+++ b/config.js
@@ -48,6 +48,13 @@ const CONFIG = {
// request all of the records. Otherwise, minimum limits of 0 are enforced.
ALLOW_NO_LIMIT_QUERIES: process.env.TALK_ALLOW_NO_LIMIT_QUERIES === 'TRUE',
+ // LOGGING_LEVEL specifies the logging level used by the bunyan logger.
+ LOGGING_LEVEL: ['fatal', 'error', 'warn', 'info', 'debug', 'trace'].includes(
+ process.env.TALK_LOGGING_LEVEL
+ )
+ ? process.env.TALK_LOGGING_LEVEL
+ : 'info',
+
//------------------------------------------------------------------------------
// JWT based configuration
//------------------------------------------------------------------------------
diff --git a/docs/_docs/02-02-advanced-configuration.md b/docs/_docs/02-02-advanced-configuration.md
index 4fc60a2cc..c2c661021 100644
--- a/docs/_docs/02-02-advanced-configuration.md
+++ b/docs/_docs/02-02-advanced-configuration.md
@@ -563,3 +563,14 @@ This is a **Build Variable** and must be consumed during build. If using the
image you can specify it with `--build-arg TALK_REPLY_COMMENTS_LOAD_DEPTH=3`.
Specifies the initial replies to load for a comment. (Default `3`)
+
+## TALK_LOGGING_LEVEL
+
+Sets the logging level for the context logger (from [Bunyan](https://github.com/trentm/node-bunyan)) that will be phased in to replace most existing `debug()` calls. Supports the following values:
+
+- `fatal`
+- `error`
+- `warn`
+- `info`
+- `debug`
+- `trace`
\ No newline at end of file
diff --git a/docs/_docs/04-03-additional-plugins.md b/docs/_docs/04-03-additional-plugins.md
index b88d8437c..e8a4a12a5 100644
--- a/docs/_docs/04-03-additional-plugins.md
+++ b/docs/_docs/04-03-additional-plugins.md
@@ -142,6 +142,10 @@ anything. You need to enable one of the `talk-plugin-notifications-category-*` p
```
{:.no-copy}
+Configuration:
+
+- `DISABLE_REQUIRE_EMAIL_VERIFICATIONS` - When `TRUE`, it will disable the verification email check before sending notifications for those emails. **Note that organizations implementing a custom authentication system _must_ disable this feature, as they don't use our integrated auth**. (Default `FALSE`).
+
### talk-plugin-notifications-category-reply
{:.param}
@@ -157,3 +161,11 @@ Source: [plugins/talk-plugin-notifications-category-featured](https://github.com
When a comment is featured (via the `talk-plugin-featured-comments` plugin), the
user will receive a notification email.
+
+### talk-plugin-notifications-category-staff
+{:.param}
+
+Source: [plugins/talk-plugin-notifications-category-staff](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-notifications-category-staff){:target="_blank"}
+
+Replies made to each user by a staff member will trigger an email to be sent
+with the notification details if enabled.
diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js
index df180627f..3773a8dde 100644
--- a/graph/mutators/comment.js
+++ b/graph/mutators/comment.js
@@ -12,12 +12,9 @@ const {
ADD_COMMENT_TAG,
EDIT_COMMENT,
} = require('../../perms/constants');
-const debug = require('debug')('talk:graph:mutators:comment');
-const resolveTagsForComment = async (
- { user, loaders: { Tags } },
- { asset_id, tags = [] }
-) => {
+const resolveTagsForComment = async (ctx, { asset_id, tags = [] }) => {
+ const { user, loaders: { Tags } } = ctx;
const item_type = 'COMMENTS';
// Handle Tags
@@ -46,6 +43,7 @@ const resolveTagsForComment = async (
// Add the staff tag for comments created as a staff member.
if (user.can(ADD_COMMENT_TAG)) {
+ ctx.log.info({ author_id: user.id }, 'Added staff tag to comment');
tags.push(
TagsService.newTagLink(user, {
name: 'STAFF',
@@ -61,7 +59,7 @@ const resolveTagsForComment = async (
* adjustKarma will adjust the affected user's karma depending on the moderators
* action.
*/
-const adjustKarma = (Comments, id, status) => async () => {
+const adjustKarma = (ctx, Comments, id, status) => async () => {
try {
// Use the dataloader to get the comment that was just moderated and
// get the flag user's id's so we can adjust their karma too.
@@ -86,17 +84,27 @@ const adjustKarma = (Comments, id, status) => async () => {
}),
]);
- debug(`Comment[${id}] by User[${comment.author_id}] was Status[${status}]`);
+ ctx.log.info(
+ {
+ state: {
+ comment_id: id,
+ author_id: comment.author_id,
+ status: status,
+ },
+ },
+ 'Processing karma modification'
+ );
switch (status) {
case 'REJECTED':
// Reduce the user's karma.
- debug(`CommentUser[${comment.author_id}] had their karma reduced`);
+ ctx.log.info('Comment author had their karma reduced');
// Decrease the flag user's karma, the moderator disagreed with this
// action.
- debug(
- `FlaggingUser[${flagUserIDs.join(', ')}] had their karma increased`
+ ctx.log.info(
+ { flagUserIDs },
+ 'Flagging users had their karma increased'
);
await Promise.all([
KarmaService.modifyUser(comment.author_id, -1, 'comment'),
@@ -107,13 +115,11 @@ const adjustKarma = (Comments, id, status) => async () => {
case 'ACCEPTED':
// Increase the user's karma.
- debug(`CommentUser[${comment.author_id}] had their karma increased`);
+ ctx.log.info('Comment author had their karma increased');
// Increase the flag user's karma, the moderator agreed with this
// action.
- debug(
- `FlaggingUser[${flagUserIDs.join(', ')}] had their karma reduced`
- );
+ ctx.log.info({ flagUserIDs }, `Flagging users had their karma reduced`);
await Promise.all([
KarmaService.modifyUser(comment.author_id, 1, 'comment'),
KarmaService.modifyUser(flagUserIDs, -1, 'flag', true),
@@ -140,7 +146,7 @@ const adjustKarma = (Comments, id, status) => async () => {
* @return {Promise} resolves to the created comment
*/
const createComment = async (
- context,
+ ctx,
{
tags = [],
body,
@@ -150,10 +156,10 @@ const createComment = async (
metadata = {},
}
) => {
- const { user, loaders: { Comments }, pubsub } = context;
+ const { user, loaders: { Comments }, pubsub } = ctx;
// Resolve the tags for the comment.
- tags = await resolveTagsForComment(context, { asset_id, tags });
+ tags = await resolveTagsForComment(ctx, { asset_id, tags });
let comment = await CommentsService.publicCreate({
body,
@@ -165,6 +171,11 @@ const createComment = async (
metadata,
});
+ ctx.log.info(
+ { comment_id: comment.id, comment_status: status },
+ 'Created comment'
+ );
+
// If the loaders are present, clear the caches for these values because we
// just added a new comment, hence the counts should be updated. We should
// perform these increments in the event that we do have a new comment that
@@ -228,12 +239,14 @@ const createActions = async (item_id, actions = []) =>
/**
* Sets the status of a comment
- * @param {Object} context graphql context
+ * @param {Object} ctx graphql context
* @param {String} comment comment in graphql context
* @param {String} id identifier of the comment (uuid)
* @param {String} status the new status of the comment
*/
-const setStatus = async ({ user, loaders: { Comments } }, { id, status }) => {
+const setStatus = async (ctx, { id, status }) => {
+ const { user, loaders: { Comments } } = ctx;
+
let comment = await CommentsService.pushStatus(
id,
status,
@@ -253,7 +266,7 @@ const setStatus = async ({ user, loaders: { Comments } }, { id, status }) => {
// postSetCommentStatus will use the arguments from the mutation and
// adjust the affected user's karma in the next tick.
- process.nextTick(adjustKarma(Comments, id, status));
+ process.nextTick(adjustKarma(ctx, Comments, id, status));
return comment;
};
@@ -293,7 +306,7 @@ const edit = async (ctx, { id, asset_id, edit: { body, metadata = {} } }) => {
return comment;
};
-module.exports = context => {
+module.exports = ctx => {
let mutators = {
Comment: {
create: () => Promise.reject(errors.ErrNotAuthorized),
@@ -302,16 +315,16 @@ module.exports = context => {
},
};
- if (context.user && context.user.can(CREATE_COMMENT)) {
- mutators.Comment.create = comment => createPublicComment(context, comment);
+ if (ctx.user && ctx.user.can(CREATE_COMMENT)) {
+ mutators.Comment.create = comment => createPublicComment(ctx, comment);
}
- if (context.user && context.user.can(SET_COMMENT_STATUS)) {
- mutators.Comment.setStatus = action => setStatus(context, action);
+ if (ctx.user && ctx.user.can(SET_COMMENT_STATUS)) {
+ mutators.Comment.setStatus = action => setStatus(ctx, action);
}
- if (context.user && context.user.can(EDIT_COMMENT)) {
- mutators.Comment.edit = action => edit(context, action);
+ if (ctx.user && ctx.user.can(EDIT_COMMENT)) {
+ mutators.Comment.edit = action => edit(ctx, action);
}
return mutators;
diff --git a/graph/resolvers/index.js b/graph/resolvers/index.js
index 74b006ebb..a8765f743 100644
--- a/graph/resolvers/index.js
+++ b/graph/resolvers/index.js
@@ -15,6 +15,7 @@ const DontAgreeActionSummary = require('./dont_agree_action_summary');
const FlagAction = require('./flag_action');
const FlagActionSummary = require('./flag_action_summary');
const GenericUserError = require('./generic_user_error');
+const LocalUserProfile = require('./local_user_profile');
const RootMutation = require('./root_mutation');
const RootQuery = require('./root_query');
const Settings = require('./settings');
@@ -24,8 +25,9 @@ const Tag = require('./tag');
const TagLink = require('./tag_link');
const User = require('./user');
const UserError = require('./user_error');
-const UserState = require('./user_state');
const UsernameStatusHistory = require('./username_status_history');
+const UserProfile = require('./user_profile');
+const UserState = require('./user_state');
const ValidationUserError = require('./validation_user_error');
const plugins = require('../../services/plugins');
@@ -46,6 +48,7 @@ let resolvers = {
FlagAction,
FlagActionSummary,
GenericUserError,
+ LocalUserProfile,
RootMutation,
RootQuery,
Settings,
@@ -55,8 +58,9 @@ let resolvers = {
TagLink,
User,
UserError,
- UserState,
UsernameStatusHistory,
+ UserProfile,
+ UserState,
ValidationUserError,
};
diff --git a/graph/resolvers/local_user_profile.js b/graph/resolvers/local_user_profile.js
new file mode 100644
index 000000000..1c3e0874b
--- /dev/null
+++ b/graph/resolvers/local_user_profile.js
@@ -0,0 +1,7 @@
+const { property } = require('lodash');
+
+const LocalUserProfile = {
+ confirmedAt: property('metadata.confirmed_at'),
+};
+
+module.exports = LocalUserProfile;
diff --git a/graph/resolvers/user_profile.js b/graph/resolvers/user_profile.js
new file mode 100644
index 000000000..6f26fa318
--- /dev/null
+++ b/graph/resolvers/user_profile.js
@@ -0,0 +1,12 @@
+const UserProfile = {
+ __resolveType({ provider }) {
+ switch (provider) {
+ case 'local':
+ return 'LocalUserProfile';
+ default:
+ return undefined;
+ }
+ },
+};
+
+module.exports = UserProfile;
diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql
index f108a7ad4..79d259122 100644
--- a/graph/typeDefs.graphql
+++ b/graph/typeDefs.graphql
@@ -62,7 +62,7 @@ type Token {
jwt: String
}
-type UserProfile {
+interface UserProfile {
# The id is an identifier for the user profile (email, facebook id, etc)
id: String!
@@ -70,6 +70,31 @@ type UserProfile {
provider: String!
}
+# DefaultUserProfile is a fallback if the type of UserProfile can't be
+# determined (like if it was from a plugin that was removed).
+type DefaultUserProfile implements UserProfile {
+ # The id is an identifier for the user profile (email, facebook id, etc)
+ id: String!
+
+ # name of the provider attached to the authentication mode
+ provider: String!
+}
+
+# LocalUserProfile is for a User who has an authentication profile linked to
+# their email address.
+type LocalUserProfile implements UserProfile {
+ # id is the User's email address.
+ id: String!
+
+ # name of the provider attached to the authentication mode, in this case,
+ # 'local'.
+ provider: String!
+
+ # confirmedAt is the Date that the user had their email address confirmed,
+ # which is null if it has not been verified.
+ confirmedAt: Date
+}
+
# USER_STATUS_USERNAME is the different states that a username can be in.
enum USER_STATUS_USERNAME {
# UNSET is used when the username can be changed, and does not necessarily
diff --git a/plugins/talk-plugin-auth/client/login/containers/ResendEmailConfirmation.js b/plugins/talk-plugin-auth/client/login/containers/ResendEmailConfirmation.js
index bd7c9ad8c..6edeb82f2 100644
--- a/plugins/talk-plugin-auth/client/login/containers/ResendEmailConfirmation.js
+++ b/plugins/talk-plugin-auth/client/login/containers/ResendEmailConfirmation.js
@@ -41,7 +41,7 @@ ResendEmailConfirmatonContainer.propTypes = {
success: PropTypes.bool.isRequired,
loading: PropTypes.bool.isRequired,
resendEmailConfirmation: PropTypes.func.isRequired,
- errorMessage: PropTypes.string.isRequired,
+ errorMessage: PropTypes.string,
setView: PropTypes.func.isRequired,
email: PropTypes.string.isRequired,
};
diff --git a/plugins/talk-plugin-notifications-category-featured/client/containers/Toggle.js b/plugins/talk-plugin-notifications-category-featured/client/containers/Toggle.js
index ec7586b16..f888789f3 100644
--- a/plugins/talk-plugin-notifications-category-featured/client/containers/Toggle.js
+++ b/plugins/talk-plugin-notifications-category-featured/client/containers/Toggle.js
@@ -36,7 +36,11 @@ class ToggleContainer extends React.Component {
render() {
return (
-
+
{t('talk-plugin-notifications-category-featured.toggle_description')}
);
@@ -50,6 +54,7 @@ ToggleContainer.propTypes = {
indicateOff: PropTypes.func.isRequired,
setTurnOffInputFragment: PropTypes.func.isRequired,
updateNotificationSettings: PropTypes.func.isRequired,
+ disabled: PropTypes.bool.isRequired,
};
const enhance = compose(
diff --git a/plugins/talk-plugin-notifications-category-featured/index.js b/plugins/talk-plugin-notifications-category-featured/index.js
index 802c37a9a..c6b0ea2bb 100644
--- a/plugins/talk-plugin-notifications-category-featured/index.js
+++ b/plugins/talk-plugin-notifications-category-featured/index.js
@@ -1,20 +1,16 @@
-const { graphql } = require('graphql');
const { get } = require('lodash');
const path = require('path');
const handle = async (ctx, { comment }) => {
- const { connectors: { graph: { schema } } } = ctx;
-
// Check to see if this is a reply to an existing comment.
const commentID = get(comment, 'id', null);
if (commentID === null) {
- ctx.log.debug('could not get comment id');
+ ctx.log.info('could not get comment id');
return;
}
// Execute the graph request.
- const reply = await graphql(
- schema,
+ const reply = await ctx.graphql(
`
query GetAuthorUserMetadata($comment_id: ID!) {
comment(id: $comment_id) {
@@ -28,8 +24,6 @@ const handle = async (ctx, { comment }) => {
}
}
`,
- {},
- ctx,
{ comment_id: commentID }
);
if (reply.errors) {
@@ -49,7 +43,7 @@ const handle = async (ctx, { comment }) => {
const userID = get(reply, 'data.comment.user.id', null);
if (!userID) {
- ctx.log.debug('could not get comment user id');
+ ctx.log.info('could not get comment user id');
return;
}
@@ -59,10 +53,7 @@ const handle = async (ctx, { comment }) => {
};
const hydrate = async (ctx, category, context) => {
- const { connectors: { graph: { schema } } } = ctx;
-
- const reply = await graphql(
- schema,
+ const reply = await ctx.graphql(
`
query GetNotificationData($context: ID!) {
comment(id: $context) {
@@ -74,8 +65,6 @@ const hydrate = async (ctx, category, context) => {
}
}
`,
- {},
- ctx,
{ context }
);
if (reply.errors) {
diff --git a/plugins/talk-plugin-notifications-category-reply/client/containers/Toggle.js b/plugins/talk-plugin-notifications-category-reply/client/containers/Toggle.js
index 61600a29f..d261946d0 100644
--- a/plugins/talk-plugin-notifications-category-reply/client/containers/Toggle.js
+++ b/plugins/talk-plugin-notifications-category-reply/client/containers/Toggle.js
@@ -36,7 +36,11 @@ class ToggleContainer extends React.Component {
render() {
return (
-
+
{t('talk-plugin-notifications-category-reply.toggle_description')}
);
@@ -50,6 +54,7 @@ ToggleContainer.propTypes = {
indicateOff: PropTypes.func.isRequired,
setTurnOffInputFragment: PropTypes.func.isRequired,
updateNotificationSettings: PropTypes.func.isRequired,
+ disabled: PropTypes.bool.isRequired,
};
const enhance = compose(
diff --git a/plugins/talk-plugin-notifications-category-reply/index.js b/plugins/talk-plugin-notifications-category-reply/index.js
index 32d90e05d..16cfbfb72 100644
--- a/plugins/talk-plugin-notifications-category-reply/index.js
+++ b/plugins/talk-plugin-notifications-category-reply/index.js
@@ -1,20 +1,16 @@
-const { graphql } = require('graphql');
const { get } = require('lodash');
const path = require('path');
const handle = async (ctx, comment) => {
- const { connectors: { graph: { schema } } } = ctx;
-
// Check to see if this is a reply to an existing comment.
const parentID = get(comment, 'parent_id', null);
if (parentID === null) {
- ctx.log.debug('could not get parent comment id');
+ ctx.log.info('could not get parent comment id');
return;
}
// Execute the graph request.
- const reply = await graphql(
- schema,
+ const reply = await ctx.graphql(
`
query GetAuthorUserMetadata($comment_id: ID!) {
comment(id: $comment_id) {
@@ -28,8 +24,6 @@ const handle = async (ctx, comment) => {
}
}
`,
- {},
- ctx,
{ comment_id: parentID }
);
if (reply.errors) {
@@ -49,14 +43,14 @@ const handle = async (ctx, comment) => {
const userID = get(reply, 'data.comment.user.id', null);
if (!userID) {
- ctx.log.debug('could not get parent comment user id');
+ ctx.log.info('could not get parent comment user id');
return;
}
// Check to see if this is yourself replying to yourself, if that's the case
// don't send a notification.
if (userID === get(comment, 'author_id')) {
- ctx.log.debug('user id of parent comment is the same as the new comment');
+ ctx.log.info('user id of parent comment is the same as the new comment');
return;
}
@@ -66,10 +60,7 @@ const handle = async (ctx, comment) => {
};
const hydrate = async (ctx, category, context) => {
- const { connectors: { graph: { schema } } } = ctx;
-
- const reply = await graphql(
- schema,
+ const reply = await ctx.graphql(
`
query GetNotificationData($context: ID!) {
comment(id: $context) {
@@ -84,8 +75,6 @@ const hydrate = async (ctx, category, context) => {
}
}
`,
- {},
- ctx,
{ context }
);
if (reply.errors) {
diff --git a/plugins/talk-plugin-notifications-category-staff/client/containers/Toggle.js b/plugins/talk-plugin-notifications-category-staff/client/containers/Toggle.js
index 44d6d5b3c..62a4cbbce 100644
--- a/plugins/talk-plugin-notifications-category-staff/client/containers/Toggle.js
+++ b/plugins/talk-plugin-notifications-category-staff/client/containers/Toggle.js
@@ -36,7 +36,11 @@ class ToggleContainer extends React.Component {
render() {
return (
-
+
{t('talk-plugin-notifications-category-staff.toggle_description')}
);
@@ -50,6 +54,7 @@ ToggleContainer.propTypes = {
indicateOff: PropTypes.func.isRequired,
setTurnOffInputFragment: PropTypes.func.isRequired,
updateNotificationSettings: PropTypes.func.isRequired,
+ disabled: PropTypes.bool.isRequired,
};
const enhance = compose(
diff --git a/plugins/talk-plugin-notifications-category-staff/index.js b/plugins/talk-plugin-notifications-category-staff/index.js
index c5a59253d..d229456f7 100644
--- a/plugins/talk-plugin-notifications-category-staff/index.js
+++ b/plugins/talk-plugin-notifications-category-staff/index.js
@@ -1,14 +1,11 @@
-const { graphql } = require('graphql');
const { get } = require('lodash');
const path = require('path');
const handle = async (ctx, comment) => {
- const { connectors: { graph: { schema } } } = ctx;
-
// Check to see if this is a reply to an existing comment.
const parentID = get(comment, 'parent_id', null);
if (parentID === null) {
- ctx.log.debug('could not get parent comment id');
+ ctx.log.info('could not get parent comment id');
return;
}
@@ -19,8 +16,7 @@ const handle = async (ctx, comment) => {
}
// Execute the graph request.
- const reply = await graphql(
- schema,
+ const reply = await ctx.graphql(
`
query GetAuthorUserMetadata($comment_id: ID!, $author_id: ID!) {
author: user(id: $author_id) {
@@ -37,8 +33,6 @@ const handle = async (ctx, comment) => {
}
}
`,
- {},
- ctx,
{ comment_id: parentID, author_id: authorID }
);
if (reply.errors) {
@@ -53,27 +47,27 @@ const handle = async (ctx, comment) => {
false
);
if (!enabled) {
- ctx.log.debug('onStaffReply is false, will not send the notification');
+ ctx.log.info('onStaffReply is false, will not send the notification');
return;
}
const userID = get(reply, 'data.comment.user.id', null);
if (!userID) {
- ctx.log.debug('could not get parent comment user id');
+ ctx.log.info('could not get parent comment user id');
return;
}
// Check to see if this is yourself replying to yourself, if that's the case
// don't send a notification.
if (userID === authorID) {
- ctx.log.debug('user id of parent comment is the same as the new comment');
+ ctx.log.info('user id of parent comment is the same as the new comment');
return;
}
// Check to see that this comment was indeed from a staff member.
const role = get(reply, 'data.author.role');
if (!['ADMIN', 'MODERATOR', 'STAFF'].includes(role)) {
- ctx.log.debug({ role }, 'reply author is not a staff member');
+ ctx.log.info({ role }, 'reply author is not a staff member');
return;
}
@@ -83,10 +77,7 @@ const handle = async (ctx, comment) => {
};
const hydrate = async (ctx, category, context) => {
- const { connectors: { graph: { schema } } } = ctx;
-
- const reply = await graphql(
- schema,
+ const reply = await ctx.graphql(
`
query GetNotificationData($context: ID!) {
comment(id: $context) {
@@ -104,8 +95,6 @@ const hydrate = async (ctx, category, context) => {
}
}
`,
- {},
- ctx,
{ context }
);
if (reply.errors) {
diff --git a/plugins/talk-plugin-notifications/client/components/Banner.css b/plugins/talk-plugin-notifications/client/components/Banner.css
new file mode 100644
index 000000000..cfbff17cf
--- /dev/null
+++ b/plugins/talk-plugin-notifications/client/components/Banner.css
@@ -0,0 +1,42 @@
+.root {
+ display: flex;
+ border: 1px solid #a8afb3;
+ border-radius: 2px;
+ margin: 16px 0;
+
+ p:first-of-type {
+ margin-top: 0;
+ }
+ p:last-child {
+ margin-bottom: 0;
+ }
+}
+.leftColumn {
+ width: 32px;
+ background-color: #787d80;
+ text-align: center;
+ padding-top: 6px;
+ font-size: 18px;
+ flex-shrink: 0;
+
+ &.error {
+ background-color: #fa6265;
+ }
+ &.success {
+ background-color: #00cd7e;
+ }
+}
+.icon {
+ color: white;
+}
+.rightColumn {
+ padding: 8px 12px 10px 12px;
+ font-size: 14px;
+}
+.title {
+ font-size: 14px;
+ margin: 0;
+ margin-bottom: 4px;
+}
+
+
diff --git a/plugins/talk-plugin-notifications/client/components/Banner.js b/plugins/talk-plugin-notifications/client/components/Banner.js
new file mode 100644
index 000000000..401867afb
--- /dev/null
+++ b/plugins/talk-plugin-notifications/client/components/Banner.js
@@ -0,0 +1,50 @@
+import React from 'react';
+import styles from './Banner.css';
+import { Icon } from 'coral-ui';
+import PropTypes from 'prop-types';
+import cn from 'classnames';
+
+function getIcon(icon, error, success) {
+ if (icon) {
+ return icon;
+ }
+ if (error) {
+ return 'warning';
+ }
+ if (success) {
+ return 'done';
+ }
+ return 'info';
+}
+
+const Banner = ({ title, icon, error, success, children }) => (
+
+
+
+
+
+
{title}
+ {children}
+
+
+);
+
+Banner.propTypes = {
+ title: PropTypes.string,
+ icon: PropTypes.string,
+ children: PropTypes.node,
+ error: PropTypes.bool,
+ success: PropTypes.bool,
+};
+
+Banner.defaultProps = {
+ title: 'Title',
+ children: 'Lorem Ipsum Dolot Sit Ahmet',
+};
+
+export default Banner;
diff --git a/plugins/talk-plugin-notifications/client/components/EmailVerificationBanner.css b/plugins/talk-plugin-notifications/client/components/EmailVerificationBanner.css
new file mode 100644
index 000000000..388abe884
--- /dev/null
+++ b/plugins/talk-plugin-notifications/client/components/EmailVerificationBanner.css
@@ -0,0 +1,11 @@
+.spinner {
+ display: inline-block;
+}
+
+.link {
+ display: inline-block;
+ cursor: pointer;
+ margin: 2px;
+ color: #2099d6;
+ border-bottom: 1px solid #2099d6;
+}
diff --git a/plugins/talk-plugin-notifications/client/components/EmailVerificationBanner.js b/plugins/talk-plugin-notifications/client/components/EmailVerificationBanner.js
new file mode 100644
index 000000000..b2e344fc7
--- /dev/null
+++ b/plugins/talk-plugin-notifications/client/components/EmailVerificationBanner.js
@@ -0,0 +1,75 @@
+import React from 'react';
+import Banner from './Banner';
+import PropTypes from 'prop-types';
+import { Spinner } from 'plugin-api/beta/client/components/ui';
+import { t } from 'plugin-api/beta/client/services';
+import styles from './EmailVerificationBanner.css';
+
+const EmailVerificationBannerInfo = ({ onResendEmailVerification }) => (
+
+
+ {t('talk-plugin-notifications.banner_info.text')}
+ {
+ onResendEmailVerification();
+ return false;
+ }}
+ >
+ {t('talk-plugin-notifications.banner_info.verify_now')}
+
+
+
+);
+
+const EmailVerificationBannerLoading = () => (
+
+
+
+);
+
+const EmailVerificationBannerError = ({ errorMessage }) => (
+
+ {t('talk-plugin-notifications.banner_error.text')}
+ {errorMessage}
+
+);
+
+const EmailVerificationBannerSuccess = ({ email }) => (
+
+ {t('talk-plugin-notifications.banner_success.text', email)}
+
+);
+
+const EmailVerificationBanner = ({
+ onResendEmailVerification,
+ email,
+ success,
+ loading,
+ errorMessage,
+}) => (
+
+ {success && }
+ {errorMessage && (
+
+ )}
+ {loading && }
+ {!success &&
+ !errorMessage &&
+ !loading && (
+
+ )}
+
+);
+
+EmailVerificationBanner.propTypes = {
+ onResendEmailVerification: PropTypes.func.isRequired,
+ success: PropTypes.bool.isRequired,
+ errorMessage: PropTypes.string,
+ loading: PropTypes.bool.isRequired,
+ email: PropTypes.string.isRequired,
+};
+
+export default EmailVerificationBanner;
diff --git a/plugins/talk-plugin-notifications/client/components/Settings.css b/plugins/talk-plugin-notifications/client/components/Settings.css
index 12a7233e1..c4d43fd9a 100644
--- a/plugins/talk-plugin-notifications/client/components/Settings.css
+++ b/plugins/talk-plugin-notifications/client/components/Settings.css
@@ -1,5 +1,6 @@
.root {
margin-bottom: 20px;
+ width: 380px;
}
.innerSettings {
@@ -25,3 +26,7 @@
.notifcationSettingsSlot {
margin-bottom: 3px;
}
+
+.disabled {
+ color: #e5e5e5;
+}
diff --git a/plugins/talk-plugin-notifications/client/components/Settings.js b/plugins/talk-plugin-notifications/client/components/Settings.js
index dad6b2bed..010422491 100644
--- a/plugins/talk-plugin-notifications/client/components/Settings.js
+++ b/plugins/talk-plugin-notifications/client/components/Settings.js
@@ -5,6 +5,8 @@ import { Slot } from 'plugin-api/beta/client/components';
import { t } from 'plugin-api/beta/client/services';
import styles from './Settings.css';
import { BareButton } from 'plugin-api/beta/client/components/ui';
+import EmailVerificationBanner from '../containers/EmailVerificationBanner';
+import cn from 'classnames';
class Settings extends React.Component {
childFactory = el => {
@@ -23,13 +25,20 @@ class Settings extends React.Component {
updateNotificationSettings,
turnOffAll,
turnOffButtonDisabled,
+ needEmailVerification,
+ email,
} = this.props;
return (
{t('talk-plugin-notifications.settings_title')}
-
+ {needEmailVerification && }
+
{t('talk-plugin-notifications.settings_subtitle')}
@@ -40,6 +49,7 @@ class Settings extends React.Component {
childFactory={this.childFactory}
setTurnOffInputFragment={setTurnOffInputFragment}
updateNotificationSettings={updateNotificationSettings}
+ disabled={needEmailVerification}
/>
-
);
}
}
Toggle.propTypes = {
+ disabled: PropTypes.bool,
checked: PropTypes.bool,
onChange: PropTypes.func,
children: PropTypes.node,
diff --git a/plugins/talk-plugin-notifications/client/containers/EmailVerificationBanner.js b/plugins/talk-plugin-notifications/client/containers/EmailVerificationBanner.js
new file mode 100644
index 000000000..0d8a98214
--- /dev/null
+++ b/plugins/talk-plugin-notifications/client/containers/EmailVerificationBanner.js
@@ -0,0 +1,35 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { withResendEmailConfirmation } from 'plugin-api/beta/client/hocs';
+import { compose } from 'recompose';
+import EmailVerificationBanner from '../components/EmailVerificationBanner';
+
+class EmailVerificationBannerContainer extends Component {
+ handleResendEmailVerification = () => {
+ this.props.resendEmailConfirmation(this.props.email);
+ };
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EmailVerificationBannerContainer.propTypes = {
+ success: PropTypes.bool.isRequired,
+ loading: PropTypes.bool.isRequired,
+ resendEmailConfirmation: PropTypes.func.isRequired,
+ errorMessage: PropTypes.string,
+ email: PropTypes.string.isRequired,
+};
+
+export default compose(withResendEmailConfirmation)(
+ EmailVerificationBannerContainer
+);
diff --git a/plugins/talk-plugin-notifications/client/containers/Settings.js b/plugins/talk-plugin-notifications/client/containers/Settings.js
index e987bafa2..76a03462d 100644
--- a/plugins/talk-plugin-notifications/client/containers/Settings.js
+++ b/plugins/talk-plugin-notifications/client/containers/Settings.js
@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { compose, gql } from 'react-apollo';
import Settings from '../components/Settings';
-import { withFragments } from 'plugin-api/beta/client/hocs';
+import { withFragments, excludeIf } from 'plugin-api/beta/client/hocs';
import { getSlotFragmentSpreads } from 'plugin-api/beta/client/utils';
import { withUpdateNotificationSettings } from '../mutations';
@@ -15,13 +15,15 @@ class SettingsContainer extends React.Component {
};
indicateOn = plugin =>
- this.setState({
- hasNotifications: this.state.hasNotifications.concat(plugin),
- });
+ this.setState(state => ({
+ hasNotifications: state.hasNotifications.concat(plugin),
+ }));
+
indicateOff = plugin =>
- this.setState({
- hasNotifications: this.state.hasNotifications.filter(i => i !== plugin),
- });
+ this.setState(state => ({
+ hasNotifications: state.hasNotifications.filter(i => i !== plugin),
+ }));
+
setTurnOffInputFragment = fragment =>
this.setState(state => ({
turnOffInput: { ...state.turnOffInput, ...fragment },
@@ -31,6 +33,12 @@ class SettingsContainer extends React.Component {
this.props.updateNotificationSettings(this.state.turnOffInput);
};
+ getNeedEmailVerification() {
+ return !this.props.root.me.profiles.some(
+ profile => profile.provider === 'local' && profile.confirmedAt
+ );
+ }
+
render() {
return (
);
}
@@ -59,9 +69,22 @@ const enhance = compose(
fragment TalkNotifications_Settings_root on RootQuery {
__typename
${getSlotFragmentSpreads(slots, 'root')}
+ me {
+ email
+ profiles {
+ provider
+ ... on LocalUserProfile {
+ confirmedAt
+ }
+ }
+ }
}
`,
}),
+ excludeIf(
+ props =>
+ !props.root.me.profiles.some(profile => profile.provider === 'local')
+ ),
withUpdateNotificationSettings
);
diff --git a/plugins/talk-plugin-notifications/client/translations.yml b/plugins/talk-plugin-notifications/client/translations.yml
index 9088e6493..0d74114d2 100644
--- a/plugins/talk-plugin-notifications/client/translations.yml
+++ b/plugins/talk-plugin-notifications/client/translations.yml
@@ -3,3 +3,13 @@ en:
settings_title: Notifications
settings_subtitle: Receive notifications when
turn_off_all: I do not want to receive notifications
+ banner_info:
+ title: Email Verification Required
+ text: To receive email notifications you must have a verified email address.
+ verify_now: Verify your email now
+ banner_success:
+ title: Email Verification Sent
+ text: An email has been sent to {0} containing a verification link.
+ banner_error:
+ title: Error
+ text: There has been an error sending your verification email. Please try again later.
diff --git a/plugins/talk-plugin-notifications/server/NotificationManager.js b/plugins/talk-plugin-notifications/server/NotificationManager.js
index 6c1d2556d..6b53d33a7 100644
--- a/plugins/talk-plugin-notifications/server/NotificationManager.js
+++ b/plugins/talk-plugin-notifications/server/NotificationManager.js
@@ -1,7 +1,10 @@
-const { groupBy, forEach, property } = require('lodash');
+const { get, find, groupBy, forEach, property } = require('lodash');
const debug = require('debug')('talk-plugin-notifications');
const uuid = require('uuid/v4');
-const { UNSUBSCRIBE_SUBJECT } = require('./config');
+const {
+ UNSUBSCRIBE_SUBJECT,
+ DISABLE_REQUIRE_EMAIL_VERIFICATIONS,
+} = require('./config');
// handleHandlers will call the handle method on each handler to determine if a
// notification should be sent for it.
@@ -15,12 +18,12 @@ const handleHandlers = (ctx, handlers, ...args) =>
// Attempt to create a notification out of it.
const notification = await handle(ctx, ...args);
if (!notification) {
- ctx.log.debug('no notification deemed by event handler');
+ ctx.log.info('no notification deemed by event handler');
return;
}
// Send the notification back.
- ctx.log.debug({ category, event }, 'notification detected for event');
+ ctx.log.info({ category, event }, 'notification detected for event');
return { handler, notification };
} catch (err) {
ctx.log.error({ err }, 'could not handle the event');
@@ -31,13 +34,91 @@ const handleHandlers = (ctx, handlers, ...args) =>
// filterSuperseded will filter all the possible notifications and only send
// those notifications that are not superseded by another type of notification.
-const filterSuperseded = ({ handler: { category } }, index, notifications) =>
- !notifications.some(({ handler: { supersedesCategories = [] } }) =>
- supersedesCategories.some(
- supersededCategory => supersededCategory === category
- )
+const filterSuperseded = (
+ { handler: { category }, notification: { userID: destinationUserID } },
+ index,
+ notifications
+) =>
+ !notifications.some(
+ ({
+ handler: { supersedesCategories = [] },
+ notification: { userID: notificationUserID },
+ }) =>
+ // Only allow notifications to supersede another notification if that
+ // notification is also destined for the same user.
+ notificationUserID === destinationUserID &&
+ // If another notification that is destined for the same user also exists
+ // and declares that it supersedes this one, return true so we can filter
+ // this one from the list.
+ supersedesCategories.some(
+ supersededCategory => supersededCategory === category
+ )
);
+const USER_CONFIRMATION_QUERY = `
+ query CheckUserConfirmation($userID: ID!) {
+ user(id: $userID) {
+ profiles {
+ provider
+ ... on LocalUserProfile {
+ confirmedAt
+ }
+ }
+ }
+ }
+`;
+
+// filterVerifiedNotification checks to see if a user has a verified email
+// address, and if they do, returns the notification payload again, otherwise,
+// returns undefined.
+const filterVerifiedNotification = ctx => async notification => {
+ // Grab the user that we're supposed to be sending the notification to.
+ const { notification: { userID } } = notification;
+
+ // Check their confirmed status. This should have already been hit by the
+ // loaders, so we shouldn't make any more database requests.
+ const { errors, data } = await ctx.graphql(USER_CONFIRMATION_QUERY, {
+ userID,
+ });
+ if (errors) {
+ ctx.log.error(
+ { err: errors },
+ 'could not query for user confirmation status'
+ );
+ return;
+ }
+
+ // Get the first local profile from the user.
+ const profile = find(get(data, 'user.profiles', []), ['provider', 'local']);
+ if (!profile) {
+ ctx.log.warn({ user_id: userID }, 'user did not have a local profile');
+ return;
+ }
+
+ // Pull out the confirmed status from the profile.
+ const confirmed = get(profile, 'confirmedAt', null) !== null;
+ if (!confirmed) {
+ ctx.log.info(
+ { user_id: userID },
+ 'user did not have their local profile confirmed, but had settings enabled, not mailing'
+ );
+ return;
+ }
+
+ return notification;
+};
+
+// filterVerified performs filtering in a complicated way because we can't use
+// Promise.all on a Array.prototype.filter call.
+const filterVerified = async (ctx, notifications) => {
+ notifications = await Promise.all(
+ notifications.map(filterVerifiedNotification(ctx))
+ );
+
+ // This acts as a poor-mans identity filter to remove all falsy values.
+ return notifications.filter(property('notification'));
+};
+
class NotificationManager {
constructor(context) {
this.context = context;
@@ -93,6 +174,12 @@ class NotificationManager {
// had this notification superseded.
notifications = notifications.filter(filterSuperseded);
+ // Only let notifications through for users who have their email addresses
+ // verified if we are configured to do so.
+ if (!DISABLE_REQUIRE_EMAIL_VERIFICATIONS) {
+ notifications = await filterVerified(ctx, notifications);
+ }
+
// Send the remaining notifications.
return Promise.all(
notifications.map(
@@ -120,7 +207,7 @@ class NotificationManager {
'organizationName'
);
if (organizationName === null) {
- ctx.log.debug(
+ ctx.log.error(
'could not send the notification, organization name not in settings'
);
return;
@@ -153,7 +240,7 @@ class NotificationManager {
user: userID,
});
- ctx.log.debug(`Sent the notification for Job.ID[${task.id}]`);
+ ctx.log.info(`Sent the notification for Job.ID[${task.id}]`);
} catch (err) {
ctx.log.error(
{ err, message: err.message },
@@ -172,10 +259,10 @@ class NotificationManager {
*/
async getBody(ctx, handler, context) {
const { connectors: { services: { I18n: { t } } } } = ctx;
- const { category } = handler;
+ const { category, hydrate = () => [] } = handler;
// Get the body replacement variables for the translation key.
- const replacements = await handler.hydrate(ctx, category, context);
+ const replacements = await hydrate(ctx, category, context);
// Generate the body.
return t(
diff --git a/plugins/talk-plugin-notifications/server/config.js b/plugins/talk-plugin-notifications/server/config.js
index 17bd94bf9..c1c0ec50f 100644
--- a/plugins/talk-plugin-notifications/server/config.js
+++ b/plugins/talk-plugin-notifications/server/config.js
@@ -1,3 +1,8 @@
module.exports = {
UNSUBSCRIBE_SUBJECT: 'nunsub',
+
+ // TODO: replace this with a config option in the plugin config when we get there..
+ DISABLE_REQUIRE_EMAIL_VERIFICATIONS:
+ process.env.TALK_DISABLE_REQUIRE_EMAIL_VERIFICATIONS_NOTIFICATIONS ===
+ 'TRUE',
};
diff --git a/plugins/talk-plugin-notifications/server/resolvers.js b/plugins/talk-plugin-notifications/server/resolvers.js
index 0254361de..3e6aae3dc 100644
--- a/plugins/talk-plugin-notifications/server/resolvers.js
+++ b/plugins/talk-plugin-notifications/server/resolvers.js
@@ -1,4 +1,5 @@
const { get } = require('lodash');
+const { DISABLE_REQUIRE_EMAIL_VERIFICATIONS } = require('./config');
module.exports = {
User: {
@@ -16,4 +17,8 @@ module.exports = {
await User.updateNotificationSettings(input);
},
},
+ Settings: {
+ notificationsRequireConfirmation: () =>
+ Boolean(!DISABLE_REQUIRE_EMAIL_VERIFICATIONS),
+ },
};
diff --git a/plugins/talk-plugin-notifications/server/typeDefs.graphql b/plugins/talk-plugin-notifications/server/typeDefs.graphql
index 6b2a43b2e..e2e575e25 100644
--- a/plugins/talk-plugin-notifications/server/typeDefs.graphql
+++ b/plugins/talk-plugin-notifications/server/typeDefs.graphql
@@ -5,6 +5,13 @@ type User {
notificationSettings: NotificationSettings
}
+type Settings {
+ # notificationsRequireConfirmation when true indicates that User's must have
+ # their email address confirmed/verified before they can receive
+ # notifications.
+ notificationsRequireConfirmation: Boolean
+}
+
type UpdateNotificationSettingsResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
diff --git a/services/logging.js b/services/logging.js
index 48367f834..9aad098ab 100644
--- a/services/logging.js
+++ b/services/logging.js
@@ -1,6 +1,7 @@
const { version } = require('../package.json');
const Logger = require('bunyan');
const uuid = require('uuid/v1');
+const { LOGGING_LEVEL } = require('../config');
// Create the logging instance that all logger's are branched from.
function createLogger(name, id = uuid()) {
@@ -9,6 +10,7 @@ function createLogger(name, id = uuid()) {
name,
id,
version,
+ level: LOGGING_LEVEL,
serializers: { req: Logger.stdSerializers.req },
});
}