@@ -17,4 +17,3 @@ export default ({children, restricted, message = lang.t('contentNotAvailable'),
}
};
-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/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/translations.json b/client/coral-framework/translations.json
index 2e4c405b4..2ea58e159 100644
--- a/client/coral-framework/translations.json
+++ b/client/coral-framework/translations.json
@@ -7,6 +7,8 @@
"successNameUpdate": "Your username has been updated",
"contentNotAvailable": "This content is not available",
"bannedAccountMsg": "Your account is currently suspended. This means that you cannot Like, Report, or write comments. Please contact us if you have any questions.",
+
+ "temporarilySuspended": "In accordance with {0}'s community guidlines, your account has been temporarily suspended. Please rejoin the conversation {1}.",
"editName": {
"msg": "Your account is currently suspended because your username has been deemed inappropriate. To restore your account, please enter a new username. Please contact us if you have any questions.",
"label": "New Username",
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-ui/components/Button.css b/client/coral-ui/components/Button.css
index 219b70a65..5100d44a4 100644
--- a/client/coral-ui/components/Button.css
+++ b/client/coral-ui/components/Button.css
@@ -25,12 +25,6 @@
letter-spacing: 0.7px;
font-weight: 400;
- i {
- margin-right: 13px;
- font-size: 18px;
- vertical-align: middle;
- }
-
&:disabled {
background: #E0E0E0;
color: #4f5c67;
@@ -38,11 +32,22 @@
}
}
+.icon {
+ margin-right: 13px;
+ font-size: 18px;
+ vertical-align: middle;
+}
+
.type--black {
- color: #E0E0E0;
+ color: white;
background: #212121;
}
+.type--white {
+ color: #212121;
+ background: white;
+}
+
.type--local {
background: #E0E0E0;
color: #212121;
@@ -163,7 +168,7 @@
cursor: not-allowed;
}
-.type--ban {
+.type--ban, .type--actions {
display: block;
color: #616161;
border: solid 1px rgba(97, 97, 97, 0.77);
@@ -179,11 +184,16 @@
font-size: 14px;
width: auto;
-&:hover {
- box-shadow: none;
- color: white;
- background-color: #616161;
- }
+ &:hover {
+ box-shadow: none;
+ color: white;
+ background-color: #616161;
+ }
+
+ > .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 76d822033..84d0fdf52 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 3cbe44a2b..728687248 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"