From 1d811bb7d3e896f29fe7b9a6d8e8d4948ae2bead Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 14 Feb 2019 22:11:47 -0600 Subject: [PATCH 1/5] BE: user banning + silencing + pagination --- backend/grant/admin/views.py | 49 ++++++++++++++-- backend/grant/app.py | 7 +++ backend/grant/comment/views.py | 14 ++--- backend/grant/milestone/views.py | 14 ++--- backend/grant/proposal/views.py | 7 ++- backend/grant/user/models.py | 20 +++++++ backend/grant/user/views.py | 7 ++- backend/grant/utils/auth.py | 15 +++++ backend/grant/utils/pagination.py | 61 +++++++++++++++++++- backend/migrations/versions/27975c4a04a4_.py | 32 ++++++++++ backend/tests/admin/test_api.py | 3 +- backend/tests/proposal/test_comment_api.py | 20 +++++++ backend/tests/user/test_user_api.py | 56 ++++++++++++++++++ frontend/yarn.lock | 4 -- 14 files changed, 280 insertions(+), 29 deletions(-) create mode 100644 backend/migrations/versions/27975c4a04a4_.py diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index 23ac8cb9..15938a01 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -91,12 +91,24 @@ def delete_user(user_id): @blueprint.route("/users", methods=["GET"]) -@endpoint.api() +@endpoint.api( + parameter('page', type=int, required=False), + parameter('filters', type=list, required=False), + parameter('search', type=str, required=False), + parameter('sort', type=str, required=False) +) @admin_auth_required -def get_users(): - users = User.query.all() - result = admin_users_schema.dump(users) - return result +def get_users(page, filters, search, sort): + filters_workaround = request.args.getlist('filters[]') + page = pagination.user( + schema=admin_users_schema, + query=User.query, + page=page, + filters=filters_workaround, + search=search, + sort=sort, + ) + return page @blueprint.route('/users/', methods=['GET']) @@ -117,6 +129,33 @@ def get_user(id): return {"message": f"Could not find user with id {id}"}, 404 +@blueprint.route('/users/', methods=['PUT']) +@endpoint.api( + parameter('silenced', type=bool, required=False), + parameter('banned', type=bool, required=False), + parameter('bannedReason', type=str, required=False), +) +@admin_auth_required +def edit_user(user_id, silenced, banned, banned_reason): + user = User.query.filter(User.id == user_id).first() + if not user: + return {"message": f"Could not find user with id {id}"}, 404 + + if silenced is not None: + user.silenced = silenced + db.session.add(user) + + if banned is not None: + if banned and not banned_reason: # if banned true, provide reason + return {"message": "Please include reason for banning"}, 417 + user.banned = banned + user.banned_reason = banned_reason + db.session.add(user) + + db.session.commit() + return admin_user_schema.dump(user) + + # ARBITERS diff --git a/backend/grant/app.py b/backend/grant/app.py index 99223288..73a17bcd 100644 --- a/backend/grant/app.py +++ b/backend/grant/app.py @@ -9,6 +9,7 @@ from grant import commands, proposal, user, comment, milestone, admin, email, bl from grant.extensions import bcrypt, migrate, db, ma, security from grant.settings import SENTRY_RELEASE, ENV from sentry_sdk.integrations.flask import FlaskIntegration +from grant.utils.auth import AuthException, handle_auth_error def create_app(config_objects=["grant.settings"]): @@ -20,12 +21,18 @@ def create_app(config_objects=["grant.settings"]): register_blueprints(app) register_shellcontext(app) register_commands(app) + if not app.config.get("TESTING"): sentry_sdk.init( environment=ENV, release=SENTRY_RELEASE, integrations=[FlaskIntegration()] ) + + # handle all AuthExceptions thusly + # NOTE: testing mode does not honor this handler, and instead returns the generic 500 response + app.register_error_handler(AuthException, handle_auth_error) + return app diff --git a/backend/grant/comment/views.py b/backend/grant/comment/views.py index ecd0724a..c4b8d08f 100644 --- a/backend/grant/comment/views.py +++ b/backend/grant/comment/views.py @@ -5,10 +5,10 @@ from .models import Comment, comments_schema blueprint = Blueprint("comment", __name__, url_prefix="/api/v1/comment") - -@blueprint.route("/", methods=["GET"]) -@endpoint.api() -def get_comments(): - all_comments = Comment.query.all() - result = comments_schema.dump(all_comments) - return result +# Unused +# @blueprint.route("/", methods=["GET"]) +# @endpoint.api() +# def get_comments(): +# all_comments = Comment.query.all() +# result = comments_schema.dump(all_comments) +# return result diff --git a/backend/grant/milestone/views.py b/backend/grant/milestone/views.py index b1c12c8b..751652b7 100644 --- a/backend/grant/milestone/views.py +++ b/backend/grant/milestone/views.py @@ -5,10 +5,10 @@ from .models import Milestone, milestones_schema blueprint = Blueprint('milestone', __name__, url_prefix='/api/v1/milestones') - -@blueprint.route("/", methods=["GET"]) -@endpoint.api() -def get_milestones(): - milestones = Milestone.query.all() - result = milestones_schema.dump(milestones) - return result +# Unused +# @blueprint.route("/", methods=["GET"]) +# @endpoint.api() +# def get_milestones(): +# milestones = Milestone.query.all() +# result = milestones_schema.dump(milestones) +# return result diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index ca11e2dc..2391b34d 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -94,8 +94,11 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id): # Make sure user has verified their email if not g.current_user.email_verification.has_verified: - message = "Please confirm your email before commenting." - return {"message": message}, 401 + return {"message": "Please confirm your email before commenting"}, 401 + + # Make sure user is not silenced + if g.current_user.silenced: + return {"message": "User is silenced, cannot comment"}, 403 # Make the comment comment = Comment( diff --git a/backend/grant/user/models.py b/backend/grant/user/models.py index bb4941e1..74ce874b 100644 --- a/backend/grant/user/models.py +++ b/backend/grant/user/models.py @@ -107,6 +107,12 @@ class User(db.Model, UserMixin): title = db.Column(db.String(255), unique=False, nullable=True) active = db.Column(db.Boolean, default=True) + # moderation + silenced = db.Column(db.Boolean, default=False) + banned = db.Column(db.Boolean, default=False) + banned_reason = db.Column(db.String(), nullable=True) + + # relations social_medias = db.relationship(SocialMedia, backref="user", lazy=True, cascade="all, delete-orphan") comments = db.relationship(Comment, backref="user", lazy=True) avatar = db.relationship(Avatar, uselist=False, back_populates="user", cascade="all, delete-orphan") @@ -227,6 +233,17 @@ class User(db.Model, UserMixin): 'recover_url': make_url(f'/email/recover?code={er.code}'), }) + def set_banned(self, is_ban: bool, reason: str=None): + self.banned = is_ban + self.banned_reason = reason + db.session.add(self) + db.session.flush() + + def set_silenced(self, is_silence: bool): + self.silenced = is_silence + db.session.add(self) + db.session.flush() + class SelfUserSchema(ma.Schema): class Meta: @@ -241,6 +258,9 @@ class SelfUserSchema(ma.Schema): "userid", "email_verified", "arbiter_proposals", + "silenced", + "banned", + "banned_reason", ) social_medias = ma.Nested("SocialMediaSchema", many=True) diff --git a/backend/grant/user/views.py b/backend/grant/user/views.py index 86814639..db924863 100644 --- a/backend/grant/user/views.py +++ b/backend/grant/user/views.py @@ -13,12 +13,12 @@ from grant.proposal.models import ( user_proposals_schema, user_proposal_arbiters_schema ) -from grant.utils.auth import requires_auth, requires_same_user_auth, get_authed_user +from grant.utils.auth import requires_auth, requires_same_user_auth, get_authed_user, throw_on_banned from grant.utils.exceptions import ValidationException from grant.utils.social import verify_social, get_social_login_url, VerifySocialException from grant.utils.upload import remove_avatar, sign_avatar_upload, AvatarException from grant.utils.enums import ProposalStatus, ContributionStatus - +from flask import current_app from .models import ( User, SocialMedia, @@ -146,6 +146,7 @@ def auth_user(email, password): return {"message": "No user exists with that email"}, 400 if not existing_user.check_password(password): return {"message": "Invalid password"}, 403 + throw_on_banned(existing_user) existing_user.login() return self_user_schema.dump(existing_user) @@ -238,6 +239,7 @@ def recover_user(email): existing_user = User.get_by_email(email) if not existing_user: return {"message": "No user exists with that email"}, 400 + throw_on_banned(existing_user) existing_user.send_recovery_email() return None, 200 @@ -251,6 +253,7 @@ def recover_email(code, password): if er: if er.is_expired(): return {"message": "Reset code expired"}, 401 + throw_on_banned(er.user) er.user.set_password(password) db.session.delete(er) db.session.commit() diff --git a/backend/grant/utils/auth.py b/backend/grant/utils/auth.py index 324ecb5e..b77bbca2 100644 --- a/backend/grant/utils/auth.py +++ b/backend/grant/utils/auth.py @@ -8,15 +8,30 @@ from grant.settings import BLOCKCHAIN_API_SECRET from grant.user.models import User +class AuthException(Exception): + pass + + +# use with: @blueprint.errorhandler(AuthException) +def handle_auth_error(e): + return jsonify(message=str(e)), 403 + + def get_authed_user(): return current_user if current_user.is_authenticated else None +def throw_on_banned(user): + if user.banned: + raise AuthException("User is banned") + + def requires_auth(f): @wraps(f) def decorated(*args, **kwargs): if not current_user.is_authenticated: return jsonify(message="Authentication is required to access this resource"), 401 + throw_on_banned(current_user) g.current_user = current_user with sentry_sdk.configure_scope() as scope: scope.user = { diff --git a/backend/grant/utils/pagination.py b/backend/grant/utils/pagination.py index d75c50e8..8dd1bfd5 100644 --- a/backend/grant/utils/pagination.py +++ b/backend/grant/utils/pagination.py @@ -2,6 +2,7 @@ import abc from sqlalchemy import or_, and_ from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema +from grant.user.models import User, users_schema from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus @@ -166,8 +167,66 @@ class ContributionPagination(Pagination): } +class UserPagination(Pagination): + def __init__(self): + self.FILTERS = ['BANNED', 'SILENCED', 'ARBITER'] + self.PAGE_SIZE = 9 + self.SORT_MAP = { + 'EMAIL:DESC': User.email_address.desc(), + 'EMAIL:ASC': User.email_address, + 'NAME:DESC': User.display_name.desc(), + 'NAME:ASC': User.display_name, + } + + def paginate( + self, + schema: ma.Schema=users_schema, + query: db.Query=None, + page: int=1, + filters: list=None, + search: str=None, + sort: str='EMAIL:DESC', + ): + query = query or Proposal.query + sort = sort or 'EMAIL:DESC' + + # FILTER + if filters: + self.validate_filters(filters) + if 'BANNED' in filters: + query = query.filter(User.banned == True) + if 'SILENCED' in filters: + query = query.filter(User.silenced == True) + if 'ARBITER' in filters: + query = query.join(User.arbiter_proposals) \ + .filter(ProposalArbiter.status == ProposalArbiterStatus.ACCEPTED) + + # SORT (see self.SORT_MAP) + if sort: + self.validate_sort(sort) + query = query.order_by(self.SORT_MAP[sort]) + + # SEARCH + if search: + query = query.filter( + User.email_address.ilike(f'%{search}%') | + User.display_name.ilike(f'%{search}%') + ) + + res = query.paginate(page, self.PAGE_SIZE, False) + return { + 'page': res.page, + 'total': res.total, + 'page_size': self.PAGE_SIZE, + 'items': schema.dump(res.items), + 'filters': filters, + 'search': search, + 'sort': sort + } + + # expose pagination methods here proposal = ProposalPagination().paginate contribution = ContributionPagination().paginate # comment = CommentPagination().paginate -# user = UserPagination().paginate +user = UserPagination().paginate diff --git a/backend/migrations/versions/27975c4a04a4_.py b/backend/migrations/versions/27975c4a04a4_.py new file mode 100644 index 00000000..d5d22437 --- /dev/null +++ b/backend/migrations/versions/27975c4a04a4_.py @@ -0,0 +1,32 @@ +"""user banned & silenced fields + +Revision ID: 27975c4a04a4 +Revises: 86d300cb6d69 +Create Date: 2019-02-14 10:30:47.596818 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '27975c4a04a4' +down_revision = '86d300cb6d69' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('banned', sa.Boolean(), nullable=True)) + op.add_column('user', sa.Column('banned_reason', sa.String(), nullable=True)) + op.add_column('user', sa.Column('silenced', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'silenced') + op.drop_column('user', 'banned_reason') + op.drop_column('user', 'banned') + # ### end Alembic commands ### diff --git a/backend/tests/admin/test_api.py b/backend/tests/admin/test_api.py index db31863d..d73c6ad1 100644 --- a/backend/tests/admin/test_api.py +++ b/backend/tests/admin/test_api.py @@ -69,8 +69,9 @@ class TestAdminAPI(BaseProposalCreatorConfig): self.login_admin() resp = self.app.get("/api/v1/admin/users") self.assert200(resp) + print(resp.json) # 2 users created by BaseProposalCreatorConfig - self.assertEqual(len(resp.json), 2) + self.assertEqual(len(resp.json['items']), 2) def test_get_proposals(self): self.login_admin() diff --git a/backend/tests/proposal/test_comment_api.py b/backend/tests/proposal/test_comment_api.py index 7a1a7ea3..7ba89fe4 100644 --- a/backend/tests/proposal/test_comment_api.py +++ b/backend/tests/proposal/test_comment_api.py @@ -111,3 +111,23 @@ class TestProposalCommentAPI(BaseUserConfig): ) self.assertStatus(comment_res, 403) + + def test_create_new_proposal_comment_fails_with_silenced_user(self): + self.login_default_user() + self.user.set_silenced(True) + + proposal = Proposal( + status="LIVE" + ) + db.session.add(proposal) + db.session.commit() + proposal_id = proposal.id + + comment_res = self.app.post( + "/api/v1/proposals/{}/comments".format(proposal_id), + data=json.dumps(test_comment), + content_type='application/json' + ) + + self.assertStatus(comment_res, 403) + self.assertIn('silenced', comment_res.json['message']) diff --git a/backend/tests/user/test_user_api.py b/backend/tests/user/test_user_api.py index f3462ea7..e16098af 100644 --- a/backend/tests/user/test_user_api.py +++ b/backend/tests/user/test_user_api.py @@ -119,6 +119,20 @@ class TestUserAPI(BaseUserConfig): self.assert400(user_auth_resp) self.assertTrue(user_auth_resp.json['message'] is not None) + def test_user_auth_banned(self): + self.user.set_banned(True, 'reason for banning') + user_auth_resp = self.app.post( + "/api/v1/users/auth", + data=json.dumps({ + "email": self.user.email_address, + "password": self.user_password + }), + content_type="application/json" + ) + # in test mode we get 500s instead of 403 + self.assert500(user_auth_resp) + self.assertIn('banned', user_auth_resp.json['data']) + def test_create_user_duplicate_400(self): # self.user is identical to test_user, should throw response = self.app.post( @@ -224,6 +238,20 @@ class TestUserAPI(BaseUserConfig): self.assertStatus(reset_resp, 401) self.assertIsNotNone(reset_resp.json['message']) + @patch('grant.email.send.send_email') + def test_recover_user_banned(self, mock_send_email): + mock_send_email.return_value.ok = True + self.user.set_banned(True, 'Reason for banning') + # 1. request reset email + response = self.app.post( + "/api/v1/users/recover", + data=json.dumps({'email': self.user.email_address}), + content_type='application/json' + ) + # 404 outside testing mode + self.assertStatus(response, 500) + self.assertIn('banned', response.json['data']) + def test_recover_user_no_user(self): response = self.app.post( "/api/v1/users/recover", @@ -244,6 +272,34 @@ class TestUserAPI(BaseUserConfig): self.assertStatus(reset_resp, 400) self.assertIsNotNone(reset_resp.json['message']) + @patch('grant.email.send.send_email') + def test_recover_user_code_banned(self, mock_send_email): + mock_send_email.return_value.ok = True + + # 1. request reset email + response = self.app.post( + "/api/v1/users/recover", + data=json.dumps({'email': self.user.email_address}), + content_type='application/json' + ) + + self.assertStatus(response, 200) + er = self.user.email_recovery + code = er.code + + self.user.set_banned(True, "Reason") + + # 2. reset password + new_password = 'n3wp455w3rd' + reset_resp = self.app.post( + f"/api/v1/users/recover/{code}", + data=json.dumps({'password': new_password}), + content_type='application/json' + ) + # 403 outside of testing mode + self.assertStatus(reset_resp, 500) + self.assertIn('banned', reset_resp.json['data']) + @patch('grant.user.views.verify_social') def test_user_verify_social(self, mock_verify_social): mock_verify_social.return_value = 'billy' diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 0316a461..48d5e89c 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -12857,10 +12857,6 @@ redux-logger@^3.0.6: dependencies: deep-diff "^0.3.5" -redux-persist@5.10.0: - version "5.10.0" - resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-5.10.0.tgz#5d8d802c5571e55924efc1c3a9b23575283be62b" - redux-promise-middleware@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/redux-promise-middleware/-/redux-promise-middleware-5.1.1.tgz#37689339a58a33d1fda675ed1ba2053a2d196b8d" From 7090d3e97b121820fc36c517b1202d794a628ef4 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 14 Feb 2019 22:18:43 -0600 Subject: [PATCH 2/5] admin: user pagination + silencing + banning --- admin/src/components/FeedbackModal/index.less | 27 +++ admin/src/components/FeedbackModal/index.tsx | 93 ++++++++++ admin/src/components/UserDetail/index.less | 7 + admin/src/components/UserDetail/index.tsx | 159 ++++++++++++++++-- admin/src/components/Users/UserItem.tsx | 28 +-- admin/src/components/Users/index.tsx | 31 ++-- admin/src/store.ts | 76 +++++++-- admin/src/types.ts | 3 + admin/src/util/filters.ts | 27 +++ 9 files changed, 399 insertions(+), 52 deletions(-) create mode 100644 admin/src/components/FeedbackModal/index.less create mode 100644 admin/src/components/FeedbackModal/index.tsx diff --git a/admin/src/components/FeedbackModal/index.less b/admin/src/components/FeedbackModal/index.less new file mode 100644 index 00000000..629b3044 --- /dev/null +++ b/admin/src/components/FeedbackModal/index.less @@ -0,0 +1,27 @@ +.FeedbackModal { + // watch these antd overrides when upgrading + // no icon, no margin + .ant-modal-confirm-content { + margin-left: 0; // override antd + margin-top: 1rem; + } + // hiding these so we can use our own + .ant-modal-confirm-btns { + display: none; + } + + &-label { + font-weight: bold; + margin-bottom: 0.3rem; + } + + // our own controls + &-controls { + margin-top: 1rem; + text-align: right; + + & > button + button { + margin-left: 0.5rem; + } + } +} diff --git a/admin/src/components/FeedbackModal/index.tsx b/admin/src/components/FeedbackModal/index.tsx new file mode 100644 index 00000000..243cf803 --- /dev/null +++ b/admin/src/components/FeedbackModal/index.tsx @@ -0,0 +1,93 @@ +import React, { ReactNode } from 'react'; +import { Modal, Input, Button } from 'antd'; +import { ModalFuncProps } from 'antd/lib/modal'; +import TextArea from 'antd/lib/input/TextArea'; +import './index.less'; + +interface OpenProps extends ModalFuncProps { + label: ReactNode; + onOk: (feedback: string) => void; +} + +const open = (p: OpenProps) => { + // NOTE: display=none antd buttons and using our own to control things more + const ref = { text: '' }; + const { label, content, okText, cancelText, ...rest } = p; + const modal = Modal.confirm({ + maskClosable: true, + icon: <>, + className: 'FeedbackModal', + content: ( + { + modal.destroy(); + }} + onOk={() => { + modal.destroy(); + p.onOk(ref.text); + }} + onChange={(t: string) => (ref.text = t)} + /> + ), + ...rest, + }); +}; + +// Feedback content +interface OwnProps { + onChange: (t: string) => void; + label: ReactNode; + onOk: ModalFuncProps['onOk']; + onCancel: ModalFuncProps['onCancel']; + okText?: ReactNode; + cancelText?: ReactNode; + content?: ReactNode; +} + +type Props = OwnProps; + +const STATE = { + text: '', +}; + +type State = typeof STATE; + +class Feedback extends React.Component { + state = STATE; + input: null | TextArea = null; + componentDidMount() { + if (this.input) this.input.focus(); + } + render() { + const { text } = this.state; + const { label, onOk, onCancel, content, okText, cancelText } = this.props; + return ( +
+ {content &&

{content}

} +
{label}
+ (this.input = ta)} + rows={4} + required={true} + value={text} + onChange={e => { + this.setState({ text: e.target.value }); + this.props.onChange(e.target.value); + }} + /> +
+ + +
+
+ ); + } +} + +export default { open }; diff --git a/admin/src/components/UserDetail/index.less b/admin/src/components/UserDetail/index.less index df276d66..fa94b55a 100644 --- a/admin/src/components/UserDetail/index.less +++ b/admin/src/components/UserDetail/index.less @@ -8,6 +8,13 @@ } } + &-controls { + &-control + &-control { + margin-left: 0 !important; + margin-top: 0.8rem; + } + } + &-comment { color: black; margin-left: 1rem; diff --git a/admin/src/components/UserDetail/index.tsx b/admin/src/components/UserDetail/index.tsx index c22245b7..02426dcc 100644 --- a/admin/src/components/UserDetail/index.tsx +++ b/admin/src/components/UserDetail/index.tsx @@ -1,7 +1,19 @@ import React from 'react'; import { view } from 'react-easy-state'; import { RouteComponentProps, withRouter } from 'react-router'; -import { Row, Col, Card, Button, Collapse, Popconfirm, Avatar, List, message } from 'antd'; +import { + Row, + Col, + Card, + Button, + Collapse, + Popconfirm, + Avatar, + List, + message, + Switch, + Modal, +} from 'antd'; import TextArea from 'antd/lib/input/TextArea'; import store from 'src/store'; import { Proposal, Comment, Contribution } from 'src/types'; @@ -10,6 +22,8 @@ import { Link } from 'react-router-dom'; import Back from 'components/Back'; import './index.less'; import Markdown from 'components/Markdown'; +import FeedbackModal from 'components/FeedbackModal'; +import Info from '../Info'; type Props = RouteComponentProps; @@ -24,7 +38,7 @@ class UserDetailNaked extends React.Component { componentDidMount() { this.loadDetail(); } - + render() { const id = this.getIdFromQuery(); const { userDetail: u, userDetailFetching } = store; @@ -36,21 +50,77 @@ class UserDetailNaked extends React.Component { const renderDelete = () => ( - Are you sure? Due to GDPR compliance, -
- this cannot be undone. - } + title={ + <> + Are you sure? Due to GDPR compliance, +
+ this cannot be undone. + + } okText="Delete" cancelText="Cancel" okType="danger" > -
); + const renderSilenceControl = () => ( +
+ {u.silenced ? 'Allow' : 'Disallow'} commenting?} + okText="ok" + cancelText="cancel" + > + {' '} + + + Silence{' '} + + Silence User +
User will not be able to comment. +
+ } + /> + +
+ ); + + const renderBanControl = () => ( +
+ {' '} + + Ban{' '} + + Ban User +
User will not be able to sign-in or perform authenticated actions. +
+ } + /> + +
+ ); + const renderDeetItem = (name: string, val: any) => (
{name} @@ -115,7 +185,10 @@ class UserDetailNaked extends React.Component { , ]} > - + )} /> @@ -189,7 +262,11 @@ class UserDetailNaked extends React.Component { {/* SIDE */} {/* ACTIONS */} - {renderDelete()} + + {renderDelete()} + {renderSilenceControl()} + {renderBanControl()} +
@@ -212,6 +289,68 @@ class UserDetailNaked extends React.Component { this.props.history.replace('/users'); } }; + + private handleToggleSilence = async () => { + if (store.userDetail) { + const ud = store.userDetail; + const newSilenced = !ud.silenced; + await store.editUser(ud.userid, { silenced: newSilenced }); + if (store.userSaved) { + message.success( + <> + {ud.displayName} {newSilenced ? 'is silenced' : 'can comment again'} + , + 2, + ); + } + } + }; + + private handleToggleBan = () => { + if (store.userDetail) { + const ud = store.userDetail; + const newBanned = !ud.banned; + const informSuccess = () => { + if (store.userSaved) { + message.success( + <> + {ud.displayName} has been{' '} + {newBanned ? 'banned' : 'freed to roam the land'} + , + 2, + ); + } + }; + + if (newBanned) { + FeedbackModal.open({ + title: 'Ban user?', + content: 'They will not be able to login.', + label: 'Please provide a reason:', + okText: 'Ban', + onOk: async reason => { + await store.editUser(ud.userid, { banned: newBanned, bannedReason: reason }); + informSuccess(); + }, + }); + } else { + Modal.confirm({ + title: 'Unban user?', + okText: 'Unban', + content: ( + <> +

This user was banned for the following reason:

+ {ud.bannedReason} + + ), + onOk: async () => { + await store.editUser(ud.userid, { banned: newBanned }); + informSuccess(); + }, + }); + } + } + }; } const UserDetail = withRouter(view(UserDetailNaked)); diff --git a/admin/src/components/Users/UserItem.tsx b/admin/src/components/Users/UserItem.tsx index e6627835..b64c4d56 100644 --- a/admin/src/components/Users/UserItem.tsx +++ b/admin/src/components/Users/UserItem.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import { view } from 'react-easy-state'; import { List, Avatar } from 'antd'; import { Link } from 'react-router-dom'; @@ -8,21 +8,23 @@ import './UserItem.less'; class UserItemNaked extends React.Component { render() { const p = this.props; - const actions = [view]; + const actions = [] as ReactNode[]; return ( - - } - title={p.displayName} - description={p.emailAddress} - /> + + + } + title={p.displayName} + description={p.emailAddress} + /> + ); } diff --git a/admin/src/components/Users/index.tsx b/admin/src/components/Users/index.tsx index 6dd35a0b..a0f29b19 100644 --- a/admin/src/components/Users/index.tsx +++ b/admin/src/components/Users/index.tsx @@ -1,10 +1,11 @@ import React from 'react'; import { view } from 'react-easy-state'; -import { Button, List } from 'antd'; import { RouteComponentProps, withRouter } from 'react-router'; import store from 'src/store'; +import Pageable from 'components/Pageable'; import { User } from 'src/types'; import UserItem from './UserItem'; +import { userFilters } from 'util/filters'; import './index.less'; type Props = RouteComponentProps; @@ -15,22 +16,20 @@ class UsersNaked extends React.Component { } render() { - const { users, usersFetched, usersFetching } = store; - const loading = !usersFetched || usersFetching; - + const { page } = store.users; + // NOTE: sync with /backend ... pagination.py UserPagination.SORT_MAP + const sorts = ['EMAIL:DESC', 'EMAIL:ASC', 'NAME:DESC', 'NAME:ASC']; return ( -
-
-
- } - /> -
+ } + handleSearch={store.fetchUsers} + handleChangeQuery={store.setUserPageQuery} + handleResetQuery={store.resetUserPageQuery} + /> ); } } diff --git a/admin/src/store.ts b/admin/src/store.ts index 13774f78..887648e6 100644 --- a/admin/src/store.ts +++ b/admin/src/store.ts @@ -42,8 +42,8 @@ async function fetchStats() { return data; } -async function fetchUsers() { - const { data } = await api.get('/admin/users'); +async function fetchUsers(params: Partial) { + const { data } = await api.get('/admin/users', { params }); return data; } @@ -52,6 +52,11 @@ async function fetchUserDetail(id: number) { return data; } +async function editUser(id: number, args: Partial) { + const { data } = await api.put(`/admin/users/${id}`, args); + return data; +} + async function deleteUser(id: number) { const { data } = await api.delete('/admin/users/' + id); return data; @@ -156,9 +161,12 @@ const app = store({ proposalNoArbiterCount: 0, }, - usersFetching: false, - usersFetched: false, - users: [] as User[], + users: { + page: createDefaultPageData('EMAIL:DESC'), + }, + userSaving: false, + userSaved: false, + userDetailFetching: false, userDetail: null as null | User, userDeleting: false, @@ -215,12 +223,15 @@ const app = store({ }, updateUserInStore(u: User) { - const index = app.users.findIndex(x => x.userid === u.userid); + const index = app.users.page.items.findIndex(x => x.userid === u.userid); if (index > -1) { - app.users[index] = u; + app.users.page.items[index] = u; } if (app.userDetail && app.userDetail.userid === u.userid) { - app.userDetail = u; + app.userDetail = { + ...app.userDetail, + ...u, + }; } }, @@ -261,14 +272,40 @@ const app = store({ // Users async fetchUsers() { - app.usersFetching = true; + app.users.page.fetching = true; try { - app.users = await fetchUsers(); - app.usersFetched = true; + const page = await fetchUsers(app.getUserPageQuery()); + app.users.page = { + ...app.users.page, + ...page, + fetched: true, + }; } catch (e) { handleApiError(e); } - app.usersFetching = false; + app.users.page.fetching = false; + }, + + getUserPageQuery() { + return pick(app.users.page, ['page', 'search', 'filters', 'sort']) as PageQuery; + }, + + setUserPageQuery(query: Partial) { + // sometimes we need to reset page to 1 + if (query.filters || query.search) { + query.page = 1; + } + app.users.page = { + ...app.users.page, + ...query, + }; + }, + + resetUserPageQuery() { + app.users.page.page = 1; + app.users.page.search = ''; + app.users.page.sort = 'CREATED:DESC'; + app.users.page.filters = []; }, async fetchUserDetail(id: number) { @@ -281,12 +318,25 @@ const app = store({ app.userDetailFetching = false; }, + async editUser(id: number, args: Partial) { + app.userSaving = true; + app.userSaved = false; + try { + const user = await editUser(id, args); + app.updateUserInStore(user); + app.userSaved = true; + } catch (e) { + handleApiError(e); + } + app.userSaving = false; + }, + async deleteUser(id: number) { app.userDeleting = false; app.userDeleted = false; try { await deleteUser(id); - app.users = app.users.filter(u => u.userid !== id); + app.users.page.items = app.users.page.items.filter(u => u.userid !== id); app.userDeleted = true; app.userDetail = null; } catch (e) { diff --git a/admin/src/types.ts b/admin/src/types.ts index 0473d561..523f5808 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -126,6 +126,9 @@ export interface User { proposals: Proposal[]; comments: Comment[]; contributions: Contribution[]; + silenced: boolean; + banned: boolean; + bannedReason: string; } export interface EmailExample { diff --git a/admin/src/util/filters.ts b/admin/src/util/filters.ts index df91f8d2..68f6c785 100644 --- a/admin/src/util/filters.ts +++ b/admin/src/util/filters.ts @@ -75,3 +75,30 @@ export const contributionFilters: Filters = { list: CONTRIBUTION_FILTERS, getById: getFilterById(CONTRIBUTION_FILTERS), }; + +// User +const USER_FILTERS = [ + { + id: `BANNED`, + display: `Banned`, + color: 'rgb(235, 65, 24)', + group: 'Misc', + }, + { + id: `SILENCED`, + display: `Silenced`, + color: 'rgb(255, 170, 0)', + group: 'Misc', + }, + { + id: `ARBITER`, + display: `Arbiter`, + color: 'rgb(16, 142, 233)', + group: 'Misc', + }, +]; + +export const userFilters: Filters = { + list: USER_FILTERS, + getById: getFilterById(USER_FILTERS), +}; From 0c4cb59262dbf31cc7dcc4d9e585ee0e99e6699a Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 14 Feb 2019 22:50:16 -0600 Subject: [PATCH 3/5] fix migration branch --- backend/migrations/versions/27975c4a04a4_.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/migrations/versions/27975c4a04a4_.py b/backend/migrations/versions/27975c4a04a4_.py index d5d22437..f6c34387 100644 --- a/backend/migrations/versions/27975c4a04a4_.py +++ b/backend/migrations/versions/27975c4a04a4_.py @@ -1,7 +1,7 @@ """user banned & silenced fields Revision ID: 27975c4a04a4 -Revises: 86d300cb6d69 +Revises: 3793d9a71e27 Create Date: 2019-02-14 10:30:47.596818 """ @@ -11,7 +11,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '27975c4a04a4' -down_revision = '86d300cb6d69' +down_revision = '3793d9a71e27' branch_labels = None depends_on = None From 6fb576c8a631f89aa8e3d701dc578dbc40f57201 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Fri, 15 Feb 2019 13:57:04 -0500 Subject: [PATCH 4/5] Fix revision branch. --- backend/migrations/versions/27975c4a04a4_.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/migrations/versions/27975c4a04a4_.py b/backend/migrations/versions/27975c4a04a4_.py index f6c34387..24a27e40 100644 --- a/backend/migrations/versions/27975c4a04a4_.py +++ b/backend/migrations/versions/27975c4a04a4_.py @@ -1,7 +1,7 @@ """user banned & silenced fields Revision ID: 27975c4a04a4 -Revises: 3793d9a71e27 +Revises: d39bb526eef4 Create Date: 2019-02-14 10:30:47.596818 """ @@ -11,7 +11,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '27975c4a04a4' -down_revision = '3793d9a71e27' +down_revision = 'd39bb526eef4' branch_labels = None depends_on = None From 7b365a28c77fd4212de32bfce394c464a677676d Mon Sep 17 00:00:00 2001 From: Daniel Ternyak Date: Fri, 15 Feb 2019 13:09:51 -0600 Subject: [PATCH 5/5] Address user directly in error messages --- backend/grant/proposal/views.py | 2 +- backend/grant/utils/auth.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 787c19a7..896bacc2 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -100,7 +100,7 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id): # Make sure user is not silenced if g.current_user.silenced: - return {"message": "User is silenced, cannot comment"}, 403 + return {"message": "Your account has been silenced, commenting is disabled."}, 403 # Make the comment comment = Comment( diff --git a/backend/grant/utils/auth.py b/backend/grant/utils/auth.py index 5ba2f92a..112bde74 100644 --- a/backend/grant/utils/auth.py +++ b/backend/grant/utils/auth.py @@ -23,7 +23,7 @@ def get_authed_user(): def throw_on_banned(user): if user.banned: - raise AuthException("User is banned") + raise AuthException("You are banned") def requires_auth(f):