Merge branch 'develop' into sabrecat/teams-rebase

This commit is contained in:
SabreCat
2022-05-03 15:53:11 -05:00
100 changed files with 2492 additions and 289 deletions

159
package-lock.json generated
View File

@@ -2347,6 +2347,19 @@
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"optional": true
},
"glob": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
"integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
@@ -6259,6 +6272,22 @@
"optional": true,
"requires": {
"glob": "^7.1.3"
},
"dependencies": {
"glob": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
"integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
"optional": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
}
}
},
"yallist": {
@@ -6813,6 +6842,22 @@
"requires": {
"glob": "^7.0.3",
"minimatch": "^3.0.3"
},
"dependencies": {
"glob": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
"integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
}
}
},
"fill-range": {
@@ -6928,6 +6973,21 @@
"integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==",
"requires": {
"glob": "^7.1.3"
},
"dependencies": {
"glob": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
"integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
}
}
}
}
@@ -7413,16 +7473,34 @@
"dev": true
},
"glob": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
"integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.0.1.tgz",
"integrity": "sha512-cF7FYZZ47YzmCu7dDy50xSRRfO3ErRfrXuLZcNIuyiJEco0XSrGtuilG19L5xp3NcwTx7Gn+X6Tv3fmsUPTbow==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"minimatch": "^5.0.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"dependencies": {
"brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"requires": {
"balanced-match": "^1.0.0"
}
},
"minimatch": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz",
"integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==",
"requires": {
"brace-expansion": "^2.0.1"
}
}
}
},
"glob-parent": {
@@ -7450,6 +7528,19 @@
"unique-stream": "^2.0.2"
},
"dependencies": {
"glob": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
"integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"glob-parent": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz",
@@ -7744,6 +7835,19 @@
"slash": "^3.0.0"
},
"dependencies": {
"glob": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
"integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"ignore": {
"version": "5.1.8",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz",
@@ -9219,6 +9323,22 @@
"dev": true,
"requires": {
"glob": "^7.1.3"
},
"dependencies": {
"glob": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
"integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
}
}
}
}
@@ -10534,6 +10654,22 @@
"dev": true,
"requires": {
"glob": "^7.1.3"
},
"dependencies": {
"glob": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
"integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
}
}
}
}
@@ -12708,6 +12844,21 @@
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"requires": {
"glob": "^7.1.3"
},
"dependencies": {
"glob": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
"integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
}
}
},
"run-async": {

View File

@@ -30,7 +30,7 @@
"express": "^4.17.3",
"express-basic-auth": "^1.2.1",
"express-validator": "^5.2.0",
"glob": "^7.2.0",
"glob": "^8.0.1",
"got": "^11.8.3",
"gulp": "^4.0.0",
"gulp-babel": "^8.0.0",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -231,7 +231,7 @@ describe('GET challenges/user', () => {
publicGuild = group;
await user.update({
'contributor.admin': true,
'permissions.challengeAdmin': true,
});
officialChallenge = await generateChallenge(user, group, {

View File

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

View File

@@ -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', () => {

View File

@@ -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());

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ describe('POST /coupons/enter/:code', () => {
beforeEach(async () => {
user = await generateUser();
sudoUser = await generateUser({
'contributor.sudo': true,
'permissions.coupons': true,
});
});

View File

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

View File

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

View File

@@ -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 () => {

View File

@@ -219,11 +219,19 @@ describe('GET /groups', () => {
it('returns 30 guilds per page ordered by number of members', async () => {
await user.update({ balance: 9000 });
const groups = await Promise.all(_.times(60, i => generateGroup(user, {
const delay = () => new Promise(resolve => setTimeout(resolve, 40));
const promises = [];
for (let i = 0; i < 60; i += 1) {
promises.push(generateGroup(user, {
name: `public guild ${i} - is member`,
type: 'guild',
privacy: 'public',
})));
}));
await delay(); // eslint-disable-line no-await-in-loop
}
const groups = await Promise.all(promises);
// update group number 32 and not the first to make sure sorting works
await groups[32].update({ name: 'guild with most members', memberCount: 199 });

View File

@@ -315,7 +315,7 @@ describe('GET /groups/:id', () => {
beforeEach(async () => {
admin = await generateUser({
'contributor.admin': true,
'permissions.moderator': true,
});
});

View File

@@ -2,6 +2,7 @@ import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
import { model as Group } from '../../../../../website/server/models/group';
describe('POST /group', () => {
let user;
@@ -203,6 +204,23 @@ describe('POST /group', () => {
expect(updatedUser.balance).to.eql(user.balance - 1);
});
it('does not deduct the gems from user when guild creation fails', async () => {
const stub = sinon.stub(Group.prototype, 'save').rejects();
const promise = user.post('/groups', {
name: groupName,
type: groupType,
privacy: groupPrivacy,
});
await expect(promise).to.eventually.be.rejected;
const updatedUser = await user.get('/user');
expect(updatedUser.balance).to.eql(user.balance);
stub.restore();
});
});
});

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ describe('POST /coupons/enter/:code', () => {
beforeEach(async () => {
user = await generateUser();
sudoUser = await generateUser({
'contributor.sudo': true,
'permissions.coupons': true,
});
});

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ describe('GET /news', () => {
before(async () => {
api = requester();
const user = await generateUser({
'contributor.newsPoster': true,
'permissions.news': true,
});
await Promise.all([

View File

@@ -15,7 +15,7 @@ describe('GET /news/:newsID', () => {
};
beforeEach(async () => {
user = await generateUser({
'contributor.newsPoster': true,
'permissions.news': true,
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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?';
},
},
};

View 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' });
}
},
},
};

View File

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

View File

@@ -0,0 +1,34 @@
<template>
<div>
<h2>@{{ auth.local.username }} &nbsp; / &nbsp; {{ profile.name }}</h2>
{{ userId }} &nbsp;
<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>

View File

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

View File

@@ -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: &nbsp;
<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, &nbsp;
<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>

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'}"

View File

@@ -118,6 +118,7 @@ import { MAX_LEVEL_HARD_CAP } from '@/../../common/script/constants';
import notifications from '@/mixins/notifications';
import guide from '@/mixins/guide';
import { CONSTANTS, setLocalSetting } from '@/libs/userlocalManager';
import * as Analytics from '@/libs/analytics';
import yesterdailyModal from './tasks/yesterdailyModal';
import newStuff from './news/modal';
@@ -841,11 +842,21 @@ export default {
},
async runCronAction () {
// Run Cron
await axios.post('/api/v4/cron');
const response = await axios.post('/api/v4/cron');
if (response.status === 200) {
// Reset daily analytics actions
setLocalSetting(CONSTANTS.keyConstants.TASKS_SCORED_COUNT, 0);
setLocalSetting(CONSTANTS.keyConstants.TASKS_CREATED_COUNT, 0);
} else {
// Note a failed cron event, for our records and investigation
Analytics.track({
eventName: 'cron failed',
eventAction: 'cron failed',
eventCategory: 'behavior',
hitType: 'event',
responseCode: response.status,
}, { trackOnClient: true });
}
// Sync
await Promise.all([

View File

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

View File

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

View File

@@ -143,12 +143,11 @@ export default {
},
methods: {
close () {
this.validateInputs();
this.$root.$emit('bv::hide::modal', 'restore');
},
restore () {
if (this.restoreValues.stats.lvl < 1) {
// @TODO:
// Notification.error(env.t('invalidLevel'), true);
if (!this.validateInputs()) {
return;
}
@@ -175,6 +174,35 @@ export default {
this.$store.dispatch('user:set', settings);
this.$root.$emit('bv::hide::modal', 'restore');
},
validateInputs () {
const canRestore = ['hp', 'exp', 'gp', 'mp'];
let valid = true;
for (const stat of canRestore) {
if (this.restoreValues.stats[stat] === '') {
this.restoreValues.stats[stat] = this.user.stats[stat];
valid = false;
}
}
const inputLevel = Number(this.restoreValues.stats.lvl);
if (this.restoreValues.stats.lvl === ''
|| !Number.isInteger(inputLevel)
|| inputLevel < 1) {
this.restoreValues.stats.lvl = this.user.stats.lvl;
valid = false;
}
const inputStreak = Number(this.restoreValues.achievements.streak);
if (this.restoreValues.achievements.streak === ''
|| !Number.isInteger(inputStreak)
|| inputStreak < 0) {
this.restoreValues.achievements.streak = this.user.achievements.streak;
valid = false;
}
return valid;
},
},
};
</script>

View File

@@ -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 () {

View File

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

View File

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

View File

@@ -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('/');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.',

View File

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

View File

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

View File

@@ -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();

View File

@@ -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();

View File

@@ -137,8 +137,12 @@ api.createGroup = {
user.party._id = group._id;
}
const results = await Promise.all([user.save(), group.save()]);
const savedGroup = results[1];
let savedGroup;
await Group.db.transaction(async session => {
await user.save({ session });
savedGroup = await group.save({ session });
});
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
// await Q.ninvoke(savedGroup, 'populate', ['leader', nameFields]);
@@ -463,12 +467,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 {
@@ -477,7 +481,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'));
@@ -932,7 +936,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
@@ -942,9 +946,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'));

View File

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

View File

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

View File

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

View File

@@ -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();

View File

@@ -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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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')));
}
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.fullAccess) {
// No matter what is checked, fullAccess admins can do it
return next();
}
if (!user.permissions[permission]) {
return next(new NotAuthorized(apiError('noPrivAccess')));
}
return next();
};
}

View File

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

View File

@@ -396,7 +396,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;

View File

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

View File

@@ -522,7 +522,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

View File

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