From 38b39b600cbfb7a3168b5b9cce0325b0fe3bc789 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Tue, 3 May 2022 21:40:56 +0200 Subject: [PATCH] Adminpanel and revamped permissions (#13843) * create Admin Panel page with initial content from Hall's admin section * reorganise Admin Panel form and add more accordians * add lastCron to fields returned by api.getHeroes * improve timestamps and authentication section * add party and quest info to Admin Panel, add party to heroAdminFields * move Admin Panel menu item to top of menu, make invisible to non-admins * remove code used for displaying all Heroes * add avatar appearance and drops section in Admin Panel * allow logged-in user to be the default hero loaded * add time zones to timestamp/authentication section * rename Items to Update Items This will allow a new Items section to be added. * add read-only Items display with button to copy data to Update Items section * remove never-used allItemsPaths code that had been copied from Hall * update tests for the attributes added to heroAdminFields * supply names for items and also set information for gear/equipment * remove code that loads subsections of content We use enough of the content that it's easier to load it all and access it through the content object, especially when we're looping through different item types. * add gear names and set details to Avatar Costume/Battle Gear section * make the wiki URLs clickable and make minor item format improvements * add gear sets for Check-In Incentives and animal ears and tails * add gear set for Gold-Purchasable Quest Lines Also merges the existing Mystery of the Masterclassers quest set into it. * fix error with Kickstarter gear set and include wiki link * improve description of check-in incentive gear set * fix description of Items section * fix lint warnings * update another test for the attributes added to heroAdminFields * allow "@" to be included when specifying Username to load * create GetHeroParty API v3 route to fetch a given user's party data Only some data from the party will be loaded (e.g., not private data such as name, description). Includes tests for the route. See the next commit for front-end changes that use this. * display data from a given user's party in admin panel Only some data from the party will be loaded (e.g., not private data such as name, description). Also adds support for finding and displaying errors from the user's data. * use new error handling method for other sections - Time zone differences - Cron bugs - Privilege removal (mute/block) - not a bug but needs to be highlighted * redirect non-admin users away from admin-only page (WIP) This needs more work. Currently, admin users are also redirected if they access the page by direct URL or after reload. * clarify source of items from Check-In Incentives and Lunar Battle quests * replace non-standard form fields with HTML forms * add user's language, remove unused export blocks * convert functions to filters: formatDate, formatTimeZone * improve display of minutes portion of time zone in Admin Panel * move basic details about user to a new component * move Timestamp/Cron/Auth/etc details to a new component - WIP, has errors The automatic expand and error warnings don't reset themselves when you fetch data for a new user. * replace non-standard form fields with HTML forms Most of this was done in 26fdcbbee51937d39a149a20bf7ccd1cf674ca18 * move Timestamp/Cron/Auth/etc details to a new component (fixed) * move Avatar and Drops section to a new component * move Party and Quest section to a new component * move Contributor Details to new component, add checkbox for admin, add preview This adds a markdown-enabled preview of the Contributions textarea. It also removes the code that automatically set contributor.admin to true when the Tier was above 7. That feature wasn't secure because the Tier can be accidentally changed if you scroll while the cursor is over the Tier form field (we accidentally demoted a Socialite once by doing that and if we'd scrolled in the other direction we would have given her admin privileges). Instead there's now a checkbox for giving moderator-level privileges. We'll want that anyway when we move to a system of selected privileges for each admin instead of all admin privileges being given to all mods/staff. There's also a commented-out checkbox for giving Bailey CMS privileges, for when we're ready to use that. The User model doesn't yet have support for it. * move Privileges and Gems section to a new component * rename formatItems to getItemDescription; make other minor fixes * remove an outdated test description This "pended" explanation probably wasn't needed after "x" was removed from "describe" in 2ab76db27cbc2ea6c8e65a407a3fcc51fb015687 * add newsPoster Bailey CMS permission to User model and Admin Panel * move formatDate from mixins to filters * make lint fixes * remove development comments from hall.js I'll be handling the TODO comment and I've left in my "XXX" marker to remind me * fix bug in Hall's castItemVal: mounts are null not false * move Items section to a new component and delete Update Items section The Update Items section is no longer needed because the new Items component has in-place editing. * remove unused imports * add "secret" field to "Privileges, Gem Balance" section. Also move the markdownPreview style from contributorDetails.vue to index.vue since it's used in two components now. * show non-Standard never-owned Pets and Mounts in Items section * redirect non-admin users away from admin-only page This completes the work started in commit a4f9c754ad3886466925de4d8ed9a5ac55d2f999 It now allows admins to access the page when coming from another page on the site or from a direct link, including if the admin user isn't logged in yet. * display memberCount for party * add secret.text field to Contributor Details This is in addition to showing it in the Privileges section because the secret text could be about either troublesome behaviour or contributions. * allow user to be loaded into Admin Panel via a URL This includes: - router config has a child route for the admin panel with a Username/ID as a parameter - loadHero code moved from top-level index page into a new "user support" index page - links in the Hall changed to point to admin panel route - admin panel link added to admin section of user profile modal * keep list of known titles on their own lines * sort heroFields alphabetically No actual changes. * return all flags for use in Admin Panel and fix Hall tests for flags Future Admin Panel changes will display more flags. NB 'flags' wasn't in the tests before, even though two optional flags were being fetched. The tests weren't failing because the test users hadn't been given data for those optional flags. The primary reason for this change now is to fix the tests. * show part of the API Token in the Admin Panel * send full hero object into cronAndAuth.vue This is a prelude to allowing this component to change the hero. * split heroAdminFields string into two: one for fetching data and one for showing it This is because apiToken must be fetched but not shown, while apiTokenObscured is calculated (not fetched) and shown. * let admin change a user's API Token * restore sanity * remove code to show obscured version of API Token It will return with tighter permissions for viewing it. * add Custom Day Start time (CDS) to Timestamps, Time Zone... section * commit lint's automatic fixes - one for admin-panel changes in hall.js The other fixes aren't related to this PR but I figured they may as well go live. * apply fixes from paglias's comments, excluding style/CSS changesd The comments that this PR fixes start at https://github.com/HabitRPG/habitica/pull/12035#pullrequestreview-500422316 Style fixes will be in a future commit. * fix styles/CSS * allow profile modal to close when using admin panel link Also removes an empty components block. * prevent Admin Panel being used without new userSupport privilege Also adds initial support for other contributor.priv privileges and changes Debug Menu to add userSupport privilege * don't do this: this.hero = { ...hero }; * enhance quest error messages * redirect to admin-panel home page when using "Save and Clear Data" The user's ID / name is still in the form for easy refetching. * create ensurePriv function, use in api.getHeroParty * fix lint problems and integration tests * add page title to top-level Admin Panel Also add more details to a router comment (consistent with a similar comment) in case it helps anyone. * fix tests * display Moderation Notes above Contributions * lint fix * remove placeholder code for new privileges I had planned to have each of these implemented in stages, but paglias wanted it all done at once. I'm afraid that's too big a project for me to take on in a single PR so I'm cancelling the plans for adjusting the privileges. * Improve permission handling * Don't report timezone error on first day * fix lint error * . * Fix lint error * fix failing tests * Fix more tests * . * .. * ... * fix(admin): always include permissions when querying user also remove unnecessary failing test case * permission improvements * show transactions in admin panel * fix lint errors * fix permission check * fix(panel): missing mixin, handle empty perms object Co-authored-by: Alys Co-authored-by: SabreCat --- test/api/unit/libs/items/utils.test.js | 23 +- .../middlewares/ensureAccessRight.test.js | 43 ++- test/api/unit/models/group.test.js | 6 +- test/api/unit/models/user.test.js | 2 +- .../GET-challenges_group_groupid.test.js | 2 +- .../challenges/GET-challenges_user.test.js | 2 +- .../challenges/POST-challenges.test.js | 4 +- .../integration/chat/DELETE-chat_id.test.js | 2 +- .../integration/chat/POST-chat.flag.test.js | 2 +- ...POST-groups_id_chat_id_clear_flags.test.js | 2 +- .../integration/coupons/GET-coupons.test.js | 6 +- .../coupons/POST-coupons_enter_code.test.js | 2 +- .../POST-coupons_generate_event.test.js | 10 +- .../POST-coupons_validate_code.test.js | 2 +- .../debug/POST-debug_make-admin.test.js | 6 +- .../integration/groups/GET-groups_id.test.js | 2 +- .../POST-groups_id_removeMember.test.js | 2 +- .../v3/integration/groups/PUT-groups.test.js | 7 +- .../hall/GET-hall_heroes_heroId.test.js | 19 +- .../GET-hall_heroes_party_groupId.test.js | 61 ++++ .../hall/PUT-hall_heores_heroId.test.js | 49 ++- .../members/POST-send_private_message.test.js | 4 +- .../v3/integration/tasks/GET-tasks_id.test.js | 2 +- .../POST-tasks_challenge_id.test.js | 2 +- .../integration/user/POST-user_reset.test.js | 2 +- test/api/v3/integration/user/PUT-user.test.js | 1 + .../v4/coupon/POST-coupons_enter_code.test.js | 2 +- .../v4/members/GET-purchase_history.test.js | 4 +- test/api/v4/news/DELETE-news.test.js | 6 +- test/api/v4/news/GET-news.test.js | 2 +- test/api/v4/news/GET-news_id.test.js | 2 +- test/api/v4/news/POST-news.test.js | 6 +- test/api/v4/news/PUT-news_newsId.test.js | 6 +- test/api/v4/user/POST-user_reset.test.js | 2 +- test/api/v4/user/PUT-user.test.js | 1 + .../admin-panel/filters/formatDate.js | 7 + .../src/components/admin-panel/index.vue | 81 +++++ .../admin-panel/mixins/getItemDescription.js | 132 ++++++++ .../components/admin-panel/mixins/saveHero.js | 20 ++ .../user-support/avatarAndDrops.vue | 68 ++++ .../admin-panel/user-support/basicDetails.vue | 34 ++ .../user-support/contributorDetails.vue | 206 ++++++++++++ .../admin-panel/user-support/cronAndAuth.vue | 223 ++++++++++++ .../admin-panel/user-support/index.vue | 185 ++++++++++ .../admin-panel/user-support/itemsOwned.vue | 289 ++++++++++++++++ .../user-support/partyAndQuest.vue | 317 ++++++++++++++++++ .../user-support/privilegesAndGems.vue | 143 ++++++++ .../admin-panel/user-support/transactions.vue | 52 +++ website/client/src/components/appFooter.vue | 2 +- .../components/challenges/challengeDetail.vue | 7 +- .../components/challenges/challengeModal.vue | 6 +- .../client/src/components/chat/chatCard.vue | 10 +- .../src/components/chat/chatMessages.vue | 6 +- .../src/components/chat/reportFlagModal.vue | 9 +- .../client/src/components/groups/group.vue | 10 +- .../src/components/groups/groupFormModal.vue | 8 +- .../src/components/groups/membersModal.vue | 6 +- website/client/src/components/hall/heroes.vue | 35 +- .../client/src/components/hall/patrons.vue | 11 +- website/client/src/components/header/menu.vue | 8 + .../client/src/components/settings/index.vue | 5 +- .../src/components/settings/promoCode.vue | 2 +- .../src/components/userMenu/profile.vue | 13 +- website/client/src/mixins/userState.js | 21 +- website/client/src/pages/private-messages.vue | 2 +- website/client/src/router/index.js | 49 ++- website/client/src/store/actions/hall.js | 6 + website/client/src/store/getters/tasks.js | 4 +- .../store/getters/tasks/canDelete.spec.js | 2 +- .../unit/store/getters/tasks/canEdit.spec.js | 2 +- website/common/locales/en/contrib.json | 1 + .../common/script/errors/apiErrorMessages.js | 3 +- website/server/api-doc.js | 13 + website/server/controllers/api-v3/chat.js | 6 +- website/server/controllers/api-v3/coupon.js | 6 +- website/server/controllers/api-v3/debug.js | 4 +- website/server/controllers/api-v3/groups.js | 12 +- website/server/controllers/api-v3/hall.js | 86 ++++- website/server/controllers/api-v3/members.js | 2 +- website/server/controllers/api-v3/user.js | 1 + website/server/controllers/api-v4/members.js | 4 +- website/server/controllers/api-v4/news.js | 8 +- website/server/libs/challenges/index.js | 2 +- .../libs/chatReporting/groupChatReporter.js | 6 +- .../libs/chatReporting/inboxChatReporter.js | 4 +- .../server/libs/inbox/conversation.methods.js | 2 +- website/server/libs/items/utils.js | 20 +- website/server/middlewares/auth.js | 2 +- .../server/middlewares/ensureAccessRight.js | 39 +-- website/server/models/challenge.js | 2 +- website/server/models/group.js | 2 +- website/server/models/user/index.js | 2 +- website/server/models/user/methods.js | 6 +- website/server/models/user/schema.js | 11 +- 94 files changed, 2252 insertions(+), 269 deletions(-) create mode 100644 test/api/v3/integration/hall/GET-hall_heroes_party_groupId.test.js create mode 100644 website/client/src/components/admin-panel/filters/formatDate.js create mode 100644 website/client/src/components/admin-panel/index.vue create mode 100644 website/client/src/components/admin-panel/mixins/getItemDescription.js create mode 100644 website/client/src/components/admin-panel/mixins/saveHero.js create mode 100644 website/client/src/components/admin-panel/user-support/avatarAndDrops.vue create mode 100644 website/client/src/components/admin-panel/user-support/basicDetails.vue create mode 100644 website/client/src/components/admin-panel/user-support/contributorDetails.vue create mode 100644 website/client/src/components/admin-panel/user-support/cronAndAuth.vue create mode 100644 website/client/src/components/admin-panel/user-support/index.vue create mode 100644 website/client/src/components/admin-panel/user-support/itemsOwned.vue create mode 100644 website/client/src/components/admin-panel/user-support/partyAndQuest.vue create mode 100644 website/client/src/components/admin-panel/user-support/privilegesAndGems.vue create mode 100644 website/client/src/components/admin-panel/user-support/transactions.vue diff --git a/test/api/unit/libs/items/utils.test.js b/test/api/unit/libs/items/utils.test.js index 6a0e7ce34d..d1bf1f126e 100644 --- a/test/api/unit/libs/items/utils.test.js +++ b/test/api/unit/libs/items/utils.test.js @@ -99,23 +99,26 @@ describe('Items Utils', () => { expect(castItemVal('items.food.Cake_Invalid', '5')).to.equal(5); }); - it('converts values for mounts paths to numbers', () => { - expect(castItemVal('items.mounts.Cactus-Base', 'true')).to.equal(true); - expect(castItemVal('items.mounts.Aether-Invisible', 'false')).to.equal(false); - expect(castItemVal('items.mounts.Aether-Invalid', 'true')).to.equal(true); - expect(castItemVal('items.mounts.Aether-Invalid', 'truish')).to.equal(true); - expect(castItemVal('items.mounts.Aether-Invalid', 0)).to.equal(false); - }); - it('converts values for quests paths to numbers', () => { expect(castItemVal('items.quests.atom3', '5')).to.equal(5); expect(castItemVal('items.quests.invalid', '5')).to.equal(5); }); - it('converts values for owned gear', () => { + it('converts values for mounts paths to true/null', () => { + // mounts are never false but can be null (function contains more details) + expect(castItemVal('items.mounts.Cactus-Base', 'true')).to.equal(true); + expect(castItemVal('items.mounts.Aether-Invisible', 'null')).to.equal(null); + expect(castItemVal('items.mounts.Aether-Invisible', 'false')).to.equal(null); + expect(castItemVal('items.mounts.Aether-Invalid', 'true')).to.equal(true); + expect(castItemVal('items.mounts.Aether-Invalid', 'truthy')).to.equal(true); + expect(castItemVal('items.mounts.Aether-Invalid', 0)).to.equal(null); + }); + + it('converts values for owned gear to true/false', () => { expect(castItemVal('items.gear.owned.shield_warrior_0', 'true')).to.equal(true); expect(castItemVal('items.gear.owned.invalid', 'false')).to.equal(false); - expect(castItemVal('items.gear.owned.invalid', 'thruthy')).to.equal(true); + expect(castItemVal('items.gear.owned.invalid', 'null')).to.equal(false); + expect(castItemVal('items.gear.owned.invalid', 'truthy')).to.equal(true); expect(castItemVal('items.gear.owned.invalid', 0)).to.equal(false); }); }); diff --git a/test/api/unit/middlewares/ensureAccessRight.test.js b/test/api/unit/middlewares/ensureAccessRight.test.js index d163ae704d..d87cb6d035 100644 --- a/test/api/unit/middlewares/ensureAccessRight.test.js +++ b/test/api/unit/middlewares/ensureAccessRight.test.js @@ -4,8 +4,7 @@ import { generateReq, generateNext, } from '../../../helpers/api-unit.helper'; -import i18n from '../../../../website/common/script/i18n'; -import { ensureAdmin, ensureSudo, ensureNewsPoster } from '../../../../website/server/middlewares/ensureAccessRight'; +import { ensurePermission } from '../../../../website/server/middlewares/ensureAccessRight'; import { NotAuthorized } from '../../../../website/server/libs/errors'; import apiError from '../../../../website/server/libs/apiError'; @@ -20,20 +19,20 @@ describe('ensure access middlewares', () => { }); context('ensure admin', () => { - it('returns not authorized when user is not an admin', () => { - res.locals = { user: { contributor: { admin: false } } }; + it('returns not authorized when user is not in userSupport', () => { + res.locals = { user: { permissions: { userSupport: false } } }; - ensureAdmin(req, res, next); + ensurePermission('userSupport')(req, res, next); const calledWith = next.getCall(0).args; - expect(calledWith[0].message).to.equal(i18n.t('noAdminAccess')); + expect(calledWith[0].message).to.equal(apiError('noPrivAccess')); expect(calledWith[0] instanceof NotAuthorized).to.equal(true); }); - it('passes when user is an admin', () => { - res.locals = { user: { contributor: { admin: true } } }; + it('passes when user is an userSuppor', () => { + res.locals = { user: { permissions: { userSupport: true } } }; - ensureAdmin(req, res, next); + ensurePermission('userSupport')(req, res, next); expect(next).to.be.calledOnce; expect(next.args[0]).to.be.empty; @@ -42,40 +41,40 @@ describe('ensure access middlewares', () => { context('ensure newsPoster', () => { it('returns not authorized when user is not a newsPoster', () => { - res.locals = { user: { contributor: { newsPoster: false } } }; + res.locals = { user: { permissions: { news: false } } }; - ensureNewsPoster(req, res, next); + ensurePermission('news')(req, res, next); const calledWith = next.getCall(0).args; - expect(calledWith[0].message).to.equal(apiError('noNewsPosterAccess')); + expect(calledWith[0].message).to.equal(apiError('noPrivAccess')); expect(calledWith[0] instanceof NotAuthorized).to.equal(true); }); it('passes when user is a newsPoster', () => { - res.locals = { user: { contributor: { newsPoster: true } } }; + res.locals = { user: { permissions: { news: true } } }; - ensureNewsPoster(req, res, next); + ensurePermission('news')(req, res, next); expect(next).to.be.calledOnce; expect(next.args[0]).to.be.empty; }); }); - context('ensure sudo', () => { - it('returns not authorized when user is not a sudo user', () => { - res.locals = { user: { contributor: { sudo: false } } }; + context('ensure coupons', () => { + it('returns not authorized when user does not have access to coupon calls', () => { + res.locals = { user: { permissions: { coupons: false } } }; - ensureSudo(req, res, next); + ensurePermission('coupons')(req, res, next); const calledWith = next.getCall(0).args; - expect(calledWith[0].message).to.equal(apiError('noSudoAccess')); + expect(calledWith[0].message).to.equal(apiError('noPrivAccess')); expect(calledWith[0] instanceof NotAuthorized).to.equal(true); }); - it('passes when user is a sudo user', () => { - res.locals = { user: { contributor: { sudo: true } } }; + it('passes when user has access to coupon calls', () => { + res.locals = { user: { permissions: { coupons: true } } }; - ensureSudo(req, res, next); + ensurePermission('coupons')(req, res, next); expect(next).to.be.calledOnce; expect(next.args[0]).to.be.empty; diff --git a/test/api/unit/models/group.test.js b/test/api/unit/models/group.test.js index 59efba1134..fd5cea9b05 100644 --- a/test/api/unit/models/group.test.js +++ b/test/api/unit/models/group.test.js @@ -1029,7 +1029,7 @@ describe('Group Model', () => { expect(toJSON.chat.length).to.equal(1); }); - it('shows messages with >= 2 flag to admins', async () => { + it('shows messages with >= 2 flag to moderators', async () => { party.chat = [{ flagCount: 3, info: { @@ -1037,12 +1037,12 @@ describe('Group Model', () => { quest: 'basilist', }, }]; - const admin = new User({ 'contributor.admin': true }); + const admin = new User({ 'permissions.moderator': true }); const toJSON = await Group.toJSONCleanChat(party, admin); expect(toJSON.chat.length).to.equal(1); }); - it('doesn\'t show flagged messages to non-admins', async () => { + it('doesn\'t show flagged messages to non-moderators', async () => { party.chat = [{ flagCount: 3, info: { diff --git a/test/api/unit/models/user.test.js b/test/api/unit/models/user.test.js index d559ba0025..06307e39a3 100644 --- a/test/api/unit/models/user.test.js +++ b/test/api/unit/models/user.test.js @@ -877,7 +877,7 @@ describe('User Model', () => { expect(user.isNewsPoster()).to.equal(false); - user.contributor.newsPoster = true; + user.permissions = { news: true }; expect(user.isNewsPoster()).to.equal(true); }); diff --git a/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js index 8e4fc6a432..fc26d4ce0d 100644 --- a/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js @@ -202,7 +202,7 @@ describe('GET challenges/groups/:groupId', () => { publicGuild = group; await user.update({ - 'contributor.admin': true, + 'permissions.challengeAdmin': true, }); officialChallenge = await generateChallenge(user, group, { diff --git a/test/api/v3/integration/challenges/GET-challenges_user.test.js b/test/api/v3/integration/challenges/GET-challenges_user.test.js index 0ff9385891..cc9c3aabd7 100644 --- a/test/api/v3/integration/challenges/GET-challenges_user.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_user.test.js @@ -231,7 +231,7 @@ describe('GET challenges/user', () => { publicGuild = group; await user.update({ - 'contributor.admin': true, + 'permissions.challengeAdmin': true, }); officialChallenge = await generateChallenge(user, group, { diff --git a/test/api/v3/integration/challenges/POST-challenges.test.js b/test/api/v3/integration/challenges/POST-challenges.test.js index 8896c3e79d..e1b3dc63a0 100644 --- a/test/api/v3/integration/challenges/POST-challenges.test.js +++ b/test/api/v3/integration/challenges/POST-challenges.test.js @@ -203,8 +203,8 @@ describe('POST /challenges', () => { it('sets challenge as official if created by admin and official flag is set', async () => { await groupLeader.update({ - contributor: { - admin: true, + permissions: { + challengeAdmin: true, }, }); diff --git a/test/api/v3/integration/chat/DELETE-chat_id.test.js b/test/api/v3/integration/chat/DELETE-chat_id.test.js index 58203ea94d..0d53dfa999 100644 --- a/test/api/v3/integration/chat/DELETE-chat_id.test.js +++ b/test/api/v3/integration/chat/DELETE-chat_id.test.js @@ -22,7 +22,7 @@ describe('DELETE /groups/:groupId/chat/:chatId', () => { message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message' }); message = message.message; userThatDidNotCreateChat = await generateUser(); - admin = await generateUser({ 'contributor.admin': true }); + admin = await generateUser({ 'permissions.moderator': true }); }); context('Chat errors', () => { diff --git a/test/api/v3/integration/chat/POST-chat.flag.test.js b/test/api/v3/integration/chat/POST-chat.flag.test.js index 50f47b3572..acff8b507b 100644 --- a/test/api/v3/integration/chat/POST-chat.flag.test.js +++ b/test/api/v3/integration/chat/POST-chat.flag.test.js @@ -17,7 +17,7 @@ describe('POST /chat/:chatId/flag', () => { beforeEach(async () => { user = await generateUser({ balance: 1, 'auth.timestamps.created': moment().subtract(USER_AGE_FOR_FLAGGING + 1, 'days').toDate() }); - admin = await generateUser({ balance: 1, 'contributor.admin': true }); + admin = await generateUser({ balance: 1, 'permissions.moderator': true }); anotherUser = await generateUser({ 'auth.timestamps.created': moment().subtract(USER_AGE_FOR_FLAGGING + 1, 'days').toDate() }); newUser = await generateUser({ 'auth.timestamps.created': moment().subtract(1, 'days').toDate() }); sandbox.stub(IncomingWebhook.prototype, 'send').returns(Promise.resolve()); diff --git a/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js b/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js index 9bbc122244..e9f77e5561 100644 --- a/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js +++ b/test/api/v3/integration/chat/POST-groups_id_chat_id_clear_flags.test.js @@ -23,7 +23,7 @@ describe('POST /groups/:id/chat/:id/clearflags', () => { groupWithChat = group; author = groupLeader; nonAdmin = await generateUser({ 'auth.timestamps.created': moment().subtract(USER_AGE_FOR_FLAGGING + 1, 'days').toDate() }); - admin = await generateUser({ 'contributor.admin': true }); + admin = await generateUser({ 'permissions.moderator': true }); message = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message' }); message = message.message; diff --git a/test/api/v3/integration/coupons/GET-coupons.test.js b/test/api/v3/integration/coupons/GET-coupons.test.js index 24cc230e49..0839f22dba 100644 --- a/test/api/v3/integration/coupons/GET-coupons.test.js +++ b/test/api/v3/integration/coupons/GET-coupons.test.js @@ -14,18 +14,18 @@ describe('GET /coupons/', () => { user = await generateUser(); }); - it('returns an error if user has no sudo permission', async () => { + it('returns an error if user has no coupons permission', async () => { await user.get('/user'); // needed so the request after this will authenticate with the correct cookie session await expect(user.get('/coupons')).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: apiError('noSudoAccess'), + message: apiError('noPrivAccess'), }); }); it('should return the coupons in CSV format ordered by creation date', async () => { await user.update({ - 'contributor.sudo': true, + 'permissions.coupons': true, }); const coupons = await user.post('/coupons/generate/wondercon?count=11'); diff --git a/test/api/v3/integration/coupons/POST-coupons_enter_code.test.js b/test/api/v3/integration/coupons/POST-coupons_enter_code.test.js index d2b0fdec44..31830a0689 100644 --- a/test/api/v3/integration/coupons/POST-coupons_enter_code.test.js +++ b/test/api/v3/integration/coupons/POST-coupons_enter_code.test.js @@ -15,7 +15,7 @@ describe('POST /coupons/enter/:code', () => { beforeEach(async () => { user = await generateUser(); sudoUser = await generateUser({ - 'contributor.sudo': true, + 'permissions.coupons': true, }); }); diff --git a/test/api/v3/integration/coupons/POST-coupons_generate_event.test.js b/test/api/v3/integration/coupons/POST-coupons_generate_event.test.js index 591cf1a567..f0c5bfe436 100644 --- a/test/api/v3/integration/coupons/POST-coupons_generate_event.test.js +++ b/test/api/v3/integration/coupons/POST-coupons_generate_event.test.js @@ -14,19 +14,19 @@ describe('POST /coupons/generate/:event', () => { beforeEach(async () => { user = await generateUser({ - 'contributor.sudo': true, + 'permissions.coupons': true, }); }); - it('returns an error if user has no sudo permission', async () => { + it('returns an error if user has no coupons permission', async () => { await user.update({ - 'contributor.sudo': false, + 'permissions.coupons': false, }); await expect(user.post('/coupons/generate/aaa')).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: apiError('noSudoAccess'), + message: apiError('noPrivAccess'), }); }); @@ -48,7 +48,7 @@ describe('POST /coupons/generate/:event', () => { it('should generate coupons', async () => { await user.update({ - 'contributor.sudo': true, + 'permissions.coupons': true, }); const coupons = await user.post('/coupons/generate/wondercon?count=2'); diff --git a/test/api/v3/integration/coupons/POST-coupons_validate_code.test.js b/test/api/v3/integration/coupons/POST-coupons_validate_code.test.js index 5d6bef2b91..a206d245c7 100644 --- a/test/api/v3/integration/coupons/POST-coupons_validate_code.test.js +++ b/test/api/v3/integration/coupons/POST-coupons_validate_code.test.js @@ -21,7 +21,7 @@ describe('POST /coupons/validate/:code', () => { it('returns true if coupon code is valid', async () => { const sudoUser = await generateUser({ - 'contributor.sudo': true, + 'permissions.coupons': true, }); const [coupon] = await sudoUser.post('/coupons/generate/wondercon?count=1'); diff --git a/test/api/v3/integration/debug/POST-debug_make-admin.test.js b/test/api/v3/integration/debug/POST-debug_make-admin.test.js index 311d477994..c03fa4627f 100644 --- a/test/api/v3/integration/debug/POST-debug_make-admin.test.js +++ b/test/api/v3/integration/debug/POST-debug_make-admin.test.js @@ -3,7 +3,7 @@ import { generateUser, } from '../../../../helpers/api-integration/v3'; -describe('POST /debug/make-admin (pended for v3 prod testing)', () => { +describe('POST /debug/make-admin', () => { let user; before(async () => { @@ -14,12 +14,12 @@ describe('POST /debug/make-admin (pended for v3 prod testing)', () => { nconf.set('IS_PROD', false); }); - it('makes user an admine', async () => { + it('makes user an admin', async () => { await user.post('/debug/make-admin'); await user.sync(); - expect(user.contributor.admin).to.eql(true); + expect(user.permissions.fullAccess).to.eql(true); }); it('returns error when not in production mode', async () => { diff --git a/test/api/v3/integration/groups/GET-groups_id.test.js b/test/api/v3/integration/groups/GET-groups_id.test.js index 801ee3d434..7d1657db18 100644 --- a/test/api/v3/integration/groups/GET-groups_id.test.js +++ b/test/api/v3/integration/groups/GET-groups_id.test.js @@ -315,7 +315,7 @@ describe('GET /groups/:id', () => { beforeEach(async () => { admin = await generateUser({ - 'contributor.admin': true, + 'permissions.moderator': true, }); }); diff --git a/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js index d91267ed2b..7b7fc0e56e 100644 --- a/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js +++ b/test/api/v3/integration/groups/POST-groups_id_removeMember.test.js @@ -32,7 +32,7 @@ describe('POST /groups/:groupId/removeMember/:memberId', () => { invitedUser = invitees[0]; // eslint-disable-line prefer-destructuring member = members[0]; // eslint-disable-line prefer-destructuring member2 = members[1]; // eslint-disable-line prefer-destructuring - adminUser = await generateUser({ 'contributor.admin': true }); + adminUser = await generateUser({ 'permissions.moderator': true }); }); context('All Groups', () => { diff --git a/test/api/v3/integration/groups/PUT-groups.test.js b/test/api/v3/integration/groups/PUT-groups.test.js index 61bb6370bc..1ff67958da 100644 --- a/test/api/v3/integration/groups/PUT-groups.test.js +++ b/test/api/v3/integration/groups/PUT-groups.test.js @@ -20,7 +20,7 @@ describe('PUT /group', () => { }, members: 1, }); - adminUser = await generateUser({ 'contributor.admin': true }); + adminUser = await generateUser({ 'permissions.moderator': true }); groupToUpdate = group; leader = groupLeader; nonLeader = members[0]; // eslint-disable-line prefer-destructuring @@ -104,11 +104,11 @@ describe('PUT /group', () => { // Update the bannedWordsAllowed property for the group const response = await groupLeader.put(`/groups/${group._id}`, updateGroupDetails); - expect(groupLeader.contributor.admin).to.eql(true); + expect(groupLeader.permissions.fullAccess).to.eql(true); expect(response.bannedWordsAllowed).to.eql(true); }); - it('does not allow for a non-admin to update the bannedWordsAllow property for an existing guild', async () => { + it('does not allow for a non-moderator to update the bannedWordsAllow property for an existing guild', async () => { const { group, groupLeader } = await createAndPopulateGroup({ groupDetails: { name: 'public guild', @@ -128,7 +128,6 @@ describe('PUT /group', () => { // Update the bannedWordsAllowed property for the group const response = await groupLeader.put(`/groups/${group._id}`, updateGroupDetails); - expect(groupLeader.contributor.admin).to.eql(undefined); expect(response.bannedWordsAllowed).to.eql(undefined); }); }); diff --git a/test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js b/test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js index bcafdcfd80..107f729a7d 100644 --- a/test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js +++ b/test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js @@ -7,9 +7,14 @@ import { describe('GET /heroes/:heroId', () => { let user; + const heroFields = [ + '_id', 'id', 'auth', 'balance', 'contributor', 'flags', 'items', + 'lastCron', 'party', 'preferences', 'profile', 'purchased', 'secret', + ]; + before(async () => { user = await generateUser({ - contributor: { admin: true }, + permissions: { userSupport: true }, }); }); @@ -19,7 +24,7 @@ describe('GET /heroes/:heroId', () => { await expect(nonAdmin.get(`/hall/heroes/${user._id}`)).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('noAdminAccess'), + message: t('noPrivAccess'), }); }); @@ -49,10 +54,7 @@ describe('GET /heroes/:heroId', () => { }); const heroRes = await user.get(`/hall/heroes/${hero._id}`); - expect(heroRes).to.have.all.keys([ // works as: object has all and only these keys - '_id', 'id', 'balance', 'profile', 'purchased', - 'contributor', 'auth', 'items', 'secret', - ]); + expect(heroRes).to.have.all.keys(heroFields); // works as: object has all and only these keys expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); expect(heroRes.profile).to.have.all.keys(['name']); expect(heroRes.secret.text).to.be.eq('Super Hero'); @@ -64,10 +66,7 @@ describe('GET /heroes/:heroId', () => { }); const heroRes = await user.get(`/hall/heroes/${hero.auth.local.username}`); - expect(heroRes).to.have.all.keys([ // works as: object has all and only these keys - '_id', 'id', 'balance', 'profile', 'purchased', - 'contributor', 'auth', 'items', 'secret', - ]); + expect(heroRes).to.have.all.keys(heroFields); expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); expect(heroRes.profile).to.have.all.keys(['name']); }); diff --git a/test/api/v3/integration/hall/GET-hall_heroes_party_groupId.test.js b/test/api/v3/integration/hall/GET-hall_heroes_party_groupId.test.js new file mode 100644 index 0000000000..ea61800d0c --- /dev/null +++ b/test/api/v3/integration/hall/GET-hall_heroes_party_groupId.test.js @@ -0,0 +1,61 @@ +import { v4 as generateUUID } from 'uuid'; +import { + generateUser, + generateGroup, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import apiError from '../../../../../website/server/libs/apiError'; + +describe('GET /heroes/party/:groupId', () => { + let user; // admin user + + before(async () => { + user = await generateUser({ + 'permissions.userSupport': true, + }); + }); + + it('requires the caller to be an admin', async () => { + const nonAdmin = await generateUser(); + const party = await generateGroup(nonAdmin, { type: 'party', privacy: 'private' }); + await expect(nonAdmin.get(`/hall/heroes/party/${party._id}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: apiError('noPrivAccess'), + }); + }); + + it('validates req.params.groupId', async () => { + await expect(user.get('/hall/heroes/party/invalidUUID')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('handles non-existing party', async () => { + const dummyId = generateUUID(); + await expect(user.get(`/hall/heroes/party/${dummyId}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: apiError('groupWithIDNotFound', { groupId: dummyId }), + }); + }); + + it('returns only necessary party data given group id', async () => { + const nonAdmin = await generateUser(); + const party = await generateGroup(nonAdmin, { type: 'party', privacy: 'private' }); + + const partyRes = await user.get(`/hall/heroes/party/${party._id}`); + + expect(partyRes).to.have.all.keys([ // works as: object has all and only these keys + '_id', 'id', 'balance', 'challengeCount', 'leader', 'leaderOnly', 'memberCount', + 'purchased', 'quest', 'summary', + ]); + expect(partyRes.summary).to.eq(' '); + // NB: 'summary' is NOT a field that the API route retrieves! + // It must not be retrieved for privacy reasons. + // However the group model automatically adds a summary for reasons given here: + // https://github.com/HabitRPG/habitica/blob/8da36bf27c62ba0397a6af260c20d35a17f3d911/website/server/models/group.js#L161-L170 + }); +}); diff --git a/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js b/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js index 5e9d042ea5..2f98e89b3f 100644 --- a/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js +++ b/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js @@ -1,4 +1,5 @@ import { v4 as generateUUID } from 'uuid'; +import { model as User } from '../../../../../website/server/models/user'; import { generateUser, translate as t, @@ -8,15 +9,12 @@ describe('PUT /heroes/:heroId', () => { let user; const heroFields = [ - '_id', 'balance', 'profile', 'purchased', - 'contributor', 'auth', 'items', 'flags', - 'secret', + '_id', 'auth', 'balance', 'contributor', 'flags', 'items', 'lastCron', + 'party', 'preferences', 'profile', 'purchased', 'secret', 'permissions', ]; before(async () => { - user = await generateUser({ - contributor: { admin: true }, - }); + user = await generateUser({ 'permissions.userSupport': true }); }); it('requires the caller to be an admin', async () => { @@ -25,7 +23,7 @@ describe('PUT /heroes/:heroId', () => { await expect(nonAdmin.put(`/hall/heroes/${user._id}`)).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('noAdminAccess'), + message: t('noPrivAccess'), }); }); @@ -57,8 +55,7 @@ describe('PUT /heroes/:heroId', () => { }); // test response - // works as: object has all and only these keys - expect(heroRes).to.have.all.keys(heroFields); + expect(heroRes).to.have.all.keys(heroFields); // works as: object has all and only these keys expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); expect(heroRes.profile).to.have.all.keys(['name']); @@ -134,7 +131,6 @@ describe('PUT /heroes/:heroId', () => { }); // test response - // works as: object has all and only these keys expect(heroRes).to.have.all.keys(heroFields); expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); expect(heroRes.profile).to.have.all.keys(['name']); @@ -159,7 +155,6 @@ describe('PUT /heroes/:heroId', () => { }); // test response - // works as: object has all and only these keys expect(heroRes).to.have.all.keys(heroFields); expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); expect(heroRes.profile).to.have.all.keys(['name']); @@ -215,7 +210,6 @@ describe('PUT /heroes/:heroId', () => { }); // test response - // works as: object has all and only these keys expect(heroRes).to.have.all.keys(heroFields); expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); expect(heroRes.profile).to.have.all.keys(['name']); @@ -226,4 +220,35 @@ describe('PUT /heroes/:heroId', () => { await hero.sync(); expect(hero.items.special.snowball).to.equal(5); }); + + it('does not accidentally update API Token', async () => { + // This test has been included because hall.js will contain code to produce + // a truncated version of the API Token, and we want to be sure that + // the real Token is not modified by bugs in that code. + const hero = await generateUser(); + const originalToken = hero.apiToken; + + // make any change to the user except the Token + await user.put(`/hall/heroes/${hero._id}`, { + contributor: { text: 'Astronaut' }, + }); + + const updatedHero = await User.findById(hero._id).exec(); + expect(updatedHero.apiToken).to.equal(originalToken); + expect(updatedHero.apiTokenObscured).to.not.exist; + }); + + it('does update API Token when admin changes it', async () => { + const hero = await generateUser(); + const originalToken = hero.apiToken; + + // change the user's API Token + await user.put(`/hall/heroes/${hero._id}`, { + changeApiToken: true, + }); + + const updatedHero = await User.findById(hero._id).exec(); + expect(updatedHero.apiToken).to.not.equal(originalToken); + expect(updatedHero.apiTokenObscured).to.not.exist; + }); }); diff --git a/test/api/v3/integration/members/POST-send_private_message.test.js b/test/api/v3/integration/members/POST-send_private_message.test.js index a66c682286..a51ca111b8 100644 --- a/test/api/v3/integration/members/POST-send_private_message.test.js +++ b/test/api/v3/integration/members/POST-send_private_message.test.js @@ -176,7 +176,7 @@ describe('POST /members/send-private-message', () => { it('allows admin to send when sender has blocked the admin', async () => { userToSendMessage = await generateUser({ - 'contributor.admin': 1, + 'permissions.moderator': true, }); const receiver = await generateUser({ 'inbox.blocks': [userToSendMessage._id] }); @@ -204,7 +204,7 @@ describe('POST /members/send-private-message', () => { it('allows admin to send when to user has opted out of messaging', async () => { userToSendMessage = await generateUser({ - 'contributor.admin': 1, + 'permissions.moderator': true, }); const receiver = await generateUser({ 'inbox.optOut': true }); diff --git a/test/api/v3/integration/tasks/GET-tasks_id.test.js b/test/api/v3/integration/tasks/GET-tasks_id.test.js index a699815491..2e9353a9a3 100644 --- a/test/api/v3/integration/tasks/GET-tasks_id.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_id.test.js @@ -105,7 +105,7 @@ describe('GET /tasks/:id', () => { it('can get challenge task if admin', async () => { const admin = await generateUser({ - 'contributor.admin': true, + 'permissions.challengeAdmin': true, }); const getTask = await admin.get(`/tasks/${task._id}`); diff --git a/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_id.test.js b/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_id.test.js index 272dd858fd..7d75753217 100644 --- a/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_id.test.js +++ b/test/api/v3/integration/tasks/challenges/POST-tasks_challenge_id.test.js @@ -60,7 +60,7 @@ describe('POST /tasks/challenge/:challengeId', () => { }); it('allows non-leader admin to add tasks to a challenge when not a member', async () => { - const admin = await generateUser({ 'contributor.admin': true }); + const admin = await generateUser({ 'permissions.challengeAdmin': true }); const task = await admin.post(`/tasks/challenge/${challenge._id}`, { text: 'test habit from admin', type: 'habit', diff --git a/test/api/v3/integration/user/POST-user_reset.test.js b/test/api/v3/integration/user/POST-user_reset.test.js index 11f771ce5a..760c64df37 100644 --- a/test/api/v3/integration/user/POST-user_reset.test.js +++ b/test/api/v3/integration/user/POST-user_reset.test.js @@ -120,7 +120,7 @@ describe('POST /user/reset', () => { it('does not delete secret', async () => { const admin = await generateUser({ - contributor: { admin: true }, + permissions: { userSupport: true }, }); const hero = await generateUser({ diff --git a/test/api/v3/integration/user/PUT-user.test.js b/test/api/v3/integration/user/PUT-user.test.js index b2d6121741..1a0451318f 100644 --- a/test/api/v3/integration/user/PUT-user.test.js +++ b/test/api/v3/integration/user/PUT-user.test.js @@ -135,6 +135,7 @@ describe('PUT /user', () => { 'gem balance': { balance: 100 }, auth: { 'auth.blocked': true, 'auth.timestamps.created': new Date() }, contributor: { 'contributor.level': 9, 'contributor.admin': true, 'contributor.text': 'some text' }, + permissions: { 'permissions.fullAccess': true, 'permissions.news': true, 'permissions.moderator': 'some text' }, backer: { 'backer.tier': 10, 'backer.npc': 'Bilbo' }, subscriptions: { 'purchased.plan.extraMonths': 500, 'purchased.plan.consecutive.trinkets': 1000 }, 'customization gem purchases': { 'purchased.background.tavern': true, 'purchased.skin.bear': true }, diff --git a/test/api/v4/coupon/POST-coupons_enter_code.test.js b/test/api/v4/coupon/POST-coupons_enter_code.test.js index 71e4c05719..acc6b51a66 100644 --- a/test/api/v4/coupon/POST-coupons_enter_code.test.js +++ b/test/api/v4/coupon/POST-coupons_enter_code.test.js @@ -15,7 +15,7 @@ describe('POST /coupons/enter/:code', () => { beforeEach(async () => { user = await generateUser(); sudoUser = await generateUser({ - 'contributor.sudo': true, + 'permissions.coupons': true, }); }); diff --git a/test/api/v4/members/GET-purchase_history.test.js b/test/api/v4/members/GET-purchase_history.test.js index 99623a4561..58d77f099a 100644 --- a/test/api/v4/members/GET-purchase_history.test.js +++ b/test/api/v4/members/GET-purchase_history.test.js @@ -8,7 +8,7 @@ describe('GET /members/:memberId/purchase-history', () => { before(async () => { user = await generateUser({ - contributor: { admin: true }, + permissions: { userSupport: true }, }); }); @@ -26,7 +26,7 @@ describe('GET /members/:memberId/purchase-history', () => { await expect(nonAdmin.get(`/members/${member._id}/purchase-history`)).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: t('noAdminAccess'), + message: t('noPrivAccess'), }); }); diff --git a/test/api/v4/news/DELETE-news.test.js b/test/api/v4/news/DELETE-news.test.js index 211c0d706d..5e1d540b2a 100644 --- a/test/api/v4/news/DELETE-news.test.js +++ b/test/api/v4/news/DELETE-news.test.js @@ -15,16 +15,16 @@ describe('DELETE /news/:newsID', () => { }; beforeEach(async () => { user = await generateUser({ - 'contributor.newsPoster': true, + 'permissions.news': true, }); }); it('disallows access to non-newsPosters', async () => { - const nonAdminUser = await generateUser({ 'contributor.newsPoster': false }); + const nonAdminUser = await generateUser({ 'permissions.news': false }); await expect(nonAdminUser.del(`/news/${v4()}`)).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: 'You don\'t have news poster access.', + message: t('noPrivAccess'), }); }); diff --git a/test/api/v4/news/GET-news.test.js b/test/api/v4/news/GET-news.test.js index 64db6d8391..9ea0eac2d1 100644 --- a/test/api/v4/news/GET-news.test.js +++ b/test/api/v4/news/GET-news.test.js @@ -15,7 +15,7 @@ describe('GET /news', () => { before(async () => { api = requester(); const user = await generateUser({ - 'contributor.newsPoster': true, + 'permissions.news': true, }); await Promise.all([ diff --git a/test/api/v4/news/GET-news_id.test.js b/test/api/v4/news/GET-news_id.test.js index a6b7ed0579..c0fdd46d04 100644 --- a/test/api/v4/news/GET-news_id.test.js +++ b/test/api/v4/news/GET-news_id.test.js @@ -15,7 +15,7 @@ describe('GET /news/:newsID', () => { }; beforeEach(async () => { user = await generateUser({ - 'contributor.newsPoster': true, + 'permissions.news': true, }); }); diff --git a/test/api/v4/news/POST-news.test.js b/test/api/v4/news/POST-news.test.js index f102616624..733204a632 100644 --- a/test/api/v4/news/POST-news.test.js +++ b/test/api/v4/news/POST-news.test.js @@ -16,16 +16,16 @@ describe('POST /news', () => { }; beforeEach(async () => { user = await generateUser({ - 'contributor.newsPoster': true, + 'permissions.news': true, }); }); it('disallows access to non-admins', async () => { - const nonAdminUser = await generateUser({ 'contributor.newsPoster': false }); + const nonAdminUser = await generateUser({ 'permissions.news': false }); await expect(nonAdminUser.post('/news')).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: 'You don\'t have news poster access.', + message: 'You don\'t have the required privileges.', }); }); diff --git a/test/api/v4/news/PUT-news_newsId.test.js b/test/api/v4/news/PUT-news_newsId.test.js index 6301b94aa3..172603a45a 100644 --- a/test/api/v4/news/PUT-news_newsId.test.js +++ b/test/api/v4/news/PUT-news_newsId.test.js @@ -17,16 +17,16 @@ describe('PUT /news/:newsID', () => { }; beforeEach(async () => { user = await generateUser({ - 'contributor.newsPoster': true, + 'permissions.news': true, }); }); it('disallows access to non-admins', async () => { - const nonAdminUser = await generateUser({ 'contributor.newsPoster': false }); + const nonAdminUser = await generateUser({ 'permissions.news': false }); await expect(nonAdminUser.put('/news/1234')).to.eventually.be.rejected.and.eql({ code: 401, error: 'NotAuthorized', - message: 'You don\'t have news poster access.', + message: 'You don\'t have the required privileges.', }); }); diff --git a/test/api/v4/user/POST-user_reset.test.js b/test/api/v4/user/POST-user_reset.test.js index 1c98059cc6..677bf193ce 100644 --- a/test/api/v4/user/POST-user_reset.test.js +++ b/test/api/v4/user/POST-user_reset.test.js @@ -120,7 +120,7 @@ describe('POST /user/reset', () => { it('does not delete secret', async () => { const admin = await generateUser({ - contributor: { admin: true }, + permissions: { userSupport: true }, }); const hero = await generateUser({ diff --git a/test/api/v4/user/PUT-user.test.js b/test/api/v4/user/PUT-user.test.js index 2fe1e32bc1..a588a6f386 100644 --- a/test/api/v4/user/PUT-user.test.js +++ b/test/api/v4/user/PUT-user.test.js @@ -84,6 +84,7 @@ describe('PUT /user', () => { 'gem balance': { balance: 100 }, auth: { 'auth.blocked': true, 'auth.timestamps.created': new Date() }, contributor: { 'contributor.level': 9, 'contributor.admin': true, 'contributor.text': 'some text' }, + permissions: { 'permissions.fullAccess': true, 'permissions.news': true, 'permissions.moderator': 'some text' }, backer: { 'backer.tier': 10, 'backer.npc': 'Bilbo' }, subscriptions: { 'purchased.plan.extraMonths': 500, 'purchased.plan.consecutive.trinkets': 1000 }, 'customization gem purchases': { 'purchased.background.tavern': true, 'purchased.skin.bear': true }, diff --git a/website/client/src/components/admin-panel/filters/formatDate.js b/website/client/src/components/admin-panel/filters/formatDate.js new file mode 100644 index 0000000000..339fc08262 --- /dev/null +++ b/website/client/src/components/admin-panel/filters/formatDate.js @@ -0,0 +1,7 @@ +import moment from 'moment'; + +export default function formatDate (inputDate) { + if (!inputDate) return ''; + const date = moment(inputDate).utcOffset(0).format('YYYY-MM-DD HH:mm'); + return `${date} UTC`; +} diff --git a/website/client/src/components/admin-panel/index.vue b/website/client/src/components/admin-panel/index.vue new file mode 100644 index 0000000000..acd58c31d2 --- /dev/null +++ b/website/client/src/components/admin-panel/index.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/website/client/src/components/admin-panel/mixins/getItemDescription.js b/website/client/src/components/admin-panel/mixins/getItemDescription.js new file mode 100644 index 0000000000..0664f3a8fd --- /dev/null +++ b/website/client/src/components/admin-panel/mixins/getItemDescription.js @@ -0,0 +1,132 @@ +import content from '@/../../common/script/content'; + +function _getGearSetName (key) { + let set = 'NO SET [probably an omission in the API data]'; + if (content.gear.flat[key].set) { + set = `${content.gear.flat[key].set}`; + } + return set; +} + +function _getGearSetDescription (key) { + let setName = _getGearSetName(key); + if (setName === 'special-takeThis') { + // no point displaying set details for gear where it's obvious + return ''; + } + const klassNames = { + healer: 'Healer', + rogue: 'Rogue', + warrior: 'Warrior', + wizard: 'Mage', + }; + const lunarBattleQuestGear = ['armor_special_lunarWarriorArmor', 'head_special_lunarWarriorHelm', 'weapon_special_lunarScythe']; + + const loginIncentivesGear = ['armor_special_bardRobes', 'armor_special_dandySuit', 'armor_special_lunarWarriorArmor', 'armor_special_nomadsCuirass', 'armor_special_pageArmor', 'armor_special_samuraiArmor', 'armor_special_sneakthiefRobes', 'armor_special_snowSovereignRobes', 'back_special_snowdriftVeil', 'head_special_bardHat', 'head_special_clandestineCowl', 'head_special_dandyHat', 'head_special_kabuto', 'head_special_lunarWarriorHelm', 'head_special_pageHelm', 'head_special_snowSovereignCrown', 'head_special_spikedHelm', 'shield_special_diamondStave', 'shield_special_lootBag', 'shield_special_wakizashi', 'shield_special_wintryMirror', 'weapon_special_bardInstrument', 'weapon_special_fencingFoil', 'weapon_special_lunarScythe', 'weapon_special_nomadsScimitar', 'weapon_special_pageBanner', 'weapon_special_skeletonKey', 'weapon_special_tachi']; + + const goldQuestsGear = ['armor_special_finnedOceanicArmor', 'head_special_fireCoralCirclet', 'weapon_special_tridentOfCrashingTides', 'shield_special_moonpearlShield', 'head_special_pyromancersTurban', 'armor_special_pyromancersRobes', 'weapon_special_taskwoodsLantern', 'armor_special_mammothRiderArmor', 'head_special_mammothRiderHelm', 'weapon_special_mammothRiderSpear', 'shield_special_mammothRiderHorn', 'armor_special_roguishRainbowMessengerRobes', 'head_special_roguishRainbowMessengerHood', 'weapon_special_roguishRainbowMessage', 'shield_special_roguishRainbowMessage', 'eyewear_special_aetherMask', 'body_special_aetherAmulet', 'back_special_aetherCloak', 'weapon_special_aetherCrystals']; + + const animalGear = ['back_special_bearTail', 'back_special_cactusTail', 'back_special_foxTail', 'back_special_lionTail', 'back_special_pandaTail', 'back_special_pigTail', 'back_special_tigerTail', 'back_special_wolfTail', 'headAccessory_special_bearEars', 'headAccessory_special_cactusEars', 'headAccessory_special_foxEars', 'headAccessory_special_lionEars', 'headAccessory_special_pandaEars', 'headAccessory_special_pigEars', 'headAccessory_special_tigerEars', 'headAccessory_special_wolfEars']; + + let wantSetName = true; // some set names are useful, others aren't + let setType = '[cannot determine set type]'; + if (setName === 'base-0') { + setType = 'empty slot'; + wantSetName = false; + } else if (setName.includes('special-turkey')) { + setType = 'Turkey Day'; + wantSetName = false; + } else if (setName.includes('special-nye')) { + setType = 'New Year\'s Eve'; + wantSetName = false; + } else if (setName.includes('special-birthday')) { + setType = 'Habitica Birthday Bash'; + wantSetName = false; + } else if (setName.includes('special-0') || key === 'weapon_special_3') { + setType = 'Kickstarter 2013'; + wantSetName = false; + } else if (setName.includes('special-1')) { + setType = 'Contributor gear'; + wantSetName = false; + } else if (setName.includes('special-2') || key === 'shield_special_goldenknight') { + setType = 'Legendary Equipment'; + wantSetName = false; + } else if (setName.includes('special-wondercon')) { + setType = 'Unconventional Armor'; + wantSetName = false; + } else if (lunarBattleQuestGear.includes(key)) { + setType = 'Lunar Battle Quest Line'; + wantSetName = false; + } else if (loginIncentivesGear.includes(key)) { + setType = 'Check-In Incentive'; + wantSetName = false; + } else if (goldQuestsGear.includes(key)) { + setType = 'from Gold-Purchasable Quest Lines'; + wantSetName = false; + } else if (animalGear.includes(key)) { + setType = 'Animal Avatar Accessory Customisations'; + wantSetName = false; + } else if (!content.gear.flat[key].klass) { + setType = 'NO "klass" [omission in API data]'; + } else if (content.gear.flat[key].klass === 'armoire') { + setType = 'Armoire set'; + } else if (content.gear.flat[key].klass === 'mystery') { + setType = 'Mystery Items'; + setName = setName.replace(/mystery-(....)(..)/, '$1-$2'); + } else if (content.gear.flat[key].klass === 'special') { + const specialClass = content.gear.flat[key].specialClass || ''; + if (specialClass && Object.keys(klassNames).includes(specialClass)) { + setType = `Grand Gala ${klassNames[specialClass]} set`; + } else if (key.includes('special_gaymerx')) { + setType = 'GaymerX'; + wantSetName = false; + } else if (key.includes('special_ks2019')) { + setType = 'Kickstarter 2019'; + wantSetName = false; + } else { + setType = '[unknown set]'; + wantSetName = false; + } + } else if (Object.keys(klassNames).includes(content.gear.flat[key].klass)) { + // e.g., base class gear such as weapon_warrior_6 (Golden Sword) + setType = `base ${klassNames[content.gear.flat[key].klass]} gear`; + wantSetName = false; + } + return (wantSetName) ? `${setType}: ${setName}` : setType; +} + + +export default { + data () { + return { + content, + }; + }, + methods: { + getItemDescription (itemType, key) { + // Returns item name. Also returns other info for equipment. + + const simpleItemTypes = ['eggs', 'hatchingPotions', 'food', 'quests', 'special']; + if (simpleItemTypes.includes(itemType) && content[itemType][key]) { + return content[itemType][key].text(); + } + + if (itemType === 'mounts' && content.mountInfo[key]) { + return content.mountInfo[key].text(); + } + + if (itemType === 'pets' && content.petInfo[key]) { + return content.petInfo[key].text(); + } + + if (itemType === 'gear' && content.gear.flat[key]) { + const name = content.gear.flat[key].text(); + const description = _getGearSetDescription(key); + if (description) return `${name} -- ${description}`; + return name; + } + + return 'NO NAME - invalid item?'; + }, + }, +}; diff --git a/website/client/src/components/admin-panel/mixins/saveHero.js b/website/client/src/components/admin-panel/mixins/saveHero.js new file mode 100644 index 0000000000..8bbef7170b --- /dev/null +++ b/website/client/src/components/admin-panel/mixins/saveHero.js @@ -0,0 +1,20 @@ +export default { + methods: { + async saveHero ({ hero, msg = 'User', clearData }) { + await this.$store.dispatch('hall:updateHero', { heroDetails: hero }); + await this.$store.dispatch('snackbars:add', { + title: '', + text: `${msg} updated`, + type: 'info', + }); + + if (clearData) { + // Use clearData when the saved changes may affect data in other components + // (e.g., adding a contributor tier will increase the Gem balance) + // The admin should re-fetch the data if they need to keep working on that user. + this.$emit('clear-data'); + this.$router.push({ name: 'adminPanel' }); + } + }, + }, +}; diff --git a/website/client/src/components/admin-panel/user-support/avatarAndDrops.vue b/website/client/src/components/admin-panel/user-support/avatarAndDrops.vue new file mode 100644 index 0000000000..3790263f21 --- /dev/null +++ b/website/client/src/components/admin-panel/user-support/avatarAndDrops.vue @@ -0,0 +1,68 @@ + + + diff --git a/website/client/src/components/admin-panel/user-support/basicDetails.vue b/website/client/src/components/admin-panel/user-support/basicDetails.vue new file mode 100644 index 0000000000..9227b68a71 --- /dev/null +++ b/website/client/src/components/admin-panel/user-support/basicDetails.vue @@ -0,0 +1,34 @@ + + + diff --git a/website/client/src/components/admin-panel/user-support/contributorDetails.vue b/website/client/src/components/admin-panel/user-support/contributorDetails.vue new file mode 100644 index 0000000000..48b72b8358 --- /dev/null +++ b/website/client/src/components/admin-panel/user-support/contributorDetails.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/website/client/src/components/admin-panel/user-support/cronAndAuth.vue b/website/client/src/components/admin-panel/user-support/cronAndAuth.vue new file mode 100644 index 0000000000..1a28b044c7 --- /dev/null +++ b/website/client/src/components/admin-panel/user-support/cronAndAuth.vue @@ -0,0 +1,223 @@ + + + diff --git a/website/client/src/components/admin-panel/user-support/index.vue b/website/client/src/components/admin-panel/user-support/index.vue new file mode 100644 index 0000000000..d94d6d44c4 --- /dev/null +++ b/website/client/src/components/admin-panel/user-support/index.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/website/client/src/components/admin-panel/user-support/itemsOwned.vue b/website/client/src/components/admin-panel/user-support/itemsOwned.vue new file mode 100644 index 0000000000..83b25c3e29 --- /dev/null +++ b/website/client/src/components/admin-panel/user-support/itemsOwned.vue @@ -0,0 +1,289 @@ + + + + + diff --git a/website/client/src/components/admin-panel/user-support/partyAndQuest.vue b/website/client/src/components/admin-panel/user-support/partyAndQuest.vue new file mode 100644 index 0000000000..a00aa6bec8 --- /dev/null +++ b/website/client/src/components/admin-panel/user-support/partyAndQuest.vue @@ -0,0 +1,317 @@ + + + diff --git a/website/client/src/components/admin-panel/user-support/privilegesAndGems.vue b/website/client/src/components/admin-panel/user-support/privilegesAndGems.vue new file mode 100644 index 0000000000..9de6873469 --- /dev/null +++ b/website/client/src/components/admin-panel/user-support/privilegesAndGems.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/website/client/src/components/admin-panel/user-support/transactions.vue b/website/client/src/components/admin-panel/user-support/transactions.vue new file mode 100644 index 0000000000..b5809c7580 --- /dev/null +++ b/website/client/src/components/admin-panel/user-support/transactions.vue @@ -0,0 +1,52 @@ + + + diff --git a/website/client/src/components/appFooter.vue b/website/client/src/components/appFooter.vue index 8157e2abd8..8ca55b4063 100644 --- a/website/client/src/components/appFooter.vue +++ b/website/client/src/components/appFooter.vue @@ -589,7 +589,7 @@ export default { async makeAdmin () { await axios.post('/api/v4/debug/make-admin'); // @TODO: Notification.text('You are now an admin! - // Go to the Hall of Heroes to change your contributor level.'); + // Reload the website then go to Help > Admin Panel to set contributor level, etc.'); // @TODO: sync() }, openModifyInventoryModal () { diff --git a/website/client/src/components/challenges/challengeDetail.vue b/website/client/src/components/challenges/challengeDetail.vue index 5b6e4907d5..bacd28c6fc 100644 --- a/website/client/src/components/challenges/challengeDetail.vue +++ b/website/client/src/components/challenges/challengeDetail.vue @@ -321,7 +321,7 @@ import cloneDeep from 'lodash/cloneDeep'; import omit from 'lodash/omit'; import { v4 as uuid } from 'uuid'; -import { mapState } from '@/libs/store'; +import { userStateMixin } from '../../mixins/userState'; import memberSearchDropdown from '@/components/members/memberSearchDropdown'; import closeChallengeModal from './closeChallengeModal'; import Column from '../tasks/column'; @@ -358,7 +358,7 @@ export default { userLink, groupLink, }, - mixins: [challengeMemberSearchMixin], + mixins: [challengeMemberSearchMixin, userStateMixin], props: ['challengeId'], data () { return { @@ -387,7 +387,6 @@ export default { }; }, computed: { - ...mapState({ user: 'user.data' }), isMember () { return this.user.challenges.indexOf(this.challenge._id) !== -1; }, @@ -396,7 +395,7 @@ export default { return this.user._id === this.challenge.leader._id; }, isAdmin () { - return Boolean(this.user.contributor.admin); + return this.hasPermission(this.user, 'challengeAdmin'); }, canJoin () { return !this.isMember; diff --git a/website/client/src/components/challenges/challengeModal.vue b/website/client/src/components/challenges/challengeModal.vue index c17e2d7fce..35de6ff8b8 100644 --- a/website/client/src/components/challenges/challengeModal.vue +++ b/website/client/src/components/challenges/challengeModal.vue @@ -112,7 +112,7 @@
@@ -277,14 +277,15 @@ import clone from 'lodash/clone'; import throttle from 'lodash/throttle'; import markdownDirective from '@/directives/markdown'; +import { userStateMixin } from '../../mixins/userState'; import { TAVERN_ID, MIN_SHORTNAME_SIZE_FOR_CHALLENGES, MAX_SUMMARY_SIZE_FOR_CHALLENGES } from '@/../../common/script/constants'; -import { mapState } from '@/libs/store'; export default { directives: { markdown: markdownDirective, }, + mixins: [userStateMixin], props: ['groupId'], data () { const categoryOptions = [ @@ -378,7 +379,6 @@ export default { }; }, computed: { - ...mapState({ user: 'user.data' }), creating () { return !this.workingChallenge.id; }, diff --git a/website/client/src/components/chat/chatCard.vue b/website/client/src/components/chat/chatCard.vue index ef0e5a1b06..a22914c727 100644 --- a/website/client/src/components/chat/chatCard.vue +++ b/website/client/src/components/chat/chatCard.vue @@ -5,7 +5,7 @@ class="mentioned-icon" >
{{ flagCountDescription }} @@ -54,7 +54,7 @@
@@ -68,7 +68,7 @@
@@ -202,7 +202,7 @@ import cloneDeep from 'lodash/cloneDeep'; import escapeRegExp from 'lodash/escapeRegExp'; import renderWithMentions from '@/libs/renderWithMentions'; -import { mapState } from '@/libs/store'; +import { userStateMixin } from '../../mixins/userState'; import userLink from '../userLink'; import deleteIcon from '@/assets/svg/delete.svg'; @@ -223,6 +223,7 @@ export default { return moment(value).toDate().toString(); }, }, + mixins: [userStateMixin], props: { msg: {}, groupId: {}, @@ -240,7 +241,6 @@ export default { }; }, computed: { - ...mapState({ user: 'user.data' }), isUserMentioned () { const message = this.msg; diff --git a/website/client/src/components/chat/chatMessages.vue b/website/client/src/components/chat/chatMessages.vue index c34bce5ce7..823d781789 100644 --- a/website/client/src/components/chat/chatMessages.vue +++ b/website/client/src/components/chat/chatMessages.vue @@ -149,7 +149,7 @@ import moment from 'moment'; import axios from 'axios'; import debounce from 'lodash/debounce'; import findIndex from 'lodash/findIndex'; -import { mapState } from '@/libs/store'; +import { userStateMixin } from '../../mixins/userState'; import Avatar from '../avatar'; import copyAsTodoModal from './copyAsTodoModal'; @@ -161,6 +161,7 @@ export default { chatCard, Avatar, }, + mixins: [userStateMixin], props: { chat: {}, groupType: {}, @@ -182,7 +183,6 @@ export default { }; }, computed: { - ...mapState({ user: 'user.data' }), // @TODO: We need a different lazy load mechnism. // But honestly, adding a paging route to chat would solve this messages () { @@ -214,7 +214,7 @@ export default { canViewFlag (message) { if (message.uuid === this.user._id) return true; if (!message.flagCount || message.flagCount < 2) return true; - return this.user.contributor.admin; + return this.hasPermission(this.user, 'moderator'); }, loadProfileCache: debounce(function loadProfileCache (screenPosition) { this._loadProfileCache(screenPosition); diff --git a/website/client/src/components/chat/reportFlagModal.vue b/website/client/src/components/chat/reportFlagModal.vue index ff12fc4589..53b2f0f020 100644 --- a/website/client/src/components/chat/reportFlagModal.vue +++ b/website/client/src/components/chat/reportFlagModal.vue @@ -23,7 +23,7 @@