Merge pull request #166 from grant-project/develop

Release 5
This commit is contained in:
Daniel Ternyak 2018-10-31 19:52:40 +01:00 committed by GitHub
commit 35d7139a88
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
153 changed files with 18323 additions and 661 deletions

4
admin/.envexample Normal file
View File

@ -0,0 +1,4 @@
# admin listen port
PORT=3500
# backend url
BACKEND_URL=http://localhost:5000

12
admin/.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
.next
node_modules
.idea
build
out
src/build
dist
*.log
.env
*.pid
client/lib/contracts
.vscode

1
admin/.npmignore Normal file
View File

@ -0,0 +1 @@
.gitignore

1
admin/.npmrc Normal file
View File

@ -0,0 +1 @@
package-lock=false

1
admin/.nvmrc Normal file
View File

@ -0,0 +1 @@
8.11.4

10
admin/.prettierrc Normal file
View File

@ -0,0 +1,10 @@
{
"printWidth": 90,
"singleQuote": true,
"useTabs": false,
"semi": true,
"tabWidth": 2,
"trailingComma": "all",
"bracketSpacing": true,
"jsxBracketSameLine": false
}

22
admin/README.md Normal file
View File

@ -0,0 +1,22 @@
# Grant.io Admin UI
This is the admin component of [Grant.io](http://grant.io).
## Development
1. Install local project dependencies:
```bash
# Local dependencies
yarn
```
1. Make sure ganache is running and contracts have been built for the dev network (if frontend dev is running this should be the case).
1. Run the webpack build for the admin ui:
```bash
yarn dev
```
1. Open a web browser to localhost:3500.

113
admin/package.json Normal file
View File

@ -0,0 +1,113 @@
{
"name": "grant",
"version": "1.0.1",
"main": "index.js",
"license": "MIT",
"scripts": {
"build": "cross-env NODE_ENV=production webpack",
"dev": "cross-env NODE_ENV=development BACKEND_URL=http://localhost:5000 webpack-dev-server",
"lint": "tslint --project ./tsconfig.json --config ./tslint.json -e \"**/build/**\"",
"start": "NODE_ENV=production node ./server.js",
"tsc": "tsc"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"pre-push": "yarn run lint && yarn run tsc"
}
},
"lint-staged": {
"*.{ts,tsx}": [
"prettier --write --config ./.prettierrc --config-precedence file-override",
"git add"
]
},
"dependencies": {
"@babel/core": "^7.0.1",
"@babel/plugin-proposal-class-properties": "^7.0.0",
"@babel/plugin-proposal-object-rest-spread": "^7.0.0",
"@babel/plugin-syntax-dynamic-import": "^7.0.0",
"@babel/plugin-transform-modules-commonjs": "^7.0.0",
"@babel/polyfill": "^7.0.0",
"@babel/preset-env": "^7.0.0",
"@babel/preset-react": "^7.0.0",
"@babel/preset-typescript": "^7.0.0",
"@babel/register": "^7.0.0",
"@svgr/webpack": "^2.4.0",
"@types/classnames": "^2.2.6",
"@types/dotenv": "^4.0.3",
"@types/lodash": "^4.14.112",
"@types/numeral": "^0.0.25",
"@types/react": "16.4.18",
"@types/react-dom": "16.0.9",
"@types/react-helmet": "^5.0.7",
"@types/react-redux": "^6.0.2",
"@types/react-router": "^4.0.31",
"@types/react-router-dom": "^4.3.1",
"@types/showdown": "^1.7.5",
"@types/webpack": "4.4.17",
"@types/webpack-env": "^1.13.6",
"ant-design-pro": "2.0.0",
"antd": "3.9.3",
"axios": "^0.18.0",
"babel-core": "^7.0.0-bridge.0",
"babel-loader": "^8.0.2",
"babel-plugin-dynamic-import-node": "^2.1.0",
"babel-plugin-dynamic-import-webpack": "^1.0.2",
"babel-plugin-import": "^1.8.0",
"babel-plugin-module-resolver": "^3.1.1",
"bn.js": "4.11.8",
"classnames": "^2.2.6",
"clean-webpack-plugin": "^0.1.19",
"core-js": "^2.5.7",
"cross-env": "^5.2.0",
"css-loader": "^1.0.0",
"dotenv": "^6.0.0",
"ethereum-blockies-base64": "1.0.2",
"ethereumjs-util": "5.2.0",
"file-loader": "^2.0.0",
"font-awesome": "^4.7.0",
"fork-ts-checker-webpack-plugin": "^0.4.2",
"global": "4.3.2",
"html-webpack-plugin": "^3.2.0",
"husky": "^1.0.0-rc.8",
"less": "^3.7.1",
"less-loader": "^4.1.0",
"lint-staged": "^7.2.2",
"lodash": "^4.17.10",
"mini-css-extract-plugin": "^0.4.2",
"moment": "^2.22.2",
"prettier": "^1.13.4",
"prettier-package-json": "^1.6.0",
"query-string": "6.1.0",
"react": "16.5.2",
"react-dev-utils": "^5.0.2",
"react-dom": "16.5.2",
"react-easy-state": "^6.0.4",
"react-hot-loader": "^4.3.8",
"react-router": "^4.3.1",
"react-router-dom": "^4.3.1",
"showdown": "^1.8.7",
"style-loader": "^0.23.0",
"ts-loader": "^5.1.1",
"tslint": "^5.10.0",
"tslint-config-airbnb": "^5.9.2",
"tslint-config-prettier": "^1.13.0",
"tslint-eslint-rules": "^5.3.1",
"tslint-react": "^3.6.0",
"typescript": "3.0.3",
"url-loader": "^1.1.1",
"web3": "^1.0.0-beta.34",
"webpack": "^4.19.0",
"webpack-cli": "^3.1.0",
"webpack-dev-server": "^3.1.8",
"webpack-hot-middleware": "^2.24.0",
"xss": "1.0.3"
},
"devDependencies": {
"@types/bn.js": "4.11.1",
"@types/ethereumjs-util": "5.2.0",
"@types/query-string": "6.1.0",
"@types/web3": "1.0.3"
}
}

20
admin/server.js Normal file
View File

@ -0,0 +1,20 @@
const express = require('express');
const path = require('path');
require('dotenv').config();
const PORT = process.env.PORT || 3500;
const app = express();
app.use(express.static(__dirname + '/build'));
app.get('*', function(request, response) {
response.sendFile(path.resolve(__dirname, 'build', 'index.html'));
});
app.use('/favicon.ico', (req, res) => {
res.send('');
});
app.listen(PORT, () => {
console.log(`App is listening on port ${PORT} `);
});

40
admin/src/Routes.tsx Normal file
View File

@ -0,0 +1,40 @@
import React from 'react';
import { view } from 'react-easy-state';
import { hot } from 'react-hot-loader';
import { Switch, Route, RouteComponentProps, withRouter } from 'react-router';
import Template from 'components/Template';
import store from './store';
import Login from 'components/Login';
import Home from 'components/Home';
import Users from 'components/Users';
import Proposals from 'components/Proposals';
import 'styles/style.less';
type Props = RouteComponentProps<any>;
class Routes extends React.Component<Props> {
render() {
const { hasCheckedLogin, isLoggedIn } = store;
if (!hasCheckedLogin) {
return <div>checking auth status...</div>;
}
return (
<Template>
{!isLoggedIn ? (
<Login />
) : (
<Switch>
<Route path="/" exact={true} component={Home} />
<Route path="/users/:id?" exact={true} component={Users} />
<Route path="/proposals/:id?" component={Proposals} />
</Switch>
)}
</Template>
);
}
}
const ConnectedRoutes = withRouter(view(Routes));
export default hot(module)(ConnectedRoutes);

View File

@ -0,0 +1,25 @@
.Field {
&-title {
opacity: 0.7;
font-size: 0.8rem;
margin-left: 0.2rem;
}
// ant collapse
& .ant-collapse-item {
border: none;
& > .ant-collapse-header {
padding: 0rem 0 0rem 15px;
&:hover {
background: rgba(0, 0, 0, 0.1);
}
& > .anticon {
left: 0;
transform: translateY(-45%);
}
}
}
}

View File

@ -0,0 +1,39 @@
import React from 'react';
import { Collapse } from 'antd';
import moment from 'moment';
import './index.less';
interface Props {
title: string | JSX.Element;
value: string | number | JSX.Element;
isTime?: boolean;
}
function fmtTime(n: number) {
return moment(n).format('YYYY/MM/DD h:mm a');
}
export default class CollapseField extends React.Component<Props> {
render() {
const { title, value } = this.props;
if (null === value || ['string', 'number'].indexOf(typeof value) > -1) {
return (
<div className="Field">
<div className="Field-value">
{this.props.isTime ? fmtTime(Number(value)) : value || 'n/a'}
<span className="Field-title">({title})</span>
</div>
</div>
);
}
return (
<Collapse className="Field" bordered={false}>
<Collapse.Panel header={title} key="1">
{value}
</Collapse.Panel>
</Collapse>
);
}
}

View File

@ -0,0 +1,9 @@
.Home {
h1 {
font-size: 1.5rem;
}
& > div {
margin-bottom: 0.5rem;
}
}

View File

@ -0,0 +1,32 @@
import React from 'react';
import { view } from 'react-easy-state';
import store from '../../store';
import './index.less';
class Home extends React.Component {
componentDidMount() {
store.fetchStats();
}
render() {
const { userCount, proposalCount } = store.stats;
return (
<div className="Home">
<h1>Home</h1>
<div>isLoggedIn: {store.isLoggedIn ? 'true' : 'false'}</div>
<div>web3 type: {store.web3Type}</div>
<div>ethereum network: {store.ethNetId}</div>
<div>ethereum account: {store.ethAccount}</div>
<div>CrowdFundFactory: {store.crowdFundFactoryDefinitionStatus}</div>
{userCount > -1 && (
<>
<div>user count: {userCount}</div>
<div>proposal count: {proposalCount}</div>
</>
)}
</div>
);
}
}
export default view(Home);

View File

@ -0,0 +1,12 @@
.Login {
max-width: 300px;
margin: 2rem auto;
h1 {
font-size: 1.5rem;
}
& > div {
margin-bottom: 0.5rem;
}
}

View File

@ -0,0 +1,52 @@
import React from 'react';
import { view } from 'react-easy-state';
import { Input, Button, Alert } from 'antd';
import store from '../../store';
import './index.less';
class Login extends React.Component {
state = {
username: '',
password: '',
};
render() {
return (
<div className="Login">
<h1>Login</h1>
<div>
<Input
name="username"
placeholder="Username"
value={this.state.username}
onChange={e => this.setState({ username: e.currentTarget.value })}
/>
</div>
<div>
<Input
name="password"
type="password"
placeholder="Password"
value={this.state.password}
onChange={e => this.setState({ password: e.currentTarget.value })}
/>
</div>
{store.loginError && (
<div>
<Alert message={store.loginError} type="warning" />
</div>
)}
<div>
<Button
type="primary"
onClick={() => store.login(this.state.username, this.state.password)}
>
Login
</Button>
</div>
</div>
);
}
}
export default view(Login);

View File

@ -0,0 +1,196 @@
@controls-height: 40px;
.Proposals {
margin-top: @controls-height + 0.5rem;
h1 {
font-size: 1.5rem;
}
&-controls {
height: @controls-height;
padding: 0.25rem 1rem;
margin-left: -1rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
position: fixed;
top: 0;
right: 0;
left: 216px;
z-index: 5;
background: white;
&-status {
position: fixed;
top: 0.2rem;
right: 0.2rem;
color: white;
padding: 0 0.4rem;
border-radius: 0.5rem;
background: rgba(0, 0, 0, 0.25);
}
}
&-proposal {
display: flex;
padding-bottom: 1rem;
border-bottom: 1px solid rgb(214, 214, 214);
margin-bottom: 1rem;
&-controls {
margin: 0 0.5rem 0.2rem 0;
background: rgba(0, 0, 0, 0.1);
padding: 0.1rem;
border-radius: 0.5rem;
width: fit-content;
}
&-img {
width: 100px;
height: 100px;
margin-right: 0.5rem;
background: rgba(0, 0, 0, 0.1);
& img {
width: 100%;
}
}
& button {
cursor: pointer;
margin: 0 0.3rem 0 0;
outline: none !important;
&:hover {
color: #1890ff;
}
}
&-body {
margin: 0;
border: 1px solid rgba(0, 0, 0, 0.1);
padding: 1rem;
max-height: 600px;
overflow-y: scroll;
& img {
max-width: 100%;
}
ul,
ol {
padding-left: 30px;
font-size: 1.05rem;
}
ul {
list-style: circle;
}
ol {
list-style: decimal;
}
}
&-milestones {
display: flex;
flex-flow: wrap;
& > div {
width: 316px;
margin: 0 1rem 1rem 0;
& > div > span {
display: inline-block;
margin-left: 0.3rem;
font-size: 0.8rem;
opacity: 0.7;
}
}
}
&-comments {
border-left: 1rem solid rgba(0, 0, 0, 0.1);
& > div {
margin: 0.2rem 0 0.2rem 0.4rem;
}
}
&-contract {
&-method {
opacity: 0.7;
}
&-array {
margin-left: 0.7rem;
margin-bottom: 0.2rem;
&-milestones,
&-contributors {
display: flex;
& span {
opacity: 0.7;
}
& > div {
margin-right: 0.5rem;
padding: 0.2rem;
border: 1px solid rgba(0, 0, 0, 0.1);
}
}
}
&-status {
display: inline-block;
width: 0.6rem;
height: 0.6rem;
border-radius: 0.3rem;
margin-right: 0.2rem;
&.is-unloaded {
background-color: rgb(197, 197, 197);
}
&.is-loading {
background-color: rgb(241, 177, 0);
}
&.is-waiting {
background-color: rgb(0, 226, 230);
}
&.is-loaded {
background-color: rgb(0, 165, 25);
}
&.is-error {
background-color: rgb(194, 0, 0);
}
}
&-method {
margin-top: 0.5rem;
}
&-inputs {
display: inline-block;
min-width: 300px;
background: rgba(0, 0, 0, 0.1);
padding: 0.23rem;
border-radius: 0.2rem;
margin-right: 0.5rem;
}
&-input {
margin-right: 0.5rem;
&.is-wei,
&.is-string,
&.is-integer {
width: 13rem;
}
}
& .ant-alert {
max-width: 600px;
margin: 0.5rem 0 0.5rem 0.8rem;
}
}
}
}

View File

@ -0,0 +1,406 @@
import React from 'react';
import { view } from 'react-easy-state';
import { Icon, Button, Popover, InputNumber, Checkbox, Alert, Input } from 'antd';
import { RouteComponentProps, withRouter } from 'react-router';
import Showdown from 'showdown';
import moment from 'moment';
import store from 'src/store';
import {
Proposal,
Contract,
ContractMethod as TContractMethod,
ContractMilestone,
ContractContributor,
} from 'src/types';
import './index.less';
import Field from 'components/Field';
import { Link } from 'react-router-dom';
const showdownConverter = new Showdown.Converter({
simplifiedAutoLink: true,
tables: true,
strikethrough: true,
disableForced4SpacesIndentedSublists: true,
openLinksInNewWindow: true,
excludeTrailingPunctuationFromURLs: true,
});
type Props = RouteComponentProps<any>;
class ProposalsNaked extends React.Component<Props> {
componentDidMount() {
store.fetchProposals();
}
render() {
const id = this.props.match.params.id;
const { proposals, proposalsFetched } = store;
if (!proposalsFetched) {
return 'loading proposals...';
}
if (id) {
const singleProposal = proposals.find(p => p.proposalId === id);
if (singleProposal) {
return (
<div className="Proposals">
<div className="Proposals-controls">
<Link to="/proposals">proposals</Link> <Icon type="right" /> {id}{' '}
<Button
title="refresh"
icon="reload"
onClick={() => store.fetchProposals()}
/>
<div className="Proposals-controls-status">
{store.crowdFundGeneralStatus}
</div>
</div>
<ProposalItem key={singleProposal.proposalId} {...singleProposal} />
</div>
);
} else {
return `could not find proposal: ${id}`;
}
}
return (
<div className="Proposals">
<div className="Proposals-controls">
<Button title="refresh" icon="reload" onClick={() => store.fetchProposals()} />
<div className="Proposals-controls-status">{store.crowdFundGeneralStatus}</div>
</div>
{proposals.length === 0 && <div>no proposals</div>}
{proposals.length > 0 &&
proposals.map(p => <ProposalItem key={p.proposalId} {...p} />)}
</div>
);
}
}
// tslint:disable-next-line:max-classes-per-file
class ProposalItemNaked extends React.Component<Proposal> {
state = {
showDelete: false,
};
render() {
const p = this.props;
const body = showdownConverter.makeHtml(p.body);
return (
<div key={p.proposalId} className="Proposals-proposal">
<div>
<div className="Proposals-proposal-controls">
<Popover
content={
<div>
<Button type="primary" onClick={this.handleDelete}>
delete {p.title}
</Button>{' '}
<Button onClick={() => this.setState({ showDelete: false })}>
cancel
</Button>
</div>
}
title="Permanently delete proposal?"
trigger="click"
visible={this.state.showDelete}
onVisibleChange={showDelete => this.setState({ showDelete })}
>
<Button icon="delete" shape="circle" size="small" title="delete" />
</Popover>
{/* TODO: implement disable payments on BE */}
<Button
icon="dollar"
shape="circle"
size="small"
title={false ? 'allow payments' : 'disable payments'}
type={false ? 'danger' : 'default'}
disabled={true}
/>
</div>
<b>{p.title}</b> {p.proposalId} <Field title="category" value={p.category} />
<Field title="dateCreated" value={p.dateCreated * 1000} isTime={true} />
<Field title="stage" value={p.stage} />
<Field
title={`team (${p.team.length})`}
value={
<div>
{p.team.map(u => (
<div key={u.userid}>
{u.displayName} (
<Link to={`/users/${u.accountAddress}`}>{u.accountAddress}</Link>)
</div>
))}
</div>
}
/>
<Field
title={`comments (${p.comments.length})`}
value={<div>TODO: comments</div>}
/>
<Field
title={`body (${body.length}chr)`}
value={
<div
className="Proposals-proposal-body"
dangerouslySetInnerHTML={{ __html: body }}
/>
}
/>
<Field
title={`milestones (${p.milestones.length})`}
value={
<div className="Proposals-proposal-milestones">
{p.milestones.map((ms, idx) => (
<div key={idx}>
<div>
<b>
{idx}. {ms.title}
</b>
<span>(title)</span>
</div>
<div>
{moment(ms.dateCreated).format('YYYY/MM/DD h:mm a')}
<span>(dateCreated)</span>
</div>
<div>
{moment(ms.dateEstimated).format('YYYY/MM/DD h:mm a')}
<span>(dateEstimated)</span>
</div>
<div>
{ms.stage}
<span>(stage)</span>
</div>
<div>
{JSON.stringify(ms.immediatePayout)}
<span>(immediatePayout)</span>
</div>
<div>
{ms.payoutPercent}
<span>(payoutPercent)</span>
</div>
<div>
{ms.body}
<span>(body)</span>
</div>
{/* <small>content</small>
<div>{ms.content}</div> */}
</div>
))}
</div>
}
/>
<Field
title={`web3 (${p.contractStatus || 'not loaded'})`}
value={
<div className="Proposals-proposal-contract">
<Button
icon="reload"
size="small"
title="refresh contract"
onClick={() => store.populateProposalContract(p.proposalId)}
>
refresh contract
</Button>
{Object.keys(p.contract)
.map(k => k as keyof Contract)
.map(k => (
<ContractMethod
key={k}
proposalId={p.proposalId}
name={k}
{...p.contract[k]}
/>
))}
</div>
}
/>
</div>
</div>
);
}
private handleDelete = () => {
store.deleteProposal(this.props.proposalId);
};
}
const ProposalItem = view(ProposalItemNaked);
// tslint:disable-next-line:max-classes-per-file
class ContractMethodNaked extends React.Component<
TContractMethod & { proposalId: string; name: string }
> {
state = {};
render() {
const { name, value, status, type, format } = this.props;
const isObj = typeof value === 'object' && value !== null;
const isArray = Array.isArray(value);
const fmt = (val: any) => {
if (val && format === 'time') {
const asNumber = Number(val) * 1000;
return `${moment(asNumber).format()} (${moment(asNumber).fromNow()})`;
} else if (val && format === 'duration') {
const asNumber = Number(val) * 1000;
return `${asNumber} (${moment.duration(asNumber).humanize()})`;
}
return value;
};
if (type === 'send') {
return <ContractMethodSend {...this.props} />;
}
return (
<div>
<div className={`Proposals-proposal-contract-status is-${status || ''}`} />
<span className="Proposals-proposal-contract-method">{name}:</span>
{(!isObj && <span> {fmt(value)}</span>) || (
<div className="Proposals-proposal-contract-array">
{isArray &&
name !== 'milestones' &&
name !== 'contributors' &&
(value as string[]).map((x, i) => (
<div key={x}>
{i}: {x}
</div>
))}
{isArray &&
name === 'milestones' && (
<div className="Proposals-proposal-contract-array-milestones">
{(value as ContractMilestone[]).map((cm, idx) => (
<div key={idx}>
<div>
<span>paid:</span> {JSON.stringify(cm.paid)}
</div>
<div>
<span>amount:</span> {cm.amount}
</div>
<div>
<span>payoutRequestVoteDeadline:</span>{' '}
{Number(cm.payoutRequestVoteDeadline) < 2
? cm.payoutRequestVoteDeadline
: moment(Number(cm.payoutRequestVoteDeadline) * 1000).fromNow()}
</div>
<div>
<span>amountVotingAgainstPayout:</span>{' '}
{cm.amountVotingAgainstPayout}
</div>
</div>
))}
</div>
)}
{isArray &&
name === 'contributors' && (
<div className="Proposals-proposal-contract-array-contributors">
{(value as ContractContributor[]).map(c => (
<div key={c.address}>
<div>
<span>address:</span> {c.address}
</div>
<div>
<span>milestoneNoVotes:</span>{' '}
{JSON.stringify(c.milestoneNoVotes)}
</div>
<div>
<span>contributionAmount:</span> {c.contributionAmount}
</div>
<div>
<span>refundVote:</span> {JSON.stringify(c.refundVote)}
</div>
<div>
<span>refunded:</span> {JSON.stringify(c.refunded)}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
}
const ContractMethod = view(ContractMethodNaked);
// tslint:disable-next-line:max-classes-per-file
class ContractMethodSendNaked extends React.Component<
TContractMethod & { proposalId: string; name: string }
> {
state = {
args: this.props.input.map(i => (i.type === 'boolean' ? false : '')) as any[],
};
render() {
const { name, status, input, proposalId, error } = this.props;
return (
<div className="Proposals-proposal-contract-method">
<div className={`Proposals-proposal-contract-status is-${status || ''}`} />
<div className="Proposals-proposal-contract-inputs">
{input.length === 0 && 'no input'}
{input.map(
(x, idx) =>
((x.type === 'wei' || x.type === 'integer') && (
<InputNumber
size="small"
key={x.name}
name={x.name}
placeholder={`${x.name} (${x.type})`}
onChange={val => {
const args = [...this.state.args];
args[idx] = val;
this.setState({ args });
}}
value={this.state.args[idx]}
className={`Proposals-proposal-contract-input is-${x.type || ''}`}
/>
)) ||
(x.type === 'string' && (
<Input
size="small"
key={x.name}
name={x.name}
placeholder={`${x.name} (${x.type})`}
onChange={evt => {
const args = [...this.state.args];
args[idx] = evt.currentTarget.value;
this.setState({ args });
}}
value={this.state.args[idx]}
className={`Proposals-proposal-contract-input is-${x.type || ''}`}
/>
)) || (
<Checkbox
key={x.name}
onChange={evt => {
const args = [...this.state.args];
args[idx] = evt.target.checked;
this.setState({ args });
}}
value={this.state.args[idx]}
className={`Proposals-proposal-contract-input is-${x.type || ''}`}
>
{x.name}
</Checkbox>
),
)}
</div>
<Button
icon="arrow-right"
size="default"
loading={status === 'loading' || status === 'waiting'}
onClick={() =>
store.proposalContractSend(
proposalId,
name as keyof Contract,
input,
this.state.args,
)
}
>
{name}
</Button>
{error && <Alert message={error} type="error" closable={true} />}
</div>
);
}
}
const ContractMethodSend = view(ContractMethodSendNaked);
const Proposals = withRouter(view(ProposalsNaked));
export default Proposals;

View File

@ -0,0 +1,40 @@
.Template {
&-sider {
overflow: auto;
height: 100vh;
position: fixed;
left: 0;
&-logo {
color: #ffffff;
text-align: center;
font-size: 1.5rem;
}
}
&-layout {
margin-left: 200px;
background: #ffffff;
&-content {
padding: 1rem;
overflow: initial;
}
}
&-errors {
background: rgba(0, 0, 0, 0.3);
position: fixed;
display: flex;
flex-flow: column;
align-items: center;
top: 0;
padding: 0.8rem;
width: 100%;
z-index: 100;
& > div {
margin: 0.2rem;
width: 400px;
}
}
}

View File

@ -0,0 +1,68 @@
import React from 'react';
import { hot } from 'react-hot-loader';
import { RouteComponentProps, withRouter } from 'react-router';
import { Link } from 'react-router-dom';
import { Layout, Menu, Icon, Alert } from 'antd';
import './index.less';
import store from 'src/store';
import { view } from 'react-easy-state';
const { Content, Sider } = Layout;
type Props = RouteComponentProps<any>;
class Template extends React.Component<Props> {
render() {
const { pathname } = this.props.location;
return (
<Layout className="Template">
{store.generalError.length > 0 && (
<div className="Template-errors">
{store.generalError.map((e, i) => (
<Alert
key={i}
message={e}
type="error"
closable={true}
onClose={() => store.removeGeneralError(i)}
/>
))}
</div>
)}
<Sider className="Template-sider">
<div className="Template-sider-logo">grant.io</div>
<Menu theme="dark" mode="inline" selectedKeys={[pathname]}>
<Menu.Item key="/">
<Link to="/">
<Icon type="home" />
<span className="nav-text">home</span>
</Link>
</Menu.Item>
<Menu.Item key="/users">
<Link to="/users">
<Icon type="user" />
<span className="nav-text">users</span>
</Link>
</Menu.Item>
<Menu.Item key="/proposals">
<Link to="/proposals">
<Icon type="file" />
<span className="nav-text">proposals</span>
</Link>
</Menu.Item>
<Menu.Item key="logout" onClick={store.logout}>
<Icon type="logout" />
<span className="nav-text">logout</span>
</Menu.Item>
</Menu>
</Sider>
<Layout className="Template-layout">
<Content className="Template-layout-content">{this.props.children}</Content>
</Layout>
</Layout>
);
}
}
const ConnectedTemplate = withRouter(view(Template));
export default hot(module)(ConnectedTemplate);

View File

@ -0,0 +1,57 @@
@controls-height: 40px;
.Users {
margin-top: @controls-height + 0.5rem;
h1 {
font-size: 1.5rem;
}
&-controls {
height: @controls-height;
padding: 0.25rem 1rem;
margin-left: -1rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
position: fixed;
top: 0;
right: 0;
left: 216px;
z-index: 5;
background: white;
}
&-user {
display: flex;
padding-bottom: 1rem;
border-bottom: 1px solid rgb(214, 214, 214);
margin-bottom: 1rem;
&-controls {
margin: 0 0.5rem 0.5rem 0;
background: rgba(0, 0, 0, 0.1);
padding: 0.1rem;
border-radius: 0.5rem;
}
&-img {
width: 100px;
height: 100px;
margin-right: 0.5rem;
background: rgba(0, 0, 0, 0.1);
& img {
width: 100%;
}
}
& button {
cursor: pointer;
margin: 0 0.3rem 0 0;
outline: none !important;
&:hover {
color: #1890ff;
}
}
}
}

View File

@ -0,0 +1,139 @@
import React from 'react';
import { view } from 'react-easy-state';
import { Button, Popover, Icon } from 'antd';
import { RouteComponentProps, withRouter } from 'react-router';
import { Link } from 'react-router-dom';
import store from 'src/store';
import { User } from 'src/types';
import './index.less';
import Field from 'components/Field';
type Props = RouteComponentProps<any>;
class UsersNaked extends React.Component<Props> {
componentDidMount() {
store.fetchUsers();
}
render() {
const id = this.props.match.params.id;
const { users, usersFetched } = store;
if (!usersFetched) {
return 'loading users...';
}
if (id) {
const singleUser = users.find(u => u.accountAddress === id);
if (singleUser) {
return (
<div className="Users">
<div className="Users-controls">
<Link to="/users">users</Link> <Icon type="right" /> {id}{' '}
<Button title="refresh" icon="reload" onClick={() => store.fetchUsers()} />
</div>
<UserItem key={singleUser.userid} {...singleUser} />
</div>
);
} else {
return `could not find user: ${id}`;
}
}
return (
<div className="Users">
<div className="Users-controls">
<Button title="refresh" icon="reload" onClick={() => store.fetchUsers()} />
</div>
{users.length === 0 && <div>no users</div>}
{users.length > 0 && users.map(u => <UserItem key={u.userid} {...u} />)}
</div>
);
}
}
// tslint:disable-next-line:max-classes-per-file
class UserItemNaked extends React.Component<User> {
state = {
showProposals: false,
activeProposal: '',
showDelete: false,
};
render() {
const u = this.props;
return (
<div key={u.userid} className="Users-user">
<div>
<div className="Users-user-controls">
<Popover
content={
<div>
<Button type="primary" onClick={this.handleDelete}>
delete {u.emailAddress}
</Button>{' '}
<Button onClick={() => this.setState({ showDelete: false })}>
cancel
</Button>
</div>
}
title="Permanently delete user?"
trigger="click"
visible={this.state.showDelete}
onVisibleChange={showDelete => this.setState({ showDelete })}
>
<Button icon="delete" shape="circle" size="small" title="delete" />
</Popover>
{/* TODO: implement silence user on BE */}
<Button
icon="notification"
shape="circle"
size="small"
title={false ? 'allow commenting' : 'disable commenting'}
type={false ? 'danger' : 'default'}
disabled={true}
/>
</div>
<div className="Users-user-img">
{u.avatar ? <img src={u.avatar.imageUrl} /> : 'n/a'}
</div>
</div>
<div>
<Field title="displayName" value={u.displayName} />
<Field title="title" value={u.title} />
<Field title="emailAddress" value={u.emailAddress} />
<Field title="accountAddress" value={u.accountAddress} />
<Field title="userid" value={u.userid} />
<Field
title="avatar.imageUrl"
value={(u.avatar && u.avatar.imageUrl) || 'n/a'}
/>
<Field
title={`proposals (${u.proposals.length})`}
value={
<div className="Users-user-proposals">
{u.proposals.map(p => (
<div key={p.proposalId}>
{p.title} (
<Link to={`/proposals/${p.proposalId}`}>{p.proposalId}</Link>)
</div>
))}
</div>
}
/>
<Field
title={`comments (${u.comments.length})`}
value={<div>TODO: comments</div>}
/>
</div>
</div>
);
}
private handleDelete = () => {
store.deleteUser(this.props.accountAddress);
};
}
const UserItem = view(UserItemNaked);
const Users = withRouter(view(UsersNaked));
export default Users;

14
admin/src/index.tsx Normal file
View File

@ -0,0 +1,14 @@
import '@babel/polyfill';
import React from 'react';
import { hot } from 'react-hot-loader';
import { render } from 'react-dom';
import { BrowserRouter as Router } from 'react-router-dom';
import Routes from './Routes';
const App = hot(module)(() => (
<Router>
<Routes />
</Router>
));
render(<App />, document.getElementById('root'));

View File

@ -0,0 +1,15 @@
<!doctype html>
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Admin - Grant.io</title>
</head>
<body>
<div id='root'>
</div>
</body>
</html>

191
admin/src/store.ts Normal file
View File

@ -0,0 +1,191 @@
import { cloneDeep } from 'lodash';
import { store } from 'react-easy-state';
import axios, { AxiosError } from 'axios';
import { User, Proposal, INITIAL_CONTRACT, Contract, ContractMethodInput } from './types';
import {
initializeWeb3,
populateProposalContract,
proposalContractSend,
} from './web3helper';
// API
const api = axios.create({
baseURL: process.env.BACKEND_URL + '/api/v1',
withCredentials: true,
});
async function login(username: string, password: string) {
const { data } = await api.post('/admin/login', {
username,
password,
});
return data.isLoggedIn;
}
async function logout() {
const { data } = await api.get('/admin/logout');
return data.isLoggedIn;
}
async function checkLogin() {
const { data } = await api.get('/admin/checklogin');
return data.isLoggedIn;
}
async function fetchStats() {
const { data } = await api.get('/admin/stats');
return data;
}
async function fetchUsers() {
const { data } = await api.get('/admin/users');
return data;
}
async function deleteUser(id: string) {
const { data } = await api.delete('/admin/users/' + id);
return data;
}
async function fetchProposals() {
const { data } = await api.get('/admin/proposals');
data.forEach((p: Proposal) => (p.contract = cloneDeep(INITIAL_CONTRACT)));
return data;
}
async function deleteProposal(id: string) {
const { data } = await api.delete('/admin/proposals/' + id);
return data;
}
// STORE
const app = store({
hasCheckedLogin: false,
isLoggedIn: false,
loginError: '',
generalError: [] as string[],
stats: {
userCount: -1,
proposalCount: -1,
},
usersFetched: false,
users: [] as User[],
proposalsFetched: false,
proposals: [] as Proposal[],
web3Type: '',
ethNetId: -1,
ethAccount: '',
crowdFundFactoryDefinitionStatus: '',
crowdFundGeneralStatus: 'idle',
removeGeneralError(i: number) {
app.generalError.splice(i, 1);
},
async checkLogin() {
app.isLoggedIn = await checkLogin();
app.hasCheckedLogin = true;
},
async login(username: string, password: string) {
try {
app.isLoggedIn = await login(username, password);
} catch (e) {
app.loginError = e.response.data.message;
}
},
async logout() {
try {
app.isLoggedIn = await logout();
} catch (e) {
app.generalError.push(e.toString());
}
},
async fetchStats() {
try {
app.stats = await fetchStats();
} catch (e) {
handleApiError(e);
}
},
async fetchUsers() {
try {
app.users = await fetchUsers();
app.usersFetched = true;
} catch (e) {
handleApiError(e);
}
},
async deleteUser(id: string) {
try {
await deleteUser(id);
app.users = app.users.filter(u => u.accountAddress !== id && u.emailAddress !== id);
} catch (e) {
handleApiError(e);
}
},
async fetchProposals() {
try {
app.proposals = await fetchProposals();
app.proposalsFetched = true;
// for (const p of app.proposals) {
// TODO: partial populate contributorList
// await app.populateProposalContract(p.proposalId);
// }
} catch (e) {
handleApiError(e);
}
},
async populateProposalContract(proposalId: string) {
if (web3) {
await populateProposalContract(app, web3, proposalId);
}
},
async proposalContractSend(
proposalId: string,
methodName: keyof Contract,
inputs: ContractMethodInput[],
args: any[],
) {
if (web3) {
await proposalContractSend(app, web3, proposalId, methodName, inputs, args);
}
},
async deleteProposal(id: string) {
try {
await deleteProposal(id);
app.proposals = app.proposals.filter(p => p.proposalId === id);
} catch (e) {
handleApiError(e);
}
},
});
function handleApiError(e: AxiosError) {
if (e.response && e.response.data!.message) {
app.generalError.push(e.response!.data.message);
} else if (e.response && e.response.data!.data!) {
app.generalError.push(e.response!.data.data);
} else {
app.generalError.push(e.toString());
}
}
(window as any).appStore = app;
// check login status periodically
app.checkLogin();
window.setInterval(app.checkLogin, 10000);
const web3 = initializeWeb3(app);
export type TApp = typeof app;
export default app;

View File

@ -0,0 +1,16 @@
* {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
}
html {
font-size: 16px;
@media (max-width: 900px) {
font-size: 14px;
}
@media (max-width: 600px) {
font-size: 12px;
}
}

129
admin/src/types.ts Normal file
View File

@ -0,0 +1,129 @@
// backend
export interface SocialMedia {
socialMediaLink: string;
}
export interface Milestone {
body: string;
content: string;
dateCreated: string;
dateEstimated: string;
immediatePayout: boolean;
payoutPercent: string;
stage: string;
title: string;
}
export interface Proposal {
proposalId: string;
dateCreated: number;
title: string;
body: string;
stage: string;
category: string;
milestones: Milestone[];
team: User[];
comments: Comment[];
contractStatus: string;
contract: Contract;
}
export interface Comment {
commentId: string;
dateCreated: string;
content: string;
}
export interface User {
accountAddress: string;
avatar: null | { imageUrl: string };
displayName: string;
emailAddress: string;
socialMedias: SocialMedia[];
title: string;
userid: number;
proposals: Proposal[];
comments: Comment[];
}
// web3 contract
export const INITIAL_CONTRACT_CONTRIBUTOR = {
address: '',
milestoneNoVotes: [] as string[],
contributionAmount: '',
refundVote: false,
refunded: false,
};
export type ContractContributor = typeof INITIAL_CONTRACT_CONTRIBUTOR;
export const INITIAL_CONTRACT_MILESTONE = {
amount: '',
payoutRequestVoteDeadline: '',
amountVotingAgainstPayout: '',
paid: '',
};
export type ContractMilestone = typeof INITIAL_CONTRACT_MILESTONE;
export interface ContractMethodInput {
type: string;
name: string;
}
export const INITIAL_CONTRACT_METHOD = {
updated: '',
status: 'unloaded',
value: '' as string | string[] | ContractMilestone[] | ContractContributor[],
type: '',
input: [] as ContractMethodInput[],
error: '',
format: '',
};
export type ContractMethod = typeof INITIAL_CONTRACT_METHOD;
export const INITIAL_CONTRACT = {
isCallerTrustee: { ...INITIAL_CONTRACT_METHOD },
immediateFirstMilestonePayout: { ...INITIAL_CONTRACT_METHOD },
beneficiary: { ...INITIAL_CONTRACT_METHOD },
amountRaised: { ...INITIAL_CONTRACT_METHOD },
raiseGoal: { ...INITIAL_CONTRACT_METHOD },
isRaiseGoalReached: { ...INITIAL_CONTRACT_METHOD },
amountVotingForRefund: { ...INITIAL_CONTRACT_METHOD },
milestoneVotingPeriod: { ...INITIAL_CONTRACT_METHOD, format: 'duration' },
deadline: { ...INITIAL_CONTRACT_METHOD, format: 'time' },
isFailed: { ...INITIAL_CONTRACT_METHOD },
getBalance: { ...INITIAL_CONTRACT_METHOD, type: 'eth' },
frozen: { ...INITIAL_CONTRACT_METHOD },
getFreezeReason: { ...INITIAL_CONTRACT_METHOD },
trustees: { ...INITIAL_CONTRACT_METHOD, type: 'array' },
contributorList: { ...INITIAL_CONTRACT_METHOD, type: 'array' },
contributors: { ...INITIAL_CONTRACT_METHOD, type: 'deep' },
milestones: { ...INITIAL_CONTRACT_METHOD, type: 'array' },
contribute: {
...INITIAL_CONTRACT_METHOD,
type: 'send',
input: [{ name: 'value', type: 'wei' }],
},
refund: {
...INITIAL_CONTRACT_METHOD,
type: 'send',
input: [],
},
withdraw: {
...INITIAL_CONTRACT_METHOD,
type: 'send',
input: [{ name: 'address', type: 'string' }],
},
requestMilestonePayout: {
...INITIAL_CONTRACT_METHOD,
type: 'send',
input: [{ name: 'index', type: 'integer' }],
},
voteMilestonePayout: {
...INITIAL_CONTRACT_METHOD,
type: 'send',
input: [{ name: 'index', type: 'integer' }, { name: 'vote', type: 'boolean' }],
},
payMilestonePayout: {
...INITIAL_CONTRACT_METHOD,
type: 'send',
input: [{ name: 'index', type: 'integer' }],
},
voteRefund: {
...INITIAL_CONTRACT_METHOD,
type: 'send',
input: [{ name: 'vote', type: 'boolean' }],
},
};
export type Contract = typeof INITIAL_CONTRACT;

219
admin/src/web3helper.ts Normal file
View File

@ -0,0 +1,219 @@
import { pick } from 'lodash';
import Web3 from 'web3';
import { TransactionObject } from 'web3/eth/types';
import EthContract from 'web3/eth/contract';
import { TApp } from './store';
import CrowdFundFactory from 'contracts/contracts/CrowdFundFactory.json';
import CrowdFund from 'contracts/contracts/CrowdFund.json';
import {
Proposal,
Contract,
INITIAL_CONTRACT,
ContractMilestone,
ContractContributor,
ContractMethodInput,
INITIAL_CONTRACT_CONTRIBUTOR,
INITIAL_CONTRACT_MILESTONE,
} from './types';
type Web3Method<T> = (index: number) => TransactionObject<T>;
export function initializeWeb3(app: TApp) {
let web3 = (window as any).web3;
if (web3) {
app.web3Type = 'injected';
web3 = new Web3(web3.currentProvider);
} else if (process.env.NODE_ENV !== 'production') {
const localProviderString = 'http://localhost:8545';
const provider = new Web3.providers.HttpProvider(localProviderString);
web3 = new Web3(provider);
app.web3Type = 'local - ' + localProviderString;
} else {
console.error('No web3 detected!');
return;
}
getNetwork(app, web3);
getAccount(app, web3);
checkCrowdFundFactory(app, web3);
window.setInterval(() => getAccount(app, web3), 10000);
return web3;
}
function getNetwork(app: TApp, web3: Web3) {
web3.eth.net.getId((_, netId) => {
app.ethNetId = netId;
});
}
async function getAccount(app: TApp, web3: Web3) {
await web3.eth.getAccounts((_, accounts) => {
app.ethAccount = (accounts.length && accounts[0]) || '';
});
}
function checkCrowdFundFactory(app: TApp, web3: Web3) {
web3.eth.net.getId((_, netId) => {
const networks = Object.keys((CrowdFundFactory as any).networks).join(', ');
if (!(CrowdFundFactory as any).networks[netId]) {
app.crowdFundFactoryDefinitionStatus = `network mismatch (has ${networks})`;
} else {
app.crowdFundFactoryDefinitionStatus = `loaded, has correct network (${networks})`;
}
});
}
export async function proposalContractSend(
app: TApp,
web3: Web3,
proposalId: string,
methodName: keyof Contract,
inputs: ContractMethodInput[],
args: any[],
) {
const storeProposal = app.proposals.find(p => p.proposalId === proposalId);
if (storeProposal) {
await getAccount(app, web3);
const storeMethod = storeProposal.contract[methodName];
const contract = new web3.eth.Contract(CrowdFund.abi, proposalId);
app.crowdFundGeneralStatus = `calling (${storeProposal.title}).${methodName}...`;
try {
console.log(args);
storeMethod.status = 'loading';
storeMethod.error = '';
if (inputs.length === 1 && inputs[0].name === 'value') {
await contract.methods[methodName]()
.send({
from: app.ethAccount,
value: args[0],
})
.once('transactionHash', () => {
storeMethod.status = 'waiting';
})
.once('confirmation', () => {
storeMethod.status = 'loaded';
});
} else {
await contract.methods[methodName](...args)
.send({ from: app.ethAccount })
.once('transactionHash', () => {
storeMethod.status = 'waiting';
})
.once('confirmation', () => {
storeMethod.status = 'loaded';
});
}
} catch (e) {
console.error(e);
storeMethod.error = e.message || e.toString();
storeMethod.status = 'error';
}
app.crowdFundGeneralStatus = `idle`;
}
}
export async function populateProposalContract(
app: TApp,
web3: Web3,
proposalId: string,
) {
const storeProposal = app.proposals.find(p => p.proposalId === proposalId);
const contract = new web3.eth.Contract(CrowdFund.abi, proposalId);
if (storeProposal) {
storeProposal.contractStatus = 'loading...';
const methods = Object.keys(INITIAL_CONTRACT).map(k => k as keyof Contract);
for (const method of methods) {
const methodType = INITIAL_CONTRACT[method].type;
if (methodType !== 'deep' && methodType !== 'send') {
app.crowdFundGeneralStatus = `calling (${storeProposal.title}).${method}...`;
const storeMethod = storeProposal.contract[method];
const contractMethod = contract.methods[method];
try {
storeMethod.status = 'loading';
if (methodType === 'eth' && method === 'getBalance') {
storeMethod.value = (await web3.eth.getBalance(proposalId)) + '';
} else if (methodType === 'array') {
const result = await collectArrayElements(contractMethod, app.ethAccount);
if (method === 'milestones') {
storeMethod.value = result.map(r =>
// clean-up incoming object before attaching to store
cleanClone(INITIAL_CONTRACT_MILESTONE, r),
);
} else {
storeMethod.value = result.map(r => r + '');
}
} else {
storeMethod.value =
(await contractMethod().call({ from: app.ethAccount })) + '';
}
storeMethod.status = 'loaded';
} catch (e) {
console.error(proposalId, method, e);
storeMethod.status = 'error';
}
}
}
await populateProposalContractDeep(storeProposal.contract, contract, app.ethAccount);
storeProposal.contractStatus = 'updated @ ' + new Date().toISOString();
app.crowdFundGeneralStatus = `idle`;
}
}
async function populateProposalContractDeep(
storeContract: Proposal['contract'],
contract: EthContract,
fromAcct: string,
) {
storeContract.contributors.status = 'loading';
const milestones = storeContract.milestones.value as ContractMilestone[];
const contributorList = storeContract.contributorList.value as string[];
const contributors = await Promise.all(
contributorList.map(async addr => {
const contributor = await contract.methods
.contributors(addr)
.call({ from: fromAcct });
contributor.address = addr;
contributor.milestoneNoVotes = await Promise.all(
milestones.map(
async (_, idx) =>
await contract.methods
.getContributorMilestoneVote(addr, idx)
.call({ from: fromAcct }),
),
);
contributor.contributionAmount = await contract.methods
.getContributorContributionAmount(addr)
.call({ from: fromAcct });
// clean-up incoming object before attaching to store
return cleanClone(INITIAL_CONTRACT_CONTRIBUTOR, contributor);
}),
);
storeContract.contributors.value = contributors as ContractContributor[];
storeContract.contributors.status = 'loaded';
}
// clone and filter keys by keySource object's keys
export function cleanClone<T extends object>(keySource: T, target: Partial<T>) {
const sourceKeys = Object.keys(keySource);
const fullClone = { ...(target as object) };
const clone = pick(fullClone, sourceKeys);
return clone as T;
}
export async function collectArrayElements<T>(
method: Web3Method<T>,
account: string,
): Promise<T[]> {
const arrayElements = [];
let noError = true;
let index = 0;
while (noError) {
try {
arrayElements.push(await method(index).call({ from: account }));
index += 1;
} catch (e) {
noError = false;
}
}
return arrayElements;
}

30
admin/tsconfig.json Normal file
View File

@ -0,0 +1,30 @@
{
"compileOnSave": false,
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"outDir": "dist",
"jsx": "react",
"allowJs": true,
"moduleResolution": "node",
"strict": true,
"allowSyntheticDefaultImports": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitAny": true,
"removeComments": false,
"preserveConstEnums": true,
"sourceMap": true,
"skipLibCheck": true,
"baseUrl": "src",
"lib": ["dom", "es2017"],
"paths": {
"src/*": ["./*"],
"contracts/*": ["../../contract/build/*"],
"components/*": ["./components/*"],
"styles/*": ["./styles/*"]
}
},
"include": ["./src/**/*"],
"exclude": ["./src/static"]
}

16
admin/tslint.json Normal file
View File

@ -0,0 +1,16 @@
{
"extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
"rules": {
"ordered-imports": false,
"object-literal-sort-keys": false,
"jsx-boolean-value": false,
"jsx-no-lambda": false,
"member-access": false,
"interface-name": [true, "never-prefix"],
"curly": [true, "ignore-same-line"],
"no-console": false
},
"linterOptions": {
"exclude": ["../contract/build/contracts/**/*.json"]
}
}

139
admin/webpack.config.js Normal file
View File

@ -0,0 +1,139 @@
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const isDev = process.env.NODE_ENV === 'development';
require('dotenv').config();
module.exports = {
mode: isDev ? 'development' : 'production',
entry: {
bundle: './src/index.tsx',
},
output: {
path: path.join(__dirname, 'build'),
filename: 'bundle.js',
publicPath: '/',
chunkFilename: isDev ? '[name].chunk.js' : '[name].[chunkhash:8].chunk.js',
},
devtool: 'inline-source-map',
devServer: {
port: 3500,
contentBase: './build',
hot: true,
historyApiFallback: {
disableDotRule: true,
},
},
module: {
rules: [
// typescript
{
test: /\.tsx?$/,
use: [
{
loader: 'babel-loader',
options: {
plugins: [
'react-hot-loader/babel',
'@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-proposal-class-properties',
['import', { libraryName: 'antd', style: true }],
],
presets: ['@babel/react', ['@babel/env', { useBuiltIns: 'entry' }]],
},
},
{
loader: 'ts-loader',
options: { transpileOnly: isDev },
},
],
},
// less
{
test: /\.less$/,
// exclude: [/node_modules/],
use: [
isDev && 'style-loader',
!isDev && MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
},
{
loader: 'less-loader',
options: { javascriptEnabled: true },
},
].filter(Boolean),
},
// images (url loader)
{
test: /\.(png|jpe?g|gif)$/,
loader: require.resolve('url-loader'),
options: {
limit: 2048,
name: 'assets/[name].[hash:8].[ext]',
},
},
// svg
{
test: /\.svg$/,
issuer: {
test: /\.tsx?$/,
},
use: [
{
loader: '@svgr/webpack',
options: {
svgoConfig: {
plugins: [{ inlineStyles: { onlyMatchedOnce: false } }],
},
},
},
], // svg -> react component
},
// other files (file loader)
{
exclude: [/\.(js|ts|tsx|css|less|mjs|html|json|ejs)$/],
use: [
{
loader: 'file-loader',
options: {
name: 'assets/[name].[hash:8].[ext]',
},
},
],
},
],
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.mjs', '.json'],
// resolveModules = [ './src', path.join(__dirname, 'node_modules')]
// tsconfig.compilerOptions.paths should sync with these
alias: {
src: path.resolve(__dirname, 'src'),
contracts: path.resolve(__dirname, '../contract/build'),
components: path.resolve(__dirname, 'src/components'),
styles: path.resolve(__dirname, 'src/styles'),
},
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
new CleanWebpackPlugin(['build']),
new webpack.DefinePlugin({
'process.env.BACKEND_URL': JSON.stringify(process.env.BACKEND_URL),
}),
new HtmlWebpackPlugin({
template: './src/static/index.html',
}),
new MiniCssExtractPlugin({
filename:
process.env.NODE_ENV === 'development' ? '[name].css' : '[name].[hash:8].css',
chunkFilename:
process.env.NODE_ENV === 'development'
? '[name].chunk.css'
: '[name].[chunkhash:8].chunk.css',
}),
],
};

12979
admin/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,3 +4,4 @@ FLASK_ENV=development
DATABASE_URL="sqlite:////tmp/dev.db"
REDISTOGO_URL="redis://localhost:6379"
SECRET_KEY="not-so-secret"
SENDGRID_API_KEY="optional, but emails won't send without it"

View File

@ -2,9 +2,7 @@
This is the backend component of [Grant.io](http://grant.io).
## Database Setup
## Environment Setup
Run the following commands to bootstrap your environment.
Note: db setup is configured in .env when running locally. SQLLite is used by default in /tmp/
@ -19,6 +17,11 @@ Note: db setup is configured in .env when running locally. SQLLite is used by de
# Create environment variables file, edit as needed
cp .env.example .env
If you want emails to work properly, you'll both need a SendGrid secret api key in `.env`,
and if youre running Python 3.6+ on macOS, you'll need to
[fix your certificates](https://stackoverflow.com/a/42334357).
## Database Setup
Once you have installed your DBMS, run the following to create your app's
database tables and perform the initial migration
@ -28,7 +31,7 @@ database tables and perform the initial migration
## Running the App
Depending on what you need to run, there are several servies that need to be started
Depending on what you need to run, there are several services that need to be started
If you just need the API, you can run
@ -82,3 +85,11 @@ To create a proposal, run
flask create_proposal "FUNDING_REQUIRED" 1 123 "My Awesome Proposal" "### Hi! I have a great proposal"
## External Services
To decode EIP-712 signed messages, a Grant.io deployed service was created `https://eip-712.herokuapp.com`.
To adjust this endpoint, simply export `AUTH_URL` with a new endpoint value:
export AUTH_URL=http://new-endpoint.com
To learn more about this auth service, you can visit the repo [here](https://github.com/grant-project/eip-712-server).

View File

@ -1,72 +0,0 @@
import copy
import re
from flask import jsonify
def _camel_dict(dict_obj, deep=True):
converted_dict_obj = {}
for snake_case_k in dict_obj:
camel_case_k = re.sub('_([a-z])', lambda match: match.group(1).upper(), snake_case_k)
value = dict_obj[snake_case_k]
if type(value) == dict and deep:
converted_dict_obj[camel_case_k] = camel(**value)
elif type(value) == list and deep:
converted_list_items = []
for item in value:
converted_list_items.append(camel(**item))
converted_dict_obj[camel_case_k] = converted_list_items
else:
converted_dict_obj[camel_case_k] = dict_obj[snake_case_k]
return converted_dict_obj
def camel(dict_or_list_obj=None, **kwargs):
dict_or_list_obj = kwargs if kwargs else dict_or_list_obj
deep = True
if type(dict_or_list_obj) == dict:
return _camel_dict(dict_obj=dict_or_list_obj, deep=deep)
elif type(dict_or_list_obj) == list or type(dict_or_list_obj) == tuple or type(dict_or_list_obj) == map:
return list(map(_camel_dict, list(dict_or_list_obj)))
else:
raise ValueError("type {} is not supported!".format(type(dict_or_list_obj)))
"""
JSONResponse allows several argument formats:
1. JSONResponse([{"userId": 1, "name": "John" }, {"userId": 2, "name": "Dave" }])
2. JSONResponse(result=[my_results])
JSONResponse does not accept the following:
1. Intermixed positional and keyword arguments: JSONResponse(some_data, wow=True)
1a. The exception to this is _statusCode, which is allowed to be mixed.
An HTTP Status code should be set here by the caller, or 200 will be used.
1. Multiple positional arguments: JSONResponse(some_data, other_data)
"""
# TODO - use something standard. Insane that it's so hard to camelCase JSON output
def JSONResponse(*args, **kwargs):
if args:
if len(args) > 1:
raise ValueError("Only one positional arg supported")
if kwargs.get("_statusCode"):
status = copy.copy(kwargs["_statusCode"])
del kwargs["_statusCode"]
else:
status = 200
if args and kwargs:
raise ValueError("Only positional args or keyword args supported, not both")
if not kwargs and not args:
# TODO add log. This should never happen
return jsonify({}), 500
if kwargs:
return jsonify(camel(**kwargs)), status
else:
return jsonify(camel(args[0])), status

View File

@ -0,0 +1 @@
from . import views

View File

@ -0,0 +1,133 @@
from functools import wraps
from flask import Blueprint, g, jsonify, session
from flask_yoloapi import endpoint, parameter
from hashlib import sha256
from uuid import uuid4
from flask_cors import CORS, cross_origin
from sqlalchemy import func
from grant.extensions import db
from grant.user.models import User, users_schema
from grant.proposal.models import Proposal, proposals_schema
from grant.comment.models import Comment, comments_schema
blueprint = Blueprint('admin', __name__, url_prefix='/api/v1/admin')
admin_auth = {
"username": "admin",
"password": "79994491a17ec1d817fb0330303ea88880835961fbab1d12329f5d720602fbb3",
"salt": "ad01deb1ccba4d0e8b831ed3d1e82c10"
}
def auth_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if 'username' in session:
return f(*args, **kwargs)
else:
return {"message": "Authentication required"}, 401
return decorated
@blueprint.route("/checklogin", methods=["GET"])
@cross_origin(supports_credentials=True)
@endpoint.api()
def loggedin():
if 'username' in session:
return {"isLoggedIn": True}
if 'username' not in session:
return {"isLoggedIn": False}
@blueprint.route("/login", methods=["POST"])
@cross_origin(supports_credentials=True)
@endpoint.api(
parameter('username', type=str, required=False),
parameter('password', type=str, required=False),
)
def login(username, password):
pass_salt = ('%s%s' % (password, admin_auth['salt'])).encode('utf-8')
pass_hash = sha256(pass_salt).hexdigest()
if username == admin_auth['username'] and pass_hash == admin_auth['password']:
session['username'] = username
return {"isLoggedIn": True}
else:
return {"message": "Username or password incorrect."}, 401
@blueprint.route("/logout", methods=["GET"])
@cross_origin(supports_credentials=True)
@endpoint.api()
def logout():
del session['username']
return {"isLoggedIn": False}
@blueprint.route("/stats", methods=["GET"])
@cross_origin(supports_credentials=True)
@endpoint.api()
@auth_required
def stats():
user_count = db.session.query(func.count(User.id)).scalar()
proposal_count = db.session.query(func.count(Proposal.id)).scalar()
return {
"userCount": user_count,
"proposalCount": proposal_count
}
@blueprint.route('/users/<id>', methods=['DELETE'])
@cross_origin(supports_credentials=True)
@endpoint.api()
@auth_required
def delete_user(id):
user = User.get_by_email_or_account_address(email_address=id, account_address=id)
# TODO: fix delete
if user:
db.session.delete(user)
db.session.commit()
return {}, 204
return {"message": "No such user."}, 404
@blueprint.route("/users", methods=["GET"])
@cross_origin(supports_credentials=True)
@endpoint.api()
@auth_required
def get_users():
users = User.query.all()
result = users_schema.dump(users)
for user in result:
user_proposals = Proposal.query.filter(Proposal.team.any(id=user['userid'])).all()
user['proposals'] = proposals_schema.dump(user_proposals)
user_comments = Comment.query.filter(Comment.user_id == user['userid']).all()
user['comments'] = comments_schema.dump(user_comments)
return result
@blueprint.route("/proposals", methods=["GET"])
@cross_origin(supports_credentials=True)
@endpoint.api()
@auth_required
def get_proposals():
proposals = Proposal.query.order_by(Proposal.date_created.desc()).all()
dumped_proposals = proposals_schema.dump(proposals)
return dumped_proposals
@blueprint.route('/proposals/<id>', methods=['DELETE'])
@cross_origin(supports_credentials=True)
@endpoint.api()
@auth_required
def delete_proposal(id):
proposal = Proposal.query.filter_by(proposal_id=id).first()
if proposal:
# TODO: fix delete function
db.session.delete(proposal)
db.session.commit()
return {}, 204
return {"message": "No such proposal."}, 404

View File

@ -3,8 +3,8 @@
from flask import Flask
from flask_cors import CORS
from grant import commands, proposal, user, comment, milestone
from grant.extensions import bcrypt, migrate, db, ma
from grant import commands, proposal, user, comment, milestone, admin
from grant.extensions import bcrypt, migrate, db, ma, mail
def create_app(config_object="grant.settings"):
@ -23,6 +23,7 @@ def register_extensions(app):
db.init_app(app)
migrate.init_app(app, db)
ma.init_app(app)
mail.init_app(app)
CORS(app)
return None
@ -33,6 +34,8 @@ def register_blueprints(app):
app.register_blueprint(proposal.views.blueprint)
app.register_blueprint(user.views.blueprint)
app.register_blueprint(milestone.views.blueprint)
app.register_blueprint(admin.views.blueprint)
def register_shellcontext(app):
"""Register shell context objects."""

View File

@ -1,6 +1,5 @@
from flask import Blueprint
from grant import JSONResponse
from flask import Blueprint, jsonify
from animal_case import animalify
from .models import Comment, comments_schema
@ -11,4 +10,4 @@ blueprint = Blueprint("comment", __name__, url_prefix="/api/v1/comment")
def get_comments():
all_comments = Comment.query.all()
result = comments_schema.dump(all_comments)
return JSONResponse(result)
return jsonify(animalify(result))

View File

@ -0,0 +1,45 @@
from flask import render_template, Markup
from grant.extensions import mail
default_template_args = {
'home_url': 'https://grant.io',
'account_url': 'https://grant.io/user',
'email_settings_url': 'https://grant.io/user/settings',
'unsubscribe_url': 'https://grant.io/unsubscribe',
}
email_template_args = {
'signup': {
'subject': 'Confirm your email on Grant.io',
'title': 'Welcome to Grant.io!',
'preview': 'Welcome to Grant.io, we just need to confirm your email address.',
},
}
def send_email(to, type, email_args):
try:
body_text = render_template('emails/%s.txt' % (type), args=email_args)
body_html = render_template('emails/%s.html' % (type), args=email_args)
html = render_template('emails/template.html', args={
**default_template_args,
**email_template_args[type],
'body': Markup(body_html),
})
text = render_template('emails/template.txt', args={
**default_template_args,
**email_template_args[type],
'body': body_text,
})
res = mail.send_email(
to_email=to,
subject=email_template_args[type]['subject'],
text=text,
html=html,
)
print('Just sent an email to %s of type %s, response code: %s' % (to, type, res.status_code))
except Exception as e:
print('An error occured while sending an email to %s - %s: %s' % (to, e.__class__.__name__, e))

View File

@ -4,8 +4,10 @@ from flask_bcrypt import Bcrypt
from flask_marshmallow import Marshmallow
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from flask_sendgrid import SendGrid
bcrypt = Bcrypt()
db = SQLAlchemy()
migrate = Migrate()
ma = Marshmallow()
mail = SendGrid()

View File

@ -1,6 +1,7 @@
from flask import Blueprint
from flask import Blueprint, jsonify
from animal_case import animalify
from grant import JSONResponse
from .models import Milestone, milestones_schema
blueprint = Blueprint('milestone', __name__, url_prefix='/api/v1/milestones')
@ -10,4 +11,4 @@ blueprint = Blueprint('milestone', __name__, url_prefix='/api/v1/milestones')
def get_users():
milestones = Milestone.query.all()
result = milestones_schema.dump(milestones)
return JSONResponse(result)
return jsonify(animalify(result))

View File

@ -1,9 +1,10 @@
from datetime import datetime
from flask import Blueprint, request
from animal_case import animalify
from flask import Blueprint, jsonify
from flask_yoloapi import endpoint, parameter
from sqlalchemy.exc import IntegrityError
from grant import JSONResponse
from grant.comment.models import Comment, comment_schema
from grant.milestone.models import Milestone
from grant.user.models import User, SocialMedia, Avatar
@ -17,9 +18,9 @@ def get_proposal(proposal_id):
proposal = Proposal.query.filter_by(proposal_id=proposal_id).first()
if proposal:
dumped_proposal = proposal_schema.dump(proposal)
return JSONResponse(dumped_proposal)
return jsonify(animalify(dumped_proposal))
else:
return JSONResponse(message="No proposal matching id", _statusCode=404)
return jsonify(message="No proposal matching id"), 404
@blueprint.route("/<proposal_id>/comments", methods=["GET"])
@ -27,22 +28,23 @@ def get_proposal_comments(proposal_id):
proposal = Proposal.query.filter_by(proposal_id=proposal_id).first()
if proposal:
dumped_proposal = proposal_schema.dump(proposal)
return JSONResponse(
return jsonify(animalify(
proposal_id=proposal_id,
total_comments=len(dumped_proposal["comments"]),
comments=dumped_proposal["comments"]
)
))
else:
return JSONResponse(message="No proposal matching id", _statusCode=404)
return jsonify(message="No proposal matching id", _statusCode=404)
@blueprint.route("/<proposal_id>/comments", methods=["POST"])
def post_proposal_comments(proposal_id):
@endpoint.api(
parameter('userId', type=int, required=True),
parameter('content', type=str, required=True)
)
def post_proposal_comments(proposal_id, user_id, content):
proposal = Proposal.query.filter_by(proposal_id=proposal_id).first()
if proposal:
incoming = request.get_json()
user_id = incoming["userId"]
content = incoming["content"]
user = User.query.filter_by(id=user_id).first()
if user:
@ -54,17 +56,19 @@ def post_proposal_comments(proposal_id):
db.session.add(comment)
db.session.commit()
dumped_comment = comment_schema.dump(comment)
return JSONResponse(dumped_comment, _statusCode=201)
return dumped_comment, 201
else:
return JSONResponse(message="No user matching id", _statusCode=404)
return {"message": "No user matching id"}, 404
else:
return JSONResponse(message="No proposal matching id", _statusCode=404)
return {"message": "No proposal matching id"}, 404
@blueprint.route("/", methods=["GET"])
def get_proposals():
stage = request.args.get("stage")
@endpoint.api(
parameter('stage', type=str, required=False)
)
def get_proposals(stage):
if stage:
proposals = (
Proposal.query.filter_by(stage=stage)
@ -74,24 +78,27 @@ def get_proposals():
else:
proposals = Proposal.query.order_by(Proposal.date_created.desc()).all()
dumped_proposals = proposals_schema.dump(proposals)
return JSONResponse(dumped_proposals)
return dumped_proposals
@blueprint.route("/", methods=["POST"])
def make_proposal():
@endpoint.api(
parameter('crowdFundContractAddress', type=str, required=True),
parameter('content', type=str, required=True),
parameter('title', type=str, required=True),
parameter('milestones', type=list, required=True),
parameter('category', type=str, required=True),
parameter('team', type=list, required=True)
)
def make_proposal(crowd_fund_contract_address, content, title, milestones, category, team):
from grant.user.models import User
incoming = request.get_json()
proposal_id = incoming["crowdFundContractAddress"]
content = incoming["content"]
title = incoming["title"]
milestones = incoming["milestones"]
category = incoming["category"]
existing_proposal = Proposal.query.filter_by(proposal_id=crowd_fund_contract_address).first()
if existing_proposal:
return {"message": "Oops! Something went wrong."}, 409
proposal = Proposal.create(
stage="FUNDING_REQUIRED",
proposal_id=proposal_id,
proposal_id=crowd_fund_contract_address,
content=content,
title=title,
category=category
@ -99,9 +106,8 @@ def make_proposal():
db.session.add(proposal)
team = incoming["team"]
if not len(team) > 0:
return JSONResponse(message="Team must be at least 1", _statusCode=400)
return {"message": "Team must be at least 1"}, 400
for team_member in team:
account_address = team_member.get("accountAddress")
@ -149,7 +155,7 @@ def make_proposal():
db.session.commit()
except IntegrityError as e:
print(e)
return JSONResponse(message="Proposal with that hash already exists", _statusCode=409)
return {"message": "Oops! Something went wrong."}, 409
results = proposal_schema.dump(proposal)
return JSONResponse(results, _statusCode=201)
return results, 201

View File

@ -13,6 +13,7 @@ env.read_env()
ENV = env.str("FLASK_ENV", default="production")
DEBUG = ENV == "development"
AUTH_URL = env.str('AUTH_URL', default='https://eip-712.herokuapp.com')
SQLALCHEMY_DATABASE_URI = env.str("DATABASE_URL")
QUEUES = ["default"]
SECRET_KEY = env.str("SECRET_KEY")
@ -21,3 +22,5 @@ DEBUG_TB_ENABLED = DEBUG
DEBUG_TB_INTERCEPT_REDIRECTS = False
CACHE_TYPE = "simple" # Can be "memcached", "redis", etc.
SQLALCHEMY_TRACK_MODIFICATIONS = False
SENDGRID_API_KEY = env.str("SENDGRID_API_KEY", default="")
SENDGRID_DEFAULT_FROM = "noreply@grant.io"

View File

@ -0,0 +1,31 @@
<p style="margin: 0;">
We're excited to have you get started. First, you need to confirm your email address. Just click the button below.
</p>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#1890ff">
<a href="{{ args.confirm_url }}" target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 40px; border-radius: 4px; border: 1px solid #1890ff; display: inline-block;">
Confirm Account
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p style="margin: 0 0 10px; font-size: 16px;">
If that doesn't work, copy and paste the following link in your browser:
</p>
<p style="margin: 0 0 30px; font-size: 12px;">
<a href="{{ args.confirm_url }}" target="_blank" style="color: #1890ff;">{{ args.confirm_url }}</a>
</p>
<p style="margin: 0; font-size: 10px; color: #AAA; text-align: center;">
Dont know why you got this email? Dont worry, you can safely ignore it. We wont send you anymore.
</p>

View File

@ -0,0 +1,5 @@
We're excited to have you get started. First, you need to confirm your email address. Just go to the URL below:
{{ args.confirm_url }}
Dont know why you got this email? Dont worry, you can safely ignore it. We wont send you anymore.

View File

@ -0,0 +1,175 @@
<!-- THIS EMAIL WAS BUILT AND TESTED WITH LITMUS http://litmus.com -->
<!-- IT WAS RELEASED UNDER THE MIT LICENSE https://opensource.org/licenses/MIT -->
<!-- QUESTIONS? TWEET US @LITMUSAPP -->
<!DOCTYPE html>
<html>
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<style type="text/css">
/* FONTS */
@import url('https://fonts.googleapis.com/css?family=Nunito+Sans');
/* CLIENT-SPECIFIC STYLES */
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { -ms-interpolation-mode: bicubic; }
/* RESET STYLES */
img { border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
table { border-collapse: collapse !important; }
body { height: 100% !important; margin: 0 !important; padding: 0 !important; width: 100% !important; }
/* iOS BLUE LINKS */
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
}
/* MOBILE STYLES */
@media screen and (max-width:600px){
h1 {
font-size: 32px !important;
line-height: 32px !important;
}
}
/* ANDROID CENTER FIX */
div[style*="margin: 16px 0;"] { margin: 0 !important; }
</style>
</head>
<body style="background-color: #f4f4f4; margin: 0 !important; padding: 0 !important;">
<!-- HIDDEN PREHEADER TEXT -->
<div style="display: none; font-size: 1px; color: #fefefe; line-height: 1px; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden;">
{{ args.preview }}
</div>
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<!-- LOGO -->
<tr>
<td bgcolor="#4a4a4a" align="center">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td align="center" valign="top" style="padding: 40px 10px 40px 10px;">
<a href="{{ args.home_url }}" target="_blank">
<img alt="Logo" src="https://i.imgur.com/t0DPkyl.png" width="120" height="44" style="display: block; width: 150px; max-width: 150px; min-width: 150px; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; color: #ffffff; font-size: 18px;" border="0">
</a>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- TITLE -->
<tr>
<td bgcolor="#4a4a4a" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
<h1 style="font-size: 42px; font-weight: 400; margin: 0;">
{{ args.title }}
</h1>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- BODY -->
<tr>
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" style="padding: 20px 30px 40px 30px; color: #666666; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px; border-radius: 0px 0px 4px 4px;" >
{{ args.body }}
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- FOOTER -->
<tr>
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<!-- NAVIGATION -->
<tr>
<td bgcolor="#f4f4f4" align="center" style="padding: 30px 30px 30px 30px; color: #666666; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 18px;" >
<p style="margin: 0;">
<a href="{{ args.home_url }}" target="_blank" style="color: #111111; font-weight: 700;">Grant.io</a> -
<a href="{{ args.account_url }}" target="_blank" style="color: #111111; font-weight: 700;">Your Account</a> -
<a href="{{ args.email_settings_url }}" target="_blank" style="color: #111111; font-weight: 700;">Email Settings</a>
</p>
</td>
</tr>
<!-- UNSUBSCRIBE -->
<tr>
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 30px 30px 30px; color: #666666; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 18px;" >
<p style="margin: 0;">
Dont want anymore emails?
<a href="{{ args.unsubscribe_url }}" target="_blank" style="color: #111111; font-weight: 700;">
Click here to unsubscribe
</a>
.
</p>
</td>
</tr>
<!-- ADDRESS -->
<tr>
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 30px 30px 30px; color: #AAAAAA; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; font-size: 12px; font-weight: 400; line-height: 18px;" >
<p style="margin: 0;">
Grant.io Inc, 123 Address Street, Somewhere, NY 11211
</p>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,9 @@
{{ body }}
===============
Grant.io
123 Address Street
City, ST 12345
Unsubscribe here: https://grant.io/unsubscribe

View File

@ -52,6 +52,16 @@ class User(db.Model):
self.display_name = display_name
self.title = title
@staticmethod
def get_by_email_or_account_address(email_address: str = None, account_address: str = None):
if not email_address and not account_address:
raise ValueError("Either email_address or account_address is required to get a user")
return User.query.filter(
(User.account_address == account_address) |
(User.email_address == email_address)
).first()
class UserSchema(ma.Schema):
class Meta:
@ -86,6 +96,7 @@ class SocialMediaSchema(ma.Schema):
# Fields to expose
fields = ("social_media_link",)
social_media_schema = SocialMediaSchema()
social_media_schemas = SocialMediaSchema(many=True)

View File

@ -1,52 +1,59 @@
from flask import Blueprint, request
from animal_case import animalify
from flask import Blueprint, g, jsonify
from flask_yoloapi import endpoint, parameter
from grant import JSONResponse
from .models import User, users_schema, user_schema, db
from .models import User, SocialMedia, Avatar, users_schema, user_schema, db
from ..email.send import send_email
from ..proposal.models import Proposal, proposal_team
from ..utils.auth import requires_sm
blueprint = Blueprint('user', __name__, url_prefix='/api/v1/users')
@blueprint.route("/", methods=["GET"])
def get_users():
proposal_query = request.args.get('proposalId')
proposal = Proposal.query.filter_by(proposal_id=proposal_query).first()
@endpoint.api(
parameter('proposalId', type=str, required=False)
)
def get_users(proposal_id):
proposal = Proposal.query.filter_by(proposal_id=proposal_id).first()
if not proposal:
users = User.query.all()
else:
users = User.query.join(proposal_team).join(Proposal) \
.filter(proposal_team.c.proposal_id == proposal.id).all()
result = users_schema.dump(users)
return JSONResponse(result)
return result
@blueprint.route("/me", methods=["GET"])
@requires_sm
def get_me():
dumped_user = user_schema.dump(g.current_user)
return jsonify(animalify(dumped_user))
@blueprint.route("/<user_identity>", methods=["GET"])
def get_user(user_identity):
user = User.query.filter(
(User.account_address == user_identity) | (User.email_address == user_identity)).first()
user = User.get_by_email_or_account_address(email_address=user_identity, account_address=user_identity)
if user:
result = user_schema.dump(user)
return JSONResponse(result)
return jsonify(animalify(result))
else:
return JSONResponse(
message="User with account_address or user_identity matching {} not found".format(user_identity),
_statusCode=404)
return jsonify(
message="User with account_address or user_identity matching {} not found".format(user_identity)), 404
@blueprint.route("/", methods=["POST"])
def create_user():
incoming = request.get_json()
account_address = incoming["accountAddress"]
email_address = incoming["emailAddress"]
display_name = incoming["displayName"]
title = incoming["title"]
# TODO: Move create and validation stuff into User model
existing_user = User.query.filter(
(User.account_address == account_address) | (User.email_address == email_address)).first()
@endpoint.api(
parameter('accountAddress', type=str, required=True),
parameter('emailAddress', type=str, required=True),
parameter('displayName', type=str, required=True),
parameter('title', type=str, required=True),
)
def create_user(account_address, email_address, display_name, title):
existing_user = User.get_by_email_or_account_address(email_address=email_address, account_address=account_address)
if existing_user:
return JSONResponse(
message="User with that address or email already exists",
_statusCode=400)
return {"message": "User with that address or email already exists"}, 409
# TODO: Handle avatar & social stuff too
user = User(
@ -59,5 +66,49 @@ def create_user():
db.session.flush()
db.session.commit()
send_email(email_address, 'signup', {
'display_name': display_name,
# TODO: Make this dynamic
'confirm_url': 'https://grant.io/user/confirm',
})
result = user_schema.dump(user)
return JSONResponse(result)
return result
@blueprint.route("/<user_identity>", methods=["PUT"])
@endpoint.api(
parameter('displayName', type=str, required=False),
parameter('title', type=str, required=False),
parameter('socialMedias', type=list, required=False),
parameter('avatar', type=dict, required=False)
)
def update_user(user_identity, display_name, title, social_medias, avatar):
user = User.get_by_email_or_account_address(email_address=user_identity, account_address=user_identity)
if not user:
return {"message": "User with that address or email not found"}, 404
if display_name is not None:
user.display_name = display_name
if title is not None:
user.title = title
if social_medias is not None:
sm_query = SocialMedia.query.filter_by(user_id=user.id)
sm_query.delete()
for social_media in social_medias:
sm = SocialMedia(social_media_link=social_media.get("link"), user_id=user.id)
db.session.add(sm)
if avatar is not None:
Avatar.query.filter_by(user_id=user.id).delete()
avatar_link = avatar.get('link')
if avatar_link:
avatar_obj = Avatar(image_url=avatar_link, user_id=user.id)
db.session.add(avatar_obj)
db.session.commit()
result = user_schema.dump(user)
return result

View File

@ -1,10 +1,14 @@
import ast
import json
from functools import wraps
import requests
from flask import request, g, jsonify
from itsdangerous import SignatureExpired, BadSignature
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
from grant.settings import SECRET_KEY
from grant.settings import SECRET_KEY, AUTH_URL
from ..user.models import User
TWO_WEEKS = 1209600
@ -41,3 +45,33 @@ def requires_auth(f):
return jsonify(message="Authentication is required to access this resource"), 401
return decorated
def requires_sm(f):
@wraps(f)
def decorated(*args, **kwargs):
typed_data = request.headers.get('RawTypedData', None)
signature = request.headers.get('MsgSignature', None)
if typed_data and signature:
loaded_typed_data = ast.literal_eval(typed_data)
url = AUTH_URL + "/message/recover"
payload = json.dumps({"sig": signature, "data": loaded_typed_data})
headers = {'content-type': 'application/json'}
response = requests.request("POST", url, data=payload, headers=headers)
json_response = response.json()
recovered_address = json_response.get('recoveredAddress')
if not recovered_address:
return jsonify(message="No user exists with address: {}".format(recovered_address)), 401
user = User.get_by_email_or_account_address(account_address=recovered_address)
if not user:
return jsonify(message="No user exists with address: {}".format(recovered_address)), 401
g.current_user = user
return f(*args, **kwargs)
return jsonify(message="Authentication is required to access this resource"), 401
return decorated

View File

@ -1,77 +1,5 @@
import datetime
import time
from contextlib import closing
from requests import get
from requests.exceptions import RequestException
def simple_get(url):
"""
Attempts to get the content at `url` by making an HTTP GET request.
If the content-type of response is some kind of HTML/XML, return the
text content, otherwise return None.
"""
try:
with closing(get(url, stream=True)) as resp:
if is_good_response(resp):
return resp.content
else:
return None
except RequestException as e:
log_error("Error during requests to {0} : {1}".format(url, str(e)))
return None
def is_good_response(resp):
"""
Returns True if the response seems to be HTML, False otherwise.
"""
content_type = resp.headers["Content-Type"].lower()
return (
resp.status_code == 200
and content_type is not None
and content_type.find("html") > -1
)
def log_error(e):
"""
It is always a good idea to log errors.
This function just prints them, but you can
make it do anything.
"""
print(e)
def strip_number_formatting_from_string(string: str) -> str:
return string.replace(",", "").replace(".", "").replace(" ", "").strip()
def convert_monero_to_piconero(monero_string: str) -> int:
monero_string = strip_number_formatting_from_string(monero_string)
for _ in range(11):
monero_string += "0"
return int(monero_string)
def convert_piconero_to_monero(piconero_string: str) -> str:
reversed_piconero = piconero_string[::-1]
added_decimal = reversed_piconero[:13] + "." + reversed_piconero[13:]
unreversed_piconero = added_decimal[::-1]
return unreversed_piconero
def convert_string_money_to_float(money_string: str) -> float:
reversed_money_string = money_string[::-1]
added_decimal = reversed_money_string[:2] + "." + reversed_money_string[2:]
unreversed_money_string = added_decimal[::-1]
return float(unreversed_money_string)
epoch = datetime.datetime.utcfromtimestamp(0)

View File

@ -16,4 +16,5 @@ flake8-quotes==1.0.0
isort==4.3.4
pep8-naming==0.7.0
pre-commit
flask_testing
flask_testing
mock

View File

@ -25,11 +25,9 @@ marshmallow==3.0.0b13
flask-marshmallow==0.9.0
marshmallow-sqlalchemy
# CORS
Flask-Cors==3.0.6
# Deployment
gunicorn>=19.1.1
@ -51,3 +49,10 @@ redis==2.10.6
# md
markdownify
# email
flask-sendgrid==0.6
sendgrid==5.3.0
# input validation
flask-yolo2API

View File

@ -1,40 +0,0 @@
# -*- coding: utf-8 -*-
"""Defines fixtures available to all tests."""
import pytest
from webtest import TestApp
from grant.app import create_app
from grant.app import db as _db
@pytest.fixture
def app():
"""An application for the tests."""
_app = create_app('tests.settings')
ctx = _app.test_request_context()
ctx.push()
yield _app
ctx.pop()
@pytest.fixture
def testapp(app):
"""A Webtest app."""
return TestApp(app)
@pytest.fixture
def db(app):
"""A database for the tests."""
_db.app = app
with app.app_context():
_db.create_all()
yield _db
# Explicitly close DB connection
_db.session.close()
_db.drop_all()

View File

@ -48,7 +48,7 @@ class TestAPI(BaseTestConfig):
proposal_id=proposal["crowdFundContractAddress"]
).first())
self.app.post(
resp = self.app.post(
"/api/v1/proposals/",
data=json.dumps(proposal),
content_type='application/json'
@ -92,3 +92,18 @@ class TestAPI(BaseTestConfig):
)
self.assertTrue(comment_res.json)
def test_create_new_proposal_duplicate(self):
proposal_res = self.app.post(
"/api/v1/proposals/",
data=json.dumps(proposal),
content_type='application/json'
)
proposal_res2 = self.app.post(
"/api/v1/proposals/",
data=json.dumps(proposal),
content_type='application/json'
)
self.assertEqual(proposal_res2.status_code, 409)

View File

@ -0,0 +1,103 @@
import json
from ..config import BaseTestConfig
account_address = '0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826'
message = {
"sig": "0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b915621c",
"data": {"types": {"EIP712Domain": [{"name": "name", "type": "string"}, {"name": "version", "type": "string"},
{"name": "chainId", "type": "uint256"},
{"name": "verifyingContract", "type": "address"}],
"Person": [{"name": "name", "type": "string"}, {"name": "wallet", "type": "address"}],
"Mail": [{"name": "from", "type": "Person"}, {"name": "to", "type": "Person"},
{"name": "contents", "type": "string"}]}, "primaryType": "Mail",
"domain": {"name": "Ether Mail", "version": "1", "chainId": 1,
"verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"},
"message": {"from": {"name": "Cow", "wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"},
"to": {"name": "Bob", "wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"},
"contents": "Hello, Bob!"}}
}
user = {
"accountAddress": account_address,
"displayName": 'Groot',
"emailAddress": 'iam@groot.com',
"title": 'I am Groot!',
"avatar": {
"link": 'https://avatars2.githubusercontent.com/u/1393943?s=400&v=4'
},
"socialMedias": [
{
"link": 'https://github.com/groot'
}
]
}
class TestRequiredSignedMessageDecorator(BaseTestConfig):
def test_required_sm_aborts_without_data_and_sig_headers(self):
self.app.post(
"/api/v1/users/",
data=json.dumps(user),
content_type='application/json'
)
response = self.app.get(
"/api/v1/users/me",
headers={
"MsgSignature": message["sig"],
# "RawTypedData: message["data"]
}
)
self.assert401(response)
response = self.app.get(
"/api/v1/users/me",
headers={
# "MsgSignature": message["sig"],
"RawTypedData": message["data"]
}
)
self.assert401(response)
def test_required_sm_aborts_without_existing_user(self):
# We don't create the user here to test a failure case
# self.app.post(
# "/api/v1/users/",
# data=json.dumps(user),
# content_type='application/json'
# )
response = self.app.get(
"/api/v1/users/me",
headers={
"MsgSignature": message["sig"],
"RawTypedData": message["data"]
}
)
self.assert401(response)
def test_required_sm_decorator_authorizes_when_recovered_address_matches_existing_user(self):
self.app.post(
"/api/v1/users/",
data=json.dumps(user),
content_type='application/json'
)
response = self.app.get(
"/api/v1/users/me",
headers={
"MsgSignature": message["sig"],
"RawTypedData": message["data"]
}
)
response_json = response.json
self.assert200(response)
self.assertEqual(response_json["displayName"], user["displayName"])

View File

@ -6,6 +6,7 @@ from grant.proposal.models import CATEGORIES
from grant.proposal.models import Proposal
from grant.user.models import User
from ..config import BaseTestConfig
from mock import patch
milestones = [
{
@ -181,3 +182,88 @@ class TestAPI(BaseTestConfig):
self.assertEqual(users_json["avatar"]["imageUrl"], team[0]["avatar"]["link"])
self.assertEqual(users_json["socialMedias"][0]["socialMediaLink"], team[0]["socialMedias"][0]["link"])
self.assertEqual(users_json["displayName"], team[0]["displayName"])
@patch('grant.email.send.send_email')
def test_create_user(self, mock_send_email):
mock_send_email.return_value.ok = True
self.app.post(
"/api/v1/users/",
data=json.dumps(team[0]),
content_type='application/json'
)
# User
user_db = User.get_by_email_or_account_address(account_address=team[0]["accountAddress"])
self.assertEqual(user_db.display_name, team[0]["displayName"])
self.assertEqual(user_db.title, team[0]["title"])
self.assertEqual(user_db.account_address, team[0]["accountAddress"])
@patch('grant.email.send.send_email')
def test_create_user_duplicate_400(self, mock_send_email):
mock_send_email.return_value.ok = True
self.test_create_user()
response = self.app.post(
"/api/v1/users/",
data=json.dumps(team[0]),
content_type='application/json'
)
self.assertEqual(response.status_code, 409)
def test_update_user_remove_social_and_avatar(self):
self.app.post(
"/api/v1/proposals/",
data=json.dumps(proposal),
content_type='application/json'
)
updated_user = copy.deepcopy(team[0])
updated_user['displayName'] = 'Billy'
updated_user['title'] = 'Commander'
updated_user['socialMedias'] = []
updated_user['avatar'] = {}
user_update_resp = self.app.put(
"/api/v1/users/{}".format(proposal["team"][0]["accountAddress"]),
data=json.dumps(updated_user),
content_type='application/json'
)
users_json = user_update_resp.json
self.assertFalse(users_json["avatar"])
self.assertFalse(len(users_json["socialMedias"]))
self.assertEqual(users_json["displayName"], updated_user["displayName"])
self.assertEqual(users_json["title"], updated_user["title"])
def test_update_user(self):
self.app.post(
"/api/v1/proposals/",
data=json.dumps(proposal),
content_type='application/json'
)
updated_user = copy.deepcopy(team[0])
updated_user['displayName'] = 'Billy'
updated_user['title'] = 'Commander'
updated_user['socialMedias'] = [
{
"link": "https://github.com/billyman"
}
]
updated_user['avatar'] = {
"link": "https://x.io/avatar.png"
}
user_update_resp = self.app.put(
"/api/v1/users/{}".format(proposal["team"][0]["accountAddress"]),
data=json.dumps(updated_user),
content_type='application/json'
)
users_json = user_update_resp.json
self.assertEqual(users_json["avatar"]["imageUrl"], updated_user["avatar"]["link"])
self.assertEqual(users_json["socialMedias"][0]["socialMediaLink"], updated_user["socialMedias"][0]["link"])
self.assertEqual(users_json["displayName"], updated_user["displayName"])
self.assertEqual(users_json["title"], updated_user["title"])

View File

@ -2,4 +2,7 @@
FUND_ETH_ADDRESSES=0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520,0xDECAF9CD2367cdbb726E904cD6397eDFcAe6068DEe0460DFa261520,0xDECAF9CD2367cdbb726E904cD6397eDFcAe6068D
# Disable typescript checking for dev building (reduce build time & resource usage)
NO_DEV_TS_CHECK=true
NO_DEV_TS_CHECK=true
# Set the public host url (no trailing slash)
PUBLIC_HOST_URL=https://demo.grant.io

View File

@ -22,6 +22,10 @@ const SignOut = loadable(() => import('pages/sign-out'));
const Profile = loadable(() => import('pages/profile'));
const Settings = loadable(() => import('pages/settings'));
const Exception = loadable(() => import('pages/exception'));
const Tos = loadable(() => import('pages/tos'));
const About = loadable(() => import('pages/about'));
const Privacy = loadable(() => import('pages/privacy'));
const Contact = loadable(() => import('pages/contact'));
import 'styles/style.less';
@ -107,6 +111,54 @@ const routeConfigs: RouteConfig[] = [
},
onlyLoggedIn: true,
},
{
// Terms of Service page
route: {
path: '/tos',
component: Tos,
exact: true,
},
template: {
title: 'Terms of Service',
},
onlyLoggedIn: false,
},
{
// About page
route: {
path: '/about',
component: About,
exact: true,
},
template: {
title: 'About',
},
onlyLoggedIn: false,
},
{
// Privacy page
route: {
path: '/privacy',
component: Privacy,
exact: true,
},
template: {
title: 'Privacy Policy',
},
onlyLoggedIn: false,
},
{
// Contact page
route: {
path: '/contact',
component: Contact,
exact: true,
},
template: {
title: 'Contact',
},
onlyLoggedIn: false,
},
{
// User profile
route: {
@ -144,7 +196,7 @@ const routeConfigs: RouteConfig[] = [
// 404
route: {
path: '/*',
render: () => <Exception type="404" />,
render: () => <Exception code="404" />,
},
template: {
title: 'Page not found',
@ -157,7 +209,9 @@ type Props = RouteComponentProps<any>;
class Routes extends React.PureComponent<Props> {
render() {
const { pathname } = this.props.location;
const currentRoute = routeConfigs.find(config => !!matchPath(pathname, config.route));
const currentRoute =
routeConfigs.find(config => !!matchPath(pathname, config.route)) ||
routeConfigs[routeConfigs.length - 1];
const routeComponents = routeConfigs.map(config => {
const { route, onlyLoggedIn, onlyLoggedOut } = config;
if (onlyLoggedIn || onlyLoggedOut) {

View File

@ -64,3 +64,12 @@ export function createUser(payload: {
return res;
});
}
export function updateUser(user: TeamMember): Promise<{ data: TeamMember }> {
return axios
.put(`/api/v1/users/${user.ethAddress}`, formatTeamMemberForPost(user))
.then(res => {
res.data = formatTeamMemberFromGet(res.data);
return res;
});
}

View File

@ -0,0 +1,18 @@
import React, { PureComponent } from 'react';
import './style.less';
export default class About extends PureComponent {
render() {
return (
<div className="About">
<h1 className="About-title">About Grant.io</h1>
<section>
<p>
Grant.io organizes creators and community members to incentivize ecosystem
improvements.
</p>
</section>
</div>
);
}
}

View File

@ -0,0 +1,21 @@
.About {
max-width: 640px;
margin: 0 auto;
&-title {
text-align: center;
margin-top: 1rem;
padding-bottom: 3rem;
border-bottom: 1px solid #e5e5e5;
margin-bottom: 2.5rem;
font-size: 2.5rem;
}
section {
margin-bottom: 3rem;
p {
font-size: 1rem;
}
}
}

View File

@ -0,0 +1,35 @@
.AddressInput {
&-input {
&-identicon {
position: relative;
border-radius: 100%;
left: -2px;
width: 22px;
height: 22px;
.ant-input-affix-wrapper-lg & {
height: 28px;
width: 28px;
}
.ant-input-affix-wrapper-sm & {
left: -4px;
width: 16px;
height: 16px;
}
}
// Ant overrides
&.ant-input-affix-wrapper .ant-input:not(:first-child) {
padding-left: 38px;
}
&.ant-input-affix-wrapper-lg .ant-input:not(:first-child) {
padding-left: 44px;
}
&.ant-input-affix-wrapper-sm .ant-input:not(:first-child) {
padding-left: 28px;
}
}
}

View File

@ -0,0 +1,58 @@
import React from 'react';
import classnames from 'classnames';
import { Form, Input } from 'antd';
import { InputProps } from 'antd/lib/input';
import { FormItemProps } from 'antd/lib/form';
import { isValidEthAddress } from 'utils/validators';
import Identicon from 'components/Identicon';
import { DONATION } from 'utils/constants';
import './AddressInput.less';
export interface Props {
value: string | undefined;
className?: string;
showIdenticon?: boolean;
inputProps?: InputProps;
formItemProps?: FormItemProps;
onChange(ev: React.ChangeEvent<HTMLInputElement>): void;
}
export default class AddressInput extends React.Component<Props> {
render() {
const { value, onChange, className, showIdenticon } = this.props;
const passedFormItemProps = this.props.formItemProps || {};
const passedInputProps = this.props.inputProps || {};
const isInvalid = value && !isValidEthAddress(value);
const formItemProps = {
validateStatus: (isInvalid
? 'error'
: undefined) as FormItemProps['validateStatus'],
help: isInvalid ? 'Address is invalid' : undefined,
...passedFormItemProps,
className: classnames('AddressInput', className, passedFormItemProps.className),
};
const inputProps = {
placeholder: DONATION.ETH,
prefix: value &&
showIdenticon && (
<Identicon className="AddressInput-input-identicon" address={value} />
),
...passedInputProps,
value,
onChange,
className: classnames(
'AddressInput-input',
className && `${className}-input`,
passedInputProps.className,
),
};
return (
<Form.Item {...formItemProps}>
<Input {...inputProps} />
</Form.Item>
);
}
}

View File

@ -40,24 +40,26 @@ class AuthFlow extends React.Component<Props> {
title: () => 'Prove your Identity',
subtitle: () => 'Log into your Grant.io account by proving your identity',
render: () => {
const user = this.props.checkedUsers[this.state.address];
const { address, provider } = this.state;
const user = address && this.props.checkedUsers[address];
return (
user && (
<SignIn provider={this.state.provider} user={user} reset={this.resetState} />
)
user &&
provider && <SignIn provider={provider} user={user} reset={this.resetState} />
);
},
},
SIGN_UP: {
title: () => 'Claim your Identity',
subtitle: () => 'Create a Grant.io account by claiming your identity',
render: () => (
<SignUp
address={this.state.address}
provider={this.state.provider}
reset={this.resetState}
/>
),
render: () => {
const { address, provider } = this.state;
return (
address &&
provider && (
<SignUp address={address} provider={provider} reset={this.resetState} />
)
);
},
},
SELECT_PROVIDER: {
title: () => 'Provide an Identity',
@ -80,13 +82,17 @@ class AuthFlow extends React.Component<Props> {
return 'Connect with MetaMask';
}
},
render: () => (
<ProvideIdentity
provider={this.state.provider}
onSelectAddress={this.setAddress}
reset={this.resetState}
/>
),
render: () => {
return (
this.state.provider && (
<ProvideIdentity
provider={this.state.provider}
onSelectAddress={this.setAddress}
reset={this.resetState}
/>
)
);
},
},
};
@ -105,7 +111,7 @@ class AuthFlow extends React.Component<Props> {
render() {
const { checkedUsers, isCheckingUser } = this.props;
const { provider, address } = this.state;
const checkedUser = checkedUsers[address];
const checkedUser = address && checkedUsers[address];
let page;
if (provider) {

View File

@ -1,6 +1,7 @@
import React from 'react';
import { Form, Input, Button } from 'antd';
import { Form, Button } from 'antd';
import { isValidEthAddress } from 'utils/validators';
import AddressInput from 'components/AddressInput';
import './Address.less';
interface Props {
@ -20,14 +21,12 @@ export default class AddressProvider extends React.Component<Props, State> {
const { address } = this.state;
return (
<Form className="AddressProvider" onSubmit={this.handleSubmit}>
<Form.Item className="AddressProvider-address">
<Input
size="large"
value={address}
onChange={this.handleChange}
placeholder="0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520"
/>
</Form.Item>
<AddressInput
className="AddressProvider-address"
value={address}
onChange={this.handleChange}
inputProps={{ size: 'large' }}
/>
<Button
type="primary"

View File

@ -146,7 +146,7 @@ interface AddressChoiceProps {
const AddressChoice: React.SFC<AddressChoiceProps> = props => (
<button
className={classnames('AddressChoice', props.isFake && 'is-fake')}
onClick={props.onClick ? () => props.onClick(props.address) : undefined}
onClick={() => props.onClick && props.onClick(props.address)}
>
{/* TODO: Use user avatar + name if they have an account */}
{props.isFake ? (

View File

@ -1,13 +1,20 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { RouteComponentProps, withRouter } from 'react-router';
import ogpLogo from 'static/images/ogp-logo.png';
import { urlToPublic } from 'utils/helpers';
interface Props {
interface OwnProps {
title: string;
}
export default class BasicHead extends React.Component<Props> {
type Props = OwnProps & RouteComponentProps<any>;
class BasicHead extends React.Component<Props> {
render() {
const { children, title } = this.props;
const defaultOgpUrl = process.env.PUBLIC_HOST_URL + this.props.location.pathname;
const defaultOgpImage = urlToPublic(ogpLogo);
return (
<div>
<Helmet>
@ -19,9 +26,29 @@ export default class BasicHead extends React.Component<Props> {
integrity="sha384-hWVjflwFxL6sNzntih27bfxkr27PmbbK/iSvJ+a4+0owXq79v+lsFkW54bOGbiDQ"
crossOrigin="anonymous"
/>
{/* open graph protocol defaults, can be overridden in children <HeaderDetails ...> */}
<meta property="og:site_name" content="Grant.io" />
<meta property="og:title" content={`Grant.io - ${title}`} />
<meta property="og:type" content="website" />
<meta property="og:url" content={defaultOgpUrl} />
<meta property="og:image" content={defaultOgpImage} />
<meta property="og:locale" content="en_US" />
{/* TODO: i18n */}
{/* <meta property="og:locale:alternate" content="en_US" /> */}
{/* <meta property="og:locale:alternate" content="de_DE" /> */}
{/* twitter defaults, can be overridden in children <HeaderDetails ...> */}
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:site" content="@io_grant" />
<meta property="twitter:title" content={`Grant.io - ${title}`} />
<meta property="twitter:image" content={defaultOgpImage} />
<meta property="twitter:url" content={defaultOgpUrl} />
</Helmet>
{children}
</div>
);
}
}
export default withRouter(BasicHead);

View File

@ -0,0 +1,18 @@
import React, { PureComponent } from 'react';
import './style.less';
export default class Contact extends PureComponent {
render() {
return (
<div className="Contact">
<h1 className="Contact-title">Contact Us</h1>
<section>
<p>
You may contact the Grant.io project by emailing{' '}
<a href="mailto:daniel@grant.io">daniel@grant.io</a>.
</p>
</section>
</div>
);
}
}

View File

@ -0,0 +1,21 @@
.Contact {
max-width: 640px;
margin: 0 auto;
&-title {
text-align: center;
margin-top: 1rem;
padding-bottom: 3rem;
border-bottom: 1px solid #e5e5e5;
margin-bottom: 2.5rem;
font-size: 2.5rem;
}
section {
margin-bottom: 3rem;
p {
font-size: 1rem;
}
}
}

View File

@ -1,8 +1,10 @@
import React from 'react';
import { Input, Form, Icon, Select } from 'antd';
import { SelectValue } from 'antd/lib/select';
import { PROPOSAL_CATEGORY, CATEGORY_UI } from 'api/constants';
import { CreateFormState } from 'types';
import { getCreateErrors } from 'modules/create/utils';
import { typedKeys } from 'utils/ts';
interface State {
title: string;
@ -37,8 +39,8 @@ export default class CreateFlowBasics extends React.Component<Props, State> {
});
};
handleCategoryChange = (value: PROPOSAL_CATEGORY) => {
this.setState({ category: value }, () => {
handleCategoryChange = (value: SelectValue) => {
this.setState({ category: value as PROPOSAL_CATEGORY }, () => {
this.props.updateForm(this.state);
});
};
@ -85,7 +87,7 @@ export default class CreateFlowBasics extends React.Component<Props, State> {
value={category || undefined}
onChange={this.handleCategoryChange}
>
{Object.keys(PROPOSAL_CATEGORY).map((c: PROPOSAL_CATEGORY) => (
{typedKeys(PROPOSAL_CATEGORY).map(c => (
<Select.Option value={c} key={c}>
<Icon
type={CATEGORY_UI[c].icon}

View File

@ -4,6 +4,7 @@ import { RadioChangeEvent } from 'antd/lib/radio';
import { CreateFormState } from 'types';
import { getCreateErrors } from 'modules/create/utils';
import { ONE_DAY } from 'utils/time';
import { DONATION } from 'utils/constants';
interface State {
payOutAddress: string;
@ -43,7 +44,7 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
<Input
size="large"
name="payOutAddress"
placeholder="0xe12a34230e5e7fc73d094e52025135e4fbf24653"
placeholder={DONATION.ETH}
type="text"
value={payOutAddress}
onChange={this.handleInputChange}
@ -143,7 +144,7 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
private handleRadioChange = (event: RadioChangeEvent) => {
const { value, name } = event.target;
this.setState({ [name]: value } as any, () => {
this.setState({ [name as string]: value } as any, () => {
this.props.updateForm(this.state);
});
};
@ -172,7 +173,7 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
interface TrusteeFieldsProps {
index: number;
value: string;
error: null | false | string;
error: string | Falsy;
onChange(index: number, value: string): void;
onRemove(index: number): void;
}
@ -192,7 +193,7 @@ const TrusteeFields = ({
<div style={{ display: 'flex' }}>
<Input
size="large"
placeholder="0xe12a34230e5e7fc73d094e52025135e4fbf24653"
placeholder={DONATION.ETH}
type="text"
value={value}
onChange={ev => onChange(index, ev.currentTarget.value)}

View File

@ -108,7 +108,7 @@ export default class CreateFlowMilestones extends React.Component<Props, State>
interface MilestoneFieldsProps {
index: number;
milestone: CreateMilestone;
error: null | false | string;
error: Falsy | string;
onChange(index: number, milestone: CreateMilestone): void;
onRemove(index: number): void;
}

View File

@ -27,7 +27,7 @@ class CreateFlowPreview extends React.Component<Props> {
<ProposalDetail
account="0x0"
proposalId="preview"
fetchProposal={() => null}
fetchProposal={(() => null) as any}
proposal={proposal}
isPreview
/>

View File

@ -6,7 +6,7 @@ import { getCreateErrors, KeyOfForm, FIELD_NAME_MAP } from 'modules/create/utils
import Markdown from 'components/Markdown';
import { AppState } from 'store/reducers';
import { CREATE_STEP } from './index';
import { CATEGORY_UI } from 'api/constants';
import { CATEGORY_UI, PROPOSAL_CATEGORY } from 'api/constants';
import './Review.less';
import UserAvatar from 'components/UserAvatar';
@ -23,7 +23,7 @@ type Props = OwnProps & StateProps;
interface Field {
key: KeyOfForm;
content: React.ReactNode;
error: string | undefined | false;
error: string | Falsy;
}
interface Section {
@ -36,7 +36,7 @@ class CreateReview extends React.Component<Props> {
render() {
const { form } = this.props;
const errors = getCreateErrors(this.props.form);
const catUI = CATEGORY_UI[form.category] || ({} as any);
const catUI = CATEGORY_UI[form.category as PROPOSAL_CATEGORY] || ({} as any);
const sections: Section[] = [
{
step: CREATE_STEP.BASICS,
@ -121,13 +121,15 @@ class CreateReview extends React.Component<Props> {
},
{
key: 'deadline',
content: `${Math.floor(moment.duration(form.deadline * 1000).asDays())} days`,
content: `${Math.floor(
moment.duration((form.deadline || 0) * 1000).asDays(),
)} days`,
error: errors.deadline,
},
{
key: 'milestoneDeadline',
content: `${Math.floor(
moment.duration(form.milestoneDeadline * 1000).asDays(),
moment.duration((form.milestoneDeadline || 0) * 1000).asDays(),
)} days`,
error: errors.milestoneDeadline,
},

View File

@ -113,7 +113,7 @@ class CreateFlowTeam extends React.Component<Props, State> {
};
}
const withConnect = connect<StateProps>((state: AppState) => ({
const withConnect = connect<StateProps, {}, {}, AppState>(state => ({
authUser: state.auth.user,
}));

View File

@ -1,6 +1,24 @@
import { PROPOSAL_CATEGORY } from 'api/constants';
import { SOCIAL_TYPE, CreateFormState } from 'types';
function generateRandomAddress() {
return (
'0x' +
Math.random()
.toString(16)
.substring(2, 12) +
Math.random()
.toString(16)
.substring(2, 12) +
Math.random()
.toString(16)
.substring(2, 12) +
Math.random()
.toString(16)
.substring(2, 12)
);
}
const createExampleProposal = (
payOutAddress: string,
trustees: string[],
@ -29,7 +47,7 @@ const createExampleProposal = (
avatarUrl: `https://randomuser.me/api/portraits/women/${Math.floor(
Math.random() * 80,
)}.jpg`,
ethAddress: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520',
ethAddress: generateRandomAddress(),
emailAddress: 'designer@tshirt.com',
socialAccounts: {
[SOCIAL_TYPE.KEYBASE]: 'willo',

View File

@ -4,6 +4,7 @@ import { compose } from 'recompose';
import { Steps, Icon, Spin, Alert } from 'antd';
import qs from 'query-string';
import { withRouter, RouteComponentProps } from 'react-router';
import { History } from 'history';
import { debounce } from 'underscore';
import Basics from './Basics';
import Team from './Team';
@ -46,7 +47,7 @@ interface StepInfo {
title: React.ReactNode;
subtitle: React.ReactNode;
help: React.ReactNode;
component: React.ComponentClass<any>;
component: any;
}
const STEP_INFO: { [key in CREATE_STEP]: StepInfo } = {
[CREATE_STEP.BASICS]: {
@ -145,22 +146,12 @@ class CreateFlow extends React.Component<Props, State> {
isExample: false,
};
this.debouncedUpdateForm = debounce(this.updateForm, 800);
this.historyUnlisten = this.props.history.listen(this.handlePop);
}
componentDidMount() {
this.props.resetCreateCrowdFund();
this.props.fetchDraft();
this.historyUnlisten = this.props.history.listen((location, action) => {
if (action === 'POP') {
const searchValues = qs.parse(location.search);
const urlStep = searchValues.step && searchValues.step.toUpperCase();
if (urlStep && CREATE_STEP[urlStep]) {
this.setStep(urlStep as CREATE_STEP, true);
} else {
this.setStep(CREATE_STEP.BASICS, true);
}
}
});
}
componentWillUnmount() {
@ -315,6 +306,18 @@ class CreateFlow extends React.Component<Props, State> {
return !!Object.keys(errors).length;
};
private handlePop: History.LocationListener = (location, action) => {
if (action === 'POP') {
const searchValues = qs.parse(location.search);
const urlStep = searchValues.step && searchValues.step.toUpperCase();
if (urlStep && CREATE_STEP[urlStep]) {
this.setStep(urlStep as CREATE_STEP, true);
} else {
this.setStep(CREATE_STEP.BASICS, true);
}
}
};
private fillInExample = () => {
const { accounts } = this.props;
const [payoutAddress, ...trustees] = accounts;

View File

@ -1,18 +1,26 @@
import React from 'react';
import { Link } from 'react-router-dom';
import Logo from 'static/images/logo-name.svg';
import './style.less';
export default () => (
<footer className="Footer">
<Link className="Footer-title" to="/">
Grant.io
<Logo className="Footer-title-logo" />
</Link>
{/*
<div className="Footer-links">
<a className="Footer-links-link">about</a>
<a className="Footer-links-link">legal</a>
<a className="Footer-links-link">privacy policy</a>
</div>
*/}
<div className="Footer-links">
<Link to="/about" className="Footer-links-link">
about
</Link>
<Link to="/contact" className="Footer-links-link">
contact
</Link>
<Link to="/tos" className="Footer-links-link">
terms of service
</Link>
<Link to="/privacy" className="Footer-links-link">
privacy policy
</Link>
</div>
</footer>
);

View File

@ -8,18 +8,22 @@
height: 140px;
&-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: #fff;
transition: transform 100ms ease;
margin-bottom: 0.75rem;
opacity: 0.95;
transition: transform 100ms ease, opacity 100ms ease;
&:hover,
&:focus,
&:active {
opacity: 1;
transform: translateY(-1px);
color: inherit;
}
&-logo {
height: 2.6rem;
fill: #FFF;
}
}
&-links {

View File

@ -107,9 +107,11 @@ class HeaderAuth extends React.Component<Props> {
private closeMenu = () => this.setState({ isMenuOpen: false });
private handleVisibilityChange = (visibility: boolean) => {
private handleVisibilityChange = (visibility?: boolean) => {
// Handle the dropdown component's built in close events
this.setState({ isMenuOpen: visibility });
if (visibility) {
this.setState({ isMenuOpen: visibility });
}
};
}

View File

@ -55,7 +55,7 @@ class HeaderDrawer extends React.Component<Props> {
onClose={onClose}
placement="left"
>
<div className="HeaderDrawer-title">Grant.io</div>
<div className="HeaderDrawer-title">Navigation</div>
<Menu mode="inline" style={{ borderRight: 0 }}>
<Menu.ItemGroup className="HeaderDrawer-user" title={userTitle}>
{user ? (

View File

@ -4,6 +4,7 @@ import classnames from 'classnames';
import HeaderAuth from './Auth';
import HeaderDrawer from './Drawer';
import MenuIcon from 'static/images/menu.svg';
import Logo from 'static/images/logo-name.svg';
import './style.less';
interface Props {
@ -46,7 +47,7 @@ export default class Header extends React.Component<Props, State> {
</div>
<Link className="Header-title" to="/">
Grant.io
<Logo className="Header-title-logo" />
</Link>
<div className="Header-links is-right">

View File

@ -18,6 +18,10 @@
text-shadow: none;
box-shadow: 0 1px rgba(0, 0, 0, 0.1);
svg {
fill: #333;
}
&.is-transparent {
position: absolute;
color: #fff;
@ -28,31 +32,32 @@
svg {
fill: #fff;
}
.Header-title {
transform: translateY(0px) translate(-50%, -50%);
}
}
&-title {
display: flex;
position: absolute;
left: 50%;
top: 50%;
transform: translateY(-2px) translate(-50%, -50%);
font-size: 1.8rem;
transform: translate(-50%, -50%);
margin: 0;
color: inherit;
letter-spacing: 0.08rem;
font-weight: 500;
transition: transform 100ms ease;
transition: transform 100ms ease, opacity 100ms ease;
flex-grow: 1;
text-align: center;
opacity: 0.92;
&:hover,
&:focus,
&:active {
&:focus {
color: inherit;
transform: translateY(-4px) translate(-50%, -50%);
transform: translateY(-2px) translate(-50%, -50%);
opacity: 1;
}
&-logo {
height: 2rem;
width: auto;
transform: translateY(10%);
}
}

View File

@ -0,0 +1,33 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { urlToPublic } from 'utils/helpers';
interface Props {
title: string;
image?: string;
url?: string;
type?: string;
description?: string;
}
export default class HeaderDetails extends React.Component<Props> {
render() {
const { title, image, url, type, description } = this.props;
return (
<Helmet>
<title>{`Grant.io - ${title}`}</title>
{/* open graph protocol */}
{type && <meta property="og:type" content="website" />}
<meta property="og:title" content={title} />
{description && <meta property="og:description" content={description} />}
{url && <meta property="og:url" content={urlToPublic(url)} />}
{image && <meta property="og:image" content={urlToPublic(image)} />}
{/* twitter card */}
<meta property="twitter:title" content={title} />
{description && <meta property="twitter:description" content={description} />}
{url && <meta property="twitter:url" content={urlToPublic(url)} />}
{image && <meta property="twitter:image" content={urlToPublic(image)} />}
</Helmet>
);
}
}

View File

@ -1,6 +1,7 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Icon } from 'antd';
import HeaderDetails from 'components/HeaderDetails';
import TeamsSvg from 'static/images/intro-teams.svg';
import FundingSvg from 'static/images/intro-funding.svg';
import CommunitySvg from 'static/images/intro-community.svg';
@ -25,6 +26,11 @@ export default class Home extends React.Component {
render() {
return (
<div className="Home">
<HeaderDetails
title="Home"
description="Grant.io organizes creators and community members to incentivize ecosystem
improvements"
/>
<div className="Home-hero">
<h1 className="Home-hero-title">
Decentralized funding for <br /> Blockchain ecosystem improvements

View File

@ -2,7 +2,7 @@ import React from 'react';
import { convert, MARKDOWN_TYPE } from 'utils/markdown';
import './Markdown.less';
interface Props extends React.HTMLAttributes<any> {
interface Props extends React.HTMLAttributes<HTMLDivElement> {
source: string;
type?: MARKDOWN_TYPE;
}
@ -11,8 +11,14 @@ export default class Markdown extends React.PureComponent<Props> {
render() {
const { source, type, ...rest } = this.props;
const html = convert(source, type);
// TS types seem to be fighting over react prop defs for div
const divProps = rest as any;
return (
<div className="Markdown" {...rest} dangerouslySetInnerHTML={{ __html: html }} />
<div
className="Markdown"
{...divProps}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
}

View File

@ -39,7 +39,7 @@ interface Props {
}
interface State {
mdeState: ReactMdeTypes.MdeState;
mdeState: ReactMdeTypes.MdeState | null;
}
export default class MarkdownEditor extends React.PureComponent<Props, State> {
@ -55,7 +55,7 @@ export default class MarkdownEditor extends React.PureComponent<Props, State> {
handleChange = (mdeState: ReactMdeTypes.MdeState) => {
this.setState({ mdeState });
this.props.onChange(mdeState.markdown);
this.props.onChange(mdeState.markdown || '');
};
generatePreview = (md: string) => {
@ -73,7 +73,7 @@ export default class MarkdownEditor extends React.PureComponent<Props, State> {
>
<ReactMde
onChange={this.handleChange}
editorState={this.state.mdeState}
editorState={this.state.mdeState as ReactMdeTypes.MdeState}
generateMarkdownPreview={this.generatePreview}
commands={commands[type]}
layout="tabbed"

View File

@ -0,0 +1,64 @@
import React, { PureComponent } from 'react';
import './style.less';
export default class Privacy extends PureComponent {
render() {
return (
<div className="Privacy">
<h1 className="Privacy-title">Privacy Policy</h1>
<section>
<h2>1. Lorem Ipsum</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis
nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore
eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt
in culpa qui officia deserunt mollit anim id est laborum.
</p>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis
nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore
eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt
in culpa qui officia deserunt mollit anim id est laborum.
</p>
</section>
<section>
<h2>2. Duis Aute Irure</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis
nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore
eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt
in culpa qui officia deserunt mollit anim id est laborum.
</p>
</section>
<section>
<h2>3. Ullamco Laboris Reprehenderit</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis
nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</p>
</section>
<section>
<h2>4. Fugiat</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis
nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore
eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt
in culpa qui officia deserunt mollit anim id est laborum.
</p>
</section>
<section>
<b>Effective:</b> Oct 25, 2018
</section>
</div>
);
}
}

View File

@ -0,0 +1,5 @@
@import '~styles/legal-document-mixin.less';
.Privacy {
.legal-document-mixin();
}

View File

@ -0,0 +1,121 @@
@small-query: ~'(max-width: 500px)';
.ProfileEditShade {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 1000;
}
.ProfileEdit {
position: relative;
z-index: 1001;
display: flex;
align-items: center;
padding: 1rem;
margin: -1rem;
background: #fff;
border-radius: 0.2rem;
@media @small-query {
flex-direction: column;
}
&.is-editing {
align-items: flex-start;
}
&-avatar {
position: relative;
height: 10.5rem;
width: 10.5rem;
margin-right: 1.25rem;
align-self: start;
@media @small-query {
margin-bottom: 1rem;
}
&-img {
height: 100%;
width: 100%;
border-radius: 1rem;
}
&-change {
position: absolute;
display: flex;
flex-flow: column;
justify-content: center;
align-items: center;
color: #ffffff;
font-size: 1.2rem;
font-weight: 600;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4);
border-radius: 1rem;
border: none;
cursor: pointer;
&:hover,
&:hover:focus {
background: rgba(0, 0, 0, 0.6);
color: #ffffff;
border: none;
}
&:focus {
background: rgba(0, 0, 0, 0.4);
color: #ffffff;
border: none;
}
&-icon {
font-size: 2rem;
}
}
&-delete {
position: absolute;
top: 0.2rem;
right: 0.2rem;
cursor: pointer;
color: #ffffff;
background: transparent;
border: none;
&:hover,
&:hover:focus {
background: rgba(0, 0, 0, 0.4);
color: #ffffff;
border: none;
}
}
}
&-info {
flex: 1;
.ant-form-item {
margin-bottom: 0.25rem;
}
.ant-btn {
margin-right: 0.5rem;
&:last-child {
margin: 0;
}
}
}
&-alert {
margin-top: 1rem;
}
}

View File

@ -0,0 +1,254 @@
import React from 'react';
import lodash from 'lodash';
import { Input, Form, Col, Row, Button, Icon, Alert } from 'antd';
import { SOCIAL_INFO } from 'utils/social';
import { SOCIAL_TYPE, TeamMember } from 'types';
import { UserState } from 'modules/users/reducers';
import { getCreateTeamMemberError } from 'modules/create/utils';
import UserAvatar from 'components/UserAvatar';
import './ProfileEdit.less';
interface Props {
user: UserState;
onDone(): void;
onEdit(user: TeamMember): void;
}
interface State {
fields: TeamMember;
isChanged: boolean;
showError: boolean;
}
export default class ProfileEdit extends React.PureComponent<Props, State> {
state: State = {
fields: { ...this.props.user } as TeamMember,
isChanged: false,
showError: false,
};
componentDidUpdate(prevProps: Props, _: State) {
if (
prevProps.user.isUpdating &&
!this.props.user.isUpdating &&
!this.state.showError
) {
this.setState({ showError: true });
}
if (
prevProps.user.isUpdating &&
!this.props.user.isUpdating &&
!this.props.user.updateError
) {
this.props.onDone();
}
}
render() {
const { fields } = this.state;
const error = getCreateTeamMemberError(fields);
const isMissingField =
!fields.name || !fields.title || !fields.emailAddress || !fields.ethAddress;
const isDisabled = !!error || isMissingField || !this.state.isChanged;
return (
<>
<div className="ProfileEdit">
<div className="ProfileEdit-avatar">
<UserAvatar className="ProfileEdit-avatar-img" user={fields} />
<Button
className="ProfileEdit-avatar-change"
onClick={this.handleChangePhoto}
>
<Icon
className="ProfileEdit-avatar-change-icon"
type={fields.avatarUrl ? 'picture' : 'plus-circle'}
/>
<div>{fields.avatarUrl ? 'Change photo' : 'Add photo'}</div>
</Button>
{fields.avatarUrl && (
<Button
className="ProfileEdit-avatar-delete"
icon="delete"
shape="circle"
onClick={this.handleDeletePhoto}
/>
)}
</div>
<div className="ProfileEdit-info">
<Form
className="ProfileEdit-info-form"
layout="vertical"
onSubmit={this.handleSave}
>
<Form.Item>
<Input
name="name"
autoComplete="off"
placeholder="Display name (Required)"
value={fields.name}
onChange={this.handleChangeField}
/>
</Form.Item>
<Form.Item>
<Input
name="title"
autoComplete="off"
placeholder="Title (Required)"
value={fields.title}
onChange={this.handleChangeField}
/>
</Form.Item>
<Form.Item>
<Input
name="emailAddress"
disabled={true}
placeholder="Email address (Required)"
type="email"
autoComplete="email"
value={fields.emailAddress}
onChange={this.handleChangeField}
/>
</Form.Item>
<Form.Item>
<Input
name="ethAddress"
disabled={true}
autoComplete="ethAddress"
placeholder="Ethereum address (Required)"
value={fields.ethAddress}
onChange={this.handleChangeField}
/>
</Form.Item>
<Row gutter={12}>
{Object.values(SOCIAL_INFO).map(s => (
<Col xs={24} sm={12} key={s.type}>
<Form.Item>
<Input
placeholder={`${s.name} account`}
autoComplete="off"
value={fields.socialAccounts[s.type]}
onChange={ev => this.handleSocialChange(ev, s.type)}
addonBefore={s.icon}
/>
</Form.Item>
</Col>
))}
</Row>
{!isMissingField &&
error && (
<Alert
type="error"
message={error}
showIcon
style={{ marginBottom: '0.75rem' }}
/>
)}
<Row>
<Button
type="primary"
htmlType="submit"
disabled={isDisabled}
loading={this.props.user.isUpdating}
>
Save changes
</Button>
<Button type="ghost" htmlType="button" onClick={this.handleCancel}>
Cancel
</Button>
</Row>
</Form>
{this.state.showError &&
this.props.user.updateError && (
<Alert
className="ProfileEdit-alert"
message={`There was an error attempting to update your profile. (code ${
this.props.user.updateError
})`}
type="error"
/>
)}
</div>
</div>
<div className="ProfileEditShade" />
</>
);
}
private handleSave = (evt: React.SyntheticEvent<any>) => {
evt.preventDefault();
this.props.onEdit(this.state.fields);
};
private handleCancel = () => {
this.props.onDone();
};
private handleChangeField = (ev: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = ev.currentTarget;
const fields = {
...this.state.fields,
[name as any]: value,
};
const isChanged = this.isChangedCheck(fields);
this.setState({
isChanged,
fields,
});
};
private handleSocialChange = (
ev: React.ChangeEvent<HTMLInputElement>,
type: SOCIAL_TYPE,
) => {
const { value } = ev.currentTarget;
const fields = {
...this.state.fields,
socialAccounts: {
...this.state.fields.socialAccounts,
[type]: value,
},
};
// delete key for empty string
if (!value) {
delete fields.socialAccounts[type];
}
const isChanged = this.isChangedCheck(fields);
this.setState({
isChanged,
fields,
});
};
private handleChangePhoto = () => {
// TODO: Actual file uploading
const gender = ['men', 'women'][Math.floor(Math.random() * 2)];
const num = Math.floor(Math.random() * 80);
const fields = {
...this.state.fields,
avatarUrl: `https://randomuser.me/api/portraits/${gender}/${num}.jpg`,
};
const isChanged = this.isChangedCheck(fields);
this.setState({
isChanged,
fields,
});
};
private handleDeletePhoto = () => {
const fields = lodash.clone(this.state.fields);
delete fields.avatarUrl;
const isChanged = this.isChangedCheck(fields);
this.setState({ isChanged, fields });
};
private isChangedCheck = (a: TeamMember) => {
return !lodash.isEqual(a, this.props.user);
};
}

View File

@ -51,6 +51,7 @@
&-social {
display: flex;
margin-bottom: 1rem;
& a {
display: block;

View File

@ -1,20 +1,59 @@
import React from 'react';
import { SocialInfo, TeamMember } from 'types';
import { connect } from 'react-redux';
import { Button } from 'antd';
import { SocialInfo } from 'types';
import { usersActions } from 'modules/users';
import { UserState } from 'modules/users/reducers';
import { typedKeys } from 'utils/ts';
import ProfileEdit from './ProfileEdit';
import UserAvatar from 'components/UserAvatar';
import './ProfileUser.less';
import { SOCIAL_INFO, socialAccountToUrl } from 'utils/social';
import ShortAddress from 'components/ShortAddress';
import './ProfileUser.less';
import { AppState } from 'store/reducers';
interface OwnProps {
user: TeamMember;
user: UserState;
}
export default class Profile extends React.Component<OwnProps> {
interface StateProps {
authUser: AppState['auth']['user'];
}
interface DispatchProps {
updateUser: typeof usersActions['updateUser'];
}
interface State {
isEditing: boolean;
}
type Props = OwnProps & StateProps & DispatchProps;
class ProfileUser extends React.Component<Props> {
state: State = {
isEditing: false,
};
render() {
const {
authUser,
user,
user: { socialAccounts },
} = this.props;
const isSelf = !!authUser && authUser.ethAddress === user.ethAddress;
if (this.state.isEditing) {
return (
<ProfileEdit
user={user}
onDone={() => this.setState({ isEditing: false })}
onEdit={this.props.updateUser}
/>
);
}
return (
<div className="ProfileUser">
<div className="ProfileUser-avatar">
@ -37,15 +76,28 @@ export default class Profile extends React.Component<OwnProps> {
</div>
)}
</div>
<div className="ProfileUser-info-social">
{Object.values(SOCIAL_INFO).map(
s =>
(socialAccounts[s.type] && (
<Social key={s.type} account={socialAccounts[s.type]} info={s} />
)) ||
null,
)}
</div>
{Object.keys(socialAccounts).length > 0 && (
<div className="ProfileUser-info-social">
{typedKeys(SOCIAL_INFO).map(
s =>
(socialAccounts[s] && (
<Social
key={s}
account={socialAccounts[s] as string}
info={SOCIAL_INFO[s]}
/>
)) ||
null,
)}
</div>
)}
{isSelf && (
<div>
<Button onClick={() => this.setState({ isEditing: true })}>
Edit profile
</Button>
</div>
)}
</div>
</div>
);
@ -59,3 +111,14 @@ const Social = ({ account, info }: { account: string; info: SocialInfo }) => {
</a>
);
};
const connectedProfileUser = connect<StateProps, DispatchProps, {}, AppState>(
state => ({
authUser: state.auth.user,
}),
{
updateUser: usersActions.updateUser,
},
)(ProfileUser);
export default connectedProfileUser;

View File

@ -6,6 +6,7 @@ import { AppState } from 'store/reducers';
import { connect } from 'react-redux';
import { compose } from 'recompose';
import { Spin, Tabs, Badge } from 'antd';
import HeaderDetails from 'components/HeaderDetails';
import ProfileUser from './ProfileUser';
import ProfileProposal from './ProfileProposal';
import ProfileComment from './ProfileComment';
@ -43,7 +44,7 @@ class Profile extends React.Component<Props> {
const userLookupParam = this.props.match.params.id;
const { authUser } = this.props;
if (!userLookupParam) {
if (authUser.ethAddress) {
if (authUser && authUser.ethAddress) {
return <Redirect to={`/profile/${authUser.ethAddress}`} />;
} else {
return <Redirect to="auth" />;
@ -58,7 +59,7 @@ class Profile extends React.Component<Props> {
}
if (user.fetchError) {
return <Exception type="404" />;
return <Exception code="404" />;
}
const { createdProposals, fundedProposals, comments } = user;
@ -68,6 +69,13 @@ class Profile extends React.Component<Props> {
return (
<div className="Profile">
{/* TODO: SSR fetch user details */}
{/* TODO: customize details for funders/creators */}
<HeaderDetails
title={`${user.name} is funding projects on Grant.io`}
description={`Join ${user.name} in funding the future!`}
image={user.avatarUrl}
/>
<ProfileUser user={user} />
<Tabs>
<Tabs.TabPane
@ -134,8 +142,8 @@ const TabTitle = (disp: string, count: number) => (
</div>
);
const withConnect = connect<StateProps, DispatchProps>(
(state: AppState) => ({
const withConnect = connect<StateProps, DispatchProps, {}, AppState>(
state => ({
usersMap: state.users.map,
authUser: state.auth.user,
}),
@ -147,7 +155,7 @@ const withConnect = connect<StateProps, DispatchProps>(
},
);
export default compose<Props, any>(
export default compose<Props, {}>(
withRouter,
withConnect,
)(Profile);

View File

@ -194,17 +194,18 @@ export class Milestones extends React.Component<Props> {
return (
<div className="MilestonAction">
<div className="MilestoneAction-top">
{showVoteProgress && (
<div className="MilestoneAction-progress">
<Progress
type="dashboard"
percent={activeVoteMilestone.percentAgainstPayout}
format={p => `${p}%`}
status="exception"
/>
<div className="MilestoneAction-progress-text">voted against payout</div>
</div>
)}
{showVoteProgress &&
activeVoteMilestone && (
<div className="MilestoneAction-progress">
<Progress
type="dashboard"
percent={activeVoteMilestone.percentAgainstPayout}
format={p => `${p}%`}
status="exception"
/>
<div className="MilestoneAction-progress-text">voted against payout</div>
</div>
)}
<div>
{content}
{button && (

View File

@ -43,7 +43,7 @@ interface State {
}
class ProposalMilestones extends React.Component<Props, State> {
stepTitleRefs: Array<React.RefObject<HTMLDivElement>>;
stepTitleRefs: Array<React.RefObject<HTMLDivElement>> = [];
ref: React.RefObject<HTMLDivElement>;
throttledUpdateDoTitlesOverflow: () => void;
constructor(props: Props) {
@ -261,23 +261,34 @@ class ProposalMilestones extends React.Component<Props, State> {
private updateDoTitlesOverflow = () => {
// hmr can sometimes muck up refs, let's make sure they all exist
if (!this.stepTitleRefs.reduce((a, r) => !!r.current && a)) return;
if (!this.ref || !this.ref.current || !this.stepTitleRefs) {
return;
}
if (!this.stepTitleRefs.reduce((a, r) => !!r.current && a, true)) {
return;
}
let doTitlesOverflow = false;
const stepCount = this.stepTitleRefs.length;
if (stepCount > 1) {
// avoiding style calculation here by hardcoding antd icon width + padding + margin
const iconWidths = stepCount * 56;
const totalWidth = this.ref.current.clientWidth;
const last = this.stepTitleRefs.slice(stepCount - 1).pop().current;
// last title gets full space
const lastWidth = last.clientWidth;
const remainingWidth = totalWidth - (lastWidth + iconWidths);
const remainingWidthSingle = remainingWidth / (stepCount - 1);
// first titles have to share remaining space
this.stepTitleRefs.slice(0, stepCount - 1).forEach(r => {
doTitlesOverflow =
doTitlesOverflow || r.current.clientWidth > remainingWidthSingle;
});
const last = this.stepTitleRefs[stepCount - 1].current;
if (last) {
// last title gets full space
const lastWidth = last.clientWidth;
const remainingWidth = totalWidth - (lastWidth + iconWidths);
const remainingWidthSingle = remainingWidth / (stepCount - 1);
// first titles have to share remaining space
doTitlesOverflow = this.stepTitleRefs
.slice(0, stepCount - 1)
.reduce(
(prev, r) =>
prev || (r.current ? r.current.clientWidth : 0) > remainingWidthSingle,
false,
);
}
}
this.setState({ doTitlesOverflow });
};

View File

@ -30,7 +30,7 @@ interface OwnProps {
}
interface StateProps {
proposal: ProposalWithCrowdFund;
proposal: ProposalWithCrowdFund | null;
}
interface DispatchProps {

Some files were not shown because too many files have changed in this diff Show More