diff --git a/app/controllers/admin.js b/app/controllers/admin.js index a9dc8c4..ccc30ed 100644 --- a/app/controllers/admin.js +++ b/app/controllers/admin.js @@ -44,6 +44,7 @@ class AdminController extends SiteController { router.use('/content-report',await this.loadChild(path.join(__dirname, 'admin', 'content-report'))); router.use('/core-node',await this.loadChild(path.join(__dirname, 'admin', 'core-node'))); + router.use('/core-user',await this.loadChild(path.join(__dirname, 'admin', 'core-user'))); router.use('/host',await this.loadChild(path.join(__dirname, 'admin', 'host'))); router.use('/job-queue', await this.loadChild(path.join(__dirname, 'admin', 'job-queue'))); router.use('/log', await this.loadChild(path.join(__dirname, 'admin', 'log'))); diff --git a/app/controllers/admin/core-user.js b/app/controllers/admin/core-user.js new file mode 100644 index 0000000..1d72973 --- /dev/null +++ b/app/controllers/admin/core-user.js @@ -0,0 +1,96 @@ +// admin/core-user.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const express = require('express'); +// const multer = require('multer'); + +const { SiteController, SiteError } = require('../../../lib/site-lib'); + +class CoreUserController extends SiteController { + + constructor (dtp) { + super(dtp, module.exports); + } + + async start ( ) { + // const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/upload` }); + + const router = express.Router(); + router.use(async (req, res, next) => { + res.locals.currentView = 'admin'; + res.locals.adminView = 'core-user'; + return next(); + }); + + router.param('coreUserId', this.populateCoreUserId.bind(this)); + + router.post('/:coreUserId', this.postUpdateCoreUser.bind(this)); + + router.get('/:coreUserId', this.getCoreUserView.bind(this)); + router.get('/', this.getIndex.bind(this)); + + return router; + } + + async populateCoreUserId (req, res, next, coreUserId) { + const { coreNode: coreNodeService } = this.dtp.services; + try { + res.locals.userAccount = await coreNodeService.getUserByLocalId(coreUserId); + if (!res.locals.userAccount) { + throw new SiteError(404, 'Core Member not found'); + } + return next(); + } catch (error) { + this.log.error('failed to populate coreUserId', { coreUserId, error }); + return next(error); + } + } + + async postUpdateCoreUser (req, res, next) { + const { coreNode: coreNodeService } = this.dtp.services; + try { + await coreNodeService.updateUserForAdmin(res.locals.userAccount, req.body); + res.redirect('/admin/core-user'); + } catch (error) { + return next(error); + } + } + + async getCoreUserView (req, res, next) { + const { comment: commentService } = this.dtp.services; + try { + res.locals.pagination = this.getPaginationParameters(req, 20); + res.locals.recentComments = await commentService.getForAuthor(res.locals.userAccount, res.locals.pagination); + res.render('admin/core-user/form'); + } catch (error) { + this.log.error('failed to produce user view', { error }); + return next(error); + } + } + + async getIndex (req, res, next) { + const { coreNode: coreNodeService } = this.dtp.services; + const search = { }; + try { + res.locals.pagination = this.getPaginationParameters(req, 20); + res.locals.users = await coreNodeService.searchUsers(search, res.locals.pagination); + res.render('admin/core-user/index'); + } catch (error) { + this.log.error('failed to render Core User home', { + search, + pagination: res.locals.pagination, + error, + }); + return next(error); + } + } +} + +module.exports = { + name: 'adminCoreUser', + slug: 'admin-core-user', + create: async (dtp) => { return new CoreUserController(dtp); }, +}; \ No newline at end of file diff --git a/app/models/core-user.js b/app/models/core-user.js index ab1cf88..13f5ab7 100644 --- a/app/models/core-user.js +++ b/app/models/core-user.js @@ -16,7 +16,7 @@ const CoreUserSchema = new Schema({ core: { type: Schema.ObjectId, required: true, ref: 'CoreNode' }, coreUserId: { type: Schema.ObjectId, required: true }, username: { type: String, required: true }, - username_lc: { type: String, required: true, lowercase: true }, + username_lc: { type: String, required: true, lowercase: true, index: 1 }, displayName: { type: String }, bio: { type: String, maxlength: 300 }, flags: { type: UserFlagsSchema, select: false }, diff --git a/app/models/lib/user-types.js b/app/models/lib/user-types.js index d3633a9..55d25c0 100644 --- a/app/models/lib/user-types.js +++ b/app/models/lib/user-types.js @@ -12,6 +12,7 @@ module.exports.DTP_THEME_LIST = ['dtp-light', 'dtp-dark']; module.exports.UserFlagsSchema = new Schema({ isAdmin: { type: Boolean, default: false, required: true }, isModerator: { type: Boolean, default: false, required: true }, + isEmailVerified: { type: Boolean, default: false, required: true }, }); module.exports.UserPermissionsSchema = new Schema({ diff --git a/app/services/core-node.js b/app/services/core-node.js index 14f3777..e29b0ce 100644 --- a/app/services/core-node.js +++ b/app/services/core-node.js @@ -16,6 +16,8 @@ const CoreNodeRequest = mongoose.model('CoreNodeRequest'); const passport = require('passport'); const OAuth2Strategy = require('passport-oauth2'); +const striptags = require('striptags'); + const { SiteService, SiteError } = require('../../lib/site-lib'); class CoreAddress { @@ -382,6 +384,22 @@ class CoreNodeService extends SiteService { return cores; } + async searchUsers (search, pagination) { + const users = await CoreUser + .find(search) + .select('+flags +permissions +optIn') + .sort({ username_lc: 1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateCoreUser) + .lean(); + + return users.map((user) => { + user.type = 'CoreUser'; + return user; + }); + } + async getUserByLocalId (userId) { const user = await CoreUser .findOne({ _id: userId }) @@ -392,6 +410,42 @@ class CoreNodeService extends SiteService { return user; } + async updateUserForAdmin (user, settings) { + const NOW = new Date(); + + if (!settings.username || !settings.username.length) { + throw new SiteError(406, 'Must include username'); + } + settings.username = striptags(settings.username.trim()); + settings.username_lc = settings.username.toLowerCase(); + + await CoreUser.updateOne( + { _id: user._id }, + { + $set: { + updated: NOW, + + username: settings.username, + username_lc: settings.username_lc, + displayName: striptags(settings.displayName.trim()), + bio: striptags(settings.bio.trim()), + + 'flags.isAdmin': settings.isAdmin === 'on', + 'flags.isModerator': settings.isModerator === 'on', + 'flags.isEmailVerified': settings.isEmailVerified === 'on', + + 'permissions.canLogin': settings.canLogin === 'on', + 'permissions.canChat': settings.canChat === 'on', + 'permissions.canComment': settings.canComment === 'on', + 'permissions.canReport': settings.canReport === 'on', + + 'optIn.system': settings.optInSystem === 'on', + 'optIn.marketing': settings.optInMarketing === 'on', + }, + }, + ); + } + async updateUserSettings (user, settings) { await CoreUser.updateOne( { _id: user._id }, diff --git a/app/services/oauth2.js b/app/services/oauth2.js index f871311..c3290d0 100644 --- a/app/services/oauth2.js +++ b/app/services/oauth2.js @@ -393,6 +393,21 @@ class OAuth2Service extends SiteService { } return done(null, token.user, { scope: token.scope }); } + + /** + * Retrieves OAuth2 access/refresh tokens for a specific CoreUser. + * @param {CoreUser} user The user for which tokens are wanted. + * @param {*} type The type of token wanted (access or refresh), or don't + * specify to receive all tokens (unfiltered). + * @returns Array of tokens for the specified user, if any. + */ + async getUserTokens (user, type) { + const tokens = await OAuth2Token + .find({ user: user._id, type }) + .populate(this.populateOAuth2Token) + .lean(); + return tokens; + } } module.exports = { diff --git a/app/services/user.js b/app/services/user.js index b0d8a7c..cb854ce 100644 --- a/app/services/user.js +++ b/app/services/user.js @@ -97,6 +97,7 @@ class UserService extends SiteService { user.flags = { isAdmin: false, isModerator: false, + isEmailVerified: false, }; user.permissions = { @@ -229,12 +230,19 @@ class UserService extends SiteService { username: userDefinition.username, username_lc, displayName: userDefinition.displayName, + bio: striptags(userDefinition.bio.trim()), + 'flags.isAdmin': userDefinition.isAdmin === 'on', 'flags.isModerator': userDefinition.isModerator === 'on', + 'flags.isEmailVerified': userDefinition.isEmailVerified === 'on', + 'permissions.canLogin': userDefinition.canLogin === 'on', 'permissions.canChat': userDefinition.canChat === 'on', 'permissions.canComment': userDefinition.canComment === 'on', 'permissions.canReport': userDefinition.canReport === 'on', + + 'optIn.system': userDefinition.optInSystem === 'on', + 'optIn.marketing': userDefinition.optInMarketing === 'on', }, }, ); diff --git a/app/views/admin/components/menu.pug b/app/views/admin/components/menu.pug index 969c932..6d688da 100644 --- a/app/views/admin/components/menu.pug +++ b/app/views/admin/components/menu.pug @@ -32,6 +32,11 @@ ul.uk-nav.uk-nav-default span.nav-item-icon i.fas.fa-project-diagram span.uk-margin-small-left Core Nodes + li(class={ 'uk-active': (adminView === 'core-user') }) + a(href="/admin/core-user") + span.nav-item-icon + i.fas.fa-user-astronaut + span.uk-margin-small-left Core Users li(class={ 'uk-active': (adminView === 'core-node') }) a(href="/admin/service-node") diff --git a/app/views/admin/core-user/form.pug b/app/views/admin/core-user/form.pug new file mode 100644 index 0000000..68aed98 --- /dev/null +++ b/app/views/admin/core-user/form.pug @@ -0,0 +1,120 @@ +extends ../layouts/main +block content + + include ../../comment/components/comment-review + + div(uk-grid).uk-grid-small + div(class="uk-width-1-1 uk-width-2-3@l") + form(method="POST", action=`/admin/core-user/${userAccount._id}`).uk-form + input(type="hidden", name="username", value= userAccount.username) + input(type="hidden", name="displayName", value= userAccount.displayName) + .uk-card.uk-card-default.uk-card-small + .uk-card-header + if userAccount.displayName + .uk-text-large= userAccount.displayName + div + a(href=`/user/${userAccount._id}`) @#{userAccount.username} + + .uk-card-body + .uk-margin + label(for="bio").uk-form-label Bio + textarea(id="bio", name="bio", rows="3", placeholder="Enter profile bio").uk-textarea= userAccount.bio + + .uk-margin + label.uk-form-label Flags + div(uk-grid).uk-grid-small + label + input(id="is-admin", name="isAdmin", type="checkbox", checked= userAccount.flags.isAdmin) + | Admin + label + input(id="is-moderator", name="isModerator", type="checkbox", checked= userAccount.flags.isModerator) + | Moderator + label + input(id="is-email-verified", name="isEmailVerified", type="checkbox", checked= userAccount.flags.isEmailVerified) + | Email Verified + + .uk-margin + label.uk-form-label Permissions + .uk-margin + div(uk-grid).uk-grid-small + label + input(id="can-login", name="canLogin", type="checkbox", checked= userAccount.permissions.canLogin) + | Can Login + label + input(id="can-chat", name="canChat", type="checkbox", checked= userAccount.permissions.canChat) + | Can Chat + label + input(id="can-comment", name="canComment", type="checkbox", checked= userAccount.permissions.canComment) + | Can Comment + label + input(id="can-report", name="canReport", type="checkbox", checked= userAccount.permissions.canReport) + | Can Report + + .uk-margin + label.uk-form-label Opt-Ins + div(uk-grid).uk-grid-small + label + input(id="optin-system", name="optInSystem", type="checkbox", checked= userAccount.optIn.system) + | System + label + input(id="optin-marketing", name="optInMarketing", type="checkbox", checked= userAccount.optIn.marketing) + | Marketing + + .uk-card-footer + div(uk-grid).uk-grid-small + .uk-width-expand + +renderBackButton() + .uk-width-auto + button(type="submit").uk-button.uk-button-primary Update User + + div(class="uk-width-1-1 uk-width-1-3@l") + + .uk-margin + .uk-card.uk-card-default.uk-card-small + .uk-card-header + h4.uk-card-title + div(uk-grid).uk-grid-small + .uk-width-expand + div= userAccount.core.meta.name + .uk-width-auto + img(src="/img/icon/dtp-core.svg", alt="DTP Core Icon", style="height: 1em; width: auto;") + + .uk-card-body + .uk-margin + label.uk-form-label Description + div= userAccount.core.meta.description + + .uk-margin + div(uk-grid) + .uk-width-auto + label.uk-form-label Name + div= userAccount.core.meta.name + .uk-width-auto + label.uk-form-label Created + div= moment(userAccount.core.created).fromNow() + .uk-width-auto + label.uk-form-label Updated + div= moment(userAccount.core.updated).fromNow() + + .uk-margin + div(uk-grid) + .uk-width-auto + label.uk-form-label Domain + div= userAccount.core.meta.domain + .uk-width-auto + label.uk-form-label Domain Key + div= userAccount.core.meta.domainKey + + .uk-margin + .uk-card.uk-card-default.uk-card-small + .uk-card-header + h4.uk-card-title Recent Comments + + .uk-card-body + if Array.isArray(recentComments) && (recentComments.length > 0) + ul.uk-list.uk-list-divider + each comment in recentComments + li + +renderCommentReview(comment) + else + div #{userAccount.displayName || userAccount.uslername} has no recent comments. \ No newline at end of file diff --git a/app/views/admin/core-user/index.pug b/app/views/admin/core-user/index.pug new file mode 100644 index 0000000..3b1517f --- /dev/null +++ b/app/views/admin/core-user/index.pug @@ -0,0 +1,33 @@ +extends ../layouts/main +block content + h1 Core Users + + if Array.isArray(users) && (users.length > 0) + .uk-overflow-auto + table.uk-table.uk-table-divider.uk-table-small.uk-table-justify + thead + th Username + th Display Name + th Created + th Core + th Core Domain + th Core User ID + th User ID + tbody + each userAccount in users + tr + td + a(href=`/admin/core-user/${userAccount._id}`)= userAccount.username + td + if userAccount.displayName + a(href=`/admin/core-user/${userAccount._id}`)= userAccount.displayName + else + .uk-text-muted N/A + td= moment(userAccount.created).format('YYYY-MM-DD hh:mm a') + td= userAccount.core.meta.name + td= userAccount.core.meta.domainKey + td= userAccount.coreUserId + td= userAccount._id + + else + div There are no Core users. \ No newline at end of file diff --git a/app/views/admin/user/form.pug b/app/views/admin/user/form.pug index f853995..ff149f6 100644 --- a/app/views/admin/user/form.pug +++ b/app/views/admin/user/form.pug @@ -8,63 +8,81 @@ block content form(method="POST", action=`/admin/user/${userAccount._id}`).uk-form input(type="hidden", name="username", value= userAccount.username) input(type="hidden", name="displayName", value= userAccount.displayName) - .uk-card.uk-card-secondary.uk-card-small + .uk-card.uk-card-default.uk-card-small .uk-card-header - if userAccount.displayName - .uk-text-large= userAccount.displayName - div - a(href=`mailto:${userAccount.email}`)= userAccount.email - div - a(href=`/user/${userAccount._id}`) @#{userAccount.username} + div(uk-grid).uk-grid-small.uk-flex-middle + if userAccount.picture + .uk-width-auto + +renderProfileIcon(userAccount) + .uk-width-expand + .uk-text-large= userAccount.displayName || userAccount.username + div(uk-grid).uk-grid-small.uk-flex-between + .uk-width-auto + a(href=`mailto:${userAccount.email}`)= userAccount.email + .uk-width-auto + a(href=`/user/${userAccount._id}`) @#{userAccount.username} .uk-card-body .uk-margin - div(uk-grid) - div(class="uk-width-1-1 uk-width-1-2@m") - fieldset - legend Flags - .uk-margin - div(uk-grid).uk-grid-small - label - input(id="is-admin", name="isAdmin", type="checkbox", checked= userAccount.flags.isAdmin) - | Admin - label - input(id="is-moderator", name="isModerator", type="checkbox", checked= userAccount.flags.isModerator) - | Moderator + label(for="bio").uk-form-label Bio + textarea(id="bio", name="bio", rows="3", placeholder="Enter profile bio").uk-textarea= userAccount.bio - div(class="uk-width-1-1 uk-width-1-2@m") - fieldset - legend Permissions - .uk-margin - div(uk-grid).uk-grid-small - label - input(id="can-login", name="canLogin", type="checkbox", checked= userAccount.permissions.canLogin) - | Can Login - label - input(id="can-chat", name="canChat", type="checkbox", checked= userAccount.permissions.canChat) - | Can Chat - label - input(id="can-comment", name="canComment", type="checkbox", checked= userAccount.permissions.canComment) - | Can Comment - label - input(id="can-report", name="canReport", type="checkbox", checked= userAccount.permissions.canReport) - | Can Report - label - input(id="can-author-pages", name="canAuthorPages", type="checkbox", checked= userAccount.permissions.canAuthorPages) - | Can Author Pages - label - input(id="can-author-posts", name="canAuthorPosts", type="checkbox", checked= userAccount.permissions.canAuthorPosts) - | Can Author Posts + .uk-margin + label.uk-form-label Flags + div(uk-grid).uk-grid-small + label + input(id="is-admin", name="isAdmin", type="checkbox", checked= userAccount.flags.isAdmin) + | Admin + label + input(id="is-moderator", name="isModerator", type="checkbox", checked= userAccount.flags.isModerator) + | Moderator + label + input(id="is-email-verified", name="isEmailVerified", type="checkbox", checked= userAccount.flags.isEmailVerified) + | Email Verified + + .uk-margin + label.uk-form-label Permissions + div(uk-grid).uk-grid-small + label + input(id="can-login", name="canLogin", type="checkbox", checked= userAccount.permissions.canLogin) + | Can Login + label + input(id="can-chat", name="canChat", type="checkbox", checked= userAccount.permissions.canChat) + | Can Chat + label + input(id="can-comment", name="canComment", type="checkbox", checked= userAccount.permissions.canComment) + | Can Comment + label + input(id="can-report", name="canReport", type="checkbox", checked= userAccount.permissions.canReport) + | Can Report + + .uk-margin + label.uk-form-label Opt-Ins + div(uk-grid).uk-grid-small + label + input(id="optin-system", name="optInSystem", type="checkbox", checked= userAccount.optIn.system) + | System + label + input(id="optin-marketing", name="optInMarketing", type="checkbox", checked= userAccount.optIn.marketing) + | Marketing - button(type="submit").uk-button.dtp-button-primary.uk-display-block.uk-width-1-1 Update User + .uk-card-footer + div(uk-grid).uk-grid-small + .uk-width-expand + +renderBackButton() + .uk-width-auto + button(type="submit").uk-button.uk-button-primary Update User div(class="uk-width-1-1 uk-width-1-3@l") - .uk-card.uk-card-secondary.uk-card-small + .uk-card.uk-card-default.uk-card-small .uk-card-header - h4.uk-card-title #{userAccount.displayName || userAccount.username}'s Comments + h4.uk-card-title Recent Comments .uk-card-body - ul.uk-list.uk-list-divider - each comment in recentComments - li - +renderCommentReview(comment) \ No newline at end of file + if Array.isArray(recentComments) && (recentComments.length > 0) + ul.uk-list.uk-list-divider + each comment in recentComments + li + +renderCommentReview(comment) + else + div #{userAccount.displayName || userAccount.username} has no recent comments. \ No newline at end of file