@@ -14,5 +14,3 @@ export default ({children, restricted, message = t('framework.content_not_availa
);
}
};
-
-const messageBox = (message) =>
{message}
;
diff --git a/client/coral-framework/components/RestrictedMessageBox.css b/client/coral-framework/components/RestrictedMessageBox.css
new file mode 100644
index 000000000..94b193a17
--- /dev/null
+++ b/client/coral-framework/components/RestrictedMessageBox.css
@@ -0,0 +1,6 @@
+.message {
+ background: #D8D8D8;
+ padding: 25px;
+ margin-bottom: 8px;
+}
+
diff --git a/client/coral-framework/components/RestrictedMessageBox.js b/client/coral-framework/components/RestrictedMessageBox.js
new file mode 100644
index 000000000..c579a4731
--- /dev/null
+++ b/client/coral-framework/components/RestrictedMessageBox.js
@@ -0,0 +1,4 @@
+import React from 'react';
+import styles from './RestrictedMessageBox.css';
+
+export default ({children}) =>
{children}
;
diff --git a/client/coral-framework/locales/en.yml b/client/coral-framework/locales/en.yml
index fd3b8a3e3..13809693f 100644
--- a/client/coral-framework/locales/en.yml
+++ b/client/coral-framework/locales/en.yml
@@ -52,6 +52,7 @@ en:
people: People
role: "Select role..."
select-status: "Select status..."
+ staff: "Staff"
status: Status
username_and_email: "Username and Email"
yes_ban_user: "Yes Ban User"
@@ -306,8 +307,10 @@ en:
description_1: "Suspending this user will temporarily disable their account and hide all of their comments on the site."
email: "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment like or flag content until you rewrite your username. Please e-mail us if you have any questions or concerns."
email_subject: "Your account has been suspended"
+ email_message_reject: "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please e-mail us if you have any questions or concerns."
no_cancel: "No cancel"
send: Send
+ suspend_user: "Suspend User"
title: "Suspend a user"
title_0: "We noticed you rejected a username"
title_1: "Notify the user of their temporary suspension"
diff --git a/client/coral-framework/reducers/auth.js b/client/coral-framework/reducers/auth.js
index 0796f7574..8fa4ed75b 100644
--- a/client/coral-framework/reducers/auth.js
+++ b/client/coral-framework/reducers/auth.js
@@ -4,7 +4,6 @@ import * as actions from '../constants/auth';
const initialState = Map({
isLoading: false,
loggedIn: false,
- isAdmin: false,
user: null,
showSignInDialog: false,
showCreateUsernameDialog: false,
@@ -76,12 +75,10 @@ export default function auth (state = initialState, action) {
return state
.set('checkedInitialLogin', true)
.set('loggedIn', true)
- .set('isAdmin', action.isAdmin)
.set('user', purge(action.user));
case actions.FETCH_SIGNIN_SUCCESS:
return state
.set('loggedIn', true)
- .set('isAdmin', action.isAdmin)
.set('user', purge(action.user));
case actions.FETCH_SIGNIN_FAILURE:
return state
@@ -117,8 +114,7 @@ export default function auth (state = initialState, action) {
return state
.set('user', null)
.set('isLoading', false)
- .set('loggedIn', false)
- .set('isAdmin', false);
+ .set('loggedIn', false);
case actions.INVALID_FORM:
return state
.set('error', action.error);
diff --git a/client/coral-framework/services/perms.js b/client/coral-framework/services/perms.js
new file mode 100644
index 000000000..e93744506
--- /dev/null
+++ b/client/coral-framework/services/perms.js
@@ -0,0 +1,34 @@
+import intersection from 'lodash/intersection';
+
+const basicRoles = {
+ HAS_STAFF_TAG: ['ADMIN', 'MODERATOR', 'STAFF']
+};
+
+const queryRoles = {
+ UPDATE_CONFIG: ['ADMIN'],
+ ACCESS_ADMIN: ['ADMIN', 'MODERATOR'],
+ VIEW_USER_EMAILS: ['ADMIN']
+};
+
+const mutationRoles = {
+ CHANGE_ROLES: ['ADMIN'],
+ MODERATE_COMMENTS: ['ADMIN', 'MODERATOR']
+};
+
+const roles = {...basicRoles, ...queryRoles, ...mutationRoles};
+
+export const can = (user, ...perms) => {
+
+ if (!user) {
+ return false;
+ }
+
+ return perms.every((perm) => {
+ const role = roles[perm];
+ if (typeof role === 'undefined') {
+ throw new Error(`${perm} is not a valid role`);
+ }
+
+ return intersection(role, user.roles).length > 0;
+ });
+};
diff --git a/client/coral-framework/utils/index.js b/client/coral-framework/utils/index.js
index 512759e98..8fb9cbf81 100644
--- a/client/coral-framework/utils/index.js
+++ b/client/coral-framework/utils/index.js
@@ -64,10 +64,39 @@ export function separateDataAndRoot(
};
}
+/**
+ * Taken from: http://stackoverflow.com/questions/1197928/how-to-add-30-minutes-to-a-javascript-date-object.
+ * Adds time to a date. Modelled after MySQL DATE_ADD function.
+ * Example: dateAdd(new Date(), 'minute', 30) //returns 30 minutes from now.
+ *
+ * @param date Date to start with
+ * @param interval One of: year, quarter, month, week, day, hour, minute, second
+ * @param units Number of units of the given interval to add.
+ */
+export function dateAdd(date, interval, units) {
+ let ret = new Date(date); // don't change original date
+ const checkRollover = () => {
+ if (ret.getDate() !== date.getDate()) {
+ ret.setDate(0);
+ }
+ };
+ switch(interval.toLowerCase()) {
+ case 'year' : ret.setFullYear(ret.getFullYear() + units); checkRollover(); break;
+ case 'quarter': ret.setMonth(ret.getMonth() + 3 * units); checkRollover(); break;
+ case 'month' : ret.setMonth(ret.getMonth() + units); checkRollover(); break;
+ case 'week' : ret.setDate(ret.getDate() + 7 * units); break;
+ case 'day' : ret.setDate(ret.getDate() + units); break;
+ case 'hour' : ret.setTime(ret.getTime() + units * 3600000); break;
+ case 'minute' : ret.setTime(ret.getTime() + units * 60000); break;
+ case 'second' : ret.setTime(ret.getTime() + units * 1000); break;
+ default : ret = undefined; break;
+ }
+ return ret;
+}
+
export function mergeDocuments(documents) {
const main = typeof documents[0] === 'string' ? documents[0] : documents[0].loc.source.body;
const substitutions = documents.slice(1);
const literals = [main, ...substitutions.map(() => '\n')];
return gql.apply(null, [literals, ...substitutions]);
}
-
diff --git a/client/coral-sign-in/containers/ChangeUsernameContainer.js b/client/coral-sign-in/containers/ChangeUsernameContainer.js
index d9cffa962..a08359b3f 100644
--- a/client/coral-sign-in/containers/ChangeUsernameContainer.js
+++ b/client/coral-sign-in/containers/ChangeUsernameContainer.js
@@ -102,7 +102,7 @@ class ChangeUsernameContainer extends Component {
return (
.icon {
+ margin-right: 5px;
+ font-size: 14px;
+ }
}
.full {
diff --git a/client/coral-ui/components/Button.js b/client/coral-ui/components/Button.js
index 52d806adb..6d3dc94d2 100644
--- a/client/coral-ui/components/Button.js
+++ b/client/coral-ui/components/Button.js
@@ -13,7 +13,7 @@ const Button = ({cStyle = 'local', children, className, raised = false, full = f
`}
{...props}
>
- {icon && }
+ {icon && }
{children}
);
diff --git a/graph/loaders/comments.js b/graph/loaders/comments.js
index 96f0e8f73..92483c0f7 100644
--- a/graph/loaders/comments.js
+++ b/graph/loaders/comments.js
@@ -4,6 +4,10 @@ const {
arrayJoinBy
} = require('./util');
const DataLoader = require('dataloader');
+const {
+ SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS,
+ SEARCH_OTHERS_COMMENTS
+} = require('../../perms/constants');
const CommentModel = require('../../models/comment');
const UsersService = require('../../services/users');
@@ -230,7 +234,7 @@ const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, a
// Only administrators can search for comments with statuses that are not
// `null`, or `'ACCEPTED'`.
- if (user != null && user.hasRoles('ADMIN') && statuses) {
+ if (user != null && user.can(SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS) && statuses) {
comments = comments.where({
status: {
$in: statuses
@@ -253,7 +257,7 @@ const getCommentsByQuery = async ({user}, {ids, statuses, asset_id, parent_id, a
}
// Only let an admin request any user or the current user request themself.
- if (user && (user.hasRoles('ADMIN') || user.id === author_id) && author_id != null) {
+ if (user && (user.can(SEARCH_OTHERS_COMMENTS) || user.id === author_id) && author_id != null) {
comments = comments.where({author_id});
}
@@ -403,7 +407,7 @@ const genRecentComments = (_, ids) => {
*/
const genComments = ({user}, ids) => {
let comments;
- if (user && user.hasRoles('ADMIN')) {
+ if (user && user.can(SEARCH_OTHERS_COMMENTS)) {
comments = CommentModel.find({
id: {
$in: ids
diff --git a/graph/mutators/action.js b/graph/mutators/action.js
index c5f225e33..0d1cd49f8 100644
--- a/graph/mutators/action.js
+++ b/graph/mutators/action.js
@@ -2,6 +2,7 @@ const ActionModel = require('../../models/action');
const ActionsService = require('../../services/actions');
const UsersService = require('../../services/users');
const errors = require('../../errors');
+const {CREATE_ACTION, DELETE_ACTION} = require('../../perms/constants');
/**
* Creates an action on a item. If the item is a user flag, sets the user's status to
@@ -45,7 +46,7 @@ const deleteAction = ({user}, {id}) => {
};
module.exports = (context) => {
- if (context.user && context.user.can('mutation:createAction', 'mutation:deleteAction')) {
+ if (context.user && context.user.can(CREATE_ACTION, DELETE_ACTION)) {
return {
Action: {
create: (action) => createAction(context, action),
diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js
index 621494db0..8acb2f8ed 100644
--- a/graph/mutators/comment.js
+++ b/graph/mutators/comment.js
@@ -9,6 +9,13 @@ const KarmaService = require('../../services/karma');
const linkify = require('linkify-it')();
const Wordlist = require('../../services/wordlist');
+const {
+ CREATE_COMMENT,
+ SET_COMMENT_STATUS,
+ ADD_COMMENT_TAG,
+ REMOVE_COMMENT_TAG,
+ EDIT_COMMENT
+} = require('../../perms/constants');
/**
* adjustKarma will adjust the affected user's karma depending on the moderators
@@ -101,7 +108,7 @@ const createComment = async ({user, loaders: {Comments}, pubsub}, {body, asset_i
tags = tags.map((tag) => ({name: tag}));
// If admin or moderator, adding STAFF tag
- if (user.hasRoles('ADMIN') || user.hasRoles('MODERATOR')) {
+ if (user.isStaff()) {
tags.push({name: 'STAFF'});
}
@@ -179,7 +186,7 @@ const resolveNewCommentStatus = async (context, {asset_id, body}, wordlist = {},
if (wordlist.banned) {
return 'REJECTED';
}
-
+
if (settings.premodLinksEnable && linkify.test(body)) {
return 'PREMOD';
}
@@ -328,7 +335,7 @@ const edit = async (context, {id, asset_id, edit: {body}}) => {
const [wordlist, settings] = await filterNewComment(context, {asset_id, body});
// Determine the new status of the comment.
- const status = await resolveNewCommentStatus(context, {asset_id, body}, wordlist, settings);
+ const status = await resolveNewCommentStatus(context, {asset_id, body}, wordlist, settings);
// Execute the edit.
await CommentsService.edit(id, context.user.id, {body, status});
@@ -347,23 +354,23 @@ module.exports = (context) => {
}
};
- if (context.user && context.user.can('mutation:createComment')) {
+ if (context.user && context.user.can(CREATE_COMMENT)) {
mutators.Comment.create = (comment) => createPublicComment(context, comment);
}
- if (context.user && context.user.can('mutation:setCommentStatus')) {
+ if (context.user && context.user.can(SET_COMMENT_STATUS)) {
mutators.Comment.setStatus = (action) => setStatus(context, action);
}
- if (context.user && context.user.can('mutation:addCommentTag')) {
+ if (context.user && context.user.can(ADD_COMMENT_TAG)) {
mutators.Comment.addCommentTag = (action) => addCommentTag(context, action);
}
- if (context.user && context.user.can('mutation:removeCommentTag')) {
+ if (context.user && context.user.can(REMOVE_COMMENT_TAG)) {
mutators.Comment.removeCommentTag = (action) => removeCommentTag(context, action);
}
- if (context.user && context.user.can('mutation:editComment')) {
+ if (context.user && context.user.can(EDIT_COMMENT)) {
mutators.Comment.edit = (action) => edit(context, action);
}
diff --git a/graph/mutators/user.js b/graph/mutators/user.js
index 2cc1962be..cf0106a2c 100644
--- a/graph/mutators/user.js
+++ b/graph/mutators/user.js
@@ -1,12 +1,17 @@
const errors = require('../../errors');
const UsersService = require('../../services/users');
+const {SET_USER_STATUS, SUSPEND_USER, REJECT_USERNAME} = require('../../perms/constants');
const setUserStatus = ({user}, {id, status}) => {
return UsersService.setStatus(id, status);
};
-const suspendUser = ({user}, {id, message}) => {
- return UsersService.suspendUser(id, message);
+const suspendUser = ({user}, {id, message, until}) => {
+ return UsersService.suspendUser(id, message, until);
+};
+
+const rejectUsername = ({user}, {id, message}) => {
+ return UsersService.rejectUsername(id, message);
};
const ignoreUser = ({user}, userToIgnore) => {
@@ -22,18 +27,23 @@ module.exports = (context) => {
User: {
setUserStatus: () => Promise.reject(errors.ErrNotAuthorized),
suspendUser: () => Promise.reject(errors.ErrNotAuthorized),
+ rejectUsername: () => Promise.reject(errors.ErrNotAuthorized),
ignoreUser: (action) => ignoreUser(context, action),
stopIgnoringUser: (action) => stopIgnoringUser(context, action),
}
};
- if (context.user && context.user.can('mutation:setUserStatus')) {
+ if (context.user && context.user.can(SET_USER_STATUS)) {
mutators.User.setUserStatus = (action) => setUserStatus(context, action);
}
- if (context.user && context.user.can('mutation:suspendUser')) {
+ if (context.user && context.user.can(SUSPEND_USER)) {
mutators.User.suspendUser = (action) => suspendUser(context, action);
}
+ if (context.user && context.user.can(REJECT_USERNAME)) {
+ mutators.User.rejectUsername = (action) => rejectUsername(context, action);
+ }
+
return mutators;
};
diff --git a/graph/resolvers/action.js b/graph/resolvers/action.js
index 393024fe1..8ca05daf8 100644
--- a/graph/resolvers/action.js
+++ b/graph/resolvers/action.js
@@ -1,3 +1,5 @@
+const {SEARCH_OTHER_USERS} = require('../../perms/constants');
+
const Action = {
__resolveType({action_type}) {
switch (action_type) {
@@ -11,7 +13,7 @@ const Action = {
// This will load the user for the specific action. We'll limit this to the
// admin users only or the current logged in user.
user({user_id}, _, {loaders: {Users}, user}) {
- if (user && (user.hasRole('ADMIN') || user_id === user.id)) {
+ if (user && (user.can(SEARCH_OTHER_USERS) || user_id === user.id)) {
return Users.getByID.load(user_id);
}
}
diff --git a/graph/resolvers/comment.js b/graph/resolvers/comment.js
index 6f3313356..2e8412169 100644
--- a/graph/resolvers/comment.js
+++ b/graph/resolvers/comment.js
@@ -25,14 +25,13 @@ const Comment = {
// TODO: remove
if (user && excludeIgnored) {
- return Comments.countByParentIDPersonalized({id, excludeIgnored});
+ return Comments.countByParentIDPersonalized({id, excludeIgnored});
}
- return Comments.countByParentID.load(id);
+ return Comments.countByParentID.load(id);
},
actions({id}, _, {user, loaders: {Actions}}) {
- // Only return the actions if the user is not an admin.
- if (user && user.hasRoles('ADMIN')) {
+ if (user && user.can('SEARCH_ACTIONS')) {
return Actions.getByID.load(id);
}
diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js
index 2abc88f0d..e17cdcc8a 100644
--- a/graph/resolvers/root_mutation.js
+++ b/graph/resolvers/root_mutation.js
@@ -20,8 +20,11 @@ const RootMutation = {
setUserStatus(_, {id, status}, {mutators: {User}}) {
return wrapResponse(null)(User.setUserStatus({id, status}));
},
- suspendUser(_, {id, message}, {mutators: {User}}) {
- return wrapResponse(null)(User.suspendUser({id, message}));
+ suspendUser(_, {input: {id, message, until}}, {mutators: {User}}) {
+ return wrapResponse(null)(User.suspendUser({id, message, until}));
+ },
+ rejectUsername(_, {input: {id, message}}, {mutators: {User}}) {
+ return wrapResponse(null)(User.rejectUsername({id, message}));
},
ignoreUser(_, {id}, {mutators: {User}}) {
return wrapResponse(null)(User.ignoreUser({id}));
diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js
index 8c1552b5f..9f3a5e157 100644
--- a/graph/resolvers/root_query.js
+++ b/graph/resolvers/root_query.js
@@ -1,6 +1,13 @@
+const {
+ SEARCH_ASSETS,
+ SEARCH_OTHERS_COMMENTS,
+ SEARCH_COMMENT_METRICS,
+ SEARCH_OTHER_USERS
+} = require('../../perms/constants');
+
const RootQuery = {
assets(_, args, {loaders: {Assets}, user}) {
- if (user == null || !user.hasRoles('ADMIN')) {
+ if (user == null || !user.can(SEARCH_ASSETS)) {
return null;
}
@@ -22,7 +29,7 @@ const RootQuery = {
async comments(_, {query}, {user, loaders: {Comments, Actions}}) {
let {action_type} = query;
- if (user != null && user.hasRoles('ADMIN') && action_type) {
+ if (user != null && user.can(SEARCH_OTHERS_COMMENTS) && action_type) {
query.ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
}
@@ -34,7 +41,7 @@ const RootQuery = {
},
async commentCount(_, {query}, {user, loaders: {Actions, Comments}}) {
- if (user == null || !user.hasRoles('ADMIN')) {
+ if (user == null || !user.can(SEARCH_OTHERS_COMMENTS)) {
return null;
}
@@ -48,7 +55,7 @@ const RootQuery = {
},
assetMetrics(_, {from, to, sort, limit = 10}, {user, loaders: {Metrics: {Assets}}}) {
- if (user == null || !user.hasRoles('ADMIN')) {
+ if (user == null || !user.can(SEARCH_ASSETS)) {
return null;
}
@@ -60,7 +67,7 @@ const RootQuery = {
},
commentMetrics(_, {from, to, sort, limit = 10}, {user, loaders: {Metrics: {Comments}}}) {
- if (user == null || !user.hasRoles('ADMIN')) {
+ if (user == null || !user.can(SEARCH_COMMENT_METRICS)) {
return null;
}
@@ -89,7 +96,7 @@ const RootQuery = {
// This endpoint is used for loading the user moderation queues (users whose username has been flagged),
// so hide it in the event that we aren't an admin.
async users(_, {query}, {user, loaders: {Users, Actions}}) {
- if (user == null || !user.hasRoles('ADMIN')) {
+ if (user == null || !user.can(SEARCH_OTHER_USERS)) {
return null;
}
diff --git a/graph/resolvers/user.js b/graph/resolvers/user.js
index e7c0db20c..d3254588e 100644
--- a/graph/resolvers/user.js
+++ b/graph/resolvers/user.js
@@ -1,4 +1,5 @@
const KarmaService = require('../../services/karma');
+const {SEARCH_ACTIONS, SEARCH_OTHERS_COMMENTS, UPDATE_USER_ROLES} = require('../../perms/constants');
const User = {
action_summaries({id}, _, {loaders: {Actions}}) {
@@ -7,7 +8,7 @@ const User = {
actions({id}, _, {user, loaders: {Actions}}) {
// Only return the actions if the user is not an admin.
- if (user && user.hasRoles('ADMIN')) {
+ if (user && user.can(SEARCH_ACTIONS)) {
return Actions.getByID.load(id);
}
@@ -23,7 +24,7 @@ const User = {
// If the user is not an admin, only return comment list for the owner of
// the comments.
- if (user && (user.hasRoles('ADMIN') || user.id === id)) {
+ if (user && (user.can(SEARCH_OTHERS_COMMENTS) || user.id === id)) {
return Comments.getByQuery({author_id: id, sort: 'REVERSE_CHRONOLOGICAL'});
}
@@ -56,7 +57,7 @@ const User = {
roles({id, roles}, _, {user}) {
// If the user is not an admin, only return the current user's roles.
- if (user && (user.hasRoles('ADMIN') || user.id === id)) {
+ if (user && (user.can(UPDATE_USER_ROLES) || user.id === id)) {
return roles;
}
diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql
index b11889865..1abb894b8 100644
--- a/graph/typeDefs.graphql
+++ b/graph/typeDefs.graphql
@@ -443,6 +443,7 @@ type Settings {
charCountEnable: Boolean
charCount: Int
+ organizationName: String
}
################################################################################
@@ -711,6 +712,29 @@ input CreateDontAgreeInput {
message: String
}
+# Input for suspendUser mutation.
+input SuspendUserInput {
+
+ # id of target user.
+ id: ID!
+
+ # message to be sent to the user.
+ message: String!
+
+ # target user will be suspended until this date.
+ until: Date!
+}
+
+# Input for rejectUsername mutation.
+input RejectUsernameInput {
+
+ # id of target user.
+ id: ID!
+
+ # message to be sent to the user.
+ message: String!
+}
+
# DeleteActionResponse is the response returned with possibly some errors
# relating to the delete action attempt.
type DeleteActionResponse implements Response {
@@ -735,6 +759,14 @@ type SuspendUserResponse implements Response {
errors: [UserError]
}
+# RejectUsernameResponse is the response returned with possibly some errors
+# relating to the reject username action attempt.
+type RejectUsernameResponse implements Response {
+
+ # An array of errors relating to the mutation that occurred.
+ errors: [UserError]
+}
+
# SetCommentStatusResponse is the response returned with possibly some errors
# relating to the delete action attempt.
type SetCommentStatusResponse implements Response {
@@ -807,8 +839,11 @@ type RootMutation {
# Sets User status. Requires the `ADMIN` role.
setUserStatus(id: ID!, status: USER_STATUS!): SetUserStatusResponse
- # Sets User status to BANNED and canEditName to true. It sends a message to the banned User. Requires the `ADMIN` role.
- suspendUser(id: ID!, message: String): SuspendUserResponse
+ # Suspends a user. Requires the `ADMIN` role.
+ suspendUser(input: SuspendUserInput!): SuspendUserResponse
+
+ # Suspends a user. Requires the `ADMIN` role.
+ rejectUsername(input: RejectUsernameInput!): RejectUsernameResponse
# Sets Comment status. Requires the `ADMIN` role.
setCommentStatus(id: ID!, status: COMMENT_STATUS!): SetCommentStatusResponse
diff --git a/models/user.js b/models/user.js
index 56f750d34..27cecaef9 100644
--- a/models/user.js
+++ b/models/user.js
@@ -1,11 +1,14 @@
const mongoose = require('../services/mongoose');
const bcrypt = require('bcrypt');
const uuid = require('uuid');
+const intersection = require('lodash/intersection');
+const can = require('../perms');
// USER_ROLES is the array of roles that is permissible as a user role.
const USER_ROLES = [
'ADMIN',
- 'MODERATOR'
+ 'MODERATOR',
+ 'STAFF'
];
// USER_STATUS is the list of statuses that are permitted for the user status.
@@ -111,6 +114,14 @@ const UserSchema = new mongoose.Schema({
default: false
},
+ // User's suspension details.
+ suspension: {
+ until: {
+ type: Date,
+ default: null,
+ },
+ },
+
// User's settings
settings: {
bio: {
@@ -157,14 +168,10 @@ UserSchema.index({
});
/**
- * Returns true if the user has all the roles specified.
+ * returns true if a commenter is staff
*/
-UserSchema.method('hasRoles', function(...roles) {
- return roles.every((role) => {
-
- // TODO: remove toUpperCase() once we've migrated usage.
- return this.roles.indexOf(role.toUpperCase()) >= 0;
- });
+UserSchema.method('isStaff', function () {
+ return intersection(['ADMIN', 'MODERATOR', 'STAFF'], this.roles).length !== 0;
});
/**
@@ -186,47 +193,12 @@ UserSchema.method('verifyPassword', function(password) {
});
});
-/**
- * All the graph operations that are available for a user.
- * @type {Array}
- */
-const USER_GRAPH_OPERATIONS = [
- 'mutation:createComment',
- 'mutation:createAction',
- 'mutation:deleteAction',
- 'mutation:editName',
- 'mutation:setUserStatus',
- 'mutation:suspendUser',
- 'mutation:setCommentStatus',
- 'mutation:addCommentTag',
- 'mutation:removeCommentTag',
- 'mutation:editComment'
-];
-
/**
* Can returns true if the user is allowed to perform a specific graph
* operation.
*/
UserSchema.method('can', function(...actions) {
- if (actions.some((action) => USER_GRAPH_OPERATIONS.indexOf(action) === -1)) {
- throw new Error(`invalid actions: ${actions}`);
- }
-
- if (this.status === 'BANNED') {
- return false;
- }
-
- if (actions.some((action) => action === 'mutation:setUserStatus' || action === 'mutation:suspendUser' || action === 'mutation:setCommentStatus') && !this.hasRoles('ADMIN')) {
- return false;
- }
-
- // {add,remove}CommentTag - requires admin and/or moderator role
- const userCanModifyTags = (user) => ['ADMIN', 'MODERATOR'].some((r) => user.hasRoles(r));
- if (actions.some((a) => ['mutation:removeCommentTag', 'mutation:addCommentTag'].includes(a)) && !userCanModifyTags(this)) {
- return false;
- }
-
- return true;
+ return can(this, ...actions);
});
// Create the User model.
diff --git a/package.json b/package.json
index 23ceeb447..05d6f873b 100644
--- a/package.json
+++ b/package.json
@@ -100,6 +100,7 @@
"prop-types": "^15.5.8",
"react-apollo": "^1.1.0",
"react-recaptcha": "^2.2.6",
+ "react-toastify": "^1.5.0",
"recompose": "^0.23.1",
"redis": "^2.7.1",
"resolve": "^1.3.2",
diff --git a/perms/constants.js b/perms/constants.js
new file mode 100644
index 000000000..2b5b907ac
--- /dev/null
+++ b/perms/constants.js
@@ -0,0 +1,25 @@
+module.exports = {
+
+ // mutations
+ CREATE_COMMENT: 'CREATE_COMMENT',
+ CREATE_ACTION: 'CREATE_ACTION',
+ DELETE_ACTION: 'DELETE_ACTION',
+ EDIT_NAME: 'EDIT_NAME',
+ EDIT_COMMENT: 'EDIT_COMMENT',
+ REJECT_USERNAME: 'REJECT_USERNAME',
+ SET_USER_STATUS: 'SET_USER_STATUS',
+ SUSPEND_USER: 'SUSPEND_USER',
+ SET_COMMENT_STATUS: 'SET_COMMENT_STATUS',
+ ADD_COMMENT_TAG: 'ADD_COMMENT_TAG',
+ REMOVE_COMMENT_TAG: 'REMOVE_COMMENT_TAG',
+ UPDATE_USER_ROLES: 'UPDATE_USER_ROLES',
+ UPDATE_CONFIG: 'UPDATE_CONFIG',
+
+ // queries
+ SEARCH_ASSETS: 'SEARCH_ASSETS',
+ SEARCH_OTHER_USERS: 'SEARCH_OTHER_USERS',
+ SEARCH_ACTIONS: 'SEARCH_ACTIONS',
+ SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS: 'SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS',
+ SEARCH_OTHERS_COMMENTS: 'SEARCH_OTHERS_COMMENTS',
+ SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS'
+};
diff --git a/perms/index.js b/perms/index.js
new file mode 100644
index 000000000..f0e14c5fc
--- /dev/null
+++ b/perms/index.js
@@ -0,0 +1,52 @@
+const constants = require('./constants');
+const root = require('./rootReducer');
+const queries = require('./queryReducer');
+const mutations = require('./mutationReducer');
+
+const reducers = [
+ root,
+ queries,
+ mutations
+];
+
+// this will make 'reducer' a key in this array. hm.
+const allPermissions = Object.keys(constants);
+
+const findGrant = (user, perms) => {
+
+ return perms.every((perm) => {
+
+ for (let key in reducers) {
+ const reducer = reducers[key];
+ const grant = reducer(user, perm);
+
+ if (grant !== null && typeof grant !== 'undefined') {
+ return grant;
+ }
+ }
+
+ return false;
+ });
+};
+
+/**
+ * returns true, false, or null depending on whether the user has those permissions
+ * throws an error if you pass a permission that's not known to the system
+ * @param {User} user the user making the request for db operations
+ * @param {[type]} context [description]
+ * @param {String/Array} perms a string an array of strings which are the names of the permissions
+ * @return {Boolean}
+ */
+module.exports = (user, ...perms) => {
+
+ // make sure all the passed permissions are not typos
+ const missingPerms = perms.filter((perm) => {
+ return allPermissions.indexOf(perm) === -1;
+ });
+
+ if (missingPerms.length > 0) {
+ throw new Error(`${missingPerms.join(' ')} are not valid permissions.`);
+ }
+
+ return findGrant(user, perms);
+};
diff --git a/perms/mutationReducer.js b/perms/mutationReducer.js
new file mode 100644
index 000000000..53cece51f
--- /dev/null
+++ b/perms/mutationReducer.js
@@ -0,0 +1,35 @@
+const {check} = require('./utils');
+const types = require('./constants');
+
+module.exports = (user, perm) => {
+ switch (perm) {
+ case types.CREATE_COMMENT:
+ return true;
+ case types.CREATE_ACTION:
+ return true;
+ case types.DELETE_ACTION:
+ return true;
+ case types.EDIT_NAME:
+ return true;
+ case types.EDIT_COMMENT:
+ return true;
+ case types.UPDATE_USER_ROLES:
+ return check(user, ['ADMIN']);
+ case types.REJECT_USERNAME:
+ return check(user, ['ADMIN', 'MODERATOR']);
+ case types.SET_USER_STATUS:
+ return check(user, ['ADMIN', 'MODERATOR']);
+ case types.SUSPEND_USER:
+ return check(user, ['ADMIN', 'MODERATOR']);
+ case types.SET_COMMENT_STATUS:
+ return check(user, ['ADMIN', 'MODERATOR']);
+ case types.ADD_COMMENT_TAG:
+ return check(user, ['ADMIN', 'MODERATOR']);
+ case types.REMOVE_COMMENT_TAG:
+ return check(user, ['ADMIN', 'MODERATOR']);
+ case types.UPDATE_CONFIG:
+ return check(user, ['ADMIN', 'MODERATOR']);
+ default:
+ break;
+ }
+};
diff --git a/perms/queryReducer.js b/perms/queryReducer.js
new file mode 100644
index 000000000..0e5054788
--- /dev/null
+++ b/perms/queryReducer.js
@@ -0,0 +1,21 @@
+const {check} = require('./utils');
+const types = require('./constants');
+
+module.exports = (user, perm) => {
+ switch (perm) {
+ case types.SEARCH_ASSETS:
+ return check(user, ['ADMIN', 'MODERATOR']);
+ case types.SEARCH_OTHER_USERS:
+ return check(user, ['ADMIN', 'MODERATOR']);
+ case types.SEARCH_ACTIONS:
+ return check(user, ['ADMIN', 'MODERATOR']);
+ case types.SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS:
+ return check(user, ['ADMIN', 'MODERATOR']);
+ case types.SEARCH_OTHERS_COMMENTS:
+ return check(user, ['ADMIN', 'MODERATOR']);
+ case types.SEARCH_COMMENT_METRICS:
+ return check(user, ['ADMIN', 'MODERATOR']);
+ default:
+ break;
+ }
+};
diff --git a/perms/rootReducer.js b/perms/rootReducer.js
new file mode 100644
index 000000000..7fb665654
--- /dev/null
+++ b/perms/rootReducer.js
@@ -0,0 +1,10 @@
+module.exports = (user /* , perm*/) => {
+
+ // this runs before everything
+ if (
+ user.status === 'BANNED' ||
+ (user.suspension.until && user.suspension.until > new Date())
+ ) {
+ return false;
+ }
+};
diff --git a/perms/utils.js b/perms/utils.js
new file mode 100644
index 000000000..e72a49c53
--- /dev/null
+++ b/perms/utils.js
@@ -0,0 +1,8 @@
+const intersection = require('lodash/intersection');
+const check = (user, roles) => {
+ return intersection(roles, user.roles).length > 0;
+};
+
+module.exports = {
+ check
+};
diff --git a/services/email/suspension.ejs b/services/email/suspension.ejs
deleted file mode 100644
index b36560ec5..000000000
--- a/services/email/suspension.ejs
+++ /dev/null
@@ -1 +0,0 @@
-<%= body %>
diff --git a/services/email/suspension.html.ejs b/services/email/suspension.html.ejs
index b36560ec5..4fd8f191c 100644
--- a/services/email/suspension.html.ejs
+++ b/services/email/suspension.html.ejs
@@ -1 +1 @@
-<%= body %>
+<%= body.replace(/\n/g, '
') %>
diff --git a/services/users.js b/services/users.js
index 0c83832ab..496db4528 100644
--- a/services/users.js
+++ b/services/users.js
@@ -389,13 +389,7 @@ module.exports = class UsersService {
return Promise.reject(new Error(`role ${role} is not supported`));
}
- return UserModel.update({
- id: id
- }, {
- $addToSet: {
- roles: role
- }
- });
+ return UserModel.update({id}, {$set: {roles: [role]}});
}
/**
@@ -450,28 +444,64 @@ module.exports = class UsersService {
}
/**
- * Suspend a user. It changes the status to BANNED and canEditName to True.
- * @param {String} id id of a user
- * @param {Function} done callback after the operation is complete
+ * Suspend a user until specified time.
+ * @param {String} id id of a user
+ * @param {String} message message to be send to the user
+ * @param {Date} until date until the suspension is valid.
*/
- static suspendUser(id, message) {
+ static suspendUser(id, message, until) {
+ return UserModel.findOneAndUpdate(
+ {id}, {
+ $set: {
+ suspension: {
+ until,
+ },
+ }
+ })
+ .then((user) => {
+ if (message) {
+ let localProfile = user.profiles.find((profile) => profile.provider === 'local');
+ if (localProfile) {
+ const options =
+ {
+ template: 'suspension', // needed to know which template to render!
+ locals: { // specifies the template locals.
+ body: message
+ },
+ subject: 'Your account has been suspended',
+ to: localProfile.id // This only works if the user has registered via e-mail.
+ // We may want a standard way to access a user's e-mail address in the future
+ };
+
+ return MailerService.sendSimple(options);
+ }
+ }
+ });
+ }
+
+ /**
+ * Reject username. It changes the status to BANNED and canEditName to True.
+ * @param {String} id id of a user
+ * @param {String} message message to be send to the user
+ * @param {Date} until date until the suspension is valid.
+ */
+ static rejectUsername(id, message) {
return UserModel.findOneAndUpdate({
id
}, {
$set: {
status: 'BANNED',
- canEditName: true
+ canEditName: true,
}
})
.then((user) => {
if (message) {
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
-
if (localProfile) {
const options =
{
template: 'suspension', // needed to know which template to render!
- locals: { // specifies the template locals.
+ locals: { // specifies the template locals.
body: message
},
subject: 'Email Suspension',
@@ -480,8 +510,6 @@ module.exports = class UsersService {
};
return MailerService.sendSimple(options);
- } else {
- return Promise.reject(errors.ErrMissingEmail);
}
}
});
@@ -813,7 +841,7 @@ module.exports = class UsersService {
username: username,
lowercaseUsername: username.toLowerCase(),
canEditName: false,
- status: 'PENDING'
+ status: 'PENDING',
}
})
.then((result) => {
diff --git a/test/server/graph/mutations/addCommentTag.js b/test/server/graph/mutations/addCommentTag.js
index f456cda20..5d2a5b46c 100644
--- a/test/server/graph/mutations/addCommentTag.js
+++ b/test/server/graph/mutations/addCommentTag.js
@@ -44,6 +44,7 @@ describe('graph.mutations.addCommentTag', () => {
Object.entries({
'anonymous': undefined,
'regular commenter': new UserModel({}),
+ 'staff': new UserModel({roles: ['STAFF']}),
'banned moderator': new UserModel({roles: ['MODERATOR'], status: 'BANNED'})
}).forEach(([ userDescription, user ]) => {
it(userDescription, async function () {
diff --git a/yarn.lock b/yarn.lock
index dca6a3b44..e1a5d1123 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1480,6 +1480,10 @@ chai@^3.5.0:
deep-eql "^0.1.3"
type-detect "^1.0.0"
+chain-function@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/chain-function/-/chain-function-1.0.0.tgz#0d4ab37e7e18ead0bdc47b920764118ce58733dc"
+
chalk@1.1.3, chalk@^1.0.0, chalk@^1.1.0, chalk@^1.1.1, chalk@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
@@ -2441,6 +2445,10 @@ doctypes@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/doctypes/-/doctypes-1.1.0.tgz#ea80b106a87538774e8a3a4a5afe293de489e0a9"
+dom-helpers@^3.2.0:
+ version "3.2.1"
+ resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.2.1.tgz#3203e07fed217bd1f424b019735582fc37b2825a"
+
dom-serializer@0, dom-serializer@~0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz#073c697546ce0780ce23be4a28e293e40bc30c82"
@@ -6862,6 +6870,22 @@ react-tagsinput@^3.14.0:
version "3.16.1"
resolved "https://registry.yarnpkg.com/react-tagsinput/-/react-tagsinput-3.16.1.tgz#dfb3bcbe5fc4430f60c145716c17cdc2613ce117"
+react-toastify@^1.5.0:
+ version "1.5.0"
+ resolved "https://registry.yarnpkg.com/react-toastify/-/react-toastify-1.5.0.tgz#e9857e0b5d640064e5ba6caf7a96bb1578273de7"
+ dependencies:
+ prop-types "^15.5.8"
+ react-transition-group "^1.1.2"
+
+react-transition-group@^1.1.2:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-1.1.3.tgz#5e02cf6e44a863314ff3c68a0c826c2d9d70b221"
+ dependencies:
+ chain-function "^1.0.0"
+ dom-helpers "^3.2.0"
+ prop-types "^15.5.6"
+ warning "^3.0.0"
+
react@^15.3.1, react@^15.4.2:
version "15.5.4"
resolved "https://registry.yarnpkg.com/react/-/react-15.5.4.tgz#fa83eb01506ab237cdc1c8c3b1cea8de012bf047"