* admin backend

* admin ui

* tslint ignore contracts

* fix name

* build & serve
This commit is contained in:
AMStrix 2018-10-30 11:35:47 -05:00 committed by William O'Beirne
parent 2d75150dff
commit d4298e62cc
35 changed files with 15183 additions and 1 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

@ -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,7 +3,7 @@
from flask import Flask
from flask_cors import CORS
from grant import commands, proposal, user, comment, milestone
from grant import commands, proposal, user, comment, milestone, admin
from grant.extensions import bcrypt, migrate, db, ma, mail
@ -34,6 +34,7 @@ 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):