Merge pull request #207 from grant-project/user-banning

User banning
This commit is contained in:
Daniel Ternyak 2019-02-15 13:12:30 -06:00 committed by GitHub
commit 30557e3b9c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 679 additions and 81 deletions

View File

@ -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;
}
}
}

View File

@ -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: (
<Feedback
label={label}
content={content}
okText={okText}
cancelText={cancelText}
onCancel={() => {
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<Props, State> {
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 (
<div>
{content && <p>{content}</p>}
<div className="FeedbackModal-label">{label}</div>
<Input.TextArea
ref={ta => (this.input = ta)}
rows={4}
required={true}
value={text}
onChange={e => {
this.setState({ text: e.target.value });
this.props.onChange(e.target.value);
}}
/>
<div className="FeedbackModal-controls">
<Button onClick={onCancel}>{cancelText || 'Cancel'}</Button>
<Button onClick={onOk} disabled={text.length === 0} type="primary">
{okText || 'Ok'}
</Button>
</div>
</div>
);
}
}
export default { open };

View File

@ -8,6 +8,13 @@
} }
} }
&-controls {
&-control + &-control {
margin-left: 0 !important;
margin-top: 0.8rem;
}
}
&-comment { &-comment {
color: black; color: black;
margin-left: 1rem; margin-left: 1rem;

View File

@ -1,7 +1,19 @@
import React from 'react'; import React from 'react';
import { view } from 'react-easy-state'; import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router'; 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 TextArea from 'antd/lib/input/TextArea';
import store from 'src/store'; import store from 'src/store';
import { Proposal, Comment, Contribution } from 'src/types'; import { Proposal, Comment, Contribution } from 'src/types';
@ -10,6 +22,8 @@ import { Link } from 'react-router-dom';
import Back from 'components/Back'; import Back from 'components/Back';
import './index.less'; import './index.less';
import Markdown from 'components/Markdown'; import Markdown from 'components/Markdown';
import FeedbackModal from 'components/FeedbackModal';
import Info from '../Info';
type Props = RouteComponentProps<any>; type Props = RouteComponentProps<any>;
@ -24,7 +38,7 @@ class UserDetailNaked extends React.Component<Props, State> {
componentDidMount() { componentDidMount() {
this.loadDetail(); this.loadDetail();
} }
render() { render() {
const id = this.getIdFromQuery(); const id = this.getIdFromQuery();
const { userDetail: u, userDetailFetching } = store; const { userDetail: u, userDetailFetching } = store;
@ -36,21 +50,77 @@ class UserDetailNaked extends React.Component<Props, State> {
const renderDelete = () => ( const renderDelete = () => (
<Popconfirm <Popconfirm
onConfirm={this.handleDelete} onConfirm={this.handleDelete}
title={<> title={
Are you sure? Due to GDPR compliance, <>
<br/> Are you sure? Due to GDPR compliance,
this <strong>cannot</strong> be undone. <br />
</>} this <strong>cannot</strong> be undone.
</>
}
okText="Delete" okText="Delete"
cancelText="Cancel" cancelText="Cancel"
okType="danger" okType="danger"
> >
<Button icon="delete" type="danger" ghost block> <Button
icon="delete"
type="danger"
className="UserDetail-controls-control"
ghost
block
>
Delete Delete
</Button> </Button>
</Popconfirm> </Popconfirm>
); );
const renderSilenceControl = () => (
<div className="UserDetail-controls-control">
<Popconfirm
overlayClassName="UserDetail-popover-overlay"
onConfirm={this.handleToggleSilence}
title={<>{u.silenced ? 'Allow' : 'Disallow'} commenting?</>}
okText="ok"
cancelText="cancel"
>
<Switch checked={u.silenced} loading={store.userSaving} />{' '}
</Popconfirm>
<span>
Silence{' '}
<Info
placement="right"
content={
<span>
<b>Silence User</b>
<br /> User will not be able to comment.
</span>
}
/>
</span>
</div>
);
const renderBanControl = () => (
<div className="UserDetail-controls-control">
<Switch
checked={u.banned}
onChange={this.handleToggleBan}
loading={store.userSaving}
/>{' '}
<span>
Ban{' '}
<Info
placement="right"
content={
<span>
<b>Ban User</b>
<br /> User will not be able to sign-in or perform authenticated actions.
</span>
}
/>
</span>
</div>
);
const renderDeetItem = (name: string, val: any) => ( const renderDeetItem = (name: string, val: any) => (
<div className="UserDetail-deet"> <div className="UserDetail-deet">
<span>{name}</span> <span>{name}</span>
@ -115,7 +185,10 @@ class UserDetailNaked extends React.Component<Props, State> {
</Link>, </Link>,
]} ]}
> >
<List.Item.Meta title={p.title} description={p.brief} /> <List.Item.Meta
title={p.title || '(no title)'}
description={p.brief || '(no brief)'}
/>
</List.Item> </List.Item>
)} )}
/> />
@ -189,7 +262,11 @@ class UserDetailNaked extends React.Component<Props, State> {
{/* SIDE */} {/* SIDE */}
<Col span={6}> <Col span={6}>
{/* ACTIONS */} {/* ACTIONS */}
<Card size="small">{renderDelete()}</Card> <Card size="small" className="UserDetail-controls">
{renderDelete()}
{renderSilenceControl()}
{renderBanControl()}
</Card>
</Col> </Col>
</Row> </Row>
</div> </div>
@ -212,6 +289,68 @@ class UserDetailNaked extends React.Component<Props, State> {
this.props.history.replace('/users'); 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(
<>
<b>{ud.displayName}</b> {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(
<>
<b>{ud.displayName}</b> 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: (
<>
<p>This user was banned for the following reason: </p>
<q>{ud.bannedReason}</q>
</>
),
onOk: async () => {
await store.editUser(ud.userid, { banned: newBanned });
informSuccess();
},
});
}
}
};
} }
const UserDetail = withRouter(view(UserDetailNaked)); const UserDetail = withRouter(view(UserDetailNaked));

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { ReactNode } from 'react';
import { view } from 'react-easy-state'; import { view } from 'react-easy-state';
import { List, Avatar } from 'antd'; import { List, Avatar } from 'antd';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@ -8,21 +8,23 @@ import './UserItem.less';
class UserItemNaked extends React.Component<User> { class UserItemNaked extends React.Component<User> {
render() { render() {
const p = this.props; const p = this.props;
const actions = [<Link to={`/users/${p.userid}`} key="view">view</Link>]; const actions = [] as ReactNode[];
return ( return (
<List.Item key={p.userid} className="UserItem" actions={actions}> <List.Item key={p.userid} className="UserItem" actions={actions}>
<List.Item.Meta <Link to={`/users/${p.userid}`} key="view">
avatar={ <List.Item.Meta
<Avatar avatar={
shape="square" <Avatar
icon="user" shape="square"
src={(p.avatar && p.avatar.imageUrl) || ''} icon="user"
/> src={(p.avatar && p.avatar.imageUrl) || ''}
} />
title={p.displayName} }
description={p.emailAddress} title={p.displayName}
/> description={p.emailAddress}
/>
</Link>
</List.Item> </List.Item>
); );
} }

View File

@ -1,10 +1,11 @@
import React from 'react'; import React from 'react';
import { view } from 'react-easy-state'; import { view } from 'react-easy-state';
import { Button, List } from 'antd';
import { RouteComponentProps, withRouter } from 'react-router'; import { RouteComponentProps, withRouter } from 'react-router';
import store from 'src/store'; import store from 'src/store';
import Pageable from 'components/Pageable';
import { User } from 'src/types'; import { User } from 'src/types';
import UserItem from './UserItem'; import UserItem from './UserItem';
import { userFilters } from 'util/filters';
import './index.less'; import './index.less';
type Props = RouteComponentProps<any>; type Props = RouteComponentProps<any>;
@ -15,22 +16,20 @@ class UsersNaked extends React.Component<Props> {
} }
render() { render() {
const { users, usersFetched, usersFetching } = store; const { page } = store.users;
const loading = !usersFetched || usersFetching; // NOTE: sync with /backend ... pagination.py UserPagination.SORT_MAP
const sorts = ['EMAIL:DESC', 'EMAIL:ASC', 'NAME:DESC', 'NAME:ASC'];
return ( return (
<div className="Users"> <Pageable
<div className="Users-controls"> page={page}
<Button title="refresh" icon="reload" onClick={() => store.fetchUsers()} /> filters={userFilters}
</div> sorts={sorts}
<List searchPlaceholder="Search user email or display name"
className="Users-list" renderItem={(u: User) => <UserItem key={u.userid} {...u} />}
bordered handleSearch={store.fetchUsers}
dataSource={users} handleChangeQuery={store.setUserPageQuery}
loading={loading} handleResetQuery={store.resetUserPageQuery}
renderItem={(u: User) => <UserItem key={u.userid} {...u} />} />
/>
</div>
); );
} }
} }

View File

@ -42,8 +42,8 @@ async function fetchStats() {
return data; return data;
} }
async function fetchUsers() { async function fetchUsers(params: Partial<PageQuery>) {
const { data } = await api.get('/admin/users'); const { data } = await api.get('/admin/users', { params });
return data; return data;
} }
@ -52,6 +52,11 @@ async function fetchUserDetail(id: number) {
return data; return data;
} }
async function editUser(id: number, args: Partial<User>) {
const { data } = await api.put(`/admin/users/${id}`, args);
return data;
}
async function deleteUser(id: number) { async function deleteUser(id: number) {
const { data } = await api.delete('/admin/users/' + id); const { data } = await api.delete('/admin/users/' + id);
return data; return data;
@ -165,9 +170,12 @@ const app = store({
proposalMilestonePayoutsCount: 0, proposalMilestonePayoutsCount: 0,
}, },
usersFetching: false, users: {
usersFetched: false, page: createDefaultPageData<User>('EMAIL:DESC'),
users: [] as User[], },
userSaving: false,
userSaved: false,
userDetailFetching: false, userDetailFetching: false,
userDetail: null as null | User, userDetail: null as null | User,
userDeleting: false, userDeleting: false,
@ -225,12 +233,15 @@ const app = store({
}, },
updateUserInStore(u: User) { 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) { if (index > -1) {
app.users[index] = u; app.users.page.items[index] = u;
} }
if (app.userDetail && app.userDetail.userid === u.userid) { if (app.userDetail && app.userDetail.userid === u.userid) {
app.userDetail = u; app.userDetail = {
...app.userDetail,
...u,
};
} }
}, },
@ -271,14 +282,40 @@ const app = store({
// Users // Users
async fetchUsers() { async fetchUsers() {
app.usersFetching = true; app.users.page.fetching = true;
try { try {
app.users = await fetchUsers(); const page = await fetchUsers(app.getUserPageQuery());
app.usersFetched = true; app.users.page = {
...app.users.page,
...page,
fetched: true,
};
} catch (e) { } catch (e) {
handleApiError(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<PageQuery>) {
// 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) { async fetchUserDetail(id: number) {
@ -291,12 +328,25 @@ const app = store({
app.userDetailFetching = false; app.userDetailFetching = false;
}, },
async editUser(id: number, args: Partial<User>) {
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) { async deleteUser(id: number) {
app.userDeleting = false; app.userDeleting = false;
app.userDeleted = false; app.userDeleted = false;
try { try {
await deleteUser(id); 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.userDeleted = true;
app.userDetail = null; app.userDetail = null;
} catch (e) { } catch (e) {

View File

@ -149,6 +149,9 @@ export interface User {
proposals: Proposal[]; proposals: Proposal[];
comments: Comment[]; comments: Comment[];
contributions: Contribution[]; contributions: Contribution[];
silenced: boolean;
banned: boolean;
bannedReason: string;
} }
export interface EmailExample { export interface EmailExample {

View File

@ -84,3 +84,30 @@ export const contributionFilters: Filters = {
list: CONTRIBUTION_FILTERS, list: CONTRIBUTION_FILTERS,
getById: getFilterById(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),
};

View File

@ -107,12 +107,24 @@ def delete_user(user_id):
@blueprint.route("/users", methods=["GET"]) @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 @admin_auth_required
def get_users(): def get_users(page, filters, search, sort):
users = User.query.all() filters_workaround = request.args.getlist('filters[]')
result = admin_users_schema.dump(users) page = pagination.user(
return result schema=admin_users_schema,
query=User.query,
page=page,
filters=filters_workaround,
search=search,
sort=sort,
)
return page
@blueprint.route('/users/<id>', methods=['GET']) @blueprint.route('/users/<id>', methods=['GET'])
@ -133,6 +145,33 @@ def get_user(id):
return {"message": f"Could not find user with id {id}"}, 404 return {"message": f"Could not find user with id {id}"}, 404
@blueprint.route('/users/<user_id>', 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 # ARBITERS

View File

@ -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.extensions import bcrypt, migrate, db, ma, security
from grant.settings import SENTRY_RELEASE, ENV from grant.settings import SENTRY_RELEASE, ENV
from sentry_sdk.integrations.flask import FlaskIntegration from sentry_sdk.integrations.flask import FlaskIntegration
from grant.utils.auth import AuthException, handle_auth_error
def create_app(config_objects=["grant.settings"]): def create_app(config_objects=["grant.settings"]):
@ -20,12 +21,18 @@ def create_app(config_objects=["grant.settings"]):
register_blueprints(app) register_blueprints(app)
register_shellcontext(app) register_shellcontext(app)
register_commands(app) register_commands(app)
if not app.config.get("TESTING"): if not app.config.get("TESTING"):
sentry_sdk.init( sentry_sdk.init(
environment=ENV, environment=ENV,
release=SENTRY_RELEASE, release=SENTRY_RELEASE,
integrations=[FlaskIntegration()] 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 return app

View File

@ -5,10 +5,10 @@ from .models import Comment, comments_schema
blueprint = Blueprint("comment", __name__, url_prefix="/api/v1/comment") blueprint = Blueprint("comment", __name__, url_prefix="/api/v1/comment")
# Unused
@blueprint.route("/", methods=["GET"]) # @blueprint.route("/", methods=["GET"])
@endpoint.api() # @endpoint.api()
def get_comments(): # def get_comments():
all_comments = Comment.query.all() # all_comments = Comment.query.all()
result = comments_schema.dump(all_comments) # result = comments_schema.dump(all_comments)
return result # return result

View File

@ -5,10 +5,10 @@ from .models import Milestone, milestones_schema
blueprint = Blueprint('milestone', __name__, url_prefix='/api/v1/milestones') blueprint = Blueprint('milestone', __name__, url_prefix='/api/v1/milestones')
# Unused
@blueprint.route("/", methods=["GET"]) # @blueprint.route("/", methods=["GET"])
@endpoint.api() # @endpoint.api()
def get_milestones(): # def get_milestones():
milestones = Milestone.query.all() # milestones = Milestone.query.all()
result = milestones_schema.dump(milestones) # result = milestones_schema.dump(milestones)
return result # return result

View File

@ -96,8 +96,11 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id):
# Make sure user has verified their email # Make sure user has verified their email
if not g.current_user.email_verification.has_verified: if not g.current_user.email_verification.has_verified:
message = "Please confirm your email before commenting." return {"message": "Please confirm your email before commenting"}, 401
return {"message": message}, 401
# Make sure user is not silenced
if g.current_user.silenced:
return {"message": "Your account has been silenced, commenting is disabled."}, 403
# Make the comment # Make the comment
comment = Comment( comment = Comment(

View File

@ -107,6 +107,12 @@ class User(db.Model, UserMixin):
title = db.Column(db.String(255), unique=False, nullable=True) title = db.Column(db.String(255), unique=False, nullable=True)
active = db.Column(db.Boolean, default=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") social_medias = db.relationship(SocialMedia, backref="user", lazy=True, cascade="all, delete-orphan")
comments = db.relationship(Comment, backref="user", lazy=True) comments = db.relationship(Comment, backref="user", lazy=True)
avatar = db.relationship(Avatar, uselist=False, back_populates="user", cascade="all, delete-orphan") 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}'), '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 SelfUserSchema(ma.Schema):
class Meta: class Meta:
@ -241,6 +258,9 @@ class SelfUserSchema(ma.Schema):
"userid", "userid",
"email_verified", "email_verified",
"arbiter_proposals", "arbiter_proposals",
"silenced",
"banned",
"banned_reason",
) )
social_medias = ma.Nested("SocialMediaSchema", many=True) social_medias = ma.Nested("SocialMediaSchema", many=True)

View File

@ -13,12 +13,12 @@ from grant.proposal.models import (
user_proposals_schema, user_proposals_schema,
user_proposal_arbiters_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.exceptions import ValidationException
from grant.utils.social import verify_social, get_social_login_url, VerifySocialException 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.upload import remove_avatar, sign_avatar_upload, AvatarException
from grant.utils.enums import ProposalStatus, ContributionStatus from grant.utils.enums import ProposalStatus, ContributionStatus
from flask import current_app
from .models import ( from .models import (
User, User,
SocialMedia, SocialMedia,
@ -146,6 +146,7 @@ def auth_user(email, password):
return {"message": "No user exists with that email"}, 400 return {"message": "No user exists with that email"}, 400
if not existing_user.check_password(password): if not existing_user.check_password(password):
return {"message": "Invalid password"}, 403 return {"message": "Invalid password"}, 403
throw_on_banned(existing_user)
existing_user.login() existing_user.login()
return self_user_schema.dump(existing_user) return self_user_schema.dump(existing_user)
@ -238,6 +239,7 @@ def recover_user(email):
existing_user = User.get_by_email(email) existing_user = User.get_by_email(email)
if not existing_user: if not existing_user:
return {"message": "No user exists with that email"}, 400 return {"message": "No user exists with that email"}, 400
throw_on_banned(existing_user)
existing_user.send_recovery_email() existing_user.send_recovery_email()
return None, 200 return None, 200
@ -251,6 +253,7 @@ def recover_email(code, password):
if er: if er:
if er.is_expired(): if er.is_expired():
return {"message": "Reset code expired"}, 401 return {"message": "Reset code expired"}, 401
throw_on_banned(er.user)
er.user.set_password(password) er.user.set_password(password)
db.session.delete(er) db.session.delete(er)
db.session.commit() db.session.commit()

View File

@ -8,15 +8,30 @@ from grant.settings import BLOCKCHAIN_API_SECRET
from grant.user.models import User 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(): def get_authed_user():
return current_user if current_user.is_authenticated else None return current_user if current_user.is_authenticated else None
def throw_on_banned(user):
if user.banned:
raise AuthException("You are banned")
def requires_auth(f): def requires_auth(f):
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
if not current_user.is_authenticated: if not current_user.is_authenticated:
return jsonify(message="Authentication is required to access this resource"), 401 return jsonify(message="Authentication is required to access this resource"), 401
throw_on_banned(current_user)
g.current_user = current_user g.current_user = current_user
with sentry_sdk.configure_scope() as scope: with sentry_sdk.configure_scope() as scope:
scope.user = { scope.user = {

View File

@ -2,6 +2,7 @@ import abc
from sqlalchemy import or_, and_ from sqlalchemy import or_, and_
from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema
from grant.user.models import User, users_schema
from grant.milestone.models import Milestone from grant.milestone.models import Milestone
from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, MilestoneStage from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, MilestoneStage
@ -172,8 +173,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 # expose pagination methods here
proposal = ProposalPagination().paginate proposal = ProposalPagination().paginate
contribution = ContributionPagination().paginate contribution = ContributionPagination().paginate
# comment = CommentPagination().paginate # comment = CommentPagination().paginate
# user = UserPagination().paginate user = UserPagination().paginate

View File

@ -0,0 +1,32 @@
"""user banned & silenced fields
Revision ID: 27975c4a04a4
Revises: d39bb526eef4
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 = 'd39bb526eef4'
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 ###

View File

@ -69,8 +69,9 @@ class TestAdminAPI(BaseProposalCreatorConfig):
self.login_admin() self.login_admin()
resp = self.app.get("/api/v1/admin/users") resp = self.app.get("/api/v1/admin/users")
self.assert200(resp) self.assert200(resp)
print(resp.json)
# 2 users created by BaseProposalCreatorConfig # 2 users created by BaseProposalCreatorConfig
self.assertEqual(len(resp.json), 2) self.assertEqual(len(resp.json['items']), 2)
def test_get_proposals(self): def test_get_proposals(self):
self.login_admin() self.login_admin()

View File

@ -111,3 +111,23 @@ class TestProposalCommentAPI(BaseUserConfig):
) )
self.assertStatus(comment_res, 403) 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'])

View File

@ -119,6 +119,20 @@ class TestUserAPI(BaseUserConfig):
self.assert400(user_auth_resp) self.assert400(user_auth_resp)
self.assertTrue(user_auth_resp.json['message'] is not None) 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): def test_create_user_duplicate_400(self):
# self.user is identical to test_user, should throw # self.user is identical to test_user, should throw
response = self.app.post( response = self.app.post(
@ -224,6 +238,20 @@ class TestUserAPI(BaseUserConfig):
self.assertStatus(reset_resp, 401) self.assertStatus(reset_resp, 401)
self.assertIsNotNone(reset_resp.json['message']) 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): def test_recover_user_no_user(self):
response = self.app.post( response = self.app.post(
"/api/v1/users/recover", "/api/v1/users/recover",
@ -244,6 +272,34 @@ class TestUserAPI(BaseUserConfig):
self.assertStatus(reset_resp, 400) self.assertStatus(reset_resp, 400)
self.assertIsNotNone(reset_resp.json['message']) 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') @patch('grant.user.views.verify_social')
def test_user_verify_social(self, mock_verify_social): def test_user_verify_social(self, mock_verify_social):
mock_verify_social.return_value = 'billy' mock_verify_social.return_value = 'billy'

View File

@ -12857,10 +12857,6 @@ redux-logger@^3.0.6:
dependencies: dependencies:
deep-diff "^0.3.5" 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: redux-promise-middleware@^5.1.1:
version "5.1.1" version "5.1.1"
resolved "https://registry.yarnpkg.com/redux-promise-middleware/-/redux-promise-middleware-5.1.1.tgz#37689339a58a33d1fda675ed1ba2053a2d196b8d" resolved "https://registry.yarnpkg.com/redux-promise-middleware/-/redux-promise-middleware-5.1.1.tgz#37689339a58a33d1fda675ed1ba2053a2d196b8d"