1
0
Fork 0

Dump of changes

master
Ambrose Chua 2017-04-12 00:14:51 +08:00
parent d8b0554682
commit 35408b43e2
15 changed files with 465 additions and 114 deletions

2
.gitignore vendored
View File

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

View File

@ -7,7 +7,7 @@ module.exports = function (grunt) {
entry: __dirname + '/app/index.jsx', entry: __dirname + '/app/index.jsx',
output: { output: {
filename: 'bundle.js', filename: 'bundle.js',
path: __dirname + '/dist/app/' path: __dirname + '/dist/app/',
}, },
resolve: { resolve: {
extensions: ['.js', '.jsx'], extensions: ['.js', '.jsx'],
@ -25,15 +25,15 @@ module.exports = function (grunt) {
presets: [ presets: [
['env', { ['env', {
targets: { targets: {
browsers: ['last 2 versions'] browsers: ['last 2 versions'],
}, },
modules: false modules: false,
}], }],
'react' 'react',
] ],
} },
} },
] ],
}, },
{ {
test: /\.css$/, test: /\.css$/,
@ -45,17 +45,17 @@ module.exports = function (grunt) {
sourceMap: true, sourceMap: true,
modules: true, modules: true,
importLoaders: 1, importLoaders: 1,
localIdentName: '[name]--[local]--[hash:base64:8]' localIdentName: '[name]--[local]--[hash:base64:8]',
} },
}, },
{ {
loader: 'postcss-loader' loader: 'postcss-loader',
} },
] ],
} },
] ],
} },
} },
}, },
copy: { copy: {
app: { app: {
@ -64,34 +64,37 @@ module.exports = function (grunt) {
expand: true, expand: true,
cwd: __dirname + '/app/', cwd: __dirname + '/app/',
src: ['index.html'], src: ['index.html'],
dest: __dirname + '/dist/app/' dest: __dirname + '/dist/app/',
}, },
{ {
expand: true, expand: true,
cwd: __dirname + '/app/', cwd: __dirname + '/app/',
src: ['assets/**'], src: ['assets/**'],
dest: __dirname + '/dist/app/assets/' dest: __dirname + '/dist/app/assets/',
}, },
{ {
expand: true, expand: true,
cwd: __dirname + '/node_modules/oidc-client/', cwd: __dirname + '/node_modules/oidc-client/',
src: ['dist/**'], src: ['dist/**'],
dest: __dirname + '/dist/app/assets/oidc-client/' dest: __dirname + '/dist/app/assets/oidc-client/',
} },
] ],
} },
}, },
babel: { babel: {
options: { options: {
sourceMap: true, sourceMap: true,
presets: [ presets: [
['env', { [
targets: { 'env',
node: 'current' {
targets: {
node: 'current',
},
modules: 'commonjs',
}, },
modules: 'commonjs' ],
}] ],
]
}, },
server: { server: {
files: [ files: [
@ -99,10 +102,10 @@ module.exports = function (grunt) {
expand: true, expand: true,
cwd: __dirname + '/server/', cwd: __dirname + '/server/',
src: ['**/*.js'], src: ['**/*.js'],
dest: __dirname + '/dist/server/' dest: __dirname + '/dist/server/',
} },
] ],
} },
}, },
eslint: { eslint: {
app: { app: {
@ -111,9 +114,9 @@ module.exports = function (grunt) {
expand: true, expand: true,
cwd: __dirname + '/app/', cwd: __dirname + '/app/',
src: ['**/*.js*'], src: ['**/*.js*'],
dest: __dirname + '/dist/app/' dest: __dirname + '/dist/app/',
} },
] ],
}, },
server: { server: {
files: [ files: [
@ -121,27 +124,27 @@ module.exports = function (grunt) {
expand: true, expand: true,
cwd: __dirname + '/server/', cwd: __dirname + '/server/',
src: ['**/*.js'], src: ['**/*.js'],
dest: __dirname + '/dist/server/' dest: __dirname + '/dist/server/',
} },
] ],
} },
}, },
watch: { watch: {
app: { app: {
files: '**/*.js*', files: '**/*.js*',
tasks: ['app'], tasks: ['app'],
options: { options: {
cwd: __dirname + '/app/' cwd: __dirname + '/app/',
} },
}, },
server: { server: {
files: '**/*.js', files: '**/*.js',
tasks: ['server'], tasks: ['server'],
options: { options: {
cwd: __dirname + '/server/' cwd: __dirname + '/server/',
} },
} },
} },
}); });
grunt.loadNpmTasks('grunt-webpack'); grunt.loadNpmTasks('grunt-webpack');

View File

@ -31,4 +31,5 @@ A school event planner and timetable
## Security Pitfalls ## Security Pitfalls
- Auth mechanism not verified - Auth mechanism not verified
- Verification of OID tokens is done by upn being the email address
- Succeptable to insecure direct object references - Succeptable to insecure direct object references

View File

@ -0,0 +1,127 @@
import React from 'react';
import { Dialog, Input, Dropdown, DatePicker, TimePicker } from 'react-toolbox';
export default class AddEventDialog extends React.Component {
constructor(props) {
super(props);
const now = new Date();
this.state = {
group: null,
name: '',
start: new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 8),
end: new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 10),
groups: [
{ value: 1, label: 'M17502' },
{ value: 2, label: 'Infocomm Club' },
{ value: 3, label: 'Youth Flying Club', disabled: true },
{ value: 4, label: 'Engineering Intrest Group' },
],
};
this.handleGroupChange = this.handleGroupChange.bind(this);
this.handleNameChange = this.handleNameChange.bind(this);
this.handleStartChange = this.handleStartChange.bind(this);
this.handleEndChange = this.handleEndChange.bind(this);
this.actions = [
{ label: 'Cancel', onClick: this.props.onCancel, accent: true },
{ label: 'Add', onClick: this.addEvent, accent: true },
];
// pull user groups, together with the relationship attribute role
// put user groups into state.groups, disabling those not admin
}
handleGroupChange(value) {
this.setState({
group: value,
});
}
handleNameChange(value) {
this.setState({
name: value,
});
}
handleStartChange(value) {
this.setState(prev => ({
start: value,
end: new Date(
value.getFullYear(),
value.getMonth(),
value.getDate(),
prev.end.getHours(),
prev.end.getMinutes(),
),
}));
}
handleEndChange(value) {
this.setState({
end: value,
});
}
render() {
return (
<Dialog
{...this.props}
actions={this.actions}
title="Create a one-time event"
>
<Dropdown
auto
label="Group"
source={this.state.groups}
value={this.state.group}
required
onChange={this.handleGroupChange}
/>
<Input
type="text"
label="Name"
value={this.state.name}
required
onChange={this.handleNameChange}
/>
<DatePicker
label="Start Date"
minDate={new Date()}
value={this.state.start}
required
onChange={this.handleStartChange}
/>
<TimePicker
label="Start Time"
value={this.state.start}
required
onChange={this.handleStartChange}
/>
<DatePicker
label="End Date"
minDate={
new Date(
this.state.start.getFullYear(),
this.state.start.getMonth(),
this.state.start.getDate(),
)
}
value={this.state.end}
required
onChange={this.handleEndChange}
/>
<TimePicker
label="End Time"
value={this.state.end}
required
onChange={this.handleEndChange}
/>
</Dialog>
);
}
}
AddEventDialog.propTypes = {
onCancel: React.PropTypes.func.isRequired,
};

34
app/components/app.jsx Normal file
View File

@ -0,0 +1,34 @@
import React from 'react';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import LayoutMain from '../layouts/main';
import PageHome from '../pages/home';
import PageLogin from '../pages/login';
import PageLoginSchool from '../pages/login_school';
export default class App extends React.Component {
getChildContext() {
return {
user: {},
token: null,
};
}
render() {
return (
<Router>
<LayoutMain>
<Route exact path="/" component={PageHome} />
<Route path="/login" component={PageLogin} />
<Route path="/login/:id" component={PageLoginSchool} />
</LayoutMain>
</Router>
);
}
}
App.childContextTypes = {
// eslint-disable-next-line react/forbid-prop-types
user: React.PropTypes.object.isRequired,
token: React.PropTypes.string,
};

View File

@ -1,6 +1,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Chronos</title> <title>Chronos</title>
<meta name="viewport" content="width=device-width" />
<link href="//fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" /> <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" /> <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> <style>*, *:before, *:after { box-sizing: border-box; } html, body { margin: 0; padding: 0; }</style>

View File

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

View File

@ -14,7 +14,7 @@ export default class LayoutMain extends React.Component {
toggleDrawerActive() { toggleDrawerActive() {
this.setState({ this.setState({
drawerActive: !this.state.drawerActive, drawerActive: !this.state.drawerActive, // TODO: use function instead
}); });
} }
@ -27,11 +27,14 @@ export default class LayoutMain extends React.Component {
onOverlayClick={this.toggleDrawerActive} onOverlayClick={this.toggleDrawerActive}
> >
<div style={{ fontSize: '1.2em' }}> <div style={{ fontSize: '1.2em' }}>
{this.props.user ? `Hello, ${this.props.user.name}!` : 'Not logged in'} {this.context.user.email ?
<span>
Hello, <span title={this.context.user.email}>{this.context.user.name}</span>!
</span> : 'Not logged in'}
</div> </div>
<List selectable ripple> <List selectable ripple>
{this.props.user ? <ListItem caption="Home" onClick={() => this.context.router.history.push('/')} /> : null} {this.context.user.email ? <ListItem caption="Home" onClick={() => this.context.router.history.push('/')} /> : null}
{this.props.user ? <ListItem caption="Logout" /> : null} {this.context.user.email ? <ListItem caption="Logout" /> : null}
<ListItem caption="Help" /> <ListItem caption="Help" />
<ListItem caption="About" /> <ListItem caption="About" />
<ListItem to="https://github.com/ambrosechua/chronos" caption="GitHub" /> <ListItem to="https://github.com/ambrosechua/chronos" caption="GitHub" />
@ -40,8 +43,7 @@ export default class LayoutMain extends React.Component {
<Panel> <Panel>
<AppBar title="Chronos" leftIcon="menu" onLeftIconClick={this.toggleDrawerActive}> <AppBar title="Chronos" leftIcon="menu" onLeftIconClick={this.toggleDrawerActive}>
<Navigation type="horizontal"> <Navigation type="horizontal">
<Link label="Inbox" icon="inbox" onClick={() => this.context.router.history.push('/login')} /> <Link label="Login" onClick={() => this.context.router.history.push('/login')} style={{ color: 'var(--color-dark-contrast)' }} />
<Link active label="Profile" icon="person" />
</Navigation> </Navigation>
</AppBar> </AppBar>
{this.props.children} {this.props.children}
@ -53,14 +55,10 @@ export default class LayoutMain extends React.Component {
LayoutMain.defaultProps = { LayoutMain.defaultProps = {
children: null, children: null,
user: null,
}; };
LayoutMain.propTypes = { LayoutMain.propTypes = {
children: React.PropTypes.node, children: React.PropTypes.node,
user: React.PropTypes.shape({
name: React.PropTypes.string,
}),
}; };
LayoutMain.contextTypes = { LayoutMain.contextTypes = {
@ -69,5 +67,7 @@ LayoutMain.contextTypes = {
push: React.PropTypes.func.isRequired, push: React.PropTypes.func.isRequired,
}).isRequired, }).isRequired,
}).isRequired, }).isRequired,
// eslint-disable-next-line react/forbid-prop-types
user: React.PropTypes.object.isRequired,
}; };

View File

@ -1,6 +1,11 @@
import React from 'react'; import React from 'react';
import { Card, CardTitle, CardText, CardActions, Button } from 'react-toolbox'; import { Card, CardTitle, CardText, CardActions, Button } from 'react-toolbox';
// import BigCalendar from 'react-big-calendar';
// import moment from 'moment';
// BigCalendar.setLocalizer(BigCalendar.momentLocalizer(moment));
import AddEventDialog from '../components/addeventdialog';
// eslint-disable-next-line react/prefer-stateless-function // eslint-disable-next-line react/prefer-stateless-function
export default class PageHome extends React.Component { export default class PageHome extends React.Component {
@ -10,22 +15,47 @@ export default class PageHome extends React.Component {
cards: [ cards: [
{ key: 0, title: 'Math Test', description: 'Meow' }, { key: 0, title: 'Math Test', description: 'Meow' },
], ],
addEventDialogActive: false,
}; };
this.showAddEventDialog = this.showAddEventDialog.bind(this);
this.hideAddEventDialog = this.hideAddEventDialog.bind(this);
}
showAddEventDialog() {
this.setState({
addEventDialogActive: true,
});
}
hideAddEventDialog() {
this.setState({
addEventDialogActive: false,
});
} }
render() { render() {
return ( return (
<div style={{ padding: '1em' }}> <main>
{this.state.cards.map(card => <div style={{ padding: '1em' }}>
<Card key={card.key} style={{ margin: '1em', width: 'auto' }}> {this.state.cards.map(card =>
<CardTitle title={card.title} /> <Card key={card.key} style={{ margin: '1em', width: 'auto' }}>
<CardText>{card.description}</CardText> <CardTitle title={card.title} />
<CardActions> <CardText>{card.description}</CardText>
<Button label="Edit" /> <CardActions>
</CardActions> <Button label="Edit" accent />
</Card>, </CardActions>
)} </Card>,
</div> )}
</div>
<Button style={{ position: 'fixed', bottom: '1em', right: '1em' }} icon="add" floating accent onClick={this.showAddEventDialog} />
<AddEventDialog
active={this.state.addEventDialogActive}
onCancel={this.hideAddEventDialog}
onEscKeyDown={this.hideAddEventDialog}
onOverlayClick={this.hideAddEventDialog}
/>
</main>
); );
} }
} }

View File

@ -4,6 +4,21 @@ import { Input, Button, Snackbar } from 'react-toolbox';
import { UserManager } from 'oidc-client'; import { UserManager } from 'oidc-client';
const getParams = (query) => {
if (!query) {
return {};
}
return (/^[?#]/.test(query) ? query.slice(1) : query)
.split('&')
.reduce((params, param) => {
const [key, value] = param.split('=');
const obj = {};
obj[key] = value ? decodeURIComponent(value.replace(/\+/g, ' ')) : '';
return Object.assign(params, obj);
}, {});
};
// eslint-disable-next-line react/prefer-stateless-function // eslint-disable-next-line react/prefer-stateless-function
export default class PageLoginSchool extends React.Component { export default class PageLoginSchool extends React.Component {
constructor(props) { constructor(props) {
@ -26,16 +41,33 @@ export default class PageLoginSchool extends React.Component {
client_id: this.state.school.auth[0].oid_cid, client_id: this.state.school.auth[0].oid_cid,
redirect_uri: `${window.location.origin}/login/${this.state.school.id}`, redirect_uri: `${window.location.origin}/login/${this.state.school.id}`,
}); });
this.userManager.getUser() const params = getParams(window.location.hash);
.then((user) => { if (params.id_token) {
console.log(user); // TODO: check auth by sending request to server
if (user) { // TODO: auth endpoint should return user information
this.context.router.history.push('/'); // TODO: use user information here:
} const method = 'POST';
}) const headers = new Headers();
.catch(() => { headers.append('Content-Type', 'application/json');
const body = JSON.stringify({
}); type: 'OID',
id_token: params.id_token,
});
fetch(`/api/v1/schools/${this.state.school.id}/login`, {
method, headers, body,
}).then(async (response) => {
if (response.ok) {
Object.assign(this.context.user, await response.json());
this.context.router.history.push('/');
} else {
console.error(await response.json());
// TODO
}
}).catch((e) => {
// TODO
console.error(e);
});
}
} }
}); });
} }
@ -122,4 +154,6 @@ PageLoginSchool.contextTypes = {
push: React.PropTypes.func.isRequired, push: React.PropTypes.func.isRequired,
}).isRequired, }).isRequired,
}).isRequired, }).isRequired,
// eslint-disable-next-line react/forbid-prop-types
user: React.PropTypes.object.isRequired,
}; };

View File

@ -43,10 +43,17 @@
}, },
"dependencies": { "dependencies": {
"babel-polyfill": "^6.23.0", "babel-polyfill": "^6.23.0",
"body-parser": "^1.17.1",
"express": "^4.14.1", "express": "^4.14.1",
"jsonwebtoken": "^7.3.0",
"jwk-to-pem": "^1.2.6",
"moment": "^2.18.1",
"mysql": "^2.13.0", "mysql": "^2.13.0",
"node-fetch": "^1.6.3",
"oidc-client": "^1.3.0-beta.3", "oidc-client": "^1.3.0-beta.3",
"react": "^15.4.2", "react": "^15.4.2",
"react-big-calendar": "^0.13.0",
"react-css-themr": "^2.0.0",
"react-dom": "^15.4.2", "react-dom": "^15.4.2",
"react-router-dom": "^4.0.0-beta.6", "react-router-dom": "^4.0.0-beta.6",
"react-toolbox": "^2.0.0-beta.7", "react-toolbox": "^2.0.0-beta.7",

View File

@ -1,10 +1,21 @@
module.exports = { module.exports = {
plugins: { plugins: {
'postcss-import': { 'postcss-import': {
root: __dirname, root: __dirname,
}, },
'postcss-mixins': {}, 'postcss-mixins': {},
'postcss-each': {}, 'postcss-each': {},
'postcss-cssnext': {} 'postcss-cssnext': {
}, features: {
}; customProperties: {
variables: {
'color-primary': 'var(--palette-purple-500)',
'color-primary-dark': 'var(--palette-purple-700)',
'color-accent': 'var(--palette-blue-a200)',
'color-accent-dark': 'var(--palette-blue-700)',
},
},
},
},
},
};

View File

@ -1,6 +1,11 @@
import { get } from 'https'; import { get } from 'https';
import Router from 'express'; import Router from 'express';
import { WebError, UnauthenticatedError, NotFoundError } from './errors'; import bodyParser from 'body-parser';
import fetch from 'node-fetch';
import jwt from 'jsonwebtoken';
import jwkToPem from 'jwk-to-pem';
import { WebError, UnknownError, UnauthenticatedError, NotFoundError, InvalidCredentialsError, BadRequestError } from './errors';
export default class API { export default class API {
constructor(database) { constructor(database) {
@ -9,10 +14,14 @@ export default class API {
// Binds // Binds
this.auth = this.auth.bind(this); this.auth = this.auth.bind(this);
// Router
this.router = Router({ this.router = Router({
strict: true, strict: true,
}); });
this.router.use(bodyParser.json());
// Routes // Routes
this.router.get('/', (req, res) => { this.router.get('/', (req, res) => {
@ -50,21 +59,33 @@ export default class API {
.catch(next); .catch(next);
}); });
// 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
})
.catch(next);
});
// Users // Users
this.router.get('/schools/:school/users/', this.auth, (req, res, next) => { this.router.get('/schools/:school/users/', this.auth, (req, res, next) => {
this.database.getUsers(req.params.school) this.database.getUsers(req.params.school)
.then((data) => { .then((data) => {
res.json(data); res.json(data);
}).catch(next); })
.catch(next);
}); });
this.router.get('/schools/:school/users/:id', this.auth, (req, res, next) => { this.router.get('/schools/:school/users/:id', this.auth, (req, res, next) => {
this.database.getUser(req.params.school, req.params.id) this.database.getUserWithSchool(req.params.school, req.params.id)
.then((data) => { .then((data) => {
res.json(Object.assign(data, { res.json(Object.assign(data, {
pwd: undefined, pwd: undefined,
oid_id: undefined, oid_id: undefined,
})); }));
}).catch(next); })
.catch(next);
}); });
this.router.use('/*', (req, res, next) => { this.router.use('/*', (req, res, next) => {
@ -76,6 +97,9 @@ export default class API {
auth(req, res, next) { auth(req, res, next) {
// res.end('not implemented'); // res.end('not implemented');
if (this.validate(req.get('FakeAuth'))) { if (this.validate(req.get('FakeAuth'))) {
req.user = {
id: req.get('FakeID'),
};
return next(); return next();
} }
return next(new UnauthenticatedError()); return next(new UnauthenticatedError());
@ -86,15 +110,59 @@ export default class API {
return !!token; return !!token;
} }
async checkLogin(school, options) {
const checkLoginPassword = async () => {
// TODO
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) {
throw new Error();
}
const o = await fetch(oidUrl).then(res => res.json());
const jwksUrl = o.jwks_uri;
const k = await fetch(jwksUrl).then(res => res.json());
const keys = k.keys;
const verified = keys.reduce((a, key) => {
try {
return jwt.verify(token, jwkToPem(key), {
algorithms: ['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512'],
ignoreExpiration: true,
});
} catch (e) {
return a;
}
}, null);
if (!verified) {
throw new InvalidCredentialsError('Token not verifiable');
}
return verified;
};
if (options.type === 'PWD') { // not used
return this.database.getUserByEmail(options.email)
.then(data => checkLoginPassword(data.pwd_hash, options.pwd) && data);
} else if (options.type === 'OID') {
return checkLoginToken(school, options.id_token)
.then(data => this.database.getUserByEmail(data.upn));
}
return new BadRequestError();
}
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
static error(err, req, res, next) { static error(err, req, res, next) {
if (err instanceof WebError) { if (err instanceof WebError) {
err.responseTo(res); err.responseTo(res);
} else if (err) { } else if (err) {
console.error(err); console.error(err);
res.status(500).json({ new UnknownError().responseTo(res);
error: 'Unknown error',
});
} }
next(); next();
} }

View File

@ -33,13 +33,13 @@ export default class Database {
id: results[0].id, id: results[0].id,
name: results[0].name, name: results[0].name,
domain: results[0].domain, domain: results[0].domain,
auth: results.map(r => Object.assign(r, { auth: results[0].type ? results.map(r => Object.assign(r, {
school: undefined, school: undefined,
name: undefined, name: undefined,
domain: undefined, domain: undefined,
})), })) : [],
})) }))
.catch(err => err.withNoun('School')); .catch(err => Promise.reject(err.withNoun('School')));
} }
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
@ -50,7 +50,17 @@ export default class Database {
WHERE user.school = ? WHERE user.school = ?
`, [school]); `, [school]);
} }
async getUser(school, id) { 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) {
return this.query(` return this.query(`
SELECT * SELECT *
FROM user FROM user
@ -59,7 +69,18 @@ export default class Database {
`, [school, id], { `, [school, id], {
single: true, single: true,
}) })
.catch(err => err.withNoun('User')); .catch(err => Promise.reject(err.withNoun('User')));
}
async getUserByEmail(email) {
// assumes unique email
return this.query(`
SELECT *
FROM user
WHERE user.email = ?
`, [email], {
single: true,
})
.catch(err => Promise.reject(err.withNoun('User')));
} }
query(query, values, options = {}) { query(query, values, options = {}) {

View File

@ -11,6 +11,15 @@ export class WebError extends Error {
} }
} }
export class UnknownError extends WebError {
constructor() {
super();
this.name = this.constructor.name;
this.message = 'Unknown error';
this.code = 500;
}
}
export class NotFoundError extends WebError { export class NotFoundError extends WebError {
constructor(noun = 'Resource') { constructor(noun = 'Resource') {
super(); super();
@ -32,3 +41,21 @@ export class UnauthenticatedError extends WebError {
this.code = 403; this.code = 403;
} }
} }
export class InvalidCredentialsError extends WebError {
constructor() {
super();
this.name = this.constructor.name;
this.message = 'Invalid credentials';
this.code = 403;
}
}
export class BadRequestError extends WebError {
constructor() {
super();
this.name = this.constructor.name;
this.message = 'Bad request';
this.code = 400;
}
}