mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-15 05:37:22 +01:00
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 in26fdcbbee5* 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" in2ab76db27c* 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 commita4f9c754adIt 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 <alice.harris@oldgods.net> Co-authored-by: SabreCat <sabe@habitica.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -231,7 +231,7 @@ describe('GET challenges/user', () => {
|
||||
publicGuild = group;
|
||||
|
||||
await user.update({
|
||||
'contributor.admin': true,
|
||||
'permissions.challengeAdmin': true,
|
||||
});
|
||||
|
||||
officialChallenge = await generateChallenge(user, group, {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('POST /coupons/enter/:code', () => {
|
||||
beforeEach(async () => {
|
||||
user = await generateUser();
|
||||
sudoUser = await generateUser({
|
||||
'contributor.sudo': true,
|
||||
'permissions.coupons': true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -315,7 +315,7 @@ describe('GET /groups/:id', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
admin = await generateUser({
|
||||
'contributor.admin': true,
|
||||
'permissions.moderator': true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('POST /coupons/enter/:code', () => {
|
||||
beforeEach(async () => {
|
||||
user = await generateUser();
|
||||
sudoUser = await generateUser({
|
||||
'contributor.sudo': true,
|
||||
'permissions.coupons': true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('GET /news', () => {
|
||||
before(async () => {
|
||||
api = requester();
|
||||
const user = await generateUser({
|
||||
'contributor.newsPoster': true,
|
||||
'permissions.news': true,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('GET /news/:newsID', () => {
|
||||
};
|
||||
beforeEach(async () => {
|
||||
user = await generateUser({
|
||||
'contributor.newsPoster': true,
|
||||
'permissions.news': true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
81
website/client/src/components/admin-panel/index.vue
Normal file
81
website/client/src/components/admin-panel/index.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="row standard-page">
|
||||
<div class="well col-12">
|
||||
<h1>Admin Panel</h1>
|
||||
|
||||
<div>
|
||||
<form
|
||||
class="form-inline"
|
||||
@submit.prevent="loadHero(userIdentifier)"
|
||||
>
|
||||
<input
|
||||
v-model="userIdentifier"
|
||||
class="form-control uidField"
|
||||
type="text"
|
||||
:placeholder="'User ID or Username; blank for your account'"
|
||||
>
|
||||
<input
|
||||
type="submit"
|
||||
value="Load User"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<router-view @changeUserIdentifier="changeUserIdentifier" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.uidField {
|
||||
min-width: 45ch;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import VueRouter from 'vue-router';
|
||||
import { mapState } from '@/libs/store';
|
||||
|
||||
const { isNavigationFailure, NavigationFailureType } = VueRouter;
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
userIdentifier: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
},
|
||||
mounted () {
|
||||
this.$store.dispatch('common:setTitle', {
|
||||
section: 'Admin Panel',
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
changeUserIdentifier (newId) {
|
||||
// If we've accessed the admin panel from a URL that had a user identifier in it,
|
||||
// this method will insert that identifier into the "Load User" form field
|
||||
// (useful if we want to re-fetch the user after making changes).
|
||||
this.userIdentifier = newId;
|
||||
},
|
||||
async loadHero (userIdentifier) {
|
||||
const id = userIdentifier || this.user._id;
|
||||
|
||||
this.$router.push({
|
||||
name: 'adminPanelUser',
|
||||
params: { userIdentifier: id },
|
||||
}).catch(failure => {
|
||||
if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
|
||||
// the admin has requested that the same user be displayed again so reload the page
|
||||
// (e.g., if they changed their mind about changes they were making)
|
||||
this.$router.go();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -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 = '<a href="https://habitica.fandom.com/wiki/Turkey_Day">Turkey Day</a>';
|
||||
wantSetName = false;
|
||||
} else if (setName.includes('special-nye')) {
|
||||
setType = '<a href="https://habitica.fandom.com/wiki/Event_Item_Sequences">New Year\'s Eve</a>';
|
||||
wantSetName = false;
|
||||
} else if (setName.includes('special-birthday')) {
|
||||
setType = '<a href="https://habitica.fandom.com/wiki/Habitica_Birthday_Bash">Habitica Birthday Bash</a>';
|
||||
wantSetName = false;
|
||||
} else if (setName.includes('special-0') || key === 'weapon_special_3') {
|
||||
setType = '<a href="https://habitica.fandom.com/wiki/Kickstarter">Kickstarter 2013</a>';
|
||||
wantSetName = false;
|
||||
} else if (setName.includes('special-1')) {
|
||||
setType = 'Contributor gear';
|
||||
wantSetName = false;
|
||||
} else if (setName.includes('special-2') || key === 'shield_special_goldenknight') {
|
||||
setType = '<a href="https://habitica.fandom.com/wiki/Legendary_Equipment">Legendary Equipment</a>';
|
||||
wantSetName = false;
|
||||
} else if (setName.includes('special-wondercon')) {
|
||||
setType = '<a href="https://habitica.fandom.com/wiki/Unconventional_Armor">Unconventional Armor</a>';
|
||||
wantSetName = false;
|
||||
} else if (lunarBattleQuestGear.includes(key)) {
|
||||
setType = '<a href="https://habitica.fandom.com/wiki/Quest_Lines#Lunar_Battle_Quest_Line">Lunar Battle Quest Line</a>';
|
||||
wantSetName = false;
|
||||
} else if (loginIncentivesGear.includes(key)) {
|
||||
setType = '<a href="https://habitica.fandom.com/wiki/Daily_Check-In_Incentives">Check-In Incentive</a>';
|
||||
wantSetName = false;
|
||||
} else if (goldQuestsGear.includes(key)) {
|
||||
setType = 'from <a href="https://habitica.fandom.com/wiki/Quest_Lines#Gold_Purchasable_Quest_Lines">Gold-Purchasable Quest Lines</a>';
|
||||
wantSetName = false;
|
||||
} else if (animalGear.includes(key)) {
|
||||
setType = '<a href="https://habitica.fandom.com/wiki/Avatar_Customizations">Animal Avatar Accessory Customisations</a>';
|
||||
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 = '<a href="https://habitica.fandom.com/wiki/Kickstarter">Kickstarter 2019</a>';
|
||||
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?';
|
||||
},
|
||||
},
|
||||
};
|
||||
20
website/client/src/components/admin-panel/mixins/saveHero.js
Normal file
20
website/client/src/components/admin-panel/mixins/saveHero.js
Normal file
@@ -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' });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="accordion-group">
|
||||
<h3
|
||||
class="expand-toggle"
|
||||
:class="{'open': expand}"
|
||||
@click="expand = !expand"
|
||||
>
|
||||
Current Avatar Appearance, Drop Count Today
|
||||
</h3>
|
||||
<div v-if="expand">
|
||||
<div>Drops Today: {{ items.lastDrop.count }}</div>
|
||||
<div>Most Recent Drop: {{ items.lastDrop.date | formatDate }}</div>
|
||||
<div>Use Costume: {{ preferences.costume ? 'on' : 'off' }}</div>
|
||||
<div class="subsection-start">
|
||||
Equipped Gear:
|
||||
<ul v-html="formatEquipment(items.gear.equipped)"></ul>
|
||||
</div>
|
||||
<div>
|
||||
Costume:
|
||||
<ul v-html="formatEquipment(items.gear.costume)"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import formatDate from '../filters/formatDate';
|
||||
import getItemDescription from '../mixins/getItemDescription';
|
||||
|
||||
export default {
|
||||
filters: {
|
||||
formatDate,
|
||||
},
|
||||
mixins: [
|
||||
getItemDescription,
|
||||
],
|
||||
props: {
|
||||
items: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
preferences: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
expand: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
formatEquipment (gearWorn) {
|
||||
const gearTypes = ['head', 'armor', 'weapon', 'shield', 'headAccessory', 'eyewear',
|
||||
'body', 'back'];
|
||||
let equipmentList = '';
|
||||
gearTypes.forEach(gearType => {
|
||||
const key = gearWorn[gearType] || '';
|
||||
const description = (key)
|
||||
? `<strong>${key}</strong> : ${this.getItemDescription('gear', gearWorn[gearType])}`
|
||||
: 'none';
|
||||
equipmentList += `<li>${gearType} : ${description}</li>\n`;
|
||||
});
|
||||
return equipmentList;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>@{{ auth.local.username }} / {{ profile.name }}</h2>
|
||||
{{ userId }}
|
||||
<router-link :to="{'name': 'userProfile', 'params': {'userId': userId}}">
|
||||
profile link
|
||||
</router-link>
|
||||
<br>
|
||||
language: {{ preferences.language }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
userId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
auth: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
preferences: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
profile: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="accordion-group">
|
||||
<h3
|
||||
class="expand-toggle"
|
||||
:class="{'open': expand}"
|
||||
@click="expand = !expand"
|
||||
>
|
||||
Contributor Details
|
||||
</h3>
|
||||
<div v-if="expand">
|
||||
<form @submit.prevent="saveHero({hero, msg: 'Contributor details', clearData: true})">
|
||||
<div>
|
||||
<label>Permissions</label>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input
|
||||
v-model="hero.permissions.fullAccess"
|
||||
:disabled="!hasPermission(user, 'fullAccess')"
|
||||
type="checkbox"
|
||||
>
|
||||
Full Admin Access (Allows access to everything. EVERYTHING)
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input
|
||||
v-model="hero.permissions.userSupport"
|
||||
:disabled="!hasPermission(user, 'fullAccess')"
|
||||
type="checkbox"
|
||||
>
|
||||
User Support (Access this form, access purchase history)
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input
|
||||
v-model="hero.permissions.news"
|
||||
:disabled="!hasPermission(user, 'fullAccess')"
|
||||
type="checkbox"
|
||||
>
|
||||
News poster (Bailey CMS)
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input
|
||||
v-model="hero.permissions.moderator"
|
||||
:disabled="!hasPermission(user, 'fullAccess')"
|
||||
type="checkbox"
|
||||
>
|
||||
Community Moderator (ban and mute users, access chat flags, manage social spaces)
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input
|
||||
v-model="hero.permissions.challengeAdmin"
|
||||
:disabled="!hasPermission(user, 'fullAccess')"
|
||||
type="checkbox"
|
||||
>
|
||||
Challenge Admin (can create official habitica challenges and admin all challenges)
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input
|
||||
v-model="hero.permissions.coupons"
|
||||
:disabled="!hasPermission(user, 'fullAccess')"
|
||||
type="checkbox"
|
||||
>
|
||||
Coupon Creator (can manage coupon codes)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Title</label>
|
||||
<input
|
||||
v-model="hero.contributor.text"
|
||||
class="form-control textField"
|
||||
type="text"
|
||||
>
|
||||
<small>
|
||||
Common titles:
|
||||
<strong>Ambassador, Artisan, Bard, Blacksmith, Challenger, Comrade, Fletcher,
|
||||
Linguist, Linguistic Scribe, Scribe, Socialite, Storyteller</strong>.
|
||||
<br>
|
||||
Rare titles:
|
||||
Advisor, Chamberlain, Designer, Mathematician, Shirtster, Spokesperson,
|
||||
Statistician, Tinker, Transcriber, Troubadour.
|
||||
</small>
|
||||
</div>
|
||||
<div class="form-group form-inline">
|
||||
<label>Tier</label>
|
||||
<input
|
||||
v-model="hero.contributor.level"
|
||||
class="form-control levelField"
|
||||
type="number"
|
||||
>
|
||||
<small>
|
||||
1-7 for normal contributors, 8 for moderators, 9 for staff.
|
||||
This determines which items, pets, mounts are available, and name-tag coloring.
|
||||
Tiers 8 and 9 are automatically given admin status.
|
||||
</small>
|
||||
</div>
|
||||
<div
|
||||
v-if="hero.secret.text"
|
||||
class="form-group"
|
||||
>
|
||||
<label>Moderation Notes</label>
|
||||
<div
|
||||
v-markdown="hero.secret.text"
|
||||
class="markdownPreview"
|
||||
></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Contributions</label>
|
||||
<textarea
|
||||
v-model="hero.contributor.contributions"
|
||||
class="form-control"
|
||||
cols="5"
|
||||
rows="5"
|
||||
></textarea>
|
||||
<div
|
||||
v-markdown="hero.contributor.contributions"
|
||||
class="markdownPreview"
|
||||
></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Edit Moderation Notes</label>
|
||||
<textarea
|
||||
v-model="hero.secret.text"
|
||||
class="form-control"
|
||||
cols="5"
|
||||
rows="3"
|
||||
></textarea>
|
||||
<div
|
||||
v-markdown="hero.secret.text"
|
||||
class="markdownPreview"
|
||||
></div>
|
||||
</div>
|
||||
<input
|
||||
type="submit"
|
||||
value="Save and Clear Data"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.levelField {
|
||||
min-width: 10ch;
|
||||
}
|
||||
.textField {
|
||||
min-width: 50ch;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import markdownDirective from '@/directives/markdown';
|
||||
import saveHero from '../mixins/saveHero';
|
||||
|
||||
import { mapState } from '@/libs/store';
|
||||
import { userStateMixin } from '../../../mixins/userState';
|
||||
|
||||
function resetData (self) {
|
||||
self.expand = self.hero.contributor.level;
|
||||
}
|
||||
|
||||
export default {
|
||||
directives: {
|
||||
markdown: markdownDirective,
|
||||
},
|
||||
mixins: [
|
||||
userStateMixin,
|
||||
saveHero,
|
||||
],
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
},
|
||||
props: {
|
||||
resetCounter: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
hero: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
expand: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
resetCounter () {
|
||||
resetData(this);
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
resetData(this);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div class="accordion-group">
|
||||
<h3
|
||||
class="expand-toggle"
|
||||
:class="{'open': expand}"
|
||||
@click="expand = !expand"
|
||||
>
|
||||
Timestamps, Time Zone, Authentication, Email Address
|
||||
<span
|
||||
v-if="errorsOrWarningsExist"
|
||||
>- ERRORS / WARNINGS EXIST</span>
|
||||
</h3>
|
||||
<div v-if="expand">
|
||||
<p
|
||||
v-if="errorsOrWarningsExist"
|
||||
class="errorMessage"
|
||||
>
|
||||
See error(s) below.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
Account created:
|
||||
<strong>{{ hero.auth.timestamps.created | formatDate }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
Most recent cron:
|
||||
<strong>{{ hero.auth.timestamps.loggedin | formatDate }}</strong>
|
||||
("auth.timestamps.loggedin")
|
||||
</div>
|
||||
<div v-if="cronError">
|
||||
"lastCron" value:
|
||||
<strong>{{ hero.lastCron | formatDate }}</strong>
|
||||
<br>
|
||||
<span class="errorMessage">
|
||||
ERROR: cron probably crashed before finishing
|
||||
("auth.timestamps.loggedin" and "lastCron" dates are different).
|
||||
</span>
|
||||
</div>
|
||||
<div class="subsection-start">
|
||||
Time zone:
|
||||
<strong>{{ hero.preferences.timezoneOffset | formatTimeZone }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
Custom Day Start time (CDS):
|
||||
<strong>{{ hero.preferences.dayStart }}</strong>
|
||||
</div>
|
||||
<div v-if="timezoneDiffError || timezoneMissingError">
|
||||
Time zone at previous cron:
|
||||
<strong>{{ hero.preferences.timezoneOffsetAtLastCron | formatTimeZone }}</strong>
|
||||
|
||||
<div class="errorMessage">
|
||||
<div v-if="timezoneDiffError">
|
||||
ERROR: the player's current time zone is different than their time zone when
|
||||
their previous cron ran. This can be because:
|
||||
<ul>
|
||||
<li>daylight savings started or stopped <sup>*</sup></li>
|
||||
<li>the player changed zones due to travel <sup>*</sup></li>
|
||||
<li>the player has devices set to different zones <sup>**</sup></li>
|
||||
<li>the player uses a VPN with varying zones <sup>**</sup></li>
|
||||
<li>something similarly unpleasant is happening. <sup>**</sup></li>
|
||||
</ul>
|
||||
<p>
|
||||
<em>* The problem should fix itself in about a day.</em><br>
|
||||
<em>** One of these causes is probably happening if the time zones stay
|
||||
different for more than a day.</em>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="timezoneMissingError">
|
||||
ERROR: One of the player's time zones is missing.
|
||||
This is expected and okay if it's the "Time zone at previous cron"
|
||||
AND if it's their first day in Habitica.
|
||||
Otherwise an error has occurred.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subsection-start form-inline">
|
||||
API Token:
|
||||
<form @submit.prevent="changeApiToken()">
|
||||
<input
|
||||
type="submit"
|
||||
value="Change API Token"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
</form>
|
||||
<div
|
||||
v-if="tokenModified"
|
||||
class="form-inline"
|
||||
>
|
||||
<strong>API Token has been changed. Tell the player something like this:</strong>
|
||||
<br>
|
||||
I've given you a new API Token.
|
||||
You'll need to log out of the website and mobile app then log back in
|
||||
otherwise they won't work correctly.
|
||||
If you have trouble logging out, for the website go to
|
||||
https://habitica.com/static/clear-browser-data and click the red button there,
|
||||
and for the Android app, clear its data.
|
||||
For the iOS app, if you can't log out you might need to uninstall it,
|
||||
reboot your phone, then reinstall it.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subsection-start">
|
||||
Local authentication:
|
||||
<span v-if="hero.auth.local.email">Yes,
|
||||
<strong>{{ hero.auth.local.email }}</strong></span>
|
||||
<span v-else><strong>None</strong></span>
|
||||
</div>
|
||||
<div>
|
||||
Google authentication:
|
||||
<pre v-if="authMethodExists('google')">{{ hero.auth.google }}</pre>
|
||||
<span v-else><strong>None</strong></span>
|
||||
</div>
|
||||
<div>
|
||||
Facebook authentication:
|
||||
<pre v-if="authMethodExists('facebook')">{{ hero.auth.facebook }}</pre>
|
||||
<span v-else><strong>None</strong></span>
|
||||
</div>
|
||||
<div>
|
||||
Apple ID authentication:
|
||||
<pre v-if="authMethodExists('apple')">{{ hero.auth.apple }}</pre>
|
||||
<span v-else><strong>None</strong></span>
|
||||
</div>
|
||||
<div class="subsection-start">
|
||||
Full "auth" object for checking above is correct:
|
||||
<pre>{{ hero.auth }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import formatDate from '../filters/formatDate';
|
||||
import saveHero from '../mixins/saveHero';
|
||||
|
||||
function resetData (self) {
|
||||
self.cronError = false;
|
||||
self.timezoneDiffError = false;
|
||||
self.timezoneMissingError = false;
|
||||
self.errorsOrWarningsExist = false;
|
||||
self.expand = false;
|
||||
|
||||
const cronDate1 = moment(self.hero.auth.timestamps.loggedin);
|
||||
const cronDate2 = moment(self.hero.lastCron);
|
||||
const maxAllowableSecondsDifference = 60; // expect cron to take less than this many seconds
|
||||
if (Math.abs(cronDate1.diff(cronDate2, 'seconds')) > maxAllowableSecondsDifference) {
|
||||
self.cronError = true;
|
||||
self.errorsOrWarningsExist = true;
|
||||
}
|
||||
|
||||
// compare the user's time zones to see if they're different
|
||||
const newTimezone = self.hero.preferences.timezoneOffset;
|
||||
const oldTimezone = self.hero.preferences.timezoneOffsetAtLastCron;
|
||||
if ((newTimezone === undefined || oldTimezone === undefined)
|
||||
&& (self.cronError || self.hero.flags.cronCount > 0)) {
|
||||
self.timezoneMissingError = true;
|
||||
self.errorsOrWarningsExist = true;
|
||||
} else if (newTimezone !== oldTimezone) {
|
||||
self.timezoneDiffError = true;
|
||||
self.errorsOrWarningsExist = true;
|
||||
}
|
||||
self.expand = self.errorsOrWarningsExist;
|
||||
}
|
||||
|
||||
export default {
|
||||
filters: {
|
||||
formatDate,
|
||||
formatTimeZone (timezoneOffset) {
|
||||
if (timezoneOffset === undefined) return 'No value recorded.';
|
||||
// convert reverse offset to time zone in "+/-H:MM UTC" format
|
||||
const sign = (timezoneOffset < 0) ? '+' : '-'; // reverse the sign
|
||||
const timezoneHours = Math.floor(Math.abs(timezoneOffset) / 60);
|
||||
const timezoneMinutes = Math.floor((Math.abs(timezoneOffset) / 60 - timezoneHours) * 60);
|
||||
const timezoneMinutesDisplay = (timezoneMinutes) ? `:${timezoneMinutes}` : ''; // don't display :00
|
||||
return `${sign}${timezoneHours}${timezoneMinutesDisplay} UTC`;
|
||||
},
|
||||
},
|
||||
mixins: [
|
||||
saveHero,
|
||||
],
|
||||
props: {
|
||||
resetCounter: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
hero: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
cronError: false,
|
||||
timezoneDiffError: false,
|
||||
timezoneMissingError: false,
|
||||
tokenModified: false,
|
||||
errorsOrWarningsExist: false,
|
||||
expand: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
resetCounter () {
|
||||
resetData(this);
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
resetData(this);
|
||||
},
|
||||
methods: {
|
||||
authMethodExists (authMethod) {
|
||||
if (this.hero.auth[authMethod] && this.hero.auth[authMethod].length !== 0) return true;
|
||||
return false;
|
||||
},
|
||||
async changeApiToken () {
|
||||
this.hero.changeApiToken = true;
|
||||
await this.saveHero({ hero: this.hero, msg: 'API Token' });
|
||||
this.tokenModified = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
185
website/client/src/components/admin-panel/user-support/index.vue
Normal file
185
website/client/src/components/admin-panel/user-support/index.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div v-if="hasPermission(user, 'userSupport')">
|
||||
<div
|
||||
v-if="hero && hero.profile"
|
||||
class="row"
|
||||
>
|
||||
<div class="form col-12">
|
||||
<basic-details
|
||||
:user-id="hero._id"
|
||||
:auth="hero.auth"
|
||||
:preferences="hero.preferences"
|
||||
:profile="hero.profile"
|
||||
/>
|
||||
|
||||
<privileges-and-gems
|
||||
:hero="hero"
|
||||
:reset-counter="resetCounter"
|
||||
/>
|
||||
|
||||
<cron-and-auth
|
||||
:hero="hero"
|
||||
:reset-counter="resetCounter"
|
||||
/>
|
||||
|
||||
<party-and-quest
|
||||
v-if="adminHasPrivForParty"
|
||||
:user-id="hero._id"
|
||||
:username="hero.auth.local.username"
|
||||
:user-has-party="hasParty"
|
||||
:party-not-exist-error="partyNotExistError"
|
||||
:user-party-data="hero.party"
|
||||
:group-party-data="party"
|
||||
:reset-counter="resetCounter"
|
||||
/>
|
||||
|
||||
<avatar-and-drops
|
||||
:items="hero.items"
|
||||
:preferences="hero.preferences"
|
||||
/>
|
||||
|
||||
<items-owned
|
||||
:hero="hero"
|
||||
:reset-counter="resetCounter"
|
||||
/>
|
||||
|
||||
<transactions
|
||||
:hero="hero"
|
||||
/>
|
||||
|
||||
<contributor-details
|
||||
:hero="hero"
|
||||
:reset-counter="resetCounter"
|
||||
@clear-data="clearData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .accordion-group .accordion-group {
|
||||
margin-left: 1em;
|
||||
}
|
||||
::v-deep h3 {
|
||||
margin-top: 2em;
|
||||
}
|
||||
::v-deep h4 {
|
||||
margin-top: 1em;
|
||||
}
|
||||
::v-deep .expand-toggle::after {
|
||||
margin-left: 5px;
|
||||
}
|
||||
::v-deep .subsection-start {
|
||||
margin-top: 1em;
|
||||
}
|
||||
::v-deep .form-inline {
|
||||
margin-bottom: 1em;
|
||||
input, span {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
::v-deep .errorMessage {
|
||||
font-weight: bold;
|
||||
}
|
||||
::v-deep .markdownPreview {
|
||||
margin-left: 3em;
|
||||
margin-top: 1em;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import BasicDetails from './basicDetails';
|
||||
import ItemsOwned from './itemsOwned';
|
||||
import CronAndAuth from './cronAndAuth';
|
||||
import PartyAndQuest from './partyAndQuest';
|
||||
import AvatarAndDrops from './avatarAndDrops';
|
||||
import PrivilegesAndGems from './privilegesAndGems';
|
||||
import ContributorDetails from './contributorDetails';
|
||||
import Transactions from './transactions';
|
||||
|
||||
import { userStateMixin } from '../../../mixins/userState';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BasicDetails,
|
||||
ItemsOwned,
|
||||
CronAndAuth,
|
||||
PartyAndQuest,
|
||||
AvatarAndDrops,
|
||||
PrivilegesAndGems,
|
||||
ContributorDetails,
|
||||
Transactions,
|
||||
},
|
||||
mixins: [userStateMixin],
|
||||
data () {
|
||||
return {
|
||||
userIdentifier: '',
|
||||
resetCounter: 0,
|
||||
hero: {},
|
||||
party: {},
|
||||
hasParty: false,
|
||||
partyNotExistError: false,
|
||||
adminHasPrivForParty: true,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
userIdentifier () {
|
||||
// close modal if the page is opened in an existing tab from the modal
|
||||
this.$root.$emit('bv::hide::modal', 'profile');
|
||||
|
||||
this.loadHero(this.userIdentifier);
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
this.userIdentifier = this.$route.params.userIdentifier;
|
||||
},
|
||||
methods: {
|
||||
clearData () {
|
||||
this.hero = {};
|
||||
},
|
||||
|
||||
async loadHero (userIdentifier) {
|
||||
const id = userIdentifier.replace(/@/, ''); // allow "@name" to be entered
|
||||
this.$emit('changeUserIdentifier', id); // change user identifier in Admin Panel's form
|
||||
|
||||
this.hero = await this.$store.dispatch('hall:getHero', { uuid: id });
|
||||
|
||||
if (!this.hero.flags) {
|
||||
this.hero.flags = {
|
||||
chatRevoked: false,
|
||||
chatShadowMuted: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.hero.permissions) {
|
||||
this.hero.permissions = {};
|
||||
}
|
||||
|
||||
this.hasParty = false;
|
||||
this.partyNotExistError = false;
|
||||
this.adminHasPrivForParty = true;
|
||||
if (this.hero.party && this.hero.party._id) {
|
||||
try {
|
||||
this.party = await this.$store.dispatch('hall:getHeroParty', { groupId: this.hero.party._id });
|
||||
this.hasParty = true;
|
||||
} catch (e) {
|
||||
if (e.message.includes('status code 401')) {
|
||||
// @TODO is there a better way to recognise NotAuthorized error?
|
||||
this.adminHasPrivForParty = false;
|
||||
} else {
|
||||
// the API's error message isn't worth reporting ("Request failed with status code 404")
|
||||
this.partyNotExistError = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.resetCounter += 1; // tell child components to reinstantiate from scratch
|
||||
},
|
||||
},
|
||||
beforeRouteUpdate (to, from, next) {
|
||||
this.userIdentifier = to.params.userIdentifier;
|
||||
next();
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,289 @@
|
||||
<template>
|
||||
<div class="accordion-group">
|
||||
<h3
|
||||
class="expand-toggle"
|
||||
:class="{'open': expand}"
|
||||
@click="expand = !expand"
|
||||
>
|
||||
Items
|
||||
</h3>
|
||||
<div v-if="expand">
|
||||
<div>
|
||||
The sections below display each item's key (bolded if the player has ever owned it),
|
||||
followed by the item's English name.
|
||||
<ul>
|
||||
<li>
|
||||
Click on an item's key or value to change it
|
||||
(hovering shows an underline to show where you can click).
|
||||
</li>
|
||||
<li>For Mounts and Gear, clicking toggles between the allowed values.</li>
|
||||
<li>For other item types, clicking gives you a form field to enter a new value.</li>
|
||||
<li>Click Save when the correct value is displayed.</li>
|
||||
<li>
|
||||
You must Save for each item individually but you do not need to reload the user
|
||||
between each Save.
|
||||
</li>
|
||||
<li>If you adjust an item and do not click Save for it, the change will be lost.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="itemType in itemTypes"
|
||||
:key="itemType"
|
||||
>
|
||||
<div class="accordion-group">
|
||||
<h4
|
||||
class="expand-toggle"
|
||||
:class="{'open': expandItemType[itemType]}"
|
||||
@click="expandItemType[itemType] = !expandItemType[itemType]"
|
||||
>
|
||||
{{ itemType }}
|
||||
</h4>
|
||||
|
||||
<div v-if="expandItemType[itemType]">
|
||||
<p v-if="itemType === 'pets'">
|
||||
A value of -1 means they owned the Pet but Released it
|
||||
and have not yet rehatched it.
|
||||
</p>
|
||||
<p v-if="itemType === 'mounts'">
|
||||
A value of "null" means they owned the Mount but Released it
|
||||
and have not yet retamed it.
|
||||
</p>
|
||||
<p v-if="itemType === 'special'">
|
||||
When there are 0 of these items, we can't tell if
|
||||
they had been owned and were all used, or have never been owned.
|
||||
</p>
|
||||
<p v-if="itemType === 'gear'">
|
||||
A value of true means they own the item now and can wear it.
|
||||
A value of false means they used to own it but lost it from Death
|
||||
(or an old Rebirth).
|
||||
</p>
|
||||
<ul>
|
||||
<li
|
||||
v-for="item in collatedItemData[itemType]"
|
||||
:key="item.path"
|
||||
>
|
||||
<form @submit.prevent="saveItem(item)">
|
||||
<span
|
||||
class="enableValueChange"
|
||||
@click="enableValueChange(item)"
|
||||
>
|
||||
{{ item | displayValue }}
|
||||
:
|
||||
<span :class="{ ownedItem: !item.neverOwned }">{{ item.key }} : </span>
|
||||
</span>
|
||||
<span v-html="item.name"></span>
|
||||
|
||||
<div
|
||||
v-if="item.modified"
|
||||
class="form-inline"
|
||||
>
|
||||
<input
|
||||
v-if="item.valueIsInteger"
|
||||
v-model="item.value"
|
||||
class="form-control valueField"
|
||||
type="number"
|
||||
>
|
||||
<input
|
||||
v-if="item.modified"
|
||||
type="submit"
|
||||
value="Save"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ownedItem {
|
||||
font-weight: bold;
|
||||
}
|
||||
.enableValueChange:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.valueField {
|
||||
min-width: 10ch;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import content from '@/../../common/script/content';
|
||||
import getItemDescription from '../mixins/getItemDescription';
|
||||
import saveHero from '../mixins/saveHero';
|
||||
|
||||
function collateItemData (self) {
|
||||
const collatedItemData = {};
|
||||
self.itemTypes.forEach(itemType => {
|
||||
// itemTypes are pets, food, gear, etc
|
||||
|
||||
// Set up some basic data for this itemType:
|
||||
let basePath = `items.${itemType}`;
|
||||
let ownedItems = self.hero.items[itemType] || {};
|
||||
let allItems = content[itemType];
|
||||
if (itemType === 'gear') {
|
||||
basePath = 'items.gear.owned';
|
||||
ownedItems = self.hero.items.gear.owned || {};
|
||||
allItems = content.gear.flat;
|
||||
} else if (itemType === 'pets' || itemType === 'mounts') {
|
||||
// add the non-Standard pets and mounts
|
||||
const ucItemType = (itemType === 'pets') ? 'Pets' : 'Mounts';
|
||||
self.petMountSubTypes.forEach(subType => {
|
||||
allItems = { ...allItems, ...content[subType + ucItemType] };
|
||||
});
|
||||
}
|
||||
|
||||
const itemData = []; // all items for this itemType
|
||||
|
||||
// Collate data for items that the user owns or used to own:
|
||||
for (const key of Object.keys(ownedItems)) {
|
||||
// Do not sort keys. The order in the items object gives hints about order received.
|
||||
if (itemType !== 'special' || self.specialItems.includes(key)) {
|
||||
const valueIsInteger = !self.nonIntegerTypes.includes(itemType);
|
||||
itemData.push({
|
||||
neverOwned: false,
|
||||
itemType,
|
||||
key,
|
||||
modified: false,
|
||||
name: self.getItemDescription(itemType, key),
|
||||
path: `${basePath}.${key}`,
|
||||
value: ownedItems[key],
|
||||
valueIsInteger,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Collate data for items that the user never owned:
|
||||
for (const key of Object.keys(allItems).sort()) {
|
||||
if (
|
||||
// ignore items the user owns because we captured them above:
|
||||
!(key in ownedItems)
|
||||
|
||||
// ignore gear items that indicate empty equipped slots (e.g., head_base_0):
|
||||
&& !(itemType === 'gear' && content.gear.flat[key].set
|
||||
&& content.gear.flat[key].set === 'base-0')
|
||||
|
||||
// ignore "special" items that aren't Snowballs, Seafoam, etc:
|
||||
&& (itemType !== 'special' || self.specialItems.includes(key))
|
||||
) {
|
||||
const valueIsInteger = !self.nonIntegerTypes.includes(itemType);
|
||||
const value = (valueIsInteger) ? 0 : '';
|
||||
itemData.push({
|
||||
neverOwned: true,
|
||||
itemType,
|
||||
key,
|
||||
modified: false,
|
||||
name: self.getItemDescription(itemType, key),
|
||||
path: `${basePath}.${key}`,
|
||||
value,
|
||||
valueIsInteger,
|
||||
});
|
||||
}
|
||||
}
|
||||
collatedItemData[itemType] = itemData;
|
||||
});
|
||||
return collatedItemData;
|
||||
}
|
||||
|
||||
function resetData (self) {
|
||||
self.collatedItemData = collateItemData(self);
|
||||
self.itemTypes.forEach(itemType => { self.expandItemType[itemType] = false; });
|
||||
}
|
||||
|
||||
export default {
|
||||
filters: {
|
||||
displayValue (item) {
|
||||
if (item.value === '') return 'never owned';
|
||||
if (item.value === 0 && item.neverOwned) return '0 (never owned)';
|
||||
if (item.value === null) return 'null'; // we need visible text
|
||||
return item.value; // true or false or an integer
|
||||
},
|
||||
},
|
||||
mixins: [
|
||||
getItemDescription,
|
||||
saveHero,
|
||||
],
|
||||
props: {
|
||||
resetCounter: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
hero: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
expand: false,
|
||||
expandItemType: {
|
||||
eggs: false,
|
||||
hatchingPotions: false,
|
||||
food: false,
|
||||
pets: false,
|
||||
mounts: false,
|
||||
quests: false,
|
||||
gear: false,
|
||||
special: false,
|
||||
},
|
||||
itemTypes: ['eggs', 'hatchingPotions', 'food', 'pets', 'mounts', 'quests', 'gear', 'special'],
|
||||
nonIntegerTypes: ['mounts', 'gear'],
|
||||
petMountSubTypes: ['premium', 'quest', 'special', 'wacky'], // e.g., 'premiumPets'
|
||||
// items.special includes many things but we are interested in these only:
|
||||
specialItems: ['snowball', 'spookySparkles', 'shinySeed', 'seafoam'],
|
||||
collatedItemData: {},
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
resetCounter () {
|
||||
resetData(this);
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
resetData(this);
|
||||
},
|
||||
methods: {
|
||||
async saveItem (item) {
|
||||
// prepare the item's new value and path for being saved
|
||||
this.hero.itemPath = item.path;
|
||||
if (item.value === null) {
|
||||
this.hero.itemVal = 'null';
|
||||
} else if (item.value === false) {
|
||||
this.hero.itemVal = 'false';
|
||||
} else {
|
||||
this.hero.itemVal = item.value;
|
||||
}
|
||||
|
||||
await this.saveHero({ hero: this.hero, msg: item.key });
|
||||
item.neverOwned = false;
|
||||
item.modified = false;
|
||||
},
|
||||
enableValueChange (item) {
|
||||
// allow form field(s) to be shown:
|
||||
item.modified = true;
|
||||
|
||||
// for non-integer items, toggle through the allowed values:
|
||||
if (item.itemType === 'gear') {
|
||||
// Allowed starting values are true, false, and '' (never owned)
|
||||
// Allowed values to switch to are true and false
|
||||
item.value = !item.value;
|
||||
} else if (item.itemType === 'mounts') {
|
||||
// Allowed starting values are true, null, and "never owned"
|
||||
// Allowed values to switch to are true and null
|
||||
if (item.value === true) {
|
||||
item.value = null;
|
||||
} else {
|
||||
item.value = true;
|
||||
}
|
||||
}
|
||||
// @TODO add a delete option
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<div class="accordion-group">
|
||||
<h3
|
||||
class="expand-toggle"
|
||||
:class="{'open': expand}"
|
||||
@click="expand = !expand"
|
||||
>
|
||||
Party, Quest
|
||||
<span
|
||||
v-if="errorsOrWarningsExist"
|
||||
>- ERRORS / WARNINGS EXIST</span>
|
||||
</h3>
|
||||
<div v-if="expand">
|
||||
<div
|
||||
v-if="errorsOrWarningsExist"
|
||||
class="errorMessage"
|
||||
>
|
||||
<p v-if="partyNotExistError">
|
||||
ERROR: User has a Party ID but that Party does not exist.
|
||||
If you are seeing a red error notification on screen now
|
||||
("<strong>Group with id ... not found</strong>"), it's refering to this issue.
|
||||
<br>Ask a database admin to delete the user's Party ID ({{ userPartyData._id }}).
|
||||
</p>
|
||||
<p
|
||||
v-if="questErrors"
|
||||
v-html="questErrors"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Party:
|
||||
<span v-if="userHasParty">
|
||||
yes: party ID {{ groupPartyData._id }},
|
||||
member count {{ groupPartyData.memberCount }} (may be wrong)
|
||||
<br>
|
||||
<span v-if="userIsPartyLeader">User is the party leader</span>
|
||||
<span v-else>Party leader is
|
||||
<router-link :to="{'name': 'userProfile', 'params': {'userId': groupPartyData.leader}}">
|
||||
{{ groupPartyData.leader }}
|
||||
</router-link>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>no</span>
|
||||
</div>
|
||||
<div class="subsection-start">
|
||||
<p v-html="questStatus"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as quests from '@/../../common/script/content/quests';
|
||||
|
||||
function determineQuestStatus (self) {
|
||||
// Quest data is in the user doc and party doc. They can be out of sync.
|
||||
// Here we collate data from both sources, showing error messages if needed.
|
||||
|
||||
// First get data from the party's document.
|
||||
const groupQuestData = self.groupPartyData.quest;
|
||||
let questExists = false; // true if quest is active or in invitation stage
|
||||
let questIsActive = false; // true if quest's invitation stage is over
|
||||
let inviteStatusForUser = '';
|
||||
let expectedRsvpStatusForUser = false;
|
||||
let countOfQuestMembers = 0;
|
||||
if (self.userHasParty && groupQuestData) {
|
||||
questIsActive = groupQuestData.active;
|
||||
if (groupQuestData.members) countOfQuestMembers = Object.keys(groupQuestData.members).length;
|
||||
if (groupQuestData.key) {
|
||||
questExists = true;
|
||||
if (!countOfQuestMembers) {
|
||||
self.questErrors = 'ERROR: Quest is running or in invitation stage but has no participants.';
|
||||
} else if (groupQuestData.members[self.userId] === null) {
|
||||
inviteStatusForUser = 'pending';
|
||||
if (questIsActive) {
|
||||
self.questErrors = 'ERROR: Quest is running but user\'s invitation is still pending ("null") in quest object.';
|
||||
} else {
|
||||
expectedRsvpStatusForUser = true;
|
||||
}
|
||||
} else if (groupQuestData.members[self.userId] === false) {
|
||||
inviteStatusForUser = 'rejected';
|
||||
if (questIsActive) {
|
||||
self.questErrors = 'ERROR: Quest is running and user\'s invitation was rejected BUT '
|
||||
+ 'it wasn\'t cleared properly from the quest\'s data ("false"). '
|
||||
+ 'That shouldn\'t cause any problems though.';
|
||||
}
|
||||
} else if (groupQuestData.members[self.userId] === true) {
|
||||
inviteStatusForUser = 'accepted';
|
||||
} else if (questIsActive) {
|
||||
inviteStatusForUser = 'rejected OR not accepted before quest start OR user joined party after quest started';
|
||||
} else {
|
||||
inviteStatusForUser = 'missing';
|
||||
self.questErrors = 'ERROR: Quest is in invitation stage but user doesn\'t have an invitation '
|
||||
+ 'in the party\'s data ("quest.members" needs to be fixed).';
|
||||
}
|
||||
} else if (questIsActive) {
|
||||
self.questErrors = 'ERROR: Quest is running but there is no "key" to say which quest it is. '
|
||||
+ 'This means the other data and errors in this section are unreliable, '
|
||||
+ 'and there may be more errors not shown here.'
|
||||
+ 'Other errors here may tell you which key to add.'
|
||||
+ 'After fixing, check for more errors.';
|
||||
// @TODO display a similar message for when it happens during invitation stage
|
||||
}
|
||||
}
|
||||
if (self.questErrors) self.questErrors += '<br>';
|
||||
// from this point on, further quest errors need to be appended to that
|
||||
|
||||
let questStatus = '<p>';
|
||||
if (questExists) {
|
||||
questStatus = 'Quest exists and is ';
|
||||
if (questIsActive) {
|
||||
questStatus += 'running.<br>User is ';
|
||||
if (inviteStatusForUser !== 'accepted') questStatus += 'not ';
|
||||
questStatus += 'a participant.';
|
||||
} else {
|
||||
questStatus += 'in invitation stage.<br>'
|
||||
+ `User's invitation is ${inviteStatusForUser}.`;
|
||||
}
|
||||
questStatus += '<br>';
|
||||
if (!groupQuestData.leader) {
|
||||
self.questErrors += 'ERROR: quest does not have its owner specified '
|
||||
+ '(party needs value for "quest.leader").<br>';
|
||||
} else if (groupQuestData.leader === self.userId) {
|
||||
questStatus += 'User is the quest owner.';
|
||||
} else {
|
||||
questStatus += `Quest owner is ${groupQuestData.leader}`;
|
||||
}
|
||||
} else {
|
||||
questStatus = 'No quest.';
|
||||
}
|
||||
questStatus += '</p>';
|
||||
|
||||
// Assess quest participants.
|
||||
if (questExists && countOfQuestMembers) {
|
||||
const participants = (questIsActive) ? 'participants' : 'invitees';
|
||||
questStatus += `<p>Quest has ${countOfQuestMembers} ${participants}:<ul>`;
|
||||
for (const [memberId, inviteStatus] of Object.entries(groupQuestData.members)) {
|
||||
questStatus += '<li>';
|
||||
questStatus += (memberId === self.userId)
|
||||
? `@${self.username}`
|
||||
: memberId;
|
||||
let invitationDescription = '';
|
||||
const errMsg = ' - MINOR ERROR: this data should have been deleted when quest started';
|
||||
if (inviteStatus === true) {
|
||||
if (!questIsActive) invitationDescription = ' - invitation accepted';
|
||||
// we don't display anything if quest is running - obvious that participant accepted
|
||||
} else if (inviteStatus === false) {
|
||||
invitationDescription += ' - invitation rejected';
|
||||
if (questIsActive) invitationDescription += errMsg;
|
||||
} else {
|
||||
invitationDescription += ' - invitation pending';
|
||||
if (questIsActive) invitationDescription += errMsg;
|
||||
}
|
||||
questStatus += invitationDescription;
|
||||
questStatus += '</li>';
|
||||
}
|
||||
questStatus += '</ul></p>';
|
||||
// @TODO: show error if all invitations accepted but quest not active
|
||||
}
|
||||
|
||||
// Now get data from the user's document.
|
||||
if (!self.userPartyData.quest) self.userPartyData.quest = {};
|
||||
if (self.userPartyData.quest.RSVPNeeded !== expectedRsvpStatusForUser) {
|
||||
self.questErrors
|
||||
+= `ERROR: User's quest invitation ("party.quest.RSVPNeeded") should be "${expectedRsvpStatusForUser}" but isn't.<br>`;
|
||||
}
|
||||
|
||||
if (inviteStatusForUser === 'pending' || inviteStatusForUser === 'accepted') {
|
||||
if (!self.userPartyData.quest.key) {
|
||||
self.questErrors += 'ERROR: User has accepted quest invitation or invitation is '
|
||||
+ 'still pending but their account has no "key" for the quest.<br>';
|
||||
} else if (self.userPartyData.quest.key !== groupQuestData.key) {
|
||||
self.questErrors += 'ERROR: User has accepted quest invitation or invitation is '
|
||||
+ `still pending but the "key" in their account (${self.userPartyData.quest.key}) `
|
||||
+ `is different than the quest's "key" (${groupQuestData.key}).<br>`;
|
||||
}
|
||||
} else if (self.userPartyData.quest.key) {
|
||||
self.questErrors += `ERROR: User has a "key" for the quest (${self.userPartyData.quest.key})`
|
||||
+ 'but perhaps should not have (no quest exists, or user not participating, '
|
||||
+ 'or quest is in erroneous state).<br>';
|
||||
}
|
||||
|
||||
// Display details of quest (name, type, progress, etc).
|
||||
if (questExists) {
|
||||
const questContent = quests.quests[groupQuestData.key];
|
||||
if (questContent) {
|
||||
let questContentData = `<strong>Quest Details</strong>:<br>Quest name: ${questContent.text()}<br>Quest "key": ${questContent.key}`;
|
||||
let questProgress = '<strong>Quest Progress:</strong>';
|
||||
if (!questIsActive) questProgress += ' none (quest is in invitation stage)';
|
||||
let userProgressToday;
|
||||
let userMadeZeroProgress = false;
|
||||
if (questContent.boss) {
|
||||
// NB Data rounding below is done in the same way as on the user's party page.
|
||||
questContentData += `<br>Boss name: ${questContent.boss.name()}`
|
||||
+ `<br>Boss's starting HP: ${questContent.boss.hp}`
|
||||
+ `<br>Boss's Strength: ${questContent.boss.str}`;
|
||||
let bossHasRage;
|
||||
if (questContent.boss.rage && questContent.boss.rage.value) {
|
||||
bossHasRage = true;
|
||||
questContentData += `<br>Boss's rage name for this quest: ${questContent.boss.rage.title()}`;
|
||||
questContentData += `<br>Boss's rage limit: ${questContent.boss.rage.value}`;
|
||||
}
|
||||
if (questIsActive) {
|
||||
if (!groupQuestData.progress || groupQuestData.progress.hp === undefined) {
|
||||
self.questErrors += 'ERROR: Party\'s quest is missing some or all of the "progress" data.<br>';
|
||||
} else {
|
||||
questProgress += `<br>Current Boss HP: ${Math.ceil(groupQuestData.progress.hp * 100) / 100}`;
|
||||
}
|
||||
if (bossHasRage) {
|
||||
questProgress += `<br>Current Rage: ${Math.floor(groupQuestData.progress.rage * 100) / 100}`;
|
||||
}
|
||||
}
|
||||
userProgressToday = `Player's pending damage to Boss: ${Math.floor(self.userPartyData.quest.progress.up * 10) / 10}`;
|
||||
if (!self.userPartyData.quest.progress.up) userMadeZeroProgress = true;
|
||||
} else {
|
||||
questContentData += '<br>Need to collect:<ul>';
|
||||
if (questIsActive) questProgress += '<br>Current found items: <ul>';
|
||||
for (const [key, obj] of Object.entries(questContent.collect)) {
|
||||
questContentData += `<li>${obj.text()}: ${obj.count} ("key": ${key})</li>`;
|
||||
if (questIsActive) {
|
||||
if (!groupQuestData.progress || !groupQuestData.progress.collect) {
|
||||
self.questErrors += 'ERROR: Party\'s quest is missing some or all of the "progress" data.<br>';
|
||||
} else if (groupQuestData.progress.collect[key] !== undefined) {
|
||||
questProgress += `<li>${obj.text()}: ${groupQuestData.progress.collect[key]}</li>`;
|
||||
} else {
|
||||
self.questErrors += `ERROR: Party's quest has no entry for "${key}" `
|
||||
+ '("quest.progress.collect" needs to be fixed).<br>';
|
||||
}
|
||||
}
|
||||
}
|
||||
questContentData += '</ul>';
|
||||
if (questIsActive) questProgress += '</ul>';
|
||||
userProgressToday = `Player's pending collected items: ${self.userPartyData.quest.progress.collectedItems}`;
|
||||
if (!self.userPartyData.quest.progress.collectedItems) userMadeZeroProgress = true;
|
||||
}
|
||||
if (userMadeZeroProgress) userProgressToday += '<br>NB: Zero pending quest progress may be from an error in which the user\'s database document is missing the pending progress fields. That error can\'t be identified here because the API will apply default data. If the user claims to have made pending progress but none is showing for them, a database admin has to check that.';
|
||||
questStatus += `<p>${questContentData}</p>`
|
||||
+ `<p>${questProgress}</p>`
|
||||
+ `<p>${userProgressToday}</p>`;
|
||||
questStatus += `<p><strong>Raw Quest Data:</strong></p><pre>party: ${JSON.stringify(groupQuestData, null, ' ')}`
|
||||
+ `\nuser: ${JSON.stringify(self.userPartyData.quest, null, ' ')}</pre>`;
|
||||
} else {
|
||||
self.questErrors += `ERROR: quest "key" ${groupQuestData.key} does not match a known quest.`;
|
||||
}
|
||||
}
|
||||
return questStatus;
|
||||
}
|
||||
|
||||
function resetData (self) {
|
||||
self.questStatus = '';
|
||||
self.questErrors = '';
|
||||
self.errorsOrWarningsExist = false;
|
||||
self.expand = false;
|
||||
|
||||
if (self.partyNotExistError) {
|
||||
self.errorsOrWarningsExist = true;
|
||||
} else {
|
||||
self.userIsPartyLeader = self.groupPartyData.leader === self.userId;
|
||||
}
|
||||
|
||||
// check for quest errors even if party doesn't exist (user can have old quest data)
|
||||
self.questStatus = determineQuestStatus(self);
|
||||
if (self.questErrors) self.errorsOrWarningsExist = true;
|
||||
|
||||
self.expand = self.errorsOrWarningsExist;
|
||||
}
|
||||
|
||||
export default {
|
||||
props: {
|
||||
resetCounter: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
userId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
userHasParty: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
partyNotExistError: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
userPartyData: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
groupPartyData: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
userIsPartyLeader: false,
|
||||
questStatus: '',
|
||||
questErrors: '',
|
||||
errorsOrWarningsExist: false,
|
||||
expand: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
resetCounter () {
|
||||
resetData(this);
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
resetData(this);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="accordion-group">
|
||||
<h3
|
||||
class="expand-toggle"
|
||||
:class="{'open': expand}"
|
||||
@click="expand = !expand"
|
||||
>
|
||||
Privileges, Gem Balance
|
||||
</h3>
|
||||
<div v-if="expand">
|
||||
<p
|
||||
v-if="errorsOrWarningsExist"
|
||||
class="errorMessage"
|
||||
>
|
||||
Player has had privileges removed or has moderation notes.
|
||||
</p>
|
||||
|
||||
<form @submit.prevent="saveHero({hero, msg: 'Privileges or Gems or Moderation Notes'})">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input
|
||||
v-if="hero.flags"
|
||||
v-model="hero.flags.chatShadowMuted"
|
||||
type="checkbox"
|
||||
> Shadow Mute
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input
|
||||
v-if="hero.flags"
|
||||
v-model="hero.flags.chatRevoked"
|
||||
type="checkbox"
|
||||
> Mute (Revoke Chat Privileges)
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input
|
||||
v-model="hero.auth.blocked"
|
||||
type="checkbox"
|
||||
> Ban / Block
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-inline">
|
||||
<label>
|
||||
Balance
|
||||
<input
|
||||
v-model="hero.balance"
|
||||
class="form-control balanceField"
|
||||
type="number"
|
||||
step="0.25"
|
||||
>
|
||||
</label>
|
||||
<span>
|
||||
<small>
|
||||
Balance is in USD, not in Gems.
|
||||
E.g., if this number is 1, it means 4 Gems.
|
||||
Arrows change Balance by 0.25 (i.e., 1 Gem per click).
|
||||
Do not use when awarding tiers; tier gems are automatic.
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Moderation Notes</label>
|
||||
<textarea
|
||||
v-model="hero.secret.text"
|
||||
class="form-control"
|
||||
cols="5"
|
||||
rows="5"
|
||||
></textarea>
|
||||
<div
|
||||
v-markdown="hero.secret.text"
|
||||
class="markdownPreview"
|
||||
></div>
|
||||
</div>
|
||||
<input
|
||||
type="submit"
|
||||
value="Save"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.balanceField {
|
||||
min-width: 15ch;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import markdownDirective from '@/directives/markdown';
|
||||
import saveHero from '../mixins/saveHero';
|
||||
|
||||
function resetData (self) {
|
||||
self.errorsOrWarningsExist = false;
|
||||
self.expand = false;
|
||||
if (self.hero.flags.chatRevoked || self.hero.flags.chatShadowMuted || self.hero.auth.blocked
|
||||
|| (self.hero.secret.text && !self.hero.contributor.level)) {
|
||||
// We automatically expand this section if the user has had privileges removed.
|
||||
// We also expand if they have secret.text UNLESS they have a contributor tier because
|
||||
// in that case the notes are probably about their contributions and can be seen in the
|
||||
// Contributor Details section (which will be automatically expanded because of their tier).
|
||||
self.errorsOrWarningsExist = true;
|
||||
self.expand = true;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
directives: {
|
||||
markdown: markdownDirective,
|
||||
},
|
||||
mixins: [
|
||||
saveHero,
|
||||
],
|
||||
props: {
|
||||
resetCounter: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
hero: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
errorsOrWarningsExist: false,
|
||||
expand: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
resetCounter () {
|
||||
resetData(this);
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
resetData(this);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="accordion-group">
|
||||
<h3
|
||||
class="expand-toggle"
|
||||
:class="{'open': expand}"
|
||||
@click="toggleTransactionsOpen"
|
||||
>
|
||||
Transactions
|
||||
</h3>
|
||||
<div v-if="expand">
|
||||
<purchase-history-table
|
||||
:gem-transactions="gemTransactions"
|
||||
:hourglass-transactions="hourglassTransactions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PurchaseHistoryTable from '../../ui/purchaseHistoryTable.vue';
|
||||
import { userStateMixin } from '../../../mixins/userState';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PurchaseHistoryTable,
|
||||
},
|
||||
mixins: [userStateMixin],
|
||||
props: {
|
||||
hero: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
expand: false,
|
||||
gemTransactions: [],
|
||||
hourglassTransactions: [],
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async toggleTransactionsOpen () {
|
||||
this.expand = !this.expand;
|
||||
if (this.expand) {
|
||||
const transactions = await this.$store.dispatch('members:getPurchaseHistory', { memberId: this.hero._id });
|
||||
this.gemTransactions = transactions.filter(transaction => transaction.currency === 'gems');
|
||||
this.hourglassTransactions = transactions.filter(transaction => transaction.currency === 'hourglasses');
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
<!-- eslint-disable vue/no-use-v-if-with-v-for -->
|
||||
<div
|
||||
v-for="group in categoryOptions"
|
||||
v-if="group.key !== 'habitica_official' || user.contributor.admin"
|
||||
v-if="group.key !== 'habitica_official' || hasPermission(user, 'challengeAdmin')"
|
||||
:key="group.key"
|
||||
class="form-check"
|
||||
>
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
class="mentioned-icon"
|
||||
></div>
|
||||
<div
|
||||
v-if="user.contributor.admin && msg.flagCount"
|
||||
v-if="hasPermission(user, 'moderator') && msg.flagCount"
|
||||
class="message-hidden"
|
||||
>
|
||||
{{ flagCountDescription }}
|
||||
@@ -54,7 +54,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="(user.flags.communityGuidelinesAccepted && msg.uuid !== 'system')
|
||||
&& (!isMessageReported || user.contributor.admin)"
|
||||
&& (!isMessageReported || hasPermission(user, 'moderator'))"
|
||||
class="action d-flex align-items-center"
|
||||
@click="report(msg)"
|
||||
>
|
||||
@@ -68,7 +68,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="msg.uuid === user._id || user.contributor.admin"
|
||||
v-if="msg.uuid === user._id || hasPermission(user, 'moderator')"
|
||||
class="action d-flex align-items-center"
|
||||
@click="remove()"
|
||||
>
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
<div class="footer text-center">
|
||||
<button
|
||||
v-if="user.contributor.admin"
|
||||
v-if="hasPermission(user, 'moderator')"
|
||||
class="pull-left btn btn-danger"
|
||||
@click="clearFlagCount()"
|
||||
>
|
||||
@@ -88,15 +88,15 @@
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { mapState } from '@/libs/store';
|
||||
import notifications from '@/mixins/notifications';
|
||||
import markdownDirective from '@/directives/markdown';
|
||||
import { userStateMixin } from '../../mixins/userState';
|
||||
|
||||
export default {
|
||||
directives: {
|
||||
markdown: markdownDirective,
|
||||
},
|
||||
mixins: [notifications],
|
||||
mixins: [notifications, userStateMixin],
|
||||
data () {
|
||||
const abuseFlagModalBody = {
|
||||
firstLinkStart: '<a href="/static/community-guidelines" target="_blank">',
|
||||
@@ -111,9 +111,6 @@ export default {
|
||||
reportComment: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
},
|
||||
mounted () {
|
||||
this.$root.$on('habitica::report-chat', this.handleReport);
|
||||
},
|
||||
|
||||
@@ -288,7 +288,7 @@
|
||||
import extend from 'lodash/extend';
|
||||
import groupUtilities from '@/mixins/groupsUtilities';
|
||||
import styleHelper from '@/mixins/styleHelper';
|
||||
import { mapState, mapGetters } from '@/libs/store';
|
||||
import { mapGetters } from '@/libs/store';
|
||||
import * as Analytics from '@/libs/analytics';
|
||||
import participantListModal from './participantListModal';
|
||||
import groupFormModal from './groupFormModal';
|
||||
@@ -312,6 +312,7 @@ import QuestDetailModal from './questDetailModal';
|
||||
import RightSidebar from '@/components/groups/rightSidebar';
|
||||
import InvitationListModal from './invitationListModal';
|
||||
import { PAGES } from '@/libs/consts';
|
||||
import { userStateMixin } from '../../mixins/userState';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -327,7 +328,7 @@ export default {
|
||||
directives: {
|
||||
markdown: markdownDirective,
|
||||
},
|
||||
mixins: [groupUtilities, styleHelper],
|
||||
mixins: [groupUtilities, styleHelper, userStateMixin],
|
||||
props: ['groupId'],
|
||||
data () {
|
||||
return {
|
||||
@@ -356,9 +357,6 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
user: 'user.data',
|
||||
}),
|
||||
...mapGetters({
|
||||
partyMembers: 'party:members',
|
||||
}),
|
||||
@@ -372,7 +370,7 @@ export default {
|
||||
return this.user._id === this.group.leader._id;
|
||||
},
|
||||
isAdmin () {
|
||||
return Boolean(this.user.contributor.admin);
|
||||
return Boolean(this.hasPermission(this.user, 'moderator'));
|
||||
},
|
||||
isMember () {
|
||||
return this.isMemberOfGroup(this.user, this.group);
|
||||
|
||||
@@ -213,7 +213,7 @@ label.custom-control-label(v-once) {{ $t('allowGuildInvitationsFromNonMembers')
|
||||
<!-- eslint-disable vue/no-use-v-if-with-v-for -->
|
||||
<div
|
||||
v-for="group in categoryOptions"
|
||||
v-if="group.key !== 'habitica_official' || user.contributor.admin"
|
||||
v-if="group.key !== 'habitica_official' || hasPermission(user, 'challengeAdmin')"
|
||||
:key="group.key"
|
||||
class="form-check"
|
||||
>
|
||||
@@ -372,13 +372,13 @@ label.custom-control-label(v-once) {{ $t('allowGuildInvitationsFromNonMembers')
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { mapState } from '@/libs/store';
|
||||
import toggleSwitch from '@/components/ui/toggleSwitch';
|
||||
import markdownDirective from '@/directives/markdown';
|
||||
import gemIcon from '@/assets/svg/gem.svg';
|
||||
import informationIcon from '@/assets/svg/information.svg';
|
||||
|
||||
import { MAX_SUMMARY_SIZE_FOR_GUILDS } from '@/../../common/script/constants';
|
||||
import { userStateMixin } from '../../mixins/userState';
|
||||
|
||||
// @TODO: Not sure the best way to pass party creating status
|
||||
// Since we need the modal in the header, passing props doesn't work
|
||||
@@ -393,6 +393,7 @@ export default {
|
||||
directives: {
|
||||
markdown: markdownDirective,
|
||||
},
|
||||
mixins: [userStateMixin],
|
||||
data () {
|
||||
const data = {
|
||||
workingGroup: {
|
||||
@@ -491,7 +492,6 @@ export default {
|
||||
return data;
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
editingGroup () {
|
||||
return this.$store.state.editingGroup;
|
||||
},
|
||||
@@ -512,7 +512,7 @@ export default {
|
||||
return this.workingGroup.type === 'party';
|
||||
},
|
||||
isAdmin () {
|
||||
return Boolean(this.user.contributor.admin);
|
||||
return Boolean(this.hasPermission(this.user, 'moderator'));
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
|
||||
@@ -379,7 +379,6 @@
|
||||
<script>
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { mapState } from '@/libs/store';
|
||||
|
||||
import removeMemberModal from '@/components/members/removeMemberModal';
|
||||
import loadingGryphon from '@/components/ui/loadingGryphon';
|
||||
@@ -390,6 +389,7 @@ import starIcon from '@/assets/members/star.svg';
|
||||
import dots from '@/assets/svg/dots.svg';
|
||||
import SelectList from '@/components/ui/selectList';
|
||||
import { PAGES } from '@/libs/consts';
|
||||
import { userStateMixin } from '../../mixins/userState';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -398,6 +398,7 @@ export default {
|
||||
removeMemberModal,
|
||||
loadingGryphon,
|
||||
},
|
||||
mixins: [userStateMixin],
|
||||
props: ['hideBadge'],
|
||||
data () {
|
||||
return {
|
||||
@@ -462,13 +463,12 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
isLeader () {
|
||||
if (!this.group || !this.group.leader) return false;
|
||||
return this.user._id === this.group.leader || this.user._id === this.group.leader._id;
|
||||
},
|
||||
isAdmin () {
|
||||
return Boolean(this.user.contributor.admin);
|
||||
return Boolean(this.hasPermission(this.user, 'moderator'));
|
||||
},
|
||||
isLoadMoreAvailable () {
|
||||
// Only available if the current length of `members` is less than the
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
<div class="row standard-page">
|
||||
<div>
|
||||
<div v-if="user.contributor.admin">
|
||||
<div v-if="hasPermission(user, 'userSupport')">
|
||||
<h2>Reward User</h2>
|
||||
<div
|
||||
v-if="!hero.profile"
|
||||
@@ -247,7 +247,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('name') }}</th>
|
||||
<th v-if="user.contributor && user.contributor.admin">
|
||||
<th v-if="hasPermission(user, 'userSupport')">
|
||||
{{ $t('userId') }}
|
||||
</th>
|
||||
<th>{{ $t('contribLevel') }}</th>
|
||||
@@ -257,12 +257,12 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(hero, index) in heroes"
|
||||
v-for="hero in heroes"
|
||||
:key="hero._id"
|
||||
>
|
||||
<td>
|
||||
<user-link
|
||||
v-if="hero.contributor && hero.contributor.admin"
|
||||
v-if="hasPermission(hero, 'userSupport')"
|
||||
:user="hero"
|
||||
:popover="$t('gamemaster')"
|
||||
popover-trigger="mouseenter"
|
||||
@@ -274,11 +274,16 @@
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
v-if="user.contributor.admin"
|
||||
v-if="hasPermission(hero, 'userSupport')"
|
||||
:key="hero._id"
|
||||
class="btn-link"
|
||||
@click="populateContributorInput(hero._id, index)"
|
||||
>
|
||||
<router-link
|
||||
:to="{ name: 'adminPanelUser',
|
||||
params: { userIdentifier: hero._id } }"
|
||||
>
|
||||
{{ hero._id }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ hero.contributor.level }}</td>
|
||||
<td>{{ hero.contributor.text }}</td>
|
||||
@@ -305,10 +310,8 @@
|
||||
|
||||
<script>
|
||||
import each from 'lodash/each';
|
||||
|
||||
import markdownDirective from '@/directives/markdown';
|
||||
import styleHelper from '@/mixins/styleHelper';
|
||||
import { mapState } from '@/libs/store';
|
||||
import * as quests from '@/../../common/script/content/quests';
|
||||
import { mountInfo, petInfo } from '@/../../common/script/content/stable';
|
||||
import content from '@/../../common/script/content';
|
||||
@@ -316,6 +319,7 @@ import gear from '@/../../common/script/content/gear';
|
||||
import notifications from '@/mixins/notifications';
|
||||
import userLink from '../userLink';
|
||||
import PurchaseHistoryTable from '../ui/purchaseHistoryTable.vue';
|
||||
import { userStateMixin } from '../../mixins/userState';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -325,7 +329,7 @@ export default {
|
||||
directives: {
|
||||
markdown: markdownDirective,
|
||||
},
|
||||
mixins: [notifications, styleHelper],
|
||||
mixins: [notifications, styleHelper, userStateMixin],
|
||||
data () {
|
||||
return {
|
||||
heroes: [],
|
||||
@@ -347,9 +351,6 @@ export default {
|
||||
expandTransactions: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
},
|
||||
async mounted () {
|
||||
this.$store.dispatch('common:setTitle', {
|
||||
section: this.$t('hallContributors'),
|
||||
@@ -392,11 +393,9 @@ export default {
|
||||
},
|
||||
getFormattedItemReference (pathPrefix, itemKeys, values) {
|
||||
let finishedString = '\n'.concat('path: ', pathPrefix, ', ', 'value: {', values, '}\n');
|
||||
|
||||
each(itemKeys, key => {
|
||||
finishedString = finishedString.concat('\t', pathPrefix, '.', key, '\n');
|
||||
});
|
||||
|
||||
return finishedString;
|
||||
},
|
||||
async loadHero (uuid, heroIndex) {
|
||||
@@ -413,7 +412,6 @@ export default {
|
||||
this.expandAuth = false;
|
||||
},
|
||||
async saveHero () {
|
||||
this.hero.contributor.admin = this.hero.contributor.level > 7;
|
||||
const heroUpdated = await this.$store.dispatch('hall:updateHero', { heroDetails: this.hero });
|
||||
this.text('User updated');
|
||||
this.hero = {};
|
||||
@@ -426,11 +424,6 @@ export default {
|
||||
this.heroID = -1;
|
||||
this.currentHeroIndex = -1;
|
||||
},
|
||||
populateContributorInput (id, index) {
|
||||
this.heroID = id;
|
||||
window.scrollTo(0, 200);
|
||||
this.loadHero(id, index);
|
||||
},
|
||||
async toggleTransactionsOpen () {
|
||||
this.expandTransactions = !this.expandTransactions;
|
||||
if (this.expandTransactions) {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('name') }}</th>
|
||||
<th v-if="user.contributor.admin">
|
||||
<th v-if="hasPermission(user, 'userSupport')">
|
||||
{{ $t('userId') }}
|
||||
</th>
|
||||
<th>{{ $t('backerTier') }}</th>
|
||||
@@ -28,7 +28,7 @@
|
||||
></a>
|
||||
{{ patron.profile.name }}
|
||||
</td>
|
||||
<td v-if="user.contributor.admin">
|
||||
<td v-if="hasPermission(user, 'userSupport')">
|
||||
{{ patron._id }}
|
||||
</td>
|
||||
<td>{{ patron.backer.tier }}</td>
|
||||
@@ -40,19 +40,16 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from '@/libs/store';
|
||||
import styleHelper from '@/mixins/styleHelper';
|
||||
import { userStateMixin } from '../../mixins/userState';
|
||||
|
||||
export default {
|
||||
mixins: [styleHelper],
|
||||
mixins: [styleHelper, userStateMixin],
|
||||
data () {
|
||||
return {
|
||||
patrons: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
},
|
||||
async mounted () {
|
||||
this.$store.dispatch('common:setTitle', {
|
||||
section: this.$t('hallPatrons'),
|
||||
|
||||
@@ -297,6 +297,14 @@
|
||||
{{ $t('help') }}
|
||||
</router-link>
|
||||
<div class="topbar-dropdown">
|
||||
<router-link
|
||||
v-if="user.permissions.fullAccess ||
|
||||
user.permissions.userSupport || user.permissions.newsPoster"
|
||||
class="topbar-dropdown-item dropdown-item"
|
||||
:to="{name: 'adminPanel'}"
|
||||
>
|
||||
Admin Panel
|
||||
</router-link>
|
||||
<router-link
|
||||
class="topbar-dropdown-item dropdown-item"
|
||||
:to="{name: 'faq'}"
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
{{ $t('subscription') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="user.contributor.admin"
|
||||
v-if="hasPermission(user, 'userSupport')"
|
||||
class="nav-link"
|
||||
:to="{name: 'transactions'}"
|
||||
:class="{'active': $route.name === 'transactions'}"
|
||||
@@ -123,11 +123,13 @@ import find from 'lodash/find';
|
||||
import { mapState } from '@/libs/store';
|
||||
import SecondaryMenu from '@/components/secondaryMenu';
|
||||
import gifts from '@/assets/svg/gifts-vertical.svg';
|
||||
import { userStateMixin } from '../../mixins/userState';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SecondaryMenu,
|
||||
},
|
||||
mixins: [userStateMixin],
|
||||
data () {
|
||||
return {
|
||||
icons: Object.freeze({
|
||||
@@ -138,7 +140,6 @@ export default {
|
||||
computed: {
|
||||
...mapState({
|
||||
currentEventList: 'worldState.data.currentEventList',
|
||||
user: 'user.data',
|
||||
}),
|
||||
currentEvent () {
|
||||
return find(this.currentEventList, event => Boolean(event.promo));
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div>
|
||||
<small>{{ $t('couponText') }}</small>
|
||||
</div>
|
||||
<div v-if="user.contributor.sudo">
|
||||
<div v-if="user.permissions.coupons">
|
||||
<hr>
|
||||
<h4>{{ $t('generateCodes') }}</h4>
|
||||
<div
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
></div>
|
||||
</button>
|
||||
<button
|
||||
v-if="userLoggedIn.contributor.admin"
|
||||
v-if="hasPermission(userLoggedIn, 'moderator')"
|
||||
v-b-tooltip.hover.right="'Admin - Toggle Tools'"
|
||||
class="btn btn-secondary positive-icon d-flex justify-content-center align-items-center"
|
||||
@click="toggleAdminTools()"
|
||||
@@ -71,7 +71,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="userLoggedIn.contributor.admin && adminToolsLoaded"
|
||||
v-if="hasPermission(userLoggedIn, 'moderator') && adminToolsLoaded"
|
||||
class="row admin-profile-actions"
|
||||
>
|
||||
<div class="col-12 text-right">
|
||||
@@ -111,6 +111,12 @@
|
||||
class="admin-action"
|
||||
@click="adminUnblockUser()"
|
||||
>un-ban</span>
|
||||
<router-link
|
||||
:to="{ name: 'adminPanelUser', params: { userIdentifier: userId } }"
|
||||
replace
|
||||
>
|
||||
Admin Panel
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
@@ -730,6 +736,7 @@ import challenge from '@/assets/svg/challenge.svg';
|
||||
import member from '@/assets/svg/member-icon.svg';
|
||||
import staff from '@/assets/svg/tier-staff.svg';
|
||||
import error404 from '../404';
|
||||
import { userCustomStateMixin } from '../../mixins/userState';
|
||||
// @TODO: EMAILS.COMMUNITY_MANAGER_EMAIL
|
||||
const COMMUNITY_MANAGER_EMAIL = 'admin@habitica.com';
|
||||
|
||||
@@ -742,6 +749,7 @@ export default {
|
||||
profileStats,
|
||||
error404,
|
||||
},
|
||||
mixins: [userCustomStateMixin('userLoggedIn')],
|
||||
props: ['userId', 'startingPage'],
|
||||
data () {
|
||||
return {
|
||||
@@ -780,7 +788,6 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
userLoggedIn: 'user.data',
|
||||
flatGear: 'content.gear.flat',
|
||||
}),
|
||||
userJoinedDate () {
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import { mapState } from '@/libs/store';
|
||||
|
||||
export const userStateMixin = { // eslint-disable-line import/prefer-default-export
|
||||
export const userCustomStateMixin = fieldname => {
|
||||
const map = { };
|
||||
map[fieldname] = 'user.data';
|
||||
return { // eslint-disable-line import/prefer-default-export
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
...mapState(map),
|
||||
},
|
||||
methods: {
|
||||
hasPermission (user, permission) {
|
||||
return Boolean((user.permissions
|
||||
&& (user.permissions[permission] || user.permissions.fullAccess))
|
||||
|| (user.contributor && user.contributor.admin));
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const userStateMixin = userCustomStateMixin('user');
|
||||
|
||||
@@ -800,7 +800,7 @@ export default {
|
||||
|
||||
await this.reload();
|
||||
|
||||
// close members modal if the Private Messages page is opened in an existing tab
|
||||
// close modal if the Private Messages page is opened in an existing tab
|
||||
this.$root.$emit('bv::hide::modal', 'profile');
|
||||
this.$root.$emit('bv::hide::modal', 'members-modal');
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import handleRedirect from './handleRedirect';
|
||||
import ParentPage from '@/components/parentPage';
|
||||
import { PAGES } from '@/libs/consts';
|
||||
|
||||
// NOTE: when adding a page make sure to implement setTitle
|
||||
// NOTE: when adding a page make sure to implement the `common:setTitle` action
|
||||
|
||||
// Static Pages
|
||||
const StaticWrapper = () => import(/* webpackChunkName: "entry" */'@/components/static/staticWrapper');
|
||||
@@ -53,6 +53,10 @@ const HallPage = () => import(/* webpackChunkName: "hall" */'@/components/hall/i
|
||||
const PatronsPage = () => import(/* webpackChunkName: "hall" */'@/components/hall/patrons');
|
||||
const HeroesPage = () => import(/* webpackChunkName: "hall" */'@/components/hall/heroes');
|
||||
|
||||
// Admin Panel
|
||||
const AdminPanelPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin-panel');
|
||||
const AdminPanelUserPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin-panel/user-support');
|
||||
|
||||
// Except for tasks that are always loaded all the other main level
|
||||
// All the main level
|
||||
// components are loaded in separate webpack chunks.
|
||||
@@ -109,7 +113,7 @@ const router = new VueRouter({
|
||||
scrollBehavior () {
|
||||
return { x: 0, y: 0 };
|
||||
},
|
||||
// requiresLogin is true by default, isStatic false
|
||||
// meta defaults: requiresLogin true, privilegeNeeded empty
|
||||
// NOTE: when adding a new route entry make sure to implement the `common:setTitle` action
|
||||
// in the route component to set a specific subtitle for the page.
|
||||
routes: [
|
||||
@@ -348,6 +352,31 @@ const router = new VueRouter({
|
||||
{ name: 'contributors', path: 'contributors', component: HeroesPage },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
name: 'adminPanel',
|
||||
path: '/admin-panel',
|
||||
component: AdminPanelPage,
|
||||
meta: {
|
||||
privilegeNeeded: [ // any one of these is enough to give access
|
||||
'userSupport',
|
||||
'newsPoster',
|
||||
],
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'adminPanelUser',
|
||||
path: ':userIdentifier', // User ID or Username
|
||||
component: AdminPanelUserPage,
|
||||
meta: {
|
||||
privilegeNeeded: [
|
||||
'userSupport',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Only used to handle some redirects
|
||||
// See router.beforeEach
|
||||
{ path: '/redirect/:redirect', name: 'redirect' },
|
||||
@@ -357,9 +386,10 @@ const router = new VueRouter({
|
||||
|
||||
const store = getStore();
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const { isUserLoggedIn } = store.state;
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const { isUserLoggedIn, isUserLoaded } = store.state;
|
||||
const routeRequiresLogin = to.meta.requiresLogin !== false;
|
||||
const routePrivilegeNeeded = to.meta.privilegeNeeded;
|
||||
|
||||
if (to.name === 'redirect') return handleRedirect(to, from, next);
|
||||
|
||||
@@ -392,6 +422,17 @@ router.beforeEach((to, from, next) => {
|
||||
return next({ name: 'tasks' });
|
||||
}
|
||||
|
||||
if (routePrivilegeNeeded) {
|
||||
// Redirect non-admin users when trying to access a page.
|
||||
if (!isUserLoaded) await store.dispatch('user:fetch');
|
||||
if (!store.state.user.data.permissions.fullAccess) {
|
||||
const userHasPriv = routePrivilegeNeeded.some(
|
||||
privName => store.state.user.data.permissions[privName],
|
||||
);
|
||||
if (!userHasPriv) return next({ name: 'tasks' });
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect old guild urls
|
||||
if (to.hash.indexOf('#/options/groups/guilds/') !== -1) {
|
||||
const splits = to.hash.split('/');
|
||||
|
||||
@@ -26,3 +26,9 @@ export async function getPatrons (store, payload) {
|
||||
const response = await axios.get(url);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function getHeroParty (store, payload) {
|
||||
const url = `/api/v4/hall/heroes/party/${payload.groupId}`;
|
||||
const response = await axios.get(url);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function canDelete (store) {
|
||||
const user = store.state.user.data;
|
||||
const userId = user.id || user._id;
|
||||
|
||||
const isUserAdmin = user.contributor && !!user.contributor.admin;
|
||||
const isUserAdmin = user.permissions && user.permissions.challengeAdmin;
|
||||
const isUserGroupLeader = group && (group.leader
|
||||
&& group.leader._id === userId);
|
||||
const isUserGroupManager = group && (group.managers
|
||||
@@ -84,7 +84,7 @@ export function canEdit (store) {
|
||||
const user = store.state.user.data;
|
||||
const userId = user.id || user._id;
|
||||
|
||||
const isUserAdmin = user.contributor && !!user.contributor.admin;
|
||||
const isUserAdmin = user.permissions && user.permissions.challengeAdmin;
|
||||
const isUserGroupLeader = group && (group.leader
|
||||
&& group.leader._id === userId);
|
||||
const isUserGroupManager = group && (group.managers
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('canDelete getter', () => {
|
||||
});
|
||||
|
||||
it('can Delete any challenge task as admin', () => {
|
||||
store.state.user.data.contributor.admin = true;
|
||||
store.state.user.data.permissions = { challengeAdmin: true };
|
||||
|
||||
expect(store.getters['tasks:canDelete'](task, 'challenge', true, null, challenge)).to.equal(true);
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('canEdit getter', () => {
|
||||
});
|
||||
|
||||
it('can Edit any challenge task if admin', () => {
|
||||
store.state.user.data.contributor.admin = true;
|
||||
store.state.user.data.permissions = { challengeAdmin: true };
|
||||
|
||||
expect(store.getters['tasks:canEdit'](task, 'challenge', true, null, challenge)).to.equal(true);
|
||||
expect(store.getters['tasks:canEdit'](task, 'challenge', false, null, challenge)).to.equal(true);
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"hallContributors": "Hall of Contributors",
|
||||
"hallPatrons": "Hall of Patrons",
|
||||
"noAdminAccess": "You don't have admin access.",
|
||||
"noPrivAccess": "You don't have the required privileges.",
|
||||
"userNotFound": "User not found.",
|
||||
"invalidUUID": "UUID must be valid",
|
||||
"title": "Title",
|
||||
|
||||
@@ -14,9 +14,10 @@ export default {
|
||||
guildsOnlyPaginate: 'Only public guilds support pagination.',
|
||||
guildsPaginateBooleanString: 'req.query.paginate must be a boolean string.',
|
||||
groupIdRequired: 'req.params.groupId must contain a groupId.',
|
||||
groupWithIDNotFound: 'Group with id "<%= groupId %>" not found.',
|
||||
groupRemainOrLeaveChallenges: 'req.query.keep must be either "remain-in-challenges" or "leave-challenges"',
|
||||
managerIdRequired: 'req.body.managerId must contain a User ID.',
|
||||
noSudoAccess: 'You don\'t have sudo access.',
|
||||
noPrivAccess: 'You don\'t have the required privileges.',
|
||||
|
||||
eventRequired: '"req.params.event" is required.',
|
||||
countRequired: '"req.query.count" is required.',
|
||||
|
||||
@@ -56,6 +56,19 @@
|
||||
* }
|
||||
*/
|
||||
|
||||
/**
|
||||
* @apiDefine NoPrivs You don't have the required privileges.
|
||||
*
|
||||
* @apiError (401) {NotAuthorized} NoPrivs User does not have the required admin privileges.
|
||||
*
|
||||
* @apiErrorExample User does not have the required privileges.
|
||||
* {
|
||||
* "success": false,
|
||||
* "error": "NotAuthorized",
|
||||
* "message": "You don't have the required privileges."
|
||||
* }
|
||||
*/
|
||||
|
||||
/**
|
||||
* @apiDefine NoUser No user
|
||||
* @apiError (404) {NotFound} NoUser The specified user could not be found.
|
||||
|
||||
@@ -401,14 +401,14 @@ api.clearChatFlags = {
|
||||
const validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
if (!user.contributor.admin) {
|
||||
if (!user.hasPermission('moderator')) {
|
||||
throw new NotAuthorized(res.t('messageGroupChatAdminClearFlagCount'));
|
||||
}
|
||||
|
||||
const group = await Group.getGroup({
|
||||
user,
|
||||
groupId,
|
||||
optionalMembership: user.contributor.admin,
|
||||
optionalMembership: user.hasPermission('moderator'),
|
||||
});
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
|
||||
@@ -550,7 +550,7 @@ api.deleteChat = {
|
||||
const message = await Chat.findOne({ _id: chatId }).exec();
|
||||
if (!message) throw new NotFound(res.t('messageGroupChatNotFound'));
|
||||
|
||||
if (user._id !== message.uuid && !user.contributor.admin) {
|
||||
if (user._id !== message.uuid && !user.hasPermission('moderator')) {
|
||||
throw new NotAuthorized(res.t('onlyCreatorOrAdminCanDeleteChat'));
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
authWithHeaders,
|
||||
authWithSession,
|
||||
} from '../../middlewares/auth';
|
||||
import { ensureSudo } from '../../middlewares/ensureAccessRight';
|
||||
import { ensurePermission } from '../../middlewares/ensureAccessRight';
|
||||
import * as couponsLib from '../../libs/coupons';
|
||||
import apiError from '../../libs/apiError';
|
||||
import { model as Coupon } from '../../models/coupon';
|
||||
@@ -35,7 +35,7 @@ const api = {};
|
||||
api.getCoupons = {
|
||||
method: 'GET',
|
||||
url: '/coupons',
|
||||
middlewares: [authWithSession, ensureSudo],
|
||||
middlewares: [authWithSession, ensurePermission('coupons')],
|
||||
async handler (req, res) {
|
||||
const coupons = await Coupon.find().sort('createdAt').lean().exec();
|
||||
|
||||
@@ -70,7 +70,7 @@ api.getCoupons = {
|
||||
api.generateCoupons = {
|
||||
method: 'POST',
|
||||
url: '/coupons/generate/:event',
|
||||
middlewares: [authWithHeaders(), ensureSudo],
|
||||
middlewares: [authWithHeaders(), ensurePermission('coupons')],
|
||||
async handler (req, res) {
|
||||
req.checkParams('event', apiError('eventRequired')).notEmpty();
|
||||
req.checkQuery('count', apiError('countRequired')).notEmpty().isNumeric();
|
||||
|
||||
@@ -90,7 +90,7 @@ api.setCron = {
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/debug/make-admin Sets contributor.admin to true
|
||||
* @api {post} /api/v3/debug/make-admin Sets admin privileges for current user
|
||||
* @apiName setCron
|
||||
* @apiGroup Development
|
||||
* @apiPermission Developers
|
||||
@@ -104,7 +104,7 @@ api.makeAdmin = {
|
||||
async handler (req, res) {
|
||||
const { user } = res.locals;
|
||||
|
||||
user.contributor.admin = true;
|
||||
user.permissions.fullAccess = true;
|
||||
|
||||
await user.save();
|
||||
|
||||
|
||||
@@ -465,12 +465,12 @@ api.updateGroup = {
|
||||
|
||||
const validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
const optionalMembership = Boolean(user.contributor.admin);
|
||||
const optionalMembership = Boolean(user.hasPermission('moderator'));
|
||||
const group = await Group.getGroup({ user, groupId: req.params.groupId, optionalMembership });
|
||||
|
||||
if (!group) throw new NotFound(res.t('groupNotFound'));
|
||||
|
||||
if (user.contributor.admin) {
|
||||
if (user.hasPermission('moderator')) {
|
||||
if (req.body.bannedWordsAllowed === true) {
|
||||
group.bannedWordsAllowed = true;
|
||||
} else {
|
||||
@@ -479,7 +479,7 @@ api.updateGroup = {
|
||||
}
|
||||
|
||||
if (group.leader !== user._id && group.type === 'party') throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate'));
|
||||
else if (group.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate'));
|
||||
else if (group.leader !== user._id && !user.hasPermission('moderator')) throw new NotAuthorized(res.t('messageGroupOnlyLeaderCanUpdate'));
|
||||
|
||||
if (req.body.leader !== user._id && group.hasNotCancelled()) throw new NotAuthorized(res.t('cannotChangeLeaderWithActiveGroupPlan'));
|
||||
|
||||
@@ -934,7 +934,7 @@ api.removeGroupMember = {
|
||||
|
||||
const validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
const optionalMembership = Boolean(user.contributor.admin);
|
||||
const optionalMembership = Boolean(user.hasPermission('moderator'));
|
||||
const group = await Group.getGroup({
|
||||
user, groupId: req.params.groupId, optionalMembership, fields: '-chat',
|
||||
}); // Do not fetch chat
|
||||
@@ -944,9 +944,9 @@ api.removeGroupMember = {
|
||||
const uuid = req.params.memberId;
|
||||
|
||||
if (group.leader !== user._id && group.type === 'party') throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember'));
|
||||
if (group.leader !== user._id && !user.contributor.admin) throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember'));
|
||||
if (group.leader !== user._id && !user.hasPermission('moderator')) throw new NotAuthorized(res.t('onlyLeaderCanRemoveMember'));
|
||||
|
||||
if (group.leader === uuid && user.contributor.admin) throw new NotAuthorized(res.t('cannotRemoveCurrentLeader'));
|
||||
if (group.leader === uuid && user.hasPermission('moderator')) throw new NotAuthorized(res.t('cannotRemoveCurrentLeader'));
|
||||
|
||||
if (user._id === uuid) throw new NotAuthorized(res.t('memberCannotRemoveYourself'));
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import _ from 'lodash';
|
||||
import validator from 'validator';
|
||||
import { authWithHeaders } from '../../middlewares/auth';
|
||||
import { ensureAdmin } from '../../middlewares/ensureAccessRight';
|
||||
import { ensurePermission } from '../../middlewares/ensureAccessRight';
|
||||
import { model as User } from '../../models/user';
|
||||
import { model as Group } from '../../models/group';
|
||||
import common from '../../../common';
|
||||
import {
|
||||
NotFound,
|
||||
} from '../../libs/errors';
|
||||
@@ -144,7 +146,12 @@ api.getHeroes = {
|
||||
// Note, while the following routes are called getHero / updateHero
|
||||
// they can be used by admins to get/update any user
|
||||
|
||||
const heroAdminFields = 'contributor balance profile.name purchased items auth flags.chatRevoked flags.chatShadowMuted secret';
|
||||
const heroAdminFields = 'auth balance contributor flags items lastCron party preferences profile.name purchased secret permissions';
|
||||
const heroAdminFieldsToFetch = heroAdminFields; // these variables will make more sense when...
|
||||
const heroAdminFieldsToShow = heroAdminFields; // ... apiTokenObscured is added
|
||||
|
||||
const heroPartyAdminFields = 'balance challengeCount leader leaderOnly memberCount purchased quest';
|
||||
// must never include Party name, description, summary, leaderMessage
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/hall/heroes/:heroId Get any user ("hero") given the UUID or Username
|
||||
@@ -153,7 +160,7 @@ const heroAdminFields = 'contributor balance profile.name purchased items auth f
|
||||
* @apiGroup Hall
|
||||
* @apiPermission Admin
|
||||
*
|
||||
* @apiDescription Returns the profile of the given user. User does not need to be a contributor.
|
||||
* @apiDescription Returns various data about the user. User does not need to be a contributor.
|
||||
*
|
||||
* @apiSuccess {Object} data The user object
|
||||
*
|
||||
@@ -165,7 +172,7 @@ const heroAdminFields = 'contributor balance profile.name purchased items auth f
|
||||
api.getHero = {
|
||||
method: 'GET',
|
||||
url: '/hall/heroes/:heroId',
|
||||
middlewares: [authWithHeaders(), ensureAdmin],
|
||||
middlewares: [authWithHeaders(), ensurePermission('userSupport')],
|
||||
async handler (req, res) {
|
||||
req.checkParams('heroId', res.t('heroIdRequired')).notEmpty();
|
||||
|
||||
@@ -183,16 +190,17 @@ api.getHero = {
|
||||
|
||||
const hero = await User
|
||||
.findOne(query)
|
||||
.select(heroAdminFields)
|
||||
.select(heroAdminFieldsToFetch)
|
||||
.exec();
|
||||
|
||||
if (!hero) throw new NotFound(res.t('userWithIDNotFound', { userId: heroId }));
|
||||
const heroRes = hero.toJSON({ minimize: true });
|
||||
heroRes.secret = hero.getSecretData();
|
||||
|
||||
// supply to the possible absence of hero.contributor
|
||||
// if we didn't pass minimize: true it would have returned all fields as empty
|
||||
if (!heroRes.contributor) heroRes.contributor = {};
|
||||
|
||||
heroRes.secret = hero.getSecretData();
|
||||
|
||||
res.respond(200, heroRes);
|
||||
},
|
||||
};
|
||||
@@ -209,9 +217,8 @@ const gemsPerTier = {
|
||||
* @apiGroup Hall
|
||||
* @apiPermission Admin
|
||||
*
|
||||
* @apiDescription Update user's gem balance, contributions and contribution tier,
|
||||
* or admin status. Grant items. Block / unblock user's account.
|
||||
* Revoke / unrevoke chat privileges.
|
||||
* @apiDescription Update various details in the user's User document,
|
||||
* including but not limited to privileges, gems, contributions, items.
|
||||
*
|
||||
* @apiExample Example Body:
|
||||
* {
|
||||
@@ -224,12 +231,17 @@ const gemsPerTier = {
|
||||
* "purchased": {"ads": true},
|
||||
* "contributor": {
|
||||
* "admin": true,
|
||||
* "newsPoster": false,
|
||||
* "contributions": "Improving API documentation",
|
||||
* "level": 5,
|
||||
* "text": "Scribe, Blacksmith"
|
||||
* },
|
||||
* "secret": {
|
||||
* "text": "child with permission to use site",
|
||||
* },
|
||||
* "itemPath": "items.pets.BearCub-Skeleton",
|
||||
* "itemVal": 1
|
||||
* "itemVal": 5,
|
||||
* "changeApiToken": true,
|
||||
* }
|
||||
*
|
||||
* @apiSuccess {Object} data The updated user object
|
||||
@@ -242,7 +254,7 @@ const gemsPerTier = {
|
||||
api.updateHero = {
|
||||
method: 'PUT',
|
||||
url: '/hall/heroes/:heroId',
|
||||
middlewares: [authWithHeaders(), ensureAdmin],
|
||||
middlewares: [authWithHeaders(), ensurePermission('userSupport')],
|
||||
async handler (req, res) {
|
||||
const { heroId } = req.params;
|
||||
const updateData = req.body;
|
||||
@@ -275,6 +287,7 @@ api.updateHero = {
|
||||
}
|
||||
|
||||
if (updateData.contributor) _.assign(hero.contributor, updateData.contributor);
|
||||
if (updateData.permissions && res.locals.user.hasPermission('userSupport')) _.assign(hero.permissions, updateData.permissions);
|
||||
if (updateData.purchased && updateData.purchased.ads) {
|
||||
hero.purchased.ads = updateData.purchased.ads;
|
||||
}
|
||||
@@ -310,11 +323,13 @@ api.updateHero = {
|
||||
}
|
||||
}
|
||||
|
||||
if (updateData.changeApiToken) hero.apiToken = common.uuid();
|
||||
|
||||
const savedHero = await hero.save();
|
||||
const heroJSON = savedHero.toJSON();
|
||||
heroJSON.secret = savedHero.getSecretData();
|
||||
const responseHero = { _id: heroJSON._id }; // only respond with important fields
|
||||
heroAdminFields.split(' ').forEach(field => {
|
||||
heroAdminFieldsToShow.split(' ').forEach(field => {
|
||||
_.set(responseHero, field, _.get(heroJSON, field));
|
||||
});
|
||||
|
||||
@@ -322,4 +337,49 @@ api.updateHero = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @api {get} /api/v3/hall/heroes/party/:groupId Get any Party given its ID
|
||||
* @apiParam (Path) {UUID} groupId party's group ID
|
||||
* @apiName GetHeroParty
|
||||
* @apiGroup Hall
|
||||
* @apiPermission userSupport
|
||||
*
|
||||
* @apiDescription Returns some basic information about a given Party,
|
||||
* to assist admins with user support.
|
||||
*
|
||||
* @apiSuccess {Object} data The party object (contains computed fields
|
||||
* that are not in the Group model)
|
||||
*
|
||||
* @apiUse NoAuthHeaders
|
||||
* @apiUse NoAccount
|
||||
* @apiUse NoUser
|
||||
* @apiUse NoPrivs
|
||||
* @apiUse groupIdRequired
|
||||
* @apiUse GroupNotFound
|
||||
*/
|
||||
api.getHeroParty = { // @TODO XXX add tests
|
||||
method: 'GET',
|
||||
url: '/hall/heroes/party/:groupId',
|
||||
middlewares: [authWithHeaders(), ensurePermission('userSupport')],
|
||||
async handler (req, res) {
|
||||
req.checkParams('groupId', apiError('groupIdRequired')).notEmpty().isUUID();
|
||||
|
||||
const validationErrors = req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
const { groupId } = req.params;
|
||||
|
||||
const query = { _id: groupId, type: 'party' };
|
||||
|
||||
const party = await Group
|
||||
.findOne(query)
|
||||
.select(heroPartyAdminFields)
|
||||
.exec();
|
||||
|
||||
if (!party) throw new NotFound(apiError('groupWithIDNotFound', { groupId }));
|
||||
const partyRes = party.toJSON();
|
||||
res.respond(200, partyRes);
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -668,7 +668,7 @@ api.sendPrivateMessage = {
|
||||
if (!receiver.flags.verifiedUsername) delete receiver.auth.local.username;
|
||||
|
||||
const objections = sender.getObjectionsToInteraction('send-private-message', receiver);
|
||||
if (objections.length > 0 && !sender.isAdmin()) throw new NotAuthorized(res.t(objections[0]));
|
||||
if (objections.length > 0 && !sender.hasPermission('moderator')) throw new NotAuthorized(res.t(objections[0]));
|
||||
|
||||
const messageSent = await sentMessage(sender, receiver, message, res.t);
|
||||
|
||||
|
||||
@@ -382,6 +382,7 @@ api.getUserAnonymized = {
|
||||
delete user.achievements.challenges;
|
||||
delete user.notifications;
|
||||
delete user.secret;
|
||||
delete user.permissions;
|
||||
|
||||
_.forEach(user.inbox.messages, msg => {
|
||||
msg.text = 'inbox message text';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { authWithHeaders } from '../../middlewares/auth';
|
||||
import { chatReporterFactory } from '../../libs/chatReporting/chatReporterFactory';
|
||||
import { ensureAdmin } from '../../middlewares/ensureAccessRight';
|
||||
import { ensurePermission } from '../../middlewares/ensureAccessRight';
|
||||
import { model as Transaction } from '../../models/transaction';
|
||||
|
||||
const api = {};
|
||||
@@ -58,7 +58,7 @@ api.flagPrivateMessage = {
|
||||
*/
|
||||
api.purchaseHistory = {
|
||||
method: 'GET',
|
||||
middlewares: [authWithHeaders(), ensureAdmin],
|
||||
middlewares: [authWithHeaders(), ensurePermission('userSupport')],
|
||||
url: '/members/:memberId/purchase-history',
|
||||
async handler (req, res) {
|
||||
req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID();
|
||||
|
||||
@@ -2,7 +2,7 @@ import _ from 'lodash';
|
||||
import { authWithHeaders } from '../../middlewares/auth';
|
||||
import apiError from '../../libs/apiError';
|
||||
import { model as NewsPost } from '../../models/newsPost';
|
||||
import { ensureNewsPoster } from '../../middlewares/ensureAccessRight';
|
||||
import { ensurePermission } from '../../middlewares/ensureAccessRight';
|
||||
import {
|
||||
NotFound,
|
||||
} from '../../libs/errors';
|
||||
@@ -70,7 +70,7 @@ api.getNews = {
|
||||
api.createNews = {
|
||||
method: 'POST',
|
||||
url: '/news',
|
||||
middlewares: [authWithHeaders(), ensureNewsPoster],
|
||||
middlewares: [authWithHeaders(), ensurePermission('news')],
|
||||
async handler (req, res) {
|
||||
const newsPost = new NewsPost(NewsPost.sanitize(req.body));
|
||||
newsPost.author = res.locals.user._id;
|
||||
@@ -146,7 +146,7 @@ api.getPost = {
|
||||
api.updateNews = {
|
||||
method: 'PUT',
|
||||
url: '/news/:postId',
|
||||
middlewares: [authWithHeaders(), ensureNewsPoster],
|
||||
middlewares: [authWithHeaders(), ensurePermission('news')],
|
||||
async handler (req, res) {
|
||||
req.checkParams('postId', apiError('postIdRequired')).notEmpty().isUUID();
|
||||
const validationErrors = req.validationErrors();
|
||||
@@ -181,7 +181,7 @@ api.updateNews = {
|
||||
api.deleteNews = {
|
||||
method: 'DELETE',
|
||||
url: '/news/:postId',
|
||||
middlewares: [authWithHeaders(), ensureNewsPoster],
|
||||
middlewares: [authWithHeaders(), ensurePermission('news')],
|
||||
async handler (req, res) {
|
||||
req.checkParams('postId', apiError('postIdRequired')).notEmpty().isUUID();
|
||||
const validationErrors = req.validationErrors();
|
||||
|
||||
@@ -56,7 +56,7 @@ export async function createChallenge (user, req, res) {
|
||||
req.body.summary = req.body.name;
|
||||
}
|
||||
req.body.leader = user._id;
|
||||
req.body.official = !!(user.contributor.admin && req.body.official);
|
||||
req.body.official = !!(user.hasPermission('challengeAdmin') && req.body.official);
|
||||
const challenge = new Challenge(Challenge.sanitize(req.body));
|
||||
|
||||
// First validate challenge so we don't save group if it's invalid (only runs sync validators)
|
||||
|
||||
@@ -37,7 +37,7 @@ export default class GroupChatReporter extends ChatReporter {
|
||||
const group = await Group.getGroup({
|
||||
user: this.user,
|
||||
groupId: this.groupId,
|
||||
optionalMembership: this.user.contributor.admin,
|
||||
optionalMembership: this.user.hasPermission('moderator'),
|
||||
});
|
||||
if (!group) throw new NotFound(this.res.t('groupNotFound'));
|
||||
|
||||
@@ -72,13 +72,13 @@ export default class GroupChatReporter extends ChatReporter {
|
||||
// Log user ids that have flagged the message
|
||||
if (!message.flags) message.flags = {};
|
||||
// TODO fix error type
|
||||
if (message.flags[this.user._id] && !this.user.contributor.admin) throw new NotFound(this.res.t('messageGroupChatFlagAlreadyReported'));
|
||||
if (message.flags[this.user._id] && !this.user.hasPermission('moderator')) throw new NotFound(this.res.t('messageGroupChatFlagAlreadyReported'));
|
||||
message.flags[this.user._id] = true;
|
||||
message.markModified('flags');
|
||||
|
||||
// Log total number of flags (publicly viewable)
|
||||
if (!message.flagCount) message.flagCount = 0;
|
||||
if (this.user.contributor.admin) {
|
||||
if (this.user.hasPermission('moderator')) {
|
||||
// Arbitrary amount, higher than 2
|
||||
message.flagCount = 5;
|
||||
} else if (increaseFlagCount) {
|
||||
|
||||
@@ -30,7 +30,7 @@ export default class InboxChatReporter extends ChatReporter {
|
||||
const validationErrors = this.req.validationErrors();
|
||||
if (validationErrors) throw validationErrors;
|
||||
|
||||
if (this.user.contributor.admin && this.req.query.userId) {
|
||||
if (this.user.hasPermission('moderator') && this.req.query.userId) {
|
||||
this.inboxUser = await User.findOne({ _id: this.req.query.userId }).exec();
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export default class InboxChatReporter extends ChatReporter {
|
||||
// Log user ids that have flagged the message
|
||||
if (!message.flags) message.flags = {};
|
||||
// TODO fix error type
|
||||
if (message.flags[this.user._id] && !this.user.contributor.admin) {
|
||||
if (message.flags[this.user._id] && !this.user.hasPermission('moderator')) {
|
||||
throw new BadRequest(this.res.t('messageGroupChatFlagAlreadyReported'));
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ export async function listConversations (owner, page) {
|
||||
|
||||
const isOwnerBlocked = user.blocks.includes(owner._id);
|
||||
|
||||
conversation.canReceive = !(user.optOut || isOwnerBlocked) || owner.isAdmin();
|
||||
conversation.canReceive = !(user.optOut || isOwnerBlocked) || owner.hasPermission('moderator');
|
||||
}
|
||||
|
||||
return conversation;
|
||||
|
||||
@@ -60,7 +60,7 @@ export function validateItemPath (itemPath) {
|
||||
|
||||
// When passed a value of an item in the user object it'll convert the
|
||||
// value to the correct format.
|
||||
// Example a numeric string like "5" applied to a food item (expecting an interger)
|
||||
// Example a numeric string like "5" applied to a food item (expecting an integer)
|
||||
// will be converted to the number 5
|
||||
export function castItemVal (itemPath, itemVal) {
|
||||
if (
|
||||
@@ -73,13 +73,19 @@ export function castItemVal (itemPath, itemVal) {
|
||||
return Number(itemVal);
|
||||
}
|
||||
|
||||
if (
|
||||
itemPath.indexOf('items.mounts') === 0
|
||||
|| itemPath.indexOf('items.gear.owned') === 0
|
||||
) {
|
||||
if (itemVal === 'true') return true;
|
||||
if (itemVal === 'false') return false;
|
||||
if (itemPath.indexOf('items.mounts') === 0) {
|
||||
// Mounts are true when you own them and null when you have used Keys to the Kennel
|
||||
// to release them.
|
||||
// They are never false but allow 'false' to be null in case of user error.
|
||||
if (itemVal === 'null' || itemVal === 'false') return null;
|
||||
if (itemVal) return true; // any truthy value
|
||||
return null; // any false value
|
||||
}
|
||||
|
||||
if (itemPath.indexOf('items.gear.owned') === 0) {
|
||||
// Gear is true when you own it and false if you previously owned it but lost it (e.g., Death)
|
||||
// It is never null but allow 'null' to be false in case of user error.
|
||||
if (itemVal === 'false' || itemVal === 'null') return false;
|
||||
return Boolean(itemVal);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import common from '../../common';
|
||||
import { getLanguageFromUser } from '../libs/language';
|
||||
|
||||
const COMMUNITY_MANAGER_EMAIL = nconf.get('EMAILS_COMMUNITY_MANAGER_EMAIL');
|
||||
const USER_FIELDS_ALWAYS_LOADED = ['_id', 'notifications', 'preferences', 'auth', 'flags'];
|
||||
const USER_FIELDS_ALWAYS_LOADED = ['_id', 'notifications', 'preferences', 'auth', 'flags', 'permissions'];
|
||||
|
||||
function getUserFields (options, req) {
|
||||
// A list of user fields that aren't needed for the route and are not loaded from the db.
|
||||
|
||||
@@ -3,32 +3,19 @@ import {
|
||||
} from '../libs/errors';
|
||||
import apiError from '../libs/apiError';
|
||||
|
||||
export function ensureAdmin (req, res, next) {
|
||||
export function ensurePermission (permission) {
|
||||
return function ensurePermissionHandler (req, res, next) {
|
||||
const { user } = res.locals;
|
||||
|
||||
if (!user.contributor.admin) {
|
||||
return next(new NotAuthorized(res.t('noAdminAccess')));
|
||||
}
|
||||
|
||||
if (user.permissions.fullAccess) {
|
||||
// No matter what is checked, fullAccess admins can do it
|
||||
return next();
|
||||
}
|
||||
|
||||
export function ensureNewsPoster (req, res, next) {
|
||||
const { user } = res.locals;
|
||||
|
||||
if (!user.contributor.newsPoster) {
|
||||
return next(new NotAuthorized(apiError('noNewsPosterAccess')));
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
export function ensureSudo (req, res, next) {
|
||||
const { user } = res.locals;
|
||||
|
||||
if (!user.contributor.sudo) {
|
||||
return next(new NotAuthorized(apiError('noSudoAccess')));
|
||||
if (!user.permissions[permission]) {
|
||||
return next(new NotAuthorized(apiError('noPrivAccess')));
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ schema.methods.isMember = function isChallengeMember (user) {
|
||||
|
||||
// Returns true if the user can modify (close, selectWinner, ...) the challenge
|
||||
schema.methods.canModify = function canModifyChallenge (user) {
|
||||
return user.contributor.admin || this.isLeader(user);
|
||||
return user.hasPermission('challengeAdmin') || this.isLeader(user);
|
||||
};
|
||||
|
||||
// Returns true if user can join the challenge
|
||||
|
||||
@@ -393,7 +393,7 @@ schema.statics.toJSONCleanChat = async function groupToJSONCleanChat (group, use
|
||||
chatMsg.timestamp = chatMsg.timestamp.getTime();
|
||||
}
|
||||
|
||||
if (!user.contributor.admin) {
|
||||
if (!user.hasPermission('moderator')) {
|
||||
// Flags are hidden to non admins
|
||||
chatMsg.flags = {};
|
||||
if (chatMsg._meta) chatMsg._meta = undefined;
|
||||
|
||||
@@ -26,7 +26,7 @@ export const model = mongoose.model('User', schema);
|
||||
export const mods = [];
|
||||
|
||||
mongoose.model('User')
|
||||
.find({ 'contributor.admin': true })
|
||||
.find({ 'contributor.moderator': true })
|
||||
.sort('-contributor.level -backer.npc profile.name')
|
||||
.select('profile contributor backer')
|
||||
.exec()
|
||||
|
||||
@@ -507,7 +507,11 @@ schema.methods.isAdmin = function isAdmin () {
|
||||
};
|
||||
|
||||
schema.methods.isNewsPoster = function isNewsPoster () {
|
||||
return Boolean(this.contributor && this.contributor.newsPoster);
|
||||
return this.hasPermission('news');
|
||||
};
|
||||
|
||||
schema.methods.hasPermission = function hasPermission (permission) {
|
||||
return Boolean(this.permissions && (this.permissions[permission] || this.permissions.fullAccess));
|
||||
};
|
||||
|
||||
// When converting to json add inbox messages from the Inbox collection
|
||||
|
||||
@@ -171,8 +171,6 @@ export default new Schema({
|
||||
max: 9,
|
||||
},
|
||||
admin: Boolean,
|
||||
newsPoster: Boolean,
|
||||
sudo: Boolean,
|
||||
// Artisan, Friend, Blacksmith, etc
|
||||
text: String,
|
||||
// a markdown textarea to list their contributions + links
|
||||
@@ -180,7 +178,14 @@ export default new Schema({
|
||||
// user can own Critical Hammer of Bug-Crushing if this has a truthy value
|
||||
critical: String,
|
||||
},
|
||||
|
||||
permissions: {
|
||||
fullAccess: Boolean, // esentially what was previously contributor.admin. Can do everything
|
||||
news: Boolean,
|
||||
userSupport: Boolean, // access User Support feature in Admin Panel
|
||||
challengeAdmin: Boolean, // Can manage and administrate challenges
|
||||
moderator: Boolean, // Can ban, flag users and manage social spaces
|
||||
coupons: Boolean, // Can generate and request coupons
|
||||
},
|
||||
balance: { $type: Number, default: 0 },
|
||||
|
||||
purchased: {
|
||||
|
||||
Reference in New Issue
Block a user