add admin pages for whitelist management

pull/41/head
unknown 2019-02-24 16:27:24 -05:00
parent bda175b802
commit 0481099098
33 changed files with 515 additions and 62 deletions

View File

@ -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)
}
}
}

View File

@ -17,7 +17,7 @@ module.exports = {
},
list: async function (req, res) {
try {
const keys = await PublishKey.find()
const keys = await PublishKey.find({ user: req.user.id })
return res.json(keys)
} catch (e) {
return (new HttpError(500, e.message)).send(res)

View File

@ -1,10 +1,10 @@
const crypto = require('crypto')
function generateToken({ bytes, base }) {
return new Promise((res, rej) => {
function generateToken ({ bytes, base }) {
return new Promise((resolve, reject) => {
crypto.randomBytes(bytes, (err, buf) => {
if (err) rej(err)
else res(buf.toString(base || 'base64'))
if (err) reject(err)
else resolve(buf.toString(base || 'base64'))
})
})
}
@ -24,17 +24,16 @@ module.exports = {
type: 'string',
required: true
},
key: {
type: 'string',
required: true
whitelisted: 'boolean',
appid: {
type: 'string'
},
secret: {
type: 'string',
required: true
type: 'string'
}
},
beforeCreate: async function (key, next) {
key.key = await generateToken({ bytes: 12 })
key.appid = await generateToken({ bytes: 12 })
key.secret = await generateToken({ bytes: 48 })
next()
},

View File

@ -18,7 +18,8 @@ module.exports = {
},
email: {
type: 'string'
}
},
admin: 'boolean'
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗

View File

@ -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.' })
}

View File

@ -1,11 +1,11 @@
module.exports = async function (req, res, next) {
const key = req.param('key') || req.header('x-roe-publish-key')
const secret = req.param('secret') || req.header('x-roe-publish-secret')
console.log(key)
console.log(secret)
const key = req.param('key') || req.headers['roe-key']
const secret = req.param('secret') || req.headers['roe-secret']
if (await PublishKey.findOne({ key, secret })) {
return next()
const pk = await PublishKey.findOne({ appid: key, secret })
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.' })

View File

@ -9,6 +9,6 @@ module.exports = function (req, res, next) {
if (req.session.authenticated) {
return next()
}
// res.status(403).json({ error: 'You are not permitted to perform this action.' })
res.redirect('/login')
res.status(403).json({ error: 'You are not permitted to perform this action.' })
// res.redirect('/login')
}

View File

@ -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))
}
}

View File

@ -2,6 +2,8 @@
import Ajax from '../lib/Ajax'
const getPath = str => window.location.hostname === 'localhost' ? `http://localhost:3000${str}` : str
const ACTIONS = {
set_working: 'set_working',
add_url: 'add_url',
@ -12,7 +14,8 @@ const ACTIONS = {
error: 'error',
set_user: 'set_user',
add_publisher: 'add_publisher',
delete_publisher: 'delete_publisher'
delete_publisher: 'delete_publisher',
set_publishers: 'set_publishers'
}
export default ACTIONS
@ -32,6 +35,11 @@ export const setUser = user => ({
data: user
})
export const setPublishers = user => ({
type: ACTIONS.set_publishers,
data: user
})
export const addUrl = url => ({
type: ACTIONS.add_url,
data: url
@ -60,7 +68,7 @@ export const removeUrl = id => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
await Ajax.delete({
url: '/api/targets/' + id
url: getPath('/api/targets/' + id)
})
dispatch({
type: ACTIONS.delete_url,
@ -80,13 +88,18 @@ export const fetchData = () => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
const { data: user } = await Ajax.get({
url: '/api/me'
url: getPath('/api/me')
})
dispatch(setUser(user))
const { data: urls } = await Ajax.get({
url: '/api/targets'
url: getPath('/api/targets')
})
dispatch(setUrls(urls))
const { data: publishers } = await Ajax.get({
url: getPath('/api/keys')
})
dispatch(setPublishers(publishers))
} catch (e) {
dispatch({
type: ACTIONS.error,
@ -101,7 +114,7 @@ export const createNewUrl = () => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
const { data } = await Ajax.post({
url: '/api/targets'
url: getPath('/api/targets')
})
dispatch(addUrl(data))
} catch (e) {
@ -118,7 +131,7 @@ export const setUrl = (value) => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
await Ajax.patch({
url: '/api/targets/' + value.id,
url: getPath('/api/targets/' + value.id),
data: {
...value,
id: undefined
@ -140,7 +153,7 @@ export const editUser = (user) => async (dispatch, getState) => {
try {
// if (!user.currentPassword) throw new Error('Please enter your current password.')
await Ajax.patch({
url: '/api/me',
url: getPath('/api/me'),
data: {
id: user.id,
email: user.email,
@ -166,7 +179,7 @@ export const createNewPublisher = (url) => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
const { data } = await Ajax.post({
url: '/api/keys',
url: getPath('/api/keys'),
data: {
url
}
@ -186,7 +199,7 @@ export const removePublisher = id => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
await Ajax.delete({
url: '/api/keys/' + id
url: getPath('/api/keys/' + id)
})
dispatch({
type: ACTIONS.delete_publisher,

View File

@ -2,6 +2,8 @@
import Ajax from '../lib/Ajax'
const getPath = str => window.location.hostname === 'localhost' ? `http://localhost:3000${str}` : str
const ACTIONS = {
set_working: 'set_working',
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)) {
try {
await Ajax.post({
url: '/auth/email_exists',
url: getPath('/auth/email_exists'),
data: {
email
}
@ -83,7 +85,7 @@ export const checkPassword = (email, password) => async (dispatch, getState) =>
// do email + password check
try {
const res = await Ajax.post({
url: '/auth/local',
url: getPath('/auth/local'),
data: {
identifier: email,
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)) {
try {
await Ajax.post({
url: '/auth/email_available',
url: getPath('/auth/email_available'),
data: {
email
}
})
await Ajax.post({
url: '/register',
url: getPath('/register'),
data: {
email,
password

149
assets/js/admin.js Normal file
View File

@ -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'))

View File

@ -1,26 +1,38 @@
import React from 'react'
import IconButton from '../components/IconButton'
import UnderlineInput from '../components/UnderlineInput'
import { removePublisher } from '../actions'
import '../../styles/shared/listitem.scss'
const uriRegex = /(.+:\/\/)?(.+\.)*(.+\.).{1,}(:\d+)?(.+)?/i
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 () {
return (
<li className='uri-list-item flex-container'>
<div className='stack flex flex-container flex-vertical'>
<span className='label'>Website URL</span>
<li className='uri-list-item publisher-list-item flex-container'>
<div className='stack flex site-name flex-container flex-vertical'>
<span className='label'>Website name</span>
<span className='value'>{this.props.item.url}</span>
</div>
<div className='stack flex flex-container flex-vertical'>
<span className='label'>Key</span>
<input className='value' value={this.props.item.key} readOnly={true} />
<div className='flex flex-container'>
<div className='stack flex-container flex-vertical'>
<span className='label'>AppID</span>
<input className='value' value={this.props.item.appid} readOnly />
</div>
<div className='stack flex flex-container flex-vertical'>
<span className='label'>Secret</span>
<input className='value' value={this.props.item.secret} readOnly={true} />
<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>
<IconButton icon='delete' onClick={() => this.props.dispatch(removePublisher(this.props.item.id))} />
</li>

View File

@ -6,6 +6,7 @@ import { BrowserRouter as Router, Route, NavLink, Switch, Redirect } from 'react
import Progress from './components/Progress'
import UnderlineInput from './components/UnderlineInput'
import UriListItem from './containers/UriListItem'
import PublisherListItem from './containers/PublisherListItem'
import reducer from './reducers'
import { fetchData, createNewUrl, setEditing, editUser, createNewPublisher } from './actions'
@ -105,9 +106,12 @@ class App extends React.Component {
</h2>
</header>
<ul>
<li><NavLink to='/keys'>Publishing keys</NavLink></li>
<li><NavLink to='/targets'>Push URIs</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>
</aside>
<section className={'content flex' + (this.state.working ? ' working' : '')}>
@ -139,7 +143,7 @@ class App extends React.Component {
<div className='creator flex-container'>
<UnderlineInput
className='flex'
placeholder='Site URL'
placeholder='Site name'
value={this.state.newPublisherUrl}
onChange={this.setPublisherUrl} />
<button className='btn' onClick={() => this.dispatch(createNewPublisher(this.state.newPublisherUrl))}>Create keys</button>

11
assets/js/lib/Util.js Normal file
View File

@ -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 {}
}
}
}

View File

@ -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

View File

@ -17,6 +17,10 @@ const reducer = (state = {}, action) => {
...data
}
}
case Actions.set_publishers:
return {
publishers: data
}
case Actions.list_url:
return {
urls: data || []

48
assets/styles/admin.scss Normal file
View File

@ -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;
}
}
}
}
}
}

View File

@ -28,7 +28,7 @@
margin-top: 4px;
color: $text-dark-2;
text-shadow: 1px 1px 2px $black-4;
}
.creator {
padding: 0 14px;
line-height: 60px;

View File

@ -55,4 +55,29 @@
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;
}
}
}

View File

@ -6,7 +6,6 @@
box-shadow: $shadow-1;
header {
height: 50px;
line-height: 50px;
padding: 0 14px;

View File

@ -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>

View File

@ -5,6 +5,7 @@
<head>
<meta charset="UTF-8">
<title>River of Ebooks</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] %>"<% } %> /><%

View File

@ -4,7 +4,8 @@
<html>
<head>
<meta charset="UTF-8">
<title>RoE - Login</title>
<title>River of Ebooks - Login</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] %>"<% } %> /><%

View File

@ -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 = {
/****************************************************************************
@ -47,6 +55,7 @@ module.exports.http = {
***************************************************************************/
order: [
'allowCrossDomain',
'rateLimit',
'publishLimit',
'cookieParser',
@ -63,7 +72,8 @@ module.exports.http = {
rateLimit: rateLimiter,
publishLimit: publishLimiter,
passportInit: require('passport').initialize(),
passportSession: require('passport').session()
passportSession: require('passport').session(),
allowCrossDomain: allowCrossDomain
/***************************************************************************
* *

View File

@ -34,7 +34,7 @@ module.exports.models = {
* *
***************************************************************************/
// schema: true,
schema: true,
/***************************************************************************
* *

View File

@ -42,5 +42,9 @@ module.exports.policies = {
BooksController: {
'*': true,
publish: [ 'keyAuth' ]
},
AdminController: {
'*': [ 'adminAuth' ]
}
}

View File

@ -34,6 +34,12 @@ module.exports.routes = {
// figure out why proper clientside routing breaks the backend session
'GET /account': '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',
'GET /api/keys': 'PublishKeyController.list',
'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'
// ╦ ╦╔═╗╔╗ ╦ ╦╔═╗╔═╗╦╔═╔═╗
// ║║║║╣ ╠╩╗╠═╣║ ║║ ║╠╩╗╚═╗

View File

@ -4,7 +4,13 @@
### 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,
@ -37,7 +43,7 @@ The server will respond with either:
or
```
400 BAD REQUEST
400 BAD REQUEST / 403 UNAUTHORIZED
{
"error": string,
"hint": string

View File

@ -1,4 +1,3 @@
exports.up = function (knex, Promise) {
return Promise.all([
knex.schema.table('book', t => {

View File

@ -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')
])
}

View File

1
views/pages/admin.ejs Normal file
View File

@ -0,0 +1 @@
<%- partial('../../.tmp/public/admin.html') %>

View File

@ -10,11 +10,13 @@ module.exports = (env, argv) => {
mode: mode || 'development',
entry: {
login: './assets/js/login.js',
index: './assets/js/index.js'
index: './assets/js/index.js',
admin: './assets/js/admin.js'
},
output: {
path: path.join(__dirname, '/.tmp/public'),
filename: '[name].bundle.js'
filename: '[name].bundle.js',
publicPath: '/'
},
module: {
rules: [
@ -36,16 +38,22 @@ module.exports = (env, argv) => {
plugins: [
new HtmlWebpackPlugin({
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'),
chunks: ['login']
}),
new HtmlWebpackPlugin({
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'),
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({
filename: '[name].css'
}),