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 {
|
&-comment {
|
||||||
color: black;
|
color: black;
|
||||||
margin-left: 1rem;
|
margin-left: 1rem;
|
||||||
|
|
|
@ -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>;
|
||||||
|
|
||||||
|
@ -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));
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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),
|
||||||
|
};
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
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()
|
||||||
|
|
|
@ -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'])
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue