diff --git a/index.html b/index.html index 6d524eece8886bfeff1e9b795f5a2d5e25162e2a..bf12928aeae431bc02f3bcf4d39cbce201a60571 100644 --- a/index.html +++ b/index.html @@ -1,139 +1,17 @@ -<!DOCTYPE html> -<html lang="en"> +<!doctype html> +<html> <head> - <meta charset="utf-8"> - <meta http-equiv="X-UA-Compatible" content="IE=edge"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>Admintool</title> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>webpack-babel-eslint</title> - <link rel="apple-touch-icon" sizes="57x57" href="res/favicon/apple-icon-57x57.png"> - <link rel="apple-touch-icon" sizes="60x60" href="res/favicon/apple-icon-60x60.png"> - <link rel="apple-touch-icon" sizes="72x72" href="res/favicon/apple-icon-72x72.png"> - <link rel="apple-touch-icon" sizes="76x76" href="res/favicon/apple-icon-76x76.png"> - <link rel="apple-touch-icon" sizes="114x114" href="res/favicon/apple-icon-114x114.png"> - <link rel="apple-touch-icon" sizes="120x120" href="res/favicon/apple-icon-120x120.png"> - <link rel="apple-touch-icon" sizes="144x144" href="res/favicon/apple-icon-144x144.png"> - <link rel="apple-touch-icon" sizes="152x152" href="res/favicon/apple-icon-152x152.png"> - <link rel="apple-touch-icon" sizes="180x180" href="res/favicon/apple-icon-180x180.png"> - <link rel="icon" type="image/png" sizes="192x192" href="res/favicon/android-icon-192x192.png"> - <link rel="icon" type="image/png" sizes="32x32" href="res/favicon/favicon-32x32.png"> - <link rel="icon" type="image/png" sizes="96x96" href="res/favicon/favicon-96x96.png"> - <link rel="icon" type="image/png" sizes="16x16" href="res/favicon/favicon-16x16.png"> - <link rel="manifest" href="res/favicon/manifest.json"> - <meta name="msapplication-TileColor" content="#ffffff"> - <meta name="msapplication-TileImage" content="res/favicon/ms-icon-144x144.png"> - <meta name="theme-color" content="#ffffff"> - - <link href="lib/bootstrap/css/bootstrap.min.css" rel="stylesheet"> - <link href="lib/cust/main.css" rel="stylesheet"> - - <script src="lib/jquery/jquery-2.2.2.min.js"></script> - <script src="lib/bootstrap/js/bootstrap.min.js"></script> - <script> - // set the api url for the amivcore js library - var api_url_config = "https://amiv-api.ethz.ch"; - var spec_url_config = "lib/amiv/spec.json"; - </script> - <script src="lib/amiv/amivcore.js"></script> - <script src="lib/cust/main.js"></script> - - <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.9.0/moment-with-locales.js"></script> - <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.14.30/js/bootstrap-datetimepicker.min.js"></script> - <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.14.30/css/bootstrap-datetimepicker.css"> + <link href="res/bootstrap/css/bootstrap.min.css" rel="stylesheet"> + <link href="lib/cust/main.css" rel="stylesheet"> </head> <body> - <!-- Login Overlay--> - <div class="loginPanel smooth"> - <div class="col-sm-4"></div> - <div class="col-sm-4 well"> - <img class="login-logo" src="res/logo/main.svg" style="margin-left: 15%;" alt="AMIV Logo"> - <div class="input-group"> - <input type="text" class="form-control" id="loginUsername" name="user" placeholder="user"> - <span class="input-group-addon">@student.ethz.ch</span> - </div> - <br> - <input type="password" class="form-control" id="loginPassword" name="password" placeholder="password"> - <br> - <button type="submit" class="btn btn-default loginAction">Submit</button> - </div> - <div class="col-sm-4"></div> - </div> - - <!-- Modal --> - <div class="modal fade modalCont" tabindex="-1" role="dialog"> - <div class="modal-dialog"> - <div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button> - <h4 class="modal-title"></h4> - </div> - <div class="modal-body"></div> - <div class="modal-footer"> - </div> - </div> - </div> - </div> - - <!-- Log Container --> - <div class="alertCont"></div> - - <!-- Main Container --> - <div class="wrapper-main smooth"> - - <!-- Sidebar Container --> - <div class="wrapper-sidebar smooth"> - <div class="container-fluid"> - <a href="#home"><img class="sidebar-logo" src="res/logo/main.svg" alt="AMIV Logo"></a> - <ul class="nav nav-pills nav-stacked nav-sidebar" role="navigation"> - <li> - <div class="input-group"> - <input type="text" class="form-control" placeholder="Find Me!"> - <span class="input-group-btn"> - <button class="btn btn-default" type="button"><span class="glyphicon glyphicon-search" aria-hidden="true"></span></button> - </span> - </div> - </li> - <li role="separator" class="divider"></li> - <li><a href="#users"><span class="glyphicon glyphicon-list-alt" aria-hidden="true"></span> Users</a></li> - <li><a href="#events"><span class="glyphicon glyphicon-calendar" aria-hidden="true"></span> Events</a></li> - <li><a href="#groups"><span class="glyphicon glyphicon-blackboard" aria-hidden="true"></span> Groups</a></li> - <li><a href="#annouce"><span class="glyphicon glyphicon-bullhorn" aria-hidden="true"></span> Announce</a></li> - <li><a href="#studydocuments"><span class="glyphicon glyphicon-folder-open" aria-hidden="true"></span> Studydocs</a></li> - <li><a href="#beerncoffee"><span class="glyphicon glyphicon-cutlery" aria-hidden="true"></span> Beer & Coffee</a></li> -</li> - </ul> - </div> - </div> - - <!-- Top Navbar --> - <nav class="navbar navbar-default navbar-main"> - <div class="container-fluid"> - <div class="navbar-header"> - <button type="button" class="navbar-toggle collapsed toggleSidebarBtn" aria-expanded="false"> - <span class="sr-only">Toggle navigation</span> - <span class="icon-bar"></span> - <span class="icon-bar"></span> - <span class="icon-bar"></span> - </button> - <img height="40" style="margin: 5px;" src="res/logo/wheel.svg" id="wheel-logo" class="smooth" alt="Loading Wheel"> - </div> - - <ul class="nav navbar-nav navbar-left cust-menu pull-left"> - </ul> - - <ul class="nav navbar-nav navbar-right pull-right"> - <li><a href="#" class="logoutAction"><span class="glyphicon glyphicon-off" aria-hidden="true"></span></a></li> - </ul> - </div> - </nav> - - <!-- Main Content / Tool --> - <div class="wrapper-content" id="main-content"> - </div> - </div> - + <script src="/dist/bundle.js"></script> </body> </html> diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000000000000000000000000000000000000..35e5e6d6ede18c134f93ae3e087d5a0dc83705eb --- /dev/null +++ b/src/auth.js @@ -0,0 +1,88 @@ +import axios from 'axios'; +import * as localStorage from './localStorage'; + +// Object which stores the current login-state +const APISession = { + authenticated: false, + token: '', +}; + +const amivapi = axios.create({ + baseURL: 'https://amiv-api.ethz.ch/', + timeout: 10000, + headers: { 'Content-Type': 'application/json' }, +}); + +function checkToken(token) { + // check if a token is still valid + return new Promise((resolve, reject) => { + amivapi.get('users', { + headers: { + 'Content-Type': 'application/json', + Authorization: token, + }, + }).then((response) => { + console.log(response.data); + if (response.status === 200) resolve(); + else reject(); + }).catch(reject); + }); +} + +export function checkAuthenticated() { + // return a promise that resolves always, with a bool that shows whether + // the user is authenticated + return new Promise((resolve, reject) => { + if (APISession.authenticated) resolve(true); + else { + console.log('looking for token'); + // let's see if we have a stored token + const token = localStorage.get('token'); + console.log(`found this token: ${token}`); + if (token !== '') { + // check of token is valid + checkToken(token).then(() => { + APISession.token = token; + APISession.authenticated = true; + resolve(true); + }).catch(() => { + resolve(false); + }); + } else resolve(false); + } + }); +} + +export function getSession() { + // Promise resolves with authenticated axios-session or fails + return new Promise((resolve, reject) => { + checkAuthenticated().then((authenticated) => { + if (authenticated) { + const authenticatedSession = axios.create({ + baseURL: 'https://amiv-api.ethz.ch/', + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + Authorization: APISession.token, + }, + }); + resolve(authenticatedSession); + } else reject(); + }).catch(reject); + }); +} + +export function login(username, password) { + return new Promise((resolve, reject) => { + amivapi.post('sessions', { username, password }) + .then((response) => { + if (response.status === 201) { + APISession.token = response.data.token; + APISession.authenticated = true; + localStorage.set('token', response.data.token); + resolve(); + } + reject(); + }).catch(reject); + }); +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000000000000000000000000000000000000..8a3e9886d68925d242d9bc9a9d759adbde4182d6 --- /dev/null +++ b/src/index.js @@ -0,0 +1,39 @@ +import { LoginScreen } from './login'; +import TableView from './views/tableView'; +import UserModal from './userTool'; + +const m = require('mithril'); + +const main = document.createElement('div'); +document.body.appendChild(main); +const root = main; + + +m.route(root, '/users', { + '/users': { + view() { + return m(TableView, { + resource: 'users', + keys: ['firstname', 'lastname', 'nethz', 'legi', 'membership'], + }); + }, + }, + '/users/:id': UserModal, + '/events': { + view() { + return m(TableView, { + resource: 'events', + keys: ['title_de', 'time_start', 'show_website', 'spots', 'signup_count'], + }); + }, + }, + '/groups': { + view() { + return m(TableView, { + resource: 'groups', + keys: ['name'], + }); + }, + }, + '/login': LoginScreen, +}); diff --git a/src/localStorage.js b/src/localStorage.js new file mode 100644 index 0000000000000000000000000000000000000000..ddb35c3ad120a3e49e8a430e3d7ebb45f4bc1028 --- /dev/null +++ b/src/localStorage.js @@ -0,0 +1,36 @@ +// Get something stored at key from local storage +export function get(key) { + const longStorage = window.sessionStorage.getItem(`glob-${key}`); + if (!longStorage || longStorage === '') { + // If longStorage is empty, look in short storage + return window.localStorage.getItem(`glob-${key}`); + } + return longStorage; +} + +/** + * Remove variable in localStorage + * @param {string} cname + */ +export function remove(key) { + if (window.sessionStorage.getItem(`glob-${key}`)) { + window.sessionStorage.removeItem(`glob-${key}`); + } + if (window.localStorage.getItem(`glob-${key}`)) { + window.localStorage.removeItem(`glob-${key}`); + } +} + +/** + * Save and get into localStorage + * @constructor + * @param {string} key + * @param {string} value + */ +export function set(key, value, shortSession = false) { + if (shortSession) { + window.sessionStorage.setItem(`glob-${key}`, value); + } else { + window.localStorage.setItem(`glob-${key}`, value); + } +} diff --git a/src/login.js b/src/login.js new file mode 100644 index 0000000000000000000000000000000000000000..ca1a3409e8dbfdcde4aaf94f84606b4fd19725db --- /dev/null +++ b/src/login.js @@ -0,0 +1,47 @@ +import { login } from './auth'; + +const m = require('mithril'); + +const FormState = { + username: '', + setUsername(v) { FormState.username = v; }, + password: '', + setPassword(v) { FormState.password = v; }, +}; + +export default class LoginScreen { + view() { + return m('div', { class: 'loginPanel smooth' }, [ + m('div.col-sm-4'), + m('div.col-sm-4', [ + m('img.login-logo', { src: 'res/logo/main.svg' }), + m('div.input-group', [ + m('input.form-control', { + oninput: m.withAttr('value', FormState.setUsername), + placeholder: 'user', + }), + m('span.input-group-addon', '@student.ethz.ch'), + ]), + m('br'), + m('input.form-control', { + oninput: m.withAttr('value', FormState.setPassword), + placeholder: 'password', + type: 'password', + }), + m('br'), + m('button.btn.btn-default', { + onclick() { + login(FormState.username, FormState.password).then(() => { + m.route.set('/users'); + }); + }, + }, 'Submit'), + ]), + m('div.col-sm-4'), + m('div', [ + m('span', FormState.username), + m('span', FormState.password), + ]), + ]); + } +} diff --git a/src/userTool.js b/src/userTool.js new file mode 100644 index 0000000000000000000000000000000000000000..54dc2d3004db2c9c5e3e52d86a70d180ac5a996d --- /dev/null +++ b/src/userTool.js @@ -0,0 +1,133 @@ +import { ItemView } from './views/itemView'; +import { EditView, inputGroup, selectGroup } from './views/editView'; +import Table from './views/tableView'; + +const m = require('mithril'); + +const keyDescriptors = { + legi: 'Legi Number', + firstname: 'First Name', + lastname: 'Last Name', + rfid: 'RFID', + phone: 'Phone', + nethz: 'nethz Account', + gender: 'Gender', + department: 'Department', + email: 'Email', +}; + +class UserView extends ItemView { + constructor() { + super('users'); + this.memberships = []; + } + + view() { + // do not render anything if there is no data yet + if (!this.data) return m.trust(''); + + let membershipBadge = m('span.label.label-important', 'No Member'); + if (this.data.membership === 'regular') { + membershipBadge = m('span.label.label-success', 'Member'); + } else if (this.data.membership === 'extraordinary') { + membershipBadge = m('span.label.label-success', 'Extraordinary Member'); + } else if (this.data.membership === 'honory') { + membershipBadge = m('span.label.label-warning', 'Honory Member'); + } + + const detailKeys = [ + 'email', 'phone', 'nethz', 'legi', 'rfid', 'department', 'gender']; + + return m('div', [ + m('h1', `${this.data.firstname} ${this.data.lastname}`), + membershipBadge, + m('table', detailKeys.map(key => m('tr', [ + m('td.detail-descriptor', keyDescriptors[key]), + m('td', this.data[key] ? this.data[key] : ''), + ]))), + m('h2', 'Memberships'), m('br'), + m(Table, { + resource: 'groupmemberships', + keys: ['group.name', 'expiry'], + querystring: m.buildQueryString({ + where: `user=="${this.id}"`, + embedded: '{"group":1}', + }), + }), + m('h2', 'Signups'), m('br'), + m(Table, { + resource: 'eventsignups', + keys: ['event.title_de'], + querystring: m.buildQueryString({ + where: `user=="${this.id}"`, + embedded: '{"event":1}', + }), + }), + ]); + } +} + +class UserEdit extends EditView { + constructor(vnode) { + super(vnode, 'users'); + } + + view() { + // do not render anything if there is no data yet + if (!this.data) return m.trust(''); + + // UPDATE button is inactive if form is not valid + const buttonArgs = this.patchOnClick([ + 'lastname', 'firstname', 'email', 'membership', 'gender']); + const updateButton = m( + 'div.btn.btn-warning', + this.valid ? buttonArgs : { disabled: 'disabled' }, + 'Update', + ); + + return m('form', [ + m('div.row', [ + m(inputGroup, this.bind({ + classes: 'col-xs-6', title: 'Last Name', name: 'lastname', + })), + m(inputGroup, this.bind({ + classes: 'col-xs-6', title: 'First Name', name: 'firstname', + })), + m(inputGroup, this.bind({ title: 'Email', name: 'email' })), + m(selectGroup, this.bind({ + classes: 'col-xs-6', + title: 'Membership Status', + name: 'membership', + options: ['regular', 'extraordinary', 'honory'], + })), + m(selectGroup, this.bind({ + classes: 'col-xs-6', + title: 'Gender', + name: 'gender', + options: ['male', 'female'], + })), + ]), + m('span', JSON.stringify(this.data)), + m('span', JSON.stringify(this.errorLists)), + updateButton, + ]); + } +} + +export default class UserModal { + constructor() { + this.edit = false; + } + + view() { + if (this.edit) { + return m(UserEdit, { onfinish: () => { this.edit = false; m.redraw(); } }); + } + // else + return m('div', [ + m('div.btn', { onclick: () => { this.edit = true; } }, 'Edit'), + m('br'), + m(UserView), + ]); + } +} diff --git a/src/views/editView.js b/src/views/editView.js new file mode 100644 index 0000000000000000000000000000000000000000..46d07d7f4c0dd6c40dffd7151e405481903ebe24 --- /dev/null +++ b/src/views/editView.js @@ -0,0 +1,138 @@ +import Ajv from 'ajv'; +import { ItemView } from './itemView'; +import { getSession } from '../auth'; + + +const m = require('mithril'); + +const objectNameForResource = { + users: 'User', +}; + +export class EditView extends ItemView { + constructor(vnode, resource, valid = true) { + super(resource); + this.changed = false; + + // state for validation + this.valid = valid; + this.ajv = new Ajv({ missingRefs: 'ignore' }); + this.errors = {}; + + // callback when edit is finished + this.callback = vnode.attrs.onfinish; + } + + oninit() { + // load data for item + getSession().then((apiSession) => { + this.loadItemData(apiSession); + }).catch(() => { + m.route.set('/login'); + }); + // load schema + m.request('http://amiv-api.ethz.ch/docs/api-docs').then((schema) => { + const objectSchema = schema.definitions[ + objectNameForResource[this.resource]]; + this.ajv.addSchema(objectSchema, 'schema'); + }); + } + + // bind form-fields to the object data and validation + bind(attrs) { + // initialize error-list for every bound field + if (!this.errors[attrs.name]) this.errors[attrs.name] = []; + + const boundFormelement = { + onchange: (e) => { + this.changed = true; + // bind changed data + this.data[e.target.name] = e.target.value; + + // validate against schema + const validate = this.ajv.getSchema('schema'); + this.valid = validate(this.data); + + // get errors of this field + let errors = []; + if (!this.valid) { + errors = validate.errors.filter(error => + `.${e.target.name}` === error.dataPath); + errors = errors.map(error => error.message); + } + this.errors[e.target.name] = errors; + }, + getErrors: () => this.errors[attrs.name], + value: this.data[attrs.name], + }; + // add the given attributes + Object.keys(attrs).forEach((key) => { boundFormelement[key] = attrs[key]; }); + + return boundFormelement; + } + + patchOnClick(patchableFields) { + return { + onclick: () => { + if (this.changed) { + getSession().then((apiSession) => { + // fields like `_id` are not patchable and would lead to an error + // We therefore only send patchable fields + const patchData = {}; + patchableFields.forEach((key) => { + patchData[key] = this.data[key]; + }); + + apiSession.patch(`${this.resource}/${this.id}`, patchData, { + headers: { 'If-Match': this.data._etag }, + }).then(() => { this.callback(); }); + }); + } else { + this.callback(); + } + }, + }; + } +} + +export class inputGroup { + constructor(vnode) { + // Link the error-getting function from the binding + this.getErrors = () => []; + if (vnode.attrs.getErrors) { + this.getErrors = vnode.attrs.getErrors; + } + } + + view(vnode) { + // set display-settings accoridng to error-state + let errorField = null; + const groupClasses = vnode.attrs.classes ? vnode.attrs.classes : []; + const errors = this.getErrors(); + if (errors.length > 0) { + errorField = m('span.help-block', `Error: ${errors.join(', ')}`); + groupClasses.push('has-error'); + } + + return m('div.form-group', { class: groupClasses }, [ + m(`label[for=${vnode.attrs.name}]`, vnode.attrs.title), + m(`input[name=${vnode.attrs.name}][id=${vnode.attrs.name}].form-control`, { + value: vnode.attrs.value, onchange: vnode.attrs.onchange, + }), + errorField, + ]); + } +} + +export class selectGroup { + view(vnode) { + return m('div.form-group', { class: vnode.attrs.classes }, [ + m(`label[for=${vnode.attrs.name}]`, vnode.attrs.title), + m( + `select[name=${vnode.attrs.name}][id=${vnode.attrs.name}].form-control`, + { value: vnode.attrs.value, onchange: vnode.attrs.onchange }, + vnode.attrs.options.map(option => m('option', option)), + ), + ]); + } +} diff --git a/src/views/itemView.js b/src/views/itemView.js new file mode 100644 index 0000000000000000000000000000000000000000..6cc828033e11db70b3fcc21fb26c3bb98dee7c49 --- /dev/null +++ b/src/views/itemView.js @@ -0,0 +1,32 @@ +import { getSession } from '../auth'; + +const m = require('mithril'); + +export class ItemView { + constructor(resource) { + this.data = null; + this.id = m.route.param('id'); + this.resource = resource; + } + + loadItemData(session) { + session.get(`${this.resource}/${this.id}`).then((response) => { + this.data = response.data; + m.redraw(); + }); + } + + oninit() { + getSession().then((apiSession) => { + this.loadItemData(apiSession); + }).catch(() => { + m.route.set('/login'); + }); + } +} + +export class Title { + view(vnode) { + return m('h1', vnode.attrs); + } +} diff --git a/src/views/tableView.js b/src/views/tableView.js new file mode 100644 index 0000000000000000000000000000000000000000..b70e86e2231998ce47f96c7be23c4befdf9390f4 --- /dev/null +++ b/src/views/tableView.js @@ -0,0 +1,57 @@ +import { getSession } from '../auth'; + +const m = require('mithril'); + +class TableRow { + view(vnode) { + return m( + 'tr', + { onclick() { m.route.set(`/${vnode.attrs.data._links.self.href}`); } }, + vnode.attrs.show_keys.map((key) => { + // Access a nested key, indicated by dot-notation + let data = vnode.attrs.data; + key.split('.').forEach((subKey) => { data = data[subKey]; }); + return m('td', data); + }), + ); + } +} + +export default class TableView { + constructor(vnode) { + this.items = []; + this.show_keys = vnode.attrs.keys; + this.resource = vnode.attrs.resource; + // the querystring is either given or will be parsed from the url + if (vnode.attrs.querystring) { + this.querystring = vnode.attrs.querystring; + } else { + this.querystring = m.buildQueryString(m.route.param()); + } + } + + oninit() { + getSession().then((apiSession) => { + let url = this.resource; + if (this.querystring.length > 0) url += `?${this.querystring}`; + apiSession.get(url).then((response) => { + this.items = response.data._items; + console.log(this.items); + m.redraw(); + }); + }).catch(() => { + m.route.set('/login'); + }); + } + + view() { + return m('div', [ + m('table.table.table-hover', [ + m('thead', m('tr', this.show_keys.map(title => m('th', title)))), + m('tbody', this.items.map(item => + m(TableRow, { show_keys: this.show_keys, data: item }))), + ]), + ]); + } +} +