diff --git a/.eslintrc b/.eslintrc index 190386e..7dbccca 100644 --- a/.eslintrc +++ b/.eslintrc @@ -7,7 +7,8 @@ } }, "globals": { - "FroalaEditor": "readonly" + "FroalaEditor": "readonly", + "gapi": "readonly" }, "extends": "eslint:recommended", "env": { diff --git a/api/article/routes.mjs b/api/article/routes.mjs index 596569c..6492b9b 100644 --- a/api/article/routes.mjs +++ b/api/article/routes.mjs @@ -13,7 +13,7 @@ export default class ArticleRoutes { async getAllArticles(ctx) { await this.security.ensureIncludes(ctx) - ctx.body = await this.Article.getAll(ctx, { }, ctx.state.filter.includes) + ctx.body = await this.Article.getAll(ctx, { }, ctx.state.filter.includes, ctx.query.sort || '-id') } /** GET: /api/pages/:pageId/articles */ diff --git a/api/authentication/helper.mjs b/api/authentication/helper.mjs new file mode 100644 index 0000000..eeb417b --- /dev/null +++ b/api/authentication/helper.mjs @@ -0,0 +1,35 @@ +import Staff from '../staff/model.mjs' +import Jwt from '../jwt.mjs' + +export default class AuthHelper { + constructor(opts = {}) { + Object.assign(this, { + Staff: opts.Staff || Staff, + jwt: opts.jwt || new Jwt(), + }) + } + + async loginStaff(ctx) { + let staff + + try { + staff = await this.Staff + .query(qb => { + qb.where({ email: ctx.request.body.username }) + qb.select('*') + }) + .fetch({ require: true }) + + console.log(ctx.request.body.password, staff.get('password')) + + await this.Staff.compare(ctx.request.body.password, staff.get('password')) + } catch (err) { + if (err.message === 'EmptyResponse' || err.message === 'PasswordMismatch') { + ctx.throw(422, 'The email or password did not match') + } + throw err + } + + return this.jwt.createToken(staff.get('email'), staff.get('level')) + } +} diff --git a/api/authentication/routes.mjs b/api/authentication/routes.mjs index 6e9716f..c6acd30 100644 --- a/api/authentication/routes.mjs +++ b/api/authentication/routes.mjs @@ -1,13 +1,17 @@ import Staff from '../staff/model.mjs' import Jwt from '../jwt.mjs' import * as google from './google.mjs' +import * as security from './security.mjs' +import AuthHelper from './helper.mjs' export default class AuthRoutes { constructor(opts = {}) { Object.assign(this, { + helper: opts.helper || new AuthHelper(), Staff: opts.Staff || Staff, jwt: opts.jwt || new Jwt(), google: opts.google || google, + security: opts.security || security, }) } @@ -36,4 +40,21 @@ export default class AuthRoutes { ctx.body = { token: this.jwt.createToken(output.email, level) } } + + /* + * POST /api/login/user - Authenticate a user using password login + * + * @body {string} username - Username + * @body {string} password - Password + * @returns + * + * { token: 'Authentication token' } + */ + async loginUser(ctx) { + this.security.isValidLogin(ctx, ctx.request.body) + + let token = await this.helper.loginStaff(ctx) + + ctx.body = { token } + } } diff --git a/api/authentication/security.mjs b/api/authentication/security.mjs new file mode 100644 index 0000000..61f4216 --- /dev/null +++ b/api/authentication/security.mjs @@ -0,0 +1,18 @@ + +export function isValidLogin(ctx, body) { + if (!body.username) { + ctx.throw(422, 'Body was missing property username') + } + + if (!body.password) { + ctx.throw(422, 'Body was missing property password') + } + + if (typeof body.password !== 'string') { + ctx.throw(422, 'Property password must be a string') + } + + if (typeof body.username !== 'string') { + ctx.throw(422, 'Property username must be a string') + } +} diff --git a/api/router.mjs b/api/router.mjs index fc95621..f5ffa17 100644 --- a/api/router.mjs +++ b/api/router.mjs @@ -7,6 +7,7 @@ import MediaRoutes from './media/routes.mjs' import FileRoutes from './file/routes.mjs' import PageRoutes from './page/routes.mjs' import ArticleRoutes from './article/routes.mjs' +import StaffRoutes from './staff/routes.mjs' import { restrict } from './access/middleware.mjs' const router = new Router() @@ -14,6 +15,7 @@ const router = new Router() // API Authentication const authentication = new AuthRoutes() router.post('/api/login', authentication.login.bind(authentication)) +router.post('/api/login/user', authentication.loginUser.bind(authentication)) // API Media const media = new MediaRoutes() @@ -42,4 +44,11 @@ router.post('/api/articles', restrict(access.Manager), article.createArticle.bin router.put('/api/articles/:id', restrict(access.Manager), article.updateArticle.bind(article)) router.del('/api/articles/:id', restrict(access.Manager), article.removeArticle.bind(article)) +const staff = new StaffRoutes() +router.get('/api/staff', restrict(access.Admin), staff.getAllStaff.bind(staff)) +router.get('/api/staff/:id', restrict(access.Admin), staff.getSingleStaff.bind(staff)) +router.post('/api/staff', restrict(access.Admin), staff.createStaff.bind(staff)) +router.put('/api/staff/:id', restrict(access.Admin), staff.updateStaff.bind(staff)) +router.del('/api/staff/:id', restrict(access.Admin), staff.removeStaff.bind(staff)) + export default router diff --git a/api/staff/model.mjs b/api/staff/model.mjs index 5fa5be4..92f8044 100644 --- a/api/staff/model.mjs +++ b/api/staff/model.mjs @@ -1,4 +1,6 @@ import bookshelf from '../bookshelf.mjs' +import bcrypt from 'bcrypt' +import config from '../config.mjs' /* Staff model: { @@ -16,13 +18,57 @@ import bookshelf from '../bookshelf.mjs' const Staff = bookshelf.createModel({ tableName: 'staff', + + privateFields: bookshelf.safeColumns([ + 'fullname', + 'email', + 'level', + ]), }, { // Hide password from any relations and include requests. publicFields: bookshelf.safeColumns([ - 'username', 'fullname', - 'level', ]), + + hash(password) { + return new Promise((resolve, reject) => + bcrypt.hash(password, config.get('bcrypt'), (err, hashed) => { + if (err) return reject(err) + + resolve(hashed) + }) + ) + }, + + compare(password, hashed) { + return new Promise((resolve, reject) => + bcrypt.compare(password, hashed, (err, res) => { + if (err || !res) return reject(new Error('PasswordMismatch')) + resolve() + }) + ) + }, + + getAll(ctx, where = {}, withRelated = [], orderBy = 'id') { + return this.query(qb => { + this.baseQueryAll(ctx, qb, where, orderBy) + qb.select(bookshelf.safeColumns([ + 'fullname', + 'email', + 'level', + ])) + }) + .fetchPage({ + pageSize: ctx.state.pagination.perPage, + page: ctx.state.pagination.page, + withRelated, + ctx: ctx, + }) + .then(result => { + ctx.state.pagination.total = result.pagination.rowCount + return result + }) + }, }) export default Staff diff --git a/api/staff/routes.mjs b/api/staff/routes.mjs new file mode 100644 index 0000000..ea0f078 --- /dev/null +++ b/api/staff/routes.mjs @@ -0,0 +1,52 @@ +import Staff from './model.mjs' +import * as security from './security.mjs' + +export default class StaffRoutes { + constructor(opts = {}) { + Object.assign(this, { + Staff: opts.Staff || Staff, + security: opts.security || security, + }) + } + + /** GET: /api/articles */ + async getAllStaff(ctx) { + ctx.body = await this.Staff.getAll(ctx, { }, []) + } + + /** GET: /api/articles/:id */ + async getSingleStaff(ctx) { + ctx.body = await this.Staff.getSingle(ctx.params.id, [], true, ctx) + } + + /** POST: /api/articles */ + async createStaff(ctx) { + await this.security.validUpdate(ctx) + + ctx.body = await this.Staff.create(ctx.request.body) + } + + /** PUT: /api/articles/:id */ + async updateStaff(ctx) { + await this.security.validUpdate(ctx) + + let page = await this.Staff.getSingle(ctx.params.id) + + page.set(ctx.request.body) + + await page.save() + + ctx.body = page + } + + /** DELETE: /api/articles/:id */ + async removeStaff(ctx) { + let page = await this.Staff.getSingle(ctx.params.id) + + page.set({ is_deleted: true }) + + await page.save() + + ctx.status = 204 + } +} diff --git a/api/staff/security.mjs b/api/staff/security.mjs new file mode 100644 index 0000000..4411e92 --- /dev/null +++ b/api/staff/security.mjs @@ -0,0 +1,21 @@ +import filter from '../filter.mjs' +import Staff from './model.mjs' + +const validFields = [ + 'fullname', + 'email', + 'password', + 'level', +] + +export async function validUpdate(ctx) { + let out = filter(Object.keys(ctx.request.body), validFields) + + if (out.length > 0) { + ctx.throw(422, `Body had following invalid properties: ${out.join(', ')}`) + } + + if (ctx.request.body.password) { + ctx.request.body.password = await Staff.hash(ctx.request.body.password) + } +} diff --git a/app/admin/admin.scss b/app/admin/admin.scss index 1cd8c57..a3c9eaf 100644 --- a/app/admin/admin.scss +++ b/app/admin/admin.scss @@ -37,3 +37,4 @@ @import 'pages'; @import 'articles'; +@import 'staff'; diff --git a/app/admin/editstaff.js b/app/admin/editstaff.js new file mode 100644 index 0000000..6091178 --- /dev/null +++ b/app/admin/editstaff.js @@ -0,0 +1,151 @@ +const m = require('mithril') + +const { createStaff, updateStaff, getStaff } = require('../api/staff') + +const EditStaff = { + oninit: function(vnode) { + this.fetchStaff(vnode) + }, + + onupdate: function(vnode) { + if (this.lastid !== m.route.param('id')) { + this.fetchStaff(vnode) + } + }, + + fetchStaff: function(vnode) { + this.lastid = m.route.param('id') + this.loading = this.lastid !== 'add' + this.creating = this.lastid === 'add' + this.error = '' + this.staff = { + fullname: '', + email: '', + password: '', + level: 10, + } + + if (this.lastid !== 'add') { + getStaff(this.lastid) + .then(function(result) { + vnode.state.editedPath = true + vnode.state.staff = result + }) + .catch(function(err) { + vnode.state.error = err.message + }) + .then(function() { + vnode.state.loading = false + m.redraw() + }) + } + }, + + updateValue: function(fullname, e) { + this.staff[fullname] = e.currentTarget.value + }, + + save: function(vnode, e) { + e.preventDefault() + if (!this.staff.fullname) { + this.error = 'Fullname is missing' + } else if (!this.staff.email) { + this.error = 'Email is missing' + } else { + this.error = '' + } + if (this.error) return + + this.staff.description = vnode.state.froala && vnode.state.froala.html.get() || this.staff.description + + this.loading = true + + let promise + + if (this.staff.id) { + promise = updateStaff(this.staff.id, { + fullname: this.staff.fullname, + email: this.staff.email, + level: this.staff.level, + password: this.staff.password, + }) + } else { + promise = createStaff({ + fullname: this.staff.fullname, + email: this.staff.email, + level: this.staff.level, + password: this.staff.password, + }) + } + + promise.then(function(res) { + m.route.set('/admin/staff') + }) + .catch(function(err) { + vnode.state.error = err.message + }) + .then(function() { + vnode.state.loading = false + m.redraw() + }) + }, + + updateLevel: function(e) { + this.staff.level = Number(e.currentTarget.value) + }, + + view: function(vnode) { + const levels = [[10, 'Manager'], [100, 'Admin']] + return ( + this.loading ? + m('div.loading-spinner') + : m('div.admin-wrapper', [ + m('div.admin-actions', this.staff.id + ? [ + m('span', 'Actions:'), + m(m.route.Link, { href: '/admin/staff' }, 'Staff list'), + ] + : null), + m('article.editstaff', [ + m('header', m('h1', this.creating ? 'Create Staff' : 'Edit ' + (this.staff.fullname || '(untitled)'))), + m('div.error', { + hidden: !this.error, + onclick: function() { vnode.state.error = '' }, + }, this.error), + m('form.editstaff.content', { + onsubmit: this.save.bind(this, vnode), + }, [ + m('label', 'Level'), + m('select', { + onchange: this.updateLevel.bind(this), + }, levels.map(function(level) { return m('option', { value: level[0], selected: level[0] === vnode.state.staff.level }, level[1]) })), + m('label', 'Fullname'), + m('input', { + type: 'text', + value: this.staff.fullname, + oninput: this.updateValue.bind(this, 'fullname'), + }), + m('label', 'Email'), + m('input', { + type: 'text', + value: this.staff.email, + oninput: this.updateValue.bind(this, 'email'), + }), + m('label', 'Password (optional)'), + m('input', { + type: 'text', + value: this.staff.password, + oninput: this.updateValue.bind(this, 'password'), + }), + m('input', { + type: 'submit', + value: 'Save', + }), + ]), + ]), + ]) + ) + }, +} + +module.exports = EditStaff diff --git a/app/admin/staff.scss b/app/admin/staff.scss new file mode 100644 index 0000000..0eca2e5 --- /dev/null +++ b/app/admin/staff.scss @@ -0,0 +1,49 @@ + +article.editstaff { + text-align: center; + background: white; + padding: 0 0 20px; + + header { + padding: 10px; + background: $secondary-bg; + + h1 { + color: $secondary-fg; + } + + a { + font-size: 14px; + text-decoration: none; + margin-left: 10px; + color: $secondary-light-fg; + } + } + + form { + padding: 0 40px 20px; + + textarea { + height: 300px; + } + + .loading-spinner { + height: 300px; + position: relative; + } + } + + h5 { + margin-bottom: 20px; + } + + & > .loading-spinner { + width: 240px; + height: 50px; + position: relative; + + &.full { + width: 100%; + } + } +} diff --git a/app/admin/stafflist.js b/app/admin/stafflist.js new file mode 100644 index 0000000..fc6c436 --- /dev/null +++ b/app/admin/stafflist.js @@ -0,0 +1,110 @@ +const m = require('mithril') + +const { getAllStaff, removeStaff } = require('../api/staff') +const Dialogue = require('../widgets/dialogue') +const Pages = require('../widgets/pages') + +const AdminStaffList = { + oninit: function(vnode) { + this.error = '' + this.lastpage = m.route.param('page') || '1' + this.staff = [] + this.removeStaff = null + + this.fetchStaffs(vnode) + }, + + fetchStaffs: function(vnode) { + this.loading = true + + return getAllStaff() + .then(function(result) { + vnode.state.staff = result + }) + .catch(function(err) { + vnode.state.error = err.message + }) + .then(function() { + vnode.state.loading = false + m.redraw() + }) + }, + + confirmRemoveStaff: function(vnode) { + let removingStaff = this.removeStaff + this.removeStaff = null + this.loading = true + removeStaff(removingStaff.id) + .then(this.oninit.bind(this, vnode)) + .catch(function(err) { + vnode.state.error = err.message + vnode.state.loading = false + m.redraw() + }) + }, + + getLevel: function(level) { + if (level === 100) { + return 'Admin' + } + return 'Manager' + }, + + view: function(vnode) { + return [ + m('div.admin-wrapper', [ + m('div.admin-actions', [ + m('span', 'Actions:'), + m(m.route.Link, { href: '/admin/staff/add' }, 'Create new staff'), + ]), + m('article.editarticle', [ + m('header', m('h1', 'All staff')), + m('div.error', { + hidden: !this.error, + onclick: function() { vnode.state.error = '' }, + }, this.error), + (this.loading + ? m('div.loading-spinner.full') + : m('table', [ + m('thead', + m('tr', [ + m('th', 'Fullname'), + m('th', 'Email'), + m('th', 'Level'), + m('th.right', 'Updated'), + m('th.right', 'Actions'), + ]) + ), + m('tbody', this.staff.map(function(item) { + return m('tr', [ + m('td', m(m.route.Link, { href: '/admin/staff/' + item.id }, item.fullname)), + m('td', item.email), + m('td.right', AdminStaffList.getLevel(item.level)), + m('td.right', (item.updated_at || '---').replace('T', ' ').split('.')[0]), + m('td.right', m('button', { onclick: function() { vnode.state.removeStaff = item } }, 'Remove')), + ]) + })), + ]) + ), + m(Pages, { + base: '/admin/staff', + links: this.links, + }), + ]), + ]), + m(Dialogue, { + hidden: vnode.state.removeStaff === null, + title: 'Delete ' + (vnode.state.removeStaff ? vnode.state.removeStaff.name : ''), + message: 'Are you sure you want to remove "' + (vnode.state.removeStaff ? vnode.state.removeStaff.fullname : '') + '" (' + (vnode.state.removeStaff ? vnode.state.removeStaff.email : '') + ')', + yes: 'Remove', + yesclass: 'alert', + no: 'Cancel', + noclass: 'cancel', + onyes: this.confirmRemoveStaff.bind(this, vnode), + onno: function() { vnode.state.removeStaff = null }, + }), + ] + }, +} + +module.exports = AdminStaffList diff --git a/app/api/common.js b/app/api/common.js index 9eaa822..11a5a84 100644 --- a/app/api/common.js +++ b/app/api/common.js @@ -25,7 +25,11 @@ exports.sendRequest = function(options, isPagination) { data: JSON.parse(xhr.responseText), } } else { - out = JSON.parse(xhr.responseText) + if (xhr.responseText) { + out = JSON.parse(xhr.responseText) + } else { + out = {} + } } if (xhr.status >= 300) { throw out diff --git a/app/api/staff.js b/app/api/staff.js new file mode 100644 index 0000000..1d97b36 --- /dev/null +++ b/app/api/staff.js @@ -0,0 +1,38 @@ +const { sendRequest } = require('./common') + +exports.createStaff = function(body) { + return sendRequest({ + method: 'POST', + url: '/api/staff', + body: body, + }) +} + +exports.updateStaff = function(id, body) { + return sendRequest({ + method: 'PUT', + url: '/api/staff/' + id, + body: body, + }) +} + +exports.getAllStaff = function() { + return sendRequest({ + method: 'GET', + url: '/api/staff', + }) +} + +exports.getStaff = function(id) { + return sendRequest({ + method: 'GET', + url: '/api/staff/' + id, + }) +} + +exports.removeStaff = function(id) { + return sendRequest({ + method: 'DELETE', + url: '/api/staff/' + id, + }) +} diff --git a/app/app.scss b/app/app.scss index 9de8923..3cfc642 100644 --- a/app/app.scss +++ b/app/app.scss @@ -244,4 +244,5 @@ table { @import 'admin/admin'; @import 'widgets/common'; @import 'pages/page'; -@import 'frontpage/frontpage' +@import 'article/article'; +@import 'frontpage/frontpage'; diff --git a/app/article/article.js b/app/article/article.js new file mode 100644 index 0000000..5c6371a --- /dev/null +++ b/app/article/article.js @@ -0,0 +1,78 @@ +const m = require('mithril') +const { getArticle } = require('../api/article') +const Authentication = require('../authentication') +const Fileinfo = require('../widgets/fileinfo') + +const Article = { + oninit: function(vnode) { + this.error = '' + this.lastarticle = m.route.param('article') || '1' + this.loadingnews = false + this.fetchArticle(vnode) + }, + + fetchArticle: function(vnode) { + this.path = m.route.param('id') + this.news = [] + this.newslinks = null + this.article = { + id: 0, + name: '', + path: '', + description: '', + media: null, + banner: null, + files: [], + } + this.loading = true + + getArticle(this.path) + .then(function(result) { + vnode.state.article = result + }) + .catch(function(err) { + vnode.state.error = err.message + }) + .then(function() { + vnode.state.loading = vnode.state.loadingnews = false + m.redraw() + }) + }, + + onupdate: function(vnode) { + if (this.path !== m.route.param('id')) { + this.fetchArticle(vnode) + } + }, + + view: function(vnode) { + return ( + this.loading ? + m('div.loading-spinner') + : m('article.article', [ + m('header', m('h1', this.article.name)), + m('.fr-view', [ + this.article.media + ? m('a.cover', { + href: this.article.media.url, + }, m('img', { src: this.article.media.medium_url })) + : null, + this.article.description ? m.trust(this.article.description) : null, + (this.article.files && this.article.files.length + ? this.article.files.map(function(file) { + return m(Fileinfo, { file: file }) + }) + : null), + ]), + Authentication.currentUser + ? m('div.admin-actions', [ + m('span', 'Admin controls:'), + m(m.route.Link, { href: '/admin/articles/' + this.article.id }, 'Edit article'), + ]) + : null, + ]) + ) + }, +} + +module.exports = Article diff --git a/app/article/article.scss b/app/article/article.scss new file mode 100644 index 0000000..22538b9 --- /dev/null +++ b/app/article/article.scss @@ -0,0 +1,43 @@ +article.article { + background: white; + padding: 0 0 20px; + + header { + text-align: center; + margin: 20px; + padding: 10px; + background: $secondary-bg; + width: 100%; + max-width: 1920px; + align-self: center; + + h1 { + color: $secondary-fg; + } + } + + .cover { + margin: 0 -10px 20px; + } + + .admin-actions { + margin-bottom: 20px; + } + + .fr-view { + margin: 0 20px; + padding: 0 20px; + width: calc(100% - 40px); + max-width: 800px; + flex: 2 0 0; + align-self: center; + + a.cover img { + margin-bottom: 30px; + } + + main { + padding: 0 5px; + } + } +} \ No newline at end of file diff --git a/app/frontpage/frontpage.scss b/app/frontpage/frontpage.scss index 9102ff2..196aec7 100644 --- a/app/frontpage/frontpage.scss +++ b/app/frontpage/frontpage.scss @@ -4,7 +4,7 @@ background-repeat: no-repeat; background-position: center; height: 150px; - width: 100%; + width: calc(100% - 40px); max-width: 1920px; align-self: center; flex: 0 0 150px; @@ -14,6 +14,8 @@ text-align: right; font-size: 1.6em; padding: 10px 20px; + text-decoration: none; + margin: 20px 0; } frontpage { @@ -29,4 +31,20 @@ frontpage { .loading-spinner { height: 100px; } + + newsitem { + margin-bottom: 30px; + } +} + +@media screen and (max-width: 480px){ + .frontpage-banner { + width: 100%; + } + + frontpage { + padding: 0 10px; + margin: 0; + width: 100%; + } } diff --git a/app/index.js b/app/index.js index 146460d..c671546 100644 --- a/app/index.js +++ b/app/index.js @@ -8,9 +8,12 @@ const Login = require('./login/login') const Logout = require('./login/logout') const EditPage = require('./admin/editpage') const Page = require('./pages/page') +const Article = require('./article/article') const AdminPages = require('./admin/pages') const AdminArticles = require('./admin/articles') const EditArticle = require('./admin/editarticle') +const AdminStaffList = require('./admin/stafflist') +const EditStaff = require('./admin/editstaff') const menuRoot = document.getElementById('nav') const mainRoot = document.getElementById('main') @@ -20,9 +23,12 @@ m.route(mainRoot, '/', { '/login': Login, '/logout': Logout, '/page/:id': Page, + '/article/:id': Article, '/admin/pages': AdminPages, '/admin/pages/:key': EditPage, '/admin/articles': AdminArticles, '/admin/articles/:id': EditArticle, + '/admin/staff': AdminStaffList, + '/admin/staff/:id': EditStaff, }) m.mount(menuRoot, Menu) diff --git a/app/login/login.js b/app/login/login.js index 85d4a2f..1d5a8f2 100644 --- a/app/login/login.js +++ b/app/login/login.js @@ -1,5 +1,6 @@ const m = require('mithril') const Authentication = require('../authentication') +const { sendRequest } = require('../api/common') const Login = { loadedGoogle: false, @@ -45,7 +46,6 @@ const Login = { onGoogleFailure: function(error) { if (error.error !== 'popup_closed_by_user' && error.error !== 'popup_blocked_by_browser') { - console.error(error) Login.error = 'Error while logging into Google: ' + error.error m.redraw() } @@ -55,6 +55,9 @@ const Login = { Login.redirect = vnode.attrs.redirect || '' if (Authentication.currentUser) return m.route.set('/') Login.error = '' + + this.username = '' + this.password = '' }, oncreate: function() { @@ -65,7 +68,42 @@ const Login = { }) }, - view: function() { + loginuser: function(vnode, e) { + e.preventDefault() + if (!this.username) { + Login.error = 'Email is missing' + } else if (!this.password) { + Login.error = 'Password is missing' + } else { + Login.error = '' + } + if (Login.error) return + + Login.loading = true + + sendRequest({ + method: 'POST', + url: '/api/login/user', + body: { + username: this.username, + password: this.password, + }, + }) + .then(function(result) { + Authentication.updateToken(result.token) + m.route.set(Login.redirect || '/') + }) + .catch(function(error) { + Login.error = 'Error while logging into NFP! ' + error.message + vnode.state.password = '' + }) + .then(function () { + Login.loading = false + m.redraw() + }) + }, + + view: function(vnode) { return [ m('div.login-wrapper', [ m('article.login', [ @@ -73,9 +111,31 @@ const Login = { m('h1', 'NFP.moe login'), ]), m('div.content', [ - m('h5', 'Please login using google to access restricted area'), + m('h5', 'Please login to access restricted area'), Login.error ? m('div.error', Login.error) : null, Login.loading ? m('div.loading-spinner') : null, + m('form', { + hidden: Login.loading, + onsubmit: this.loginuser.bind(this, vnode), + }, [ + m('label', 'Email'), + m('input', { + type: 'text', + value: this.username, + oninput: function(e) { vnode.state.username = e.currentTarget.value }, + }), + m('label', 'Password'), + m('input', { + type: 'password', + value: this.password, + oninput: function(e) { vnode.state.password = e.currentTarget.value }, + }), + m('input', { + type: 'submit', + value: 'Login', + }), + ]), + m('h5', { hidden: Login.loading }, 'Alternative login'), m('div#googlesignin', { hidden: Login.loading }, m('div.loading-spinner')), ]), ]), diff --git a/app/login/login.scss b/app/login/login.scss index 58fa542..b89f5e3 100644 --- a/app/login/login.scss +++ b/app/login/login.scss @@ -30,4 +30,9 @@ article.login { height: 50px; position: relative; } + + form { + align-self: stretch; + margin-bottom: 20px; + } } diff --git a/app/menu/menu.js b/app/menu/menu.js index 0fb6c4a..28dedbf 100644 --- a/app/menu/menu.js +++ b/app/menu/menu.js @@ -45,12 +45,13 @@ const Menu = { 'Welcome ' + Authentication.currentUser.email, m(m.route.Link, { href: '/logout' }, 'Logout'), ]), - (Authentication.currentUser.level >= 100 - ? [ - m(m.route.Link, { href: '/admin/pages' }, 'Pages'), - m(m.route.Link, { href: '/admin/articles' }, 'Articles'), - m(m.route.Link, { href: '/admin/articles/add' }, 'Create article'), - ] + (Authentication.currentUser.level >= 10 + ? m('div.adminlinks', [ + m(m.route.Link, { href: '/admin/articles/add' }, 'Create article'), + m(m.route.Link, { href: '/admin/articles' }, 'Articles'), + m(m.route.Link, { href: '/admin/pages' }, 'Pages'), + m(m.route.Link, { hidden: Authentication.currentUser.level < 100, href: '/admin/staff' }, 'Staff'), + ]) : null ), ] : [ diff --git a/app/menu/menu.scss b/app/menu/menu.scss index 994b67f..ce0aaf3 100644 --- a/app/menu/menu.scss +++ b/app/menu/menu.scss @@ -49,6 +49,18 @@ line-height: 1.4em; text-decoration: none; } + + .adminlinks { + display: flex; + justify-content: center; + max-width: 200px; + flex-wrap: wrap; + + a { + padding: 3px 5px; + min-width: 100px; + } + } } } diff --git a/app/widgets/common.scss b/app/widgets/common.scss index 4fb3465..940bb2a 100644 --- a/app/widgets/common.scss +++ b/app/widgets/common.scss @@ -319,3 +319,26 @@ pages { cursor: pointer; } } + +@media screen and (max-width: 1000px){ + newsitem a.cover { + width: 300px; + } +} + +@media screen and (max-width: 639px){ + newsitem { + a.cover { + width: 100%; + margin-bottom: 20px; + + img { + max-height: max-content; + } + } + + .newsitemcontent { + flex-direction: column; + } + } +} diff --git a/app/widgets/newsentry.js b/app/widgets/newsentry.js index c035412..33136de 100644 --- a/app/widgets/newsentry.js +++ b/app/widgets/newsentry.js @@ -16,7 +16,7 @@ const Newsentry = { return m('newsentry', [ vnode.attrs.media ? m('a.cover', { - href: vnode.attrs.media.large_url, + href: '/article/' + vnode.attrs.path, }, m('img', { src: vnode.attrs.media.small_url })) : m('a.cover.nobg'), m('div.entrycontent', [ diff --git a/app/widgets/newsitem.js b/app/widgets/newsitem.js index 4ff421c..bae3922 100644 --- a/app/widgets/newsitem.js +++ b/app/widgets/newsitem.js @@ -11,8 +11,8 @@ const Newsitem = { m('div.newsitemcontent', [ vnode.attrs.media ? m('a.cover', { - href: vnode.attrs.media.large_url, - }, m('img', { src: vnode.attrs.media.small_url })) + href: '/article/' + vnode.attrs.path, + }, m('img', { src: vnode.attrs.media.medium_url })) : m('a.cover.nobg'), m('div.entrycontent', [ (vnode.attrs.description diff --git a/migrations/20190219105500_base.js b/migrations/20190219105500_base.js index 3914454..89543bf 100644 --- a/migrations/20190219105500_base.js +++ b/migrations/20190219105500_base.js @@ -6,6 +6,7 @@ exports.up = function up(knex, Promise) { table.increments() table.text('email') table.text('fullname') + table.text('password') table.boolean('is_deleted') .notNullable() .default(false) diff --git a/package.json b/package.json index 3980ce8..42c5d0d 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "homepage": "https://github.com/nfp-projects/nfp_moe", "dependencies": { "@koa/cors": "^2.2.3", + "bcrypt": "^3.0.0", "bookshelf": "^0.15.1", "bunyan-lite": "^1.0.1", "format-link-header": "^2.1.0",