1
0
Fork 0

Begin some React Toolbox UI

master
Ambrose Chua 2017-03-30 20:55:54 +08:00
parent d1972049ae
commit 101e95599e
18 changed files with 355 additions and 208 deletions

1
.gitignore vendored
View File

@ -9,3 +9,4 @@ dist/
# Temporary files
oid_settings.json
nginx.conf
sample_data.sql

View File

View File

@ -1,23 +0,0 @@
import React from 'react';
// eslint-disable-next-line react/prefer-stateless-function
export default class Button extends React.Component {
render() {
return (
<button className={`btn btn-${this.props.color}`} onClick={this.props.onClick}>
{ this.props.children }
</button>
);
}
}
Button.propTypes = {
children: React.PropTypes.node.isRequired,
onClick: React.PropTypes.func,
color: React.PropTypes.string,
};
Button.defaultProps = {
onClick: () => {},
color: 'primary',
};

View File

@ -1,27 +0,0 @@
import React from 'react';
// eslint-disable-next-line react/prefer-stateless-function
export default class Input extends React.Component {
render() {
return (
<input
type={this.props.type}
placeholder={this.props.placeholder}
className="form-control"
value={this.props.value}
/>
);
}
}
Input.propTypes = {
type: React.PropTypes.string,
placeholder: React.PropTypes.string,
value: React.PropTypes.string,
};
Input.defaultProps = {
type: 'text',
placeholder: '',
value: '',
};

View File

@ -1,38 +0,0 @@
import React from 'react';
// eslint-disable-next-line react/prefer-stateless-function
export default class Column extends React.Component {
constructor() {
super();
this.className = this.className.bind(this);
}
className() {
if (this.props.width) {
if (this.props.breakpoint) {
return `col-${this.props.breakpoint}-${this.props.width}`;
}
return `col-${this.props.width}`;
}
return 'col';
}
render() {
return (
<div className={this.className()}>
{ this.props.children }
</div>
);
}
}
Column.propTypes = {
children: React.PropTypes.node.isRequired,
width: React.PropTypes.number,
breakpoint: React.PropTypes.string,
};
Column.defaultProps = {
width: null,
breakpoint: null,
};

View File

@ -1,16 +0,0 @@
import React from 'react';
// eslint-disable-next-line react/prefer-stateless-function
export default class Container extends React.Component {
render() {
return (
<div className="container">
{ this.props.children }
</div>
);
}
}
Container.propTypes = {
children: React.PropTypes.node.isRequired,
};

View File

@ -1,17 +0,0 @@
import React from 'react';
// eslint-disable-next-line react/prefer-stateless-function
export default class Row extends React.Component {
render() {
return (
<div className="row">
{ this.props.children }
</div>
);
}
}
Row.propTypes = {
children: React.PropTypes.node.isRequired,
};

View File

@ -1,13 +1,13 @@
<head>
<meta charset="UTF-8">
<meta charset="UTF-8" />
<title>Chronos</title>
<link rel="stylesheet" href="assets/bootstrap/dist/css/bootstrap.min.css" />
<link href="//fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<link href="//fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet" />
<style>*, *:before, *:after { box-sizing: border-box; } html, body { margin: 0; padding: 0; }</style>
</head>
<body>
<div id="root">
</div>
<script src="assets/oidc-client/dist/oidc-client.min.js"></script>
<script src="assets/bootstrap/dist/js/bootstrap.min.js"></script>
<script src="bundle.js"></script>
<script src="/bundle.js" async></script>
</body>

View File

@ -1,18 +1,24 @@
import 'babel-polyfill';
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import Navigation from './navigation';
// eslint-disable-next-line no-unused-vars
import * as oidc from 'oidc-client';
import LayoutMain from './layouts/main';
import PageHome from './pages/home';
import PageLogin from './pages/login';
import PageMain from './pages/main';
import PageLoginSchool from './pages/login_school';
ReactDOM.render(
<Router>
<div>
<Navigation />
<Route exact path="/" component={PageMain} />
<LayoutMain>
<Route exact path="/" component={PageHome} />
<Route path="/login" component={PageLogin} />
</div>
<Route path="/login/:id" component={PageLoginSchool} />
</LayoutMain>
</Router>,
document.getElementById('root'),
);

73
app/layouts/main.jsx Normal file
View File

@ -0,0 +1,73 @@
import React from 'react';
import { Layout, NavDrawer, Panel, AppBar, Navigation, Link, List, ListItem } from 'react-toolbox';
export default class LayoutMain extends React.Component {
constructor(props) {
super(props);
this.state = {
drawerActive: false,
};
this.toggleDrawerActive = this.toggleDrawerActive.bind(this);
}
toggleDrawerActive() {
this.setState({
drawerActive: !this.state.drawerActive,
});
}
render() {
return (
<Layout>
<NavDrawer
active={this.state.drawerActive}
permanentAt="md"
onOverlayClick={this.toggleDrawerActive}
>
<div style={{ fontSize: '1.2em' }}>
{this.props.user ? `Hello, ${this.props.user.name}!` : 'Not logged in'}
</div>
<List selectable ripple>
{this.props.user ? <ListItem caption="Home" onClick={() => this.context.router.history.push('/')} /> : null}
{this.props.user ? <ListItem caption="Logout" /> : null}
<ListItem caption="Help" />
<ListItem caption="About" />
<ListItem to="https://github.com/ambrosechua/chronos" caption="GitHub" />
</List>
</NavDrawer>
<Panel>
<AppBar title="Chronos" leftIcon="menu" onLeftIconClick={this.toggleDrawerActive}>
<Navigation type="horizontal">
<Link label="Inbox" icon="inbox" onClick={() => this.context.router.history.push('/login')} />
<Link active label="Profile" icon="person" />
</Navigation>
</AppBar>
{this.props.children}
</Panel>
</Layout>
);
}
}
LayoutMain.defaultProps = {
children: null,
user: null,
};
LayoutMain.propTypes = {
children: React.PropTypes.node,
user: React.PropTypes.shape({
name: React.PropTypes.string,
}),
};
LayoutMain.contextTypes = {
router: React.PropTypes.shape({
history: React.PropTypes.shape({
push: React.PropTypes.func.isRequired,
}).isRequired,
}).isRequired,
};

View File

@ -1,28 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
import Container from './components/layouts/container';
import Button from './components/button';
// eslint-disable-next-line react/prefer-stateless-function
export default class Navigation extends React.Component {
render() {
return (
<nav className="navbar navbar-inverse bg-inverse sticky-top">
<Container>
<div className="d-flex flex-row">
<span className="navbar-brand">Chronos</span>
<ul className="navbar-nav mr-auto">
<li className="nav-item">
<Link className="nav-link" to="/login">Login</Link>
</li>
</ul>
<div className="form-inline">
<Button color="secondary" onClick={() => alert('test')}>Logout</Button>
</div>
</div>
</Container>
</nav>
);
}
}

31
app/pages/home.jsx Normal file
View File

@ -0,0 +1,31 @@
import React from 'react';
import { Card, CardTitle, CardText, CardActions, Button } from 'react-toolbox';
// eslint-disable-next-line react/prefer-stateless-function
export default class PageHome extends React.Component {
constructor(props) {
super(props);
this.state = {
cards: [
{ key: 0, title: 'Math Test', description: 'Meow' },
],
};
}
render() {
return (
<div style={{ padding: '1em' }}>
{this.state.cards.map(card =>
<Card key={card.key} style={{ margin: '1em', width: 'auto' }}>
<CardTitle title={card.title} />
<CardText>{card.description}</CardText>
<CardActions>
<Button label="Edit" />
</CardActions>
</Card>,
)}
</div>
);
}
}

View File

@ -1,17 +1,62 @@
import React from 'react';
import Container from '../components/layouts/container';
import Button from '../components/button';
import { Dropdown } from 'react-toolbox';
// eslint-disable-next-line react/prefer-stateless-function
export default class PageLogin extends React.Component {
constructor(props) {
super(props);
this.state = {
schools: [],
school: null,
};
this.changeSchool = this.changeSchool.bind(this);
this.fetchSchools();
}
async fetchSchools() {
return fetch('/api/v1/schools')
.then(data => data.json())
.then((data) => {
this.setState({
schools: data.map(s => ({ value: s.id, label: s.name })),
});
})
.catch((err) => {
console.error(err);
});
}
changeSchool(school) {
this.setState({
school,
});
this.context.router.history.push(`/login/${school}`);
}
render() {
return (
<Container>
<Button>
Login with Office365
</Button>
</Container>
<section style={{ padding: '1em', margin: '0 auto', maxWidth: '480px' }}>
<h1>
Login
</h1>
<Dropdown
onChange={this.changeSchool}
source={this.state.schools}
value={this.state.school}
label="School"
/>
</section>
);
}
}
PageLogin.contextTypes = {
router: React.PropTypes.shape({
history: React.PropTypes.shape({
push: React.PropTypes.func.isRequired,
}).isRequired,
}).isRequired,
};

125
app/pages/login_school.jsx Normal file
View File

@ -0,0 +1,125 @@
import React from 'react';
import { Input, Button, Snackbar } from 'react-toolbox';
import { UserManager } from 'oidc-client';
// eslint-disable-next-line react/prefer-stateless-function
export default class PageLoginSchool extends React.Component {
constructor(props) {
super(props);
this.state = {
id: parseInt(props.match.params.id, 10),
school: {},
openId: false,
email: true,
snackbarActive: false,
};
this.loginOpenId = this.loginOpenId.bind(this);
this.loginEmail = this.loginEmail.bind(this);
this.fetchSchool().then(() => {
if (this.state.openId) {
this.userManager = new UserManager({
authority: `/api/v1/schools/${this.state.school.id}/oid`, // temp bypass: this.state.school.auth[0].oid_meta,
client_id: this.state.school.auth[0].oid_cid,
redirect_uri: `${window.location.origin}/login/${this.state.school.id}`,
});
this.userManager.getUser()
.then((user) => {
console.log(user);
if (user) {
this.context.router.history.push('/');
}
})
.catch(() => {
});
}
});
}
async fetchSchool() {
return fetch(`/api/v1/schools/${this.state.id}`)
.then(data => data.json())
.then((data) => {
this.setState({
school: data,
openId: !!data.auth[0],
});
})
.catch((err) => {
console.error(err);
});
}
loginOpenId() {
this.userManager.signinRedirect();
}
// eslint-disable-next-line class-methods-use-this
loginEmail() {
console.error('Not implemented');
}
handleSnackbarTimeout() {
this.setState({ snackbarActive: false });
}
render() {
return (
<section style={{ padding: '1em', margin: '0 auto', maxWidth: '480px' }}>
{this.state.openId &&
<Button
onClick={this.loginOpenId}
icon="fingerprint"
label="Authenticate with Office365"
raised primary style={{ width: '100%' }}
/>
}
{this.state.openId && this.state.email &&
<div style={{ textAlign: 'center', margin: '1rem 0 0 0' }}>
OR
</div>
}
{this.state.email &&
<div>
<Input
type="email"
label="Email"
/>
<Button
onClick={this.loginEmail}
label="Authenticate with email"
raised primary style={{ width: '100%' }}
/>
<Snackbar
onTimeout={this.state.handleSnackbarTimeout}
active={this.state.snackbarActive}
timeout={2000}
label="Not implemented"
type="accept"
/>
</div>
}
</section>
);
}
}
PageLoginSchool.propTypes = {
match: React.PropTypes.shape({
params: React.PropTypes.shape({
id: React.PropTypes.string.isRequired,
}).isRequired,
}).isRequired,
};
PageLoginSchool.contextTypes = {
router: React.PropTypes.shape({
history: React.PropTypes.shape({
push: React.PropTypes.func.isRequired,
}).isRequired,
}).isRequired,
};

View File

@ -1,23 +0,0 @@
import React from 'react';
import Container from '../components/layouts/container';
import Row from '../components/layouts/row';
import Column from '../components/layouts/column';
// eslint-disable-next-line react/prefer-stateless-function
export default class PageMain extends React.Component {
render() {
return (
<Container>
<Row>
<Column width="3" breakpoint="lg">
<h1>Hello, world! </h1>
</Column>
<Column>
<h1>Hello, world! </h1>
</Column>
</Row>
</Container>
);
}
}

View File

@ -1,5 +1,6 @@
import { get } from 'https';
import Router from 'express';
import { WebError, UnauthenticatedError } from './errors';
import { WebError, UnauthenticatedError, NotFoundError } from './errors';
export default class API {
constructor(database) {
@ -23,13 +24,30 @@ export default class API {
this.database.getSchools()
.then((data) => {
res.json(data);
}).catch(next);
})
.catch(next);
});
this.router.get('/schools/:school', (req, res, next) => {
this.database.getSchool(req.params.school)
this.database.getSchoolWithAuth(req.params.school)
.then((data) => {
res.json(data);
}).catch(next);
res.json(Object.assign(data, {
auth: data.auth.map(a => Object.assign(a, { oid_csecret: undefined })),
}));
})
.catch(next);
});
this.router.get('/schools/:school/oid/.well-known/openid-configuration', (req, res, next) => {
this.database.getSchoolWithAuth(req.params.school)
.then((data) => {
// assume auth[0] exists
const url = data.auth[0].oid_meta;
if (url) {
get(url, (d) => {
d.pipe(res);
});
}
})
.catch(next);
});
// Users
@ -42,11 +60,16 @@ export default class API {
this.router.get('/schools/:school/users/:id', this.auth, (req, res, next) => {
this.database.getUser(req.params.school, req.params.id)
.then((data) => {
res.json(data);
res.json(Object.assign(data, {
pwd: undefined,
oid_id: undefined,
}));
}).catch(next);
});
// this.router.all('/*', this.auth);
this.router.use('/*', (req, res, next) => {
next(new NotFoundError());
});
this.router.use(API.error);
}

View File

@ -19,7 +19,7 @@ export default class Database {
FROM school
`);
}
async getSchool(id) {
async getSchoolWithAuth(id) {
return this.query(`
SELECT *
FROM auth RIGHT JOIN school
@ -36,7 +36,6 @@ export default class Database {
school: undefined,
name: undefined,
domain: undefined,
oid_csecret: undefined,
})),
}))
.catch(err => err.withNoun('School'));
@ -52,12 +51,12 @@ export default class Database {
}
async getUser(school, id) {
return this.query(`
SELECT id, name, email, role
SELECT *
FROM user
WHERE user.school = ?
AND user.id = ?
`, [school, id], {
required: true,
single: true,
})
.catch(err => err.withNoun('User'));
}
@ -68,10 +67,14 @@ export default class Database {
this.connection.query(query, values, (err, results, fields) => {
if (err) {
reject(err);
} else if (options.required === true && results.length < 1) {
} else if ((options.required || options.single) && results.length < 1) {
reject(new NotFoundError());
}
resolve(results, fields);
if (options.single) {
resolve(results[0], fields);
} else {
resolve(results, fields);
}
});
});
}
@ -90,9 +93,10 @@ export default class Database {
// database is up-to-date
return true;
} else if (semver.satisfies(oldVersion, '~0')) { // lmao forces database migration
return true;
// database needs to be updated
return this.query(`DROP DATABASE ${DATABASE}`)
.then(() => this.checkAndMigrate());
// return this.query(`DROP DATABASE ${DATABASE}`)
// .then(() => this.checkAndMigrate());
}
// database does not exist
@ -205,12 +209,16 @@ export default class Database {
VALUES ('version', '${getVersion()}')
`);
// TODO: Build admin interface to add schools
// TODO: Build admin interface to add schools and school owner
// add first school
const firstSchool = await this.query(`
INSERT INTO school (name, domain)
VALUES (?, ?)
`, ['NUS High School', 'nushigh.edu.sg']);
await this.query(`
INSERT INTO user (school, name, email, pwd_hash, role)
VALUES (?, ?, ?, ?, ?)
`, [firstSchool.insertId, 'Ambrose Chua', 'h1310031@nushigh.edu.sg', '', 'OWN']);
// eslint-disable-next-line global-require
const fs = require('fs');

View File

@ -11,7 +11,14 @@ const database = new Database({
});
const api = new API(database);
app.use('/', express.static(path.join(__dirname, '..', 'app')));
// API mount
app.use('/api/v1', api.router);
// API fallback
app.use('/api', (req, res) => res.end('API'));
app.listen(8080);
// Assets
app.use('/', express.static(path.join(__dirname, '..', 'app')));
// Pages
app.get('/*', (req, res) => res.sendFile(path.join(__dirname, '..', 'app', 'index.html')));
app.listen(process.env.PORT || 8080);