diff --git a/backend/.env.example b/backend/.env.example index dff41cff..8fc31e09 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -27,3 +27,7 @@ BLOCKCHAIN_API_SECRET="ef0b48e41f78d3ae85b1379b386f1bca" # run `flask gen-admin-auth` to create new password for admin ADMIN_PASS_HASH=18f97883b93a975deb9e29257a341a447302040da59cdc2d10ff65a5e57cc197 + +# Blockchain explorer to link to. Top for mainnet, bottom for testnet. +# EXPLORER_URL="https://explorer.zcha.in/" +EXPLORER_URL="https://testnet.zcha.in/" diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 9db58b11..57ea6a1b 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -198,18 +198,13 @@ class Proposal(db.Model): if category and category not in CATEGORIES: raise ValidationException("Category {} not in {}".format(category, CATEGORIES)) - def validate_publishable(self, current_user=None): + def validate_publishable(self): # Require certain fields 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)) - if current_user: - if not current_user.email_verification.has_verified: - message = "Please confirm your email before attempting to publish a proposal." - raise ValidationException(message) - # Then run through regular validation Proposal.validate(vars(self)) @@ -256,8 +251,8 @@ class Proposal(db.Model): self.deadline_duration = deadline_duration Proposal.validate(vars(self)) - def submit_for_approval(self, current_user): - self.validate_publishable(current_user) + def submit_for_approval(self): + self.validate_publishable() allowed_statuses = [DRAFT, REJECTED] # specific validation if self.status not in allowed_statuses: diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 34bfa14a..4a8beb81 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -4,6 +4,7 @@ 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 @@ -232,7 +233,7 @@ def delete_proposal(proposal_id): @endpoint.api() def submit_for_approval_proposal(proposal_id): try: - g.current_proposal.submit_for_approval(current_user=g.current_user) + g.current_proposal.submit_for_approval() except ValidationException as e: return {"message": "{}".format(str(e))}, 400 db.session.add(g.current_proposal) @@ -455,7 +456,7 @@ def post_contribution_confirmation(contribution_id, to, amount, txid): send_email(contribution.user.email_address, 'contribution_confirmed', { 'contribution': contribution, 'proposal': contribution.proposal, - 'tx_explorer_url': f'https://explorer.zcha.in/transactions/{txid}', + 'tx_explorer_url': f'{EXPLORER_URL}transactions/{txid}', }) # Send to the full proposal gang diff --git a/backend/grant/settings.py b/backend/grant/settings.py index 0447646a..b515b703 100644 --- a/backend/grant/settings.py +++ b/backend/grant/settings.py @@ -52,6 +52,8 @@ BLOCKCHAIN_API_SECRET = env.str("BLOCKCHAIN_API_SECRET") ADMIN_PASS_HASH = env.str("ADMIN_PASS_HASH") +EXPLORER_URL = env.str("EXPLORER_URL", default="https://explorer.zcha.in/") + UI = { 'NAME': 'ZF Grants', 'PRIMARY': '#CF8A00', diff --git a/backend/grant/user/models.py b/backend/grant/user/models.py index 5e54f234..d07f9df4 100644 --- a/backend/grant/user/models.py +++ b/backend/grant/user/models.py @@ -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,19 +263,25 @@ 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) -user_schema = SelfUserSchema() -users_schema = SelfUserSchema(many=True) class SocialMediaSchema(ma.Schema): class Meta: diff --git a/backend/grant/utils/auth.py b/backend/grant/utils/auth.py index 80240892..70186045 100644 --- a/backend/grant/utils/auth.py +++ b/backend/grant/utils/auth.py @@ -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) diff --git a/backend/tests/admin/test_api.py b/backend/tests/admin/test_api.py index 23aa9ac6..b35743bb 100644 --- a/backend/tests/admin/test_api.py +++ b/backend/tests/admin/test_api.py @@ -78,7 +78,7 @@ class TestAdminAPI(BaseProposalCreatorConfig): def test_approve_proposal(self): self.login_admin() # submit for approval (performed by end-user) - self.proposal.submit_for_approval(self.user) + self.proposal.submit_for_approval() # approve resp = self.app.put( "/api/v1/admin/proposals/{}/approve".format(self.proposal.id), @@ -90,7 +90,7 @@ class TestAdminAPI(BaseProposalCreatorConfig): def test_reject_proposal(self): self.login_admin() # submit for approval (performed by end-user) - self.proposal.submit_for_approval(self.user) + self.proposal.submit_for_approval() # reject resp = self.app.put( "/api/v1/admin/proposals/{}/approve".format(self.proposal.id), diff --git a/backend/tests/proposal/test_api.py b/backend/tests/proposal/test_api.py index 25ff01f1..759fcdbd 100644 --- a/backend/tests/proposal/test_api.py +++ b/backend/tests/proposal/test_api.py @@ -89,7 +89,7 @@ class TestProposalAPI(BaseProposalCreatorConfig): def test_publish_proposal_approved(self): self.login_default_user() # submit for approval, then approve - self.proposal.submit_for_approval(self.user) + self.proposal.submit_for_approval() self.proposal.approve_pending(True) # admin action resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id)) self.assert200(resp) @@ -114,7 +114,7 @@ class TestProposalAPI(BaseProposalCreatorConfig): self.mark_user_not_verified() self.proposal.status = "DRAFT" resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id)) - self.assert400(resp) + self.assert403(resp) # / def test_get_proposals(self): diff --git a/backend/tests/user/test_user_api.py b/backend/tests/user/test_user_api.py index d7d80c5a..f3462ea7 100644 --- a/backend/tests/user/test_user_api.py +++ b/backend/tests/user/test_user_api.py @@ -63,7 +63,6 @@ class TestUserAPI(BaseUserConfig): }), content_type="application/json" ) - print(user_auth_resp.headers) self.assertEqual(user_auth_resp.json['emailAddress'], self.user.email_address) self.assertEqual(user_auth_resp.json['displayName'], self.user.display_name) @@ -76,20 +75,24 @@ class TestUserAPI(BaseUserConfig): }), content_type="application/json" ) - print(login_resp.headers) # should have session cookie now me_resp = self.app.get( "/api/v1/users/me" ) - print(me_resp.headers) self.assert200(me_resp) + def test_me_get_includes_email_address(self): + self.login_default_user() + me_resp = self.app.get( + "/api/v1/users/me" + ) + self.assert200(me_resp) + self.assertIsNotNone(me_resp.json['emailAddress']) + def test_user_auth_required_fail(self): me_resp = self.app.get( "/api/v1/users/me", ) - print(me_resp.json) - print(me_resp.headers) self.assert401(me_resp) def test_user_auth_bad_password(self): diff --git a/frontend/.env.example b/frontend/.env.example index 4ac16085..eb4528ba 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -11,3 +11,7 @@ BACKEND_URL=http://localhost:5000 # sentry # SENTRY_DSN=https://PUBLICKEY@sentry.io/PROJECTID # SENTRY_RELEASE="optional, provides sentry logging with release info" + +# Blockchain explorer to link to. Top for mainnet, bottom for testnet. +# EXPLORER_URL="https://explorer.zcha.in/" +EXPLORER_URL="https://testnet.zcha.in/" diff --git a/frontend/client/components/DraftList/index.tsx b/frontend/client/components/DraftList/index.tsx index 5c555189..6703ded4 100644 --- a/frontend/client/components/DraftList/index.tsx +++ b/frontend/client/components/DraftList/index.tsx @@ -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; } interface DispatchProps { @@ -51,8 +53,9 @@ class DraftList extends React.Component { 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 { } render() { - const { drafts, isCreatingDraft } = this.props; + const { drafts, isCreatingDraft, isVerified } = this.props; const { deletingId } = this.state; + if (!isVerified) { + return ( +
+ +
+ ); + } + if (!drafts || isCreatingDraft) { return ; } @@ -158,6 +172,7 @@ export default connect( createDraftError: state.create.createDraftError, isDeletingDraft: state.create.isDeletingDraft, deleteDraftError: state.create.deleteDraftError, + isVerified: getIsVerified(state), }), { fetchDrafts, diff --git a/frontend/client/components/Proposal/Comments/index.tsx b/frontend/client/components/Proposal/Comments/index.tsx index 5a815df7..44b2451d 100644 --- a/frontend/client/components/Proposal/Comments/index.tsx +++ b/frontend/client/components/Proposal/Comments/index.tsx @@ -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; isPostCommentPending: AppState['proposal']['isPostCommentPending']; postCommentError: AppState['proposal']['postCommentError']; - isSignedIn: ReturnType; + isVerified: ReturnType; } interface DispatchProps { @@ -78,7 +78,7 @@ class ProposalComments extends React.Component { isFetchingComments, commentsError, isPostCommentPending, - isSignedIn, + isVerified, } = this.props; const { comment } = this.state; let content = null; @@ -106,26 +106,33 @@ class ProposalComments extends React.Component { } return ( - <> - {isSignedIn && ( -
- (this.editor = el)} - onChange={this.handleCommentChange} - type={MARKDOWN_TYPE.REDUCED} +
+
+ {isVerified ? ( + <> + (this.editor = el)} + onChange={this.handleCommentChange} + type={MARKDOWN_TYPE.REDUCED} + /> +
+ + + ) : ( + -
- -
- )} + )} +
{content} - +
); } @@ -145,7 +152,7 @@ export default connect( commentsError: getCommentsError(state), isPostCommentPending: state.proposal.isPostCommentPending, postCommentError: state.proposal.postCommentError, - isSignedIn: getIsSignedIn(state), + isVerified: getIsVerified(state), }), { fetchProposalComments, diff --git a/frontend/client/modules/auth/selectors.ts b/frontend/client/modules/auth/selectors.ts index f590ac60..ec72be87 100644 --- a/frontend/client/modules/auth/selectors.ts +++ b/frontend/client/modules/auth/selectors.ts @@ -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; diff --git a/frontend/client/modules/users/reducers.ts b/frontend/client/modules/users/reducers.ts index 0e53e3e3..74606b70 100644 --- a/frontend/client/modules/users/reducers.ts +++ b/frontend/client/modules/users/reducers.ts @@ -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 diff --git a/frontend/client/utils/formatters.ts b/frontend/client/utils/formatters.ts index b0d61ca4..e45ff6d0 100644 --- a/frontend/client/utils/formatters.ts +++ b/frontend/client/utils/formatters.ts @@ -87,5 +87,5 @@ export function formatZcashCLI(address: string, amount?: string | number, memo?: } export function formatTxExplorerUrl(txid: string) { - return `https://explorer.zcha.in/transactions/${txid}`; + return `${process.env.EXPLORER_URL}transactions/${txid}`; } diff --git a/frontend/config/env.js b/frontend/config/env.js index 7e339d08..e9f1adce 100644 --- a/frontend/config/env.js +++ b/frontend/config/env.js @@ -41,14 +41,6 @@ envProductionRequiredHandler( 'http://localhost:' + (process.env.PORT || 3000), ); -if (!process.env.SENTRY_RELEASE) { - process.env.SENTRY_RELEASE = undefined; -} - -if (!process.env.BACKEND_URL) { - process.env.BACKEND_URL = 'http://localhost:5000'; -} - const appDirectory = fs.realpathSync(process.cwd()); process.env.NODE_PATH = (process.env.NODE_PATH || '') .split(path.delimiter) @@ -58,12 +50,13 @@ process.env.NODE_PATH = (process.env.NODE_PATH || '') module.exports = () => { const raw = { - BACKEND_URL: process.env.BACKEND_URL, + BACKEND_URL: process.env.BACKEND_URL || 'http://localhost:5000', + EXPLORER_URL: process.env.EXPLORER_URL || 'https://chain.so/zcash/', NODE_ENV: process.env.NODE_ENV || 'development', PORT: process.env.PORT || 3000, PUBLIC_HOST_URL: process.env.PUBLIC_HOST_URL, SENTRY_DSN: process.env.SENTRY_DSN || null, - SENTRY_RELEASE: process.env.SENTRY_RELEASE, + SENTRY_RELEASE: process.env.SENTRY_RELEASE || undefined, }; // Stringify all values so we can feed into Webpack DefinePlugin diff --git a/frontend/types/user.ts b/frontend/types/user.ts index 2fea8ef9..162b901a 100644 --- a/frontend/types/user.ts +++ b/frontend/types/user.ts @@ -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[];