mirror of
https://github.com/HabitRPG/habitica.git
synced 2026-05-13 03:22:52 -05:00
Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a22038d05 | |||
| 8e1bc6bcd7 | |||
| beb51fb00d | |||
| 7548834442 | |||
| 2a4886b325 | |||
| a1d0403782 | |||
| 9de6f7b3bc | |||
| d6bd10fa25 | |||
| 55f3792c96 | |||
| a390dd82a7 | |||
| d47f30fc10 | |||
| 03744c63f6 | |||
| f2a418e3fa | |||
| 1477326351 | |||
| 38b39b600c | |||
| 864293b62b | |||
| bada094139 | |||
| 1823f658c6 | |||
| 9a3e3c93eb | |||
| 7a94b031e0 | |||
| 22b5a5e6f2 | |||
| 1d68a95b64 | |||
| 88999a0751 | |||
| 2666c93e5f | |||
| 132918419a | |||
| 5faa49d032 | |||
| 55c800cb4b | |||
| deee147928 | |||
| a9cd36c109 | |||
| 4c1c00b0a3 | |||
| 245e135be8 | |||
| 978c707e17 | |||
| 623d38f281 | |||
| c88e458a97 | |||
| 9957db0669 | |||
| 2e21f58ae5 | |||
| 04be6d0744 | |||
| 8c43164e60 | |||
| 3fa940bdfd | |||
| 3492549081 | |||
| b7a6dd9706 | |||
| 78bdb52f8f | |||
| d80aaf3ab0 | |||
| 31cac936c8 | |||
| 3b54064a1f | |||
| 8b46df757f | |||
| 4a9040aefb | |||
| 723249e60d | |||
| 8b17ebd1f1 | |||
| 1c4c7b9f1e | |||
| 58887d9a3c | |||
| 664f960a8b | |||
| dfe53e8b68 | |||
| b608f0ad9c | |||
| afd1248ea3 | |||
| 0708829b2a | |||
| afa9a65933 | |||
| c8ee51b741 | |||
| 2bf63847c9 | |||
| 1d1b66d25a | |||
| 9ce4482040 | |||
| 28660c0bea | |||
| bc15d530e5 | |||
| 34b7acb246 | |||
| 4ef369c6f5 | |||
| 405721602f | |||
| 19cf89baec | |||
| 8a4e9888dd | |||
| e17b86a1f6 | |||
| 2181ab9713 | |||
| 8cb8411cc6 | |||
| 05cf0cb50d | |||
| eed6cfaf6d | |||
| 1785ac8226 | |||
| 77179d1ff1 | |||
| 55443ecc23 | |||
| 98b43c681a | |||
| 40f8c049ab | |||
| b5a7b58b57 | |||
| 006aad76d2 | |||
| 1a4af6d6bd | |||
| a0b0d1d855 | |||
| 3fb1241010 | |||
| f1d70dec18 | |||
| c7e7071998 | |||
| b4f699b7c4 | |||
| 874954b16b | |||
| 120b2e9ade | |||
| 7d00fe1ecb | |||
| 29ab8856ca | |||
| 1eb8ee4dc6 | |||
| 96c0c12c49 | |||
| 3eb9225b8b | |||
| 73a29f94a6 | |||
| 7bd190930f | |||
| d0e9339d3b | |||
| d45122ce06 | |||
| f3f5d6bb70 | |||
| 9b849e095c | |||
| 55ec42678e | |||
| adc7a6ee85 | |||
| ccc1d5b26e |
@@ -1,3 +1,7 @@
|
||||
# Files not included in deployments to Heroku, to save on file size.
|
||||
|
||||
/habitica-images
|
||||
/test
|
||||
/migrations
|
||||
/scripts
|
||||
/database_reports
|
||||
|
||||
@@ -21,6 +21,7 @@ RUN npm install -g gulp-cli mocha
|
||||
RUN mkdir -p /usr/src/habitrpg
|
||||
WORKDIR /usr/src/habitrpg
|
||||
RUN git clone --branch release --depth 1 https://github.com/HabitRPG/habitica.git /usr/src/habitrpg
|
||||
RUN git config --global url."https://".insteadOf git://
|
||||
RUN npm set unsafe-perm true
|
||||
RUN npm install
|
||||
|
||||
|
||||
+12
-4
@@ -20,17 +20,25 @@ function cssVarMap (sprite) {
|
||||
if (requiresSpecialTreatment) {
|
||||
sprite.custom = {
|
||||
px: {
|
||||
offsetX: `-${sprite.x + 25}px`,
|
||||
offsetY: `-${sprite.y + 15}px`,
|
||||
offsetX: '-25px',
|
||||
offsetY: '-15px',
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (sprite.name.indexOf('shirt') !== -1) sprite.custom.px.offsetY = `-${sprite.y + 35}px`; // even more for shirts
|
||||
|
||||
// even more for shirts
|
||||
if (sprite.name.indexOf('shirt') !== -1) {
|
||||
sprite.custom.px.offsetX = '-29px';
|
||||
sprite.custom.px.offsetY = '-42px';
|
||||
}
|
||||
|
||||
if (sprite.name.indexOf('hair_base') !== -1) {
|
||||
const styleArray = sprite.name.split('_').slice(2, 3);
|
||||
if (Number(styleArray[0]) > 14) sprite.custom.px.offsetY = `-${sprite.y}px`; // don't crop updos
|
||||
if (Number(styleArray[0]) > 14) {
|
||||
sprite.custom.px.offsetY = '0'; // don't crop updos
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
Submodule habitica-images updated: b1e0ceda42...08edadc432
Generated
+458
-281
File diff suppressed because it is too large
Load Diff
+12
-12
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "habitica",
|
||||
"description": "A habit tracker app which treats your goals like a Role Playing Game.",
|
||||
"version": "4.228.1",
|
||||
"version": "4.230.0",
|
||||
"main": "./website/server/index.js",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.17.8",
|
||||
"@babel/core": "^7.17.10",
|
||||
"@babel/preset-env": "^7.16.11",
|
||||
"@babel/register": "^7.17.7",
|
||||
"@google-cloud/trace-agent": "^5.1.6",
|
||||
@@ -13,7 +13,7 @@
|
||||
"accepts": "^1.3.8",
|
||||
"amazon-payments": "^0.2.9",
|
||||
"amplitude": "^6.0.0",
|
||||
"apidoc": "^0.51.0",
|
||||
"apidoc": "^0.51.1",
|
||||
"apple-auth": "^1.0.7",
|
||||
"bcrypt": "^5.0.1",
|
||||
"body-parser": "^1.20.0",
|
||||
@@ -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",
|
||||
@@ -43,15 +43,15 @@
|
||||
"in-app-purchase": "^1.11.3",
|
||||
"js2xmlparser": "^4.0.2",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"jwks-rsa": "^2.0.5",
|
||||
"jwks-rsa": "^2.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"merge-stream": "^2.0.0",
|
||||
"method-override": "^3.0.0",
|
||||
"moment": "^2.29.2",
|
||||
"moment": "^2.29.3",
|
||||
"moment-recur": "^1.0.7",
|
||||
"mongoose": "^5.13.7",
|
||||
"morgan": "^1.10.0",
|
||||
"nconf": "^0.11.3",
|
||||
"nconf": "^0.12.0",
|
||||
"node-gcm": "^1.0.5",
|
||||
"on-headers": "^1.0.2",
|
||||
"passport": "^0.5.0",
|
||||
@@ -61,20 +61,20 @@
|
||||
"paypal-rest-sdk": "^1.8.1",
|
||||
"pp-ipn": "^1.1.0",
|
||||
"ps-tree": "^1.0.0",
|
||||
"rate-limiter-flexible": "^2.3.6",
|
||||
"rate-limiter-flexible": "^2.3.7",
|
||||
"redis": "^3.1.2",
|
||||
"regenerator-runtime": "^0.13.9",
|
||||
"remove-markdown": "^0.3.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"short-uuid": "^4.2.0",
|
||||
"stripe": "^8.212.0",
|
||||
"superagent": "^7.1.2",
|
||||
"stripe": "^8.219.0",
|
||||
"superagent": "^7.1.3",
|
||||
"universal-analytics": "^0.5.3",
|
||||
"useragent": "^2.1.9",
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "^13.7.0",
|
||||
"vinyl-buffer": "^1.0.1",
|
||||
"winston": "^3.6.0",
|
||||
"winston": "^3.7.2",
|
||||
"winston-loggly-bulk": "^3.2.1",
|
||||
"xml2js": "^0.4.23"
|
||||
},
|
||||
@@ -122,7 +122,7 @@
|
||||
"monk": "^7.3.4",
|
||||
"require-again": "^2.0.0",
|
||||
"run-rs": "^0.7.6",
|
||||
"sinon": "^13.0.1",
|
||||
"sinon": "^13.0.2",
|
||||
"sinon-chai": "^3.7.0",
|
||||
"sinon-stub-promise": "^4.0.0"
|
||||
},
|
||||
|
||||
@@ -40,7 +40,7 @@ async function deleteHabiticaData (user, email) {
|
||||
'auth.local.passwordHashMethod': 'bcrypt',
|
||||
};
|
||||
if (!user.auth.local.email) set['auth.local.email'] = `${user._id}@example.com`;
|
||||
await User.update(
|
||||
await User.updateOne(
|
||||
{ _id: user._id },
|
||||
{ $set: set },
|
||||
);
|
||||
|
||||
@@ -99,23 +99,26 @@ describe('Items Utils', () => {
|
||||
expect(castItemVal('items.food.Cake_Invalid', '5')).to.equal(5);
|
||||
});
|
||||
|
||||
it('converts values for mounts paths to numbers', () => {
|
||||
expect(castItemVal('items.mounts.Cactus-Base', 'true')).to.equal(true);
|
||||
expect(castItemVal('items.mounts.Aether-Invisible', 'false')).to.equal(false);
|
||||
expect(castItemVal('items.mounts.Aether-Invalid', 'true')).to.equal(true);
|
||||
expect(castItemVal('items.mounts.Aether-Invalid', 'truish')).to.equal(true);
|
||||
expect(castItemVal('items.mounts.Aether-Invalid', 0)).to.equal(false);
|
||||
});
|
||||
|
||||
it('converts values for quests paths to numbers', () => {
|
||||
expect(castItemVal('items.quests.atom3', '5')).to.equal(5);
|
||||
expect(castItemVal('items.quests.invalid', '5')).to.equal(5);
|
||||
});
|
||||
|
||||
it('converts values for owned gear', () => {
|
||||
it('converts values for mounts paths to true/null', () => {
|
||||
// mounts are never false but can be null (function contains more details)
|
||||
expect(castItemVal('items.mounts.Cactus-Base', 'true')).to.equal(true);
|
||||
expect(castItemVal('items.mounts.Aether-Invisible', 'null')).to.equal(null);
|
||||
expect(castItemVal('items.mounts.Aether-Invisible', 'false')).to.equal(null);
|
||||
expect(castItemVal('items.mounts.Aether-Invalid', 'true')).to.equal(true);
|
||||
expect(castItemVal('items.mounts.Aether-Invalid', 'truthy')).to.equal(true);
|
||||
expect(castItemVal('items.mounts.Aether-Invalid', 0)).to.equal(null);
|
||||
});
|
||||
|
||||
it('converts values for owned gear to true/false', () => {
|
||||
expect(castItemVal('items.gear.owned.shield_warrior_0', 'true')).to.equal(true);
|
||||
expect(castItemVal('items.gear.owned.invalid', 'false')).to.equal(false);
|
||||
expect(castItemVal('items.gear.owned.invalid', 'thruthy')).to.equal(true);
|
||||
expect(castItemVal('items.gear.owned.invalid', 'null')).to.equal(false);
|
||||
expect(castItemVal('items.gear.owned.invalid', 'truthy')).to.equal(true);
|
||||
expect(castItemVal('items.gear.owned.invalid', 0)).to.equal(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,7 @@ import {
|
||||
generateReq,
|
||||
generateNext,
|
||||
} from '../../../helpers/api-unit.helper';
|
||||
import i18n from '../../../../website/common/script/i18n';
|
||||
import { ensureAdmin, ensureSudo, ensureNewsPoster } from '../../../../website/server/middlewares/ensureAccessRight';
|
||||
import { ensurePermission } from '../../../../website/server/middlewares/ensureAccessRight';
|
||||
import { NotAuthorized } from '../../../../website/server/libs/errors';
|
||||
import apiError from '../../../../website/server/libs/apiError';
|
||||
|
||||
@@ -20,20 +19,20 @@ describe('ensure access middlewares', () => {
|
||||
});
|
||||
|
||||
context('ensure admin', () => {
|
||||
it('returns not authorized when user is not an admin', () => {
|
||||
res.locals = { user: { contributor: { admin: false } } };
|
||||
it('returns not authorized when user is not in userSupport', () => {
|
||||
res.locals = { user: { permissions: { userSupport: false } } };
|
||||
|
||||
ensureAdmin(req, res, next);
|
||||
ensurePermission('userSupport')(req, res, next);
|
||||
|
||||
const calledWith = next.getCall(0).args;
|
||||
expect(calledWith[0].message).to.equal(i18n.t('noAdminAccess'));
|
||||
expect(calledWith[0].message).to.equal(apiError('noPrivAccess'));
|
||||
expect(calledWith[0] instanceof NotAuthorized).to.equal(true);
|
||||
});
|
||||
|
||||
it('passes when user is an admin', () => {
|
||||
res.locals = { user: { contributor: { admin: true } } };
|
||||
it('passes when user is an userSuppor', () => {
|
||||
res.locals = { user: { permissions: { userSupport: true } } };
|
||||
|
||||
ensureAdmin(req, res, next);
|
||||
ensurePermission('userSupport')(req, res, next);
|
||||
|
||||
expect(next).to.be.calledOnce;
|
||||
expect(next.args[0]).to.be.empty;
|
||||
@@ -42,40 +41,40 @@ describe('ensure access middlewares', () => {
|
||||
|
||||
context('ensure newsPoster', () => {
|
||||
it('returns not authorized when user is not a newsPoster', () => {
|
||||
res.locals = { user: { contributor: { newsPoster: false } } };
|
||||
res.locals = { user: { permissions: { news: false } } };
|
||||
|
||||
ensureNewsPoster(req, res, next);
|
||||
ensurePermission('news')(req, res, next);
|
||||
|
||||
const calledWith = next.getCall(0).args;
|
||||
expect(calledWith[0].message).to.equal(apiError('noNewsPosterAccess'));
|
||||
expect(calledWith[0].message).to.equal(apiError('noPrivAccess'));
|
||||
expect(calledWith[0] instanceof NotAuthorized).to.equal(true);
|
||||
});
|
||||
|
||||
it('passes when user is a newsPoster', () => {
|
||||
res.locals = { user: { contributor: { newsPoster: true } } };
|
||||
res.locals = { user: { permissions: { news: true } } };
|
||||
|
||||
ensureNewsPoster(req, res, next);
|
||||
ensurePermission('news')(req, res, next);
|
||||
|
||||
expect(next).to.be.calledOnce;
|
||||
expect(next.args[0]).to.be.empty;
|
||||
});
|
||||
});
|
||||
|
||||
context('ensure sudo', () => {
|
||||
it('returns not authorized when user is not a sudo user', () => {
|
||||
res.locals = { user: { contributor: { sudo: false } } };
|
||||
context('ensure coupons', () => {
|
||||
it('returns not authorized when user does not have access to coupon calls', () => {
|
||||
res.locals = { user: { permissions: { coupons: false } } };
|
||||
|
||||
ensureSudo(req, res, next);
|
||||
ensurePermission('coupons')(req, res, next);
|
||||
|
||||
const calledWith = next.getCall(0).args;
|
||||
expect(calledWith[0].message).to.equal(apiError('noSudoAccess'));
|
||||
expect(calledWith[0].message).to.equal(apiError('noPrivAccess'));
|
||||
expect(calledWith[0] instanceof NotAuthorized).to.equal(true);
|
||||
});
|
||||
|
||||
it('passes when user is a sudo user', () => {
|
||||
res.locals = { user: { contributor: { sudo: true } } };
|
||||
it('passes when user has access to coupon calls', () => {
|
||||
res.locals = { user: { permissions: { coupons: true } } };
|
||||
|
||||
ensureSudo(req, res, next);
|
||||
ensurePermission('coupons')(req, res, next);
|
||||
|
||||
expect(next).to.be.calledOnce;
|
||||
expect(next.args[0]).to.be.empty;
|
||||
|
||||
@@ -1029,7 +1029,7 @@ describe('Group Model', () => {
|
||||
expect(toJSON.chat.length).to.equal(1);
|
||||
});
|
||||
|
||||
it('shows messages with >= 2 flag to admins', async () => {
|
||||
it('shows messages with >= 2 flag to moderators', async () => {
|
||||
party.chat = [{
|
||||
flagCount: 3,
|
||||
info: {
|
||||
@@ -1037,12 +1037,12 @@ describe('Group Model', () => {
|
||||
quest: 'basilist',
|
||||
},
|
||||
}];
|
||||
const admin = new User({ 'contributor.admin': true });
|
||||
const admin = new User({ 'permissions.moderator': true });
|
||||
const toJSON = await Group.toJSONCleanChat(party, admin);
|
||||
expect(toJSON.chat.length).to.equal(1);
|
||||
});
|
||||
|
||||
it('doesn\'t show flagged messages to non-admins', async () => {
|
||||
it('doesn\'t show flagged messages to non-moderators', async () => {
|
||||
party.chat = [{
|
||||
flagCount: 3,
|
||||
info: {
|
||||
|
||||
@@ -877,7 +877,7 @@ describe('User Model', () => {
|
||||
|
||||
expect(user.isNewsPoster()).to.equal(false);
|
||||
|
||||
user.contributor.newsPoster = true;
|
||||
user.permissions = { news: true };
|
||||
expect(user.isNewsPoster()).to.equal(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ describe('GET challenges/groups/:groupId', () => {
|
||||
publicGuild = group;
|
||||
|
||||
await user.update({
|
||||
'contributor.admin': true,
|
||||
'permissions.challengeAdmin': true,
|
||||
});
|
||||
|
||||
officialChallenge = await generateChallenge(user, group, {
|
||||
|
||||
@@ -231,7 +231,7 @@ describe('GET challenges/user', () => {
|
||||
publicGuild = group;
|
||||
|
||||
await user.update({
|
||||
'contributor.admin': true,
|
||||
'permissions.challengeAdmin': true,
|
||||
});
|
||||
|
||||
officialChallenge = await generateChallenge(user, group, {
|
||||
|
||||
@@ -203,8 +203,8 @@ describe('POST /challenges', () => {
|
||||
|
||||
it('sets challenge as official if created by admin and official flag is set', async () => {
|
||||
await groupLeader.update({
|
||||
contributor: {
|
||||
admin: true,
|
||||
permissions: {
|
||||
challengeAdmin: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('DELETE /groups/:groupId/chat/:chatId', () => {
|
||||
message = await user.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message' });
|
||||
message = message.message;
|
||||
userThatDidNotCreateChat = await generateUser();
|
||||
admin = await generateUser({ 'contributor.admin': true });
|
||||
admin = await generateUser({ 'permissions.moderator': true });
|
||||
});
|
||||
|
||||
context('Chat errors', () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('POST /chat/:chatId/flag', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await generateUser({ balance: 1, 'auth.timestamps.created': moment().subtract(USER_AGE_FOR_FLAGGING + 1, 'days').toDate() });
|
||||
admin = await generateUser({ balance: 1, 'contributor.admin': true });
|
||||
admin = await generateUser({ balance: 1, 'permissions.moderator': true });
|
||||
anotherUser = await generateUser({ 'auth.timestamps.created': moment().subtract(USER_AGE_FOR_FLAGGING + 1, 'days').toDate() });
|
||||
newUser = await generateUser({ 'auth.timestamps.created': moment().subtract(1, 'days').toDate() });
|
||||
sandbox.stub(IncomingWebhook.prototype, 'send').returns(Promise.resolve());
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('POST /groups/:id/chat/:id/clearflags', () => {
|
||||
groupWithChat = group;
|
||||
author = groupLeader;
|
||||
nonAdmin = await generateUser({ 'auth.timestamps.created': moment().subtract(USER_AGE_FOR_FLAGGING + 1, 'days').toDate() });
|
||||
admin = await generateUser({ 'contributor.admin': true });
|
||||
admin = await generateUser({ 'permissions.moderator': true });
|
||||
|
||||
message = await author.post(`/groups/${groupWithChat._id}/chat`, { message: 'Some message' });
|
||||
message = message.message;
|
||||
|
||||
@@ -14,18 +14,18 @@ describe('GET /coupons/', () => {
|
||||
user = await generateUser();
|
||||
});
|
||||
|
||||
it('returns an error if user has no sudo permission', async () => {
|
||||
it('returns an error if user has no coupons permission', async () => {
|
||||
await user.get('/user'); // needed so the request after this will authenticate with the correct cookie session
|
||||
await expect(user.get('/coupons')).to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: apiError('noSudoAccess'),
|
||||
message: apiError('noPrivAccess'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the coupons in CSV format ordered by creation date', async () => {
|
||||
await user.update({
|
||||
'contributor.sudo': true,
|
||||
'permissions.coupons': true,
|
||||
});
|
||||
|
||||
const coupons = await user.post('/coupons/generate/wondercon?count=11');
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('POST /coupons/enter/:code', () => {
|
||||
beforeEach(async () => {
|
||||
user = await generateUser();
|
||||
sudoUser = await generateUser({
|
||||
'contributor.sudo': true,
|
||||
'permissions.coupons': true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,19 +14,19 @@ describe('POST /coupons/generate/:event', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await generateUser({
|
||||
'contributor.sudo': true,
|
||||
'permissions.coupons': true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error if user has no sudo permission', async () => {
|
||||
it('returns an error if user has no coupons permission', async () => {
|
||||
await user.update({
|
||||
'contributor.sudo': false,
|
||||
'permissions.coupons': false,
|
||||
});
|
||||
|
||||
await expect(user.post('/coupons/generate/aaa')).to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: apiError('noSudoAccess'),
|
||||
message: apiError('noPrivAccess'),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('POST /coupons/generate/:event', () => {
|
||||
|
||||
it('should generate coupons', async () => {
|
||||
await user.update({
|
||||
'contributor.sudo': true,
|
||||
'permissions.coupons': true,
|
||||
});
|
||||
|
||||
const coupons = await user.post('/coupons/generate/wondercon?count=2');
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('POST /coupons/validate/:code', () => {
|
||||
|
||||
it('returns true if coupon code is valid', async () => {
|
||||
const sudoUser = await generateUser({
|
||||
'contributor.sudo': true,
|
||||
'permissions.coupons': true,
|
||||
});
|
||||
|
||||
const [coupon] = await sudoUser.post('/coupons/generate/wondercon?count=1');
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
generateUser,
|
||||
} from '../../../../helpers/api-integration/v3';
|
||||
|
||||
describe('POST /debug/make-admin (pended for v3 prod testing)', () => {
|
||||
describe('POST /debug/make-admin', () => {
|
||||
let user;
|
||||
|
||||
before(async () => {
|
||||
@@ -14,12 +14,12 @@ describe('POST /debug/make-admin (pended for v3 prod testing)', () => {
|
||||
nconf.set('IS_PROD', false);
|
||||
});
|
||||
|
||||
it('makes user an admine', async () => {
|
||||
it('makes user an admin', async () => {
|
||||
await user.post('/debug/make-admin');
|
||||
|
||||
await user.sync();
|
||||
|
||||
expect(user.contributor.admin).to.eql(true);
|
||||
expect(user.permissions.fullAccess).to.eql(true);
|
||||
});
|
||||
|
||||
it('returns error when not in production mode', async () => {
|
||||
|
||||
@@ -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, {
|
||||
name: `public guild ${i} - is member`,
|
||||
type: 'guild',
|
||||
privacy: 'public',
|
||||
})));
|
||||
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 });
|
||||
|
||||
@@ -315,7 +315,7 @@ describe('GET /groups/:id', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
admin = await generateUser({
|
||||
'contributor.admin': true,
|
||||
'permissions.moderator': true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('POST /groups/:groupId/removeMember/:memberId', () => {
|
||||
invitedUser = invitees[0]; // eslint-disable-line prefer-destructuring
|
||||
member = members[0]; // eslint-disable-line prefer-destructuring
|
||||
member2 = members[1]; // eslint-disable-line prefer-destructuring
|
||||
adminUser = await generateUser({ 'contributor.admin': true });
|
||||
adminUser = await generateUser({ 'permissions.moderator': true });
|
||||
});
|
||||
|
||||
context('All Groups', () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('PUT /group', () => {
|
||||
},
|
||||
members: 1,
|
||||
});
|
||||
adminUser = await generateUser({ 'contributor.admin': true });
|
||||
adminUser = await generateUser({ 'permissions.moderator': true });
|
||||
groupToUpdate = group;
|
||||
leader = groupLeader;
|
||||
nonLeader = members[0]; // eslint-disable-line prefer-destructuring
|
||||
@@ -104,11 +104,11 @@ describe('PUT /group', () => {
|
||||
// Update the bannedWordsAllowed property for the group
|
||||
const response = await groupLeader.put(`/groups/${group._id}`, updateGroupDetails);
|
||||
|
||||
expect(groupLeader.contributor.admin).to.eql(true);
|
||||
expect(groupLeader.permissions.fullAccess).to.eql(true);
|
||||
expect(response.bannedWordsAllowed).to.eql(true);
|
||||
});
|
||||
|
||||
it('does not allow for a non-admin to update the bannedWordsAllow property for an existing guild', async () => {
|
||||
it('does not allow for a non-moderator to update the bannedWordsAllow property for an existing guild', async () => {
|
||||
const { group, groupLeader } = await createAndPopulateGroup({
|
||||
groupDetails: {
|
||||
name: 'public guild',
|
||||
@@ -128,7 +128,6 @@ describe('PUT /group', () => {
|
||||
// Update the bannedWordsAllowed property for the group
|
||||
const response = await groupLeader.put(`/groups/${group._id}`, updateGroupDetails);
|
||||
|
||||
expect(groupLeader.contributor.admin).to.eql(undefined);
|
||||
expect(response.bannedWordsAllowed).to.eql(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,9 +7,14 @@ import {
|
||||
describe('GET /heroes/:heroId', () => {
|
||||
let user;
|
||||
|
||||
const heroFields = [
|
||||
'_id', 'id', 'auth', 'balance', 'contributor', 'flags', 'items',
|
||||
'lastCron', 'party', 'preferences', 'profile', 'purchased', 'secret',
|
||||
];
|
||||
|
||||
before(async () => {
|
||||
user = await generateUser({
|
||||
contributor: { admin: true },
|
||||
permissions: { userSupport: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +24,7 @@ describe('GET /heroes/:heroId', () => {
|
||||
await expect(nonAdmin.get(`/hall/heroes/${user._id}`)).to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: t('noAdminAccess'),
|
||||
message: t('noPrivAccess'),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,10 +54,7 @@ describe('GET /heroes/:heroId', () => {
|
||||
});
|
||||
const heroRes = await user.get(`/hall/heroes/${hero._id}`);
|
||||
|
||||
expect(heroRes).to.have.all.keys([ // works as: object has all and only these keys
|
||||
'_id', 'id', 'balance', 'profile', 'purchased',
|
||||
'contributor', 'auth', 'items', 'secret',
|
||||
]);
|
||||
expect(heroRes).to.have.all.keys(heroFields); // works as: object has all and only these keys
|
||||
expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']);
|
||||
expect(heroRes.profile).to.have.all.keys(['name']);
|
||||
expect(heroRes.secret.text).to.be.eq('Super Hero');
|
||||
@@ -64,10 +66,7 @@ describe('GET /heroes/:heroId', () => {
|
||||
});
|
||||
const heroRes = await user.get(`/hall/heroes/${hero.auth.local.username}`);
|
||||
|
||||
expect(heroRes).to.have.all.keys([ // works as: object has all and only these keys
|
||||
'_id', 'id', 'balance', 'profile', 'purchased',
|
||||
'contributor', 'auth', 'items', 'secret',
|
||||
]);
|
||||
expect(heroRes).to.have.all.keys(heroFields);
|
||||
expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']);
|
||||
expect(heroRes.profile).to.have.all.keys(['name']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
import {
|
||||
generateUser,
|
||||
generateGroup,
|
||||
translate as t,
|
||||
} from '../../../../helpers/api-integration/v3';
|
||||
import apiError from '../../../../../website/server/libs/apiError';
|
||||
|
||||
describe('GET /heroes/party/:groupId', () => {
|
||||
let user; // admin user
|
||||
|
||||
before(async () => {
|
||||
user = await generateUser({
|
||||
'permissions.userSupport': true,
|
||||
});
|
||||
});
|
||||
|
||||
it('requires the caller to be an admin', async () => {
|
||||
const nonAdmin = await generateUser();
|
||||
const party = await generateGroup(nonAdmin, { type: 'party', privacy: 'private' });
|
||||
await expect(nonAdmin.get(`/hall/heroes/party/${party._id}`)).to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: apiError('noPrivAccess'),
|
||||
});
|
||||
});
|
||||
|
||||
it('validates req.params.groupId', async () => {
|
||||
await expect(user.get('/hall/heroes/party/invalidUUID')).to.eventually.be.rejected.and.eql({
|
||||
code: 400,
|
||||
error: 'BadRequest',
|
||||
message: t('invalidReqParams'),
|
||||
});
|
||||
});
|
||||
|
||||
it('handles non-existing party', async () => {
|
||||
const dummyId = generateUUID();
|
||||
await expect(user.get(`/hall/heroes/party/${dummyId}`)).to.eventually.be.rejected.and.eql({
|
||||
code: 404,
|
||||
error: 'NotFound',
|
||||
message: apiError('groupWithIDNotFound', { groupId: dummyId }),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns only necessary party data given group id', async () => {
|
||||
const nonAdmin = await generateUser();
|
||||
const party = await generateGroup(nonAdmin, { type: 'party', privacy: 'private' });
|
||||
|
||||
const partyRes = await user.get(`/hall/heroes/party/${party._id}`);
|
||||
|
||||
expect(partyRes).to.have.all.keys([ // works as: object has all and only these keys
|
||||
'_id', 'id', 'balance', 'challengeCount', 'leader', 'leaderOnly', 'memberCount',
|
||||
'purchased', 'quest', 'summary',
|
||||
]);
|
||||
expect(partyRes.summary).to.eq(' ');
|
||||
// NB: 'summary' is NOT a field that the API route retrieves!
|
||||
// It must not be retrieved for privacy reasons.
|
||||
// However the group model automatically adds a summary for reasons given here:
|
||||
// https://github.com/HabitRPG/habitica/blob/8da36bf27c62ba0397a6af260c20d35a17f3d911/website/server/models/group.js#L161-L170
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
import { model as User } from '../../../../../website/server/models/user';
|
||||
import {
|
||||
generateUser,
|
||||
translate as t,
|
||||
@@ -8,15 +9,12 @@ describe('PUT /heroes/:heroId', () => {
|
||||
let user;
|
||||
|
||||
const heroFields = [
|
||||
'_id', 'balance', 'profile', 'purchased',
|
||||
'contributor', 'auth', 'items', 'flags',
|
||||
'secret',
|
||||
'_id', 'auth', 'balance', 'contributor', 'flags', 'items', 'lastCron',
|
||||
'party', 'preferences', 'profile', 'purchased', 'secret', 'permissions',
|
||||
];
|
||||
|
||||
before(async () => {
|
||||
user = await generateUser({
|
||||
contributor: { admin: true },
|
||||
});
|
||||
user = await generateUser({ 'permissions.userSupport': true });
|
||||
});
|
||||
|
||||
it('requires the caller to be an admin', async () => {
|
||||
@@ -25,7 +23,7 @@ describe('PUT /heroes/:heroId', () => {
|
||||
await expect(nonAdmin.put(`/hall/heroes/${user._id}`)).to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: t('noAdminAccess'),
|
||||
message: t('noPrivAccess'),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,8 +55,7 @@ describe('PUT /heroes/:heroId', () => {
|
||||
});
|
||||
|
||||
// test response
|
||||
// works as: object has all and only these keys
|
||||
expect(heroRes).to.have.all.keys(heroFields);
|
||||
expect(heroRes).to.have.all.keys(heroFields); // works as: object has all and only these keys
|
||||
expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']);
|
||||
expect(heroRes.profile).to.have.all.keys(['name']);
|
||||
|
||||
@@ -134,7 +131,6 @@ describe('PUT /heroes/:heroId', () => {
|
||||
});
|
||||
|
||||
// test response
|
||||
// works as: object has all and only these keys
|
||||
expect(heroRes).to.have.all.keys(heroFields);
|
||||
expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']);
|
||||
expect(heroRes.profile).to.have.all.keys(['name']);
|
||||
@@ -159,7 +155,6 @@ describe('PUT /heroes/:heroId', () => {
|
||||
});
|
||||
|
||||
// test response
|
||||
// works as: object has all and only these keys
|
||||
expect(heroRes).to.have.all.keys(heroFields);
|
||||
expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']);
|
||||
expect(heroRes.profile).to.have.all.keys(['name']);
|
||||
@@ -215,7 +210,6 @@ describe('PUT /heroes/:heroId', () => {
|
||||
});
|
||||
|
||||
// test response
|
||||
// works as: object has all and only these keys
|
||||
expect(heroRes).to.have.all.keys(heroFields);
|
||||
expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']);
|
||||
expect(heroRes.profile).to.have.all.keys(['name']);
|
||||
@@ -226,4 +220,35 @@ describe('PUT /heroes/:heroId', () => {
|
||||
await hero.sync();
|
||||
expect(hero.items.special.snowball).to.equal(5);
|
||||
});
|
||||
|
||||
it('does not accidentally update API Token', async () => {
|
||||
// This test has been included because hall.js will contain code to produce
|
||||
// a truncated version of the API Token, and we want to be sure that
|
||||
// the real Token is not modified by bugs in that code.
|
||||
const hero = await generateUser();
|
||||
const originalToken = hero.apiToken;
|
||||
|
||||
// make any change to the user except the Token
|
||||
await user.put(`/hall/heroes/${hero._id}`, {
|
||||
contributor: { text: 'Astronaut' },
|
||||
});
|
||||
|
||||
const updatedHero = await User.findById(hero._id).exec();
|
||||
expect(updatedHero.apiToken).to.equal(originalToken);
|
||||
expect(updatedHero.apiTokenObscured).to.not.exist;
|
||||
});
|
||||
|
||||
it('does update API Token when admin changes it', async () => {
|
||||
const hero = await generateUser();
|
||||
const originalToken = hero.apiToken;
|
||||
|
||||
// change the user's API Token
|
||||
await user.put(`/hall/heroes/${hero._id}`, {
|
||||
changeApiToken: true,
|
||||
});
|
||||
|
||||
const updatedHero = await User.findById(hero._id).exec();
|
||||
expect(updatedHero.apiToken).to.not.equal(originalToken);
|
||||
expect(updatedHero.apiTokenObscured).to.not.exist;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,7 +176,7 @@ describe('POST /members/send-private-message', () => {
|
||||
|
||||
it('allows admin to send when sender has blocked the admin', async () => {
|
||||
userToSendMessage = await generateUser({
|
||||
'contributor.admin': 1,
|
||||
'permissions.moderator': true,
|
||||
});
|
||||
const receiver = await generateUser({ 'inbox.blocks': [userToSendMessage._id] });
|
||||
|
||||
@@ -204,7 +204,7 @@ describe('POST /members/send-private-message', () => {
|
||||
|
||||
it('allows admin to send when to user has opted out of messaging', async () => {
|
||||
userToSendMessage = await generateUser({
|
||||
'contributor.admin': 1,
|
||||
'permissions.moderator': true,
|
||||
});
|
||||
const receiver = await generateUser({ 'inbox.optOut': true });
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('GET /tasks/:id', () => {
|
||||
|
||||
it('can get challenge task if admin', async () => {
|
||||
const admin = await generateUser({
|
||||
'contributor.admin': true,
|
||||
'permissions.challengeAdmin': true,
|
||||
});
|
||||
|
||||
const getTask = await admin.get(`/tasks/${task._id}`);
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('POST /tasks/challenge/:challengeId', () => {
|
||||
});
|
||||
|
||||
it('allows non-leader admin to add tasks to a challenge when not a member', async () => {
|
||||
const admin = await generateUser({ 'contributor.admin': true });
|
||||
const admin = await generateUser({ 'permissions.challengeAdmin': true });
|
||||
const task = await admin.post(`/tasks/challenge/${challenge._id}`, {
|
||||
text: 'test habit from admin',
|
||||
type: 'habit',
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('POST /user/reset', () => {
|
||||
|
||||
it('does not delete secret', async () => {
|
||||
const admin = await generateUser({
|
||||
contributor: { admin: true },
|
||||
permissions: { userSupport: true },
|
||||
});
|
||||
|
||||
const hero = await generateUser({
|
||||
|
||||
@@ -135,6 +135,7 @@ describe('PUT /user', () => {
|
||||
'gem balance': { balance: 100 },
|
||||
auth: { 'auth.blocked': true, 'auth.timestamps.created': new Date() },
|
||||
contributor: { 'contributor.level': 9, 'contributor.admin': true, 'contributor.text': 'some text' },
|
||||
permissions: { 'permissions.fullAccess': true, 'permissions.news': true, 'permissions.moderator': 'some text' },
|
||||
backer: { 'backer.tier': 10, 'backer.npc': 'Bilbo' },
|
||||
subscriptions: { 'purchased.plan.extraMonths': 500, 'purchased.plan.consecutive.trinkets': 1000 },
|
||||
'customization gem purchases': { 'purchased.background.tavern': true, 'purchased.skin.bear': true },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
import {
|
||||
generateUser,
|
||||
requester,
|
||||
@@ -9,15 +10,18 @@ describe('GET /user/auth/apple', () => {
|
||||
let api;
|
||||
let user;
|
||||
const appleEndpoint = '/user/auth/apple';
|
||||
|
||||
before(async () => {
|
||||
const expectedResult = { id: 'appleId', name: 'an apple user' };
|
||||
sandbox.stub(appleAuth, 'appleProfile').returns(Promise.resolve(expectedResult));
|
||||
});
|
||||
let randomAppleId = '123456';
|
||||
|
||||
beforeEach(async () => {
|
||||
api = requester();
|
||||
user = await generateUser();
|
||||
randomAppleId = generateUUID();
|
||||
const expectedResult = { id: randomAppleId, name: 'an apple user' };
|
||||
sandbox.stub(appleAuth, 'appleProfile').returns(Promise.resolve(expectedResult));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
appleAuth.appleProfile.restore();
|
||||
});
|
||||
|
||||
it('registers a new user', async () => {
|
||||
@@ -26,7 +30,7 @@ describe('GET /user/auth/apple', () => {
|
||||
expect(response.apiToken).to.exist;
|
||||
expect(response.id).to.exist;
|
||||
expect(response.newUser).to.be.true;
|
||||
await expect(getProperty('users', response.id, 'auth.apple.id')).to.eventually.equal('appleId');
|
||||
await expect(getProperty('users', response.id, 'auth.apple.id')).to.eventually.equal(randomAppleId);
|
||||
await expect(getProperty('users', response.id, 'profile.name')).to.eventually.equal('an apple user');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import passport from 'passport';
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
import {
|
||||
generateUser,
|
||||
requester,
|
||||
@@ -10,14 +11,15 @@ describe('POST /user/auth/social', () => {
|
||||
let api;
|
||||
let user;
|
||||
const endpoint = '/user/auth/social';
|
||||
const randomAccessToken = '123456';
|
||||
const facebookId = 'facebookId';
|
||||
const googleId = 'googleId';
|
||||
let randomAccessToken = '123456';
|
||||
let randomFacebookId = 'facebookId';
|
||||
let randomGoogleId = 'googleId';
|
||||
let network = 'NoNetwork';
|
||||
|
||||
beforeEach(async () => {
|
||||
api = requester();
|
||||
user = await generateUser();
|
||||
randomAccessToken = generateUUID();
|
||||
});
|
||||
|
||||
it('fails if network is not supported', async () => {
|
||||
@@ -32,12 +34,23 @@ describe('POST /user/auth/social', () => {
|
||||
});
|
||||
|
||||
describe('facebook', () => {
|
||||
before(async () => {
|
||||
const expectedResult = { id: facebookId, displayName: 'a facebook user' };
|
||||
beforeEach(async () => {
|
||||
randomFacebookId = generateUUID();
|
||||
const expectedResult = {
|
||||
id: randomFacebookId,
|
||||
displayName: 'a facebook user',
|
||||
emails: [
|
||||
{ value: `${user.auth.local.username}+facebook@example.com` },
|
||||
],
|
||||
};
|
||||
sandbox.stub(passport._strategies.facebook, 'userProfile').yields(null, expectedResult);
|
||||
network = 'facebook';
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
passport._strategies.facebook.userProfile.restore();
|
||||
});
|
||||
|
||||
it('registers a new user', async () => {
|
||||
const response = await api.post(endpoint, {
|
||||
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
|
||||
@@ -51,7 +64,8 @@ describe('POST /user/auth/social', () => {
|
||||
|
||||
await expect(getProperty('users', response.id, 'profile.name')).to.eventually.equal('a facebook user');
|
||||
await expect(getProperty('users', response.id, 'auth.local.lowerCaseUsername')).to.exist;
|
||||
await expect(getProperty('users', response.id, 'auth.facebook.id')).to.eventually.equal(facebookId);
|
||||
await expect(getProperty('users', response.id, 'auth.local.email')).to.eventually.equal(`${user.auth.local.username}+facebook@example.com`);
|
||||
await expect(getProperty('users', response.id, 'auth.facebook.id')).to.eventually.equal(randomFacebookId);
|
||||
});
|
||||
|
||||
it('logs an existing user in', async () => {
|
||||
@@ -68,6 +82,57 @@ describe('POST /user/auth/social', () => {
|
||||
expect(response.apiToken).to.eql(registerResponse.apiToken);
|
||||
expect(response.id).to.eql(registerResponse.id);
|
||||
expect(response.newUser).to.be.false;
|
||||
expect(registerResponse.newUser).to.be.true;
|
||||
});
|
||||
|
||||
it('logs an existing user in if they have local auth with matching email', async () => {
|
||||
passport._strategies.facebook.userProfile.restore();
|
||||
const expectedResult = {
|
||||
id: randomFacebookId,
|
||||
displayName: 'a facebook user',
|
||||
emails: [
|
||||
{ value: user.auth.local.email },
|
||||
],
|
||||
};
|
||||
sandbox.stub(passport._strategies.facebook, 'userProfile').yields(null, expectedResult);
|
||||
|
||||
const response = await api.post(endpoint, {
|
||||
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
|
||||
network,
|
||||
});
|
||||
|
||||
expect(response.apiToken).to.eql(user.apiToken);
|
||||
expect(response.id).to.eql(user._id);
|
||||
expect(response.newUser).to.be.false;
|
||||
});
|
||||
|
||||
it('logs an existing user into their social account if they have local auth with matching email', async () => {
|
||||
const registerResponse = await api.post(endpoint, {
|
||||
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
|
||||
network,
|
||||
});
|
||||
expect(registerResponse.newUser).to.be.true;
|
||||
// This is important for existing accounts before the new social handling
|
||||
passport._strategies.facebook.userProfile.restore();
|
||||
const expectedResult = {
|
||||
id: randomFacebookId,
|
||||
displayName: 'a facebook user',
|
||||
emails: [
|
||||
{ value: user.auth.local.email },
|
||||
],
|
||||
};
|
||||
sandbox.stub(passport._strategies.facebook, 'userProfile').yields(null, expectedResult);
|
||||
|
||||
const response = await api.post(endpoint, {
|
||||
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
|
||||
network,
|
||||
});
|
||||
|
||||
expect(response.apiToken).to.eql(registerResponse.apiToken);
|
||||
expect(response.id).to.eql(registerResponse.id);
|
||||
expect(response.apiToken).not.to.eql(user.apiToken);
|
||||
expect(response.id).not.to.eql(user._id);
|
||||
expect(response.newUser).to.be.false;
|
||||
});
|
||||
|
||||
it('add social auth to an existing user', async () => {
|
||||
@@ -76,11 +141,28 @@ describe('POST /user/auth/social', () => {
|
||||
network,
|
||||
});
|
||||
|
||||
expect(response.apiToken).to.exist;
|
||||
expect(response.id).to.exist;
|
||||
expect(response.apiToken).to.eql(user.apiToken);
|
||||
expect(response.id).to.eql(user._id);
|
||||
expect(response.newUser).to.be.false;
|
||||
});
|
||||
|
||||
it('does not log into other account if social auth already exists', async () => {
|
||||
const registerResponse = await api.post(endpoint, {
|
||||
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
|
||||
network,
|
||||
});
|
||||
expect(registerResponse.newUser).to.be.true;
|
||||
|
||||
await expect(user.post(endpoint, {
|
||||
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
|
||||
network,
|
||||
})).to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: t('socialAlreadyExists'),
|
||||
});
|
||||
});
|
||||
|
||||
xit('enrolls a new user in an A/B test', async () => {
|
||||
await api.post(endpoint, {
|
||||
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
|
||||
@@ -92,12 +174,23 @@ describe('POST /user/auth/social', () => {
|
||||
});
|
||||
|
||||
describe('google', () => {
|
||||
before(async () => {
|
||||
const expectedResult = { id: googleId, displayName: 'a google user' };
|
||||
beforeEach(async () => {
|
||||
randomGoogleId = generateUUID();
|
||||
const expectedResult = {
|
||||
id: randomGoogleId,
|
||||
displayName: 'a google user',
|
||||
emails: [
|
||||
{ value: `${user.auth.local.username}+google@example.com` },
|
||||
],
|
||||
};
|
||||
sandbox.stub(passport._strategies.google, 'userProfile').yields(null, expectedResult);
|
||||
network = 'google';
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
passport._strategies.google.userProfile.restore();
|
||||
});
|
||||
|
||||
it('registers a new user', async () => {
|
||||
const response = await api.post(endpoint, {
|
||||
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
|
||||
@@ -107,7 +200,8 @@ describe('POST /user/auth/social', () => {
|
||||
expect(response.apiToken).to.exist;
|
||||
expect(response.id).to.exist;
|
||||
expect(response.newUser).to.be.true;
|
||||
await expect(getProperty('users', response.id, 'auth.google.id')).to.eventually.equal(googleId);
|
||||
await expect(getProperty('users', response.id, 'auth.google.id')).to.eventually.equal(randomGoogleId);
|
||||
await expect(getProperty('users', response.id, 'auth.local.email')).to.eventually.equal(`${user.auth.local.username}+google@example.com`);
|
||||
await expect(getProperty('users', response.id, 'profile.name')).to.eventually.equal('a google user');
|
||||
});
|
||||
|
||||
@@ -125,6 +219,57 @@ describe('POST /user/auth/social', () => {
|
||||
expect(response.apiToken).to.eql(registerResponse.apiToken);
|
||||
expect(response.id).to.eql(registerResponse.id);
|
||||
expect(response.newUser).to.be.false;
|
||||
expect(registerResponse.newUser).to.be.true;
|
||||
});
|
||||
|
||||
it('logs an existing user in if they have local auth with matching email', async () => {
|
||||
passport._strategies.google.userProfile.restore();
|
||||
const expectedResult = {
|
||||
id: randomGoogleId,
|
||||
displayName: 'a google user',
|
||||
emails: [
|
||||
{ value: user.auth.local.email },
|
||||
],
|
||||
};
|
||||
sandbox.stub(passport._strategies.google, 'userProfile').yields(null, expectedResult);
|
||||
|
||||
const response = await api.post(endpoint, {
|
||||
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
|
||||
network,
|
||||
});
|
||||
|
||||
expect(response.apiToken).to.eql(user.apiToken);
|
||||
expect(response.id).to.eql(user._id);
|
||||
expect(response.newUser).to.be.false;
|
||||
});
|
||||
|
||||
it('logs an existing user into their social account if they have local auth with matching email', async () => {
|
||||
const registerResponse = await api.post(endpoint, {
|
||||
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
|
||||
network,
|
||||
});
|
||||
expect(registerResponse.newUser).to.be.true;
|
||||
// This is important for existing accounts before the new social handling
|
||||
passport._strategies.google.userProfile.restore();
|
||||
const expectedResult = {
|
||||
id: randomGoogleId,
|
||||
displayName: 'a google user',
|
||||
emails: [
|
||||
{ value: user.auth.local.email },
|
||||
],
|
||||
};
|
||||
sandbox.stub(passport._strategies.google, 'userProfile').yields(null, expectedResult);
|
||||
|
||||
const response = await api.post(endpoint, {
|
||||
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
|
||||
network,
|
||||
});
|
||||
|
||||
expect(response.apiToken).to.eql(registerResponse.apiToken);
|
||||
expect(response.id).to.eql(registerResponse.id);
|
||||
expect(response.apiToken).not.to.eql(user.apiToken);
|
||||
expect(response.id).not.to.eql(user._id);
|
||||
expect(response.newUser).to.be.false;
|
||||
});
|
||||
|
||||
it('add social auth to an existing user', async () => {
|
||||
@@ -133,11 +278,28 @@ describe('POST /user/auth/social', () => {
|
||||
network,
|
||||
});
|
||||
|
||||
expect(response.apiToken).to.exist;
|
||||
expect(response.id).to.exist;
|
||||
expect(response.apiToken).to.eql(user.apiToken);
|
||||
expect(response.id).to.eql(user._id);
|
||||
expect(response.newUser).to.be.false;
|
||||
});
|
||||
|
||||
it('does not log into other account if social auth already exists', async () => {
|
||||
const registerResponse = await api.post(endpoint, {
|
||||
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
|
||||
network,
|
||||
});
|
||||
expect(registerResponse.newUser).to.be.true;
|
||||
|
||||
await expect(user.post(endpoint, {
|
||||
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
|
||||
network,
|
||||
})).to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: t('socialAlreadyExists'),
|
||||
});
|
||||
});
|
||||
|
||||
xit('enrolls a new user in an A/B test', async () => {
|
||||
await api.post(endpoint, {
|
||||
authResponse: { access_token: randomAccessToken }, // eslint-disable-line camelcase
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('POST /coupons/enter/:code', () => {
|
||||
beforeEach(async () => {
|
||||
user = await generateUser();
|
||||
sudoUser = await generateUser({
|
||||
'contributor.sudo': true,
|
||||
'permissions.coupons': true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ describe('GET /members/:memberId/purchase-history', () => {
|
||||
|
||||
before(async () => {
|
||||
user = await generateUser({
|
||||
contributor: { admin: true },
|
||||
permissions: { userSupport: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('GET /members/:memberId/purchase-history', () => {
|
||||
await expect(nonAdmin.get(`/members/${member._id}/purchase-history`)).to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: t('noAdminAccess'),
|
||||
message: t('noPrivAccess'),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,16 +15,16 @@ describe('DELETE /news/:newsID', () => {
|
||||
};
|
||||
beforeEach(async () => {
|
||||
user = await generateUser({
|
||||
'contributor.newsPoster': true,
|
||||
'permissions.news': true,
|
||||
});
|
||||
});
|
||||
|
||||
it('disallows access to non-newsPosters', async () => {
|
||||
const nonAdminUser = await generateUser({ 'contributor.newsPoster': false });
|
||||
const nonAdminUser = await generateUser({ 'permissions.news': false });
|
||||
await expect(nonAdminUser.del(`/news/${v4()}`)).to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: 'You don\'t have news poster access.',
|
||||
message: t('noPrivAccess'),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('GET /news', () => {
|
||||
before(async () => {
|
||||
api = requester();
|
||||
const user = await generateUser({
|
||||
'contributor.newsPoster': true,
|
||||
'permissions.news': true,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('GET /news/:newsID', () => {
|
||||
};
|
||||
beforeEach(async () => {
|
||||
user = await generateUser({
|
||||
'contributor.newsPoster': true,
|
||||
'permissions.news': true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,16 +16,16 @@ describe('POST /news', () => {
|
||||
};
|
||||
beforeEach(async () => {
|
||||
user = await generateUser({
|
||||
'contributor.newsPoster': true,
|
||||
'permissions.news': true,
|
||||
});
|
||||
});
|
||||
|
||||
it('disallows access to non-admins', async () => {
|
||||
const nonAdminUser = await generateUser({ 'contributor.newsPoster': false });
|
||||
const nonAdminUser = await generateUser({ 'permissions.news': false });
|
||||
await expect(nonAdminUser.post('/news')).to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: 'You don\'t have news poster access.',
|
||||
message: 'You don\'t have the required privileges.',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -17,16 +17,16 @@ describe('PUT /news/:newsID', () => {
|
||||
};
|
||||
beforeEach(async () => {
|
||||
user = await generateUser({
|
||||
'contributor.newsPoster': true,
|
||||
'permissions.news': true,
|
||||
});
|
||||
});
|
||||
|
||||
it('disallows access to non-admins', async () => {
|
||||
const nonAdminUser = await generateUser({ 'contributor.newsPoster': false });
|
||||
const nonAdminUser = await generateUser({ 'permissions.news': false });
|
||||
await expect(nonAdminUser.put('/news/1234')).to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: 'You don\'t have news poster access.',
|
||||
message: 'You don\'t have the required privileges.',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('POST /user/reset', () => {
|
||||
|
||||
it('does not delete secret', async () => {
|
||||
const admin = await generateUser({
|
||||
contributor: { admin: true },
|
||||
permissions: { userSupport: true },
|
||||
});
|
||||
|
||||
const hero = await generateUser({
|
||||
|
||||
@@ -84,6 +84,7 @@ describe('PUT /user', () => {
|
||||
'gem balance': { balance: 100 },
|
||||
auth: { 'auth.blocked': true, 'auth.timestamps.created': new Date() },
|
||||
contributor: { 'contributor.level': 9, 'contributor.admin': true, 'contributor.text': 'some text' },
|
||||
permissions: { 'permissions.fullAccess': true, 'permissions.news': true, 'permissions.moderator': 'some text' },
|
||||
backer: { 'backer.tier': 10, 'backer.npc': 'Bilbo' },
|
||||
subscriptions: { 'purchased.plan.extraMonths': 500, 'purchased.plan.consecutive.trinkets': 1000 },
|
||||
'customization gem purchases': { 'purchased.background.tavern': true, 'purchased.skin.bear': true },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import moment from 'moment';
|
||||
|
||||
import { startOfDay, daysSince } from '../../../website/common/script/cron';
|
||||
import { startOfDay, daysSince, getPlanContext } from '../../../website/common/script/cron';
|
||||
|
||||
function localMoment (timeString, utcOffset) {
|
||||
return moment(timeString).utcOffset(utcOffset, true);
|
||||
@@ -181,4 +181,63 @@ describe('cron utility functions', () => {
|
||||
expect(result).to.equal(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlanContext', () => {
|
||||
const now = new Date(2022, 5, 1);
|
||||
|
||||
function baseUserData (count, offset, planId) {
|
||||
return {
|
||||
purchased: {
|
||||
plan: {
|
||||
consecutive: {
|
||||
count,
|
||||
offset,
|
||||
gemCapExtra: 25,
|
||||
trinkets: 19,
|
||||
},
|
||||
quantity: 1,
|
||||
extraMonths: 0,
|
||||
gemsBought: 0,
|
||||
owner: '116b4133-8fb7-43f2-b0de-706621a8c9d8',
|
||||
nextBillingDate: null,
|
||||
nextPaymentProcessing: null,
|
||||
planId,
|
||||
customerId: 'group-plan',
|
||||
dateUpdated: '2022-05-10T03:00:00.144+01:00',
|
||||
paymentMethod: 'Group Plan',
|
||||
dateTerminated: null,
|
||||
lastBillingDate: null,
|
||||
dateCreated: '2017-02-10T19:00:00.355+01:00',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('offset 0, next date in 3 months', () => {
|
||||
const user = baseUserData(60, 0, 'group_plan_auto');
|
||||
|
||||
const planContext = getPlanContext(user, now);
|
||||
|
||||
expect(planContext.nextHourglassDate)
|
||||
.to.be.sameMoment('2022-08-10T02:00:00.144Z');
|
||||
});
|
||||
|
||||
it('offset 1, next date in 1 months', () => {
|
||||
const user = baseUserData(60, 1, 'group_plan_auto');
|
||||
|
||||
const planContext = getPlanContext(user, now);
|
||||
|
||||
expect(planContext.nextHourglassDate)
|
||||
.to.be.sameMoment('2022-06-10T02:00:00.144Z');
|
||||
});
|
||||
|
||||
it('offset 2, next date in 2 months - with any plan', () => {
|
||||
const user = baseUserData(60, 2, 'basic_3mo');
|
||||
|
||||
const planContext = getPlanContext(user, now);
|
||||
|
||||
expect(planContext.nextHourglassDate)
|
||||
.to.be.sameMoment('2022-07-10T02:00:00.144Z');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
import getters from '@/store/getters';
|
||||
|
||||
export const userStyles = {
|
||||
contributor: {
|
||||
@@ -82,3 +83,25 @@ export const userStyles = {
|
||||
classSelected: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
export function mockStore ({
|
||||
userData,
|
||||
...state
|
||||
}) {
|
||||
return {
|
||||
getters,
|
||||
dispatch: () => {
|
||||
},
|
||||
watch: () => {
|
||||
},
|
||||
state: {
|
||||
user: {
|
||||
data: {
|
||||
...userData,
|
||||
},
|
||||
},
|
||||
...state,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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`;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="row standard-page">
|
||||
<div class="well col-12">
|
||||
<h1>Admin Panel</h1>
|
||||
|
||||
<div>
|
||||
<form
|
||||
class="form-inline"
|
||||
@submit.prevent="loadHero(userIdentifier)"
|
||||
>
|
||||
<input
|
||||
v-model="userIdentifier"
|
||||
class="form-control uidField"
|
||||
type="text"
|
||||
:placeholder="'User ID or Username; blank for your account'"
|
||||
>
|
||||
<input
|
||||
type="submit"
|
||||
value="Load User"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<router-view @changeUserIdentifier="changeUserIdentifier" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.uidField {
|
||||
min-width: 45ch;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import VueRouter from 'vue-router';
|
||||
import { mapState } from '@/libs/store';
|
||||
|
||||
const { isNavigationFailure, NavigationFailureType } = VueRouter;
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
userIdentifier: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
},
|
||||
mounted () {
|
||||
this.$store.dispatch('common:setTitle', {
|
||||
section: 'Admin Panel',
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
changeUserIdentifier (newId) {
|
||||
// If we've accessed the admin panel from a URL that had a user identifier in it,
|
||||
// this method will insert that identifier into the "Load User" form field
|
||||
// (useful if we want to re-fetch the user after making changes).
|
||||
this.userIdentifier = newId;
|
||||
},
|
||||
async loadHero (userIdentifier) {
|
||||
const id = userIdentifier || this.user._id;
|
||||
|
||||
this.$router.push({
|
||||
name: 'adminPanelUser',
|
||||
params: { userIdentifier: id },
|
||||
}).catch(failure => {
|
||||
if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
|
||||
// the admin has requested that the same user be displayed again so reload the page
|
||||
// (e.g., if they changed their mind about changes they were making)
|
||||
this.$router.go();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,132 @@
|
||||
import content from '@/../../common/script/content';
|
||||
|
||||
function _getGearSetName (key) {
|
||||
let set = 'NO SET [probably an omission in the API data]';
|
||||
if (content.gear.flat[key].set) {
|
||||
set = `${content.gear.flat[key].set}`;
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
function _getGearSetDescription (key) {
|
||||
let setName = _getGearSetName(key);
|
||||
if (setName === 'special-takeThis') {
|
||||
// no point displaying set details for gear where it's obvious
|
||||
return '';
|
||||
}
|
||||
const klassNames = {
|
||||
healer: 'Healer',
|
||||
rogue: 'Rogue',
|
||||
warrior: 'Warrior',
|
||||
wizard: 'Mage',
|
||||
};
|
||||
const lunarBattleQuestGear = ['armor_special_lunarWarriorArmor', 'head_special_lunarWarriorHelm', 'weapon_special_lunarScythe'];
|
||||
|
||||
const loginIncentivesGear = ['armor_special_bardRobes', 'armor_special_dandySuit', 'armor_special_lunarWarriorArmor', 'armor_special_nomadsCuirass', 'armor_special_pageArmor', 'armor_special_samuraiArmor', 'armor_special_sneakthiefRobes', 'armor_special_snowSovereignRobes', 'back_special_snowdriftVeil', 'head_special_bardHat', 'head_special_clandestineCowl', 'head_special_dandyHat', 'head_special_kabuto', 'head_special_lunarWarriorHelm', 'head_special_pageHelm', 'head_special_snowSovereignCrown', 'head_special_spikedHelm', 'shield_special_diamondStave', 'shield_special_lootBag', 'shield_special_wakizashi', 'shield_special_wintryMirror', 'weapon_special_bardInstrument', 'weapon_special_fencingFoil', 'weapon_special_lunarScythe', 'weapon_special_nomadsScimitar', 'weapon_special_pageBanner', 'weapon_special_skeletonKey', 'weapon_special_tachi'];
|
||||
|
||||
const goldQuestsGear = ['armor_special_finnedOceanicArmor', 'head_special_fireCoralCirclet', 'weapon_special_tridentOfCrashingTides', 'shield_special_moonpearlShield', 'head_special_pyromancersTurban', 'armor_special_pyromancersRobes', 'weapon_special_taskwoodsLantern', 'armor_special_mammothRiderArmor', 'head_special_mammothRiderHelm', 'weapon_special_mammothRiderSpear', 'shield_special_mammothRiderHorn', 'armor_special_roguishRainbowMessengerRobes', 'head_special_roguishRainbowMessengerHood', 'weapon_special_roguishRainbowMessage', 'shield_special_roguishRainbowMessage', 'eyewear_special_aetherMask', 'body_special_aetherAmulet', 'back_special_aetherCloak', 'weapon_special_aetherCrystals'];
|
||||
|
||||
const animalGear = ['back_special_bearTail', 'back_special_cactusTail', 'back_special_foxTail', 'back_special_lionTail', 'back_special_pandaTail', 'back_special_pigTail', 'back_special_tigerTail', 'back_special_wolfTail', 'headAccessory_special_bearEars', 'headAccessory_special_cactusEars', 'headAccessory_special_foxEars', 'headAccessory_special_lionEars', 'headAccessory_special_pandaEars', 'headAccessory_special_pigEars', 'headAccessory_special_tigerEars', 'headAccessory_special_wolfEars'];
|
||||
|
||||
let wantSetName = true; // some set names are useful, others aren't
|
||||
let setType = '[cannot determine set type]';
|
||||
if (setName === 'base-0') {
|
||||
setType = 'empty slot';
|
||||
wantSetName = false;
|
||||
} else if (setName.includes('special-turkey')) {
|
||||
setType = '<a href="https://habitica.fandom.com/wiki/Turkey_Day">Turkey Day</a>';
|
||||
wantSetName = false;
|
||||
} else if (setName.includes('special-nye')) {
|
||||
setType = '<a href="https://habitica.fandom.com/wiki/Event_Item_Sequences">New Year\'s Eve</a>';
|
||||
wantSetName = false;
|
||||
} else if (setName.includes('special-birthday')) {
|
||||
setType = '<a href="https://habitica.fandom.com/wiki/Habitica_Birthday_Bash">Habitica Birthday Bash</a>';
|
||||
wantSetName = false;
|
||||
} else if (setName.includes('special-0') || key === 'weapon_special_3') {
|
||||
setType = '<a href="https://habitica.fandom.com/wiki/Kickstarter">Kickstarter 2013</a>';
|
||||
wantSetName = false;
|
||||
} else if (setName.includes('special-1')) {
|
||||
setType = 'Contributor gear';
|
||||
wantSetName = false;
|
||||
} else if (setName.includes('special-2') || key === 'shield_special_goldenknight') {
|
||||
setType = '<a href="https://habitica.fandom.com/wiki/Legendary_Equipment">Legendary Equipment</a>';
|
||||
wantSetName = false;
|
||||
} else if (setName.includes('special-wondercon')) {
|
||||
setType = '<a href="https://habitica.fandom.com/wiki/Unconventional_Armor">Unconventional Armor</a>';
|
||||
wantSetName = false;
|
||||
} else if (lunarBattleQuestGear.includes(key)) {
|
||||
setType = '<a href="https://habitica.fandom.com/wiki/Quest_Lines#Lunar_Battle_Quest_Line">Lunar Battle Quest Line</a>';
|
||||
wantSetName = false;
|
||||
} else if (loginIncentivesGear.includes(key)) {
|
||||
setType = '<a href="https://habitica.fandom.com/wiki/Daily_Check-In_Incentives">Check-In Incentive</a>';
|
||||
wantSetName = false;
|
||||
} else if (goldQuestsGear.includes(key)) {
|
||||
setType = 'from <a href="https://habitica.fandom.com/wiki/Quest_Lines#Gold_Purchasable_Quest_Lines">Gold-Purchasable Quest Lines</a>';
|
||||
wantSetName = false;
|
||||
} else if (animalGear.includes(key)) {
|
||||
setType = '<a href="https://habitica.fandom.com/wiki/Avatar_Customizations">Animal Avatar Accessory Customisations</a>';
|
||||
wantSetName = false;
|
||||
} else if (!content.gear.flat[key].klass) {
|
||||
setType = 'NO "klass" [omission in API data]';
|
||||
} else if (content.gear.flat[key].klass === 'armoire') {
|
||||
setType = 'Armoire set';
|
||||
} else if (content.gear.flat[key].klass === 'mystery') {
|
||||
setType = 'Mystery Items';
|
||||
setName = setName.replace(/mystery-(....)(..)/, '$1-$2');
|
||||
} else if (content.gear.flat[key].klass === 'special') {
|
||||
const specialClass = content.gear.flat[key].specialClass || '';
|
||||
if (specialClass && Object.keys(klassNames).includes(specialClass)) {
|
||||
setType = `Grand Gala ${klassNames[specialClass]} set`;
|
||||
} else if (key.includes('special_gaymerx')) {
|
||||
setType = 'GaymerX';
|
||||
wantSetName = false;
|
||||
} else if (key.includes('special_ks2019')) {
|
||||
setType = '<a href="https://habitica.fandom.com/wiki/Kickstarter">Kickstarter 2019</a>';
|
||||
wantSetName = false;
|
||||
} else {
|
||||
setType = '[unknown set]';
|
||||
wantSetName = false;
|
||||
}
|
||||
} else if (Object.keys(klassNames).includes(content.gear.flat[key].klass)) {
|
||||
// e.g., base class gear such as weapon_warrior_6 (Golden Sword)
|
||||
setType = `base ${klassNames[content.gear.flat[key].klass]} gear`;
|
||||
wantSetName = false;
|
||||
}
|
||||
return (wantSetName) ? `${setType}: ${setName}` : setType;
|
||||
}
|
||||
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
content,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
getItemDescription (itemType, key) {
|
||||
// Returns item name. Also returns other info for equipment.
|
||||
|
||||
const simpleItemTypes = ['eggs', 'hatchingPotions', 'food', 'quests', 'special'];
|
||||
if (simpleItemTypes.includes(itemType) && content[itemType][key]) {
|
||||
return content[itemType][key].text();
|
||||
}
|
||||
|
||||
if (itemType === 'mounts' && content.mountInfo[key]) {
|
||||
return content.mountInfo[key].text();
|
||||
}
|
||||
|
||||
if (itemType === 'pets' && content.petInfo[key]) {
|
||||
return content.petInfo[key].text();
|
||||
}
|
||||
|
||||
if (itemType === 'gear' && content.gear.flat[key]) {
|
||||
const name = content.gear.flat[key].text();
|
||||
const description = _getGearSetDescription(key);
|
||||
if (description) return `${name} -- ${description}`;
|
||||
return name;
|
||||
}
|
||||
|
||||
return 'NO NAME - invalid item?';
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
export default {
|
||||
methods: {
|
||||
async saveHero ({ hero, msg = 'User', clearData }) {
|
||||
await this.$store.dispatch('hall:updateHero', { heroDetails: hero });
|
||||
await this.$store.dispatch('snackbars:add', {
|
||||
title: '',
|
||||
text: `${msg} updated`,
|
||||
type: 'info',
|
||||
});
|
||||
|
||||
if (clearData) {
|
||||
// Use clearData when the saved changes may affect data in other components
|
||||
// (e.g., adding a contributor tier will increase the Gem balance)
|
||||
// The admin should re-fetch the data if they need to keep working on that user.
|
||||
this.$emit('clear-data');
|
||||
this.$router.push({ name: 'adminPanel' });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="accordion-group">
|
||||
<h3
|
||||
class="expand-toggle"
|
||||
:class="{'open': expand}"
|
||||
@click="expand = !expand"
|
||||
>
|
||||
Current Avatar Appearance, Drop Count Today
|
||||
</h3>
|
||||
<div v-if="expand">
|
||||
<div>Drops Today: {{ items.lastDrop.count }}</div>
|
||||
<div>Most Recent Drop: {{ items.lastDrop.date | formatDate }}</div>
|
||||
<div>Use Costume: {{ preferences.costume ? 'on' : 'off' }}</div>
|
||||
<div class="subsection-start">
|
||||
Equipped Gear:
|
||||
<ul v-html="formatEquipment(items.gear.equipped)"></ul>
|
||||
</div>
|
||||
<div>
|
||||
Costume:
|
||||
<ul v-html="formatEquipment(items.gear.costume)"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import formatDate from '../filters/formatDate';
|
||||
import getItemDescription from '../mixins/getItemDescription';
|
||||
|
||||
export default {
|
||||
filters: {
|
||||
formatDate,
|
||||
},
|
||||
mixins: [
|
||||
getItemDescription,
|
||||
],
|
||||
props: {
|
||||
items: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
preferences: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
expand: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
formatEquipment (gearWorn) {
|
||||
const gearTypes = ['head', 'armor', 'weapon', 'shield', 'headAccessory', 'eyewear',
|
||||
'body', 'back'];
|
||||
let equipmentList = '';
|
||||
gearTypes.forEach(gearType => {
|
||||
const key = gearWorn[gearType] || '';
|
||||
const description = (key)
|
||||
? `<strong>${key}</strong> : ${this.getItemDescription('gear', gearWorn[gearType])}`
|
||||
: 'none';
|
||||
equipmentList += `<li>${gearType} : ${description}</li>\n`;
|
||||
});
|
||||
return equipmentList;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>@{{ auth.local.username }} / {{ profile.name }}</h2>
|
||||
{{ userId }}
|
||||
<router-link :to="{'name': 'userProfile', 'params': {'userId': userId}}">
|
||||
profile link
|
||||
</router-link>
|
||||
<br>
|
||||
language: {{ preferences.language }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
userId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
auth: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
preferences: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
profile: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="accordion-group">
|
||||
<h3
|
||||
class="expand-toggle"
|
||||
:class="{'open': expand}"
|
||||
@click="expand = !expand"
|
||||
>
|
||||
Contributor Details
|
||||
</h3>
|
||||
<div v-if="expand">
|
||||
<form @submit.prevent="saveHero({hero, msg: 'Contributor details', clearData: true})">
|
||||
<div>
|
||||
<label>Permissions</label>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input
|
||||
v-model="hero.permissions.fullAccess"
|
||||
:disabled="!hasPermission(user, 'fullAccess')"
|
||||
type="checkbox"
|
||||
>
|
||||
Full Admin Access (Allows access to everything. EVERYTHING)
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input
|
||||
v-model="hero.permissions.userSupport"
|
||||
:disabled="!hasPermission(user, 'fullAccess')"
|
||||
type="checkbox"
|
||||
>
|
||||
User Support (Access this form, access purchase history)
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input
|
||||
v-model="hero.permissions.news"
|
||||
:disabled="!hasPermission(user, 'fullAccess')"
|
||||
type="checkbox"
|
||||
>
|
||||
News poster (Bailey CMS)
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input
|
||||
v-model="hero.permissions.moderator"
|
||||
:disabled="!hasPermission(user, 'fullAccess')"
|
||||
type="checkbox"
|
||||
>
|
||||
Community Moderator (ban and mute users, access chat flags, manage social spaces)
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input
|
||||
v-model="hero.permissions.challengeAdmin"
|
||||
:disabled="!hasPermission(user, 'fullAccess')"
|
||||
type="checkbox"
|
||||
>
|
||||
Challenge Admin (can create official habitica challenges and admin all challenges)
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input
|
||||
v-model="hero.permissions.coupons"
|
||||
:disabled="!hasPermission(user, 'fullAccess')"
|
||||
type="checkbox"
|
||||
>
|
||||
Coupon Creator (can manage coupon codes)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Title</label>
|
||||
<input
|
||||
v-model="hero.contributor.text"
|
||||
class="form-control textField"
|
||||
type="text"
|
||||
>
|
||||
<small>
|
||||
Common titles:
|
||||
<strong>Ambassador, Artisan, Bard, Blacksmith, Challenger, Comrade, Fletcher,
|
||||
Linguist, Linguistic Scribe, Scribe, Socialite, Storyteller</strong>.
|
||||
<br>
|
||||
Rare titles:
|
||||
Advisor, Chamberlain, Designer, Mathematician, Shirtster, Spokesperson,
|
||||
Statistician, Tinker, Transcriber, Troubadour.
|
||||
</small>
|
||||
</div>
|
||||
<div class="form-group form-inline">
|
||||
<label>Tier</label>
|
||||
<input
|
||||
v-model="hero.contributor.level"
|
||||
class="form-control levelField"
|
||||
type="number"
|
||||
>
|
||||
<small>
|
||||
1-7 for normal contributors, 8 for moderators, 9 for staff.
|
||||
This determines which items, pets, mounts are available, and name-tag coloring.
|
||||
Tiers 8 and 9 are automatically given admin status.
|
||||
</small>
|
||||
</div>
|
||||
<div
|
||||
v-if="hero.secret.text"
|
||||
class="form-group"
|
||||
>
|
||||
<label>Moderation Notes</label>
|
||||
<div
|
||||
v-markdown="hero.secret.text"
|
||||
class="markdownPreview"
|
||||
></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Contributions</label>
|
||||
<textarea
|
||||
v-model="hero.contributor.contributions"
|
||||
class="form-control"
|
||||
cols="5"
|
||||
rows="5"
|
||||
></textarea>
|
||||
<div
|
||||
v-markdown="hero.contributor.contributions"
|
||||
class="markdownPreview"
|
||||
></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Edit Moderation Notes</label>
|
||||
<textarea
|
||||
v-model="hero.secret.text"
|
||||
class="form-control"
|
||||
cols="5"
|
||||
rows="3"
|
||||
></textarea>
|
||||
<div
|
||||
v-markdown="hero.secret.text"
|
||||
class="markdownPreview"
|
||||
></div>
|
||||
</div>
|
||||
<input
|
||||
type="submit"
|
||||
value="Save and Clear Data"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.levelField {
|
||||
min-width: 10ch;
|
||||
}
|
||||
.textField {
|
||||
min-width: 50ch;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import markdownDirective from '@/directives/markdown';
|
||||
import saveHero from '../mixins/saveHero';
|
||||
|
||||
import { mapState } from '@/libs/store';
|
||||
import { userStateMixin } from '../../../mixins/userState';
|
||||
|
||||
function resetData (self) {
|
||||
self.expand = self.hero.contributor.level;
|
||||
}
|
||||
|
||||
export default {
|
||||
directives: {
|
||||
markdown: markdownDirective,
|
||||
},
|
||||
mixins: [
|
||||
userStateMixin,
|
||||
saveHero,
|
||||
],
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
},
|
||||
props: {
|
||||
resetCounter: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
hero: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
expand: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
resetCounter () {
|
||||
resetData(this);
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
resetData(this);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div class="accordion-group">
|
||||
<h3
|
||||
class="expand-toggle"
|
||||
:class="{'open': expand}"
|
||||
@click="expand = !expand"
|
||||
>
|
||||
Timestamps, Time Zone, Authentication, Email Address
|
||||
<span
|
||||
v-if="errorsOrWarningsExist"
|
||||
>- ERRORS / WARNINGS EXIST</span>
|
||||
</h3>
|
||||
<div v-if="expand">
|
||||
<p
|
||||
v-if="errorsOrWarningsExist"
|
||||
class="errorMessage"
|
||||
>
|
||||
See error(s) below.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
Account created:
|
||||
<strong>{{ hero.auth.timestamps.created | formatDate }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
Most recent cron:
|
||||
<strong>{{ hero.auth.timestamps.loggedin | formatDate }}</strong>
|
||||
("auth.timestamps.loggedin")
|
||||
</div>
|
||||
<div v-if="cronError">
|
||||
"lastCron" value:
|
||||
<strong>{{ hero.lastCron | formatDate }}</strong>
|
||||
<br>
|
||||
<span class="errorMessage">
|
||||
ERROR: cron probably crashed before finishing
|
||||
("auth.timestamps.loggedin" and "lastCron" dates are different).
|
||||
</span>
|
||||
</div>
|
||||
<div class="subsection-start">
|
||||
Time zone:
|
||||
<strong>{{ hero.preferences.timezoneOffset | formatTimeZone }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
Custom Day Start time (CDS):
|
||||
<strong>{{ hero.preferences.dayStart }}</strong>
|
||||
</div>
|
||||
<div v-if="timezoneDiffError || timezoneMissingError">
|
||||
Time zone at previous cron:
|
||||
<strong>{{ hero.preferences.timezoneOffsetAtLastCron | formatTimeZone }}</strong>
|
||||
|
||||
<div class="errorMessage">
|
||||
<div v-if="timezoneDiffError">
|
||||
ERROR: the player's current time zone is different than their time zone when
|
||||
their previous cron ran. This can be because:
|
||||
<ul>
|
||||
<li>daylight savings started or stopped <sup>*</sup></li>
|
||||
<li>the player changed zones due to travel <sup>*</sup></li>
|
||||
<li>the player has devices set to different zones <sup>**</sup></li>
|
||||
<li>the player uses a VPN with varying zones <sup>**</sup></li>
|
||||
<li>something similarly unpleasant is happening. <sup>**</sup></li>
|
||||
</ul>
|
||||
<p>
|
||||
<em>* The problem should fix itself in about a day.</em><br>
|
||||
<em>** One of these causes is probably happening if the time zones stay
|
||||
different for more than a day.</em>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="timezoneMissingError">
|
||||
ERROR: One of the player's time zones is missing.
|
||||
This is expected and okay if it's the "Time zone at previous cron"
|
||||
AND if it's their first day in Habitica.
|
||||
Otherwise an error has occurred.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subsection-start form-inline">
|
||||
API Token:
|
||||
<form @submit.prevent="changeApiToken()">
|
||||
<input
|
||||
type="submit"
|
||||
value="Change API Token"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
</form>
|
||||
<div
|
||||
v-if="tokenModified"
|
||||
class="form-inline"
|
||||
>
|
||||
<strong>API Token has been changed. Tell the player something like this:</strong>
|
||||
<br>
|
||||
I've given you a new API Token.
|
||||
You'll need to log out of the website and mobile app then log back in
|
||||
otherwise they won't work correctly.
|
||||
If you have trouble logging out, for the website go to
|
||||
https://habitica.com/static/clear-browser-data and click the red button there,
|
||||
and for the Android app, clear its data.
|
||||
For the iOS app, if you can't log out you might need to uninstall it,
|
||||
reboot your phone, then reinstall it.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subsection-start">
|
||||
Local authentication:
|
||||
<span v-if="hero.auth.local.email">Yes,
|
||||
<strong>{{ hero.auth.local.email }}</strong></span>
|
||||
<span v-else><strong>None</strong></span>
|
||||
</div>
|
||||
<div>
|
||||
Google authentication:
|
||||
<pre v-if="authMethodExists('google')">{{ hero.auth.google }}</pre>
|
||||
<span v-else><strong>None</strong></span>
|
||||
</div>
|
||||
<div>
|
||||
Facebook authentication:
|
||||
<pre v-if="authMethodExists('facebook')">{{ hero.auth.facebook }}</pre>
|
||||
<span v-else><strong>None</strong></span>
|
||||
</div>
|
||||
<div>
|
||||
Apple ID authentication:
|
||||
<pre v-if="authMethodExists('apple')">{{ hero.auth.apple }}</pre>
|
||||
<span v-else><strong>None</strong></span>
|
||||
</div>
|
||||
<div class="subsection-start">
|
||||
Full "auth" object for checking above is correct:
|
||||
<pre>{{ hero.auth }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import formatDate from '../filters/formatDate';
|
||||
import saveHero from '../mixins/saveHero';
|
||||
|
||||
function resetData (self) {
|
||||
self.cronError = false;
|
||||
self.timezoneDiffError = false;
|
||||
self.timezoneMissingError = false;
|
||||
self.errorsOrWarningsExist = false;
|
||||
self.expand = false;
|
||||
|
||||
const cronDate1 = moment(self.hero.auth.timestamps.loggedin);
|
||||
const cronDate2 = moment(self.hero.lastCron);
|
||||
const maxAllowableSecondsDifference = 60; // expect cron to take less than this many seconds
|
||||
if (Math.abs(cronDate1.diff(cronDate2, 'seconds')) > maxAllowableSecondsDifference) {
|
||||
self.cronError = true;
|
||||
self.errorsOrWarningsExist = true;
|
||||
}
|
||||
|
||||
// compare the user's time zones to see if they're different
|
||||
const newTimezone = self.hero.preferences.timezoneOffset;
|
||||
const oldTimezone = self.hero.preferences.timezoneOffsetAtLastCron;
|
||||
if ((newTimezone === undefined || oldTimezone === undefined)
|
||||
&& (self.cronError || self.hero.flags.cronCount > 0)) {
|
||||
self.timezoneMissingError = true;
|
||||
self.errorsOrWarningsExist = true;
|
||||
} else if (newTimezone !== oldTimezone) {
|
||||
self.timezoneDiffError = true;
|
||||
self.errorsOrWarningsExist = true;
|
||||
}
|
||||
self.expand = self.errorsOrWarningsExist;
|
||||
}
|
||||
|
||||
export default {
|
||||
filters: {
|
||||
formatDate,
|
||||
formatTimeZone (timezoneOffset) {
|
||||
if (timezoneOffset === undefined) return 'No value recorded.';
|
||||
// convert reverse offset to time zone in "+/-H:MM UTC" format
|
||||
const sign = (timezoneOffset < 0) ? '+' : '-'; // reverse the sign
|
||||
const timezoneHours = Math.floor(Math.abs(timezoneOffset) / 60);
|
||||
const timezoneMinutes = Math.floor((Math.abs(timezoneOffset) / 60 - timezoneHours) * 60);
|
||||
const timezoneMinutesDisplay = (timezoneMinutes) ? `:${timezoneMinutes}` : ''; // don't display :00
|
||||
return `${sign}${timezoneHours}${timezoneMinutesDisplay} UTC`;
|
||||
},
|
||||
},
|
||||
mixins: [
|
||||
saveHero,
|
||||
],
|
||||
props: {
|
||||
resetCounter: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
hero: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
cronError: false,
|
||||
timezoneDiffError: false,
|
||||
timezoneMissingError: false,
|
||||
tokenModified: false,
|
||||
errorsOrWarningsExist: false,
|
||||
expand: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
resetCounter () {
|
||||
resetData(this);
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
resetData(this);
|
||||
},
|
||||
methods: {
|
||||
authMethodExists (authMethod) {
|
||||
if (this.hero.auth[authMethod] && this.hero.auth[authMethod].length !== 0) return true;
|
||||
return false;
|
||||
},
|
||||
async changeApiToken () {
|
||||
this.hero.changeApiToken = true;
|
||||
await this.saveHero({ hero: this.hero, msg: 'API Token' });
|
||||
this.tokenModified = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div v-if="hasPermission(user, 'userSupport')">
|
||||
<div
|
||||
v-if="hero && hero.profile"
|
||||
class="row"
|
||||
>
|
||||
<div class="form col-12">
|
||||
<basic-details
|
||||
:user-id="hero._id"
|
||||
:auth="hero.auth"
|
||||
:preferences="hero.preferences"
|
||||
:profile="hero.profile"
|
||||
/>
|
||||
|
||||
<privileges-and-gems
|
||||
:hero="hero"
|
||||
:reset-counter="resetCounter"
|
||||
/>
|
||||
|
||||
<cron-and-auth
|
||||
:hero="hero"
|
||||
:reset-counter="resetCounter"
|
||||
/>
|
||||
|
||||
<party-and-quest
|
||||
v-if="adminHasPrivForParty"
|
||||
:user-id="hero._id"
|
||||
:username="hero.auth.local.username"
|
||||
:user-has-party="hasParty"
|
||||
:party-not-exist-error="partyNotExistError"
|
||||
:user-party-data="hero.party"
|
||||
:group-party-data="party"
|
||||
:reset-counter="resetCounter"
|
||||
/>
|
||||
|
||||
<avatar-and-drops
|
||||
:items="hero.items"
|
||||
:preferences="hero.preferences"
|
||||
/>
|
||||
|
||||
<items-owned
|
||||
:hero="hero"
|
||||
:reset-counter="resetCounter"
|
||||
/>
|
||||
|
||||
<transactions
|
||||
:hero="hero"
|
||||
/>
|
||||
|
||||
<contributor-details
|
||||
:hero="hero"
|
||||
:reset-counter="resetCounter"
|
||||
@clear-data="clearData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .accordion-group .accordion-group {
|
||||
margin-left: 1em;
|
||||
}
|
||||
::v-deep h3 {
|
||||
margin-top: 2em;
|
||||
}
|
||||
::v-deep h4 {
|
||||
margin-top: 1em;
|
||||
}
|
||||
::v-deep .expand-toggle::after {
|
||||
margin-left: 5px;
|
||||
}
|
||||
::v-deep .subsection-start {
|
||||
margin-top: 1em;
|
||||
}
|
||||
::v-deep .form-inline {
|
||||
margin-bottom: 1em;
|
||||
input, span {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
::v-deep .errorMessage {
|
||||
font-weight: bold;
|
||||
}
|
||||
::v-deep .markdownPreview {
|
||||
margin-left: 3em;
|
||||
margin-top: 1em;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import BasicDetails from './basicDetails';
|
||||
import ItemsOwned from './itemsOwned';
|
||||
import CronAndAuth from './cronAndAuth';
|
||||
import PartyAndQuest from './partyAndQuest';
|
||||
import AvatarAndDrops from './avatarAndDrops';
|
||||
import PrivilegesAndGems from './privilegesAndGems';
|
||||
import ContributorDetails from './contributorDetails';
|
||||
import Transactions from './transactions';
|
||||
|
||||
import { userStateMixin } from '../../../mixins/userState';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BasicDetails,
|
||||
ItemsOwned,
|
||||
CronAndAuth,
|
||||
PartyAndQuest,
|
||||
AvatarAndDrops,
|
||||
PrivilegesAndGems,
|
||||
ContributorDetails,
|
||||
Transactions,
|
||||
},
|
||||
mixins: [userStateMixin],
|
||||
data () {
|
||||
return {
|
||||
userIdentifier: '',
|
||||
resetCounter: 0,
|
||||
hero: {},
|
||||
party: {},
|
||||
hasParty: false,
|
||||
partyNotExistError: false,
|
||||
adminHasPrivForParty: true,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
userIdentifier () {
|
||||
// close modal if the page is opened in an existing tab from the modal
|
||||
this.$root.$emit('bv::hide::modal', 'profile');
|
||||
|
||||
this.loadHero(this.userIdentifier);
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
this.userIdentifier = this.$route.params.userIdentifier;
|
||||
},
|
||||
methods: {
|
||||
clearData () {
|
||||
this.hero = {};
|
||||
},
|
||||
|
||||
async loadHero (userIdentifier) {
|
||||
const id = userIdentifier.replace(/@/, ''); // allow "@name" to be entered
|
||||
this.$emit('changeUserIdentifier', id); // change user identifier in Admin Panel's form
|
||||
|
||||
this.hero = await this.$store.dispatch('hall:getHero', { uuid: id });
|
||||
|
||||
if (!this.hero.flags) {
|
||||
this.hero.flags = {
|
||||
chatRevoked: false,
|
||||
chatShadowMuted: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.hero.permissions) {
|
||||
this.hero.permissions = {};
|
||||
}
|
||||
|
||||
this.hasParty = false;
|
||||
this.partyNotExistError = false;
|
||||
this.adminHasPrivForParty = true;
|
||||
if (this.hero.party && this.hero.party._id) {
|
||||
try {
|
||||
this.party = await this.$store.dispatch('hall:getHeroParty', { groupId: this.hero.party._id });
|
||||
this.hasParty = true;
|
||||
} catch (e) {
|
||||
if (e.message.includes('status code 401')) {
|
||||
// @TODO is there a better way to recognise NotAuthorized error?
|
||||
this.adminHasPrivForParty = false;
|
||||
} else {
|
||||
// the API's error message isn't worth reporting ("Request failed with status code 404")
|
||||
this.partyNotExistError = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.resetCounter += 1; // tell child components to reinstantiate from scratch
|
||||
},
|
||||
},
|
||||
beforeRouteUpdate (to, from, next) {
|
||||
this.userIdentifier = to.params.userIdentifier;
|
||||
next();
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,289 @@
|
||||
<template>
|
||||
<div class="accordion-group">
|
||||
<h3
|
||||
class="expand-toggle"
|
||||
:class="{'open': expand}"
|
||||
@click="expand = !expand"
|
||||
>
|
||||
Items
|
||||
</h3>
|
||||
<div v-if="expand">
|
||||
<div>
|
||||
The sections below display each item's key (bolded if the player has ever owned it),
|
||||
followed by the item's English name.
|
||||
<ul>
|
||||
<li>
|
||||
Click on an item's key or value to change it
|
||||
(hovering shows an underline to show where you can click).
|
||||
</li>
|
||||
<li>For Mounts and Gear, clicking toggles between the allowed values.</li>
|
||||
<li>For other item types, clicking gives you a form field to enter a new value.</li>
|
||||
<li>Click Save when the correct value is displayed.</li>
|
||||
<li>
|
||||
You must Save for each item individually but you do not need to reload the user
|
||||
between each Save.
|
||||
</li>
|
||||
<li>If you adjust an item and do not click Save for it, the change will be lost.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="itemType in itemTypes"
|
||||
:key="itemType"
|
||||
>
|
||||
<div class="accordion-group">
|
||||
<h4
|
||||
class="expand-toggle"
|
||||
:class="{'open': expandItemType[itemType]}"
|
||||
@click="expandItemType[itemType] = !expandItemType[itemType]"
|
||||
>
|
||||
{{ itemType }}
|
||||
</h4>
|
||||
|
||||
<div v-if="expandItemType[itemType]">
|
||||
<p v-if="itemType === 'pets'">
|
||||
A value of -1 means they owned the Pet but Released it
|
||||
and have not yet rehatched it.
|
||||
</p>
|
||||
<p v-if="itemType === 'mounts'">
|
||||
A value of "null" means they owned the Mount but Released it
|
||||
and have not yet retamed it.
|
||||
</p>
|
||||
<p v-if="itemType === 'special'">
|
||||
When there are 0 of these items, we can't tell if
|
||||
they had been owned and were all used, or have never been owned.
|
||||
</p>
|
||||
<p v-if="itemType === 'gear'">
|
||||
A value of true means they own the item now and can wear it.
|
||||
A value of false means they used to own it but lost it from Death
|
||||
(or an old Rebirth).
|
||||
</p>
|
||||
<ul>
|
||||
<li
|
||||
v-for="item in collatedItemData[itemType]"
|
||||
:key="item.path"
|
||||
>
|
||||
<form @submit.prevent="saveItem(item)">
|
||||
<span
|
||||
class="enableValueChange"
|
||||
@click="enableValueChange(item)"
|
||||
>
|
||||
{{ item | displayValue }}
|
||||
:
|
||||
<span :class="{ ownedItem: !item.neverOwned }">{{ item.key }} : </span>
|
||||
</span>
|
||||
<span v-html="item.name"></span>
|
||||
|
||||
<div
|
||||
v-if="item.modified"
|
||||
class="form-inline"
|
||||
>
|
||||
<input
|
||||
v-if="item.valueIsInteger"
|
||||
v-model="item.value"
|
||||
class="form-control valueField"
|
||||
type="number"
|
||||
>
|
||||
<input
|
||||
v-if="item.modified"
|
||||
type="submit"
|
||||
value="Save"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ownedItem {
|
||||
font-weight: bold;
|
||||
}
|
||||
.enableValueChange:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.valueField {
|
||||
min-width: 10ch;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import content from '@/../../common/script/content';
|
||||
import getItemDescription from '../mixins/getItemDescription';
|
||||
import saveHero from '../mixins/saveHero';
|
||||
|
||||
function collateItemData (self) {
|
||||
const collatedItemData = {};
|
||||
self.itemTypes.forEach(itemType => {
|
||||
// itemTypes are pets, food, gear, etc
|
||||
|
||||
// Set up some basic data for this itemType:
|
||||
let basePath = `items.${itemType}`;
|
||||
let ownedItems = self.hero.items[itemType] || {};
|
||||
let allItems = content[itemType];
|
||||
if (itemType === 'gear') {
|
||||
basePath = 'items.gear.owned';
|
||||
ownedItems = self.hero.items.gear.owned || {};
|
||||
allItems = content.gear.flat;
|
||||
} else if (itemType === 'pets' || itemType === 'mounts') {
|
||||
// add the non-Standard pets and mounts
|
||||
const ucItemType = (itemType === 'pets') ? 'Pets' : 'Mounts';
|
||||
self.petMountSubTypes.forEach(subType => {
|
||||
allItems = { ...allItems, ...content[subType + ucItemType] };
|
||||
});
|
||||
}
|
||||
|
||||
const itemData = []; // all items for this itemType
|
||||
|
||||
// Collate data for items that the user owns or used to own:
|
||||
for (const key of Object.keys(ownedItems)) {
|
||||
// Do not sort keys. The order in the items object gives hints about order received.
|
||||
if (itemType !== 'special' || self.specialItems.includes(key)) {
|
||||
const valueIsInteger = !self.nonIntegerTypes.includes(itemType);
|
||||
itemData.push({
|
||||
neverOwned: false,
|
||||
itemType,
|
||||
key,
|
||||
modified: false,
|
||||
name: self.getItemDescription(itemType, key),
|
||||
path: `${basePath}.${key}`,
|
||||
value: ownedItems[key],
|
||||
valueIsInteger,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Collate data for items that the user never owned:
|
||||
for (const key of Object.keys(allItems).sort()) {
|
||||
if (
|
||||
// ignore items the user owns because we captured them above:
|
||||
!(key in ownedItems)
|
||||
|
||||
// ignore gear items that indicate empty equipped slots (e.g., head_base_0):
|
||||
&& !(itemType === 'gear' && content.gear.flat[key].set
|
||||
&& content.gear.flat[key].set === 'base-0')
|
||||
|
||||
// ignore "special" items that aren't Snowballs, Seafoam, etc:
|
||||
&& (itemType !== 'special' || self.specialItems.includes(key))
|
||||
) {
|
||||
const valueIsInteger = !self.nonIntegerTypes.includes(itemType);
|
||||
const value = (valueIsInteger) ? 0 : '';
|
||||
itemData.push({
|
||||
neverOwned: true,
|
||||
itemType,
|
||||
key,
|
||||
modified: false,
|
||||
name: self.getItemDescription(itemType, key),
|
||||
path: `${basePath}.${key}`,
|
||||
value,
|
||||
valueIsInteger,
|
||||
});
|
||||
}
|
||||
}
|
||||
collatedItemData[itemType] = itemData;
|
||||
});
|
||||
return collatedItemData;
|
||||
}
|
||||
|
||||
function resetData (self) {
|
||||
self.collatedItemData = collateItemData(self);
|
||||
self.itemTypes.forEach(itemType => { self.expandItemType[itemType] = false; });
|
||||
}
|
||||
|
||||
export default {
|
||||
filters: {
|
||||
displayValue (item) {
|
||||
if (item.value === '') return 'never owned';
|
||||
if (item.value === 0 && item.neverOwned) return '0 (never owned)';
|
||||
if (item.value === null) return 'null'; // we need visible text
|
||||
return item.value; // true or false or an integer
|
||||
},
|
||||
},
|
||||
mixins: [
|
||||
getItemDescription,
|
||||
saveHero,
|
||||
],
|
||||
props: {
|
||||
resetCounter: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
hero: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
expand: false,
|
||||
expandItemType: {
|
||||
eggs: false,
|
||||
hatchingPotions: false,
|
||||
food: false,
|
||||
pets: false,
|
||||
mounts: false,
|
||||
quests: false,
|
||||
gear: false,
|
||||
special: false,
|
||||
},
|
||||
itemTypes: ['eggs', 'hatchingPotions', 'food', 'pets', 'mounts', 'quests', 'gear', 'special'],
|
||||
nonIntegerTypes: ['mounts', 'gear'],
|
||||
petMountSubTypes: ['premium', 'quest', 'special', 'wacky'], // e.g., 'premiumPets'
|
||||
// items.special includes many things but we are interested in these only:
|
||||
specialItems: ['snowball', 'spookySparkles', 'shinySeed', 'seafoam'],
|
||||
collatedItemData: {},
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
resetCounter () {
|
||||
resetData(this);
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
resetData(this);
|
||||
},
|
||||
methods: {
|
||||
async saveItem (item) {
|
||||
// prepare the item's new value and path for being saved
|
||||
this.hero.itemPath = item.path;
|
||||
if (item.value === null) {
|
||||
this.hero.itemVal = 'null';
|
||||
} else if (item.value === false) {
|
||||
this.hero.itemVal = 'false';
|
||||
} else {
|
||||
this.hero.itemVal = item.value;
|
||||
}
|
||||
|
||||
await this.saveHero({ hero: this.hero, msg: item.key });
|
||||
item.neverOwned = false;
|
||||
item.modified = false;
|
||||
},
|
||||
enableValueChange (item) {
|
||||
// allow form field(s) to be shown:
|
||||
item.modified = true;
|
||||
|
||||
// for non-integer items, toggle through the allowed values:
|
||||
if (item.itemType === 'gear') {
|
||||
// Allowed starting values are true, false, and '' (never owned)
|
||||
// Allowed values to switch to are true and false
|
||||
item.value = !item.value;
|
||||
} else if (item.itemType === 'mounts') {
|
||||
// Allowed starting values are true, null, and "never owned"
|
||||
// Allowed values to switch to are true and null
|
||||
if (item.value === true) {
|
||||
item.value = null;
|
||||
} else {
|
||||
item.value = true;
|
||||
}
|
||||
}
|
||||
// @TODO add a delete option
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<div class="accordion-group">
|
||||
<h3
|
||||
class="expand-toggle"
|
||||
:class="{'open': expand}"
|
||||
@click="expand = !expand"
|
||||
>
|
||||
Party, Quest
|
||||
<span
|
||||
v-if="errorsOrWarningsExist"
|
||||
>- ERRORS / WARNINGS EXIST</span>
|
||||
</h3>
|
||||
<div v-if="expand">
|
||||
<div
|
||||
v-if="errorsOrWarningsExist"
|
||||
class="errorMessage"
|
||||
>
|
||||
<p v-if="partyNotExistError">
|
||||
ERROR: User has a Party ID but that Party does not exist.
|
||||
If you are seeing a red error notification on screen now
|
||||
("<strong>Group with id ... not found</strong>"), it's refering to this issue.
|
||||
<br>Ask a database admin to delete the user's Party ID ({{ userPartyData._id }}).
|
||||
</p>
|
||||
<p
|
||||
v-if="questErrors"
|
||||
v-html="questErrors"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Party:
|
||||
<span v-if="userHasParty">
|
||||
yes: party ID {{ groupPartyData._id }},
|
||||
member count {{ groupPartyData.memberCount }} (may be wrong)
|
||||
<br>
|
||||
<span v-if="userIsPartyLeader">User is the party leader</span>
|
||||
<span v-else>Party leader is
|
||||
<router-link :to="{'name': 'userProfile', 'params': {'userId': groupPartyData.leader}}">
|
||||
{{ groupPartyData.leader }}
|
||||
</router-link>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>no</span>
|
||||
</div>
|
||||
<div class="subsection-start">
|
||||
<p v-html="questStatus"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as quests from '@/../../common/script/content/quests';
|
||||
|
||||
function determineQuestStatus (self) {
|
||||
// Quest data is in the user doc and party doc. They can be out of sync.
|
||||
// Here we collate data from both sources, showing error messages if needed.
|
||||
|
||||
// First get data from the party's document.
|
||||
const groupQuestData = self.groupPartyData.quest;
|
||||
let questExists = false; // true if quest is active or in invitation stage
|
||||
let questIsActive = false; // true if quest's invitation stage is over
|
||||
let inviteStatusForUser = '';
|
||||
let expectedRsvpStatusForUser = false;
|
||||
let countOfQuestMembers = 0;
|
||||
if (self.userHasParty && groupQuestData) {
|
||||
questIsActive = groupQuestData.active;
|
||||
if (groupQuestData.members) countOfQuestMembers = Object.keys(groupQuestData.members).length;
|
||||
if (groupQuestData.key) {
|
||||
questExists = true;
|
||||
if (!countOfQuestMembers) {
|
||||
self.questErrors = 'ERROR: Quest is running or in invitation stage but has no participants.';
|
||||
} else if (groupQuestData.members[self.userId] === null) {
|
||||
inviteStatusForUser = 'pending';
|
||||
if (questIsActive) {
|
||||
self.questErrors = 'ERROR: Quest is running but user\'s invitation is still pending ("null") in quest object.';
|
||||
} else {
|
||||
expectedRsvpStatusForUser = true;
|
||||
}
|
||||
} else if (groupQuestData.members[self.userId] === false) {
|
||||
inviteStatusForUser = 'rejected';
|
||||
if (questIsActive) {
|
||||
self.questErrors = 'ERROR: Quest is running and user\'s invitation was rejected BUT '
|
||||
+ 'it wasn\'t cleared properly from the quest\'s data ("false"). '
|
||||
+ 'That shouldn\'t cause any problems though.';
|
||||
}
|
||||
} else if (groupQuestData.members[self.userId] === true) {
|
||||
inviteStatusForUser = 'accepted';
|
||||
} else if (questIsActive) {
|
||||
inviteStatusForUser = 'rejected OR not accepted before quest start OR user joined party after quest started';
|
||||
} else {
|
||||
inviteStatusForUser = 'missing';
|
||||
self.questErrors = 'ERROR: Quest is in invitation stage but user doesn\'t have an invitation '
|
||||
+ 'in the party\'s data ("quest.members" needs to be fixed).';
|
||||
}
|
||||
} else if (questIsActive) {
|
||||
self.questErrors = 'ERROR: Quest is running but there is no "key" to say which quest it is. '
|
||||
+ 'This means the other data and errors in this section are unreliable, '
|
||||
+ 'and there may be more errors not shown here.'
|
||||
+ 'Other errors here may tell you which key to add.'
|
||||
+ 'After fixing, check for more errors.';
|
||||
// @TODO display a similar message for when it happens during invitation stage
|
||||
}
|
||||
}
|
||||
if (self.questErrors) self.questErrors += '<br>';
|
||||
// from this point on, further quest errors need to be appended to that
|
||||
|
||||
let questStatus = '<p>';
|
||||
if (questExists) {
|
||||
questStatus = 'Quest exists and is ';
|
||||
if (questIsActive) {
|
||||
questStatus += 'running.<br>User is ';
|
||||
if (inviteStatusForUser !== 'accepted') questStatus += 'not ';
|
||||
questStatus += 'a participant.';
|
||||
} else {
|
||||
questStatus += 'in invitation stage.<br>'
|
||||
+ `User's invitation is ${inviteStatusForUser}.`;
|
||||
}
|
||||
questStatus += '<br>';
|
||||
if (!groupQuestData.leader) {
|
||||
self.questErrors += 'ERROR: quest does not have its owner specified '
|
||||
+ '(party needs value for "quest.leader").<br>';
|
||||
} else if (groupQuestData.leader === self.userId) {
|
||||
questStatus += 'User is the quest owner.';
|
||||
} else {
|
||||
questStatus += `Quest owner is ${groupQuestData.leader}`;
|
||||
}
|
||||
} else {
|
||||
questStatus = 'No quest.';
|
||||
}
|
||||
questStatus += '</p>';
|
||||
|
||||
// Assess quest participants.
|
||||
if (questExists && countOfQuestMembers) {
|
||||
const participants = (questIsActive) ? 'participants' : 'invitees';
|
||||
questStatus += `<p>Quest has ${countOfQuestMembers} ${participants}:<ul>`;
|
||||
for (const [memberId, inviteStatus] of Object.entries(groupQuestData.members)) {
|
||||
questStatus += '<li>';
|
||||
questStatus += (memberId === self.userId)
|
||||
? `@${self.username}`
|
||||
: memberId;
|
||||
let invitationDescription = '';
|
||||
const errMsg = ' - MINOR ERROR: this data should have been deleted when quest started';
|
||||
if (inviteStatus === true) {
|
||||
if (!questIsActive) invitationDescription = ' - invitation accepted';
|
||||
// we don't display anything if quest is running - obvious that participant accepted
|
||||
} else if (inviteStatus === false) {
|
||||
invitationDescription += ' - invitation rejected';
|
||||
if (questIsActive) invitationDescription += errMsg;
|
||||
} else {
|
||||
invitationDescription += ' - invitation pending';
|
||||
if (questIsActive) invitationDescription += errMsg;
|
||||
}
|
||||
questStatus += invitationDescription;
|
||||
questStatus += '</li>';
|
||||
}
|
||||
questStatus += '</ul></p>';
|
||||
// @TODO: show error if all invitations accepted but quest not active
|
||||
}
|
||||
|
||||
// Now get data from the user's document.
|
||||
if (!self.userPartyData.quest) self.userPartyData.quest = {};
|
||||
if (self.userPartyData.quest.RSVPNeeded !== expectedRsvpStatusForUser) {
|
||||
self.questErrors
|
||||
+= `ERROR: User's quest invitation ("party.quest.RSVPNeeded") should be "${expectedRsvpStatusForUser}" but isn't.<br>`;
|
||||
}
|
||||
|
||||
if (inviteStatusForUser === 'pending' || inviteStatusForUser === 'accepted') {
|
||||
if (!self.userPartyData.quest.key) {
|
||||
self.questErrors += 'ERROR: User has accepted quest invitation or invitation is '
|
||||
+ 'still pending but their account has no "key" for the quest.<br>';
|
||||
} else if (self.userPartyData.quest.key !== groupQuestData.key) {
|
||||
self.questErrors += 'ERROR: User has accepted quest invitation or invitation is '
|
||||
+ `still pending but the "key" in their account (${self.userPartyData.quest.key}) `
|
||||
+ `is different than the quest's "key" (${groupQuestData.key}).<br>`;
|
||||
}
|
||||
} else if (self.userPartyData.quest.key) {
|
||||
self.questErrors += `ERROR: User has a "key" for the quest (${self.userPartyData.quest.key})`
|
||||
+ 'but perhaps should not have (no quest exists, or user not participating, '
|
||||
+ 'or quest is in erroneous state).<br>';
|
||||
}
|
||||
|
||||
// Display details of quest (name, type, progress, etc).
|
||||
if (questExists) {
|
||||
const questContent = quests.quests[groupQuestData.key];
|
||||
if (questContent) {
|
||||
let questContentData = `<strong>Quest Details</strong>:<br>Quest name: ${questContent.text()}<br>Quest "key": ${questContent.key}`;
|
||||
let questProgress = '<strong>Quest Progress:</strong>';
|
||||
if (!questIsActive) questProgress += ' none (quest is in invitation stage)';
|
||||
let userProgressToday;
|
||||
let userMadeZeroProgress = false;
|
||||
if (questContent.boss) {
|
||||
// NB Data rounding below is done in the same way as on the user's party page.
|
||||
questContentData += `<br>Boss name: ${questContent.boss.name()}`
|
||||
+ `<br>Boss's starting HP: ${questContent.boss.hp}`
|
||||
+ `<br>Boss's Strength: ${questContent.boss.str}`;
|
||||
let bossHasRage;
|
||||
if (questContent.boss.rage && questContent.boss.rage.value) {
|
||||
bossHasRage = true;
|
||||
questContentData += `<br>Boss's rage name for this quest: ${questContent.boss.rage.title()}`;
|
||||
questContentData += `<br>Boss's rage limit: ${questContent.boss.rage.value}`;
|
||||
}
|
||||
if (questIsActive) {
|
||||
if (!groupQuestData.progress || groupQuestData.progress.hp === undefined) {
|
||||
self.questErrors += 'ERROR: Party\'s quest is missing some or all of the "progress" data.<br>';
|
||||
} else {
|
||||
questProgress += `<br>Current Boss HP: ${Math.ceil(groupQuestData.progress.hp * 100) / 100}`;
|
||||
}
|
||||
if (bossHasRage) {
|
||||
questProgress += `<br>Current Rage: ${Math.floor(groupQuestData.progress.rage * 100) / 100}`;
|
||||
}
|
||||
}
|
||||
userProgressToday = `Player's pending damage to Boss: ${Math.floor(self.userPartyData.quest.progress.up * 10) / 10}`;
|
||||
if (!self.userPartyData.quest.progress.up) userMadeZeroProgress = true;
|
||||
} else {
|
||||
questContentData += '<br>Need to collect:<ul>';
|
||||
if (questIsActive) questProgress += '<br>Current found items: <ul>';
|
||||
for (const [key, obj] of Object.entries(questContent.collect)) {
|
||||
questContentData += `<li>${obj.text()}: ${obj.count} ("key": ${key})</li>`;
|
||||
if (questIsActive) {
|
||||
if (!groupQuestData.progress || !groupQuestData.progress.collect) {
|
||||
self.questErrors += 'ERROR: Party\'s quest is missing some or all of the "progress" data.<br>';
|
||||
} else if (groupQuestData.progress.collect[key] !== undefined) {
|
||||
questProgress += `<li>${obj.text()}: ${groupQuestData.progress.collect[key]}</li>`;
|
||||
} else {
|
||||
self.questErrors += `ERROR: Party's quest has no entry for "${key}" `
|
||||
+ '("quest.progress.collect" needs to be fixed).<br>';
|
||||
}
|
||||
}
|
||||
}
|
||||
questContentData += '</ul>';
|
||||
if (questIsActive) questProgress += '</ul>';
|
||||
userProgressToday = `Player's pending collected items: ${self.userPartyData.quest.progress.collectedItems}`;
|
||||
if (!self.userPartyData.quest.progress.collectedItems) userMadeZeroProgress = true;
|
||||
}
|
||||
if (userMadeZeroProgress) userProgressToday += '<br>NB: Zero pending quest progress may be from an error in which the user\'s database document is missing the pending progress fields. That error can\'t be identified here because the API will apply default data. If the user claims to have made pending progress but none is showing for them, a database admin has to check that.';
|
||||
questStatus += `<p>${questContentData}</p>`
|
||||
+ `<p>${questProgress}</p>`
|
||||
+ `<p>${userProgressToday}</p>`;
|
||||
questStatus += `<p><strong>Raw Quest Data:</strong></p><pre>party: ${JSON.stringify(groupQuestData, null, ' ')}`
|
||||
+ `\nuser: ${JSON.stringify(self.userPartyData.quest, null, ' ')}</pre>`;
|
||||
} else {
|
||||
self.questErrors += `ERROR: quest "key" ${groupQuestData.key} does not match a known quest.`;
|
||||
}
|
||||
}
|
||||
return questStatus;
|
||||
}
|
||||
|
||||
function resetData (self) {
|
||||
self.questStatus = '';
|
||||
self.questErrors = '';
|
||||
self.errorsOrWarningsExist = false;
|
||||
self.expand = false;
|
||||
|
||||
if (self.partyNotExistError) {
|
||||
self.errorsOrWarningsExist = true;
|
||||
} else {
|
||||
self.userIsPartyLeader = self.groupPartyData.leader === self.userId;
|
||||
}
|
||||
|
||||
// check for quest errors even if party doesn't exist (user can have old quest data)
|
||||
self.questStatus = determineQuestStatus(self);
|
||||
if (self.questErrors) self.errorsOrWarningsExist = true;
|
||||
|
||||
self.expand = self.errorsOrWarningsExist;
|
||||
}
|
||||
|
||||
export default {
|
||||
props: {
|
||||
resetCounter: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
userId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
userHasParty: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
partyNotExistError: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
userPartyData: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
groupPartyData: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
userIsPartyLeader: false,
|
||||
questStatus: '',
|
||||
questErrors: '',
|
||||
errorsOrWarningsExist: false,
|
||||
expand: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
resetCounter () {
|
||||
resetData(this);
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
resetData(this);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="accordion-group">
|
||||
<h3
|
||||
class="expand-toggle"
|
||||
:class="{'open': expand}"
|
||||
@click="expand = !expand"
|
||||
>
|
||||
Privileges, Gem Balance
|
||||
</h3>
|
||||
<div v-if="expand">
|
||||
<p
|
||||
v-if="errorsOrWarningsExist"
|
||||
class="errorMessage"
|
||||
>
|
||||
Player has had privileges removed or has moderation notes.
|
||||
</p>
|
||||
|
||||
<form @submit.prevent="saveHero({hero, msg: 'Privileges or Gems or Moderation Notes'})">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input
|
||||
v-if="hero.flags"
|
||||
v-model="hero.flags.chatShadowMuted"
|
||||
type="checkbox"
|
||||
> Shadow Mute
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input
|
||||
v-if="hero.flags"
|
||||
v-model="hero.flags.chatRevoked"
|
||||
type="checkbox"
|
||||
> Mute (Revoke Chat Privileges)
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input
|
||||
v-model="hero.auth.blocked"
|
||||
type="checkbox"
|
||||
> Ban / Block
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-inline">
|
||||
<label>
|
||||
Balance
|
||||
<input
|
||||
v-model="hero.balance"
|
||||
class="form-control balanceField"
|
||||
type="number"
|
||||
step="0.25"
|
||||
>
|
||||
</label>
|
||||
<span>
|
||||
<small>
|
||||
Balance is in USD, not in Gems.
|
||||
E.g., if this number is 1, it means 4 Gems.
|
||||
Arrows change Balance by 0.25 (i.e., 1 Gem per click).
|
||||
Do not use when awarding tiers; tier gems are automatic.
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Moderation Notes</label>
|
||||
<textarea
|
||||
v-model="hero.secret.text"
|
||||
class="form-control"
|
||||
cols="5"
|
||||
rows="5"
|
||||
></textarea>
|
||||
<div
|
||||
v-markdown="hero.secret.text"
|
||||
class="markdownPreview"
|
||||
></div>
|
||||
</div>
|
||||
<input
|
||||
type="submit"
|
||||
value="Save"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.balanceField {
|
||||
min-width: 15ch;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import markdownDirective from '@/directives/markdown';
|
||||
import saveHero from '../mixins/saveHero';
|
||||
|
||||
function resetData (self) {
|
||||
self.errorsOrWarningsExist = false;
|
||||
self.expand = false;
|
||||
if (self.hero.flags.chatRevoked || self.hero.flags.chatShadowMuted || self.hero.auth.blocked
|
||||
|| (self.hero.secret.text && !self.hero.contributor.level)) {
|
||||
// We automatically expand this section if the user has had privileges removed.
|
||||
// We also expand if they have secret.text UNLESS they have a contributor tier because
|
||||
// in that case the notes are probably about their contributions and can be seen in the
|
||||
// Contributor Details section (which will be automatically expanded because of their tier).
|
||||
self.errorsOrWarningsExist = true;
|
||||
self.expand = true;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
directives: {
|
||||
markdown: markdownDirective,
|
||||
},
|
||||
mixins: [
|
||||
saveHero,
|
||||
],
|
||||
props: {
|
||||
resetCounter: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
hero: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
errorsOrWarningsExist: false,
|
||||
expand: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
resetCounter () {
|
||||
resetData(this);
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
resetData(this);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="accordion-group">
|
||||
<h3
|
||||
class="expand-toggle"
|
||||
:class="{'open': expand}"
|
||||
@click="toggleTransactionsOpen"
|
||||
>
|
||||
Transactions
|
||||
</h3>
|
||||
<div v-if="expand">
|
||||
<purchase-history-table
|
||||
:gem-transactions="gemTransactions"
|
||||
:hourglass-transactions="hourglassTransactions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PurchaseHistoryTable from '../../ui/purchaseHistoryTable.vue';
|
||||
import { userStateMixin } from '../../../mixins/userState';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PurchaseHistoryTable,
|
||||
},
|
||||
mixins: [userStateMixin],
|
||||
props: {
|
||||
hero: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
expand: false,
|
||||
gemTransactions: [],
|
||||
hourglassTransactions: [],
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async toggleTransactionsOpen () {
|
||||
this.expand = !this.expand;
|
||||
if (this.expand) {
|
||||
const transactions = await this.$store.dispatch('members:getPurchaseHistory', { memberId: this.hero._id });
|
||||
this.gemTransactions = transactions.filter(transaction => transaction.currency === 'gems');
|
||||
this.hourglassTransactions = transactions.filter(transaction => transaction.currency === 'hourglasses');
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -589,7 +589,7 @@ export default {
|
||||
async makeAdmin () {
|
||||
await axios.post('/api/v4/debug/make-admin');
|
||||
// @TODO: Notification.text('You are now an admin!
|
||||
// Go to the Hall of Heroes to change your contributor level.');
|
||||
// Reload the website then go to Help > Admin Panel to set contributor level, etc.');
|
||||
// @TODO: sync()
|
||||
},
|
||||
openModifyInventoryModal () {
|
||||
|
||||
@@ -19,27 +19,6 @@
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!registering"
|
||||
class="form-group row text-center"
|
||||
>
|
||||
<div class="col-12 col-md-12">
|
||||
<div
|
||||
class="btn btn-secondary social-button"
|
||||
@click="socialAuth('facebook')"
|
||||
>
|
||||
<div
|
||||
class="svg-icon social-icon"
|
||||
v-html="icons.facebookIcon"
|
||||
></div>
|
||||
<div
|
||||
class="text"
|
||||
>
|
||||
{{ $t('loginWithSocial', {social: 'Facebook'}) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row text-center">
|
||||
<div class="col-12 col-md-12">
|
||||
<div
|
||||
|
||||
@@ -125,6 +125,16 @@ export default {
|
||||
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
|
||||
background-color: $white;
|
||||
|
||||
.sprite.customize-option.shirt {
|
||||
margin-left: -3px !important;
|
||||
// otherwise its overriden by the .outer-option-background:not(.none) { rules
|
||||
}
|
||||
|
||||
.sprite.customize-option.skin {
|
||||
margin-left: -8px !important;
|
||||
// otherwise its overriden by the .outer-option-background:not(.none) { rules
|
||||
}
|
||||
|
||||
.option {
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
@@ -203,17 +213,9 @@ export default {
|
||||
.outer-option-background:not(.none) {
|
||||
|
||||
.sprite.customize-option {
|
||||
// margin: 0 auto;
|
||||
//margin-left: -3px;
|
||||
//margin-top: -7px;
|
||||
margin-top: 0;
|
||||
margin-left: 0;
|
||||
|
||||
&.size, &.shirt {
|
||||
margin-top: -8px;
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
&.color-bangs {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ import cloneDeep from 'lodash/cloneDeep';
|
||||
import omit from 'lodash/omit';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { mapState } from '@/libs/store';
|
||||
import { userStateMixin } from '../../mixins/userState';
|
||||
import memberSearchDropdown from '@/components/members/memberSearchDropdown';
|
||||
import closeChallengeModal from './closeChallengeModal';
|
||||
import Column from '../tasks/column';
|
||||
@@ -358,7 +358,7 @@ export default {
|
||||
userLink,
|
||||
groupLink,
|
||||
},
|
||||
mixins: [challengeMemberSearchMixin],
|
||||
mixins: [challengeMemberSearchMixin, userStateMixin],
|
||||
props: ['challengeId'],
|
||||
data () {
|
||||
return {
|
||||
@@ -387,7 +387,6 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
isMember () {
|
||||
return this.user.challenges.indexOf(this.challenge._id) !== -1;
|
||||
},
|
||||
@@ -396,7 +395,7 @@ export default {
|
||||
return this.user._id === this.challenge.leader._id;
|
||||
},
|
||||
isAdmin () {
|
||||
return Boolean(this.user.contributor.admin);
|
||||
return this.hasPermission(this.user, 'challengeAdmin');
|
||||
},
|
||||
canJoin () {
|
||||
return !this.isMember;
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
<!-- eslint-disable vue/no-use-v-if-with-v-for -->
|
||||
<div
|
||||
v-for="group in categoryOptions"
|
||||
v-if="group.key !== 'habitica_official' || user.contributor.admin"
|
||||
v-if="group.key !== 'habitica_official' || hasPermission(user, 'challengeAdmin')"
|
||||
:key="group.key"
|
||||
class="form-check"
|
||||
>
|
||||
@@ -277,14 +277,15 @@ import clone from 'lodash/clone';
|
||||
import throttle from 'lodash/throttle';
|
||||
|
||||
import markdownDirective from '@/directives/markdown';
|
||||
import { userStateMixin } from '../../mixins/userState';
|
||||
|
||||
import { TAVERN_ID, MIN_SHORTNAME_SIZE_FOR_CHALLENGES, MAX_SUMMARY_SIZE_FOR_CHALLENGES } from '@/../../common/script/constants';
|
||||
import { mapState } from '@/libs/store';
|
||||
|
||||
export default {
|
||||
directives: {
|
||||
markdown: markdownDirective,
|
||||
},
|
||||
mixins: [userStateMixin],
|
||||
props: ['groupId'],
|
||||
data () {
|
||||
const categoryOptions = [
|
||||
@@ -378,7 +379,6 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
creating () {
|
||||
return !this.workingChallenge.id;
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
class="mentioned-icon"
|
||||
></div>
|
||||
<div
|
||||
v-if="user.contributor.admin && msg.flagCount"
|
||||
v-if="hasPermission(user, 'moderator') && msg.flagCount"
|
||||
class="message-hidden"
|
||||
>
|
||||
{{ flagCountDescription }}
|
||||
@@ -54,7 +54,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="(user.flags.communityGuidelinesAccepted && msg.uuid !== 'system')
|
||||
&& (!isMessageReported || user.contributor.admin)"
|
||||
&& (!isMessageReported || hasPermission(user, 'moderator'))"
|
||||
class="action d-flex align-items-center"
|
||||
@click="report(msg)"
|
||||
>
|
||||
@@ -68,7 +68,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="msg.uuid === user._id || user.contributor.admin"
|
||||
v-if="msg.uuid === user._id || hasPermission(user, 'moderator')"
|
||||
class="action d-flex align-items-center"
|
||||
@click="remove()"
|
||||
>
|
||||
@@ -202,7 +202,7 @@ import cloneDeep from 'lodash/cloneDeep';
|
||||
import escapeRegExp from 'lodash/escapeRegExp';
|
||||
|
||||
import renderWithMentions from '@/libs/renderWithMentions';
|
||||
import { mapState } from '@/libs/store';
|
||||
import { userStateMixin } from '../../mixins/userState';
|
||||
import userLink from '../userLink';
|
||||
|
||||
import deleteIcon from '@/assets/svg/delete.svg';
|
||||
@@ -223,6 +223,7 @@ export default {
|
||||
return moment(value).toDate().toString();
|
||||
},
|
||||
},
|
||||
mixins: [userStateMixin],
|
||||
props: {
|
||||
msg: {},
|
||||
groupId: {},
|
||||
@@ -240,7 +241,6 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
isUserMentioned () {
|
||||
const message = this.msg;
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ import moment from 'moment';
|
||||
import axios from 'axios';
|
||||
import debounce from 'lodash/debounce';
|
||||
import findIndex from 'lodash/findIndex';
|
||||
import { mapState } from '@/libs/store';
|
||||
import { userStateMixin } from '../../mixins/userState';
|
||||
|
||||
import Avatar from '../avatar';
|
||||
import copyAsTodoModal from './copyAsTodoModal';
|
||||
@@ -161,6 +161,7 @@ export default {
|
||||
chatCard,
|
||||
Avatar,
|
||||
},
|
||||
mixins: [userStateMixin],
|
||||
props: {
|
||||
chat: {},
|
||||
groupType: {},
|
||||
@@ -182,7 +183,6 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
// @TODO: We need a different lazy load mechnism.
|
||||
// But honestly, adding a paging route to chat would solve this
|
||||
messages () {
|
||||
@@ -214,7 +214,7 @@ export default {
|
||||
canViewFlag (message) {
|
||||
if (message.uuid === this.user._id) return true;
|
||||
if (!message.flagCount || message.flagCount < 2) return true;
|
||||
return this.user.contributor.admin;
|
||||
return this.hasPermission(this.user, 'moderator');
|
||||
},
|
||||
loadProfileCache: debounce(function loadProfileCache (screenPosition) {
|
||||
this._loadProfileCache(screenPosition);
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
<div class="footer text-center">
|
||||
<button
|
||||
v-if="user.contributor.admin"
|
||||
v-if="hasPermission(user, 'moderator')"
|
||||
class="pull-left btn btn-danger"
|
||||
@click="clearFlagCount()"
|
||||
>
|
||||
@@ -88,15 +88,15 @@
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { mapState } from '@/libs/store';
|
||||
import notifications from '@/mixins/notifications';
|
||||
import markdownDirective from '@/directives/markdown';
|
||||
import { userStateMixin } from '../../mixins/userState';
|
||||
|
||||
export default {
|
||||
directives: {
|
||||
markdown: markdownDirective,
|
||||
},
|
||||
mixins: [notifications],
|
||||
mixins: [notifications, userStateMixin],
|
||||
data () {
|
||||
const abuseFlagModalBody = {
|
||||
firstLinkStart: '<a href="/static/community-guidelines" target="_blank">',
|
||||
@@ -111,9 +111,6 @@ export default {
|
||||
reportComment: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
},
|
||||
mounted () {
|
||||
this.$root.$on('habitica::report-chat', this.handleReport);
|
||||
},
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
<toggle-switch
|
||||
v-model="filterBackgrounds"
|
||||
class="backgroundFilterToggle"
|
||||
:label="'Hide locked backgrounds'"
|
||||
:label="$t('hideLockedBackgrounds')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -247,6 +247,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<sub-menu
|
||||
v-if="!filterBackgrounds"
|
||||
class="text-center"
|
||||
:items="bgSubMenuItems"
|
||||
:active-sub-page="activeSubPage"
|
||||
|
||||
@@ -288,7 +288,7 @@
|
||||
import extend from 'lodash/extend';
|
||||
import groupUtilities from '@/mixins/groupsUtilities';
|
||||
import styleHelper from '@/mixins/styleHelper';
|
||||
import { mapState, mapGetters } from '@/libs/store';
|
||||
import { mapGetters } from '@/libs/store';
|
||||
import * as Analytics from '@/libs/analytics';
|
||||
import participantListModal from './participantListModal';
|
||||
import groupFormModal from './groupFormModal';
|
||||
@@ -312,6 +312,7 @@ import QuestDetailModal from './questDetailModal';
|
||||
import RightSidebar from '@/components/groups/rightSidebar';
|
||||
import InvitationListModal from './invitationListModal';
|
||||
import { PAGES } from '@/libs/consts';
|
||||
import { userStateMixin } from '../../mixins/userState';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -327,7 +328,7 @@ export default {
|
||||
directives: {
|
||||
markdown: markdownDirective,
|
||||
},
|
||||
mixins: [groupUtilities, styleHelper],
|
||||
mixins: [groupUtilities, styleHelper, userStateMixin],
|
||||
props: ['groupId'],
|
||||
data () {
|
||||
return {
|
||||
@@ -356,9 +357,6 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
user: 'user.data',
|
||||
}),
|
||||
...mapGetters({
|
||||
partyMembers: 'party:members',
|
||||
}),
|
||||
@@ -372,7 +370,7 @@ export default {
|
||||
return this.user._id === this.group.leader._id;
|
||||
},
|
||||
isAdmin () {
|
||||
return Boolean(this.user.contributor.admin);
|
||||
return Boolean(this.hasPermission(this.user, 'moderator'));
|
||||
},
|
||||
isMember () {
|
||||
return this.isMemberOfGroup(this.user, this.group);
|
||||
|
||||
@@ -213,7 +213,7 @@ label.custom-control-label(v-once) {{ $t('allowGuildInvitationsFromNonMembers')
|
||||
<!-- eslint-disable vue/no-use-v-if-with-v-for -->
|
||||
<div
|
||||
v-for="group in categoryOptions"
|
||||
v-if="group.key !== 'habitica_official' || user.contributor.admin"
|
||||
v-if="group.key !== 'habitica_official' || hasPermission(user, 'challengeAdmin')"
|
||||
:key="group.key"
|
||||
class="form-check"
|
||||
>
|
||||
@@ -372,13 +372,13 @@ label.custom-control-label(v-once) {{ $t('allowGuildInvitationsFromNonMembers')
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { mapState } from '@/libs/store';
|
||||
import toggleSwitch from '@/components/ui/toggleSwitch';
|
||||
import markdownDirective from '@/directives/markdown';
|
||||
import gemIcon from '@/assets/svg/gem.svg';
|
||||
import informationIcon from '@/assets/svg/information.svg';
|
||||
|
||||
import { MAX_SUMMARY_SIZE_FOR_GUILDS } from '@/../../common/script/constants';
|
||||
import { userStateMixin } from '../../mixins/userState';
|
||||
|
||||
// @TODO: Not sure the best way to pass party creating status
|
||||
// Since we need the modal in the header, passing props doesn't work
|
||||
@@ -393,6 +393,7 @@ export default {
|
||||
directives: {
|
||||
markdown: markdownDirective,
|
||||
},
|
||||
mixins: [userStateMixin],
|
||||
data () {
|
||||
const data = {
|
||||
workingGroup: {
|
||||
@@ -491,7 +492,6 @@ export default {
|
||||
return data;
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
editingGroup () {
|
||||
return this.$store.state.editingGroup;
|
||||
},
|
||||
@@ -512,7 +512,7 @@ export default {
|
||||
return this.workingGroup.type === 'party';
|
||||
},
|
||||
isAdmin () {
|
||||
return Boolean(this.user.contributor.admin);
|
||||
return Boolean(this.hasPermission(this.user, 'moderator'));
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
|
||||
@@ -379,7 +379,6 @@
|
||||
<script>
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { mapState } from '@/libs/store';
|
||||
|
||||
import removeMemberModal from '@/components/members/removeMemberModal';
|
||||
import loadingGryphon from '@/components/ui/loadingGryphon';
|
||||
@@ -390,6 +389,7 @@ import starIcon from '@/assets/members/star.svg';
|
||||
import dots from '@/assets/svg/dots.svg';
|
||||
import SelectList from '@/components/ui/selectList';
|
||||
import { PAGES } from '@/libs/consts';
|
||||
import { userStateMixin } from '../../mixins/userState';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -398,6 +398,7 @@ export default {
|
||||
removeMemberModal,
|
||||
loadingGryphon,
|
||||
},
|
||||
mixins: [userStateMixin],
|
||||
props: ['hideBadge'],
|
||||
data () {
|
||||
return {
|
||||
@@ -462,13 +463,12 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
isLeader () {
|
||||
if (!this.group || !this.group.leader) return false;
|
||||
return this.user._id === this.group.leader || this.user._id === this.group.leader._id;
|
||||
},
|
||||
isAdmin () {
|
||||
return Boolean(this.user.contributor.admin);
|
||||
return Boolean(this.hasPermission(this.user, 'moderator'));
|
||||
},
|
||||
isLoadMoreAvailable () {
|
||||
// Only available if the current length of `members` is less than the
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
<div class="row standard-page">
|
||||
<div>
|
||||
<div v-if="user.contributor.admin">
|
||||
<div v-if="hasPermission(user, 'userSupport')">
|
||||
<h2>Reward User</h2>
|
||||
<div
|
||||
v-if="!hero.profile"
|
||||
@@ -247,9 +247,6 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('name') }}</th>
|
||||
<th v-if="user.contributor && user.contributor.admin">
|
||||
{{ $t('userId') }}
|
||||
</th>
|
||||
<th>{{ $t('contribLevel') }}</th>
|
||||
<th>{{ $t('title') }}</th>
|
||||
<th>{{ $t('contributions') }}</th>
|
||||
@@ -257,12 +254,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"
|
||||
@@ -272,13 +269,17 @@
|
||||
v-else
|
||||
:user="hero"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
v-if="user.contributor.admin"
|
||||
class="btn-link"
|
||||
@click="populateContributorInput(hero._id, index)"
|
||||
>
|
||||
{{ hero._id }}
|
||||
<span v-if="hasPermission(user, 'userSupport')">
|
||||
<br>
|
||||
{{ hero._id }}
|
||||
<br>
|
||||
<router-link
|
||||
:to="{ name: 'adminPanelUser',
|
||||
params: { userIdentifier: hero._id } }"
|
||||
>
|
||||
admin panel
|
||||
</router-link>
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ hero.contributor.level }}</td>
|
||||
<td>{{ hero.contributor.text }}</td>
|
||||
@@ -305,10 +306,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 +315,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 +325,7 @@ export default {
|
||||
directives: {
|
||||
markdown: markdownDirective,
|
||||
},
|
||||
mixins: [notifications, styleHelper],
|
||||
mixins: [notifications, styleHelper, userStateMixin],
|
||||
data () {
|
||||
return {
|
||||
heroes: [],
|
||||
@@ -347,9 +347,6 @@ export default {
|
||||
expandTransactions: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
},
|
||||
async mounted () {
|
||||
this.$store.dispatch('common:setTitle', {
|
||||
section: this.$t('hallContributors'),
|
||||
@@ -392,11 +389,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 +408,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 +420,6 @@ export default {
|
||||
this.heroID = -1;
|
||||
this.currentHeroIndex = -1;
|
||||
},
|
||||
populateContributorInput (id, index) {
|
||||
this.heroID = id;
|
||||
window.scrollTo(0, 200);
|
||||
this.loadHero(id, index);
|
||||
},
|
||||
async toggleTransactionsOpen () {
|
||||
this.expandTransactions = !this.expandTransactions;
|
||||
if (this.expandTransactions) {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('name') }}</th>
|
||||
<th v-if="user.contributor.admin">
|
||||
<th v-if="hasPermission(user, 'userSupport')">
|
||||
{{ $t('userId') }}
|
||||
</th>
|
||||
<th>{{ $t('backerTier') }}</th>
|
||||
@@ -28,7 +28,7 @@
|
||||
></a>
|
||||
{{ patron.profile.name }}
|
||||
</td>
|
||||
<td v-if="user.contributor.admin">
|
||||
<td v-if="hasPermission(user, 'userSupport')">
|
||||
{{ patron._id }}
|
||||
</td>
|
||||
<td>{{ patron.backer.tier }}</td>
|
||||
@@ -40,19 +40,16 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from '@/libs/store';
|
||||
import styleHelper from '@/mixins/styleHelper';
|
||||
import { userStateMixin } from '../../mixins/userState';
|
||||
|
||||
export default {
|
||||
mixins: [styleHelper],
|
||||
mixins: [styleHelper, userStateMixin],
|
||||
data () {
|
||||
return {
|
||||
patrons: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
},
|
||||
async mounted () {
|
||||
this.$store.dispatch('common:setTitle', {
|
||||
section: this.$t('hallPatrons'),
|
||||
|
||||
@@ -224,7 +224,7 @@
|
||||
</div>
|
||||
<router-link
|
||||
class="nav-link"
|
||||
:to="{name: 'groupPlan'}"
|
||||
:to="groupPlanTopLink"
|
||||
>
|
||||
{{ $t('group') }}
|
||||
</router-link>
|
||||
@@ -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'}"
|
||||
@@ -780,6 +788,13 @@ export default {
|
||||
groupPlans: 'groupPlans.data',
|
||||
modalStack: 'modalStack',
|
||||
}),
|
||||
groupPlanTopLink () {
|
||||
if (!this.groupPlans || this.groupPlans.length === 0) return { name: 'groupPlan' };
|
||||
return {
|
||||
name: 'groupPlanDetailTaskInformation',
|
||||
params: { groupId: this.groupPlans[0]._id },
|
||||
};
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
this.getUserGroupPlans();
|
||||
@@ -839,7 +854,6 @@ export default {
|
||||
element.classList.add('down');
|
||||
element.lastChild.style.maxHeight = `${element.lastChild.scrollHeight}px`;
|
||||
},
|
||||
|
||||
closeMenu () {
|
||||
Array.from(document.getElementsByClassName('droppable')).forEach(droppableElement => {
|
||||
this.closeDropdown(droppableElement);
|
||||
|
||||
@@ -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');
|
||||
|
||||
// Reset daily analytics actions
|
||||
setLocalSetting(CONSTANTS.keyConstants.TASKS_SCORED_COUNT, 0);
|
||||
setLocalSetting(CONSTANTS.keyConstants.TASKS_CREATED_COUNT, 0);
|
||||
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([
|
||||
|
||||
@@ -7,7 +7,7 @@ import { setup as setupPayments } from '@/libs/payments';
|
||||
|
||||
setupPayments();
|
||||
|
||||
storiesOf('Payments Buttons', module)
|
||||
storiesOf('Subscriptions/Payments Buttons', module)
|
||||
.add('simple', () => ({
|
||||
components: { PaymentsButtonsList },
|
||||
template: `
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<h5>{{ $t('dayStartAdjustment') }}</h5>
|
||||
<div class="mb-4">
|
||||
{{ $t('customDayStartInfo1') }}
|
||||
</div>
|
||||
<h3 v-once>{{ $t('adjustment') }}</h3>
|
||||
<div class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<div class="">
|
||||
<select
|
||||
v-model="newDayStart"
|
||||
class="form-control"
|
||||
>
|
||||
<option
|
||||
v-for="option in dayStartOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="btn btn-primary full-width mt-3"
|
||||
:disabled="newDayStart === user.preferences.dayStart"
|
||||
@click="openDayStartModal()"
|
||||
>
|
||||
{{ $t('save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<small>
|
||||
<p v-html="$t('timezoneUTC', {utc: timezoneOffsetToUtc})"></p>
|
||||
<p v-html="$t('timezoneInfo')"></p>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import moment from 'moment';
|
||||
import getUtcOffset from '../../../../common/script/fns/getUtcOffset';
|
||||
import { mapState } from '@/libs/store';
|
||||
|
||||
export default {
|
||||
name: 'dayStartAdjustment',
|
||||
data () {
|
||||
const dayStartOptions = [];
|
||||
for (let number = 0; number <= 12; number += 1) {
|
||||
const meridian = number < 12 ? 'AM' : 'PM';
|
||||
const hour = number % 12;
|
||||
const timeWithMeridian = `(${hour || 12}:00 ${meridian})`;
|
||||
const option = {
|
||||
value: number,
|
||||
name: `+${number} hours ${timeWithMeridian}`,
|
||||
};
|
||||
|
||||
if (number === 0) {
|
||||
option.name = `Default ${timeWithMeridian}`;
|
||||
}
|
||||
|
||||
dayStartOptions.push(option);
|
||||
}
|
||||
|
||||
return {
|
||||
newDayStart: 0,
|
||||
dayStartOptions,
|
||||
};
|
||||
},
|
||||
mounted () {
|
||||
this.newDayStart = this.user.preferences.dayStart;
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
user: 'user.data',
|
||||
}),
|
||||
timezoneOffsetToUtc () {
|
||||
const offsetString = moment().utcOffset(getUtcOffset(this.user)).format('Z');
|
||||
return `UTC${offsetString}`;
|
||||
},
|
||||
dayStart () {
|
||||
return this.user.preferences.dayStart;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async saveDayStart () {
|
||||
this.user.preferences.dayStart = this.newDayStart;
|
||||
await axios.post('/api/v4/user/custom-day-start', {
|
||||
dayStart: this.newDayStart,
|
||||
});
|
||||
// @TODO
|
||||
// Notification.text(response.data.data.message);
|
||||
},
|
||||
openDayStartModal () {
|
||||
const nextCron = this.calculateNextCron();
|
||||
// @TODO: Add generic modal
|
||||
if (!window.confirm(this.$t('sureChangeCustomDayStartTime', { time: nextCron }))) return; // eslint-disable-line no-alert
|
||||
this.saveDayStart();
|
||||
// $rootScope.openModal('change-day-start', { scope: $scope });
|
||||
},
|
||||
calculateNextCron () {
|
||||
let nextCron = moment()
|
||||
.hours(this.newDayStart)
|
||||
.minutes(0)
|
||||
.seconds(0)
|
||||
.milliseconds(0);
|
||||
|
||||
const currentHour = moment().format('H');
|
||||
if (currentHour >= this.newDayStart) {
|
||||
nextCron = nextCron.add(1, 'day');
|
||||
}
|
||||
|
||||
return nextCron.format(`${this.user.preferences.dateFormat.toUpperCase()} @ h:mm a`);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -38,7 +38,7 @@
|
||||
{{ $t('subscription') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="user.contributor.admin"
|
||||
v-if="hasPermission(user, 'userSupport')"
|
||||
class="nav-link"
|
||||
:to="{name: 'transactions'}"
|
||||
:class="{'active': $route.name === 'transactions'}"
|
||||
@@ -123,11 +123,13 @@ import find from 'lodash/find';
|
||||
import { mapState } from '@/libs/store';
|
||||
import SecondaryMenu from '@/components/secondaryMenu';
|
||||
import gifts from '@/assets/svg/gifts-vertical.svg';
|
||||
import { userStateMixin } from '../../mixins/userState';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SecondaryMenu,
|
||||
},
|
||||
mixins: [userStateMixin],
|
||||
data () {
|
||||
return {
|
||||
icons: Object.freeze({
|
||||
@@ -138,7 +140,6 @@ export default {
|
||||
computed: {
|
||||
...mapState({
|
||||
currentEventList: 'worldState.data.currentEventList',
|
||||
user: 'user.data',
|
||||
}),
|
||||
currentEvent () {
|
||||
return find(this.currentEventList, event => Boolean(event.promo));
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div>
|
||||
<small>{{ $t('couponText') }}</small>
|
||||
</div>
|
||||
<div v-if="user.contributor.sudo">
|
||||
<div v-if="user.permissions.coupons">
|
||||
<hr>
|
||||
<h4>{{ $t('generateCodes') }}</h4>
|
||||
<div
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -213,49 +213,7 @@
|
||||
{{ $t('enableClass') }}
|
||||
</button>
|
||||
<hr>
|
||||
<div>
|
||||
<h5>{{ $t('customDayStart') }}</h5>
|
||||
<div class="alert alert-warning">
|
||||
{{ $t('customDayStartInfo1') }}
|
||||
</div>
|
||||
<div class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<div class="col-7">
|
||||
<select
|
||||
v-model="newDayStart"
|
||||
class="form-control"
|
||||
>
|
||||
<option
|
||||
v-for="option in dayStartOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<button
|
||||
class="btn btn-block btn-primary mt-1"
|
||||
:disabled="newDayStart === user.preferences.dayStart"
|
||||
@click="openDayStartModal()"
|
||||
>
|
||||
{{ $t('saveCustomDayStart') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
</div>
|
||||
<h5>{{ $t('timezone') }}</h5>
|
||||
<div class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<div class="col-12">
|
||||
<p v-html="$t('timezoneUTC', {utc: timezoneOffsetToUtc})"></p>
|
||||
<p v-html="$t('timezoneInfo')"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<day-start-adjustment />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
@@ -268,7 +226,7 @@
|
||||
:key="network.key"
|
||||
>
|
||||
<button
|
||||
v-if="!user.auth[network.key].id"
|
||||
v-if="!user.auth[network.key].id && network.key !== 'facebook'"
|
||||
class="btn btn-primary mb-2"
|
||||
@click="socialAuth(network.key, user)"
|
||||
>
|
||||
@@ -429,7 +387,9 @@
|
||||
{{ $t('saveAndConfirm') }}
|
||||
</button>
|
||||
</div>
|
||||
<h5>
|
||||
<h5
|
||||
v-if="user.auth.local.email"
|
||||
>
|
||||
{{ $t('changeEmail') }}
|
||||
</h5>
|
||||
<div
|
||||
@@ -539,25 +499,20 @@
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~@/assets/scss/colors.scss';
|
||||
|
||||
input {
|
||||
color: $gray-50;
|
||||
}
|
||||
|
||||
.usersettings h5 {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.iconalert > div > span {
|
||||
line-height: 25px;
|
||||
}
|
||||
|
||||
.iconalert > div:after {
|
||||
clear: both;
|
||||
content: '';
|
||||
display: table;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
color: $red-50;
|
||||
font-size: 90%;
|
||||
@@ -568,16 +523,15 @@
|
||||
|
||||
<script>
|
||||
import hello from 'hellojs';
|
||||
import moment from 'moment';
|
||||
import axios from 'axios';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { mapState } from '@/libs/store';
|
||||
import restoreModal from './restoreModal';
|
||||
import resetModal from './resetModal';
|
||||
import deleteModal from './deleteModal';
|
||||
import dayStartAdjustment from './dayStartAdjustment';
|
||||
import { SUPPORTED_SOCIAL_NETWORKS } from '@/../../common/script/constants';
|
||||
import changeClass from '@/../../common/script/ops/changeClass';
|
||||
import getUtcOffset from '@/../../common/script/fns/getUtcOffset';
|
||||
import notificationsMixin from '../../mixins/notifications';
|
||||
import sounds from '../../libs/sounds';
|
||||
import { buildAppleAuthUrl } from '../../libs/auth';
|
||||
@@ -590,27 +544,15 @@ export default {
|
||||
restoreModal,
|
||||
resetModal,
|
||||
deleteModal,
|
||||
dayStartAdjustment,
|
||||
},
|
||||
mixins: [notificationsMixin],
|
||||
data () {
|
||||
const dayStartOptions = [];
|
||||
for (let number = 0; number < 24; number += 1) {
|
||||
const meridian = number < 12 ? 'AM' : 'PM';
|
||||
const hour = number % 12;
|
||||
const option = {
|
||||
value: number,
|
||||
name: `${hour || 12}:00 ${meridian}`,
|
||||
};
|
||||
dayStartOptions.push(option);
|
||||
}
|
||||
|
||||
return {
|
||||
SOCIAL_AUTH_NETWORKS: [],
|
||||
party: {},
|
||||
// Made available by the server as a script
|
||||
availableFormats: ['MM/dd/yyyy', 'dd/MM/yyyy', 'yyyy/MM/dd'],
|
||||
dayStartOptions,
|
||||
newDayStart: 0,
|
||||
temporaryDisplayName: '',
|
||||
usernameUpdates: { username: '' },
|
||||
emailUpdates: {},
|
||||
@@ -634,13 +576,6 @@ export default {
|
||||
availableAudioThemes () {
|
||||
return ['off', ...this.content.audioThemes];
|
||||
},
|
||||
timezoneOffsetToUtc () {
|
||||
const offsetString = moment().utcOffset(getUtcOffset(this.user)).format('Z');
|
||||
return `UTC${offsetString}`;
|
||||
},
|
||||
dayStart () {
|
||||
return this.user.preferences.dayStart;
|
||||
},
|
||||
hasClass () {
|
||||
return this.$store.getters['members:hasClass'](this.user);
|
||||
},
|
||||
@@ -690,7 +625,6 @@ export default {
|
||||
this.SOCIAL_AUTH_NETWORKS = SUPPORTED_SOCIAL_NETWORKS;
|
||||
// @TODO: We may need to request the party here
|
||||
this.party = this.$store.state.party;
|
||||
this.newDayStart = this.user.preferences.dayStart;
|
||||
this.usernameUpdates.username = this.user.auth.local.username || null;
|
||||
this.temporaryDisplayName = this.user.profile.name;
|
||||
this.emailUpdates.newEmail = this.user.auth.local.email || null;
|
||||
@@ -790,32 +724,6 @@ export default {
|
||||
return false;
|
||||
});
|
||||
},
|
||||
calculateNextCron () {
|
||||
let nextCron = moment().hours(this.newDayStart).minutes(0).seconds(0)
|
||||
.milliseconds(0);
|
||||
|
||||
const currentHour = moment().format('H');
|
||||
if (currentHour >= this.newDayStart) {
|
||||
nextCron = nextCron.add(1, 'day');
|
||||
}
|
||||
|
||||
return nextCron.format(`${this.user.preferences.dateFormat.toUpperCase()} @ h:mm a`);
|
||||
},
|
||||
openDayStartModal () {
|
||||
const nextCron = this.calculateNextCron();
|
||||
// @TODO: Add generic modal
|
||||
if (!window.confirm(this.$t('sureChangeCustomDayStartTime', { time: nextCron }))) return; // eslint-disable-line no-alert
|
||||
this.saveDayStart();
|
||||
// $rootScope.openModal('change-day-start', { scope: $scope });
|
||||
},
|
||||
async saveDayStart () {
|
||||
this.user.preferences.dayStart = this.newDayStart;
|
||||
await axios.post('/api/v4/user/custom-day-start', {
|
||||
dayStart: this.newDayStart,
|
||||
});
|
||||
// @TODO
|
||||
// Notification.text(response.data.data.message);
|
||||
},
|
||||
async changeLanguage (e) {
|
||||
const newLang = e.target.value;
|
||||
this.user.preferences.language = newLang;
|
||||
@@ -857,12 +765,10 @@ export default {
|
||||
if (network === 'apple') {
|
||||
window.location.href = buildAppleAuthUrl();
|
||||
} else {
|
||||
const auth = await hello(network).login({ scope: 'email', options: { force: true } });
|
||||
|
||||
const auth = await hello(network).login({ scope: 'email' });
|
||||
await this.$store.dispatch('auth:socialAuth', {
|
||||
auth,
|
||||
});
|
||||
|
||||
window.location.href = '/';
|
||||
}
|
||||
},
|
||||
@@ -880,8 +786,7 @@ export default {
|
||||
this.localAuth.email = this.user.auth.local.email;
|
||||
}
|
||||
await axios.post('/api/v4/user/auth/local/register', this.localAuth);
|
||||
window.alert(this.$t('addedLocalAuth')); // eslint-disable-line no-alert
|
||||
window.location.href = '/';
|
||||
window.location.href = '/user/settings/site';
|
||||
},
|
||||
restoreEmptyUsername () {
|
||||
if (this.usernameUpdates.username.length < 1) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { storiesOf } from '@storybook/vue';
|
||||
|
||||
import Subscription from './subscription.vue';
|
||||
import { mockStore } from '../../../config/storybook/mock.data';
|
||||
|
||||
storiesOf('Subscriptions/Detail Page', module)
|
||||
.add('subscribed', () => ({
|
||||
components: { Subscription },
|
||||
template: `
|
||||
<div style="position: absolute; margin: 20px">
|
||||
<subscription ></subscription>
|
||||
</div>
|
||||
`,
|
||||
data () {
|
||||
return {
|
||||
};
|
||||
},
|
||||
store: mockStore({
|
||||
userData: {
|
||||
purchased: {
|
||||
plan: {
|
||||
customerId: 'customer-id',
|
||||
planId: 'plan-id',
|
||||
subscriptionId: 'sub-id',
|
||||
gemsBought: 22,
|
||||
dateUpdated: new Date(2021, 0, 15),
|
||||
consecutive: {
|
||||
count: 2,
|
||||
gemCapExtra: 4,
|
||||
offset: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
@@ -93,7 +93,7 @@
|
||||
<div class="subscribe-card mx-auto">
|
||||
<div
|
||||
v-if="hasSubscription && !hasCanceledSubscription"
|
||||
class="d-flex flex-column align-items-center my-4"
|
||||
class="d-flex flex-column align-items-center"
|
||||
>
|
||||
<div class="round-container bg-green-10 d-flex align-items-center justify-content-center">
|
||||
<div
|
||||
@@ -102,7 +102,7 @@
|
||||
v-html="icons.checkmarkIcon"
|
||||
></div>
|
||||
</div>
|
||||
<h2 class="green-10 mx-auto">
|
||||
<h2 class="green-10 mx-auto mb-75">
|
||||
{{ $t('youAreSubscribed') }}
|
||||
</h2>
|
||||
<div
|
||||
@@ -180,17 +180,17 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="hasSubscription"
|
||||
class="bg-gray-700 p-2 text-center"
|
||||
class="bg-gray-700 py-3 mt-4 mb-3 text-center"
|
||||
>
|
||||
<div class="header-mini mb-3">
|
||||
{{ $t('subscriptionStats') }}
|
||||
</div>
|
||||
<div class="d-flex justify-content-around">
|
||||
<div class="ml-4 mr-3">
|
||||
<div class="d-flex">
|
||||
<div class="stat-column">
|
||||
<div class="d-flex justify-content-center align-items-center">
|
||||
<div
|
||||
v-once
|
||||
class="svg-icon svg-calendar mr-2"
|
||||
class="svg-icon svg-calendar mr-1"
|
||||
v-html="icons.calendarIcon"
|
||||
>
|
||||
</div>
|
||||
@@ -204,49 +204,53 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-spacer"></div>
|
||||
<div>
|
||||
<div class="stat-column">
|
||||
<div class="d-flex justify-content-center align-items-center">
|
||||
<div
|
||||
v-once
|
||||
class="svg-icon svg-gem mr-2"
|
||||
class="svg-icon svg-gem mr-1"
|
||||
v-html="icons.gemIcon"
|
||||
>
|
||||
</div>
|
||||
<div class="number-heavy">
|
||||
{{ user.purchased.plan.consecutive.gemCapExtra }}
|
||||
{{ gemCap }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-label">
|
||||
{{ $t('gemCapExtra') }}
|
||||
{{ $t('gemCap') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-spacer"></div>
|
||||
<div>
|
||||
<div class="stat-column">
|
||||
<div class="d-flex justify-content-center align-items-center">
|
||||
<div
|
||||
v-once
|
||||
class="svg-icon svg-hourglass mt-1 mr-2"
|
||||
class="svg-icon svg-hourglass mt-1 mr-1"
|
||||
v-html="icons.hourglassIcon"
|
||||
>
|
||||
</div>
|
||||
<div class="number-heavy">
|
||||
{{ user.purchased.plan.consecutive.trinkets }}
|
||||
{{ nextHourGlass }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-label">
|
||||
{{ $t('mysticHourglassesTooltip') }}
|
||||
{{ $t('nextHourglass') }}*
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 nextHourglassDescription" v-once>
|
||||
*{{ $t('nextHourglassDescription') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-column justify-content-center align-items-center mt-4 mb-3">
|
||||
<div class="d-flex flex-column justify-content-center align-items-center mb-3">
|
||||
<div
|
||||
v-once
|
||||
class="svg-icon svg-heart mb-1"
|
||||
class="svg-icon svg-heart mb-2"
|
||||
v-html="icons.heartIcon"
|
||||
>
|
||||
</div>
|
||||
<div class="stats-label">
|
||||
<div class="thanks-for-support">
|
||||
{{ $t('giftSubscriptionText4') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -350,7 +354,7 @@
|
||||
.cancel-card {
|
||||
width: 28rem;
|
||||
border: 2px solid $gray-500;
|
||||
border-radius: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
@@ -405,7 +409,10 @@
|
||||
}
|
||||
|
||||
.number-heavy {
|
||||
font-size: 24px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
line-height: 1.4;
|
||||
color: $gray-50;
|
||||
}
|
||||
|
||||
.Pet-Jackalope-RoyalPurple {
|
||||
@@ -423,7 +430,10 @@
|
||||
|
||||
.stats-label {
|
||||
font-size: 12px;
|
||||
color: $gray-200;
|
||||
color: $gray-100;
|
||||
margin-top: 6px;
|
||||
font-weight: bold;
|
||||
line-height: 1.33;
|
||||
}
|
||||
|
||||
.stats-spacer {
|
||||
@@ -433,8 +443,9 @@
|
||||
}
|
||||
|
||||
.subscribe-card {
|
||||
padding-top: 2rem;
|
||||
width: 28rem;
|
||||
border-radius: 4px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
|
||||
background-color: $white;
|
||||
}
|
||||
@@ -452,7 +463,14 @@
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.svg-calendar, .svg-heart {
|
||||
.svg-calendar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.svg-heart {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
@@ -479,8 +497,10 @@
|
||||
}
|
||||
|
||||
.svg-gem {
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.svg-gems {
|
||||
@@ -494,8 +514,10 @@
|
||||
}
|
||||
|
||||
.svg-hourglass {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.svg-gift-box {
|
||||
@@ -521,11 +543,34 @@
|
||||
.w-55 {
|
||||
width: 55%;
|
||||
}
|
||||
|
||||
.nextHourglassDescription {
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
line-height: 1.33;
|
||||
color: $gray-100;
|
||||
margin-left: 100px;
|
||||
margin-right: 100px;
|
||||
}
|
||||
|
||||
.justify-content-evenly {
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.thanks-for-support {
|
||||
font-size: 12px;
|
||||
line-height: 1.33;
|
||||
text-align: center;
|
||||
color: $gray-100;
|
||||
}
|
||||
|
||||
.stat-column {
|
||||
width: 33%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import min from 'lodash/min';
|
||||
import moment from 'moment';
|
||||
import { mapState } from '@/libs/store';
|
||||
|
||||
@@ -551,6 +596,7 @@ import logo from '@/assets/svg/habitica-logo-purple.svg';
|
||||
import paypalLogo from '@/assets/svg/paypal-logo.svg';
|
||||
import subscriberGems from '@/assets/svg/subscriber-gems.svg';
|
||||
import subscriberHourglasses from '@/assets/svg/subscriber-hourglasses.svg';
|
||||
import { getPlanContext } from '@/../../common/script/cron';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -649,23 +695,9 @@ export default {
|
||||
months: parseFloat(this.user.purchased.plan.extraMonths).toFixed(2),
|
||||
};
|
||||
},
|
||||
buyGemsGoldCap () {
|
||||
return {
|
||||
amount: min(this.gemGoldCap),
|
||||
};
|
||||
},
|
||||
gemGoldCap () {
|
||||
const baseCap = 25;
|
||||
const gemCapIncrement = 5;
|
||||
const capIncrementThreshold = 3;
|
||||
const { gemCapExtra } = this.user.purchased.plan.consecutive;
|
||||
const blocks = subscriptionBlocks[this.subscription.key].months / capIncrementThreshold;
|
||||
const flooredBlocks = Math.floor(blocks);
|
||||
|
||||
const userTotalDropCap = baseCap + gemCapExtra + flooredBlocks * gemCapIncrement;
|
||||
const maxDropCap = 50;
|
||||
|
||||
return [userTotalDropCap, maxDropCap];
|
||||
gemCap () {
|
||||
return planGemLimits.convCap
|
||||
+ this.user.purchased.plan.consecutive.gemCapExtra;
|
||||
},
|
||||
numberOfMysticHourglasses () {
|
||||
const numberOfHourglasses = subscriptionBlocks[this.subscription.key].months / 3;
|
||||
@@ -719,6 +751,16 @@ export default {
|
||||
subscriptionEndDate () {
|
||||
return moment(this.user.purchased.plan.dateTerminated).format('MM/DD/YYYY');
|
||||
},
|
||||
nextHourGlassDate () {
|
||||
const currentPlanContext = getPlanContext(this.user, new Date());
|
||||
|
||||
return currentPlanContext.nextHourglassDate;
|
||||
},
|
||||
nextHourGlass () {
|
||||
const nextHourglassMonth = this.nextHourGlassDate.format('MMM');
|
||||
|
||||
return nextHourglassMonth;
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
this.$store.dispatch('common:setTitle', {
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
></div>
|
||||
</button>
|
||||
<button
|
||||
v-if="userLoggedIn.contributor.admin"
|
||||
v-if="hasPermission(userLoggedIn, 'moderator')"
|
||||
v-b-tooltip.hover.right="'Admin - Toggle Tools'"
|
||||
class="btn btn-secondary positive-icon d-flex justify-content-center align-items-center"
|
||||
@click="toggleAdminTools()"
|
||||
@@ -71,7 +71,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="userLoggedIn.contributor.admin && adminToolsLoaded"
|
||||
v-if="hasPermission(userLoggedIn, 'moderator') && adminToolsLoaded"
|
||||
class="row admin-profile-actions"
|
||||
>
|
||||
<div class="col-12 text-right">
|
||||
@@ -111,6 +111,12 @@
|
||||
class="admin-action"
|
||||
@click="adminUnblockUser()"
|
||||
>un-ban</span>
|
||||
<router-link
|
||||
:to="{ name: 'adminPanelUser', params: { userIdentifier: userId } }"
|
||||
replace
|
||||
>
|
||||
Admin Panel
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
@@ -730,6 +736,7 @@ import challenge from '@/assets/svg/challenge.svg';
|
||||
import member from '@/assets/svg/member-icon.svg';
|
||||
import staff from '@/assets/svg/tier-staff.svg';
|
||||
import error404 from '../404';
|
||||
import { userCustomStateMixin } from '../../mixins/userState';
|
||||
// @TODO: EMAILS.COMMUNITY_MANAGER_EMAIL
|
||||
const COMMUNITY_MANAGER_EMAIL = 'admin@habitica.com';
|
||||
|
||||
@@ -742,6 +749,7 @@ export default {
|
||||
profileStats,
|
||||
error404,
|
||||
},
|
||||
mixins: [userCustomStateMixin('userLoggedIn')],
|
||||
props: ['userId', 'startingPage'],
|
||||
data () {
|
||||
return {
|
||||
@@ -780,7 +788,6 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
...mapState({
|
||||
userLoggedIn: 'user.data',
|
||||
flatGear: 'content.gear.flat',
|
||||
}),
|
||||
userJoinedDate () {
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import { mapState } from '@/libs/store';
|
||||
|
||||
export const userStateMixin = { // eslint-disable-line import/prefer-default-export
|
||||
computed: {
|
||||
...mapState({ user: 'user.data' }),
|
||||
},
|
||||
export const userCustomStateMixin = fieldname => {
|
||||
const map = { };
|
||||
map[fieldname] = 'user.data';
|
||||
return { // eslint-disable-line import/prefer-default-export
|
||||
computed: {
|
||||
...mapState(map),
|
||||
},
|
||||
methods: {
|
||||
hasPermission (user, permission) {
|
||||
return Boolean((user.permissions
|
||||
&& (user.permissions[permission] || user.permissions.fullAccess))
|
||||
|| (user.contributor && user.contributor.admin));
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const userStateMixin = userCustomStateMixin('user');
|
||||
|
||||
@@ -800,7 +800,7 @@ export default {
|
||||
|
||||
await this.reload();
|
||||
|
||||
// close members modal if the Private Messages page is opened in an existing tab
|
||||
// close modal if the Private Messages page is opened in an existing tab
|
||||
this.$root.$emit('bv::hide::modal', 'profile');
|
||||
this.$root.$emit('bv::hide::modal', 'members-modal');
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import handleRedirect from './handleRedirect';
|
||||
import ParentPage from '@/components/parentPage';
|
||||
import { PAGES } from '@/libs/consts';
|
||||
|
||||
// NOTE: when adding a page make sure to implement setTitle
|
||||
// NOTE: when adding a page make sure to implement the `common:setTitle` action
|
||||
|
||||
// Static Pages
|
||||
const StaticWrapper = () => import(/* webpackChunkName: "entry" */'@/components/static/staticWrapper');
|
||||
@@ -53,6 +53,10 @@ const HallPage = () => import(/* webpackChunkName: "hall" */'@/components/hall/i
|
||||
const PatronsPage = () => import(/* webpackChunkName: "hall" */'@/components/hall/patrons');
|
||||
const HeroesPage = () => import(/* webpackChunkName: "hall" */'@/components/hall/heroes');
|
||||
|
||||
// Admin Panel
|
||||
const AdminPanelPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin-panel');
|
||||
const AdminPanelUserPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin-panel/user-support');
|
||||
|
||||
// Except for tasks that are always loaded all the other main level
|
||||
// All the main level
|
||||
// components are loaded in separate webpack chunks.
|
||||
@@ -109,7 +113,7 @@ const router = new VueRouter({
|
||||
scrollBehavior () {
|
||||
return { x: 0, y: 0 };
|
||||
},
|
||||
// requiresLogin is true by default, isStatic false
|
||||
// meta defaults: requiresLogin true, privilegeNeeded empty
|
||||
// NOTE: when adding a new route entry make sure to implement the `common:setTitle` action
|
||||
// in the route component to set a specific subtitle for the page.
|
||||
routes: [
|
||||
@@ -348,6 +352,31 @@ const router = new VueRouter({
|
||||
{ name: 'contributors', path: 'contributors', component: HeroesPage },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
name: 'adminPanel',
|
||||
path: '/admin-panel',
|
||||
component: AdminPanelPage,
|
||||
meta: {
|
||||
privilegeNeeded: [ // any one of these is enough to give access
|
||||
'userSupport',
|
||||
'newsPoster',
|
||||
],
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'adminPanelUser',
|
||||
path: ':userIdentifier', // User ID or Username
|
||||
component: AdminPanelUserPage,
|
||||
meta: {
|
||||
privilegeNeeded: [
|
||||
'userSupport',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Only used to handle some redirects
|
||||
// See router.beforeEach
|
||||
{ path: '/redirect/:redirect', name: 'redirect' },
|
||||
@@ -357,9 +386,10 @@ const router = new VueRouter({
|
||||
|
||||
const store = getStore();
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const { isUserLoggedIn } = store.state;
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const { isUserLoggedIn, isUserLoaded } = store.state;
|
||||
const routeRequiresLogin = to.meta.requiresLogin !== false;
|
||||
const routePrivilegeNeeded = to.meta.privilegeNeeded;
|
||||
|
||||
if (to.name === 'redirect') return handleRedirect(to, from, next);
|
||||
|
||||
@@ -392,6 +422,17 @@ router.beforeEach((to, from, next) => {
|
||||
return next({ name: 'tasks' });
|
||||
}
|
||||
|
||||
if (routePrivilegeNeeded) {
|
||||
// Redirect non-admin users when trying to access a page.
|
||||
if (!isUserLoaded) await store.dispatch('user:fetch');
|
||||
if (!store.state.user.data.permissions.fullAccess) {
|
||||
const userHasPriv = routePrivilegeNeeded.some(
|
||||
privName => store.state.user.data.permissions[privName],
|
||||
);
|
||||
if (!userHasPriv) return next({ name: 'tasks' });
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect old guild urls
|
||||
if (to.hash.indexOf('#/options/groups/guilds/') !== -1) {
|
||||
const splits = to.hash.split('/');
|
||||
|
||||
@@ -26,3 +26,9 @@ export async function getPatrons (store, payload) {
|
||||
const response = await axios.get(url);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
export async function getHeroParty (store, payload) {
|
||||
const url = `/api/v4/hall/heroes/party/${payload.groupId}`;
|
||||
const response = await axios.get(url);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function canDelete (store) {
|
||||
const user = store.state.user.data;
|
||||
const userId = user.id || user._id;
|
||||
|
||||
const isUserAdmin = user.contributor && !!user.contributor.admin;
|
||||
const isUserAdmin = user.permissions && user.permissions.challengeAdmin;
|
||||
const isUserGroupLeader = group && (group.leader
|
||||
&& group.leader._id === userId);
|
||||
const isUserGroupManager = group && (group.managers
|
||||
@@ -84,7 +84,7 @@ export function canEdit (store) {
|
||||
const user = store.state.user.data;
|
||||
const userId = user.id || user._id;
|
||||
|
||||
const isUserAdmin = user.contributor && !!user.contributor.admin;
|
||||
const isUserAdmin = user.permissions && user.permissions.challengeAdmin;
|
||||
const isUserGroupLeader = group && (group.leader
|
||||
&& group.leader._id === userId);
|
||||
const isUserGroupManager = group && (group.managers
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('canDelete getter', () => {
|
||||
});
|
||||
|
||||
it('can Delete any challenge task as admin', () => {
|
||||
store.state.user.data.contributor.admin = true;
|
||||
store.state.user.data.permissions = { challengeAdmin: true };
|
||||
|
||||
expect(store.getters['tasks:canDelete'](task, 'challenge', true, null, challenge)).to.equal(true);
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('canEdit getter', () => {
|
||||
});
|
||||
|
||||
it('can Edit any challenge task if admin', () => {
|
||||
store.state.user.data.contributor.admin = true;
|
||||
store.state.user.data.permissions = { challengeAdmin: true };
|
||||
|
||||
expect(store.getters['tasks:canEdit'](task, 'challenge', true, null, challenge)).to.equal(true);
|
||||
expect(store.getters['tasks:canEdit'](task, 'challenge', false, null, challenge)).to.equal(true);
|
||||
|
||||
@@ -117,5 +117,7 @@
|
||||
"achievementKickstarter2019Text": "من مساهمي حملة كيك-ستارتر للدبابيس",
|
||||
"achievementDomesticatedModalText": "لقد جمعت كل الحيوانات الأليفة المستأنسة!",
|
||||
"achievementDomesticatedText": "لقد فقس جميع الألوان القياسية للحيوانات الأليفة المستأنسة: النمس ، وخنزير غينيا ، والديك ، والخنزير الطائر ، والجرذ ، والأرنب ، والحصان ، والبقر!",
|
||||
"achievementDomesticated": "ا-يا-ا-يا-يو"
|
||||
"achievementDomesticated": "ا-يا-ا-يا-يو",
|
||||
"achievementBirdsOfAFeatherModalText": "تقوم بجمع كل الحيوانات الأليفة الطائرة!",
|
||||
"achievementZodiacZookeeperText": "لقد فقس جميع الألوان القياسية للحيوانات الأليفة في الأبراج: الجرذ ، البقرة ، الأرنب ، الأفعى ، الحصان ، الأغنام ، القرد ، الديك ، الذئب ، النمر ، الخنزير الطائر ، والتنين!"
|
||||
}
|
||||
|
||||
@@ -408,5 +408,6 @@
|
||||
"backgroundArchaeologicalDigText": "Archaeological Dig",
|
||||
"backgroundArchaeologicalDigNotes": "Unearth secrets of the ancient past at an Archaeological Dig.",
|
||||
"backgroundScribesWorkshopText": "Scribe's Workshop",
|
||||
"backgroundScribesWorkshopNotes": "Write your next great scroll in a Scribe's Workshop."
|
||||
"backgroundScribesWorkshopNotes": "Write your next great scroll in a Scribe's Workshop.",
|
||||
"backgrounds022019": "مجموعة 57: تم إصدارها في فبراير 2019"
|
||||
}
|
||||
|
||||
@@ -118,5 +118,8 @@
|
||||
"welcome3": "تقدم في الحياة واللعبة!",
|
||||
"welcome3notes": "As you improve your life, your avatar will level up and unlock pets, quests, equipment, and more!",
|
||||
"imReady": "ادخل Habitica",
|
||||
"limitedOffer": "Available until <%= date %>"
|
||||
"limitedOffer": "Available until <%= date %>",
|
||||
"nGemsGift": "<%= nGems %> الماس (هدية)",
|
||||
"amountExp": "<%= amount %> خبرة",
|
||||
"nGems": "<%= nGems %> الماس"
|
||||
}
|
||||
|
||||
@@ -68,5 +68,13 @@
|
||||
"petsFound": "إنشاء حيوانات أليفة",
|
||||
"keyToPets": "مفتاح بيوت الحيوانات",
|
||||
"noActiveMount": "لا يوجد تثبيت نشط",
|
||||
"questPets": "بحث الحيوانات"
|
||||
"questPets": "بحث الحيوانات",
|
||||
"releasePetsConfirm": "هل أنت متأكد أنك تريد إطلاق سراح حيوانك الأليف القياسي؟",
|
||||
"keyToMounts": "مفتاح بيت الحيوان",
|
||||
"petsReleased": "أفرج عن الحيوانات الأليفة",
|
||||
"keyToPetsDesc": "حرر جميع الحيوانات المسموح بها حتى تتمكن من جمعها مرة أخرى. (لا تتأثر بالحيوانات الأليفة والحيوانات الأليفة الغريبة.)",
|
||||
"petName": "<%= potion(locale) %> <%= egg(locale) %>",
|
||||
"keyToMountsDesc": "حرر جميع العينات القياسية حتى تتمكن من جمعها مرة أخرى. (لا تتأثر عمليات تثبيت المهام وعمليات التثبيت النادرة.)",
|
||||
"keyToBoth": "مفاتيح رئيسية لبيوت الكلاب",
|
||||
"releasePetsSuccess": "تم إطلاق حيوانك الأليف القياسي!"
|
||||
}
|
||||
|
||||
@@ -125,9 +125,9 @@
|
||||
"achievementShadyCustomerText": "Hat alle Schatten-Haustiere gesammelt.",
|
||||
"achievementShadyCustomer": "Der Schatten in Dir",
|
||||
"achievementZodiacZookeeper": "Tierkreiszeichen-Pfleger",
|
||||
"achievementZodiacZookeeperText": "Hat alle Tierkreiszeichen-Tiere ausgebrütet: Ratte, Kalb, Kaninchen, Schlange, Fohlen, Schaf, Affe, Hahn, Wolf, Tiger, Fliegendes Ferkel, und Drache!",
|
||||
"achievementZodiacZookeeperText": "Hat alle Standardfarben der Tierkreiszeichen-Tiere ausgebrütet: Ratte, Kalb, Kaninchen, Schlange, Fohlen, Schaf, Affe, Hahn, Wolf, Tiger, Fliegendes Ferkel, und Drache!",
|
||||
"achievementZodiacZookeeperModalText": "Du hast alle Tierkreiszeichen-Tiere gesammelt!",
|
||||
"achievementBirdsOfAFeather": "Fliegende Freunde",
|
||||
"achievementBirdsOfAFeatherText": "Hat alle fliegenden Haustiere ausgebrütet: Fliegendes Ferkel, Eule, Papagei, Pterodactylus, Greif, Falke, Pfau, und Hahn.",
|
||||
"achievementBirdsOfAFeatherText": "Hat alle Standardfarben der fliegenden Haustiere ausgebrütet: Fliegendes Ferkel, Eule, Papagei, Pterodactylus, Greif, Falke, Pfau, und Hahn.",
|
||||
"achievementBirdsOfAFeatherModalText": "Du hast alle fliegenden Haustiere gesammelt!"
|
||||
}
|
||||
|
||||
@@ -685,5 +685,12 @@
|
||||
"backgroundFloweringPrairieText": "Blühende Prärie",
|
||||
"backgroundFloweringPrairieNotes": "Tolle durch eine blühende Prärie.",
|
||||
"backgroundAnimalsDenNotes": "Mach es Dir im Bau eines Waldtieres gemütlich.",
|
||||
"backgroundBrickWallWithIvyNotes": "Bewundere eine efeubewachsene Ziegelmauer."
|
||||
"backgroundBrickWallWithIvyNotes": "Bewundere eine efeubewachsene Ziegelmauer.",
|
||||
"backgroundBlossomingTreesText": "Blühende Bäume",
|
||||
"backgrounds042022": "SET 95: Veröffentlicht im April 2022",
|
||||
"backgroundBlossomingTreesNotes": "Verweile unter blühenden Bäumen.",
|
||||
"backgroundFlowerShopText": "Blumenladen",
|
||||
"backgroundFlowerShopNotes": "Genieße den süßen Duft eines Blumenladens.",
|
||||
"backgroundSpringtimeLakeText": "Frühlingssee",
|
||||
"backgroundSpringtimeLakeNotes": "Genieße die Aussicht an den Ufern eines Frühlingssees."
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user