1
0
Fork 0

Another changedump

master
Ambrose Chua 2017-04-13 00:27:16 +08:00
parent 002b8f5e7a
commit 2ed90533ec
11 changed files with 406 additions and 49 deletions

View File

@ -125,3 +125,10 @@ export default class AddEventDialog extends React.Component {
AddEventDialog.propTypes = {
onCancel: React.PropTypes.func.isRequired,
};
AddEventDialog.contextTypes = {
// eslint-disable-next-line react/forbid-prop-types
user: React.PropTypes.object.isRequired,
token: React.PropTypes.string,
};

View File

@ -0,0 +1,147 @@
import React from 'react';
import { Dialog, Input, Autocomplete, Dropdown } from 'react-toolbox';
export default class AddGroupDialog extends React.Component {
constructor(props, context) {
super(props);
this.state = {
name: '',
members: [],
users: [],
type: 'DEF',
types: [ // TODO: define this by school
{ value: 'DEF', label: 'Normal' },
{ value: 'CCA', label: 'CCA' },
{ value: 'MEN', label: 'Mentor Group' },
{ value: 'ING', label: 'Intrest Group' },
],
mentor_level: 0, // TODO: parse from name
mentor_year: new Date().getFullYear(),
};
this.handleNameChange = this.handleNameChange.bind(this);
this.handleMembersChange = this.handleMembersChange.bind(this);
this.handleTypeChange = this.handleTypeChange.bind(this);
this.fetchUsers = this.fetchUsers.bind(this);
this.addGroup = this.addGroup.bind(this);
this.actions = [
{ label: 'Cancel', onClick: this.props.onCancel, accent: true },
{ label: 'Ok', onClick: this.addGroup, accent: true },
];
this.fetchUsers(context);
}
fetchUsers(context) {
const headers = new Headers();
headers.append('FakeAuth', 'true');
headers.append('FakeID', context.user.id);
fetch(`/api/v1/schools/${context.user.school}/users`, {
headers,
})
.then(data => data.json())
.then((data) => {
this.setState({
users: data.reduce((a, u) => {
const o = {};
o[u.id] = u.name;
return Object.assign(a, o);
}),
});
})
.catch((err) => {
console.error(err);
});
}
addGroup() {
const method = 'POST';
const headers = new Headers();
headers.append('FakeAuth', 'true');
headers.append('FakeID', this.context.user.id);
headers.append('Content-Type', 'application/json');
const body = JSON.stringify({
name: this.state.name,
members: this.state.members,
type: this.state.type,
mentor_level: this.state.mentor_level,
mentor_year: this.state.mentor_year,
});
fetch(`/api/v1/schools/${this.context.user.school}/groups`, {
method, headers, body,
})
.then(() => {
this.props.onDone();
})
.catch((err) => {
console.error(err);
});
}
handleNameChange(value) {
this.setState({
name: value,
});
}
handleMembersChange(value) {
this.setState({
members: value,
});
}
handleTypeChange(value) {
this.setState({
type: value,
});
}
render() {
return (
<Dialog
{...this.props}
actions={this.actions}
title="Create a new group"
> // TODO: make scrollable
<Input
type="text"
label="Name"
value={this.state.name}
required
onChange={this.handleNameChange}
/>
<Autocomplete
keepFocusOnChange
direction="down"
selectedPosition="below"
type="text"
label="Add member"
hint="Start typing..."
suggestionMatch="anywhere"
source={this.state.users}
value={this.state.members}
onChange={this.handleMembersChange}
/>
<Dropdown
auto
source={this.state.types}
value={this.state.type}
onChange={this.handleTypeChange}
/>
</Dialog>
);
}
}
AddGroupDialog.propTypes = {
onCancel: React.PropTypes.func.isRequired,
onDone: React.PropTypes.func.isRequired,
};
AddGroupDialog.contextTypes = {
// eslint-disable-next-line react/forbid-prop-types
user: React.PropTypes.object.isRequired,
token: React.PropTypes.string,
};

View File

@ -5,6 +5,7 @@ import LayoutMain from '../layouts/main';
import PageHome from '../pages/home';
import PageLogin from '../pages/login';
import PageLoginSchool from '../pages/login_school';
import PageGroups from '../pages/groups';
export default class App extends React.Component {
getChildContext() {
@ -21,6 +22,7 @@ export default class App extends React.Component {
<Route exact path="/" component={PageHome} />
<Route path="/login" component={PageLogin} />
<Route path="/login/:id" component={PageLoginSchool} />
<Route path="/groups" component={PageGroups} />
</LayoutMain>
</Router>
);

View File

@ -26,24 +26,73 @@ export default class LayoutMain extends React.Component {
permanentAt="md"
onOverlayClick={this.toggleDrawerActive}
>
<div style={{ fontSize: '1.2em' }}>
<div
style={{ fontSize: '1.2em' }}
>
{this.context.user.email ?
<span>
Hello, <span title={this.context.user.email}>{this.context.user.name}</span>!
</span> : 'Not logged in'}
</span>
:
'Not logged in'
}
</div>
<List selectable ripple>
{this.context.user.email ? <ListItem caption="Home" onClick={() => this.context.router.history.push('/')} /> : null}
{this.context.user.email ? <ListItem caption="Logout" /> : null}
<ListItem caption="Help" />
<ListItem caption="About" />
<ListItem to="https://github.com/ambrosechua/chronos" caption="GitHub" />
<List
selectable
ripple
>
{this.context.user.email ?
[
<ListItem
key={0}
caption="Home"
onClick={() => this.context.router.history.push('/')}
/>,
<ListItem
key={1}
caption="Groups"
onClick={() => this.context.router.history.push('/groups')}
/>,
]
:
null
}
<ListItem
caption="Help"
onClick={() => {}}
/>
<ListItem
caption="About"
onClick={() => {}}
/>
<ListItem
caption="GitHub"
to="https://github.com/ambrosechua/chronos"
/>
</List>
</NavDrawer>
<Panel>
<AppBar title="Chronos" leftIcon="menu" onLeftIconClick={this.toggleDrawerActive}>
<Navigation type="horizontal">
<Link label="Login" onClick={() => this.context.router.history.push('/login')} style={{ color: 'var(--color-dark-contrast)' }} />
<AppBar
title="Chronos"
leftIcon="menu"
onLeftIconClick={this.toggleDrawerActive}
>
<Navigation
type="horizontal"
>
{this.context.user.email ?
<Link
style={{ color: 'var(--color-dark-contrast)' }}
label="Logout"
onClick={() => this.context.router.history.push('/logout')}
/>
:
<Link
style={{ color: 'var(--color-dark-contrast)' }}
label="Login"
onClick={() => this.context.router.history.push('/login')}
/>
}
</Navigation>
</AppBar>
{this.props.children}
@ -69,5 +118,6 @@ LayoutMain.contextTypes = {
}).isRequired,
// eslint-disable-next-line react/forbid-prop-types
user: React.PropTypes.object.isRequired,
token: React.PropTypes.string,
};

101
app/pages/groups.jsx Normal file
View File

@ -0,0 +1,101 @@
import React from 'react';
import { List, ListItem, Button } from 'react-toolbox';
import AddGroupDialog from '../components/addgroupdialog';
export default class PageGroups extends React.Component {
constructor(props, context) {
super(props);
this.state = {
groups: [
{ id: 0, name: 'Test' },
{ id: 1, name: 'Test 2' },
{ id: 2, name: 'Test 3' },
{ id: 3, name: 'Test 4' },
],
addGroupDialogActive: false,
};
this.showAddGroupDialog = this.showAddGroupDialog.bind(this);
this.hideAddGroupDialog = this.hideAddGroupDialog.bind(this);
this.fetchGroups = this.fetchGroups.bind(this);
this.fetchGroups(context);
}
async fetchGroups(context = this.context) {
return fetch(`/api/v1/schools/${context.user.school}/users/${context.user.id}/groups`, {
headers: {
FakeAuth: true,
FakeID: context.user.id,
},
})
.then(data => data.json())
.then((data) => {
this.setState({
groups: data,
});
})
.catch((err) => {
console.error(err);
});
}
showAddGroupDialog() {
this.setState({
addGroupDialogActive: true,
});
}
hideAddGroupDialog() {
this.setState({
addGroupDialogActive: false,
});
}
render() {
return (
<main>
<List
selectable
ripple
>
{this.state.groups.map(group =>
<ListItem
key={group.id}
caption={group.name}
onClick={() => this.context.router.history.push(`/groups/${group.id}`)}
/>,
)}
</List>
<Button
style={{ position: 'fixed', bottom: '1em', right: '1em' }}
floating
accent
icon="add"
onClick={this.showAddGroupDialog}
/>
<AddGroupDialog
active={this.state.addGroupDialogActive}
onCancel={this.hideAddGroupDialog}
onEscKeyDown={this.hideAddGroupDialog}
onOverlayClick={this.hideAddGroupDialog}
onDone={() => { this.fetchGroups(); this.hideAddGroupDialog(); }}
/>
</main>
);
}
}
PageGroups.contextTypes = {
router: React.PropTypes.shape({
history: React.PropTypes.shape({
push: React.PropTypes.func.isRequired,
}).isRequired,
}).isRequired,
// eslint-disable-next-line react/forbid-prop-types
user: React.PropTypes.object.isRequired,
token: React.PropTypes.string,
};

View File

@ -7,7 +7,6 @@ import { Card, CardTitle, CardText, CardActions, Button } from 'react-toolbox';
import AddEventDialog from '../components/addeventdialog';
// eslint-disable-next-line react/prefer-stateless-function
export default class PageHome extends React.Component {
constructor(props) {
super(props);

View File

@ -2,7 +2,6 @@ import React from 'react';
import { Dropdown } from 'react-toolbox';
// eslint-disable-next-line react/prefer-stateless-function
export default class PageLogin extends React.Component {
constructor(props) {
super(props);

View File

@ -19,7 +19,6 @@ const getParams = (query) => {
}, {});
};
// eslint-disable-next-line react/prefer-stateless-function
export default class PageLoginSchool extends React.Component {
constructor(props) {
super(props);
@ -43,9 +42,6 @@ export default class PageLoginSchool extends React.Component {
});
const params = getParams(window.location.hash);
if (params.id_token) {
// TODO: check auth by sending request to server
// TODO: auth endpoint should return user information
// TODO: use user information here:
const method = 'POST';
const headers = new Headers();
headers.append('Content-Type', 'application/json');
@ -57,6 +53,7 @@ export default class PageLoginSchool extends React.Component {
method, headers, body,
}).then(async (response) => {
if (response.ok) {
// TODO: change to setting token
Object.assign(this.context.user, await response.json());
this.context.router.history.push('/');
} else {
@ -156,4 +153,5 @@ PageLoginSchool.contextTypes = {
}).isRequired,
// eslint-disable-next-line react/forbid-prop-types
user: React.PropTypes.object.isRequired,
token: React.PropTypes.string,
};

View File

@ -62,9 +62,9 @@ export default class API {
// Login
this.router.post('/schools/:school/login', (req, res, next) => {
this.checkLogin(req.params.school, req.body)
.then((l) => {
res.json(l);
// Generate and return token
.then((data) => {
res.json(data);
// TODO: generate and return token instead of user object
})
.catch(next);
});
@ -78,7 +78,7 @@ export default class API {
.catch(next);
});
this.router.get('/schools/:school/users/:id', this.auth, (req, res, next) => {
this.database.getUserWithSchool(req.params.school, req.params.id)
this.database.getUser(req.params.school, req.params.id)
.then((data) => {
res.json(Object.assign(data, {
pwd: undefined,
@ -87,6 +87,29 @@ export default class API {
})
.catch(next);
});
this.router.get('/schools/:school/users/:id/groups', this.auth, (req, res, next) => {
this.database.getUserGroups(req.params.school, req.params.id)
.then((data) => {
res.json(data);
})
.catch(next);
});
// Groups
this.router.get('/schools/:school/groups/', this.auth, (req, res, next) => {
this.database.getGroups(req.params.school)
.then((data) => {
res.json(data);
})
.catch(next);
});
this.router.post('/schools/:school/groups/', this.auth, (req, res, next) => {
this.database.createGroup(req.params.school, req.body)
.then((data) => {
res.json(data);
})
.catch(next);
});
this.router.use('/*', (req, res, next) => {
next(new NotFoundError());
@ -116,12 +139,6 @@ export default class API {
throw new InvalidCredentialsError('Not implemented');
};
const checkLoginToken = async (sch, token) => {
// do
// - get school
// then
// - fetch school private key
// then
// - verify jwt
const s = await this.database.getSchoolWithAuth(sch);
const oidUrl = s.auth[0].oid_meta;
if (!oidUrl) {
@ -135,7 +152,7 @@ export default class API {
try {
return jwt.verify(token, jwkToPem(key), {
algorithms: ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512'],
ignoreExpiration: true,
//ignoreExpiration: true,
});
} catch (e) {
return a;
@ -147,11 +164,11 @@ export default class API {
return verified;
};
if (options.type === 'PWD') { // not used
return this.database.getUserByEmail(options.email)
return this.database.getUserByEmail(school, options.email)
.then(data => checkLoginPassword(data.pwd_hash, options.pwd) && data);
} else if (options.type === 'OID') {
} else if (options.type === 'OID') { // TODO: create user if user not found? no.
return checkLoginToken(school, options.id_token)
.then(data => this.database.getUserByEmail(data.upn));
.then(data => this.database.getUserByEmail(school, data.upn));
}
return new BadRequestError();
}

View File

@ -2,7 +2,7 @@ import mysql from 'mysql';
import semver from 'semver';
import { fatal, getVersion } from './utils';
import { NotFoundError } from './errors';
import { NotFoundError, attachNoun } from './errors';
const DATABASE = 'chronos';
@ -39,7 +39,7 @@ export default class Database {
domain: undefined,
})) : [],
}))
.catch(err => Promise.reject(err.withNoun('School')));
.catch(attachNoun('School'));
}
// eslint-disable-next-line class-methods-use-this
@ -50,17 +50,7 @@ export default class Database {
WHERE user.school = ?
`, [school]);
}
async getUser(id) {
return this.query(`
SELECT *
FROM user
WHERE user.id = ?
`, [id], {
single: true,
})
.catch(err => Promise.reject(err.withNoun('User')));
}
async getUserWithSchool(school, id) {
async getUser(school, id) {
return this.query(`
SELECT *
FROM user
@ -69,18 +59,56 @@ export default class Database {
`, [school, id], {
single: true,
})
.catch(err => Promise.reject(err.withNoun('User')));
.catch(attachNoun('User'));
}
async getUserByEmail(email) {
async getUserByEmail(school, email) {
// assumes unique email
return this.query(`
SELECT *
FROM user
WHERE user.email = ?
`, [email], {
WHERE user.school = ?
AND user.email = ?
`, [school, email], {
single: true,
})
.catch(err => Promise.reject(err.withNoun('User')));
.catch(attachNoun('User'));
}
async getUserGroups(school, id) {
return this.query(`
SELECT *
FROM member, group_
WHERE member.group_ = group_.id
AND member.user = ?
`, [id])
.catch(attachNoun('Group'));
}
async getGroups(school) {
return this.query(`
SELECT *
FROM user, member, group_
WHERE member.group_ = group_.id
AND member.user = user.id
AND user.school = ?
`, [school]);
}
async createGroup(school, data) {
console.log(data);
const group = await this.query(`
INSERT INTO group_ (name, type)
VALUES (?, ?)
`, [data.name, data.type]);
if (data.type === 'MEN') {
await this.query(`
INSERT INTO group_mentor (id, level, year)
VALUES (?, ?, ?)
`, [group.insertId, data.mentor_level, data.mentor_year]);
}
const insertMember = u => this.query(`
INSERT INTO member (user, group_)
VALUES (?, ?)
`, [u, group.insertId]);
await Promise.all(data.members.map(insertMember));
}
query(query, values, options = {}) {

View File

@ -59,3 +59,12 @@ export class BadRequestError extends WebError {
this.code = 400;
}
}
export function attachNoun(noun) {
return (err) => {
if (err.withNoun) {
return Promise.reject(err.withNoun(noun));
}
return Promise.reject(err);
};
}