commit
30557e3b9c
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 };
|
|
@ -8,6 +8,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
&-controls {
|
||||
&-control + &-control {
|
||||
margin-left: 0 !important;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
&-comment {
|
||||
color: black;
|
||||
margin-left: 1rem;
|
||||
|
|
|
@ -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<any>;
|
||||
|
||||
|
@ -24,7 +38,7 @@ class UserDetailNaked extends React.Component<Props, State> {
|
|||
componentDidMount() {
|
||||
this.loadDetail();
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
const id = this.getIdFromQuery();
|
||||
const { userDetail: u, userDetailFetching } = store;
|
||||
|
@ -36,21 +50,77 @@ class UserDetailNaked extends React.Component<Props, State> {
|
|||
const renderDelete = () => (
|
||||
<Popconfirm
|
||||
onConfirm={this.handleDelete}
|
||||
title={<>
|
||||
Are you sure? Due to GDPR compliance,
|
||||
<br/>
|
||||
this <strong>cannot</strong> be undone.
|
||||
</>}
|
||||
title={
|
||||
<>
|
||||
Are you sure? Due to GDPR compliance,
|
||||
<br />
|
||||
this <strong>cannot</strong> be undone.
|
||||
</>
|
||||
}
|
||||
okText="Delete"
|
||||
cancelText="Cancel"
|
||||
okType="danger"
|
||||
>
|
||||
<Button icon="delete" type="danger" ghost block>
|
||||
<Button
|
||||
icon="delete"
|
||||
type="danger"
|
||||
className="UserDetail-controls-control"
|
||||
ghost
|
||||
block
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</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) => (
|
||||
<div className="UserDetail-deet">
|
||||
<span>{name}</span>
|
||||
|
@ -115,7 +185,10 @@ class UserDetailNaked extends React.Component<Props, State> {
|
|||
</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>
|
||||
)}
|
||||
/>
|
||||
|
@ -189,7 +262,11 @@ class UserDetailNaked extends React.Component<Props, State> {
|
|||
{/* SIDE */}
|
||||
<Col span={6}>
|
||||
{/* ACTIONS */}
|
||||
<Card size="small">{renderDelete()}</Card>
|
||||
<Card size="small" className="UserDetail-controls">
|
||||
{renderDelete()}
|
||||
{renderSilenceControl()}
|
||||
{renderBanControl()}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
@ -212,6 +289,68 @@ class UserDetailNaked extends React.Component<Props, State> {
|
|||
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));
|
||||
|
|
|
@ -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<User> {
|
||||
render() {
|
||||
const p = this.props;
|
||||
const actions = [<Link to={`/users/${p.userid}`} key="view">view</Link>];
|
||||
const actions = [] as ReactNode[];
|
||||
|
||||
return (
|
||||
<List.Item key={p.userid} className="UserItem" actions={actions}>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<Avatar
|
||||
shape="square"
|
||||
icon="user"
|
||||
src={(p.avatar && p.avatar.imageUrl) || ''}
|
||||
/>
|
||||
}
|
||||
title={p.displayName}
|
||||
description={p.emailAddress}
|
||||
/>
|
||||
<Link to={`/users/${p.userid}`} key="view">
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<Avatar
|
||||
shape="square"
|
||||
icon="user"
|
||||
src={(p.avatar && p.avatar.imageUrl) || ''}
|
||||
/>
|
||||
}
|
||||
title={p.displayName}
|
||||
description={p.emailAddress}
|
||||
/>
|
||||
</Link>
|
||||
</List.Item>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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<any>;
|
||||
|
@ -15,22 +16,20 @@ class UsersNaked extends React.Component<Props> {
|
|||
}
|
||||
|
||||
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 (
|
||||
<div className="Users">
|
||||
<div className="Users-controls">
|
||||
<Button title="refresh" icon="reload" onClick={() => store.fetchUsers()} />
|
||||
</div>
|
||||
<List
|
||||
className="Users-list"
|
||||
bordered
|
||||
dataSource={users}
|
||||
loading={loading}
|
||||
renderItem={(u: User) => <UserItem key={u.userid} {...u} />}
|
||||
/>
|
||||
</div>
|
||||
<Pageable
|
||||
page={page}
|
||||
filters={userFilters}
|
||||
sorts={sorts}
|
||||
searchPlaceholder="Search user email or display name"
|
||||
renderItem={(u: User) => <UserItem key={u.userid} {...u} />}
|
||||
handleSearch={store.fetchUsers}
|
||||
handleChangeQuery={store.setUserPageQuery}
|
||||
handleResetQuery={store.resetUserPageQuery}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,8 +42,8 @@ async function fetchStats() {
|
|||
return data;
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
const { data } = await api.get('/admin/users');
|
||||
async function fetchUsers(params: Partial<PageQuery>) {
|
||||
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<User>) {
|
||||
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;
|
||||
|
@ -165,9 +170,12 @@ const app = store({
|
|||
proposalMilestonePayoutsCount: 0,
|
||||
},
|
||||
|
||||
usersFetching: false,
|
||||
usersFetched: false,
|
||||
users: [] as User[],
|
||||
users: {
|
||||
page: createDefaultPageData<User>('EMAIL:DESC'),
|
||||
},
|
||||
userSaving: false,
|
||||
userSaved: false,
|
||||
|
||||
userDetailFetching: false,
|
||||
userDetail: null as null | User,
|
||||
userDeleting: false,
|
||||
|
@ -225,12 +233,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,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -271,14 +282,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<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) {
|
||||
|
@ -291,12 +328,25 @@ const app = store({
|
|||
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) {
|
||||
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) {
|
||||
|
|
|
@ -149,6 +149,9 @@ export interface User {
|
|||
proposals: Proposal[];
|
||||
comments: Comment[];
|
||||
contributions: Contribution[];
|
||||
silenced: boolean;
|
||||
banned: boolean;
|
||||
bannedReason: string;
|
||||
}
|
||||
|
||||
export interface EmailExample {
|
||||
|
|
|
@ -84,3 +84,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),
|
||||
};
|
||||
|
|
|
@ -107,12 +107,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/<id>', methods=['GET'])
|
||||
|
@ -133,6 +145,33 @@ def get_user(id):
|
|||
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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -96,8 +96,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": "Your account has been silenced, commenting is disabled."}, 403
|
||||
|
||||
# Make the comment
|
||||
comment = Comment(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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("You are 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 = {
|
||||
|
|
|
@ -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 grant.milestone.models import Milestone
|
||||
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
|
||||
proposal = ProposalPagination().paginate
|
||||
contribution = ContributionPagination().paginate
|
||||
# comment = CommentPagination().paginate
|
||||
# user = UserPagination().paginate
|
||||
user = UserPagination().paginate
|
||||
|
|
|
@ -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 ###
|
|
@ -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()
|
||||
|
|
|
@ -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'])
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue