add admin pages for whitelist management
parent
bda175b802
commit
0481099098
|
@ -0,0 +1,27 @@
|
||||||
|
const HttpError = require('../errors/HttpError')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
show: async function (req, res) {
|
||||||
|
res.view('pages/admin', {
|
||||||
|
email: req.user.email
|
||||||
|
})
|
||||||
|
},
|
||||||
|
listUsers: async function (req, res) {
|
||||||
|
try {
|
||||||
|
const users = await User.find({})
|
||||||
|
return res.json(users)
|
||||||
|
} catch (e) {
|
||||||
|
return (new HttpError(500, e.message)).send(res)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
listPublishers: async function (req, res) {
|
||||||
|
try {
|
||||||
|
const publishers = await PublishKey.find({
|
||||||
|
select: ['id', 'user', 'appid', 'url', 'created_at', 'updated_at']
|
||||||
|
}).populate('user')
|
||||||
|
return res.json(publishers)
|
||||||
|
} catch (e) {
|
||||||
|
return (new HttpError(500, e.message)).send(res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -17,7 +17,7 @@ module.exports = {
|
||||||
},
|
},
|
||||||
list: async function (req, res) {
|
list: async function (req, res) {
|
||||||
try {
|
try {
|
||||||
const keys = await PublishKey.find()
|
const keys = await PublishKey.find({ user: req.user.id })
|
||||||
return res.json(keys)
|
return res.json(keys)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return (new HttpError(500, e.message)).send(res)
|
return (new HttpError(500, e.message)).send(res)
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
const crypto = require('crypto')
|
const crypto = require('crypto')
|
||||||
|
|
||||||
function generateToken({ bytes, base }) {
|
function generateToken ({ bytes, base }) {
|
||||||
return new Promise((res, rej) => {
|
return new Promise((resolve, reject) => {
|
||||||
crypto.randomBytes(bytes, (err, buf) => {
|
crypto.randomBytes(bytes, (err, buf) => {
|
||||||
if (err) rej(err)
|
if (err) reject(err)
|
||||||
else res(buf.toString(base || 'base64'))
|
else resolve(buf.toString(base || 'base64'))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -24,17 +24,16 @@ module.exports = {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
key: {
|
whitelisted: 'boolean',
|
||||||
type: 'string',
|
appid: {
|
||||||
required: true
|
type: 'string'
|
||||||
},
|
},
|
||||||
secret: {
|
secret: {
|
||||||
type: 'string',
|
type: 'string'
|
||||||
required: true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
beforeCreate: async function (key, next) {
|
beforeCreate: async function (key, next) {
|
||||||
key.key = await generateToken({ bytes: 12 })
|
key.appid = await generateToken({ bytes: 12 })
|
||||||
key.secret = await generateToken({ bytes: 48 })
|
key.secret = await generateToken({ bytes: 48 })
|
||||||
next()
|
next()
|
||||||
},
|
},
|
||||||
|
|
|
@ -18,7 +18,8 @@ module.exports = {
|
||||||
},
|
},
|
||||||
email: {
|
email: {
|
||||||
type: 'string'
|
type: 'string'
|
||||||
}
|
},
|
||||||
|
admin: 'boolean'
|
||||||
|
|
||||||
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
|
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
|
||||||
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
|
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
module.exports = async function (req, res, next) {
|
||||||
|
if (req.user && (req.user.id === 1 || req.user.admin)) next()
|
||||||
|
else res.status(403).json({ error: 'You are not permitted to perform this action.' })
|
||||||
|
}
|
|
@ -1,11 +1,11 @@
|
||||||
module.exports = async function (req, res, next) {
|
module.exports = async function (req, res, next) {
|
||||||
const key = req.param('key') || req.header('x-roe-publish-key')
|
const key = req.param('key') || req.headers['roe-key']
|
||||||
const secret = req.param('secret') || req.header('x-roe-publish-secret')
|
const secret = req.param('secret') || req.headers['roe-secret']
|
||||||
console.log(key)
|
|
||||||
console.log(secret)
|
|
||||||
|
|
||||||
if (await PublishKey.findOne({ key, secret })) {
|
const pk = await PublishKey.findOne({ appid: key, secret })
|
||||||
return next()
|
if (pk) {
|
||||||
|
if (pk.whitelisted) return next()
|
||||||
|
else res.status(403).json({ error: 'Your key has not been whitelisted yet. Please contact the site operator.' })
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(403).json({ error: 'Invalid publishing key.' })
|
res.status(403).json({ error: 'Invalid publishing key.' })
|
||||||
|
|
|
@ -9,6 +9,6 @@ module.exports = function (req, res, next) {
|
||||||
if (req.session.authenticated) {
|
if (req.session.authenticated) {
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
// res.status(403).json({ error: 'You are not permitted to perform this action.' })
|
res.status(403).json({ error: 'You are not permitted to perform this action.' })
|
||||||
res.redirect('/login')
|
// res.redirect('/login')
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
import Ajax from '../lib/Ajax'
|
||||||
|
|
||||||
|
const getPath = str => window.location.hostname === 'localhost' ? `http://localhost:3000${str}` : str
|
||||||
|
|
||||||
|
const ACTIONS = {
|
||||||
|
set_working: 'set_working',
|
||||||
|
error: 'error',
|
||||||
|
set_admin_data: 'set_admin_data'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ACTIONS
|
||||||
|
|
||||||
|
export const setWorking = working => ({
|
||||||
|
type: ACTIONS.set_working,
|
||||||
|
data: working
|
||||||
|
})
|
||||||
|
|
||||||
|
export const fetchAdminData = () => async (dispatch, getState) => {
|
||||||
|
dispatch(setWorking(true))
|
||||||
|
try {
|
||||||
|
const { data: user } = await Ajax.get({
|
||||||
|
url: getPath('/api/me')
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: users } = await Ajax.get({
|
||||||
|
url: getPath('/admin/api/users')
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: publishers } = await Ajax.get({
|
||||||
|
url: getPath('/admin/api/publishers')
|
||||||
|
})
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: ACTIONS.set_admin_data,
|
||||||
|
data: {
|
||||||
|
user,
|
||||||
|
users,
|
||||||
|
publishers
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
dispatch({
|
||||||
|
type: ACTIONS.error,
|
||||||
|
data: e
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
dispatch(setWorking(false))
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
import Ajax from '../lib/Ajax'
|
import Ajax from '../lib/Ajax'
|
||||||
|
|
||||||
|
const getPath = str => window.location.hostname === 'localhost' ? `http://localhost:3000${str}` : str
|
||||||
|
|
||||||
const ACTIONS = {
|
const ACTIONS = {
|
||||||
set_working: 'set_working',
|
set_working: 'set_working',
|
||||||
add_url: 'add_url',
|
add_url: 'add_url',
|
||||||
|
@ -12,7 +14,8 @@ const ACTIONS = {
|
||||||
error: 'error',
|
error: 'error',
|
||||||
set_user: 'set_user',
|
set_user: 'set_user',
|
||||||
add_publisher: 'add_publisher',
|
add_publisher: 'add_publisher',
|
||||||
delete_publisher: 'delete_publisher'
|
delete_publisher: 'delete_publisher',
|
||||||
|
set_publishers: 'set_publishers'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ACTIONS
|
export default ACTIONS
|
||||||
|
@ -32,6 +35,11 @@ export const setUser = user => ({
|
||||||
data: user
|
data: user
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const setPublishers = user => ({
|
||||||
|
type: ACTIONS.set_publishers,
|
||||||
|
data: user
|
||||||
|
})
|
||||||
|
|
||||||
export const addUrl = url => ({
|
export const addUrl = url => ({
|
||||||
type: ACTIONS.add_url,
|
type: ACTIONS.add_url,
|
||||||
data: url
|
data: url
|
||||||
|
@ -60,7 +68,7 @@ export const removeUrl = id => async (dispatch, getState) => {
|
||||||
dispatch(setWorking(true))
|
dispatch(setWorking(true))
|
||||||
try {
|
try {
|
||||||
await Ajax.delete({
|
await Ajax.delete({
|
||||||
url: '/api/targets/' + id
|
url: getPath('/api/targets/' + id)
|
||||||
})
|
})
|
||||||
dispatch({
|
dispatch({
|
||||||
type: ACTIONS.delete_url,
|
type: ACTIONS.delete_url,
|
||||||
|
@ -80,13 +88,18 @@ export const fetchData = () => async (dispatch, getState) => {
|
||||||
dispatch(setWorking(true))
|
dispatch(setWorking(true))
|
||||||
try {
|
try {
|
||||||
const { data: user } = await Ajax.get({
|
const { data: user } = await Ajax.get({
|
||||||
url: '/api/me'
|
url: getPath('/api/me')
|
||||||
})
|
})
|
||||||
dispatch(setUser(user))
|
dispatch(setUser(user))
|
||||||
const { data: urls } = await Ajax.get({
|
const { data: urls } = await Ajax.get({
|
||||||
url: '/api/targets'
|
url: getPath('/api/targets')
|
||||||
})
|
})
|
||||||
dispatch(setUrls(urls))
|
dispatch(setUrls(urls))
|
||||||
|
|
||||||
|
const { data: publishers } = await Ajax.get({
|
||||||
|
url: getPath('/api/keys')
|
||||||
|
})
|
||||||
|
dispatch(setPublishers(publishers))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: ACTIONS.error,
|
type: ACTIONS.error,
|
||||||
|
@ -101,7 +114,7 @@ export const createNewUrl = () => async (dispatch, getState) => {
|
||||||
dispatch(setWorking(true))
|
dispatch(setWorking(true))
|
||||||
try {
|
try {
|
||||||
const { data } = await Ajax.post({
|
const { data } = await Ajax.post({
|
||||||
url: '/api/targets'
|
url: getPath('/api/targets')
|
||||||
})
|
})
|
||||||
dispatch(addUrl(data))
|
dispatch(addUrl(data))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -118,7 +131,7 @@ export const setUrl = (value) => async (dispatch, getState) => {
|
||||||
dispatch(setWorking(true))
|
dispatch(setWorking(true))
|
||||||
try {
|
try {
|
||||||
await Ajax.patch({
|
await Ajax.patch({
|
||||||
url: '/api/targets/' + value.id,
|
url: getPath('/api/targets/' + value.id),
|
||||||
data: {
|
data: {
|
||||||
...value,
|
...value,
|
||||||
id: undefined
|
id: undefined
|
||||||
|
@ -140,7 +153,7 @@ export const editUser = (user) => async (dispatch, getState) => {
|
||||||
try {
|
try {
|
||||||
// if (!user.currentPassword) throw new Error('Please enter your current password.')
|
// if (!user.currentPassword) throw new Error('Please enter your current password.')
|
||||||
await Ajax.patch({
|
await Ajax.patch({
|
||||||
url: '/api/me',
|
url: getPath('/api/me'),
|
||||||
data: {
|
data: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
@ -151,7 +164,7 @@ export const editUser = (user) => async (dispatch, getState) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: ACTIONS.error,
|
type: ACTIONS.error,
|
||||||
data: null
|
data: null
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: ACTIONS.error,
|
type: ACTIONS.error,
|
||||||
|
@ -166,7 +179,7 @@ export const createNewPublisher = (url) => async (dispatch, getState) => {
|
||||||
dispatch(setWorking(true))
|
dispatch(setWorking(true))
|
||||||
try {
|
try {
|
||||||
const { data } = await Ajax.post({
|
const { data } = await Ajax.post({
|
||||||
url: '/api/keys',
|
url: getPath('/api/keys'),
|
||||||
data: {
|
data: {
|
||||||
url
|
url
|
||||||
}
|
}
|
||||||
|
@ -186,7 +199,7 @@ export const removePublisher = id => async (dispatch, getState) => {
|
||||||
dispatch(setWorking(true))
|
dispatch(setWorking(true))
|
||||||
try {
|
try {
|
||||||
await Ajax.delete({
|
await Ajax.delete({
|
||||||
url: '/api/keys/' + id
|
url: getPath('/api/keys/' + id)
|
||||||
})
|
})
|
||||||
dispatch({
|
dispatch({
|
||||||
type: ACTIONS.delete_publisher,
|
type: ACTIONS.delete_publisher,
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
import Ajax from '../lib/Ajax'
|
import Ajax from '../lib/Ajax'
|
||||||
|
|
||||||
|
const getPath = str => window.location.hostname === 'localhost' ? `http://localhost:3000${str}` : str
|
||||||
|
|
||||||
const ACTIONS = {
|
const ACTIONS = {
|
||||||
set_working: 'set_working',
|
set_working: 'set_working',
|
||||||
set_user: 'set_user',
|
set_user: 'set_user',
|
||||||
|
@ -56,7 +58,7 @@ export const checkEmail = email => async (dispatch, getState) => {
|
||||||
if (/^([a-zA-Z0-9_\-.]+)@([a-zA-Z0-9_\-.]+)\.([a-zA-Z]{2,5})$/.test(email)) {
|
if (/^([a-zA-Z0-9_\-.]+)@([a-zA-Z0-9_\-.]+)\.([a-zA-Z]{2,5})$/.test(email)) {
|
||||||
try {
|
try {
|
||||||
await Ajax.post({
|
await Ajax.post({
|
||||||
url: '/auth/email_exists',
|
url: getPath('/auth/email_exists'),
|
||||||
data: {
|
data: {
|
||||||
email
|
email
|
||||||
}
|
}
|
||||||
|
@ -83,7 +85,7 @@ export const checkPassword = (email, password) => async (dispatch, getState) =>
|
||||||
// do email + password check
|
// do email + password check
|
||||||
try {
|
try {
|
||||||
const res = await Ajax.post({
|
const res = await Ajax.post({
|
||||||
url: '/auth/local',
|
url: getPath('/auth/local'),
|
||||||
data: {
|
data: {
|
||||||
identifier: email,
|
identifier: email,
|
||||||
password
|
password
|
||||||
|
@ -106,13 +108,13 @@ export const signup = (email, password) => async (dispatch, getState) => {
|
||||||
if (/^([a-zA-Z0-9_\-.]+)@([a-zA-Z0-9_\-.]+)\.([a-zA-Z]{2,5})$/.test(email)) {
|
if (/^([a-zA-Z0-9_\-.]+)@([a-zA-Z0-9_\-.]+)\.([a-zA-Z]{2,5})$/.test(email)) {
|
||||||
try {
|
try {
|
||||||
await Ajax.post({
|
await Ajax.post({
|
||||||
url: '/auth/email_available',
|
url: getPath('/auth/email_available'),
|
||||||
data: {
|
data: {
|
||||||
email
|
email
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await Ajax.post({
|
await Ajax.post({
|
||||||
url: '/register',
|
url: getPath('/register'),
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
password
|
password
|
||||||
|
|
|
@ -0,0 +1,149 @@
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import { BrowserRouter as Router, Route, NavLink, Switch, Redirect } from 'react-router-dom'
|
||||||
|
import Progress from './components/Progress'
|
||||||
|
import appReducer from './reducers'
|
||||||
|
import adminReducer from './reducers/admin'
|
||||||
|
import { fetchAdminData } from './actions/admin'
|
||||||
|
import Util from './lib/Util'
|
||||||
|
|
||||||
|
import '../styles/admin.scss'
|
||||||
|
|
||||||
|
const reducer = Util.combineReducers(appReducer, adminReducer)
|
||||||
|
|
||||||
|
class App extends React.Component {
|
||||||
|
constructor () {
|
||||||
|
super()
|
||||||
|
this.state = {
|
||||||
|
error: '',
|
||||||
|
user: {
|
||||||
|
id: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
currentPassword: ''
|
||||||
|
},
|
||||||
|
users: [],
|
||||||
|
publishers: [],
|
||||||
|
working: false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dispatch = this.dispatch.bind(this)
|
||||||
|
this.getRegisteredUsers = this.getRegisteredUsers.bind(this)
|
||||||
|
this.getRegisteredPublishers = this.getRegisteredPublishers.bind(this)
|
||||||
|
}
|
||||||
|
dispatch (action) {
|
||||||
|
if (!action) throw new Error('dispatch: missing action')
|
||||||
|
if (action instanceof Function) {
|
||||||
|
action(this.dispatch, () => this.state)
|
||||||
|
} else {
|
||||||
|
const changes = reducer(this.state, action)
|
||||||
|
if (!changes || !Object.keys(changes).length) return
|
||||||
|
this.setState({
|
||||||
|
...changes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
componentDidMount () {
|
||||||
|
this.dispatch(fetchAdminData())
|
||||||
|
}
|
||||||
|
getRegisteredUsers () {
|
||||||
|
return this.state.users.map(user => {
|
||||||
|
return (
|
||||||
|
<li className='flex-container' key={`is-admin-${user.id}`}>
|
||||||
|
<span className='flex'>{user.email}</span>
|
||||||
|
<span className='flex'>
|
||||||
|
<label for={`is-admin-${user.id}`} className='cb-label'>Admin?</label>
|
||||||
|
<input className='checkbox' type='checkbox' defaultChecked={user.admin} id={`is-admin-${user.id}`} />
|
||||||
|
<label for={`is-admin-${user.id}`} />
|
||||||
|
</span>
|
||||||
|
<div className='stack flex flex-container flex-vertical'>
|
||||||
|
<span>{user.created_at}</span>
|
||||||
|
<span>{user.updated_at}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
getRegisteredPublishers () {
|
||||||
|
return this.state.publishers.map(pub => {
|
||||||
|
return (
|
||||||
|
<li className='flex-container' key={`is-whitelisted-${pub.id}`}>
|
||||||
|
<div className='stack flex flex-container flex-vertical'>
|
||||||
|
<span className='flex'><span className='name'>{pub.url}</span><span className='appid'>{pub.appid}</span></span>
|
||||||
|
<span className='flex'>{pub.user.email}</span>
|
||||||
|
</div>
|
||||||
|
<span className='flex'>
|
||||||
|
<label for={`is-whitelisted-${pub.id}`} className='cb-label'>Whitelisted?</label>
|
||||||
|
<input className='checkbox' type='checkbox' defaultChecked={pub.whitelisted} id={`is-whitelisted-${pub.id}`} />
|
||||||
|
<label for={`is-whitelisted-${pub.id}`} />
|
||||||
|
</span>
|
||||||
|
<div className='stack flex flex-container flex-vertical'>
|
||||||
|
<span>{pub.created_at}</span>
|
||||||
|
<span>{pub.updated_at}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
render () {
|
||||||
|
return (
|
||||||
|
<Router basename='/admin'>
|
||||||
|
<div className='root-container flex-container admin-container'>
|
||||||
|
<aside className='nav nav-left'>
|
||||||
|
<header>
|
||||||
|
<h1>RoE Admin</h1>
|
||||||
|
<h2 className='flex-container'>
|
||||||
|
<span className='flex'>{this.state.user.email}</span>
|
||||||
|
<a href='/logout'>Log out</a>
|
||||||
|
</h2>
|
||||||
|
</header>
|
||||||
|
<ul>
|
||||||
|
<li><NavLink to='/users'>Users</NavLink></li>
|
||||||
|
<li><NavLink to='/publishers'>Publishers</NavLink></li>
|
||||||
|
<li><a href='/targets'>Exit admin</a></li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
<section className={'content flex' + (this.state.working ? ' working' : '')}>
|
||||||
|
<Progress bound />
|
||||||
|
{this.state.error && <div className='error-box'>{this.state.error}</div>}
|
||||||
|
<Switch>
|
||||||
|
<Route path='/users' exact children={props => (
|
||||||
|
<div>
|
||||||
|
<header className='flex-container'>
|
||||||
|
<div className='flex'>
|
||||||
|
<h1>Site users</h1>
|
||||||
|
<h2>Registered users on RoE</h2>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<ul className='list'>
|
||||||
|
{this.getRegisteredUsers()}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)} />
|
||||||
|
|
||||||
|
<Route path='/publishers' exact children={props => (
|
||||||
|
<div>
|
||||||
|
<header className='flex-container'>
|
||||||
|
<div className='flex'>
|
||||||
|
<h1>Publishers</h1>
|
||||||
|
<h2>Whitelist sites who can publish books</h2>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<ul className='list'>
|
||||||
|
{this.getRegisteredPublishers()}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)} />
|
||||||
|
|
||||||
|
<Route path='/' render={() => <Redirect to='/users' />} />
|
||||||
|
</Switch>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Router>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.render(<App />, document.getElementById('root'))
|
|
@ -1,26 +1,38 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import IconButton from '../components/IconButton'
|
import IconButton from '../components/IconButton'
|
||||||
import UnderlineInput from '../components/UnderlineInput'
|
|
||||||
import { removePublisher } from '../actions'
|
import { removePublisher } from '../actions'
|
||||||
import '../../styles/shared/listitem.scss'
|
import '../../styles/shared/listitem.scss'
|
||||||
|
|
||||||
const uriRegex = /(.+:\/\/)?(.+\.)*(.+\.).{1,}(:\d+)?(.+)?/i
|
|
||||||
|
|
||||||
class PublisherListItem extends React.Component {
|
class PublisherListItem extends React.Component {
|
||||||
|
constructor () {
|
||||||
|
super()
|
||||||
|
this.state = {
|
||||||
|
revealed: false
|
||||||
|
}
|
||||||
|
this.toggleReveal = this.toggleReveal.bind(this)
|
||||||
|
}
|
||||||
|
toggleReveal () {
|
||||||
|
this.setState({
|
||||||
|
revealed: !this.state.revealed
|
||||||
|
})
|
||||||
|
}
|
||||||
render () {
|
render () {
|
||||||
return (
|
return (
|
||||||
<li className='uri-list-item flex-container'>
|
<li className='uri-list-item publisher-list-item flex-container'>
|
||||||
<div className='stack flex flex-container flex-vertical'>
|
<div className='stack flex site-name flex-container flex-vertical'>
|
||||||
<span className='label'>Website URL</span>
|
<span className='label'>Website name</span>
|
||||||
<span className='value'>{this.props.item.url}</span>
|
<span className='value'>{this.props.item.url}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='stack flex flex-container flex-vertical'>
|
<div className='flex flex-container'>
|
||||||
<span className='label'>Key</span>
|
<div className='stack flex-container flex-vertical'>
|
||||||
<input className='value' value={this.props.item.key} readOnly={true} />
|
<span className='label'>AppID</span>
|
||||||
</div>
|
<input className='value' value={this.props.item.appid} readOnly />
|
||||||
<div className='stack flex flex-container flex-vertical'>
|
</div>
|
||||||
<span className='label'>Secret</span>
|
<div className='stack flex flex-container flex-vertical'>
|
||||||
<input className='value' value={this.props.item.secret} readOnly={true} />
|
<span className='label'>Secret</span>
|
||||||
|
<input className='value flex' type={this.state.revealed ? 'text' : 'password'} value={this.props.item.secret} readOnly />
|
||||||
|
</div>
|
||||||
|
<button className='btn btn-clear btn-view' onClick={this.toggleReveal}>{this.state.revealed ? 'Hide' : 'Show'}</button>
|
||||||
</div>
|
</div>
|
||||||
<IconButton icon='delete' onClick={() => this.props.dispatch(removePublisher(this.props.item.id))} />
|
<IconButton icon='delete' onClick={() => this.props.dispatch(removePublisher(this.props.item.id))} />
|
||||||
</li>
|
</li>
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { BrowserRouter as Router, Route, NavLink, Switch, Redirect } from 'react
|
||||||
import Progress from './components/Progress'
|
import Progress from './components/Progress'
|
||||||
import UnderlineInput from './components/UnderlineInput'
|
import UnderlineInput from './components/UnderlineInput'
|
||||||
import UriListItem from './containers/UriListItem'
|
import UriListItem from './containers/UriListItem'
|
||||||
|
import PublisherListItem from './containers/PublisherListItem'
|
||||||
import reducer from './reducers'
|
import reducer from './reducers'
|
||||||
import { fetchData, createNewUrl, setEditing, editUser, createNewPublisher } from './actions'
|
import { fetchData, createNewUrl, setEditing, editUser, createNewPublisher } from './actions'
|
||||||
|
|
||||||
|
@ -105,9 +106,12 @@ class App extends React.Component {
|
||||||
</h2>
|
</h2>
|
||||||
</header>
|
</header>
|
||||||
<ul>
|
<ul>
|
||||||
|
<li><NavLink to='/keys'>Publishing keys</NavLink></li>
|
||||||
<li><NavLink to='/targets'>Push URIs</NavLink></li>
|
<li><NavLink to='/targets'>Push URIs</NavLink></li>
|
||||||
<li><NavLink to='/account'>My account</NavLink></li>
|
<li><NavLink to='/account'>My account</NavLink></li>
|
||||||
<li><NavLink to='/keys'>Publishing keys</NavLink></li>
|
{(this.state.user.id === 1 || this.state.user.admin) &&
|
||||||
|
<li><a href='/admin'>Admin</a></li>
|
||||||
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
<section className={'content flex' + (this.state.working ? ' working' : '')}>
|
<section className={'content flex' + (this.state.working ? ' working' : '')}>
|
||||||
|
@ -139,7 +143,7 @@ class App extends React.Component {
|
||||||
<div className='creator flex-container'>
|
<div className='creator flex-container'>
|
||||||
<UnderlineInput
|
<UnderlineInput
|
||||||
className='flex'
|
className='flex'
|
||||||
placeholder='Site URL'
|
placeholder='Site name'
|
||||||
value={this.state.newPublisherUrl}
|
value={this.state.newPublisherUrl}
|
||||||
onChange={this.setPublisherUrl} />
|
onChange={this.setPublisherUrl} />
|
||||||
<button className='btn' onClick={() => this.dispatch(createNewPublisher(this.state.newPublisherUrl))}>Create keys</button>
|
<button className='btn' onClick={() => this.dispatch(createNewPublisher(this.state.newPublisherUrl))}>Create keys</button>
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
export default class Util {
|
||||||
|
static combineReducers (...reducers) {
|
||||||
|
return function (...reducerParams) {
|
||||||
|
for (const reduce of reducers) {
|
||||||
|
const changes = reduce(...reducerParams)
|
||||||
|
if (changes && Object.keys(changes).length) return changes
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
import Actions from '../actions/admin'
|
||||||
|
|
||||||
|
const reducer = (state = {}, action) => {
|
||||||
|
const { type, data } = action
|
||||||
|
switch (type) {
|
||||||
|
case Actions.set_working:
|
||||||
|
return {
|
||||||
|
working: data
|
||||||
|
}
|
||||||
|
case Actions.set_admin_data:
|
||||||
|
return {
|
||||||
|
user: data.user,
|
||||||
|
users: data.users,
|
||||||
|
publishers: data.publishers
|
||||||
|
}
|
||||||
|
default: return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default reducer
|
|
@ -17,6 +17,10 @@ const reducer = (state = {}, action) => {
|
||||||
...data
|
...data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case Actions.set_publishers:
|
||||||
|
return {
|
||||||
|
publishers: data
|
||||||
|
}
|
||||||
case Actions.list_url:
|
case Actions.list_url:
|
||||||
return {
|
return {
|
||||||
urls: data || []
|
urls: data || []
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
@import 'index';
|
||||||
|
|
||||||
|
.admin-container {
|
||||||
|
.list {
|
||||||
|
li {
|
||||||
|
padding: 5px 0;
|
||||||
|
height: 50px;
|
||||||
|
line-height: 40px;
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
.cb-label {
|
||||||
|
display: inline-block;
|
||||||
|
height: 40px;
|
||||||
|
line-height: 40px;
|
||||||
|
margin-right: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
input.checkbox[type=checkbox] {
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
+ label {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
margin: 8px 0 12px 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
border: 2px solid $black-1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:checked + label {
|
||||||
|
&:before {
|
||||||
|
position: absolute;
|
||||||
|
content: '';
|
||||||
|
background: $accent-2;
|
||||||
|
height: 14px;
|
||||||
|
width: 14px;
|
||||||
|
left: 1px;
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -28,7 +28,7 @@
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
color: $text-dark-2;
|
color: $text-dark-2;
|
||||||
text-shadow: 1px 1px 2px $black-4;
|
text-shadow: 1px 1px 2px $black-4;
|
||||||
|
}
|
||||||
.creator {
|
.creator {
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
line-height: 60px;
|
line-height: 60px;
|
||||||
|
|
|
@ -55,4 +55,29 @@
|
||||||
margin-top: -4px;
|
margin-top: -4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.publisher-list-item {
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
margin-right: 14px;
|
||||||
|
|
||||||
|
&.site-name {
|
||||||
|
min-width: 400px;
|
||||||
|
flex: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.value {
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.btn-view {
|
||||||
|
margin: 18px 14px;
|
||||||
|
min-width: 90px;
|
||||||
|
height: 24px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,6 @@
|
||||||
box-shadow: $shadow-1;
|
box-shadow: $shadow-1;
|
||||||
|
|
||||||
header {
|
header {
|
||||||
height: 50px;
|
|
||||||
line-height: 50px;
|
line-height: 50px;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
<% var key, item %>
|
||||||
|
<% htmlWebpackPlugin.options.links = htmlWebpackPlugin.options.links || [] %>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>River of Ebooks | admin</title>
|
||||||
|
<base href="/">
|
||||||
|
<% for (item of htmlWebpackPlugin.options.links) {
|
||||||
|
if (typeof item === 'string' || item instanceof String) { item = { href: item, rel: 'stylesheet' } } %>
|
||||||
|
<link<% for (key in item) { %> <%= key %>="<%= item[key] %>"<% } %> /><%
|
||||||
|
} %>
|
||||||
|
<meta name="viewport" content="initial-scale=1, width=device-width, maximum-scale=1, minimum-scale=1, user-scalable=no" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -5,6 +5,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>River of Ebooks</title>
|
<title>River of Ebooks</title>
|
||||||
|
<base href="/">
|
||||||
<% for (item of htmlWebpackPlugin.options.links) {
|
<% for (item of htmlWebpackPlugin.options.links) {
|
||||||
if (typeof item === 'string' || item instanceof String) { item = { href: item, rel: 'stylesheet' } } %>
|
if (typeof item === 'string' || item instanceof String) { item = { href: item, rel: 'stylesheet' } } %>
|
||||||
<link<% for (key in item) { %> <%= key %>="<%= item[key] %>"<% } %> /><%
|
<link<% for (key in item) { %> <%= key %>="<%= item[key] %>"<% } %> /><%
|
||||||
|
|
|
@ -4,7 +4,8 @@
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>RoE - Login</title>
|
<title>River of Ebooks - Login</title>
|
||||||
|
<base href="/">
|
||||||
<% for (item of htmlWebpackPlugin.options.links) {
|
<% for (item of htmlWebpackPlugin.options.links) {
|
||||||
if (typeof item === 'string' || item instanceof String) { item = { href: item, rel: 'stylesheet' } } %>
|
if (typeof item === 'string' || item instanceof String) { item = { href: item, rel: 'stylesheet' } } %>
|
||||||
<link<% for (key in item) { %> <%= key %>="<%= item[key] %>"<% } %> /><%
|
<link<% for (key in item) { %> <%= key %>="<%= item[key] %>"<% } %> /><%
|
||||||
|
|
|
@ -26,6 +26,14 @@ const publishLimiter = rateLimit({
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const allowCrossDomain = function (req, res, next) {
|
||||||
|
res.header('Access-Control-Allow-Origin', 'http://localhost:8080')
|
||||||
|
res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,PATCH,DELETE')
|
||||||
|
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization')
|
||||||
|
res.header('Access-Control-Allow-Credentials', 'true')
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
module.exports.http = {
|
module.exports.http = {
|
||||||
|
|
||||||
/****************************************************************************
|
/****************************************************************************
|
||||||
|
@ -47,6 +55,7 @@ module.exports.http = {
|
||||||
***************************************************************************/
|
***************************************************************************/
|
||||||
|
|
||||||
order: [
|
order: [
|
||||||
|
'allowCrossDomain',
|
||||||
'rateLimit',
|
'rateLimit',
|
||||||
'publishLimit',
|
'publishLimit',
|
||||||
'cookieParser',
|
'cookieParser',
|
||||||
|
@ -63,7 +72,8 @@ module.exports.http = {
|
||||||
rateLimit: rateLimiter,
|
rateLimit: rateLimiter,
|
||||||
publishLimit: publishLimiter,
|
publishLimit: publishLimiter,
|
||||||
passportInit: require('passport').initialize(),
|
passportInit: require('passport').initialize(),
|
||||||
passportSession: require('passport').session()
|
passportSession: require('passport').session(),
|
||||||
|
allowCrossDomain: allowCrossDomain
|
||||||
|
|
||||||
/***************************************************************************
|
/***************************************************************************
|
||||||
* *
|
* *
|
||||||
|
|
|
@ -34,7 +34,7 @@ module.exports.models = {
|
||||||
* *
|
* *
|
||||||
***************************************************************************/
|
***************************************************************************/
|
||||||
|
|
||||||
// schema: true,
|
schema: true,
|
||||||
|
|
||||||
/***************************************************************************
|
/***************************************************************************
|
||||||
* *
|
* *
|
||||||
|
|
|
@ -42,5 +42,9 @@ module.exports.policies = {
|
||||||
BooksController: {
|
BooksController: {
|
||||||
'*': true,
|
'*': true,
|
||||||
publish: [ 'keyAuth' ]
|
publish: [ 'keyAuth' ]
|
||||||
|
},
|
||||||
|
|
||||||
|
AdminController: {
|
||||||
|
'*': [ 'adminAuth' ]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,6 +34,12 @@ module.exports.routes = {
|
||||||
// figure out why proper clientside routing breaks the backend session
|
// figure out why proper clientside routing breaks the backend session
|
||||||
'GET /account': 'TargetController.show',
|
'GET /account': 'TargetController.show',
|
||||||
'GET /targets': 'TargetController.show',
|
'GET /targets': 'TargetController.show',
|
||||||
|
'GET /keys': 'TargetController.show',
|
||||||
|
'GET /admin': 'AdminController.show',
|
||||||
|
'GET /admin/*': {
|
||||||
|
action: 'admin/show',
|
||||||
|
skipAssets: true
|
||||||
|
},
|
||||||
|
|
||||||
/***************************************************************************
|
/***************************************************************************
|
||||||
* *
|
* *
|
||||||
|
@ -78,7 +84,10 @@ module.exports.routes = {
|
||||||
'POST /api/keys': 'PublishKeyController.create',
|
'POST /api/keys': 'PublishKeyController.create',
|
||||||
'GET /api/keys': 'PublishKeyController.list',
|
'GET /api/keys': 'PublishKeyController.list',
|
||||||
'PATCH /api/keys/:id': 'PublishKeyController.refresh',
|
'PATCH /api/keys/:id': 'PublishKeyController.refresh',
|
||||||
'DELETE /api/keys/:id': 'PublishKeyController.delete'
|
'DELETE /api/keys/:id': 'PublishKeyController.delete',
|
||||||
|
|
||||||
|
'GET /admin/api/users': 'AdminController.listUsers',
|
||||||
|
'GET /admin/api/publishers': 'AdminController.listPublishers'
|
||||||
|
|
||||||
// ╦ ╦╔═╗╔╗ ╦ ╦╔═╗╔═╗╦╔═╔═╗
|
// ╦ ╦╔═╗╔╗ ╦ ╦╔═╗╔═╗╦╔═╔═╗
|
||||||
// ║║║║╣ ╠╩╗╠═╣║ ║║ ║╠╩╗╚═╗
|
// ║║║║╣ ╠╩╗╠═╣║ ║║ ║╠╩╗╚═╗
|
||||||
|
|
10
docs/api.md
10
docs/api.md
|
@ -4,7 +4,13 @@
|
||||||
### Publishing a book
|
### Publishing a book
|
||||||
|
|
||||||
```
|
```
|
||||||
POST to /api/publish containing the body:
|
POST to /api/publish containing headers:
|
||||||
|
{
|
||||||
|
roe-key: <api key>,
|
||||||
|
roe-secret: <api secret>
|
||||||
|
}
|
||||||
|
|
||||||
|
and body:
|
||||||
|
|
||||||
{
|
{
|
||||||
title: The book's title,
|
title: The book's title,
|
||||||
|
@ -37,7 +43,7 @@ The server will respond with either:
|
||||||
or
|
or
|
||||||
|
|
||||||
```
|
```
|
||||||
400 BAD REQUEST
|
400 BAD REQUEST / 403 UNAUTHORIZED
|
||||||
{
|
{
|
||||||
"error": string,
|
"error": string,
|
||||||
"hint": string
|
"hint": string
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
exports.up = function (knex, Promise) {
|
exports.up = function (knex, Promise) {
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
knex.schema.table('book', t => {
|
knex.schema.table('book', t => {
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
exports.up = function (knex, Promise) {
|
||||||
|
return Promise.all([
|
||||||
|
knex.schema.table('user', t => {
|
||||||
|
t.boolean('admin').defaultTo(false)
|
||||||
|
}),
|
||||||
|
knex.schema.createTable('publishkey', t => {
|
||||||
|
t.increments('id').primary()
|
||||||
|
t.integer('user').notNullable().references('user.id').onDelete('CASCADE').onUpdate('CASCADE')
|
||||||
|
t.string('appid')
|
||||||
|
t.string('secret')
|
||||||
|
t.boolean('whitelisted')
|
||||||
|
t.integer('created_at')
|
||||||
|
t.integer('updated_at')
|
||||||
|
})
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.down = function (knex, Promise) {
|
||||||
|
return Promise.all([
|
||||||
|
knex.schema.table('user', t => {
|
||||||
|
t.dropColumns('admin')
|
||||||
|
}),
|
||||||
|
knex.schema.dropTable('publishkey')
|
||||||
|
])
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
<%- partial('../../.tmp/public/admin.html') %>
|
|
@ -10,11 +10,13 @@ module.exports = (env, argv) => {
|
||||||
mode: mode || 'development',
|
mode: mode || 'development',
|
||||||
entry: {
|
entry: {
|
||||||
login: './assets/js/login.js',
|
login: './assets/js/login.js',
|
||||||
index: './assets/js/index.js'
|
index: './assets/js/index.js',
|
||||||
|
admin: './assets/js/admin.js'
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
path: path.join(__dirname, '/.tmp/public'),
|
path: path.join(__dirname, '/.tmp/public'),
|
||||||
filename: '[name].bundle.js'
|
filename: '[name].bundle.js',
|
||||||
|
publicPath: '/'
|
||||||
},
|
},
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
|
@ -36,16 +38,22 @@ module.exports = (env, argv) => {
|
||||||
plugins: [
|
plugins: [
|
||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
template: 'assets/templates/login.html',
|
template: 'assets/templates/login.html',
|
||||||
links: mode === 'production' ? [{ rel: 'stylesheet', type: 'text/css', href: 'login.css' }] : [],
|
// links: mode === 'production' ? [{ rel: 'stylesheet', type: 'text/css', href: '/login.css' }] : [],
|
||||||
filename: path.join(__dirname, '/.tmp/public/login.html'),
|
filename: path.join(__dirname, '/.tmp/public/login.html'),
|
||||||
chunks: ['login']
|
chunks: ['login']
|
||||||
}),
|
}),
|
||||||
new HtmlWebpackPlugin({
|
new HtmlWebpackPlugin({
|
||||||
template: 'assets/templates/index.html',
|
template: 'assets/templates/index.html',
|
||||||
links: mode === 'production' ? [{ rel: 'stylesheet', type: 'text/css', href: 'index.css' }] : [],
|
// links: mode === 'production' ? [{ rel: 'stylesheet', type: 'text/css', href: '/index.css' }] : [],
|
||||||
filename: path.join(__dirname, '/.tmp/public/index.html'),
|
filename: path.join(__dirname, '/.tmp/public/index.html'),
|
||||||
chunks: ['index']
|
chunks: ['index']
|
||||||
}),
|
}),
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
template: 'assets/templates/admin.html',
|
||||||
|
// links: mode === 'production' ? [{ rel: 'stylesheet', type: 'text/css', href: '/admin.css' }] : [],
|
||||||
|
filename: path.join(__dirname, '/.tmp/public/admin.html'),
|
||||||
|
chunks: ['admin']
|
||||||
|
}),
|
||||||
new MiniCssExtractPlugin({
|
new MiniCssExtractPlugin({
|
||||||
filename: '[name].css'
|
filename: '[name].css'
|
||||||
}),
|
}),
|
||||||
|
|
Loading…
Reference in New Issue