Merge branch 'develop' of https://github.com/dternyak/zcash-grant-system into fix-edit-profile
This commit is contained in:
commit
5fb6f4c36a
|
@ -46,7 +46,7 @@ class ArbiterControlNaked extends React.Component<Props, State> {
|
|||
{/* CONTROL */}
|
||||
<Button
|
||||
className="ArbiterControl-control"
|
||||
loading={store.proposalDetailApproving}
|
||||
loading={store.arbiterSaving}
|
||||
icon="crown"
|
||||
type="primary"
|
||||
onClick={this.handleShowSearch}
|
||||
|
@ -146,15 +146,13 @@ class ArbiterControlNaked extends React.Component<Props, State> {
|
|||
private handleSelect = async (user: User) => {
|
||||
this.setState({ showSearch: false });
|
||||
store.searchArbitersClear();
|
||||
try {
|
||||
await store.setArbiter(this.props.proposalId, user.userid);
|
||||
await store.setArbiter(this.props.proposalId, user.userid);
|
||||
if (store.arbiterSaved) {
|
||||
message.success(
|
||||
<>
|
||||
Arbiter nominated for <b>{this.props.title}</b>
|
||||
<b>{user.displayName}</b> nominated as arbiter of <b>{this.props.title}</b>
|
||||
</>,
|
||||
);
|
||||
} catch (e) {
|
||||
message.error(`Could not set arbiter: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -17,8 +17,13 @@ import {
|
|||
} from 'antd';
|
||||
import TextArea from 'antd/lib/input/TextArea';
|
||||
import store from 'src/store';
|
||||
import { formatDateSeconds } from 'util/time';
|
||||
import { PROPOSAL_STATUS, PROPOSAL_ARBITER_STATUS, MILESTONE_STAGE } from 'src/types';
|
||||
import { formatDateSeconds, formatDurationSeconds } from 'util/time';
|
||||
import {
|
||||
PROPOSAL_STATUS,
|
||||
PROPOSAL_ARBITER_STATUS,
|
||||
MILESTONE_STAGE,
|
||||
PROPOSAL_STAGE,
|
||||
} from 'src/types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Back from 'components/Back';
|
||||
import Info from 'components/Info';
|
||||
|
@ -52,6 +57,11 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
return 'loading proposal...';
|
||||
}
|
||||
|
||||
const needsArbiter =
|
||||
PROPOSAL_ARBITER_STATUS.MISSING === p.arbiter.status &&
|
||||
p.status === PROPOSAL_STATUS.LIVE &&
|
||||
!p.isFailed;
|
||||
|
||||
const renderDeleteControl = () => (
|
||||
<Popconfirm
|
||||
onConfirm={this.handleDelete}
|
||||
|
@ -72,7 +82,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
type: 'default',
|
||||
className: 'ProposalDetail-controls-control',
|
||||
block: true,
|
||||
disabled: p.status !== PROPOSAL_STATUS.LIVE,
|
||||
disabled: p.status !== PROPOSAL_STATUS.LIVE || p.isFailed,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -98,7 +108,14 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
okText="ok"
|
||||
cancelText="cancel"
|
||||
>
|
||||
<Switch checked={p.contributionMatching === 1} loading={false} />{' '}
|
||||
<Switch
|
||||
checked={p.contributionMatching === 1}
|
||||
loading={false}
|
||||
disabled={
|
||||
p.isFailed ||
|
||||
[PROPOSAL_STAGE.WIP, PROPOSAL_STAGE.COMPLETED].includes(p.stage)
|
||||
}
|
||||
/>{' '}
|
||||
</Popconfirm>
|
||||
<span>
|
||||
matching{' '}
|
||||
|
@ -108,6 +125,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
<span>
|
||||
<b>Contribution matching</b>
|
||||
<br /> Funded amount will be multiplied by 2.
|
||||
<br /> <i>Disabled after proposal is fully-funded.</i>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
@ -215,8 +233,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
);
|
||||
|
||||
const renderNominateArbiter = () =>
|
||||
PROPOSAL_ARBITER_STATUS.MISSING === p.arbiter.status &&
|
||||
p.status === PROPOSAL_STATUS.LIVE && (
|
||||
needsArbiter && (
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
|
@ -297,6 +314,21 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
);
|
||||
};
|
||||
|
||||
const renderFailed = () =>
|
||||
p.isFailed && (
|
||||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
message="Funding failed"
|
||||
description={
|
||||
<>
|
||||
This proposal failed to reach its funding goal of <b>{p.target} ZEC</b> by{' '}
|
||||
<b>{formatDateSeconds(p.datePublished + p.deadlineDuration)}</b>.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderDeetItem = (name: string, val: any) => (
|
||||
<div className="ProposalDetail-deet">
|
||||
<span>{name}</span>
|
||||
|
@ -317,6 +349,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
{renderNominateArbiter()}
|
||||
{renderNominatedArbiter()}
|
||||
{renderMilestoneAccepted()}
|
||||
{renderFailed()}
|
||||
<Collapse defaultActiveKey={['brief', 'content']}>
|
||||
<Collapse.Panel key="brief" header="brief">
|
||||
{p.brief}
|
||||
|
@ -347,6 +380,17 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
|||
<Card title="Details" size="small">
|
||||
{renderDeetItem('id', p.proposalId)}
|
||||
{renderDeetItem('created', formatDateSeconds(p.dateCreated))}
|
||||
{renderDeetItem('published', formatDateSeconds(p.datePublished))}
|
||||
{renderDeetItem(
|
||||
'deadlineDuration',
|
||||
formatDurationSeconds(p.deadlineDuration),
|
||||
)}
|
||||
{p.datePublished &&
|
||||
renderDeetItem(
|
||||
'(deadline)',
|
||||
formatDateSeconds(p.datePublished + p.deadlineDuration),
|
||||
)}
|
||||
{renderDeetItem('isFailed', JSON.stringify(p.isFailed))}
|
||||
{renderDeetItem('status', p.status)}
|
||||
{renderDeetItem('stage', p.stage)}
|
||||
{renderDeetItem('category', p.category)}
|
||||
|
|
|
@ -181,6 +181,9 @@ const app = store({
|
|||
userDeleting: false,
|
||||
userDeleted: false,
|
||||
|
||||
arbiterSaving: false,
|
||||
arbiterSaved: false,
|
||||
|
||||
arbitersSearch: {
|
||||
search: '',
|
||||
results: [] as User[],
|
||||
|
@ -385,10 +388,17 @@ const app = store({
|
|||
},
|
||||
|
||||
async setArbiter(proposalId: number, userId: number) {
|
||||
// let component handle errors for this one
|
||||
const { proposal, user } = await setArbiter(proposalId, userId);
|
||||
this.updateProposalInStore(proposal);
|
||||
this.updateUserInStore(user);
|
||||
app.arbiterSaving = true;
|
||||
app.arbiterSaved = false;
|
||||
try {
|
||||
const { proposal, user } = await setArbiter(proposalId, userId);
|
||||
this.updateProposalInStore(proposal);
|
||||
this.updateUserInStore(user);
|
||||
app.arbiterSaved = true;
|
||||
} catch (e) {
|
||||
handleApiError(e);
|
||||
}
|
||||
app.arbiterSaving = false;
|
||||
},
|
||||
|
||||
// Proposals
|
||||
|
|
|
@ -79,6 +79,13 @@ export enum PROPOSAL_STATUS {
|
|||
DELETED = 'DELETED',
|
||||
STAKING = 'STAKING',
|
||||
}
|
||||
// NOTE: sync with backend/grant/utils/enums.py ProposalStage
|
||||
export enum PROPOSAL_STAGE {
|
||||
PREVIEW = 'PREVIEW',
|
||||
FUNDING_REQUIRED = 'FUNDING_REQUIRED',
|
||||
WIP = 'WIP',
|
||||
COMPLETED = 'COMPLETED',
|
||||
}
|
||||
export interface Proposal {
|
||||
proposalId: number;
|
||||
brief: string;
|
||||
|
@ -87,9 +94,11 @@ export interface Proposal {
|
|||
dateCreated: number;
|
||||
dateApproved: number;
|
||||
datePublished: number;
|
||||
deadlineDuration: number;
|
||||
isFailed: boolean;
|
||||
title: string;
|
||||
content: string;
|
||||
stage: string;
|
||||
stage: PROPOSAL_STAGE;
|
||||
category: string;
|
||||
milestones: Milestone[];
|
||||
currentMilestone?: Milestone;
|
||||
|
|
|
@ -6,6 +6,14 @@ export const formatDateSeconds = (s: number) => {
|
|||
return moment(s * 1000).format(DATE_FMT_STRING);
|
||||
};
|
||||
|
||||
export const formatDateSecondsFromNow = (s: number) => {
|
||||
return moment(s * 1000).fromNow();
|
||||
};
|
||||
|
||||
export const formatDateMs = (s: number) => {
|
||||
return moment(s).format(DATE_FMT_STRING);
|
||||
};
|
||||
|
||||
export const formatDurationSeconds = (s: number) => {
|
||||
return moment.duration(s, 'seconds').humanize();
|
||||
};
|
||||
|
|
|
@ -209,6 +209,13 @@ def set_arbiter(proposal_id, user_id):
|
|||
if not proposal:
|
||||
return {"message": "Proposal not found"}, 404
|
||||
|
||||
for member in proposal.team:
|
||||
if member.id == user_id:
|
||||
return {"message": "Cannot set proposal team member as arbiter"}, 400
|
||||
|
||||
if proposal.is_failed:
|
||||
return {"message": "Cannot set arbiter on failed proposal"}, 400
|
||||
|
||||
user = User.query.filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return {"message": "User not found"}, 404
|
||||
|
@ -281,13 +288,7 @@ def update_proposal(id, contribution_matching):
|
|||
proposal = Proposal.query.filter(Proposal.id == id).first()
|
||||
if proposal:
|
||||
if contribution_matching is not None:
|
||||
# enforce 1 or 0 for now
|
||||
if contribution_matching == 0.0 or contribution_matching == 1.0:
|
||||
proposal.contribution_matching = contribution_matching
|
||||
# TODO: trigger check if funding target reached OR make sure
|
||||
# job schedule checks for funding completion include matching funds
|
||||
else:
|
||||
return {"message": f"Bad value for contributionMatching: {contribution_matching}"}, 400
|
||||
proposal.set_contribution_matching(contribution_matching)
|
||||
|
||||
db.session.commit()
|
||||
return proposal_schema.dump(proposal)
|
||||
|
@ -499,6 +500,11 @@ def create_contribution(proposal_id, user_id, status, amount, tx_id):
|
|||
contribution.tx_id = tx_id
|
||||
|
||||
db.session.add(contribution)
|
||||
db.session.flush()
|
||||
|
||||
contribution.proposal.set_pending_when_ready()
|
||||
contribution.proposal.set_funded_when_ready()
|
||||
|
||||
db.session.commit()
|
||||
return proposal_contribution_schema.dump(contribution), 200
|
||||
|
||||
|
@ -528,6 +534,10 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_
|
|||
if not contribution:
|
||||
return {"message": "No contribution matching that id"}, 404
|
||||
|
||||
# do not allow editing contributions once a proposal has become funded
|
||||
if contribution.proposal.is_funded:
|
||||
return {"message": "Cannot edit contributions to fully-funded proposals"}, 400
|
||||
|
||||
print((contribution_id, proposal_id, user_id, status, amount, tx_id))
|
||||
|
||||
# Proposal ID (must belong to an existing proposal)
|
||||
|
@ -558,5 +568,10 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_
|
|||
contribution.tx_id = tx_id
|
||||
|
||||
db.session.add(contribution)
|
||||
db.session.flush()
|
||||
|
||||
contribution.proposal.set_pending_when_ready()
|
||||
contribution.proposal.set_funded_when_ready()
|
||||
|
||||
db.session.commit()
|
||||
return proposal_contribution_schema.dump(contribution), 200
|
||||
|
|
|
@ -9,7 +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
|
||||
from grant.utils.auth import AuthException, handle_auth_error, get_authed_user
|
||||
|
||||
|
||||
def create_app(config_objects=["grant.settings"]):
|
||||
|
@ -33,6 +33,11 @@ def create_app(config_objects=["grant.settings"]):
|
|||
# NOTE: testing mode does not honor this handler, and instead returns the generic 500 response
|
||||
app.register_error_handler(AuthException, handle_auth_error)
|
||||
|
||||
@app.after_request
|
||||
def grantio_authed(response):
|
||||
response.headers["X-Grantio-Authed"] = 'yes' if get_authed_user() else 'no'
|
||||
return response
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
@ -46,7 +51,7 @@ def register_extensions(app):
|
|||
security.init_app(app, datastore=user_datastore, register_blueprint=False)
|
||||
|
||||
# supports_credentials for session cookies
|
||||
CORS(app, supports_credentials=True)
|
||||
CORS(app, supports_credentials=True, expose_headers='X-Grantio-Authed')
|
||||
SSLify(app)
|
||||
return None
|
||||
|
||||
|
|
|
@ -82,6 +82,14 @@ class Milestone(db.Model):
|
|||
self.reject_reason = reason
|
||||
self.reject_arbiter_id = arbiter_id
|
||||
|
||||
def accept_immediate(self):
|
||||
if self.immediate_payout and self.index == 0:
|
||||
self.date_requested = datetime.datetime.now()
|
||||
self.stage = MilestoneStage.ACCEPTED
|
||||
self.date_accepted = datetime.datetime.now()
|
||||
db.session.add(self)
|
||||
db.session.flush()
|
||||
|
||||
def accept_request(self, arbiter_id: int):
|
||||
if self.stage != MilestoneStage.REQUESTED:
|
||||
raise MilestoneException(f'Cannot accept payout request for milestone at {self.stage} stage')
|
||||
|
|
|
@ -353,6 +353,7 @@ class Proposal(db.Model):
|
|||
|
||||
return contribution
|
||||
|
||||
# state: status (DRAFT || REJECTED) -> (PENDING || STAKING)
|
||||
def submit_for_approval(self):
|
||||
self.validate_publishable()
|
||||
allowed_statuses = [ProposalStatus.DRAFT, ProposalStatus.REJECTED]
|
||||
|
@ -365,6 +366,21 @@ class Proposal(db.Model):
|
|||
else:
|
||||
self.status = ProposalStatus.STAKING
|
||||
|
||||
def set_pending_when_ready(self):
|
||||
if self.status == ProposalStatus.STAKING and self.is_staked:
|
||||
self.set_pending()
|
||||
|
||||
# state: status STAKING -> PENDING
|
||||
def set_pending(self):
|
||||
if self.status != ProposalStatus.STAKING:
|
||||
raise ValidationException(f"Proposal status must be staking in order to be set to pending")
|
||||
if not self.is_staked:
|
||||
raise ValidationException(f"Proposal is not fully staked, cannot set to pending")
|
||||
self.status = ProposalStatus.PENDING
|
||||
db.session.add(self)
|
||||
db.session.flush()
|
||||
|
||||
# state: status PENDING -> (APPROVED || REJECTED)
|
||||
def approve_pending(self, is_approve, reject_reason=None):
|
||||
self.validate_publishable()
|
||||
# specific validation
|
||||
|
@ -394,15 +410,48 @@ class Proposal(db.Model):
|
|||
'admin_note': reject_reason
|
||||
})
|
||||
|
||||
# state: status APPROVE -> LIVE, stage PREVIEW -> FUNDING_REQUIRED
|
||||
def publish(self):
|
||||
self.validate_publishable()
|
||||
# specific validation
|
||||
if not self.status == ProposalStatus.APPROVED:
|
||||
raise ValidationException(f"Proposal status must be approved")
|
||||
|
||||
self.date_published = datetime.datetime.now()
|
||||
self.status = ProposalStatus.LIVE
|
||||
self.stage = ProposalStage.FUNDING_REQUIRED
|
||||
# If we had a bounty that pushed us into funding, skip straight into WIP
|
||||
self.set_funded_when_ready()
|
||||
|
||||
def set_funded_when_ready(self):
|
||||
if self.status == ProposalStatus.LIVE and self.is_funded:
|
||||
self.set_funded()
|
||||
|
||||
# state: stage FUNDING_REQUIRED -> WIP
|
||||
def set_funded(self):
|
||||
if self.status != ProposalStatus.LIVE:
|
||||
raise ValidationException(f"Proposal status must be live in order transition to funded state")
|
||||
if self.stage != ProposalStage.FUNDING_REQUIRED:
|
||||
raise ValidationException(f"Proposal stage must be funding_required in order transition to funded state")
|
||||
if not self.is_funded:
|
||||
raise ValidationException(f"Proposal is not fully funded, cannot set to funded state")
|
||||
self.stage = ProposalStage.WIP
|
||||
db.session.add(self)
|
||||
db.session.flush()
|
||||
# check the first step, if immediate payout bump it to accepted
|
||||
self.current_milestone.accept_immediate()
|
||||
|
||||
def set_contribution_matching(self, matching: float):
|
||||
# do not allow on funded/WIP proposals
|
||||
if self.is_funded:
|
||||
raise ValidationException("Cannot set contribution matching on fully-funded proposal")
|
||||
# enforce 1 or 0 for now
|
||||
if matching == 0.0 or matching == 1.0:
|
||||
self.contribution_matching = matching
|
||||
db.session.add(self)
|
||||
db.session.flush()
|
||||
self.set_funded_when_ready()
|
||||
else:
|
||||
raise ValidationException("Bad value for contribution_matching, must be 1 or 0")
|
||||
|
||||
@hybrid_property
|
||||
def contributed(self):
|
||||
|
@ -417,6 +466,9 @@ class Proposal(db.Model):
|
|||
target = Decimal(self.target)
|
||||
# apply matching multiplier
|
||||
funded = Decimal(self.contributed) * Decimal(1 + self.contribution_matching)
|
||||
# apply bounty, if available
|
||||
if self.rfp:
|
||||
funded = funded + Decimal(self.rfp.bounty)
|
||||
# if funded > target, just set as target
|
||||
if funded > target:
|
||||
return str(target)
|
||||
|
@ -429,7 +481,15 @@ class Proposal(db.Model):
|
|||
|
||||
@hybrid_property
|
||||
def is_funded(self):
|
||||
return Decimal(self.contributed) >= Decimal(self.target)
|
||||
return Decimal(self.funded) >= Decimal(self.target)
|
||||
|
||||
@hybrid_property
|
||||
def is_failed(self):
|
||||
if not self.status == ProposalStatus.LIVE or not self.date_published:
|
||||
return False
|
||||
deadline = self.date_published + datetime.timedelta(seconds=self.deadline_duration)
|
||||
passed = deadline < datetime.datetime.now()
|
||||
return passed and not self.is_funded
|
||||
|
||||
@hybrid_property
|
||||
def current_milestone(self):
|
||||
|
@ -458,6 +518,7 @@ class ProposalSchema(ma.Schema):
|
|||
"target",
|
||||
"contributed",
|
||||
"is_staked",
|
||||
"is_failed",
|
||||
"funded",
|
||||
"content",
|
||||
"comments",
|
||||
|
|
|
@ -489,11 +489,7 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
|
|||
db.session.flush()
|
||||
|
||||
if contribution.proposal.status == ProposalStatus.STAKING:
|
||||
# fully staked, set status PENDING
|
||||
if contribution.proposal.is_staked: # Decimal(contribution.proposal.contributed) >= PROPOSAL_STAKING_AMOUNT:
|
||||
contribution.proposal.status = ProposalStatus.PENDING
|
||||
db.session.add(contribution.proposal)
|
||||
db.session.flush()
|
||||
contribution.proposal.set_pending_when_ready()
|
||||
|
||||
# email progress of staking, partial or complete
|
||||
send_email(contribution.user.email_address, 'staking_contribution_confirmed', {
|
||||
|
@ -524,12 +520,9 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
|
|||
})
|
||||
|
||||
# TODO: Once we have a task queuer in place, queue emails to everyone
|
||||
|
||||
# on funding target reached.
|
||||
if contribution.proposal.status == ProposalStatus.LIVE:
|
||||
if contribution.proposal.is_funded:
|
||||
contribution.proposal.stage = ProposalStage.WIP
|
||||
db.session.add(contribution.proposal)
|
||||
db.session.flush()
|
||||
contribution.proposal.set_funded_when_ready()
|
||||
|
||||
db.session.commit()
|
||||
return None, 200
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
The proposal milestone
|
||||
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}
|
||||
</a>
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}</a
|
||||
>
|
||||
payout of <b>{{ args.amount }} ZEC</b> has been approved.
|
||||
</p>
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
Hooray! <b>{{ args.amount }} ZEC</b> has been paid out for
|
||||
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }} </a
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}</a
|
||||
>! You can view the transaction below:
|
||||
</p>
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
The payout request for proposal milestone
|
||||
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}
|
||||
</a>
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}</a
|
||||
>
|
||||
has been rejected.
|
||||
</p>
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
A payout request for the proposal milestone
|
||||
<a href="{{ args.proposal_milestones_url }}" target="_blank">
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}
|
||||
</a>
|
||||
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}</a
|
||||
>
|
||||
has been made. As arbiter, you are responsible for reviewing this request.
|
||||
</p>
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<p style="margin: 0 0 20px;">
|
||||
You have been nominated for arbiter of
|
||||
<a href="{{ args.proposal_url }}" target="_blank">
|
||||
{{ args.proposal.title }} </a
|
||||
{{ args.proposal.title }}</a
|
||||
>. You will be responsible for reviewing milestone payout requests should you
|
||||
choose to accept.
|
||||
</p>
|
||||
|
|
|
@ -18,7 +18,7 @@ def handle_auth_error(e):
|
|||
|
||||
|
||||
def get_authed_user():
|
||||
return current_user if current_user.is_authenticated else None
|
||||
return current_user if current_user.is_authenticated and not current_user.banned else None
|
||||
|
||||
|
||||
def throw_on_banned(user):
|
||||
|
|
|
@ -97,7 +97,8 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
|||
def test_update_proposal_bad_matching(self):
|
||||
self.login_admin()
|
||||
resp = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}", data={"contributionMatching": 2})
|
||||
self.assert400(resp)
|
||||
self.assert500(resp)
|
||||
self.assertIn('Bad value', resp.json['data'])
|
||||
|
||||
@patch('requests.get', side_effect=mock_blockchain_api_requests)
|
||||
def test_approve_proposal(self, mock_get):
|
||||
|
@ -140,7 +141,7 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
|||
"/api/v1/admin/arbiters",
|
||||
data={
|
||||
'proposalId': self.proposal.id,
|
||||
'userId': self.user.id
|
||||
'userId': self.other_user.id
|
||||
}
|
||||
)
|
||||
self.assert200(resp)
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import axios from 'axios';
|
||||
import { getStoreRef } from 'store/configure';
|
||||
import { checkUser } from 'modules/auth/actions';
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: process.env.BACKEND_URL,
|
||||
|
@ -7,9 +9,24 @@ const instance = axios.create({
|
|||
withCredentials: true,
|
||||
});
|
||||
|
||||
let lastAuthed = null as null | string;
|
||||
|
||||
instance.interceptors.response.use(
|
||||
// Do nothing to responses
|
||||
res => res,
|
||||
// - watch for changes to auth header and trigger checkUser action if it changes
|
||||
// - this allows for external authorization events to be registered in this context
|
||||
// - external auth events include login/logout in another tab, or
|
||||
// the user getting banned
|
||||
res => {
|
||||
const authed = res.headers['x-grantio-authed'];
|
||||
if (lastAuthed !== null && lastAuthed !== authed) {
|
||||
const store = getStoreRef();
|
||||
if (store) {
|
||||
store.dispatch<any>(checkUser());
|
||||
}
|
||||
}
|
||||
lastAuthed = authed;
|
||||
return res;
|
||||
},
|
||||
// Try to parse error message if possible
|
||||
err => {
|
||||
if (err.response && err.response.data) {
|
||||
|
|
|
@ -61,6 +61,14 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
|||
const isDisabled = isFundingOver || !!amountError || !amountFloat || isPreview;
|
||||
const remainingTargetNum = parseFloat(fromZat(target.sub(funded)));
|
||||
|
||||
// Get bounty from RFP. If it exceeds proposal target, show bounty as full amount
|
||||
let bounty;
|
||||
if (proposal.rfp && proposal.rfp.bounty) {
|
||||
bounty = proposal.rfp.bounty.gt(proposal.target)
|
||||
? proposal.target
|
||||
: proposal.rfp.bounty;
|
||||
}
|
||||
|
||||
content = (
|
||||
<React.Fragment>
|
||||
{isLive && (
|
||||
|
@ -96,6 +104,12 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{bounty && (
|
||||
<div className="ProposalCampaignBlock-bounty">
|
||||
Awarded with <UnitDisplay value={bounty} symbol="ZEC" /> bounty
|
||||
</div>
|
||||
)}
|
||||
|
||||
{proposal.contributionMatching > 0 && (
|
||||
<div className="ProposalCampaignBlock-matching">
|
||||
<span>Funds are being matched x{proposal.contributionMatching + 1}</span>
|
||||
|
|
|
@ -32,11 +32,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
&-bounty,
|
||||
&-matching {
|
||||
margin: 0.5rem -1.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: center;
|
||||
background: @info-color;
|
||||
color: #FFF;
|
||||
font-size: 1rem;
|
||||
|
||||
|
@ -45,6 +45,19 @@
|
|||
}
|
||||
}
|
||||
|
||||
&-bounty {
|
||||
background: @primary-color;
|
||||
}
|
||||
|
||||
&-matching {
|
||||
background: @info-color;
|
||||
}
|
||||
|
||||
&-bounty + &-matching {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
|
||||
&-popover {
|
||||
&-overlay {
|
||||
max-width: 400px;
|
||||
|
|
|
@ -546,20 +546,25 @@ const MilestoneAction: React.SFC<MilestoneProps> = p => {
|
|||
}
|
||||
|
||||
// special warning if no arbiter is set for team members
|
||||
if (!p.hasArbiter && p.isTeamMember) {
|
||||
if (!p.hasArbiter && p.isTeamMember && p.stage === MILESTONE_STAGE.IDLE) {
|
||||
content = (
|
||||
<Alert
|
||||
type="error"
|
||||
type="info"
|
||||
message="Arbiter not assigned"
|
||||
description={
|
||||
<p>
|
||||
We are sorry for the inconvenience, but in order to have milestone payouts
|
||||
reviewed an arbiter must be assigned. Please{' '}
|
||||
<Link target="_blank" to="/contact">
|
||||
contact support
|
||||
</Link>{' '}
|
||||
for help.
|
||||
</p>
|
||||
<>
|
||||
<p>
|
||||
Arbiters are users who review requests for payment. When they have approved
|
||||
a payment the grant administrators are then notified to make payment.
|
||||
</p>
|
||||
<p>
|
||||
It typically takes a couple of days to have an arbiter assigned. Please{' '}
|
||||
<Link target="_blank" to="/contact">
|
||||
contact support
|
||||
</Link>{' '}
|
||||
if you have waited longer than three days.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -8,8 +8,11 @@ import {
|
|||
authUser as apiAuthUser,
|
||||
logoutUser,
|
||||
} from 'api/api';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { User } from 'types';
|
||||
|
||||
type GetState = () => AppState;
|
||||
|
||||
function setSentryScope(user: User) {
|
||||
Sentry.configureScope(scope => {
|
||||
scope.setUser({
|
||||
|
@ -20,7 +23,17 @@ function setSentryScope(user: User) {
|
|||
|
||||
// check if user has authenticated session
|
||||
export function checkUser() {
|
||||
return async (dispatch: Dispatch<any>) => {
|
||||
return async (dispatch: Dispatch<any>, getState: GetState) => {
|
||||
const state = getState();
|
||||
if (state.auth.isAuthingUser || state.auth.isLoggingOut) {
|
||||
// this happens when axios calls checkUser upon seeing a change in the
|
||||
// custom auth-header, this call will not be ignored on other tabs not
|
||||
// initiating the authentication related behaviors
|
||||
console.info(
|
||||
'ignoring checkUser action b/c we are currently authing or logging out',
|
||||
);
|
||||
return;
|
||||
}
|
||||
dispatch({ type: types.CHECK_USER_PENDING });
|
||||
try {
|
||||
const res = await checkUserAuth();
|
||||
|
|
|
@ -13,6 +13,8 @@ export interface AuthState {
|
|||
isCheckingUser: boolean;
|
||||
hasCheckedUser: boolean;
|
||||
|
||||
isLoggingOut: boolean;
|
||||
|
||||
isCreatingUser: boolean;
|
||||
createUserError: string | null;
|
||||
|
||||
|
@ -35,6 +37,8 @@ export const INITIAL_STATE: AuthState = {
|
|||
isCheckingUser: false,
|
||||
hasCheckedUser: false,
|
||||
|
||||
isLoggingOut: false,
|
||||
|
||||
authSignature: null,
|
||||
authSignatureAddress: null,
|
||||
isSigningAuth: false,
|
||||
|
@ -81,6 +85,7 @@ export default function createReducer(
|
|||
case types.CHECK_USER_REJECTED:
|
||||
return {
|
||||
...state,
|
||||
user: null,
|
||||
isCheckingUser: false,
|
||||
hasCheckedUser: true,
|
||||
};
|
||||
|
@ -135,9 +140,22 @@ export default function createReducer(
|
|||
signAuthError: action.payload,
|
||||
};
|
||||
|
||||
case types.LOGOUT_PENDING:
|
||||
return {
|
||||
...state,
|
||||
isLoggingOut: true,
|
||||
user: null,
|
||||
};
|
||||
case types.LOGOUT_FULFILLED:
|
||||
return {
|
||||
...state,
|
||||
isLoggingOut: false,
|
||||
user: null,
|
||||
};
|
||||
case types.LOGOUT_REJECTED:
|
||||
return {
|
||||
...state,
|
||||
isLoggingOut: false,
|
||||
user: null,
|
||||
};
|
||||
|
||||
|
|
|
@ -23,6 +23,11 @@ const bindMiddleware = (middleware: MiddleWare[]) => {
|
|||
return composeWithDevTools(applyMiddleware(...middleware));
|
||||
};
|
||||
|
||||
let storeRef = null as null | Store<AppState>;
|
||||
export function getStoreRef() {
|
||||
return storeRef;
|
||||
}
|
||||
|
||||
export function configureStore(initialState: Partial<AppState> = combineInitialState) {
|
||||
const store: Store<AppState> = createStore(
|
||||
rootReducer,
|
||||
|
@ -44,6 +49,6 @@ export function configureStore(initialState: Partial<AppState> = combineInitialS
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
storeRef = store;
|
||||
return { store };
|
||||
}
|
||||
|
|
|
@ -97,6 +97,9 @@ export function formatProposalFromGet(p: any): Proposal {
|
|||
proposal.milestones = proposal.milestones.map(msToFe);
|
||||
proposal.currentMilestone = msToFe(proposal.currentMilestone);
|
||||
}
|
||||
if (proposal.rfp) {
|
||||
proposal.rfp = formatRFPFromGet(proposal.rfp);
|
||||
}
|
||||
return proposal;
|
||||
}
|
||||
|
||||
|
@ -104,7 +107,9 @@ export function formatRFPFromGet(rfp: RFP): RFP {
|
|||
if (rfp.bounty) {
|
||||
rfp.bounty = toZat(rfp.bounty as any);
|
||||
}
|
||||
rfp.acceptedProposals = rfp.acceptedProposals.map(formatProposalFromGet);
|
||||
if (rfp.acceptedProposals) {
|
||||
rfp.acceptedProposals = rfp.acceptedProposals.map(formatProposalFromGet);
|
||||
}
|
||||
return rfp;
|
||||
}
|
||||
|
||||
|
|
|
@ -5,7 +5,6 @@ import { ChunkExtractor } from '@loadable/server';
|
|||
export interface Props {
|
||||
children: any;
|
||||
css: string[];
|
||||
scripts: string[];
|
||||
linkTags: Array<React.LinkHTMLAttributes<HTMLLinkElement>>;
|
||||
metaTags: Array<React.MetaHTMLAttributes<HTMLMetaElement>>;
|
||||
state: string;
|
||||
|
@ -15,7 +14,6 @@ export interface Props {
|
|||
|
||||
const HTML: React.SFC<Props> = ({
|
||||
children,
|
||||
scripts,
|
||||
css,
|
||||
state,
|
||||
i18n,
|
||||
|
@ -78,9 +76,6 @@ const HTML: React.SFC<Props> = ({
|
|||
</head>
|
||||
<body>
|
||||
<div id="app" dangerouslySetInnerHTML={{ __html: children }} />
|
||||
{scripts.map(src => {
|
||||
return <script key={src} src={src} />;
|
||||
})}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
@ -53,9 +53,6 @@ const serverRenderer = async (req: Request, res: Response) => {
|
|||
const cssFiles = ['bundle.css', 'vendor.css']
|
||||
.map(f => res.locals.assetPath(f))
|
||||
.filter(Boolean);
|
||||
const jsFiles = ['vendor.js', 'bundle.js']
|
||||
.map(f => res.locals.assetPath(f))
|
||||
.filter(Boolean);
|
||||
const mappedLinkTags = linkTags
|
||||
.map(l => ({ ...l, href: res.locals.assetPath(l.href) }))
|
||||
.filter(l => !!l.href);
|
||||
|
@ -66,7 +63,6 @@ const serverRenderer = async (req: Request, res: Response) => {
|
|||
const html = renderToString(
|
||||
<Html
|
||||
css={cssFiles}
|
||||
scripts={jsFiles}
|
||||
linkTags={mappedLinkTags}
|
||||
metaTags={mappedMetaTags}
|
||||
state={state}
|
||||
|
|
Loading…
Reference in New Issue