Merge pull request #585 from coralproject/multiple-roles

Multiple roles
This commit is contained in:
Riley Davis
2017-05-19 13:12:20 -06:00
committed by GitHub
31 changed files with 366 additions and 165 deletions
+2 -4
View File
@@ -20,8 +20,7 @@ export const handleLogin = (email, password, recaptchaResponse) => (dispatch) =>
return dispatch(checkLoginFailure('not logged in'));
}
dispatch(handleAuthToken(token));
const isAdmin = !!user.roles.filter((i) => i === 'ADMIN').length;
dispatch(checkLoginSuccess(user, isAdmin));
dispatch(checkLoginSuccess(user));
})
.catch((error) => {
if (error.translation_key === 'LOGIN_MAXIMUM_EXCEEDED') {
@@ -86,8 +85,7 @@ export const checkLogin = () => (dispatch) => {
return dispatch(checkLoginFailure('not logged in'));
}
const isAdmin = !!user.roles.filter((i) => i === 'ADMIN').length;
dispatch(checkLoginSuccess(user, isAdmin));
dispatch(checkLoginSuccess(user));
})
.catch((error) => {
console.error(error);
+28 -18
View File
@@ -4,10 +4,11 @@ import {IndexLink, Link} from 'react-router';
import styles from './Drawer.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../../translations.json';
import {can} from 'coral-framework/services/perms';
const CoralDrawer = ({handleLogout, restricted = false}) => (
const CoralDrawer = ({handleLogout, auth}) => (
<Drawer className={styles.header}>
{ !restricted ?
{ auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
<div>
<Navigation className={styles.nav}>
<IndexLink
@@ -16,28 +17,37 @@ const CoralDrawer = ({handleLogout, restricted = false}) => (
activeClassName={styles.active}>
{lang.t('configure.dashboard')}
</IndexLink>
<Link
className={styles.navLink}
to="/admin/moderate"
activeClassName={styles.active}>
{lang.t('configure.moderate')}
</Link>
<Link className={styles.navLink}
to="/admin/stories"
{
can(auth.user, 'MODERATE_COMMENTS') && (
<Link
className={styles.navLink}
to="/admin/moderate"
activeClassName={styles.active}>
{lang.t('configure.moderate')}
</Link>
)
}
<Link className={styles.navLink}
to="/admin/stories"
activeClassName={styles.active}>
{lang.t('configure.stories')}
</Link>
<Link className={styles.navLink}
to="/admin/community"
activeClassName={styles.active}>
to="/admin/community"
activeClassName={styles.active}>
{lang.t('configure.community')}
</Link>
<Link
className={styles.navLink}
to="/admin/configure"
activeClassName={styles.active}>
{lang.t('configure.configure')}
</Link>
{
can(auth.user, 'UPDATE_CONFIG') &&
(
<Link
className={styles.navLink}
to="/admin/configure"
activeClassName={styles.active}>
{lang.t('configure.configure')}
</Link>
)
}
<a onClick={handleLogout}>Sign Out</a>
<span>{`v${process.env.VERSION}`}</span>
</Navigation>
+58 -45
View File
@@ -5,50 +5,66 @@ import styles from './Header.css';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../../translations.json';
import {Logo} from './Logo';
import {can} from 'coral-framework/services/perms';
const CoralHeader = ({handleLogout, showShortcuts = () => {}, restricted = false}) => (
const CoralHeader = ({
handleLogout,
showShortcuts = () => {},
auth
}) => (
<Header className={styles.header}>
<Logo className={styles.logo} />
{
!restricted ?
<div>
<Navigation className={styles.nav}>
<IndexLink
id='dashboardNav'
className={styles.navLink}
to="/admin/dashboard"
activeClassName={styles.active}>
{lang.t('configure.dashboard')}
</IndexLink>
<Link
id='moderateNav'
className={styles.navLink}
to="/admin/moderate"
activeClassName={styles.active}>
{lang.t('configure.moderate')}
</Link>
<Link
id='streamsNav'
className={styles.navLink}
to="/admin/stories"
activeClassName={styles.active}>
{lang.t('configure.stories')}
</Link>
<Link
id='communityNav'
className={styles.navLink}
to="/admin/community"
activeClassName={styles.active}>
{lang.t('configure.community')}
</Link>
<Link
id='configureNav'
className={styles.navLink}
to="/admin/configure"
activeClassName={styles.active}>
{lang.t('configure.configure')}
</Link>
</Navigation>
{
auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ?
<Navigation className={styles.nav}>
<IndexLink
id='dashboardNav'
className={styles.navLink}
to="/admin/dashboard"
activeClassName={styles.active}>
{lang.t('configure.dashboard')}
</IndexLink>
{
can(auth.user, 'MODERATE_COMMENTS') && (
<Link
id='moderateNav'
className={styles.navLink}
to="/admin/moderate"
activeClassName={styles.active}>
{lang.t('configure.moderate')}
</Link>
)
}
<Link
id='streamsNav'
className={styles.navLink}
to="/admin/stories"
activeClassName={styles.active}>
{lang.t('configure.stories')}
</Link>
<Link
id='communityNav'
className={styles.navLink}
to="/admin/community"
activeClassName={styles.active}>
{lang.t('configure.community')}
</Link>
{
can(auth.user, 'UPDATE_CONFIG') && (
<Link
id='configureNav'
className={styles.navLink}
to="/admin/configure"
activeClassName={styles.active}>
{lang.t('configure.configure')}
</Link>
)
}
</Navigation>
:
null
}
<div className={styles.rightPanel}>
<ul>
<li className={styles.settings}>
@@ -66,16 +82,13 @@ const CoralHeader = ({handleLogout, showShortcuts = () => {}, restricted = false
</ul>
</div>
</div>
:
null
}
</Header>
);
CoralHeader.propTypes = {
auth: PropTypes.object,
showShortcuts: PropTypes.func,
handleLogout: PropTypes.func.isRequired,
restricted: PropTypes.bool // hide elemnts from a user that's logged out
handleLogout: PropTypes.func.isRequired
};
const lang = new I18n(translations);
@@ -4,12 +4,16 @@ import Header from './Header';
import Drawer from './Drawer';
import styles from './Layout.css';
const Layout = ({children, handleLogout = () => {}, toggleShortcutModal, restricted = false, ...props}) => (
const Layout = ({
children,
handleLogout = () => {},
toggleShortcutModal,
restricted = false,
...props}) => (
<LayoutMDL fixedDrawer>
<Header
handleLogout={handleLogout}
showShortcuts={toggleShortcutModal}
restricted={restricted}
{...props} />
<Drawer handleLogout={handleLogout} restricted={restricted} {...props} />
<div className={styles.layout}>
@@ -65,6 +65,7 @@ class Table extends Component {
label={lang.t('community.role')}
onChange={(role) => this.onRoleChange(row.id, role)}>
<Option value={''}>.</Option>
<Option value={'STAFF'}>{lang.t('community.staff')}</Option>
<Option value={'MODERATOR'}>{lang.t('community.moderator')}</Option>
<Option value={'ADMIN'}>{lang.t('community.admin')}</Option>
</SelectField>
@@ -15,6 +15,7 @@ import translations from 'coral-admin/src/translations.json';
import StreamSettings from './StreamSettings';
import ModerationSettings from './ModerationSettings';
import TechSettings from './TechSettings';
import {can} from 'coral-framework/services/perms';
class Configure extends Component {
constructor (props) {
@@ -118,6 +119,11 @@ class Configure extends Component {
render () {
const {activeSection} = this.state;
const section = this.getSection(activeSection);
const {auth: {user}} = this.props;
if (!can(user, 'UPDATE_CONFIG')) {
return <p>You must be an administrator to access config settings. Please find the nearest Admin and ask them to level you up!</p>;
}
const showSave = Object.keys(this.state.errors).reduce(
(bool, error) => this.state.errors[error] ? false : bool, this.state.changed);
@@ -172,6 +178,7 @@ class Configure extends Component {
}
const mapStateToProps = (state) => ({
auth: state.auth.toJS(),
settings: state.settings.toJS()
});
export default connect(mapStateToProps)(Configure);
@@ -7,6 +7,7 @@ import {logout} from 'coral-framework/actions/auth';
import {FullLoading} from '../components/FullLoading';
import {toggleModal as toggleShortcutModal} from '../actions/moderation';
import {checkLogin, handleLogin, requestPasswordReset} from '../actions/auth';
import {can} from 'coral-framework/services/perms';
class LayoutContainer extends Component {
componentWillMount() {
@@ -17,7 +18,7 @@ class LayoutContainer extends Component {
}
render() {
const {
isAdmin,
user,
loggedIn,
loadingUser,
loginError,
@@ -33,7 +34,7 @@ class LayoutContainer extends Component {
if (loadingUser) {
return <FullLoading />;
}
if (!isAdmin) {
if (!loggedIn) {
return (
<AdminLogin
loginMaxExceeded={loginMaxExceeded}
@@ -45,7 +46,7 @@ class LayoutContainer extends Component {
/>
);
}
if (isAdmin && loggedIn) {
if (can(user, 'ACCESS_ADMIN') && loggedIn) {
return (
<Layout
handleLogout={handleLogout}
@@ -53,6 +54,12 @@ class LayoutContainer extends Component {
{...this.props}
/>
);
} else if (loggedIn) {
return (
<Layout {...this.props}>
<p>This page is for team use only. Please contact an administrator if you want to join this team.</p>
</Layout>
);
}
return <FullLoading />;
}
-2
View File
@@ -4,7 +4,6 @@ import * as actions from '../constants/auth';
const initialState = Map({
loggedIn: false,
user: null,
isAdmin: false,
loginError: null,
loginMaxExceeded: false,
passwordRequestSuccess: null
@@ -24,7 +23,6 @@ export default function auth (state = initialState, action) {
return state
.set('loggedIn', true)
.set('loadingUser', false)
.set('isAdmin', action.isAdmin)
.set('user', action.user);
case actions.LOGOUT:
return initialState;
+2
View File
@@ -10,6 +10,7 @@
"newsroom_role": "Newsroom Role",
"admin": "Administrator",
"moderator": "Moderator",
"staff": "Staff",
"role": "Select role...",
"no-results": "No users found with that user name or email address. They're hiding!",
"status": "Status",
@@ -207,6 +208,7 @@
"newsroom_role": "Rol en la redacción",
"admin": "Administradora",
"moderator": "Moderadora",
"staff": "Miembro",
"role": "Seleccionar rol...",
"no-results": "No se encontraron usuarixs con ese nombre de usuario o e-mail.",
"status": "Estado",
@@ -1,6 +1,7 @@
import React from 'react';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-framework/translations';
import {can} from 'coral-framework/services/perms';
const lang = new I18n(translations);
import {TabBar, Tab, TabContent, Button} from 'coral-ui';
@@ -37,7 +38,7 @@ export default class Embed extends React.Component {
render () {
const {activeTab, logout, viewAllComments, commentId} = this.props;
const {asset: {totalCommentCount}} = this.props.root;
const {loggedIn, isAdmin, user} = this.props.auth;
const {loggedIn, user} = this.props.auth;
const userBox = <UserBox user={user} onLogout={logout} onShowProfile={this.handleShowProfile}/>;
@@ -47,7 +48,7 @@ export default class Embed extends React.Component {
<TabBar onChange={this.changeTab} activeTab={activeTab}>
<Tab><Count count={totalCommentCount}/></Tab>
<Tab>{lang.t('myProfile')}</Tab>
<Tab restricted={!isAdmin}>Configure Stream</Tab>
<Tab restricted={!can(user, 'UPDATE_CONFIG')}>Configure Stream</Tab>
</TabBar>
{
commentId &&
@@ -12,6 +12,7 @@ import IgnoredCommentTombstone from './IgnoredCommentTombstone';
import SuspendedAccount from './SuspendedAccount';
import RestrictedMessageBox
from 'coral-framework/components/RestrictedMessageBox';
import {can} from 'coral-framework/services/perms';
import ChangeUsernameContainer
from 'coral-sign-in/containers/ChangeUsernameContainer';
import I18n from 'coral-framework/modules/i18n/i18n';
@@ -42,7 +43,7 @@ class Stream extends React.Component {
removeCommentTag,
pluginProps,
ignoreUser,
auth: {loggedIn, isAdmin, user},
auth: {loggedIn, user},
commentCountCache,
editName
} = this.props;
@@ -124,7 +125,7 @@ class Stream extends React.Component {
{loggedIn &&
user &&
<ChangeUsernameContainer loggedIn={loggedIn} user={user} />}
{loggedIn && <ModerationLink assetId={asset.id} isAdmin={isAdmin} />}
{loggedIn && <ModerationLink assetId={asset.id} isAdmin={can(user, 'MODERATE_COMMENTS')} />}
{/* the highlightedComment is isolated after the user followed a permalink */}
{highlightedComment
+1 -2
View File
@@ -296,8 +296,7 @@ export const checkLogin = () => (dispatch) => {
throw new Error('Not logged in');
}
const isAdmin = !!result.user.roles.filter((i) => i === 'ADMIN').length;
dispatch(checkLoginSuccess(result.user, isAdmin));
dispatch(checkLoginSuccess(result.user));
})
.catch((error) => {
console.error(error);
+1 -5
View File
@@ -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);
+34
View File
@@ -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;
});
};
+7 -3
View File
@@ -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
+2 -1
View File
@@ -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),
+15 -8
View File
@@ -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);
}
+4 -3
View File
@@ -1,5 +1,6 @@
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);
@@ -32,15 +33,15 @@ module.exports = (context) => {
}
};
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('mutation:rejectUsername')) {
if (context.user && context.user.can(REJECT_USERNAME)) {
mutators.User.rejectUsername = (action) => rejectUsername(context, action);
}
+3 -1
View File
@@ -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);
}
}
+1 -2
View File
@@ -31,8 +31,7 @@ const Comment = {
},
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);
}
+13 -6
View File
@@ -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;
}
+4 -3
View File
@@ -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;
}
+8 -46
View File
@@ -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.
@@ -165,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;
});
/**
@@ -194,49 +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:rejectUsername',
'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' || (this.suspension.until && this.suspension.until > new Date())) {
return false;
}
const adminOnlyActions = ['mutation:setUserStatus', 'mutation:suspendUser', 'mutation:rejectUsername', 'mutation:setCommentStatus'];
if (actions.some((action) => adminOnlyActions.indexOf(action) > 0 && !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.
+25
View File
@@ -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'
};
+52
View File
@@ -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);
};
+35
View File
@@ -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;
}
};
+21
View File
@@ -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;
}
};
+10
View File
@@ -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;
}
};
+8
View File
@@ -0,0 +1,8 @@
const intersection = require('lodash/intersection');
const check = (user, roles) => {
return intersection(roles, user.roles).length > 0;
};
module.exports = {
check
};
+1 -7
View File
@@ -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]}});
}
/**
@@ -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 () {