Merge pull request #115 from grant-project/require-email-verification
Require email verification before important actions
This commit is contained in:
commit
63dc9fbd09
|
@ -200,19 +200,11 @@ class Proposal(db.Model):
|
|||
|
||||
def validate_publishable(self):
|
||||
# Require certain fields
|
||||
# TODO: I'm an idiot, make this a loop.
|
||||
if not self.title:
|
||||
raise ValidationException("Proposal must have a title")
|
||||
if not self.content:
|
||||
raise ValidationException("Proposal must have content")
|
||||
if not self.brief:
|
||||
raise ValidationException("Proposal must have a brief")
|
||||
if not self.category:
|
||||
raise ValidationException("Proposal must have a category")
|
||||
if not self.target:
|
||||
raise ValidationException("Proposal must have a target amount")
|
||||
if not self.payout_address:
|
||||
raise ValidationException("Proposal must have a payout address")
|
||||
required_fields = ['title', 'content', 'brief', 'category', 'target', 'payout_address']
|
||||
for field in required_fields:
|
||||
if not hasattr(self, field):
|
||||
raise ValidationException("Proposal must have a {}".format(field))
|
||||
|
||||
# Then run through regular validation
|
||||
Proposal.validate(vars(self))
|
||||
|
||||
|
|
|
@ -4,12 +4,12 @@ from flask_yoloapi import endpoint, parameter
|
|||
from grant.comment.models import Comment, comment_schema, comments_schema
|
||||
from grant.email.send import send_email
|
||||
from grant.milestone.models import Milestone
|
||||
from grant.settings import EXPLORER_URL
|
||||
from grant.user.models import User
|
||||
from grant.utils.auth import requires_auth, requires_team_member_auth, get_authed_user, internal_webhook
|
||||
from grant.utils.exceptions import ValidationException
|
||||
from grant.utils.misc import is_email, make_url, from_zat, make_preview
|
||||
from sqlalchemy import or_
|
||||
from grant.settings import EXPLORER_URL
|
||||
|
||||
from .models import (
|
||||
Proposal,
|
||||
|
@ -89,6 +89,11 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id):
|
|||
if not parent:
|
||||
return {"message": "Parent comment doesn’t exist"}, 400
|
||||
|
||||
# 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
|
||||
|
||||
# Make the comment
|
||||
comment = Comment(
|
||||
proposal_id=proposal_id,
|
||||
|
@ -188,7 +193,7 @@ def update_proposal(milestones, proposal_id, **kwargs):
|
|||
try:
|
||||
g.current_proposal.update(**kwargs)
|
||||
except ValidationException as e:
|
||||
return {"message": "Invalid proposal parameters: {}".format(str(e))}, 400
|
||||
return {"message": "{}".format(str(e))}, 400
|
||||
db.session.add(g.current_proposal)
|
||||
|
||||
# Delete & re-add milestones
|
||||
|
@ -230,7 +235,7 @@ def submit_for_approval_proposal(proposal_id):
|
|||
try:
|
||||
g.current_proposal.submit_for_approval()
|
||||
except ValidationException as e:
|
||||
return {"message": "Invalid proposal parameters: {}".format(str(e))}, 400
|
||||
return {"message": "{}".format(str(e))}, 400
|
||||
db.session.add(g.current_proposal)
|
||||
db.session.commit()
|
||||
return proposal_schema.dump(g.current_proposal), 200
|
||||
|
@ -243,7 +248,7 @@ def publish_proposal(proposal_id):
|
|||
try:
|
||||
g.current_proposal.publish()
|
||||
except ValidationException as e:
|
||||
return {"message": "Invalid proposal parameters: {}".format(str(e))}, 400
|
||||
return {"message": "{}".format(str(e))}, 400
|
||||
db.session.add(g.current_proposal)
|
||||
db.session.commit()
|
||||
return proposal_schema.dump(g.current_proposal), 200
|
||||
|
|
|
@ -234,16 +234,21 @@ class SelfUserSchema(ma.Schema):
|
|||
"social_medias",
|
||||
"avatar",
|
||||
"display_name",
|
||||
"userid"
|
||||
"userid",
|
||||
"email_verified"
|
||||
)
|
||||
|
||||
social_medias = ma.Nested("SocialMediaSchema", many=True)
|
||||
avatar = ma.Nested("AvatarSchema")
|
||||
userid = ma.Method("get_userid")
|
||||
email_verified = ma.Method("get_email_verified")
|
||||
|
||||
def get_userid(self, obj):
|
||||
return obj.id
|
||||
|
||||
def get_email_verified(self, obj):
|
||||
return obj.email_verification.has_verified
|
||||
|
||||
|
||||
self_user_schema = SelfUserSchema()
|
||||
self_users_schema = SelfUserSchema(many=True)
|
||||
|
@ -258,16 +263,21 @@ class UserSchema(ma.Schema):
|
|||
"social_medias",
|
||||
"avatar",
|
||||
"display_name",
|
||||
"userid"
|
||||
"userid",
|
||||
"email_verified"
|
||||
)
|
||||
|
||||
social_medias = ma.Nested("SocialMediaSchema", many=True)
|
||||
avatar = ma.Nested("AvatarSchema")
|
||||
userid = ma.Method("get_userid")
|
||||
email_verified = ma.Method("get_email_verified")
|
||||
|
||||
def get_userid(self, obj):
|
||||
return obj.id
|
||||
|
||||
def get_email_verified(self, obj):
|
||||
return obj.email_verification.has_verified
|
||||
|
||||
|
||||
user_schema = UserSchema()
|
||||
users_schema = UserSchema(many=True)
|
||||
|
|
|
@ -57,9 +57,12 @@ def requires_team_member_auth(f):
|
|||
if not proposal:
|
||||
return jsonify(message="No proposal exists with id {}".format(proposal_id)), 404
|
||||
|
||||
if not g.current_user in proposal.team:
|
||||
if g.current_user not in proposal.team:
|
||||
return jsonify(message="You are not authorized to modify this proposal"), 403
|
||||
|
||||
if not g.current_user.email_verification.has_verified:
|
||||
return jsonify(message="Please confirm your email."), 403
|
||||
|
||||
g.current_proposal = proposal
|
||||
return f(*args, **kwargs)
|
||||
|
||||
|
|
|
@ -45,6 +45,8 @@ class BaseUserConfig(BaseTestConfig):
|
|||
display_name=test_user["displayName"],
|
||||
title=test_user["title"],
|
||||
)
|
||||
self._user.email_verification.has_verified = True
|
||||
db.session.add(self._user)
|
||||
sm = SocialMedia(
|
||||
service=test_user['socialMedias'][0]['service'],
|
||||
username=test_user['socialMedias'][0]['username'],
|
||||
|
@ -74,6 +76,13 @@ class BaseUserConfig(BaseTestConfig):
|
|||
def other_user(self):
|
||||
return User.query.filter_by(id=self._other_user_id).first()
|
||||
|
||||
def mark_user_not_verified(self, user=None):
|
||||
if not user:
|
||||
user = self.user
|
||||
user.email_verification.has_verified = False
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
def login_default_user(self, cust_pass=None):
|
||||
return self.app.post(
|
||||
"/api/v1/users/auth",
|
||||
|
|
|
@ -109,6 +109,13 @@ class TestProposalAPI(BaseProposalCreatorConfig):
|
|||
resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id))
|
||||
self.assert400(resp)
|
||||
|
||||
def test_not_verified_email_address_publish_proposal(self):
|
||||
self.login_default_user()
|
||||
self.mark_user_not_verified()
|
||||
self.proposal.status = "DRAFT"
|
||||
resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id))
|
||||
self.assert403(resp)
|
||||
|
||||
# /
|
||||
def test_get_proposals(self):
|
||||
self.test_publish_proposal_approved()
|
||||
|
|
|
@ -99,3 +99,22 @@ class TestProposalCommentAPI(BaseUserConfig):
|
|||
content_type='application/json'
|
||||
)
|
||||
self.assertStatus(reply_res, 400)
|
||||
|
||||
def test_create_new_proposal_comment_fails_with_no_verification(self):
|
||||
self.login_default_user()
|
||||
self.mark_user_not_verified()
|
||||
|
||||
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, 401)
|
||||
|
|
|
@ -32,7 +32,10 @@ class CreateFinal extends React.Component<Props> {
|
|||
<div className="CreateFinal-message is-error">
|
||||
<Icon type="close-circle" />
|
||||
<div className="CreateFinal-message-text">
|
||||
Something went wrong during creation: "{submitError}"{' '}
|
||||
<h3>
|
||||
<b>Something went wrong during creation</b>
|
||||
</h3>
|
||||
<h5>{submitError}</h5>
|
||||
<a onClick={this.submit}>Click here</a> to try again.
|
||||
</div>
|
||||
</div>
|
||||
|
@ -43,8 +46,8 @@ class CreateFinal extends React.Component<Props> {
|
|||
<Icon type="check-circle" />
|
||||
<div className="CreateFinal-message-text">
|
||||
Your proposal has been submitted! Check your{' '}
|
||||
<Link to={`/profile?tab=pending`}>profile's pending proposals tab</Link>
|
||||
{' '}to check its status.
|
||||
<Link to={`/profile?tab=pending`}>profile's pending proposals tab</Link> to
|
||||
check its status.
|
||||
</div>
|
||||
{/* TODO - remove or rework depending on design choices */}
|
||||
{/* <div className="CreateFinal-message-text">
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Spin, List, Button, Divider, Popconfirm, message } from 'antd';
|
||||
import { Button, Divider, List, message, Popconfirm, Spin } from 'antd';
|
||||
import Placeholder from 'components/Placeholder';
|
||||
import { getIsVerified } from 'modules/auth/selectors';
|
||||
import Loader from 'components/Loader';
|
||||
import { ProposalDraft, STATUS } from 'types';
|
||||
import { fetchDrafts, createDraft, deleteDraft } from 'modules/create/actions';
|
||||
import { createDraft, deleteDraft, fetchDrafts } from 'modules/create/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import './style.less';
|
||||
|
||||
|
@ -17,6 +18,7 @@ interface StateProps {
|
|||
createDraftError: AppState['create']['createDraftError'];
|
||||
isDeletingDraft: AppState['create']['isDeletingDraft'];
|
||||
deleteDraftError: AppState['create']['deleteDraftError'];
|
||||
isVerified: ReturnType<typeof getIsVerified>;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
|
@ -51,8 +53,9 @@ class DraftList extends React.Component<Props, State> {
|
|||
isDeletingDraft,
|
||||
deleteDraftError,
|
||||
createDraftError,
|
||||
isVerified,
|
||||
} = this.props;
|
||||
if (createIfNone && drafts && !prevProps.drafts && !drafts.length) {
|
||||
if (isVerified && createIfNone && drafts && !prevProps.drafts && !drafts.length) {
|
||||
this.createDraft();
|
||||
}
|
||||
if (prevProps.isDeletingDraft && !isDeletingDraft) {
|
||||
|
@ -67,9 +70,20 @@ class DraftList extends React.Component<Props, State> {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { drafts, isCreatingDraft } = this.props;
|
||||
const { drafts, isCreatingDraft, isVerified } = this.props;
|
||||
const { deletingId } = this.state;
|
||||
|
||||
if (!isVerified) {
|
||||
return (
|
||||
<div className="DraftList">
|
||||
<Placeholder
|
||||
title="Your email is not verified"
|
||||
subtitle="Please confirm your email before making a proposal."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!drafts || isCreatingDraft) {
|
||||
return <Loader size="large" />;
|
||||
}
|
||||
|
@ -158,6 +172,7 @@ export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
|||
createDraftError: state.create.createDraftError,
|
||||
isDeletingDraft: state.create.isDeletingDraft,
|
||||
deleteDraftError: state.create.deleteDraftError,
|
||||
isVerified: getIsVerified(state),
|
||||
}),
|
||||
{
|
||||
fetchDrafts,
|
||||
|
|
|
@ -5,11 +5,11 @@ import { AppState } from 'store/reducers';
|
|||
import { Proposal } from 'types';
|
||||
import { fetchProposalComments, postProposalComment } from 'modules/proposals/actions';
|
||||
import {
|
||||
getProposalComments,
|
||||
getIsFetchingComments,
|
||||
getCommentsError,
|
||||
getIsFetchingComments,
|
||||
getProposalComments,
|
||||
} from 'modules/proposals/selectors';
|
||||
import { getIsSignedIn } from 'modules/auth/selectors';
|
||||
import { getIsVerified } from 'modules/auth/selectors';
|
||||
import Comments from 'components/Comments';
|
||||
import Placeholder from 'components/Placeholder';
|
||||
import Loader from 'components/Loader';
|
||||
|
@ -26,7 +26,7 @@ interface StateProps {
|
|||
commentsError: ReturnType<typeof getCommentsError>;
|
||||
isPostCommentPending: AppState['proposal']['isPostCommentPending'];
|
||||
postCommentError: AppState['proposal']['postCommentError'];
|
||||
isSignedIn: ReturnType<typeof getIsSignedIn>;
|
||||
isVerified: ReturnType<typeof getIsVerified>;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
|
@ -68,7 +68,7 @@ class ProposalComments extends React.Component<Props, State> {
|
|||
}
|
||||
|
||||
if (postCommentError && postCommentError !== prevProps.postCommentError) {
|
||||
message.error('Failed to submit comment');
|
||||
message.error(postCommentError);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -78,7 +78,7 @@ class ProposalComments extends React.Component<Props, State> {
|
|||
isFetchingComments,
|
||||
commentsError,
|
||||
isPostCommentPending,
|
||||
isSignedIn,
|
||||
isVerified,
|
||||
} = this.props;
|
||||
const { comment } = this.state;
|
||||
let content = null;
|
||||
|
@ -106,26 +106,33 @@ class ProposalComments extends React.Component<Props, State> {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSignedIn && (
|
||||
<div className="ProposalComments-post">
|
||||
<MarkdownEditor
|
||||
ref={el => (this.editor = el)}
|
||||
onChange={this.handleCommentChange}
|
||||
type={MARKDOWN_TYPE.REDUCED}
|
||||
<div>
|
||||
<div className="ProposalComments-post">
|
||||
{isVerified ? (
|
||||
<>
|
||||
<MarkdownEditor
|
||||
ref={el => (this.editor = el)}
|
||||
onChange={this.handleCommentChange}
|
||||
type={MARKDOWN_TYPE.REDUCED}
|
||||
/>
|
||||
<div style={{ marginTop: '0.5rem' }} />
|
||||
<Button
|
||||
onClick={this.postComment}
|
||||
disabled={!comment.length}
|
||||
loading={isPostCommentPending}
|
||||
>
|
||||
Submit comment
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Placeholder
|
||||
title="Your email is not verified"
|
||||
subtitle="Please verify your email to post a comment."
|
||||
/>
|
||||
<div style={{ marginTop: '0.5rem' }} />
|
||||
<Button
|
||||
onClick={this.postComment}
|
||||
disabled={!comment.length}
|
||||
loading={isPostCommentPending}
|
||||
>
|
||||
Submit comment
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
{content}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -145,7 +152,7 @@ export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
|||
commentsError: getCommentsError(state),
|
||||
isPostCommentPending: state.proposal.isPostCommentPending,
|
||||
postCommentError: state.proposal.postCommentError,
|
||||
isSignedIn: getIsSignedIn(state),
|
||||
isVerified: getIsVerified(state),
|
||||
}),
|
||||
{
|
||||
fetchProposalComments,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { AppState as S } from 'store/reducers';
|
||||
|
||||
export const getIsVerified = (s: S) => !!s.auth.user && s.auth.user.emailVerified;
|
||||
export const getIsSignedIn = (s: S) => !!s.auth.user;
|
||||
export const getAuthSignature = (s: S) => s.auth.authSignature;
|
||||
export const getAuthSignatureAddress = (s: S) => s.auth.authSignatureAddress;
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
import lodash from 'lodash';
|
||||
import { User, UserProposal, UserComment, UserContribution, TeamInviteWithProposal } from 'types';
|
||||
import {
|
||||
User,
|
||||
UserProposal,
|
||||
UserComment,
|
||||
UserContribution,
|
||||
TeamInviteWithProposal,
|
||||
} from 'types';
|
||||
import types from './types';
|
||||
|
||||
export interface TeamInviteWithResponse extends TeamInviteWithProposal {
|
||||
|
@ -32,6 +38,7 @@ export const INITIAL_USER: User = {
|
|||
avatar: null,
|
||||
displayName: '',
|
||||
emailAddress: '',
|
||||
emailVerified: false,
|
||||
socialMedias: [],
|
||||
title: '',
|
||||
};
|
||||
|
@ -153,7 +160,7 @@ export default (state = INITIAL_STATE, action: any) => {
|
|||
case types.DELETE_CONTRIBUTION:
|
||||
return updateUserState(state, payload.userId, {
|
||||
contributions: state.map[payload.userId].contributions.filter(
|
||||
c => c.id !== payload.contributionId
|
||||
c => c.id !== payload.contributionId,
|
||||
),
|
||||
});
|
||||
// proposal delete
|
||||
|
|
|
@ -3,6 +3,7 @@ import { SocialMedia } from 'types';
|
|||
export interface User {
|
||||
userid: number;
|
||||
emailAddress?: string; // TODO: Split into full user type
|
||||
emailVerified?: boolean;
|
||||
displayName: string;
|
||||
title: string;
|
||||
socialMedias: SocialMedia[];
|
||||
|
|
Loading…
Reference in New Issue