commit
a4b5080717
5162
package-lock.json
generated
5162
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
53
package.json
53
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "icynet.eu",
|
||||
"version": "0.0.1-alpha1",
|
||||
"version": "0.9.0",
|
||||
"description": "Icy Network web server",
|
||||
"main": "icynet.eu.js",
|
||||
"scripts": {
|
||||
@ -8,8 +8,8 @@
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"css": "mkdir -p build/style && stylus -o build/style src/style/*.styl",
|
||||
"css:watch": "mkdir -p build/style && stylus -w -o build/style src/style/*.styl",
|
||||
"js": "webpack",
|
||||
"js:watch": "webpack -w",
|
||||
"js": "webpack --config webpack.prod.js",
|
||||
"js:watch": "webpack --config webpack.dev.js -w",
|
||||
"watch": "concurrently --kill-others \"npm run css:watch\" \"npm run js:watch\"",
|
||||
"clean": "rm -rf build/",
|
||||
"build": "npm run clean && npm run css && npm run js"
|
||||
@ -30,43 +30,52 @@
|
||||
},
|
||||
"homepage": "https://github.com/IcyNet/IcyNet.eu#readme",
|
||||
"dependencies": {
|
||||
"babel-core": "^6.25.0",
|
||||
"babel-plugin-transform-es2015-modules-commonjs": "^6.24.1",
|
||||
"babel-core": "^6.26.0",
|
||||
"babel-plugin-transform-es2015-modules-commonjs": "^6.26.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bluebird": "^3.5.0",
|
||||
"body-parser": "^1.17.2",
|
||||
"connect-redis": "^3.3.0",
|
||||
"connect-session-knex": "^1.3.4",
|
||||
"bluebird": "^3.5.1",
|
||||
"body-parser": "^1.18.2",
|
||||
"connect-redis": "^3.3.2",
|
||||
"connect-session-knex": "^1.4.0",
|
||||
"email-templates": "^2.7.1",
|
||||
"express": "^4.15.3",
|
||||
"express": "^4.16.2",
|
||||
"express-rate-limit": "^2.9.0",
|
||||
"express-session": "^1.15.3",
|
||||
"express-session": "^1.15.6",
|
||||
"fs-extra": "^4.0.2",
|
||||
"gm": "^1.23.0",
|
||||
"knex": "^0.13.0",
|
||||
"multiparty": "^4.1.3",
|
||||
"mysql": "^2.13.0",
|
||||
"nodemailer": "^4.0.1",
|
||||
"mysql": "^2.15.0",
|
||||
"nodemailer": "^4.4.0",
|
||||
"notp": "^2.0.3",
|
||||
"oauth-libre": "^0.9.17",
|
||||
"objection": "^0.8.4",
|
||||
"pug": "^2.0.0-rc.3",
|
||||
"serve-favicon": "^2.4.3",
|
||||
"objection": "^0.8.9",
|
||||
"pug": "^2.0.0-rc.4",
|
||||
"serve-favicon": "^2.4.5",
|
||||
"stylus": "^0.54.5",
|
||||
"thirty-two": "^1.0.2",
|
||||
"toml": "^2.3.2",
|
||||
"uuid": "^3.1.0"
|
||||
"toml": "^2.3.3",
|
||||
"uuid": "^3.1.0",
|
||||
"vue": "^2.5.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^3.5.0",
|
||||
"eslint-plugin-import": "^2.7.0",
|
||||
"babel-loader": "^7.1.2",
|
||||
"babel-preset-env": "^1.6.1",
|
||||
"concurrently": "^3.5.1",
|
||||
"eslint-plugin-import": "^2.8.0",
|
||||
"jquery": "^3.2.1",
|
||||
"morgan": "^1.9.0",
|
||||
"mustache": "^2.3.0",
|
||||
"standard": "^10.0.3",
|
||||
"uglifyjs-webpack-plugin": "^0.4.6",
|
||||
"uglifyjs-webpack-plugin": "^1.1.2",
|
||||
"vue-clickaway": "^2.1.0",
|
||||
"vue-loader": "^13.5.0",
|
||||
"vue-resource": "^1.3.4",
|
||||
"vue-router": "^3.0.1",
|
||||
"vue-template-compiler": "^2.5.9",
|
||||
"watch": "^1.0.2",
|
||||
"webpack": "^3.6.0"
|
||||
"webpack": "^3.10.0",
|
||||
"webpack-merge": "^4.1.1"
|
||||
},
|
||||
"standard": {
|
||||
"env": {
|
||||
|
@ -3,7 +3,9 @@ import Models from './models'
|
||||
|
||||
const perPage = 6
|
||||
|
||||
function cleanUserObject (dbe, admin) {
|
||||
async function cleanUserObject (dbe, admin) {
|
||||
let totp = await Users.User.Login.totpTokenRequired(dbe)
|
||||
|
||||
return {
|
||||
id: dbe.id,
|
||||
username: dbe.username,
|
||||
@ -17,7 +19,8 @@ function cleanUserObject (dbe, admin) {
|
||||
password: dbe.password !== null,
|
||||
nw_privilege: dbe.nw_privilege,
|
||||
created_at: dbe.created_at,
|
||||
bannable: dbe.nw_privilege < admin.nw_privilege && dbe.id !== admin.id
|
||||
totp_enabled: totp,
|
||||
bannable: admin ? (dbe.nw_privilege < admin.nw_privilege && dbe.id !== admin.id) : false
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,7 +99,7 @@ const API = {
|
||||
for (let i in raw) {
|
||||
let entry = raw[i]
|
||||
|
||||
users.push(cleanUserObject(entry, admin))
|
||||
users.push(await cleanUserObject(entry, admin))
|
||||
}
|
||||
|
||||
return {
|
||||
@ -104,6 +107,77 @@ const API = {
|
||||
users: users
|
||||
}
|
||||
},
|
||||
getUser: async function (id) {
|
||||
let user = await Users.User.get(id)
|
||||
if (!user) throw new Error('No such user')
|
||||
|
||||
return cleanUserObject(user, null)
|
||||
},
|
||||
editUser: async function (id, data) {
|
||||
let user = await Users.User.get(id)
|
||||
if (!user) throw new Error('No such user')
|
||||
|
||||
let fields = [
|
||||
'username', 'display_name', 'email', 'nw_privilege', 'activated'
|
||||
]
|
||||
|
||||
data = dataFilter(data, fields, ['nw_privilege', 'activated'])
|
||||
if (!data) throw new Error('Missing fields')
|
||||
|
||||
await Users.User.update(user, data)
|
||||
|
||||
return {}
|
||||
},
|
||||
resendActivationEmail: async function (id) {
|
||||
let user = await Users.User.get(id)
|
||||
if (!user) throw new Error('No such user')
|
||||
|
||||
if (user.activated === 1) return {}
|
||||
|
||||
await Users.User.Register.activationEmail(user)
|
||||
|
||||
return {}
|
||||
},
|
||||
revokeTotpToken: async function (id) {
|
||||
let user = await Users.User.get(id)
|
||||
if (!user) throw new Error('No such user')
|
||||
|
||||
await Models.TotpToken.query().delete().where('user_id', user.id)
|
||||
|
||||
return {}
|
||||
},
|
||||
sendPasswordEmail: async function (id) {
|
||||
let user = await Users.User.get(id)
|
||||
if (!user) throw new Error('No such user')
|
||||
|
||||
let token = await Users.User.Reset.reset(user.email, false, true)
|
||||
|
||||
return {token}
|
||||
},
|
||||
// Search for users by terms and fields
|
||||
searchUsers: async function (terms, fields = ['email']) {
|
||||
let qb = Models.User.query()
|
||||
|
||||
terms = terms.replace(/_/g, '\\_').replace(/%/g, '\\%')
|
||||
|
||||
qb = qb.where(fields[0], 'like', '%' + terms + '%')
|
||||
if (fields.length >= 1) {
|
||||
for (let i = 1; i < fields.length; i++) {
|
||||
qb = qb.orWhere(fields[i], 'like', '%' + terms + '%')
|
||||
}
|
||||
}
|
||||
|
||||
let rows = await qb.limit(8)
|
||||
if (!rows.length) return { error: 'No results' }
|
||||
|
||||
let cleaned = []
|
||||
for (let i in rows) {
|
||||
let userRaw = rows[i]
|
||||
cleaned.push(await cleanUserObject(userRaw, null))
|
||||
}
|
||||
|
||||
return cleaned
|
||||
},
|
||||
// List all clients (paginated)
|
||||
getAllClients: async function (page) {
|
||||
let count = await Models.OAuth2Client.query().count('id as ids')
|
||||
@ -123,14 +197,13 @@ const API = {
|
||||
}
|
||||
|
||||
return {
|
||||
page: paginated,
|
||||
clients: clients
|
||||
page: paginated, clients
|
||||
}
|
||||
},
|
||||
// Get information about a client via id
|
||||
getClient: async function (id) {
|
||||
let raw = await Models.OAuth2Client.query().where('id', id)
|
||||
if (!raw.length) return null
|
||||
if (!raw.length) throw new Error('No such client')
|
||||
|
||||
return cleanClientObject(raw[0])
|
||||
},
|
||||
@ -143,8 +216,6 @@ const API = {
|
||||
data = dataFilter(data, fields, ['scope', 'verified'])
|
||||
if (!data) throw new Error('Missing fields')
|
||||
|
||||
data.verified = (data.verified != null ? 1 : 0)
|
||||
|
||||
try {
|
||||
await Models.OAuth2Client.query().patchAndFetchById(id, data)
|
||||
await Models.OAuth2AuthorizedClient.query().delete().where('client_id', id)
|
||||
@ -213,8 +284,7 @@ const API = {
|
||||
}
|
||||
|
||||
return {
|
||||
page: paginated,
|
||||
bans: bans
|
||||
page: paginated, bans
|
||||
}
|
||||
},
|
||||
// Remove a ban
|
||||
|
@ -4,6 +4,10 @@ import nodemailer from 'nodemailer'
|
||||
|
||||
import config from '../../scripts/load-config'
|
||||
|
||||
// TEMPORARY FIX FOR NODE v9.2.0
|
||||
import tls from 'tls'
|
||||
tls.DEFAULT_ECDH_CURVE = 'auto'
|
||||
|
||||
const templateDir = path.join(__dirname, '../../', 'templates')
|
||||
|
||||
let templateCache = {}
|
||||
|
@ -372,8 +372,16 @@ const API = {
|
||||
// Create user
|
||||
let user = await models.User.query().insert(data)
|
||||
|
||||
if (email) {
|
||||
await API.User.Register.activationEmail(user, true)
|
||||
}
|
||||
|
||||
return user
|
||||
},
|
||||
activationEmail: async function (user, deleteOnFail = false) {
|
||||
// Activation token
|
||||
let activationToken = API.Hash(16)
|
||||
|
||||
await models.Token.query().insert({
|
||||
expires_at: new Date(Date.now() + 86400000), // 1 day
|
||||
token: activationToken,
|
||||
@ -381,9 +389,9 @@ const API = {
|
||||
type: 1
|
||||
})
|
||||
|
||||
// Send Activation Email
|
||||
console.debug('Activation token:', activationToken)
|
||||
if (email) {
|
||||
|
||||
// Send Activation Email
|
||||
try {
|
||||
let em = await emailer.pushMail('activate', user.email, {
|
||||
domain: config.server.domain,
|
||||
@ -394,12 +402,15 @@ const API = {
|
||||
console.debug(em)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
||||
if (deleteOnFail) {
|
||||
await models.User.query().delete().where('id', user.id)
|
||||
throw new Error('Invalid email address!')
|
||||
}
|
||||
}
|
||||
|
||||
return user
|
||||
throw new Error('Invalid email address!')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
},
|
||||
Reset: {
|
||||
|
@ -88,6 +88,19 @@ router.get('/oauth2', wrap(async (req, res) => {
|
||||
* =======
|
||||
*/
|
||||
|
||||
function csrfVerify (req, res, next) {
|
||||
if (req.body.csrf !== req.session.csrf) {
|
||||
return next(new Error('Invalid session'))
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
/* =============
|
||||
* User Data
|
||||
* =============
|
||||
*/
|
||||
|
||||
apiRouter.get('/users', wrap(async (req, res) => {
|
||||
let page = parseInt(req.query.page)
|
||||
if (isNaN(page) || page < 1) {
|
||||
@ -98,6 +111,76 @@ apiRouter.get('/users', wrap(async (req, res) => {
|
||||
res.jsonp(users)
|
||||
}))
|
||||
|
||||
apiRouter.get('/user/:id', wrap(async (req, res) => {
|
||||
let id = parseInt(req.params.id)
|
||||
if (isNaN(id)) {
|
||||
throw new Error('Invalid number')
|
||||
}
|
||||
|
||||
res.jsonp(await API.getUser(id))
|
||||
}))
|
||||
|
||||
apiRouter.post('/user', csrfVerify, wrap(async (req, res) => {
|
||||
let id = parseInt(req.body.user_id)
|
||||
if (isNaN(id)) {
|
||||
throw new Error('Invalid or missing user ID')
|
||||
}
|
||||
|
||||
res.jsonp(await API.editUser(id, req.body))
|
||||
}))
|
||||
|
||||
apiRouter.post('/user/resend_activation', csrfVerify, wrap(async (req, res) => {
|
||||
let id = parseInt(req.body.user_id)
|
||||
if (isNaN(id)) {
|
||||
throw new Error('Invalid or missing user ID')
|
||||
}
|
||||
|
||||
res.jsonp(await API.resendActivationEmail(id))
|
||||
}))
|
||||
|
||||
apiRouter.post('/user/revoke_totp', csrfVerify, wrap(async (req, res) => {
|
||||
let id = parseInt(req.body.user_id)
|
||||
if (isNaN(id)) {
|
||||
throw new Error('Invalid or missing user ID')
|
||||
}
|
||||
|
||||
res.jsonp(await API.revokeTotpToken(id))
|
||||
}))
|
||||
|
||||
apiRouter.post('/user/reset_password', csrfVerify, wrap(async (req, res) => {
|
||||
let id = parseInt(req.body.user_id)
|
||||
if (isNaN(id)) {
|
||||
throw new Error('Invalid or missing user ID')
|
||||
}
|
||||
|
||||
res.jsonp(await API.sendPasswordEmail(id))
|
||||
}))
|
||||
|
||||
const availableScopes = ['uuid', 'email', 'username', 'display_name']
|
||||
apiRouter.get('/search/users', wrap(async (req, res) => {
|
||||
if (!req.query.terms) throw new Error('Please specify search terms!')
|
||||
|
||||
let scopes = []
|
||||
if (req.query.scopes) {
|
||||
let scq = req.query.scopes.split(',')
|
||||
|
||||
for (let i in scq) {
|
||||
scq[i] = scq[i].trim()
|
||||
|
||||
if (availableScopes.indexOf(scq[i]) !== -1) {
|
||||
scopes.push(scq[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!scopes.length) {
|
||||
scopes.push('email')
|
||||
}
|
||||
|
||||
let results = await API.searchUsers(req.query.terms, scopes)
|
||||
res.jsonp(results)
|
||||
}))
|
||||
|
||||
/* ===============
|
||||
* OAuth2 Data
|
||||
* ===============
|
||||
@ -115,43 +198,34 @@ apiRouter.get('/clients', wrap(async (req, res) => {
|
||||
apiRouter.get('/client/:id', wrap(async (req, res) => {
|
||||
let id = parseInt(req.params.id)
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).jsonp({error: 'Invalid number'})
|
||||
throw new Error('Invalid number')
|
||||
}
|
||||
|
||||
let client = await API.getClient(id)
|
||||
if (!client) return res.status(400).jsonp({error: 'Invalid client'})
|
||||
|
||||
res.jsonp(client)
|
||||
}))
|
||||
|
||||
apiRouter.post('/client/new', wrap(async (req, res) => {
|
||||
if (req.body.csrf !== req.session.csrf) {
|
||||
return res.status(400).jsonp({error: 'Invalid session'})
|
||||
}
|
||||
|
||||
apiRouter.post('/client/new', csrfVerify, wrap(async (req, res) => {
|
||||
await API.createClient(req.body, req.session.user)
|
||||
|
||||
res.status(204).end()
|
||||
}))
|
||||
|
||||
apiRouter.post('/client/update', wrap(async (req, res) => {
|
||||
apiRouter.post('/client/update', csrfVerify, wrap(async (req, res) => {
|
||||
let id = parseInt(req.body.id)
|
||||
|
||||
if (!id || isNaN(id)) return res.status(400).jsonp({error: 'ID missing'})
|
||||
|
||||
if (req.body.csrf !== req.session.csrf) {
|
||||
return res.status(400).jsonp({error: 'Invalid session'})
|
||||
}
|
||||
if (!id || isNaN(id)) throw new Error('ID missing')
|
||||
|
||||
await API.updateClient(id, req.body)
|
||||
|
||||
res.status(204).end()
|
||||
}))
|
||||
|
||||
apiRouter.post('/client/new_secret/:id', wrap(async (req, res) => {
|
||||
let id = parseInt(req.params.id)
|
||||
apiRouter.post('/client/new_secret', csrfVerify, wrap(async (req, res) => {
|
||||
let id = parseInt(req.body.id)
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).jsonp({error: 'Invalid number'})
|
||||
throw new Error('Invalid client ID')
|
||||
}
|
||||
|
||||
let client = await API.newSecret(id)
|
||||
@ -159,10 +233,10 @@ apiRouter.post('/client/new_secret/:id', wrap(async (req, res) => {
|
||||
res.jsonp(client)
|
||||
}))
|
||||
|
||||
apiRouter.post('/client/delete/:id', wrap(async (req, res) => {
|
||||
let id = parseInt(req.params.id)
|
||||
apiRouter.post('/client/delete', csrfVerify, wrap(async (req, res) => {
|
||||
let id = parseInt(req.body.id)
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).jsonp({error: 'Invalid number'})
|
||||
throw new Error('Invalid client ID')
|
||||
}
|
||||
|
||||
let client = await API.removeClient(id)
|
||||
@ -185,10 +259,10 @@ apiRouter.get('/bans', wrap(async (req, res) => {
|
||||
res.jsonp(bans)
|
||||
}))
|
||||
|
||||
apiRouter.post('/ban/pardon/:id', wrap(async (req, res) => {
|
||||
let id = parseInt(req.params.id)
|
||||
apiRouter.post('/ban/pardon', csrfVerify, wrap(async (req, res) => {
|
||||
let id = parseInt(req.body.id)
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).jsonp({error: 'Invalid number'})
|
||||
throw new Error('Invalid number')
|
||||
}
|
||||
|
||||
let ban = await API.removeBan(id)
|
||||
@ -196,11 +270,8 @@ apiRouter.post('/ban/pardon/:id', wrap(async (req, res) => {
|
||||
res.jsonp(ban)
|
||||
}))
|
||||
|
||||
apiRouter.post('/ban', wrap(async (req, res) => {
|
||||
if (!req.body.user_id) return res.status(400).jsonp({error: 'ID missing'})
|
||||
if (req.body.csrf !== req.session.csrf) {
|
||||
return res.status(400).jsonp({error: 'Invalid session'})
|
||||
}
|
||||
apiRouter.post('/ban', csrfVerify, wrap(async (req, res) => {
|
||||
if (!req.body.user_id) throw new Error('ID missing')
|
||||
|
||||
let result = await API.addBan(req.body, req.session.user.id)
|
||||
|
||||
|
@ -1,284 +1,24 @@
|
||||
window.$ = require('jquery')
|
||||
var Mustache = require('mustache')
|
||||
import Vue from 'vue'
|
||||
import VueResource from 'vue-resource'
|
||||
import VueRouter from 'vue-router'
|
||||
|
||||
function buildTemplateScript (id, ctx) {
|
||||
var tmpl = $('#' + id)
|
||||
if (!tmpl.length) return null
|
||||
var data = tmpl.html()
|
||||
Mustache.parse(data)
|
||||
return Mustache.render(data, ctx)
|
||||
}
|
||||
import Users from './views/Users.vue'
|
||||
import OAuth2 from './views/OAuth2.vue'
|
||||
|
||||
function paginationButton (pages) {
|
||||
var html = '<div class="pgn">'
|
||||
html += '<span class="pagenum">Page ' + pages.page + ' of ' + pages.pages + '</span>'
|
||||
Vue.use(VueResource)
|
||||
Vue.use(VueRouter)
|
||||
|
||||
if (pages.page > 1) {
|
||||
html += '<div class="button" data-page="' + (pages.page - 1) + '">Previous</div>'
|
||||
}
|
||||
const routes = [
|
||||
{ path: '/', component: Users },
|
||||
{ path: '/oauth2', component: OAuth2 }
|
||||
]
|
||||
|
||||
for (var i = 0; i < pages.pages; i++) {
|
||||
html += '<div class="button' + (i + 1 === pages.page ? ' active' : '') + '" data-page="' + (i + 1) + '">' + (i + 1) + '</div>'
|
||||
}
|
||||
|
||||
if (pages.pages > pages.page) {
|
||||
html += '<div class="button" data-page="' + (pages.page + 1) + '">Next</div>'
|
||||
}
|
||||
|
||||
html += '<span class="pagenum total">(' + pages.total + ' Total Entries)</span>'
|
||||
html += '</div>'
|
||||
return html
|
||||
}
|
||||
|
||||
function banUser (id) {
|
||||
window.Dialog.openTemplate('Ban User', 'banNew', {id: id})
|
||||
$('#fnsubmit').submit(function (e) {
|
||||
e.preventDefault()
|
||||
$.post({
|
||||
url: '/admin/api/ban',
|
||||
data: $(this).serialize(),
|
||||
success: function (data) {
|
||||
window.Dialog.close()
|
||||
loadBans(1)
|
||||
},
|
||||
error: function (e) {
|
||||
if (e.responseJSON && e.responseJSON.error) {
|
||||
$('form .message').show()
|
||||
$('form .message').text(e.responseJSON.error)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function loadBans (page) {
|
||||
$.ajax({
|
||||
type: 'get',
|
||||
url: '/admin/api/bans',
|
||||
data: {page: page},
|
||||
success: function (data) {
|
||||
$('#banlist').html('')
|
||||
if (data.error) {
|
||||
$('#banlist').html('<div class="message">' + data.error + '</div>')
|
||||
return
|
||||
}
|
||||
|
||||
var pgbtn = paginationButton(data.page)
|
||||
$('#banlist').append(pgbtn)
|
||||
$('#banlist .pgn .button').click(function (e) {
|
||||
var pgnum = $(this).data('page')
|
||||
if (pgnum == null) return
|
||||
loadBans(parseInt(pgnum))
|
||||
const router = new VueRouter({
|
||||
routes
|
||||
})
|
||||
|
||||
for (var u in data.bans) {
|
||||
var ban = data.bans[u]
|
||||
ban.created_at = new Date(ban.created_at)
|
||||
ban.expires_at = ban.expires_at === null ? 'Never' : new Date(ban.expires_at)
|
||||
var tmp = buildTemplateScript('ban', ban)
|
||||
$('#banlist').append(tmp)
|
||||
}
|
||||
|
||||
$('#banlist .remove').click(function (e) {
|
||||
$.post({
|
||||
url: '/admin/api/ban/pardon/' + parseInt($(this).data('id')),
|
||||
success: function (data) {
|
||||
loadBans(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function loadUsers (page) {
|
||||
$.ajax({
|
||||
type: 'get',
|
||||
url: '/admin/api/users',
|
||||
data: {page: page},
|
||||
success: function (data) {
|
||||
$('#userlist').html('')
|
||||
if (data.error) {
|
||||
$('#userlist').html('<div class="message error">' + data.error + '</div>')
|
||||
return
|
||||
}
|
||||
|
||||
var pgbtn = paginationButton(data.page)
|
||||
$('#userlist').append(pgbtn)
|
||||
$('#userlist .pgn .button').click(function (e) {
|
||||
var pgnum = $(this).data('page')
|
||||
if (pgnum == null) return
|
||||
loadUsers(parseInt(pgnum))
|
||||
const app = new Vue({
|
||||
router
|
||||
})
|
||||
|
||||
for (var u in data.users) {
|
||||
var user = data.users[u]
|
||||
user.created_at = new Date(user.created_at)
|
||||
var tmp = buildTemplateScript('user', user)
|
||||
$('#userlist').append(tmp)
|
||||
}
|
||||
|
||||
$('#userlist .ban').click(function (e) {
|
||||
banUser(parseInt($(this).data('id')))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function editClient (id) {
|
||||
$.ajax({
|
||||
type: 'get',
|
||||
url: '/admin/api/client/' + id,
|
||||
success: function (data) {
|
||||
window.Dialog.openTemplate('Editing client', 'clientEdit', data)
|
||||
$('#ffsubmit').submit(function (e) {
|
||||
e.preventDefault()
|
||||
$.ajax({
|
||||
type: 'post',
|
||||
url: '/admin/api/client/update',
|
||||
data: $(this).serialize(),
|
||||
success: function (data) {
|
||||
window.Dialog.close()
|
||||
loadClients(1)
|
||||
},
|
||||
error: function (e) {
|
||||
if (e.responseJSON && e.responseJSON.error) {
|
||||
$('form .message').show()
|
||||
$('form .message').text(e.responseJSON.error)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function deleteClient (id) {
|
||||
window.Dialog.openTemplate('Deleting client', 'clientRemove')
|
||||
$('#fremove').click(function (e) {
|
||||
$.post({
|
||||
url: '/admin/api/client/delete/' + id,
|
||||
success: function (data) {
|
||||
window.Dialog.close()
|
||||
loadClients(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function loadClients (page) {
|
||||
$.ajax({
|
||||
type: 'get',
|
||||
url: '/admin/api/clients',
|
||||
data: {page: page},
|
||||
success: function (data) {
|
||||
$('#clientlist').html('')
|
||||
if (data.error) {
|
||||
$('#clientlist').html('<div class="message error">' + data.error + '</div>')
|
||||
return
|
||||
}
|
||||
|
||||
var pgbtn = paginationButton(data.page)
|
||||
$('#clientlist').append(pgbtn)
|
||||
$('#clientlist .pgn .button').click(function (e) {
|
||||
var pgnum = $(this).data('page')
|
||||
if (pgnum == null) return
|
||||
loadClients(parseInt(pgnum))
|
||||
})
|
||||
|
||||
for (var u in data.clients) {
|
||||
var client = data.clients[u]
|
||||
client.created_at = new Date(client.created_at)
|
||||
var tmp = buildTemplateScript('client', client)
|
||||
$('#clientlist').append(tmp)
|
||||
}
|
||||
|
||||
$('#clientlist .edit').click(function (e) {
|
||||
var client = $(this).data('client')
|
||||
editClient(parseInt(client))
|
||||
})
|
||||
|
||||
$('#clientlist .delete').click(function (e) {
|
||||
var client = $(this).data('client')
|
||||
deleteClient(parseInt(client))
|
||||
})
|
||||
|
||||
$('#clientlist .newsecret').click(function (e) {
|
||||
var client = $(this).data('client')
|
||||
$.post({
|
||||
url: '/admin/api/client/new_secret/' + parseInt(client),
|
||||
success: function (e) {
|
||||
loadClients(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
window.Dialog = $('#dialog')
|
||||
window.Dialog.open = function (title, content, pad) {
|
||||
$('#dialog #title').text(title)
|
||||
if (pad) {
|
||||
content = '<div class="pad">' + content + '</div>'
|
||||
}
|
||||
$('#dialog #content').html(content)
|
||||
$('#dialog').fadeIn()
|
||||
}
|
||||
|
||||
window.Dialog.close = function () {
|
||||
$('#dialog').fadeOut('fast', function () {
|
||||
$('#dialog #content').html('')
|
||||
})
|
||||
}
|
||||
|
||||
window.Dialog.openTemplate = function (title, template, data) {
|
||||
window.Dialog.open(title, buildTemplateScript(template, data || {}), true)
|
||||
}
|
||||
|
||||
$('#dialog #close').click(function (e) {
|
||||
window.Dialog.close()
|
||||
})
|
||||
|
||||
if ($('#userlist').length) {
|
||||
loadUsers(1)
|
||||
}
|
||||
|
||||
if ($('#banlist').length) {
|
||||
loadBans(1)
|
||||
}
|
||||
|
||||
if ($('#clientlist').length) {
|
||||
loadClients(1)
|
||||
|
||||
$('#new').click(function (e) {
|
||||
window.Dialog.openTemplate('New Client', 'clientNew')
|
||||
$('#fnsubmit').submit(function (e) {
|
||||
e.preventDefault()
|
||||
$.post({
|
||||
url: '/admin/api/client/new',
|
||||
data: $(this).serialize(),
|
||||
success: function (data) {
|
||||
window.Dialog.close()
|
||||
loadClients(1)
|
||||
},
|
||||
error: function (e) {
|
||||
if (e.responseJSON && e.responseJSON.error) {
|
||||
$('form .message').show()
|
||||
$('form .message').text(e.responseJSON.error)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
setInterval(function () {
|
||||
$.get({
|
||||
url: '/admin/access'
|
||||
}).fail(function () {
|
||||
window.location.reload()
|
||||
})
|
||||
}, 30000)
|
||||
})
|
||||
app.$mount('#app')
|
||||
|
33
src/script/component/Ban.vue
Normal file
33
src/script/component/Ban.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<template lang="pug">
|
||||
.ban.list-item
|
||||
.stamps
|
||||
.noactive.stamp(title='Expired', v-if='expired')
|
||||
i.fa.fa-fw.fa-ban
|
||||
.info
|
||||
.section
|
||||
span.key User
|
||||
span.value {{ user.display_name }}
|
||||
.section
|
||||
span.key Admin
|
||||
span.value {{ admin.display_name }}
|
||||
.section
|
||||
span.key Reason
|
||||
span.value {{ reason }}
|
||||
.section
|
||||
span.key Placed
|
||||
span.value {{ new Date(created_at).toString() }}
|
||||
.section
|
||||
span.key Expires
|
||||
span.value(v-if='expires_at') {{ new Date(expires_at).toString() }}
|
||||
span.value(v-else='v-else')
|
||||
b This ban is permanent.
|
||||
.button.remove(@click='$parent.$emit("pardon", id)')
|
||||
i.fa.fa-fw.fa-check
|
||||
| Pardon
|
||||
</template>
|
||||
|
||||
<script type="text/javascript">
|
||||
export default {
|
||||
props: ['expired', 'user', 'admin', 'reason', 'created_at', 'expires_at', 'email', 'id']
|
||||
}
|
||||
</script>
|
68
src/script/component/BanList.vue
Normal file
68
src/script/component/BanList.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<template lang="pug">
|
||||
#banlist
|
||||
h3 Bans ({{pagination.total}})
|
||||
.message.error(v-if='error') {{ error }}
|
||||
.entry(v-else)
|
||||
pagination(:page="pagination.page" :pages="pagination.pages" v-on:page="getBans")
|
||||
.list.bans
|
||||
ban(v-for='ban in bans' v-bind="ban" :key="ban.id")
|
||||
</template>
|
||||
|
||||
<script type="text/javascript">
|
||||
import Pagination from './Pagination.vue'
|
||||
import Ban from './Ban.vue'
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').content
|
||||
|
||||
export default {
|
||||
data: function () {
|
||||
return {
|
||||
pagination: {
|
||||
offset: 0,
|
||||
page: 1,
|
||||
pages: 1,
|
||||
perPage: 6,
|
||||
total: 0
|
||||
},
|
||||
error: '',
|
||||
bans: []
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Pagination, Ban
|
||||
},
|
||||
methods: {
|
||||
getBans: function (page) {
|
||||
this.error = ''
|
||||
this.pagination.total = 0
|
||||
this.$http.get('/admin/api/bans?page=' + page).then(data => {
|
||||
if (data.body && data.body.error) {
|
||||
this.error = data.body.error
|
||||
return
|
||||
}
|
||||
|
||||
this.pagination = data.body.page
|
||||
this.bans = data.body.bans
|
||||
})
|
||||
},
|
||||
pardon: function (id) {
|
||||
this.$http.post('/admin/api/ban/pardon', {
|
||||
id: id,
|
||||
csrf: csrfToken
|
||||
}).then(data => {
|
||||
this.getBans(1)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted: function () {
|
||||
this.getBans(1)
|
||||
|
||||
this.$root.$on('reload_bans', () => {
|
||||
this.getBans(this.pagination.page)
|
||||
})
|
||||
|
||||
this.$on('pardon', (id) => {
|
||||
this.pardon(id)
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
58
src/script/component/BanModal.vue
Normal file
58
src/script/component/BanModal.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<template lang="pug">
|
||||
modal(:show='show', @close='close')
|
||||
.modal-header
|
||||
h3 Ban user
|
||||
.modal-body.aligned-form
|
||||
.message.error(v-if='error') {{ error }}
|
||||
input(type='hidden', name='user_id', :value='id')
|
||||
.cell
|
||||
label(for='reason') Reason
|
||||
input#reason(type='text', name='reason', v-model='reason')
|
||||
.cell
|
||||
label(for='expires_at') Expires
|
||||
input#expires_at(type='date', name='expires_at', v-model='expires_at')
|
||||
.modal-footer.text-align
|
||||
button(@click='submit') Ban
|
||||
button(@click='close') Cancel
|
||||
</template>
|
||||
|
||||
<script type="text/javascript">
|
||||
import Modal from './Modal.vue'
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').content
|
||||
|
||||
export default {
|
||||
props: ['show', 'id'],
|
||||
data: function () {
|
||||
return {
|
||||
error: '',
|
||||
reason: '',
|
||||
expires_at: null
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Modal
|
||||
},
|
||||
methods: {
|
||||
close: function () {
|
||||
this.$emit('close')
|
||||
this.error = ''
|
||||
this.reason = ''
|
||||
this.expires_at = null
|
||||
},
|
||||
submit: function () {
|
||||
this.$http.post('/admin/api/ban', {
|
||||
user_id: this.id,
|
||||
reason: this.reason,
|
||||
expires_at: this.expires_at,
|
||||
csrf: csrfToken
|
||||
}).then(data => {
|
||||
this.close()
|
||||
this.$root.$emit('reload_bans')
|
||||
}).catch(err => {
|
||||
console.log(err)
|
||||
if (err.body && err.body.error) this.error = err.body.error
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
115
src/script/component/ClientModal.vue
Normal file
115
src/script/component/ClientModal.vue
Normal file
@ -0,0 +1,115 @@
|
||||
<template lang="pug">
|
||||
modal(:show='show', @close='close')
|
||||
.modal-header
|
||||
h3(v-if="id > 0") Edit Client
|
||||
h3(v-else) New Client
|
||||
.modal-body.aligned-form
|
||||
.message.error(v-if='error') {{ error }}
|
||||
.cell
|
||||
label(for="title") Title
|
||||
input(type="text" id="title" name="title" v-model="title")
|
||||
.cell
|
||||
label(for="description") Description
|
||||
input(type="text" id="description" name="description" v-model="description")
|
||||
.cell
|
||||
label(for="url") URL
|
||||
input(type="text" id="url" name="url" v-model="url")
|
||||
.cell
|
||||
label(for="scope") Scope
|
||||
input(type="text" id="scope" name="scope" v-model="scope")
|
||||
.cell
|
||||
label(for="redirect_url") Redirect
|
||||
input(type="text" id="redirect_url" name="redirect_url" v-model="redirect_url")
|
||||
.cell
|
||||
label(for="verified") Verified
|
||||
input(type="checkbox" id="verified" name="verified" v-model="verified")
|
||||
.modal-footer.text-align
|
||||
button(@click='submit') Done
|
||||
button(@click='newSecret' v-if="id > 0") New Secret
|
||||
button(@click='close') Cancel
|
||||
</template>
|
||||
|
||||
<script type="text/javascript">
|
||||
import Modal from './Modal.vue'
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').content
|
||||
|
||||
export default {
|
||||
props: ['show', 'id'],
|
||||
data: function () {
|
||||
return {
|
||||
error: '',
|
||||
title: '',
|
||||
description: '',
|
||||
scope: '',
|
||||
url: '',
|
||||
redirect_url: '',
|
||||
verified: false
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Modal
|
||||
},
|
||||
methods: {
|
||||
close: function () {
|
||||
this.$emit('close')
|
||||
this.error = ''
|
||||
this.title = ''
|
||||
this.description = ''
|
||||
this.scope = ''
|
||||
this.url = ''
|
||||
this.redirect_url = ''
|
||||
this.verified = false
|
||||
},
|
||||
submit: function () {
|
||||
let uri = this.id === -1 ? 'new' : 'update'
|
||||
|
||||
this.$http.post('/admin/api/client/' + uri, {
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
scope: this.scope,
|
||||
url: this.url,
|
||||
redirect_url: this.redirect_url,
|
||||
verified: this.verified,
|
||||
csrf: csrfToken
|
||||
}).then(data => {
|
||||
this.close()
|
||||
this.$root.$emit('reload_clients')
|
||||
}).catch(err => {
|
||||
console.error(err)
|
||||
if (err.body && err.body.error) this.error = err.body.error
|
||||
})
|
||||
},
|
||||
newSecret: function () {
|
||||
this.$http.post('/admin/api/client/new_secret', {
|
||||
id: this.id,
|
||||
csrf: csrfToken
|
||||
}).then(data => {
|
||||
alert('New secret generated.')
|
||||
this.$root.$emit('reload_clients')
|
||||
}).catch(err => {
|
||||
this.error = 'Failed to generate new secret.'
|
||||
})
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
id: function () {
|
||||
if (this.id <= 0) return
|
||||
|
||||
this.$http.get('/admin/api/client/' + this.id).then(data => {
|
||||
let dr = data.body
|
||||
|
||||
this.title = dr.title
|
||||
this.description = dr.description
|
||||
this.scope = dr.scope
|
||||
this.url = dr.url
|
||||
this.redirect_url = dr.redirect_url
|
||||
this.verified = dr.verified
|
||||
}).catch(err => {
|
||||
alert('Failed to fetch client data')
|
||||
this.close()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
25
src/script/component/Modal.vue
Normal file
25
src/script/component/Modal.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<template lang="pug">
|
||||
transition(name='modal')
|
||||
.modal-mask(@click='close', v-show='show')
|
||||
.modal-wrapper
|
||||
.modal-container(@click.stop='')
|
||||
slot
|
||||
</template>
|
||||
|
||||
<script type="text/javascript">
|
||||
export default {
|
||||
props: ['show'],
|
||||
methods: {
|
||||
close: function () {
|
||||
this.$emit('close')
|
||||
}
|
||||
},
|
||||
mounted: function () {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (this.show && e.keyCode === 27) {
|
||||
this.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
74
src/script/component/OAuthClient.vue
Normal file
74
src/script/component/OAuthClient.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<template lang="pug">
|
||||
.application.list-item
|
||||
.picture
|
||||
img(v-if="icon" :src="'/usercontent/images/' + icon")
|
||||
.noicon(v-else)
|
||||
i.fa.fa-fw.fa-gears
|
||||
.info
|
||||
.stamps
|
||||
.verified.stamp(v-if="verified")
|
||||
i.fa.fa-fw.fa-check
|
||||
|
||||
.dropdown-wrapper.stamp(@click="dropdown = !dropdown" v-on-clickaway='away')
|
||||
i.fa.fa-fw.fa-ellipsis-v
|
||||
transition(name="pop")
|
||||
.dropdown(v-show="dropdown")
|
||||
.title Actions
|
||||
.action(@click="$parent.$emit('edit', id)")
|
||||
i.fa.fa-fw.fa-pencil
|
||||
| Edit
|
||||
.action(@click="$parent.$emit('delete', id)")
|
||||
i.fa.fa-fw.fa-trash
|
||||
| Delete
|
||||
|
||||
.name {{ title }}
|
||||
.description {{ description }}
|
||||
a.url(:href='url', target='_blank', rel='nofollow') {{ url }}
|
||||
|
||||
.section
|
||||
span.key Scopes
|
||||
span.value {{ scope }}
|
||||
.section
|
||||
span.key Redirect
|
||||
span.value {{ redirect_url }}
|
||||
.section
|
||||
span.key Client ID
|
||||
span.value {{ id }}
|
||||
.section
|
||||
span.key Secret
|
||||
span.value Client Secret:
|
||||
#showbutton(@click="secretShown = !secretShown")
|
||||
span(v-show="!secretShown") Click here to reveal secret
|
||||
#hiddensecret(v-show="secretShown") {{ secret }}
|
||||
.section
|
||||
span.key Grants
|
||||
span.value {{ grants }}
|
||||
.section
|
||||
span.key Owner
|
||||
span.value {{ user.display_name }}
|
||||
.section
|
||||
span.key Created
|
||||
span.value {{ new Date(created_at).toString() }}
|
||||
</template>
|
||||
|
||||
<script type="text/javascript">
|
||||
import { directive as onClickaway } from 'vue-clickaway'
|
||||
|
||||
export default {
|
||||
props: ['icon', 'verified', 'title', 'description', 'url', 'scope', 'redirect_url', 'id', 'secret', 'created_at', 'user', 'grants'],
|
||||
data: function () {
|
||||
return {
|
||||
dropdown: false,
|
||||
secretShown: false
|
||||
}
|
||||
},
|
||||
directives: {
|
||||
onClickaway: onClickaway,
|
||||
},
|
||||
methods: {
|
||||
away: function () {
|
||||
this.dropdown = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
76
src/script/component/OAuthClients.vue
Normal file
76
src/script/component/OAuthClients.vue
Normal file
@ -0,0 +1,76 @@
|
||||
<template lang="pug">
|
||||
#clientlist
|
||||
button(@click="editing = -1") New Client
|
||||
.message.error(v-if="error") {{ error }}
|
||||
.entry(v-else)
|
||||
pagination(:page="pagination.page" :pages="pagination.pages" v-on:page="getClients")
|
||||
.list.client
|
||||
o-auth-client(v-for="client in clients" v-bind="client" :key="client.id")
|
||||
client-modal(:show="editing != 0" @close='editing = 0', :id='editing')
|
||||
</template>
|
||||
|
||||
<script type="text/javascript">
|
||||
import Pagination from './Pagination.vue'
|
||||
import OAuthClient from './OAuthClient.vue'
|
||||
import ClientModal from './ClientModal.vue'
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').content
|
||||
|
||||
export default {
|
||||
data: function () {
|
||||
return {
|
||||
clients: [],
|
||||
pagination: {
|
||||
offset: 0,
|
||||
page: 1,
|
||||
pages: 1,
|
||||
perPage: 6,
|
||||
total: 0
|
||||
},
|
||||
editing: 0,
|
||||
error: ''
|
||||
}
|
||||
},
|
||||
components: {
|
||||
ClientModal, Pagination, OAuthClient
|
||||
},
|
||||
methods: {
|
||||
getClients: function (page) {
|
||||
this.pagination.total = 0
|
||||
this.error = ''
|
||||
|
||||
this.$http.get('/admin/api/clients?page=' + page).then(data => {
|
||||
if (data.body && data.body.error) {
|
||||
this.error = data.body.error
|
||||
return
|
||||
}
|
||||
|
||||
this.pagination = data.body.page
|
||||
this.clients = data.body.clients
|
||||
})
|
||||
},
|
||||
deleteClient: function (id) {
|
||||
this.$http.post('/admin/api/client/delete', {
|
||||
id: id,
|
||||
csrf: csrfToken
|
||||
}).then(data => {
|
||||
this.getClients(1)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted: function () {
|
||||
this.getClients(1)
|
||||
|
||||
this.$root.$on('reload_clients', () => {
|
||||
this.getClients(this.pagination.page)
|
||||
})
|
||||
|
||||
this.$on('edit', function (id) {
|
||||
this.editing = id
|
||||
})
|
||||
|
||||
this.$on('delete', function (id) {
|
||||
this.deleteClient(id)
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
13
src/script/component/Pagination.vue
Normal file
13
src/script/component/Pagination.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<template lang="pug">
|
||||
.pgn
|
||||
span.pagenum Page {{ page }} of {{ pages }}
|
||||
.button(v-if='page > 1' v-on:click='$emit("page", page - 1)') Previous
|
||||
.button(v-for='n in pages' v-on:click='$emit("page", n)' v-bind:class='{active: n == page}') {{ n }}
|
||||
.button(v-if='page < pages' v-on:click='$emit("page", page + 1)') Next
|
||||
</template>
|
||||
|
||||
<script type="text/javascript">
|
||||
export default {
|
||||
props: ['page', 'pages']
|
||||
}
|
||||
</script>
|
109
src/script/component/User.vue
Normal file
109
src/script/component/User.vue
Normal file
@ -0,0 +1,109 @@
|
||||
<template lang="pug">
|
||||
.user.list-item
|
||||
.avatar
|
||||
img(v-if='avatar_file', v-bind:src="'/usercontent/images/' + avatar_file")
|
||||
img(v-else='v-else', src='/static/image/avatar.png')
|
||||
.info
|
||||
.stamps
|
||||
.stamp(title="Used an external login" v-if="!password")
|
||||
i.fa.fa-fw.fa-sign-out
|
||||
|
||||
.noactive.stamp(v-if='activated == false' title='Not activated.')
|
||||
i.fa.fa-fw.fa-envelope
|
||||
|
||||
.totp.stamp(v-if='totp_enabled' title="Two-Factor Authentication Enabled")
|
||||
i.fa.fa-fw.fa-shield
|
||||
|
||||
.dropdown-wrapper.stamp(@click="dropdown = !dropdown" v-on-clickaway='away')
|
||||
i.fa.fa-fw.fa-ellipsis-v
|
||||
transition(name="pop")
|
||||
.dropdown(v-show="dropdown")
|
||||
.title Actions
|
||||
.action(v-on:click='$parent.$emit("edit", id)')
|
||||
i.fa.fa-fw.fa-pencil
|
||||
| Edit User
|
||||
.action(v-if='bannable' v-on:click='$parent.$emit("ban", id)')
|
||||
i.fa.fa-fw.fa-ban
|
||||
| Ban User
|
||||
.separator
|
||||
.action(v-if='!activated' v-on:click='activationToken')
|
||||
i.fa.fa-fw.fa-envelope
|
||||
| Activation Email
|
||||
.action(v-if="totp_enabled" v-on:click='totpRevoke')
|
||||
i.fa.fa-fw.fa-shield
|
||||
| Revoke 2FA
|
||||
.action(v-on:click='resetPassword')
|
||||
i.fa.fa-fw.fa-envelope
|
||||
| Password Email
|
||||
|
||||
.display_name {{ display_name }}
|
||||
.name {{ id }} - {{ username }} ({{ uuid }})
|
||||
.section
|
||||
span.key Email
|
||||
span.value {{ email }}
|
||||
.section
|
||||
span.key Privilege
|
||||
span.value {{ nw_privilege }}
|
||||
.section
|
||||
span.key Last IP
|
||||
span.value {{ ip_address }}
|
||||
.section
|
||||
span.key Registered
|
||||
span.value {{ new Date(created_at).toString() }}
|
||||
</template>
|
||||
|
||||
<script type="text/javascript">
|
||||
import { directive as onClickaway } from 'vue-clickaway'
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').content
|
||||
|
||||
export default {
|
||||
props: ['avatar_file', 'activated', 'display_name', 'id', 'username', 'uuid', 'email', 'nw_privilege', 'created_at', 'password', 'bannable', 'ip_address', 'totp_enabled'],
|
||||
directives: {
|
||||
onClickaway: onClickaway,
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
dropdown: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
away: function () {
|
||||
this.dropdown = false
|
||||
},
|
||||
activationToken: function () {
|
||||
this.$http.post('/admin/api/user/resend_activation', {
|
||||
user_id: this.id,
|
||||
csrf: csrfToken
|
||||
}).then(data => {
|
||||
alert('Email sent!')
|
||||
}).catch(err => {
|
||||
console.error(err)
|
||||
alert('Failed to send activation email to this user.')
|
||||
})
|
||||
},
|
||||
totpRevoke: function () {
|
||||
this.$http.post('/admin/api/user/revoke_totp', {
|
||||
user_id: this.id,
|
||||
csrf: csrfToken
|
||||
}).then(data => {
|
||||
alert('Success!')
|
||||
this.$root.$emit('reload_users')
|
||||
}).catch(err => {
|
||||
console.error(err)
|
||||
alert('An error occured.')
|
||||
})
|
||||
},
|
||||
resetPassword: function () {
|
||||
this.$http.post('/admin/api/user/reset_password', {
|
||||
user_id: this.id,
|
||||
csrf: csrfToken
|
||||
}).then(data => {
|
||||
alert('Email sent!')
|
||||
}).catch(err => {
|
||||
console.error(err)
|
||||
alert('Failed to send activation email to this user.')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
92
src/script/component/UserList.vue
Normal file
92
src/script/component/UserList.vue
Normal file
@ -0,0 +1,92 @@
|
||||
<template lang="pug">
|
||||
#userlist
|
||||
h3 Registered Users ({{ pagination.total }})
|
||||
pagination(:page="pagination.page" :pages="pagination.pages" v-on:page="getUsers")
|
||||
.list.users
|
||||
.searchbox
|
||||
input(v-model="search" placeholder="Begin typing a name or email address..")
|
||||
.message.error(v-if="error") {{ error }}
|
||||
user(v-else v-for='user in users' v-bind="user" :key="user.id")
|
||||
ban-modal(:show='banning' @close='banning = 0' :id='banning')
|
||||
user-modal(:show='editing' @close='editing = 0' :id='editing')
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Pagination from './Pagination.vue'
|
||||
import User from './User.vue'
|
||||
import BanModal from './BanModal.vue'
|
||||
import UserModal from './UserModal.vue'
|
||||
|
||||
import qs from 'querystring'
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').content
|
||||
|
||||
export default {
|
||||
data: function () {
|
||||
return {
|
||||
pagination: {
|
||||
offset: 0,
|
||||
page: 1,
|
||||
pages: 1,
|
||||
perPage: 6,
|
||||
total: 0
|
||||
},
|
||||
users: [],
|
||||
banning: 0,
|
||||
editing: 0,
|
||||
search: '',
|
||||
error: ''
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Pagination, User, BanModal, UserModal
|
||||
},
|
||||
methods: {
|
||||
getUsers: function (page) {
|
||||
this.error = ''
|
||||
this.$http.get('/admin/api/users?page=' + page).then(data => {
|
||||
if (data.body && data.body.error) return
|
||||
this.pagination = data.body.page
|
||||
this.users = data.body.users
|
||||
})
|
||||
},
|
||||
searchUsers: function () {
|
||||
this.error = ''
|
||||
this.$http.get('/admin/api/search/users?' + qs.stringify({
|
||||
terms: this.search,
|
||||
scopes: 'email,username,display_name'
|
||||
})).then(data => {
|
||||
if (data.body.error) {
|
||||
this.error = data.body.error
|
||||
return
|
||||
}
|
||||
|
||||
this.users = data.body
|
||||
})
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
search: function () {
|
||||
if (this.search === '') {
|
||||
this.getUsers(1)
|
||||
} else {
|
||||
this.searchUsers(this.search)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted: function () {
|
||||
this.getUsers(1)
|
||||
|
||||
this.$on('ban', function (id) {
|
||||
this.banning = id
|
||||
})
|
||||
|
||||
this.$on('edit', function (id) {
|
||||
this.editing = id
|
||||
})
|
||||
|
||||
this.$root.$on('reload_users', () => {
|
||||
this.getUsers(this.pagination.page)
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
93
src/script/component/UserModal.vue
Normal file
93
src/script/component/UserModal.vue
Normal file
@ -0,0 +1,93 @@
|
||||
<template lang="pug">
|
||||
modal(:show='show', @close='close')
|
||||
.modal-header
|
||||
h3 Edit User
|
||||
.modal-body.aligned-form
|
||||
.message.error(v-if='error') {{ error }}
|
||||
.cell
|
||||
label(for="username") Username
|
||||
input(type="text" id="username" name="username" v-model="username")
|
||||
.cell
|
||||
label(for="display_name") Display Name
|
||||
input(type="text" id="display_name" name="display_name" v-model="display_name")
|
||||
.cell
|
||||
label(for="email") Email
|
||||
input(type="email" id="email" name="email" v-model="email")
|
||||
.cell
|
||||
label(for="privilege") Privilege
|
||||
input(type="range" min="0" max="5" step="1" id="privilege" name="privilege" v-model="nw_privilege")
|
||||
span {{ nw_privilege }}
|
||||
.cell
|
||||
label(for="activated") Activated
|
||||
input(type="checkbox" id="activated" name="activated" v-model="activated")
|
||||
.modal-footer.text-align
|
||||
button(@click='submit') Done
|
||||
button(@click='close') Cancel
|
||||
</template>
|
||||
|
||||
<script type="text/javascript">
|
||||
import Modal from './Modal.vue'
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').content
|
||||
|
||||
export default {
|
||||
props: ['show', 'id'],
|
||||
data: function () {
|
||||
return {
|
||||
error: '',
|
||||
username: '',
|
||||
display_name: '',
|
||||
email: '',
|
||||
nw_privilege: 0,
|
||||
activated: false
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Modal
|
||||
},
|
||||
methods: {
|
||||
close: function () {
|
||||
this.$emit('close')
|
||||
this.error = ''
|
||||
this.username = ''
|
||||
this.display_name = ''
|
||||
this.email = ''
|
||||
this.nw_privilege = 0
|
||||
this.activated = true
|
||||
},
|
||||
submit: function () {
|
||||
this.$http.post('/admin/api/user', {
|
||||
user_id: this.id,
|
||||
username: this.username,
|
||||
display_name: this.display_name,
|
||||
email: this.email,
|
||||
nw_privilege: this.nw_privilege,
|
||||
activated: this.activated,
|
||||
csrf: csrfToken
|
||||
}).then(data => {
|
||||
this.close()
|
||||
this.$root.$emit('reload_users')
|
||||
}).catch(err => {
|
||||
console.error(err)
|
||||
if (err.body && err.body.error) this.error = err.body.error
|
||||
})
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
id: function () {
|
||||
if (this.id <= 0) return
|
||||
this.$http.get('/admin/api/user/' + this.id).then(data => {
|
||||
let dr = data.body
|
||||
|
||||
this.username = dr.username
|
||||
this.display_name = dr.display_name
|
||||
this.email = dr.email
|
||||
this.nw_privilege = dr.nw_privilege
|
||||
this.activated = dr.activated
|
||||
}).catch(err => {
|
||||
alert('Failed to fetch user information')
|
||||
this.close()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
20
src/script/views/OAuth2.vue
Normal file
20
src/script/views/OAuth2.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<template lang="pug">
|
||||
.root
|
||||
h1 Manage OAuth2 Clients
|
||||
o-auth-clients
|
||||
</template>
|
||||
|
||||
<script type="text/javascript">
|
||||
import OAuthClients from '../component/OAuthClients.vue'
|
||||
|
||||
export default {
|
||||
methods: {
|
||||
newClient: function () {
|
||||
alert('not yet')
|
||||
}
|
||||
},
|
||||
components: {
|
||||
OAuthClients
|
||||
}
|
||||
}
|
||||
</script>
|
19
src/script/views/Users.vue
Normal file
19
src/script/views/Users.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<template lang="pug">
|
||||
.root
|
||||
h1 Welcome to the Admin Panel
|
||||
.left
|
||||
user-list
|
||||
.right
|
||||
ban-list
|
||||
</template>
|
||||
|
||||
<script type="text/javascript">
|
||||
import UserList from '../component/UserList.vue'
|
||||
import BanList from '../component/BanList.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
UserList, BanList
|
||||
}
|
||||
}
|
||||
</script>
|
@ -42,9 +42,17 @@ nav
|
||||
float: right
|
||||
.stamps
|
||||
float: right
|
||||
.stamp
|
||||
color: #2196F3
|
||||
padding: 5px
|
||||
font-size: 120%
|
||||
display: inline-block
|
||||
|
||||
&.noactive.stamp
|
||||
color: #f70000
|
||||
|
||||
.user
|
||||
min-height: 180px
|
||||
min-height: 160px
|
||||
.avatar
|
||||
float: left
|
||||
.info
|
||||
@ -55,17 +63,151 @@ nav
|
||||
.username
|
||||
font-size: 80%
|
||||
|
||||
.application
|
||||
height: 200px
|
||||
#hiddensecret
|
||||
display: none
|
||||
&.shown
|
||||
.info
|
||||
.section
|
||||
margin: 5px 0
|
||||
.key
|
||||
width: 120px
|
||||
display: inline-block
|
||||
|
||||
.list
|
||||
padding: 10px
|
||||
.list-item
|
||||
background-color: #f5f5f5
|
||||
padding: 10px
|
||||
&:nth-child(even)
|
||||
background-color: #fff
|
||||
|
||||
.application
|
||||
min-height: 220px
|
||||
|
||||
#hiddensecret
|
||||
color: #ff796f
|
||||
background-color: #f1f1f1
|
||||
min-width: 250px
|
||||
|
||||
#showbutton
|
||||
font-style: italic
|
||||
display: inline-block
|
||||
cursor: pointer
|
||||
|
||||
.link
|
||||
color: green
|
||||
cursor: pointer
|
||||
display: inline-block
|
||||
|
||||
.dropdown-wrapper
|
||||
border-radius: 5px
|
||||
position: relative
|
||||
cursor: pointer
|
||||
color: #000 !important
|
||||
|
||||
.dropdown
|
||||
transition: all .2s ease
|
||||
cursor: default
|
||||
position: absolute
|
||||
top: 30px
|
||||
right: 0
|
||||
background-color: #fff
|
||||
border: 1px solid #ddd
|
||||
border-radius: 5px
|
||||
min-width: 210px
|
||||
font-size: 100%
|
||||
z-index: 2999
|
||||
|
||||
.title
|
||||
padding: 5px
|
||||
color: #a5a5a5
|
||||
|
||||
.action
|
||||
transition: background-color .3s linear
|
||||
cursor: pointer
|
||||
padding: 5px
|
||||
|
||||
&:hover
|
||||
background-color: #ececec
|
||||
|
||||
.separator
|
||||
margin: 0 15px
|
||||
margin-top: 10px
|
||||
border-bottom: 1px solid #ddd
|
||||
margin-bottom: 9px
|
||||
|
||||
.searchbox
|
||||
margin: 10px 0
|
||||
|
||||
input
|
||||
font-size: 140%
|
||||
display: block
|
||||
width: 100%
|
||||
|
||||
.modal-mask
|
||||
position: fixed
|
||||
z-index: 9998
|
||||
top: 0
|
||||
left: 0
|
||||
width: 100%
|
||||
height: 100%
|
||||
background-color: rgba(0, 0, 0, .5)
|
||||
display: table
|
||||
transition: opacity .3s ease
|
||||
|
||||
.modal-wrapper
|
||||
display: table-cell
|
||||
vertical-align: middle
|
||||
|
||||
.modal-container
|
||||
width: 360px
|
||||
margin: 0px auto
|
||||
padding: 20px 30px
|
||||
background-color: #fff
|
||||
border-radius: 2px
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, .33)
|
||||
transition: all .3s ease
|
||||
font-family: Helvetica, Arial, sans-serif
|
||||
|
||||
.modal-header h3
|
||||
margin-top: 0
|
||||
color: #42b983
|
||||
|
||||
.modal-body
|
||||
margin: 20px 0
|
||||
|
||||
.modal-footer
|
||||
min-height: 50px
|
||||
|
||||
button
|
||||
margin: 5px
|
||||
display: inline-block
|
||||
|
||||
&.text-align
|
||||
text-align: center
|
||||
|
||||
.fade-enter-active, .fade-leave-active
|
||||
transition-property: opacity
|
||||
transition-duration: .25s
|
||||
|
||||
.fade-enter-active
|
||||
transition-delay: .25s
|
||||
|
||||
.fade-enter, .fade-leave-active,
|
||||
.pop-enter, .pop-leave-active
|
||||
opacity: 0
|
||||
|
||||
.pop-enter, .pop-leave-active
|
||||
-webkit-transform: scale(1.1)
|
||||
transform: scale(1.1)
|
||||
|
||||
.modal-enter
|
||||
opacity: 0
|
||||
|
||||
.modal-leave-active
|
||||
opacity: 0
|
||||
|
||||
.modal-enter .modal-container, .modal-leave-active .modal-container
|
||||
-webkit-transform: scale(1.1)
|
||||
transform: scale(1.1)
|
||||
|
||||
form
|
||||
.message
|
||||
display: none
|
||||
|
@ -197,7 +197,7 @@ input:not([type="submit"])
|
||||
box-shadow: inset 2px 2px 5px #ddd
|
||||
transition: border 0.1s linear
|
||||
|
||||
.button, input[type="submit"]
|
||||
button, .button, input[type="submit"]
|
||||
display: block
|
||||
padding: 5px 10px
|
||||
background-color: #fbfbfb
|
||||
@ -211,6 +211,7 @@ input:not([type="submit"])
|
||||
|
||||
.button
|
||||
display: inline-block
|
||||
margin-right: 5px
|
||||
|
||||
.boxcont
|
||||
.box
|
||||
@ -568,6 +569,21 @@ select
|
||||
margin-left: 5px
|
||||
border-radius: 5px
|
||||
|
||||
.aligned-form
|
||||
.cell
|
||||
margin-top: 10px
|
||||
label
|
||||
width: 120px
|
||||
float: left
|
||||
margin: 0
|
||||
padding: 8px 0
|
||||
input[type="checkbox"]
|
||||
margin-top: 10px
|
||||
span
|
||||
padding: 10px
|
||||
display: inline-block
|
||||
vertical-align: top
|
||||
|
||||
@media all and (max-width: 800px)
|
||||
.navigator
|
||||
padding: 0 10px
|
||||
|
@ -1,5 +1,4 @@
|
||||
h1 Hello, #{display_name}!
|
||||
p You've requested to reset your password on Icy Network.
|
||||
p Click on or copy the following link into your URL bar in order to reset your Icy Network account password:
|
||||
a.activate(href=domain + "/reset/" + reset_token, target="_blank", rel="nofollow")= domain + "/reset/" + reset_token
|
||||
p If you did not request a password reset on Icy Network, please ignore this email.
|
||||
|
@ -1 +1 @@
|
||||
|Icy Network - Password reset request
|
||||
|Icy Network - Reset Your Password
|
||||
|
@ -3,67 +3,6 @@ extends layout.pug
|
||||
block body
|
||||
.container
|
||||
.content
|
||||
h1 Welcome to the Admin Panel
|
||||
.left
|
||||
.users
|
||||
h3 Registered Users
|
||||
#userlist
|
||||
.right
|
||||
.users
|
||||
h3 Bans
|
||||
#banlist
|
||||
.templates
|
||||
script(type="x-tmpl-mustache" id="user").
|
||||
<div class="user" id="user-{{id}}">
|
||||
<div class="avatar">
|
||||
{{#avatar_file}}
|
||||
<img src="/usercontent/images/{{avatar_file}}">
|
||||
{{/avatar_file}}
|
||||
{{^avatar_file}}
|
||||
<img src="/static/image/avatar.png">
|
||||
{{/avatar_file}}
|
||||
</div>
|
||||
<div class="info">
|
||||
<div class="stamps">
|
||||
{{^activated}}
|
||||
<div class="noactive" title="Not activated"><i class="fa fa-fw fa-envelope"></i></div>
|
||||
{{/activated}}
|
||||
</div>
|
||||
<div class="display_name">{{display_name}}</div>
|
||||
<div class="username">{{id}} - {{username}} ({{uuid}})</div>
|
||||
<div class="email">{{email}}</div>
|
||||
<div class="privilege">Privilege: level {{nw_privilege}}</div>
|
||||
<div class="timestamp">{{created_at}}</div>
|
||||
{{^password}}
|
||||
<div class="external"><b>Used external login</b></div>
|
||||
{{/password}}
|
||||
{{#bannable}}
|
||||
<div class="button ban" data-id="{{id}}"><i class="fa fa-fw fa-ban"></i>Ban User</div>
|
||||
{{/bannable}}
|
||||
</div>
|
||||
</div>
|
||||
script(type="x-tmpl-mustache" id="ban").
|
||||
<div class="ban" id="ban-{{user.id}}">
|
||||
<div class="stamps">
|
||||
{{#expired}}
|
||||
<div class="noactive" title="Expired"><i class="fa fa-fw fa-ban"></i></div>
|
||||
{{/expired}}
|
||||
</div>
|
||||
<div class="display_name">User: {{user.display_name}}</div>
|
||||
<div class="display_name">Admin: {{admin.display_name}}</div>
|
||||
<div class="description">Reason: {{reason}}</div>
|
||||
<div class="timestamp">Placed {{created_at}}</div>
|
||||
<div class="timestamp">Expires {{expires_at}}</div>
|
||||
<div class="button remove" data-id="{{id}}">Pardon</div>
|
||||
</div>
|
||||
script(type="x-tmpl-mustache" id="banNew").
|
||||
<form id="fnsubmit">
|
||||
<div class="message error"></div>
|
||||
<input type="hidden" name="csrf" value="#{csrf}">
|
||||
<input type="hidden" name="user_id" value="{{id}}">
|
||||
<label for="reason">Reason</label>
|
||||
<input type="text" id="reason" name="reason">
|
||||
<label for="expires_at">Expires</label>
|
||||
<input type="date" id="expires_at" name="expires_at">
|
||||
<input type="submit" value="Create">
|
||||
</form>
|
||||
transition(name="fade")
|
||||
router-view
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
html
|
||||
head
|
||||
meta(charset="utf8")
|
||||
meta(name="csrf-token", content=csrf)
|
||||
block links
|
||||
link(rel="stylesheet", type="text/css", href="https://fonts.googleapis.com/css?family=Open+Sans")
|
||||
link(rel="stylesheet", type="text/css", href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css")
|
||||
@ -10,30 +11,24 @@ html
|
||||
window.variables = {
|
||||
server_time: parseInt('#{server_time}')
|
||||
};
|
||||
script(src="/script/admin.js")
|
||||
title
|
||||
block title
|
||||
|Icy Network - Administration
|
||||
body
|
||||
#app
|
||||
block navigation
|
||||
nav
|
||||
ul
|
||||
li
|
||||
a.navlogo(href="/") Icy Network
|
||||
li
|
||||
a(href="/admin/") Home
|
||||
router-link(to="/") Home
|
||||
li
|
||||
a(href="/admin/oauth2/") OAuth2
|
||||
router-link(to="/oauth2") OAuth2
|
||||
ul.right
|
||||
li
|
||||
a(href="/user/manage") #{user.display_name}
|
||||
block dialog
|
||||
.dialog-drop#dialog
|
||||
.dialog
|
||||
.head
|
||||
#title
|
||||
#close
|
||||
i.fa.fa-fw.fa-times
|
||||
.content#content
|
||||
|
||||
.wrapper
|
||||
block body
|
||||
script(src="/script/admin.js")
|
||||
|
44
webpack.common.js
Normal file
44
webpack.common.js
Normal file
@ -0,0 +1,44 @@
|
||||
const path = require('path')
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
main: './src/script/main.js',
|
||||
admin: './src/script/admin.js'
|
||||
},
|
||||
output: {
|
||||
path: path.join(__dirname, 'build', 'script'),
|
||||
filename: '[name].js'
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'vue$': 'vue/dist/vue.esm.js' // 'vue/dist/vue.common.js' for webpack 1
|
||||
}
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
exclude: /(node_modules|bower_components)/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
presets: [
|
||||
'env'
|
||||
],
|
||||
plugins: [
|
||||
'transform-es2015-modules-commonjs'
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.vue$/,
|
||||
exclude: /(node_modules|bower_components)/,
|
||||
use: {
|
||||
loader: 'vue-loader'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
plugins: []
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
const path = require('path')
|
||||
const UglifyJSPlugin = require('uglifyjs-webpack-plugin')
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
main: './src/script/main.js',
|
||||
admin: './src/script/admin.js'
|
||||
},
|
||||
output: {
|
||||
path: path.join(__dirname, 'build', 'script'),
|
||||
filename: '[name].js'
|
||||
},
|
||||
plugins: [
|
||||
new UglifyJSPlugin()
|
||||
]
|
||||
}
|
6
webpack.dev.js
Normal file
6
webpack.dev.js
Normal file
@ -0,0 +1,6 @@
|
||||
const merge = require('webpack-merge')
|
||||
const common = require('./webpack.common.js')
|
||||
|
||||
module.exports = merge(common, {
|
||||
devtool: 'inline-source-map'
|
||||
})
|
13
webpack.prod.js
Normal file
13
webpack.prod.js
Normal file
@ -0,0 +1,13 @@
|
||||
const webpack = require('webpack')
|
||||
const merge = require('webpack-merge')
|
||||
const common = require('./webpack.common.js')
|
||||
const UglifyJSPlugin = require('uglifyjs-webpack-plugin')
|
||||
|
||||
module.exports = merge(common, {
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.NODE_ENV': JSON.stringify('production')
|
||||
}),
|
||||
new UglifyJSPlugin()
|
||||
]
|
||||
})
|
Reference in New Issue
Block a user