Compare commits

..

2 Commits

Author SHA1 Message Date
CuriousMagpie
e48f88cb8c chore: change TODO to NOTE where relevant 2025-01-29 15:58:37 -05:00
CuriousMagpie
368dc97497 chore: Add alerts to debug menu when operations are completed 2025-01-29 14:31:29 -05:00
882 changed files with 22108 additions and 28055 deletions

View File

@@ -9,4 +9,4 @@
}
]
]
}
}

View File

@@ -7,14 +7,5 @@ module.exports = {
rules: {
'prefer-regex-literals': 'warn',
'import/no-extraneous-dependencies': 'off',
'require-await': 'error',
},
overrides: [
{
files: ['migrations/**', 'gulp/**'], // Or *.test.js
rules: {
'require-await': 'off',
},
},
],
};

View File

@@ -1,13 +1,6 @@
name: Test
on:
push:
branches-ignore:
- 'phillip/**'
- 'sabrecat/**'
- 'kalista/**'
- 'natalie/**'
pull_request:
on: [push, pull_request]
permissions:
contents: read

2
.gitignore vendored
View File

@@ -47,5 +47,5 @@ webpack.webstorm.config
# mongodb replica set for local dev
mongodb-*.tgz
/mongodb-data*
/mongodb-data
/.nyc_output

View File

@@ -93,6 +93,5 @@
"TRUSTED_DOMAINS": "localhost,https://habitica.com",
"TIME_TRAVEL_ENABLED": "false",
"DEBUG_ENABLED": "false",
"CONTENT_SWITCHOVER_TIME_OFFSET": 8,
"SLOW_REQUEST_THRESHOLD": 1000
"CONTENT_SWITCHOVER_TIME_OFFSET": 8
}

View File

@@ -64,15 +64,6 @@ function filterFile (file) {
if (file.relative.indexOf('icon_background') === 0) {
return false;
}
if (file.relative.indexOf('notif_') === 0) {
return false;
}
if (file.relative.indexOf('quest_') === 0) {
return false;
}
if (file.relative.indexOf('inventory_quest_') === 0) {
return false;
}
return true;
}

11
gulp/gulp-start.js Normal file
View File

@@ -0,0 +1,11 @@
import gulp from 'gulp';
import nodemon from 'gulp-nodemon';
import pkg from '../package.json';
gulp.task('nodemon', done => {
nodemon({
script: pkg.main,
});
done();
});

View File

@@ -49,6 +49,12 @@ function integrationTestCommand (testDir) {
}
/* Test task definitions */
gulp.task('test:nodemon', gulp.series(done => {
process.env.PORT = TEST_SERVER_PORT; // eslint-disable-line no-process-env
process.env.NODE_DB_URI = TEST_DB_URI; // eslint-disable-line no-process-env
done();
}, 'nodemon'));
gulp.task('test:prepare:mongo', cb => {
const mongooseOptions = getDefaultConnectionOptions();
const connectionUrl = getDevelopmentConnectionUrl(TEST_DB_URI);

View File

@@ -21,6 +21,7 @@ if (process.env.NODE_ENV === 'production') { // eslint-disable-line no-process-e
require('./gulp/gulp-build'); // eslint-disable-line global-require
require('./gulp/gulp-console'); // eslint-disable-line global-require
require('./gulp/gulp-sprites'); // eslint-disable-line global-require
require('./gulp/gulp-start'); // eslint-disable-line global-require
require('./gulp/gulp-tests'); // eslint-disable-line global-require
require('./gulp/gulp-transifex-test'); // eslint-disable-line global-require
require('gulp').task('default', gulp.series('test')); // eslint-disable-line global-require

View File

@@ -37,7 +37,7 @@ let consoleStamp = require('console-stamp');
consoleStamp(console);
// Initialize configuration
require('../../website/server/libs/api-v3/setupNconf').default();
require('../../website/server/libs/api-v3/setupNconf')();
let MONGODB_OLD = nconf.get('MONGODB_OLD');
let MONGODB_NEW = nconf.get('MONGODB_NEW');

View File

@@ -32,7 +32,7 @@ let moment = require('moment');
consoleStamp(console);
// Initialize configuration
require('../../website/server/libs/api-v3/setupNconf').default();
require('../../website/server/libs/api-v3/setupNconf')();
let MONGODB_OLD = nconf.get('MONGODB_OLD');
let MONGODB_NEW = nconf.get('MONGODB_NEW');

View File

@@ -6,7 +6,7 @@ require('@babel/register'); // eslint-disable-line import/no-extraneous-dependen
function setUpServer () {
const nconf = require('nconf'); // eslint-disable-line global-require, no-unused-vars
const mongoose = require('mongoose'); // eslint-disable-line global-require, no-unused-vars
const setupNconf = require('../website/server/libs/setupNconf').default; // eslint-disable-line global-require
const setupNconf = require('../website/server/libs/setupNconf'); // eslint-disable-line global-require
setupNconf();

937
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "habitica",
"description": "A habit tracker app which treats your goals like a Role Playing Game.",
"version": "5.38.2",
"version": "5.33.0",
"main": "./website/server/index.js",
"dependencies": {
"@babel/core": "^7.22.10",
@@ -38,6 +38,7 @@
"gulp-babel": "^8.0.0",
"gulp-filter": "^7.0.0",
"gulp-imagemin": "^7.1.0",
"gulp-nodemon": "^2.5.0",
"gulp.spritesmith": "^6.13.0",
"habitica-markdown": "^3.0.0",
"helmet": "^4.6.0",
@@ -49,11 +50,12 @@
"merge-stream": "^2.0.0",
"method-override": "^3.0.0",
"moment": "^2.29.4",
"moment-recur": "git://github.com/HabitRPG/moment-recur.git#d3e8e6da0806f13b74dd2e4d7d9053e6a63db119",
"mongoose": "^8.9.5",
"moment-recur": "^1.0.7",
"mongoose": "^7.8.3",
"morgan": "^1.10.0",
"nconf": "^0.12.1",
"node-gcm": "^1.0.5",
"nodemon": "^2.0.20",
"on-headers": "^1.0.2",
"passport": "^0.5.3",
"passport-facebook": "^3.0.0",
@@ -98,22 +100,22 @@
"test:sanity": "nyc --silent --no-clean mocha test/sanity --recursive",
"test:common": "nyc --silent --no-clean mocha test/common --recursive",
"test:content": "nyc --silent --no-clean mocha test/content --recursive",
"test:nodemon": "gulp test:nodemon",
"coverage": "nyc report --reporter=html --report-dir coverage/results; open coverage/results/index.html",
"sprites": "gulp sprites:compile",
"client:dev": "cd website/client && npm run serve",
"client:build": "cd website/client && npm run build",
"client:unit": "cd website/client && npm run test:unit",
"start": "node --watch ./website/server/index.js",
"start": "gulp nodemon",
"start:simple": "node ./website/server/index.js",
"debug": "node --watch --inspect ./website/server/index.js",
"mongo:dev": "run-rs -v 7.0.23 -l ubuntu2404 --keep --dbpath mongodb-data --number 1 --quiet",
"mongo:test": "run-rs -v 7.0.23 -l ubuntu2404 --keep --dbpath mongodb-data-testing --number 1 --quiet",
"debug": "gulp nodemon --inspect",
"mongo:dev": "run-rs -v 5.0.23 -l ubuntu1804 --keep --dbpath mongodb-data --number 1 --quiet",
"postinstall": "git config --global url.\"https://\".insteadOf git:// && gulp build && cd website/client && npm install",
"apidoc": "gulp apidoc",
"heroku-postbuild": ".heroku/report_deploy.sh"
},
"devDependencies": {
"axios": "^1.8.2",
"axios": "^1.7.4",
"chai": "^4.3.7",
"chai-as-promised": "^7.1.1",
"chai-moment": "^0.1.0",

View File

@@ -71,14 +71,15 @@ async function deleteHabiticaData (user, email) {
}
async function processEmailAddress (email) {
const emailRegex = new RegExp(`^${email}$`, 'i');
const localUsers = await User.find(
{ 'auth.local.email': email },
{ 'auth.local.email': emailRegex },
{ _id: 1, apiToken: 1, auth: 1 },
).exec();
const socialUsers = await User.find(
{
'auth.local.email': { $ne: email },
'auth.local.email': { $not: emailRegex },
$or: [
{ 'auth.facebook.emails.value': email },
{ 'auth.google.emails.value': email },

View File

@@ -8,17 +8,7 @@ const TASK_VALUE_CHANGE_FACTOR = 0.9747;
const MIN_TASK_VALUE = -47.27;
async function updateTeamTasks (team) {
if (team.purchased.plan.dateTerminated) {
const dateTerminated = new Date(team.purchased.plan.dateTerminated);
if (dateTerminated < new Date()) {
team.purchased.plan.customerId = undefined;
team.markModified('purchased.plan');
return team.save();
}
}
const toSave = [];
let teamLeader = await User.findOne({ _id: team.leader }, 'preferences').exec();
if (!teamLeader) { // why would this happen?
@@ -103,7 +93,12 @@ async function updateTeamTasks (team) {
export default async function processTeamsCron () {
const activeTeams = await Group.find({
'purchased.plan.customerId': { $exists: true },
}, { cron: 1, leader: 1, purchased: 1 }).exec();
$or: [
{ 'purchased.plan.dateTerminated': { $exists: false } },
{ 'purchased.plan.dateTerminated': null },
{ 'purchased.plan.dateTerminated': { $gt: new Date() } },
],
}).exec();
const cronPromises = activeTeams.map(updateTeamTasks);
return Promise.all(cronPromises);

View File

@@ -2,22 +2,13 @@
import moment from 'moment';
import nconf from 'nconf';
import requireAgain from 'require-again';
import { v4 as generateUUID } from 'uuid';
import {
generateRes,
generateReq,
generateTodo,
generateDaily,
} from '../../../helpers/api-unit.helper';
import { cron, cronWrapper } from '../../../../website/server/libs/cron';
import { recoverCron, cron } from '../../../../website/server/libs/cron';
import { model as User } from '../../../../website/server/models/user';
import * as Tasks from '../../../../website/server/models/task';
import common from '../../../../website/common';
import * as analytics from '../../../../website/server/libs/analyticsService';
import { model as Group } from '../../../../website/server/models/group';
const CRON_TIMEOUT_WAIT = new Date(5 * 60 * 1000).getTime();
const CRON_TIMEOUT_UNIT = new Date(60 * 1000).getTime();
// const scoreTask = common.ops.scoreTask;
const pathToCronLib = '../../../../website/server/libs/cron';
@@ -1209,7 +1200,7 @@ describe('cron', async () => {
it('increments perfect day achievement if all (at least 1) due dailies were completed', async () => {
daysMissed = 1;
tasksByType.dailys[0].completed = true;
tasksByType.dailys[0].isDue = true;
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed, analytics,
@@ -1221,7 +1212,7 @@ describe('cron', async () => {
it('does not increment perfect day achievement if no due dailies', async () => {
daysMissed = 1;
tasksByType.dailys[0].completed = true;
tasksByType.dailys[0].isDue = false;
tasksByType.dailys[0].startDate = moment(new Date()).add({ days: 1 });
await cron({
user, tasksByType, daysMissed, analytics,
@@ -1233,7 +1224,7 @@ describe('cron', async () => {
it('gives perfect day buff if all (at least 1) due dailies were completed', async () => {
daysMissed = 1;
tasksByType.dailys[0].completed = true;
tasksByType.dailys[0].isDue = true;
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
const previousBuffs = user.stats.buffs.toObject();
@@ -1251,7 +1242,7 @@ describe('cron', async () => {
user.preferences.sleep = true;
daysMissed = 1;
tasksByType.dailys[0].completed = true;
tasksByType.dailys[0].isDue = true;
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
const previousBuffs = user.stats.buffs.toObject();
@@ -1268,7 +1259,7 @@ describe('cron', async () => {
it('clears buffs if user does not have a perfect day (no due dailys)', async () => {
daysMissed = 1;
tasksByType.dailys[0].completed = true;
tasksByType.dailys[0].isDue = false;
tasksByType.dailys[0].startDate = moment(new Date()).add({ days: 1 });
user.stats.buffs = {
str: 1,
@@ -1497,6 +1488,78 @@ describe('cron', async () => {
});
});
describe('notifications', async () => {
it('adds a user notification', async () => {
const mpBefore = user.stats.mp;
tasksByType.dailys[0].completed = true;
const statsComputedRes = common.statsComputed(user);
const stubbedStatsComputed = sinon.stub(common, 'statsComputed');
stubbedStatsComputed.returns(Object.assign(statsComputedRes, { maxMP: 100 }));
daysMissed = 1;
const hpBefore = user.stats.hp;
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed, analytics,
});
expect(user.notifications.length).to.be.greaterThan(0);
expect(user.notifications[1].type).to.equal('CRON');
expect(user.notifications[1].data).to.eql({
hp: user.stats.hp - hpBefore,
mp: user.stats.mp - mpBefore,
});
common.statsComputed.restore();
});
it('condenses multiple notifications into one', async () => {
const mpBefore1 = user.stats.mp;
tasksByType.dailys[0].completed = true;
const statsComputedRes = common.statsComputed(user);
const stubbedStatsComputed = sinon.stub(common, 'statsComputed');
stubbedStatsComputed.returns(Object.assign(statsComputedRes, { maxMP: 100 }));
daysMissed = 1;
const hpBefore1 = user.stats.hp;
tasksByType.dailys[0].startDate = moment(new Date()).subtract({ days: 1 });
await cron({
user, tasksByType, daysMissed, analytics,
});
expect(user.notifications.length).to.be.greaterThan(0);
expect(user.notifications[1].type).to.equal('CRON');
expect(user.notifications[1].data).to.eql({
hp: user.stats.hp - hpBefore1,
mp: user.stats.mp - mpBefore1,
});
const notifsBefore2 = user.notifications.length;
const hpBefore2 = user.stats.hp;
const mpBefore2 = user.stats.mp;
user.lastCron = moment(new Date()).subtract({ days: 2 });
await cron({
user, tasksByType, daysMissed, analytics,
});
expect(user.notifications.length - notifsBefore2).to.equal(0);
expect(user.notifications[0].type).to.not.equal('CRON');
expect(user.notifications[1].type).to.equal('CRON');
expect(user.notifications[1].data).to.eql({
hp: user.stats.hp - hpBefore2 - (hpBefore2 - hpBefore1),
mp: user.stats.mp - mpBefore2 - (mpBefore2 - mpBefore1),
});
expect(user.notifications[0].type).to.not.equal('CRON');
common.statsComputed.restore();
});
});
describe('private messages', async () => {
let lastMessageId;
@@ -1543,7 +1606,7 @@ describe('cron', async () => {
await cron({
user, tasksByType, daysMissed, analytics,
});
expect(user.notifications.length).to.eql(1);
expect(user.notifications.length).to.be.greaterThan(1);
expect(user.notifications[0].type).to.eql('LOGIN_INCENTIVE');
});
@@ -1757,258 +1820,64 @@ describe('cron', async () => {
});
});
describe('cron wrapper', () => {
let res; let
req;
let user;
describe('recoverCron', async () => {
let locals; let status; let
execStub;
beforeEach(async () => {
res = generateRes();
req = generateReq();
user = await res.locals.user.save();
res.analytics = analytics;
execStub = sandbox.stub();
sandbox.stub(User, 'findOne').returns({ exec: execStub });
status = { times: 0 };
locals = {
user: new User({
auth: {
local: {
username: 'username',
lowerCaseUsername: 'username',
email: 'email@example.com',
salt: 'salt',
hashed_password: 'hashed_password', // eslint-disable-line camelcase
},
},
}),
};
});
afterEach(() => {
afterEach(async () => {
sandbox.restore();
});
it('calls next when user is not attached', async () => {
res.locals.user = null;
await cronWrapper(req, res);
});
it('calls next when days have not been missed', async () => {
await cronWrapper(req, res);
});
it('should clear todos older than 30 days for free users', async () => {
user.lastCron = moment(new Date()).subtract({ days: 2 });
const task = generateTodo(user);
task.dateCompleted = moment(new Date()).subtract({ days: 31 });
task.completed = true;
await task.save();
await user.save();
await cronWrapper(req, res);
const taskRes = await Tasks.Task.findOne({ _id: task._id });
expect(taskRes).to.not.exist;
});
it('should not clear todos older than 30 days for subscribed users', async () => {
user.purchased.plan.customerId = 'subscribedId';
user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY');
user.lastCron = moment(new Date()).subtract({ days: 2 });
const task = generateTodo(user);
task.dateCompleted = moment(new Date()).subtract({ days: 31 });
task.completed = true;
await Promise.all([task.save(), user.save()]);
await cronWrapper(req, res);
const taskRes = await Tasks.Task.findOne({ _id: task._id });
expect(taskRes).to.exist;
});
it('should clear todos older than 90 days for subscribed users', async () => {
user.purchased.plan.customerId = 'subscribedId';
user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY');
user.lastCron = moment(new Date()).subtract({ days: 2 });
const task = generateTodo(user);
task.dateCompleted = moment(new Date()).subtract({ days: 91 });
task.completed = true;
await task.save();
await user.save();
await cronWrapper(req, res);
const taskRes = await Tasks.Task.findOne({ _id: task._id });
expect(taskRes).to.not.exist;
});
it('should call next if user was not modified after cron', async () => {
const hpBefore = user.stats.hp;
user.lastCron = moment(new Date()).subtract({ days: 2 });
await user.save();
await cronWrapper(req, res);
expect(hpBefore).to.equal(user.stats.hp);
});
it('runs cron if previous cron was incomplete', async () => {
user.lastCron = moment(new Date()).subtract({ days: 1 });
user.auth.timestamps.loggedin = moment(new Date()).subtract({ days: 4 });
const now = new Date();
await user.save();
await cronWrapper(req, res);
expect(moment(now).isSame(user.lastCron, 'day'));
expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day'));
});
it('updates user.auth.timestamps.loggedin and lastCron', async () => {
user.lastCron = moment(new Date()).subtract({ days: 2 });
const now = new Date();
await user.save();
await cronWrapper(req, res);
expect(moment(now).isSame(user.lastCron, 'day'));
expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day'));
});
it('does damage for missing dailies', async () => {
const hpBefore = user.stats.hp;
user.lastCron = moment(new Date()).subtract({ days: 2 });
const daily = generateDaily(user);
daily.startDate = moment(new Date()).subtract({ days: 2 });
await daily.save();
await user.save();
await cronWrapper(req, res);
const updatedUser = await User.findOne({ _id: user._id });
expect(updatedUser.stats.hp).to.be.lessThan(hpBefore);
});
it('updates tasks', async () => {
user.lastCron = moment(new Date()).subtract({ days: 2 });
const todo = generateTodo(user);
const todoValueBefore = todo.value;
await Promise.all([todo.save(), user.save()]);
await cronWrapper(req, res);
const todoFound = await Tasks.Task.findOne({ _id: todo._id });
expect(todoFound.value).to.be.lessThan(todoValueBefore);
});
it('updates large number of tasks', async () => {
user.lastCron = moment(new Date()).subtract({ days: 2 });
const todo = generateTodo(user);
const todoValueBefore = todo.value;
const start = new Date();
const saves = [todo.save(), user.save()];
for (let i = 0; i < 200; i += 1) {
const newTodo = generateTodo(user);
newTodo.value = i;
saves.push(newTodo.save());
}
await Promise.all(saves);
await cronWrapper(req, res);
const duration = new Date() - start;
expect(duration).to.be.lessThan(1000);
const todoFound = await Tasks.Task.findOne({ _id: todo._id });
expect(moment(start).isSame(user.lastCron, 'day'));
expect(moment(start).isSame(user.auth.timestamps.loggedin, 'day'));
expect(todoFound.value).to.be.lessThan(todoValueBefore);
});
it('fails entire cron if one task is failing', async () => {
const lastCron = moment(new Date()).subtract({ days: 2 });
user.lastCron = lastCron;
const todo = generateTodo(user);
const todoValueBefore = todo.value;
const badTodo = generateTodo(user);
badTodo.text = 'bad todo';
badTodo.attribute = 'bad';
await Promise.all([badTodo.save({ validateBeforeSave: false }), todo.save(), user.save()]);
it('throws an error if user cannot be found', async () => {
execStub.returns(Promise.resolve(null));
try {
await cronWrapper(req, res);
await recoverCron(status, locals);
throw new Error('no exception when user cannot be found');
} catch (err) {
expect(err).to.exist;
expect(err.message).to.eql(`User ${locals.user._id} not found while recovering.`);
}
const todoFound = await Tasks.Task.findOne({ _id: todo._id });
expect(moment(lastCron).isSame(user.lastCron, 'day'));
expect(todoFound.value).to.be.equal(todoValueBefore);
});
it('applies quest progress', async () => {
const hpBefore = user.stats.hp;
user.lastCron = moment(new Date()).subtract({ days: 2 });
const daily = generateDaily(user);
daily.startDate = moment(new Date()).subtract({ days: 2 });
await daily.save();
it('increases status.times count and reruns up to 4 times', async () => {
execStub.returns(Promise.resolve({ _cronSignature: 'RUNNING_CRON' }));
execStub.onCall(4).returns(Promise.resolve({ _cronSignature: 'NOT_RUNNING' }));
const questKey = 'dilatory';
user.party.quest.key = questKey;
await recoverCron(status, locals);
const party = new Group({
type: 'party',
name: generateUUID(),
leader: user._id,
});
party.quest.members[user._id] = true;
party.quest.key = questKey;
await party.save();
user.party._id = party._id;
await user.save();
party.startQuest(user);
await cronWrapper(req, res);
const updatedUser = await User.findOne({ _id: user._id });
expect(updatedUser.stats.hp).to.be.lessThan(hpBefore);
expect(status.times).to.eql(4);
expect(locals.user).to.eql({ _cronSignature: 'NOT_RUNNING' });
});
it('cronSignature less than 5 minutes ago should error', async () => {
user.lastCron = moment(new Date()).subtract({ days: 2 });
const now = new Date();
await User.updateOne({
_id: user._id,
}, {
$set: {
_cronSignature: now.getTime() - CRON_TIMEOUT_WAIT + CRON_TIMEOUT_UNIT,
},
}).exec();
await user.save();
it('throws an error if recoverCron runs 5 times', async () => {
execStub.returns(Promise.resolve({ _cronSignature: 'RUNNING_CRON' }));
try {
await cronWrapper(req, res);
await recoverCron(status, locals);
throw new Error('no exception when recoverCron runs 5 times');
} catch (err) {
expect(err).to.exist;
expect(status.times).to.eql(5);
expect(err.message).to.eql(`Impossible to recover from cron for user ${locals.user._id}.`);
}
});
it('cronSignature longer than an hour ago should allow cron', async () => {
user.lastCron = moment(new Date()).subtract({ days: 2 });
const now = new Date();
await User.updateOne({
_id: user._id,
}, {
$set: {
_cronSignature: now.getTime() - CRON_TIMEOUT_WAIT - CRON_TIMEOUT_UNIT,
},
}).exec();
await user.save();
await cronWrapper(req, res);
expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day'));
expect(user._cronSignature).to.be.equal('NOT_RUNNING');
});
it('cron should not run more than once', async () => {
user.lastCron = moment(new Date()).subtract({ days: 2 });
await user.save();
const result = await Promise.allSettled([
cronWrapper(req, res),
cronWrapper(req, res),
new Promise((resolve, reject) => {
setTimeout(async () => {
try {
const runResult = await cronWrapper(req, res);
if (runResult !== null) {
reject(new Error('cron ran more than once'));
} else {
resolve();
}
} catch (err) {
reject(err);
}
}, 200);
}),
]);
expect(result.filter(r => r.status === 'fulfilled')).to.have.lengthOf(2);
expect(result.filter(r => r.status === 'rejected')).to.have.lengthOf(1);
});
});

View File

@@ -171,23 +171,23 @@ describe('emails', () => {
expect(got.post).not.to.be.called;
});
it('throws error when mail target is only a string', async () => {
it('throws error when mail target is only a string', () => {
const emailType = 'an email type';
const mailingInfo = 'my email';
await expect(sendTxn(mailingInfo, emailType)).to.be.rejectedWith('Argument Error mailingInfoArray: does not contain email or _id');
expect(sendTxn(mailingInfo, emailType)).to.throw;
});
it('throws error when mail target has no _id or email', async () => {
it('throws error when mail target has no _id or email', () => {
const emailType = 'an email type';
const mailingInfo = {
};
await expect(sendTxn(mailingInfo, emailType)).to.be.rejectedWith('Argument Error mailingInfoArray: does not contain email or _id');
expect(sendTxn(mailingInfo, emailType)).to.throw;
});
it('throws error when variables not an array', async () => {
it('throws error when variables not an array', () => {
const emailType = 'an email type';
const mailingInfo = {
name: 'my name',
@@ -195,10 +195,9 @@ describe('emails', () => {
};
const variables = {};
await expect(sendTxn(mailingInfo, emailType, variables)).to.be.rejectedWith('Argument Error variables: is not an array');
expect(sendTxn(mailingInfo, emailType, variables)).to.throw;
});
it('throws error when variables array not contain name/content', async () => {
it('throws error when variables array not contain name/content', () => {
const emailType = 'an email type';
const mailingInfo = {
name: 'my name',
@@ -210,9 +209,8 @@ describe('emails', () => {
},
];
await expect(sendTxn(mailingInfo, emailType, variables)).to.be.rejectedWith('Argument Error variables: does not contain name or content');
expect(sendTxn(mailingInfo, emailType, variables)).to.throw;
});
it('throws no error when variables array contain name but no content', () => {
const emailType = 'an email type';
const mailingInfo = {

View File

@@ -1,4 +1,5 @@
import os from 'os';
import nconf from 'nconf';
import requireAgain from 'require-again';
const pathToMongoLib = '../../../../website/server/libs/mongodb';
@@ -28,4 +29,22 @@ describe('mongodb', () => {
expect(string).to.equal('mongodb://hostname:3030');
});
});
describe('getDefaultConnectionOptions', () => {
it('returns development config when IS_PROD is false', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(false);
const mongoLibOverride = requireAgain(pathToMongoLib);
const options = mongoLibOverride.getDefaultConnectionOptions();
expect(options).to.have.all.keys(['useNewUrlParser', 'useUnifiedTopology']);
});
it('returns production config when IS_PROD is true', () => {
sandbox.stub(nconf, 'get').withArgs('IS_PROD').returns(true);
const mongoLibOverride = requireAgain(pathToMongoLib);
const options = mongoLibOverride.getDefaultConnectionOptions();
expect(options).to.have.all.keys(['useNewUrlParser', 'useUnifiedTopology']);
});
});
});

View File

@@ -1,11 +1,8 @@
import nconf from 'nconf';
import requireAgain from 'require-again';
import {
generateRes,
generateReq,
} from '../../../helpers/api-unit.helper';
const authPath = '../../../../website/server/middlewares/auth';
import { authWithHeaders as authWithHeadersFactory } from '../../../../website/server/middlewares/auth';
describe('auth middleware', () => {
let res; let req; let
@@ -19,7 +16,6 @@ describe('auth middleware', () => {
describe('auth with headers', () => {
it('allows to specify a list of user field that we do not want to load', done => {
const authWithHeadersFactory = requireAgain(authPath).authWithHeaders;
const authWithHeaders = authWithHeadersFactory({
userFieldsToExclude: ['items'],
});
@@ -39,7 +35,6 @@ describe('auth middleware', () => {
});
it('makes sure some fields are always included', done => {
const authWithHeadersFactory = requireAgain(authPath).authWithHeaders;
const authWithHeaders = authWithHeadersFactory({
userFieldsToExclude: [
'items', 'auth.timestamps',
@@ -65,57 +60,5 @@ describe('auth middleware', () => {
return done();
});
});
it('errors with InvalidCredentialsError and code when token is wrong', done => {
const authWithHeadersFactory = requireAgain(authPath).authWithHeaders;
const authWithHeaders = authWithHeadersFactory({ userFieldsToExclude: [] });
req.headers['x-api-user'] = user._id;
req.headers['x-api-key'] = 'totally-wrong-token';
authWithHeaders(req, res, err => {
expect(err).to.exist;
expect(err.name).to.equal('InvalidCredentialsError');
expect(err.code).to.equal('invalid_credentials');
expect(err.message).to.equal(res.t('invalidCredentials'));
return done();
});
});
describe('when ENFORCE_CLIENT_HEADER is true', () => {
let authFactory;
beforeEach(() => {
sandbox.stub(nconf, 'get').withArgs('ENFORCE_CLIENT_HEADER').returns('true');
authFactory = requireAgain(authPath).authWithHeaders;
});
it('errors with missingClientHeader when x-client header is not present', done => {
const authWithHeaders = authFactory({ userFieldsToExclude: [] });
req.headers['x-api-user'] = user._id;
req.headers['x-api-key'] = user;
authWithHeaders(req, res, err => {
expect(err).to.exist;
expect(err.name).to.equal('BadRequest');
expect(err.message).to.equal(res.t('missingClientHeader'));
return done();
});
});
it('allows request to pass when x-client header is present', done => {
const authWithHeaders = authFactory({ userFieldsToExclude: [] });
req.headers['x-api-user'] = user._id;
req.headers['x-api-key'] = user.apiToken;
req.headers['x-client'] = 'habitica-web';
authWithHeaders(req, res, err => {
if (err) return done(err);
expect(res.locals.user).to.exist;
return done();
});
});
});
});
});

View File

@@ -1,197 +0,0 @@
import nconf from 'nconf';
import requireAgain from 'require-again';
import {
generateRes,
generateReq,
generateNext,
} from '../../../helpers/api-unit.helper';
import { Forbidden } from '../../../../website/server/libs/errors';
import { apiError } from '../../../../website/server/libs/apiError';
import { model as Blocker } from '../../../../website/server/models/blocker';
function checkIPBlockedErrorThrown (next) {
expect(next).to.have.been.calledOnce;
const calledWith = next.getCall(0).args;
expect(calledWith[0].message).to.equal(apiError('ipAddressBlocked'));
expect(calledWith[0] instanceof Forbidden).to.equal(true);
}
function checkClientBlockedErrorThrown (next) {
expect(next).to.have.been.calledOnce;
const calledWith = next.getCall(0).args;
expect(calledWith[0].message).to.equal(apiError('clientBlocked'));
expect(calledWith[0] instanceof Forbidden).to.equal(true);
}
function checkErrorNotThrown (next) {
expect(next).to.have.been.calledOnce;
const calledWith = next.getCall(0).args;
expect(typeof calledWith[0] === 'undefined').to.equal(true);
}
describe('Blocker middleware', () => {
const pathToBlocker = '../../../../website/server/middlewares/blocker';
let res; let req; let next;
beforeEach(() => {
res = generateRes();
req = generateReq();
next = generateNext();
});
describe('Blocking IPs', () => {
it('is disabled when the env var is not defined', () => {
sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns(undefined);
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('is disabled when the env var is an empty string', () => {
sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns('');
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('is disabled when the env var contains comma separated empty strings', () => {
sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns(' , , ');
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('does not throw when the ip does not match', () => {
req.ip = '192.168.1.1';
sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns('192.168.1.2');
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('does not throw when the blocker IP does not match', async () => {
req.ip = '192.168.1.1';
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
if (event === 'change') {
callback({ operation: 'add', blocker: { type: 'ipaddress', area: 'full', value: '192.168.1.2' } });
}
},
});
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('does not throw when a client is blocked', async () => {
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
if (event === 'change') {
callback({ operation: 'add', blocker: { type: 'client', area: 'full', value: '192.168.1.1' } });
}
},
});
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('throws when the blocker IP is blocked', async () => {
req.ip = '192.168.1.1';
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
if (event === 'change') {
callback({ operation: 'add', blocker: { type: 'ipaddress', area: 'full', value: '192.168.1.1' } });
}
},
});
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkIPBlockedErrorThrown(next);
});
});
describe('Blocking clients', () => {
beforeEach(() => {
sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns('');
req.headers['x-client'] = 'test-client';
});
it('is disabled when no clients are blocked', () => {
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('does not throw when the client does not match', async () => {
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
if (event === 'change') {
callback({ operation: 'add', blocker: { type: 'client', area: 'full', value: 'another-client' } });
}
},
});
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('throws when the client is blocked', async () => {
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
if (event === 'change') {
callback({ operation: 'add', blocker: { type: 'client', area: 'full', value: 'test-client' } });
}
},
});
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkClientBlockedErrorThrown(next);
});
it('does not throw when an ip is blocked', async () => {
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
if (event === 'change') {
callback({ operation: 'add', blocker: { type: 'ipaddress', area: 'full', value: 'test-client' } });
}
},
});
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('updates the list when data changes', async () => {
let blockCallback;
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
blockCallback = callback;
if (event === 'change') {
callback({ operation: 'add', blocker: { type: 'client', area: 'full', value: 'another-client' } });
}
},
});
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkErrorNotThrown(next);
blockCallback({ operation: 'add', blocker: { type: 'client', area: 'full', value: 'test-client' } });
attachBlocker(req, res, next);
expect(next).to.have.been.calledTwice;
const calledWith = next.getCall(1).args;
expect(calledWith[0].message).to.equal(apiError('clientBlocked'));
expect(calledWith[0] instanceof Forbidden).to.equal(true);
});
});
});

View File

@@ -0,0 +1,332 @@
import moment from 'moment';
import { v4 as generateUUID } from 'uuid';
import {
generateRes,
generateReq,
generateTodo,
generateDaily,
} from '../../../helpers/api-unit.helper';
import cronMiddleware from '../../../../website/server/middlewares/cron';
import { model as User } from '../../../../website/server/models/user';
import { model as Group } from '../../../../website/server/models/group';
import * as Tasks from '../../../../website/server/models/task';
import * as analyticsService from '../../../../website/server/libs/analyticsService';
import * as cronLib from '../../../../website/server/libs/cron';
const CRON_TIMEOUT_WAIT = new Date(60 * 60 * 1000).getTime();
const CRON_TIMEOUT_UNIT = new Date(60 * 1000).getTime();
describe('cron middleware', () => {
let res; let
req;
let user;
beforeEach(async () => {
res = generateRes();
req = generateReq();
user = await res.locals.user.save();
res.analytics = analyticsService;
});
afterEach(() => {
sandbox.restore();
});
it('calls next when user is not attached', done => {
res.locals.user = null;
cronMiddleware(req, res, done);
});
it('calls next when days have not been missed', done => {
cronMiddleware(req, res, done);
});
it('should clear todos older than 30 days for free users', async () => {
user.lastCron = moment(new Date()).subtract({ days: 2 });
const task = generateTodo(user);
task.dateCompleted = moment(new Date()).subtract({ days: 31 });
task.completed = true;
await task.save();
await user.save();
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
Tasks.Task.findOne({ _id: task }).then(foundTask => {
expect(foundTask).to.not.exist;
resolve();
});
return null;
});
});
});
it('should not clear todos older than 30 days for subscribed users', async () => {
user.purchased.plan.customerId = 'subscribedId';
user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY');
user.lastCron = moment(new Date()).subtract({ days: 2 });
const task = generateTodo(user);
task.dateCompleted = moment(new Date()).subtract({ days: 31 });
task.completed = true;
await task.save();
await user.save();
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
Tasks.Task.findOne({ _id: task }).then(foundTask => {
expect(foundTask).to.exist;
return resolve();
});
return null;
});
});
});
it('should clear todos older than 90 days for subscribed users', async () => {
user.purchased.plan.customerId = 'subscribedId';
user.purchased.plan.dateUpdated = moment('012013', 'MMYYYY');
user.lastCron = moment(new Date()).subtract({ days: 2 });
const task = generateTodo(user);
task.dateCompleted = moment(new Date()).subtract({ days: 91 });
task.completed = true;
await task.save();
await user.save();
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
Tasks.Task.findOne({ _id: task }).then(foundTask => {
expect(foundTask).to.not.exist;
return resolve();
});
return null;
});
});
});
it('should call next if user was not modified after cron', async () => {
const hpBefore = user.stats.hp;
user.lastCron = moment(new Date()).subtract({ days: 2 });
await user.save();
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
expect(hpBefore).to.equal(user.stats.hp);
return resolve();
});
});
});
it('runs cron if previous cron was incomplete', async () => {
user.lastCron = moment(new Date()).subtract({ days: 1 });
user.auth.timestamps.loggedin = moment(new Date()).subtract({ days: 4 });
const now = new Date();
await user.save();
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
expect(moment(now).isSame(user.lastCron, 'day'));
expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day'));
return resolve();
});
});
});
it('updates user.auth.timestamps.loggedin and lastCron', async () => {
user.lastCron = moment(new Date()).subtract({ days: 2 });
const now = new Date();
await user.save();
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
expect(moment(now).isSame(user.lastCron, 'day'));
expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day'));
return resolve();
});
});
});
it('does damage for missing dailies', async () => {
const hpBefore = user.stats.hp;
user.lastCron = moment(new Date()).subtract({ days: 2 });
const daily = generateDaily(user);
daily.startDate = moment(new Date()).subtract({ days: 2 });
await daily.save();
await user.save();
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
return User.findOne({ _id: user._id }).then(updatedUser => {
expect(updatedUser.stats.hp).to.be.lessThan(hpBefore);
return resolve();
});
});
});
});
it('updates tasks', async () => {
user.lastCron = moment(new Date()).subtract({ days: 2 });
const todo = generateTodo(user);
const todoValueBefore = todo.value;
await Promise.all([todo.save(), user.save()]);
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
return Tasks.Task.findOne({ _id: todo._id }).then(todoFound => {
expect(todoFound.value).to.be.lessThan(todoValueBefore);
return resolve();
});
});
});
});
it('applies quest progress', async () => {
const hpBefore = user.stats.hp;
user.lastCron = moment(new Date()).subtract({ days: 2 });
const daily = generateDaily(user);
daily.startDate = moment(new Date()).subtract({ days: 2 });
await daily.save();
const questKey = 'dilatory';
user.party.quest.key = questKey;
const party = new Group({
type: 'party',
name: generateUUID(),
leader: user._id,
});
party.quest.members[user._id] = true;
party.quest.key = questKey;
await party.save();
user.party._id = party._id;
await user.save();
party.startQuest(user);
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
return User.findOne({ _id: user._id }).then(updatedUser => {
expect(updatedUser.stats.hp).to.be.lessThan(hpBefore);
return resolve();
});
});
});
});
it('recovers from failed cron and does not error when user is already cronning', async () => {
user.lastCron = moment(new Date()).subtract({ days: 2 });
await user.save();
const updatedUser = user.toObject();
updatedUser.matchedCount = 0;
sandbox.spy(cronLib, 'recoverCron');
sandbox.stub(User, 'updateOne')
.withArgs({
_id: user._id,
$or: [
{ _cronSignature: 'NOT_RUNNING' },
{ _cronSignature: { $lt: sinon.match.number } },
],
})
.returns({
exec () {
return Promise.resolve(updatedUser);
},
});
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
expect(cronLib.recoverCron).to.be.calledOnce;
return resolve();
});
});
});
it('cronSignature less than an hour ago should error', async () => {
user.lastCron = moment(new Date()).subtract({ days: 2 });
const now = new Date();
await User.updateOne({
_id: user._id,
}, {
$set: {
_cronSignature: now.getTime() - CRON_TIMEOUT_WAIT + CRON_TIMEOUT_UNIT,
},
}).exec();
await user.save();
const expectedErrMessage = `Impossible to recover from cron for user ${user._id}.`;
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (!err) return reject(new Error('Cron should have failed.'));
expect(err.message).to.be.equal(expectedErrMessage);
return resolve();
});
});
});
it('cronSignature longer than an hour ago should allow cron', async () => {
user.lastCron = moment(new Date()).subtract({ days: 2 });
const now = new Date();
await User.updateOne({
_id: user._id,
}, {
$set: {
_cronSignature: now.getTime() - CRON_TIMEOUT_WAIT - CRON_TIMEOUT_UNIT,
},
}).exec();
await user.save();
await new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
expect(moment(now).isSame(user.auth.timestamps.loggedin, 'day'));
expect(user._cronSignature).to.be.equal('NOT_RUNNING');
return resolve();
});
});
});
it('cron should not run more than once', async () => {
user.lastCron = moment(new Date()).subtract({ days: 2 });
await user.save();
sandbox.spy(cronLib, 'cron');
await Promise.all([new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
return resolve();
});
}), new Promise((resolve, reject) => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
return resolve();
});
}), new Promise((resolve, reject) => {
setTimeout(() => {
cronMiddleware(req, res, err => {
if (err) return reject(err);
return resolve();
});
}, 400);
}),
]);
expect(cronLib.cron).to.be.calledOnce;
});
});

View File

@@ -0,0 +1,76 @@
import nconf from 'nconf';
import requireAgain from 'require-again';
import {
generateRes,
generateReq,
generateNext,
} from '../../../helpers/api-unit.helper';
import { Forbidden } from '../../../../website/server/libs/errors';
import { apiError } from '../../../../website/server/libs/apiError';
function checkErrorThrown (next) {
expect(next).to.have.been.calledOnce;
const calledWith = next.getCall(0).args;
expect(calledWith[0].message).to.equal(apiError('ipAddressBlocked'));
expect(calledWith[0] instanceof Forbidden).to.equal(true);
}
function checkErrorNotThrown (next) {
expect(next).to.have.been.calledOnce;
const calledWith = next.getCall(0).args;
expect(typeof calledWith[0] === 'undefined').to.equal(true);
}
describe('ipBlocker middleware', () => {
const pathToIpBlocker = '../../../../website/server/middlewares/ipBlocker';
let res; let req; let next;
beforeEach(() => {
res = generateRes();
req = generateReq();
next = generateNext();
});
it('is disabled when the env var is not defined', () => {
sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns(undefined);
const attachIpBlocker = requireAgain(pathToIpBlocker).default;
attachIpBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('is disabled when the env var is an empty string', () => {
sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns('');
const attachIpBlocker = requireAgain(pathToIpBlocker).default;
attachIpBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('is disabled when the env var contains comma separated empty strings', () => {
sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns(' , , ');
const attachIpBlocker = requireAgain(pathToIpBlocker).default;
attachIpBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('does not throw when the ip does not match', () => {
req.ip = '192.168.1.1';
sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns('192.168.1.2');
const attachIpBlocker = requireAgain(pathToIpBlocker).default;
attachIpBlocker(req, res, next);
checkErrorNotThrown(next);
});
it('throws when the ip is blocked', () => {
req.ip = '192.168.1.1';
sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns('192.168.1.1');
const attachIpBlocker = requireAgain(pathToIpBlocker).default;
attachIpBlocker(req, res, next);
checkErrorThrown(next);
});
});

View File

@@ -1,13 +1,9 @@
import moment from 'moment';
import requireAgain from 'require-again';
import { model as User } from '../../../../website/server/models/user';
import { model as NewsPost } from '../../../../website/server/models/newsPost';
import { model as Group } from '../../../../website/server/models/group';
import { model as Blocker } from '../../../../website/server/models/blocker';
import common from '../../../../website/common';
const pathToUserSchema = '../../../../website/server/models/user/schema';
describe('User Model', () => {
describe('.toJSON()', () => {
it('keeps user._tmp when calling .toJSON', () => {
@@ -916,73 +912,4 @@ describe('User Model', () => {
expect(user.toJSON().flags.newStuff).to.equal(true);
});
});
describe('validates email', () => {
it('does not throw an error for a valid email', () => {
const user = new User();
user.auth.local.email = 'hello@example.com';
const errors = user.validateSync();
expect(errors.errors['auth.local.email']).to.not.exist;
});
it('throws an error if email is not valid', () => {
const user = new User();
user.auth.local.email = 'invalid-email';
const errors = user.validateSync();
expect(errors.errors['auth.local.email'].message).to.equal(common.i18n.t('invalidEmail'));
});
it('throws an error if email is using a restricted domain', () => {
const user = new User();
user.auth.local.email = 'scammer@habitica.com';
const errors = user.validateSync();
expect(errors.errors['auth.local.email'].message).to.equal(common.i18n.t('invalidEmailDomain', { domains: 'habitica.com, habitrpg.com' }));
});
it('throws an error if email was blocked specifically', () => {
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
callback({ operation: 'add', blocker: { type: 'email', area: 'full', value: 'blocked@example.com' } });
},
});
const schema = requireAgain(pathToUserSchema).UserSchema;
const valid = schema.paths['auth.local.email'].options.validate.every(v => v.validator('blocked@example.com'));
expect(valid).to.equal(false);
});
it('throws an error if email domain was blocked', () => {
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
callback({ operation: 'add', blocker: { type: 'email', area: 'full', value: '@example.com' } });
},
});
const schema = requireAgain(pathToUserSchema).UserSchema;
const valid = schema.paths['auth.local.email'].options.validate.every(v => v.validator('blocked@example.com'));
expect(valid).to.equal(false);
});
it('throws an error if user portion of email was blocked', () => {
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
callback({ operation: 'add', blocker: { type: 'email', area: 'full', value: 'blocked@' } });
},
});
const schema = requireAgain(pathToUserSchema).UserSchema;
const valid = schema.paths['auth.local.email'].options.validate.every(v => v.validator('blocked@example.com'));
expect(valid).to.equal(false);
});
it('does not throw an error if email is not blocked', () => {
sandbox.stub(Blocker, 'watchBlockers').returns({
on: (event, callback) => {
callback({ operation: 'add', blocker: { type: 'email', area: 'full', value: '@example.com' } });
callback({ operation: 'add', blocker: { type: 'email', area: 'full', value: 'blocked@' } });
callback({ operation: 'add', blocker: { type: 'email', area: 'full', value: 'bad@test.com' } });
},
});
const schema = requireAgain(pathToUserSchema).UserSchema;
const valid = schema.paths['auth.local.email'].options.validate.every(v => v.validator('good@test.com'));
expect(valid).to.equal(true);
});
});
});

View File

@@ -10,7 +10,6 @@ describe('GET /heroes/:heroId', () => {
const heroFields = [
'_id', 'id', 'auth', 'balance', 'contributor', 'flags', 'items',
'lastCron', 'party', 'preferences', 'profile', 'purchased', 'secret', 'achievements',
'stats',
];
before(async () => {

View File

@@ -11,7 +11,6 @@ describe('PUT /heroes/:heroId', () => {
const heroFields = [
'_id', 'auth', 'balance', 'contributor', 'flags', 'items', 'lastCron',
'party', 'preferences', 'profile', 'purchased', 'secret', 'permissions', 'achievements',
'stats',
];
before(async () => {

View File

@@ -1,56 +0,0 @@
import {
generateUser,
translate as t,
} from '../../../../helpers/api-integration/v3';
import common from '../../../../../website/common';
describe('GET /members/username/:username', () => {
let user;
before(async () => {
user = await generateUser();
});
it('validates req.params.username', async () => {
await expect(user.get('/members/username/')).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('invalidReqParams'),
});
});
it('returns a member\'s public data only', async () => {
// make sure user has all the fields that can be returned by the getMember call
const member = await generateUser({
contributor: { level: 1 },
backer: { tier: 3 },
preferences: {
costume: false,
background: 'volcano',
},
secret: {
text: 'Clark Kent',
},
});
const memberRes = await user.get(`/members/username/${member.auth.local.username}`);
expect(memberRes).to.have.all.keys([ // works as: object has all and only these keys
'_id', 'id', 'preferences', 'profile', 'stats', 'achievements', 'party',
'backer', 'contributor', 'auth', 'items', 'inbox', 'loginIncentives', 'flags',
]);
expect(Object.keys(memberRes.auth)).to.eql(['local', 'timestamps']);
expect(Object.keys(memberRes.preferences).sort()).to.eql([
'size', 'hair', 'skin', 'shirt',
'chair', 'costume', 'sleep', 'background', 'tasks', 'disableClasses',
].sort());
expect(memberRes.stats.maxMP).to.exist;
expect(memberRes.stats.maxHealth).to.equal(common.maxHealth);
expect(memberRes.stats.toNextLevel).to.equal(common.tnl(memberRes.stats.lvl));
expect(memberRes.inbox.optOut).to.exist;
expect(memberRes.inbox.canReceive).to.exist;
expect(memberRes.inbox.messages).to.not.exist;
expect(memberRes.secret).to.not.exist;
expect(memberRes.blocks).to.not.exist;
});
});

View File

@@ -101,6 +101,34 @@ describe('GET /tasks/user', () => {
expect(allCompletedTodos[allCompletedTodos.length - 1].text).to.equal('todo to complete 2');
});
it('returns only some completed todos if req.query.type is "completedTodos" or "_allCompletedTodos"', async () => {
const LIMIT = 30;
const numberOfTodos = LIMIT + 1;
const todosInput = [];
for (let i = 0; i < numberOfTodos; i += 1) {
todosInput[i] = { text: `todo to complete ${i}`, type: 'todo' };
}
const todos = await user.post('/tasks/user', todosInput);
await user.sync();
const initialTodoCount = user.tasksOrder.todos.length;
for (let i = 0; i < numberOfTodos; i += 1) {
const id = todos[i]._id;
await user.post(`/tasks/${id}/score/up`); // eslint-disable-line no-await-in-loop
}
await user.sync();
expect(user.tasksOrder.todos.length).to.equal(initialTodoCount - numberOfTodos);
const completedTodos = await user.get('/tasks/user?type=completedTodos');
expect(completedTodos.length).to.equal(LIMIT);
const allCompletedTodos = await user.get('/tasks/user?type=_allCompletedTodos');
expect(allCompletedTodos.length).to.equal(numberOfTodos);
});
it('returns dailies with isDue for the date specified', async () => {
// @TODO Add required format
const startDate = moment().subtract('1', 'days').toISOString();

View File

@@ -238,28 +238,6 @@ describe('POST /user/auth/reset-password-set-new-one', () => {
expect(isPassValid).to.equal(true);
});
it('changes the apiToken on password reset', async () => {
const user = await generateUser();
const previousToken = user.apiToken;
const code = encrypt(JSON.stringify({
userId: user._id,
expiresAt: moment().add({ days: 1 }),
}));
await user.updateOne({
'auth.local.passwordResetCode': code,
});
await api.post(`${endpoint}`, {
newPassword: 'my new password',
confirmPassword: 'my new password',
code,
});
await user.sync();
expect(user.apiToken).to.not.eql(previousToken);
});
it('renders the success page and convert the password from sha1 to bcrypt', async () => {
const user = await generateUser();

View File

@@ -27,30 +27,11 @@ describe('PUT /user/auth/update-password', async () => {
newPassword,
confirmPassword: newPassword,
});
expect(response).to.exist;
expect(response.apiToken).to.exist;
expect(response).to.eql({});
await user.sync();
expect(user.auth.local.hashed_password).to.not.eql(previousHashedPassword);
});
it('should change the apiToken on password change', async () => {
const previousToken = user.apiToken;
const response = await user.put(ENDPOINT, {
password,
newPassword,
confirmPassword: newPassword,
});
const newToken = response.apiToken;
expect(newToken).to.exist;
await user.sync();
expect(user.apiToken).to.eql(newToken);
expect(user.apiToken).to.not.eql(previousToken);
});
it('returns an error when confirmPassword does not match newPassword', async () => {
await expect(user.put(ENDPOINT, {
password,

View File

@@ -1,104 +0,0 @@
import find from 'lodash/find';
import {
generateUser,
translate as t,
} from '../../../helpers/api-integration/v4';
/**
* Checks the messages array if the uniqueMessageId has the like flag
* @param {InboxMessage[]} messages
* @param {String} uniqueMessageId
* @param {String} userId
* @param {Boolean} likeStatus
*/
function expectMessagesLikeStatus (messages, uniqueMessageId, userId, likeStatus) {
const messageToCheck = find(messages, { uniqueMessageId });
expect(messageToCheck.likes[userId]).to.equal(likeStatus);
}
// eslint-disable-next-line mocha/no-exclusive-tests
describe('POST /inbox/like-private-message/:messageId', () => {
let userToSendMessage;
const getLikeUrl = messageId => `/inbox/like-private-message/${messageId}`;
before(async () => {
userToSendMessage = await generateUser();
});
it('returns an error when private message is not found', async () => {
await expect(userToSendMessage.post(getLikeUrl('some-unknown-id')))
.to.eventually.be.rejected.and.eql({
code: 404,
error: 'NotFound',
message: t('messageGroupChatNotFound'),
});
});
it('likes a message', async () => {
const receiver = await generateUser();
const sentMessageResult = await userToSendMessage.post('/members/send-private-message', {
message: 'some message :)',
toUserId: receiver._id,
});
const { uniqueMessageId } = sentMessageResult.message;
const likeResult = await receiver.post(getLikeUrl(uniqueMessageId));
expect(likeResult.likes[receiver._id]).to.equal(true);
const senderMessages = await userToSendMessage.get('/inbox/messages');
expectMessagesLikeStatus(senderMessages, uniqueMessageId, receiver._id, true);
const receiversMessages = await receiver.get('/inbox/messages');
expectMessagesLikeStatus(receiversMessages, uniqueMessageId, receiver._id, true);
});
it('allows a user to like their own private message', async () => {
const receiver = await generateUser();
const sentMessageResult = await userToSendMessage.post('/members/send-private-message', {
message: 'some message :)',
toUserId: receiver._id,
});
const { uniqueMessageId } = sentMessageResult.message;
const likeResult = await userToSendMessage.post(getLikeUrl(uniqueMessageId));
expect(likeResult.likes[userToSendMessage._id]).to.equal(true);
const messages = await userToSendMessage.get('/inbox/messages');
expectMessagesLikeStatus(messages, uniqueMessageId, userToSendMessage._id, true);
const receiversMessages = await receiver.get('/inbox/messages');
expectMessagesLikeStatus(receiversMessages, uniqueMessageId, userToSendMessage._id, true);
});
it('unlikes a message', async () => {
const receiver = await generateUser();
const sentMessageResult = await userToSendMessage.post('/members/send-private-message', {
message: 'some message :)',
toUserId: receiver._id,
});
const { uniqueMessageId } = sentMessageResult.message;
const likeResult = await receiver.post(getLikeUrl(uniqueMessageId));
expect(likeResult.likes[receiver._id]).to.equal(true);
const unlikeResult = await receiver.post(getLikeUrl(uniqueMessageId));
expect(unlikeResult.likes[receiver._id]).to.equal(false);
const messages = await userToSendMessage.get('/inbox/messages');
const messageToCheck = find(messages, { id: sentMessageResult.message.id });
expect(messageToCheck.likes[receiver._id]).to.equal(false);
});
});

View File

@@ -10,7 +10,7 @@ describe('events', () => {
});
it('returns empty array when no events are active', () => {
clock = sinon.useFakeTimers(new Date('2024-01-11'));
clock = sinon.useFakeTimers(new Date('2024-01-08'));
const events = getRepeatingEvents();
expect(events).to.be.empty;
});

View File

@@ -133,21 +133,21 @@ describe('Content Schedule', () => {
});
it('sets the end date for a gala', () => {
const date = new Date('2024-05-31');
const date = new Date('2024-05-20');
const matchers = getAllScheduleMatchingGroups(date);
expect(matchers.seasonalGear.end).to.eql(moment.utc(`2024-06-01T${String(switchoverTime).padStart(2, '0')}:00:00.000Z`).toDate());
expect(matchers.seasonalGear.end).to.eql(moment.utc(`2024-06-21T${String(switchoverTime).padStart(2, '0')}:00:00.000Z`).toDate());
});
it('sets the end date for a winter gala', () => {
const date = new Date('2025-02-28');
const date = new Date('2024-12-22');
const matchers = getAllScheduleMatchingGroups(date);
expect(matchers.seasonalGear.end).to.eql(moment.utc(`2025-03-01T${String(switchoverTime).padStart(2, '0')}:00:00.000Z`).toDate());
expect(matchers.seasonalGear.end).to.eql(moment.utc(`2025-03-21T${String(switchoverTime).padStart(2, '0')}:00:00.000Z`).toDate());
});
it('sets the end date in new year for a winter gala', () => {
const date = new Date('2025-02-28');
const date = new Date('2025-01-04');
const matchers = getAllScheduleMatchingGroups(date);
expect(matchers.seasonalGear.end).to.eql(moment.utc(`2025-03-01T${String(switchoverTime).padStart(2, '0')}:00:00.000Z`).toDate());
expect(matchers.seasonalGear.end).to.eql(moment.utc(`2025-03-21T${String(switchoverTime).padStart(2, '0')}:00:00.000Z`).toDate());
});
it('uses correct date for first hours of the month', () => {
@@ -190,7 +190,7 @@ describe('Content Schedule', () => {
const date = new Date('2024-04-15');
const matchers = getAllScheduleMatchingGroups(date);
expect(matchers.premiumHatchingPotions).to.exist;
expect(matchers.premiumHatchingPotions.items.length).to.equal(6);
expect(matchers.premiumHatchingPotions.items.length).to.equal(5);
expect(matchers.premiumHatchingPotions.items.indexOf('Veggie')).to.not.equal(-1);
expect(matchers.premiumHatchingPotions.items.indexOf('Porcelain')).to.not.equal(-1);
});

View File

@@ -18,7 +18,7 @@ describe('Shop Featured Items', () => {
});
it('contains the current premium hatching potions', () => {
clock = Sinon.useFakeTimers(new Date('2024-04-09'));
clock = Sinon.useFakeTimers(new Date('2024-04-08'));
const items = featuredItems.market();
expect(_.find(items, item => item.path === 'premiumHatchingPotions.Porcelain')).to.exist;
});

View File

@@ -19,6 +19,6 @@ const sinonStubPromise = require('sinon-stub-promise');
sinonStubPromise(global.sinon);
global.sandbox = sinon.createSandbox();
const setupNconf = require('../../website/server/libs/setupNconf').default;
const setupNconf = require('../../website/server/libs/setupNconf');
setupNconf('./config.json.example');

View File

@@ -74,10 +74,15 @@ export async function getDocument (collectionName, doc) {
}
before(done => {
mongoose.connection.once('open', async err => {
if (err) throw err;
await resetHabiticaDB();
done();
mongoose.connection.on('open', err => {
if (err) return done(err);
return resetHabiticaDB()
.then(() => {
done();
})
.catch(error => {
throw error;
});
});
});

View File

@@ -3,7 +3,7 @@
const nconf = require('nconf');
const mongoose = require('mongoose');
const setupNconf = require('../../website/server/libs/setupNconf').default;
const setupNconf = require('../../website/server/libs/setupNconf');
// fix further imports of require/import syntaxes
require('@babel/register');

View File

@@ -3,12 +3,11 @@ module.exports = {
root: true,
env: {
node: true,
es2021: true,
},
extends: [
'habitrpg/lib/vue',
],
ignorePatterns: ['dist/', 'node_modules/', '*.d.ts'],
ignorePatterns: ['dist/', 'node_modules/'],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
@@ -40,4 +39,7 @@ module.exports = {
order: ['template', 'style', 'script'],
}],
},
parserOptions: {
parser: 'babel-eslint',
},
};

View File

@@ -0,0 +1,9 @@
/* eslint-disable import/no-commonjs */
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset',
],
plugins: [
'@babel/plugin-proposal-optional-chaining',
],
};

File diff suppressed because it is too large Load Diff

View File

@@ -3,26 +3,28 @@
"version": "1.0.0",
"private": true,
"scripts": {
"serve": "vite",
"build": "vite build",
"preview": "vite preview",
"test:unit": "vitest run",
"test:unit:watch": "vitest watch",
"lint": "eslint --ext .js,.vue --ignore-path ../../.gitignore --fix .",
"lint-no-fix": "eslint --ext .js,.vue --no-fix src",
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"test:unit": "vue-cli-service test:unit --require ./tests/unit/helpers.js",
"lint": "vue-cli-service lint .",
"lint-no-fix": "vue-cli-service lint --no-fix .",
"postinstall": "node ./scripts/npm-postinstall.js"
},
"dependencies": {
"@froxz/vite-plugin-s3": "^1.6.0",
"@vitejs/plugin-vue2": "^2.3.3",
"@vue/cli-plugin-babel": "^5.0.8",
"@vue/cli-plugin-eslint": "^5.0.8",
"@vue/cli-plugin-router": "^5.0.8",
"@vue/cli-plugin-unit-mocha": "^5.0.8",
"@vue/cli-service": "^5.0.8",
"@vue/test-utils": "1.0.0-beta.29",
"amplitude-js": "^8.21.3",
"assert": "^2.1.0",
"autoprefixer": "^10.4.20",
"axios": "^0.28.0",
"axios-progress-bar": "^1.2.0",
"babel-eslint": "^10.1.0",
"bootstrap": "^4.6.0",
"bootstrap-vue": "^2.23.1",
"core-js": "^3.33.1",
"eslint": "7.32.0",
"eslint-config-habitrpg": "6.2.0",
"eslint-plugin-mocha": "5.3.0",
@@ -32,34 +34,31 @@
"intro.js": "^7.2.0",
"jquery": "^3.7.1",
"lodash": "^4.17.21",
"markdown-it": "^14.0.0",
"moment": "^2.29.4",
"moment-locales-webpack-plugin": "^1.2.0",
"nconf": "^0.12.1",
"sass": "^1.63.4",
"sass-loader": "^14.1.1",
"sinon": "^17.0.1",
"stopword": "^2.0.8",
"timers-browserify": "^2.0.12",
"uuid": "^9.0.1",
"validator": "^13.9.0",
"vite": "^6.0.0",
"vite-plugin-compression2": "^1.3.3",
"vue": "^2.7.10",
"vue-fragment": "^1.6.0",
"vue-mugen-scroll": "^0.2.6",
"vue-router": "^3.6.5",
"vue-template-babel-compiler": "^2.0.0",
"vue-template-compiler": "^2.7.10",
"vuedraggable": "^2.24.3",
"vuejs-datepicker": "git://github.com/habitrpg/vuejs-datepicker.git#153d339e4dbebb73733658aeda1d5b7fcc55b0a0"
},
"devDependencies": {
"@babel/plugin-proposal-optional-chaining": "^7.21.0",
"@vitest/browser": "^3.0.5",
"babel-plugin-lodash": "^3.3.4",
"chai": "^5.1.0",
"inspectpack": "^4.7.1",
"jsdom": "^26.0.0",
"mocha": "^11.1.0",
"playwright": "^1.50.1",
"terser-webpack-plugin": "^5.3.10",
"vitest": "^3.0.5",
"webpack": "^5.94.0"
}
}

View File

@@ -12,7 +12,6 @@
<link rel="shortcut icon" sizes="192x192" href="/static/icons/favicon_192x192.png">
<link rel="mask-icon" href="/static/icons/favicon.ico">
<meta property="og:image" content="/static/emails/images/meta-image.png" />
<script type="module" src="/src/main.js"></script>
</head>
<body>
<div id="loading-screen">
@@ -29,9 +28,10 @@
</div>
<div id="app"></div>
<!-- built files will be auto injected -->
<script type="text/javascript" src="//cloudfront.loggly.com/js/loggly.tracker-latest.min.js" async></script>
<!-- Translations -->
<script type='text/javascript' src='/api/v4/i18n/browser-script' vite-ignore></script>
<script type='text/javascript' src='/api/v4/i18n/browser-script'></script>
</body>
</html>

View File

@@ -29,14 +29,12 @@
</div>
<snackbars />
<router-view v-if="!isUserLoggedIn || isStaticPage" />
<div v-else>
<user-main />
</div>
<user-main v-else />
</div>
</template>
<style lang='scss' scoped>
@import '@/assets/scss/colors.scss';
@import '~@/assets/scss/colors.scss';
#loading-screen-inapp {
#melior {
@@ -92,7 +90,7 @@
</style>
<style lang='scss'>
@import '@/assets/scss/colors.scss';
@import '~@/assets/scss/colors.scss';
.modal-backdrop {
opacity: .9 !important;
@@ -110,16 +108,16 @@ import axios from 'axios';
import * as Analytics from '@/libs/analytics';
import { mapState } from '@/libs/store';
import userMain from '@/pages/user-main';
import snackbars from '@/components/snackbars/notifications';
import { LOCALSTORAGE_AUTH_KEY } from '@/libs/auth';
const COMMUNITY_MANAGER_EMAIL = import.meta.env.EMAILS_COMMUNITY_MANAGER_EMAIL;
const COMMUNITY_MANAGER_EMAIL = process.env.EMAILS_COMMUNITY_MANAGER_EMAIL; // eslint-disable-line
export default {
name: 'App',
components: {
snackbars,
userMain: () => import('@/pages/user-main'),
userMain,
},
data () {
return {
@@ -223,10 +221,11 @@ export default {
const errorData = error.response.data;
const errorMessage = errorData.message || errorData;
const errorCode = errorData.error;
// If 'invalid_credentials' signaled, force logout
if (error.response.status === 401 && errorCode === 'invalid_credentials') {
// Check for conditions to reset the user auth
// TODO use a specific error like NotificationNotFound instead of checking for the string
const invalidUserMessage = [this.$t('invalidCredentials'), 'Missing authentication headers.'];
if (invalidUserMessage.indexOf(errorMessage) !== -1) {
this.$store.dispatch('auth:logout', { redirectToLogin: true });
return null;
}
@@ -269,29 +268,16 @@ export default {
const loadingScreen = document.getElementById('loading-screen');
if (loadingScreen) document.body.removeChild(loadingScreen);
// Check if we need to show password change success message
if (sessionStorage.getItem('passwordChangeSuccess') === 'true') {
sessionStorage.removeItem('passwordChangeSuccess');
this.$store.dispatch('snackbars:add', {
title: 'Habitica',
text: this.$t('passwordSuccess'),
type: 'success',
timeout: true,
});
if (this.isStaticPage || !this.isUserLoggedIn) {
this.hideLoadingScreen();
}
this.$router.onReady(() => {
if (this.isStaticPage || !this.isUserLoggedIn) {
this.hideLoadingScreen();
}
});
},
methods: {
hideLoadingScreen () {
this.loading = false;
},
checkForBannedUser (error) {
const AUTH_SETTINGS = localStorage.getItem(LOCALSTORAGE_AUTH_KEY);
const AUTH_SETTINGS = localStorage.getItem('habit-mobile-settings');
const parseSettings = JSON.parse(AUTH_SETTINGS);
const errorMessage = error.response.data.message;
@@ -315,3 +301,4 @@ export default {
</script>
<style src="@/assets/scss/index.scss" lang="scss"></style>
<style src="@/assets/scss/sprites.scss" lang="scss"></style>

View File

@@ -22,8 +22,7 @@
height: 219px;
}
.Pet_HatchingPotion_Dessert, .Pet_HatchingPotion_Veggie, .Pet_HatchingPotion_Windup,
.Pet_HatchingPotion_VirtualPet, .Pet_HatchingPotion_Fungi, .Pet_HatchingPotion_Cryptid {
.Pet_HatchingPotion_Dessert, .Pet_HatchingPotion_Veggie, .Pet_HatchingPotion_Windup, .Pet_HatchingPotion_VirtualPet, .Pet_HatchingPotion_Fungi {
width: 68px;
height: 68px;
}
@@ -48,10 +47,6 @@
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Fungi.gif") no-repeat;
}
.Pet_HatchingPotion_Cryptid {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Pet_HatchingPotion_Cryptid.gif") no-repeat;
}
.Gems {
display:inline-block;
margin-right:5px;
@@ -177,7 +172,7 @@
height: 96px;
}
.Mount_Head_Gryphon-Gryphatrice, .Mount_Body_Gryphon-Gryphatrice, .Mount_Head_Dragon-Hydra, .Mount_Body_Dragon-Hydra {
.Mount_Head_Gryphon-Gryphatrice, .Mount_Body_Gryphon-Gryphatrice {
width: 135px;
height: 135px;
}
@@ -190,14 +185,6 @@
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/BackerOnly-Mount-Body-Gryphatrice.gif") no-repeat;
}
.Mount_Head_Dragon-Hydra {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Mount_Head_Dragon-Hydra.gif") no-repeat;
}
.Mount_Body_Dragon-Hydra {
background: url("https://habitica-assets.s3.amazonaws.com/mobileApp/images/Mount_Body_Dragon-Hydra.gif") no-repeat;
}
.background_airship, .background_clocktower, .background_steamworks {
width: 141px;
height: 147px;

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,7 @@
top: -16px !important;
}
$foolPets: Veggie, Dessert, VirtualPet, TeaShop, Fungi, Cryptid;
$foolPets: Veggie, Dessert, VirtualPet, TeaShop, Fungi;
@each $foolPet in $foolPets {
.Pet.Pet-FlyingPig-#{$foolPet} {

View File

@@ -1,5 +1,5 @@
@import '@/assets/scss/colors.scss';
@import '~@/assets/scss/colors.scss';
.featured-label {
width: auto;

View File

@@ -2,7 +2,7 @@
$grid-gutter-width: 24px;
// Bootstrap and its default variables
@import '~/bootstrap/scss/bootstrap';
@import 'node_modules/bootstrap/scss/bootstrap';
// Bootstrap Vue styles
@import '~/bootstrap-vue/dist/bootstrap-vue';
@import 'node_modules/bootstrap-vue/dist/bootstrap-vue';

View File

@@ -101,7 +101,8 @@
.btn-secondary,
.dropdown > .btn-secondary.dropdown-toggle:not(.btn-success),
.show > .btn-secondary.dropdown-toggle:not(.btn-success) {
.show > .btn-secondary.dropdown-toggle:not(.btn-success)
{
background: $white;
border: 2px solid transparent;
color: $gray-50;
@@ -297,16 +298,6 @@
box-shadow: none;
}
.btn-flat,
.dropdown > .btn-flat.dropdown-toggle:not(.btn-success),
.show > .btn-flat.dropdown-toggle:not(.btn-success) {
&.with-icon {
.svg-icon.color {
color: var(--icon-color);
}
}
}
.btn-cancel {
color: $blue-10;
}
@@ -316,9 +307,3 @@
line-height: 2;
padding: 2px 2px;
}
.btn-lg {
font-size: 1.25rem;
line-height: 1.5;
padding: .5rem 1rem;
}

View File

@@ -38,12 +38,7 @@
border-radius: 2px;
box-shadow: 0 3px 6px 0 rgba($black, 0.16), 0 3px 6px 0 rgba($black, 0.24);
padding: 0;
}
.no-min-width {
.dropdown-menu {
min-width: 0 !important;
}
}
// shared dropdown-item styles
@@ -59,8 +54,6 @@
color: $gray-50 !important;
cursor: pointer;
--dropdown-item-hover-icon-color: #{$gray-200};
&:focus {
outline: none;
background-color: inherit;
@@ -95,7 +88,7 @@
&:not(:hover) {
.with-icon .svg-icon {
color: var(dropdown-item-hover-icon-color);
color: $gray-200;
}
}
}
@@ -158,7 +151,7 @@
// selectList.vue items sizing
.selectListItem .dropdown-item {
padding: 0.25rem 1rem 0.25rem 0.75rem;
padding: 0.25rem 0.75rem;
height: 32px;
&:active, &:hover, &:focus, &.active {

View File

@@ -1,4 +1,4 @@
@import '@/assets/scss/colors.scss';
@import '~@/assets/scss/colors.scss';
h1 {
margin-top: 0px;

View File

@@ -61,13 +61,13 @@ input, textarea, input.form-control, textarea.form-control {
&.input-valid {
padding-right: 27px;
background-image: url(@/assets/svg/for-css/check.svg);
background-image: url(~@/assets/svg/for-css/check.svg);
background-size: 1rem;
}
&.input-invalid {
padding-right: 40px;
background-image: url(@/assets/svg/for-css/alert.svg);
background-image: url(~@/assets/svg/for-css/alert.svg);
background-size: 16px 16px;
border-color: $red-100 !important;
}
@@ -239,7 +239,7 @@ $bg-disabled-control: $gray-10;
&:checked~.custom-control-label::after {
width: 18px;
height: 18px;
background-image: url(@/assets/svg/for-css/checkbox-white.svg);
background-image: url(~@/assets/svg/for-css/checkbox-white.svg);
background-size: 13px 10px;
}

View File

@@ -29,13 +29,13 @@
}
.iconalert-success::before {
background-image: url(@/assets/svg/for-css/checkbox-white.svg);
background-image: url(~@/assets/svg/for-css/checkbox-white.svg);
background-size: 13px 10px;
background-color: #1ca372;
}
.iconalert-warning::before, .iconalert-error::before {
background-image: url(@/assets/svg/for-css/alert-white.svg);
background-image: url(~@/assets/svg/for-css/alert-white.svg);
background-size: 16px 16px;
}

View File

@@ -1,4 +1,4 @@
@import '@/assets/scss/colors.scss';
@import '~@/assets/scss/colors.scss';
.modal {
z-index: 1350;

View File

@@ -46,11 +46,13 @@
.background {
background-repeat: repeat-x;
height:216px;
width: 100%;
position: absolute;
top: 0;
left: 0;
display: flex;
flex-direction: column;
justify-content: center;
@@ -65,13 +67,6 @@
flex-direction: column;
}
.shop-message {
position: relative;
height: 76px;
margin: 71px auto;
width: 240px;
}
.npc {
position: absolute;
left: 0;

View File

@@ -1,4 +1,4 @@
@import '@/assets/scss/colors.scss';
@import '~@/assets/scss/colors.scss';
.container-fluid.static-view {
margin: 5em 2em 0 2em;

View File

@@ -4,7 +4,7 @@
<!-- @TODO i18n. How to setup the strings with the router-link inside?-->
<img
:class="retiredChatPage ? 'mt-5' : 'image-404'"
src="@/assets/images/404.png"
src="~@/assets/images/404.png"
>
<div v-if="retiredChatPage">
<h1>
@@ -48,7 +48,7 @@ export default {
</script>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
@import '~@/assets/scss/colors.scss';
h1, .static-wrapper h1 {
color: $purple-200;

View File

@@ -8,7 +8,7 @@
<div class="modal-body">
<div class="row">
<div class="col-6 offset-3">
<Sprite image-name="shop_armoire" />
<div class="shop_armoire"></div>
<p>{{ $t('armoireLastItem') }}</p>
<p>{{ $t('armoireNotesEmpty') }}</p>
</div>
@@ -34,12 +34,7 @@
</style>
<script>
import Sprite from '@/components/ui/sprite';
export default {
components: {
Sprite,
},
methods: {
close () {
this.$root.$emit('bv::hide::modal', 'armoire-empty');

View File

@@ -95,11 +95,7 @@
@click="clickDisableClasses(); close();"
>{{ $t('optOutOfClasses') }}</span>
</div>
<div
v-once
class="opt-out-description"
v-html="$t('optOutOfClassesText')"
></div>
<span class="opt-out-description">{{ $t('optOutOfClassesText') }}</span>
</div>
</div>
</div>
@@ -107,7 +103,7 @@
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
@import '~@/assets/scss/colors.scss';
.btn-primary:active {
border: 2px solid $purple-400 !important;
@@ -193,10 +189,10 @@
import Avatar from '../avatar';
import { mapState } from '@/libs/store';
import markdownDirective from '@/directives/markdown';
import warriorIcon from '@/assets/svg/warrior.svg?raw';
import rogueIcon from '@/assets/svg/rogue.svg?raw';
import healerIcon from '@/assets/svg/healer.svg?raw';
import wizardIcon from '@/assets/svg/wizard.svg?raw';
import warriorIcon from '@/assets/svg/warrior.svg';
import rogueIcon from '@/assets/svg/rogue.svg';
import healerIcon from '@/assets/svg/healer.svg';
import wizardIcon from '@/assets/svg/wizard.svg';
export default {
components: {

View File

@@ -70,7 +70,7 @@
</style>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
@import '~@/assets/scss/colors.scss';
h2 {
color: $purple-200;
@@ -100,7 +100,7 @@
</style>
<script>
import closeIcon from '@/assets/svg/close.svg?raw';
import closeIcon from '@/assets/svg/close.svg';
import Sprite from '@/components/ui/sprite.vue';
export default {

View File

@@ -45,7 +45,7 @@
</template>
<style lang="scss">
@import '@/assets/scss/mixins.scss';
@import '~@/assets/scss/mixins.scss';
#generic-achievement {
@include centeredModal();
@@ -61,7 +61,7 @@
</style>
<style scoped lang="scss">
@import '@/assets/scss/colors.scss';
@import '~@/assets/scss/colors.scss';
.content {
text-align: center;
@@ -98,7 +98,7 @@
<script>
import achievements from '@/../../common/script/content/achievements';
import { mapState } from '@/libs/store';
import svgClose from '@/assets/svg/close.svg?raw';
import svgClose from '@/assets/svg/close.svg';
import Sprite from '@/components/ui/sprite.vue';
export default {

View File

@@ -48,7 +48,7 @@
></span>
</div>
<Sprite :image-name="questClass" />
<div :class="questClass"></div>
</section>
<!-- @TODO: Keep this? .checkboxinput(type='checkbox', v-model=
'user.preferences.suppressModals.levelUp', @change='changeLevelupSuppress()')
@@ -58,7 +58,7 @@ label(style='display:inline-block') {{ $t('dontShowAgain') }}
</template>
<style lang="scss">
@import '@/assets/scss/colors.scss';
@import '~@/assets/scss/colors.scss';
#level-up {
.modal-content {
@@ -150,15 +150,18 @@ label(style='display:inline-block') {{ $t('dontShowAgain') }}
section.greyed {
padding-bottom: 17px
}
.scroll {
margin: -11px auto 0;
}
}
</style>
<script>
import Avatar from '../avatar';
import Sprite from '@/components/ui/sprite';
import { mapState } from '@/libs/store';
import starGroup from '@/assets/svg/star-group.svg?raw';
import sparkles from '@/assets/svg/sparkles-left.svg?raw';
import starGroup from '@/assets/svg/star-group.svg';
import sparkles from '@/assets/svg/sparkles-left.svg';
const levelQuests = {
15: 'atom1',
@@ -170,7 +173,6 @@ const levelQuests = {
export default {
components: {
Avatar,
Sprite,
},
data () {
return {
@@ -189,9 +191,7 @@ export default {
return this.user.stats.lvl in levelQuests;
},
questClass () {
const questKey = levelQuests[this.user.stats.lvl];
if (questKey) return `inventory_quest_scroll_${questKey}`;
return '';
return `scroll inventory_quest_scroll_${levelQuests[this.user.stats.lvl]}`;
},
},
methods: {

View File

@@ -17,7 +17,7 @@
</h2>
<img
class="onboarding-complete-banner d-block"
src="@/assets/images/onboarding-complete-banner@2x.png"
src="~@/assets/images/onboarding-complete-banner@2x.png"
>
<p
v-once
@@ -59,7 +59,7 @@
</style>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
@import '~@/assets/scss/colors.scss';
h2 {
color: $purple-200;
@@ -100,7 +100,7 @@ button {
</style>
<script>
import svgClose from '@/assets/svg/close.svg?raw';
import svgClose from '@/assets/svg/close.svg';
export default {
data () {

View File

@@ -97,9 +97,9 @@ import { mapState } from '@/libs/store';
import Sprite from '@/components/ui/sprite';
export default {
components: {
components: [
Sprite,
},
],
data () {
return {
maxHealth,

View File

@@ -55,7 +55,7 @@
<p v-html="$t('moreGearAchievements')"></p>
<br>
</div>
<Sprite image-name="shop_armoire" />
<div class="shop_armoire"></div>
<p v-html="$t('armoireUnlocked')"></p>
<br>
<button
@@ -87,13 +87,11 @@
import achievementFooter from './achievementFooter';
import achievementAvatar from './achievementAvatar';
import { mapState } from '@/libs/store';
import Sprite from '@/components/ui/sprite.vue';
export default {
components: {
achievementFooter,
achievementAvatar,
Sprite,
},
computed: {
...mapState({ user: 'user.data' }),

View File

@@ -73,7 +73,7 @@
</template>
<style lang="scss">
@import '@/assets/scss/colors.scss';
@import '~@/assets/scss/colors.scss';
#won-challenge {
.modal-body {
@@ -96,7 +96,7 @@
</style>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
@import '~@/assets/scss/colors.scss';
.purple {
color: $purple-300;
@@ -146,9 +146,9 @@
<script>
import habiticaMarkdown from 'habitica-markdown';
import closeIcon from '@/components/shared/closeIcon';
import sparkles from '@/assets/svg/star-group.svg?raw';
import gem from '@/assets/svg/gem.svg?raw';
import stars from '@/assets/svg/sparkles-left.svg?raw';
import sparkles from '@/assets/svg/star-group.svg';
import gem from '@/assets/svg/gem.svg';
import stars from '@/assets/svg/sparkles-left.svg';
import { mapState } from '@/libs/store';
export default {

View File

@@ -1,7 +1,7 @@
<template>
<div class="row standard-page col-12 d-flex justify-content-center">
<div class="admin-panel-content">
<h1>{{ $t("adminPanel") }}</h1>
<h1>Admin Panel</h1>
<form
class="form-inline"
@submit.prevent="searchUsers(userIdentifier)"
@@ -72,7 +72,7 @@ export default {
},
mounted () {
this.$store.dispatch('common:setTitle', {
section: this.$t('adminPanel'),
section: 'Admin Panel',
});
},
methods: {
@@ -92,6 +92,8 @@ export default {
params: { userIdentifier },
}).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();
}
});
@@ -99,16 +101,14 @@ export default {
async loadUser (userIdentifier) {
const id = userIdentifier || this.user._id;
if (this.$router.currentRoute.name === 'adminPanelUser') {
await this.$router.push({
name: 'adminPanel',
});
}
await this.$router.push({
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();
}
});

View File

@@ -0,0 +1,20 @@
export default {
methods: {
async saveHero ({ hero, msg = 'User', clearData }) {
await this.$store.dispatch('hall:updateHero', { heroDetails: hero });
await this.$store.dispatch('snackbars:add', {
title: '',
text: `${msg} updated`,
type: 'info',
});
if (clearData) {
// Use clearData when the saved changes may affect data in other components
// (e.g., adding a contributor tier will increase the Gem balance)
// The admin should re-fetch the data if they need to keep working on that user.
this.$emit('clear-data');
this.$router.push({ name: 'adminPanel' });
}
},
},
};

View File

@@ -7,11 +7,7 @@
>
Could not find any matching users.
</div>
<loading-spinner
v-if="isSearching"
class="mx-auto mb-2"
dark-color="true"
/>
<loading-spinner class="mx-auto mb-2" dark-color="true" v-if="isSearching" />
<div
v-if="users.length > 0"
class="list-group"
@@ -55,7 +51,7 @@
<script>
import VueRouter from 'vue-router';
import { mapState } from '@/libs/store';
import LoadingSpinner from '../../ui/loadingSpinner';
import LoadingSpinner from '../ui/loadingSpinner';
const { isNavigationFailure, NavigationFailureType } = VueRouter;
@@ -63,10 +59,6 @@ export default {
components: {
LoadingSpinner,
},
beforeRouteUpdate (to, from, next) {
this.userIdentifier = to.params.userIdentifier;
next();
},
data () {
return {
userIdentifier: '',
@@ -78,6 +70,10 @@ export default {
computed: {
...mapState({ user: 'user.data' }),
},
beforeRouteUpdate (to, from, next) {
this.userIdentifier = to.params.userIdentifier;
next();
},
watch: {
userIdentifier () {
this.isSearching = true;

View File

@@ -17,7 +17,6 @@
<li
v-for="item in achievements"
:key="item.path"
v-b-tooltip.hover="item.notes"
>
<form @submit.prevent="saveItem(item)">
<span
@@ -28,7 +27,7 @@
{{ item.value }}
</span>
:
{{ item.text || item.key }} - <i> {{ item.key }} </i>
{{ item.text || item.key }}
</span>
<div
@@ -69,7 +68,6 @@
<li
v-for="item in nestedAchievements[achievementType]"
:key="item.path"
v-b-tooltip.hover="item.notes"
>
<form @submit.prevent="saveItem(item)">
<span
@@ -80,7 +78,7 @@
{{ item.value }}
</span>
:
{{ item.text || item.key }} - <i> {{ item.key }} </i>
{{ item.text || item.key }}
</span>
<div
@@ -145,28 +143,79 @@ function getText (achievementItem) {
}
const { titleKey } = achievementItem;
if (titleKey !== undefined) {
return i18n.t(titleKey);
return i18n.t(titleKey, 'en');
}
const { singularTitleKey } = achievementItem;
if (singularTitleKey !== undefined) {
return i18n.t(singularTitleKey);
return i18n.t(singularTitleKey, 'en');
}
return achievementItem.key;
}
function getNotes (achievementItem, count) {
if (achievementItem === undefined) {
return '';
function collateItemData (self) {
const achievements = [];
const nestedAchievements = {};
const basePath = 'achievements';
const ownedAchievements = self.hero.achievements;
const allAchievements = content.achievements;
for (const key of Object.keys(ownedAchievements)) {
const value = ownedAchievements[key];
if (typeof value === 'object') {
nestedAchievements[key] = [];
for (const nestedKey of Object.keys(value)) {
const valueIsInteger = self.integerTypes.includes(key);
let text = nestedKey;
if (allAchievements[key] && allAchievements[key][nestedKey]) {
text = getText(allAchievements[key][nestedKey]);
}
nestedAchievements[key].push({
key: nestedKey,
text,
achievementType: key,
modified: false,
path: `${basePath}.${key}.${nestedKey}`,
value: value[nestedKey],
valueIsInteger,
});
}
} else {
const valueIsInteger = self.integerTypes.includes(key);
achievements.push({
key,
text: getText(allAchievements[key]),
modified: false,
path: `${basePath}.${key}`,
value: ownedAchievements[key],
valueIsInteger,
});
}
}
const { textKey } = achievementItem;
if (textKey !== undefined) {
return i18n.t(textKey, { count });
for (const key of Object.keys(allAchievements)) {
if (key !== '' && !key.endsWith('UltimateGear') && !key.endsWith('Quest')) {
if (ownedAchievements[key] === undefined) {
const valueIsInteger = self.integerTypes.includes(key);
achievements.push({
key,
text: getText(allAchievements[key]),
modified: false,
path: `${basePath}.${key}`,
value: valueIsInteger ? 0 : false,
valueIsInteger,
neverOwned: true,
});
}
}
}
const { singularTextKey } = achievementItem;
if (singularTextKey !== undefined) {
return i18n.t(singularTextKey, { count });
}
return '';
self.achievements = achievements;
self.nestedAchievements = nestedAchievements;
}
function resetData (self) {
collateItemData(self);
self.nestedAchievementKeys.forEach(itemType => { self.expandItemType[itemType] = false; });
}
export default {
@@ -192,34 +241,26 @@ export default {
},
nestedAchievementKeys: ['quests', 'ultimateGearSets'],
integerTypes: ['streak', 'perfect', 'birthday', 'habiticaDays', 'habitSurveys', 'habitBirthdays',
'valentine', 'congrats', 'shinySeed', 'goodluck', 'thankyou', 'seafoam', 'snowball', 'quests',
'rebirths', 'rebirthLevel', 'greeting', 'spookySparkles', 'nye', 'costumeContests', 'congrats',
'getwell', 'beastMasterCount', 'mountMasterCount', 'triadBingoCount',
],
cardTypes: ['greeting', 'birthday', 'valentine', 'goodluck', 'thankyou', 'greeting', 'nye',
'congrats', 'getwell'],
'valentine', 'congrats', 'shinySeed', 'goodluck', 'thankyou', 'seafoam', 'snowball', 'quests'],
achievements: [],
nestedAchievements: {},
};
},
watch: {
resetCounter () {
this.resetData();
resetData(this);
},
},
mounted () {
this.resetData();
resetData(this);
},
methods: {
async saveItem (item) {
await this.saveHero({
hero: {
_id: this.hero._id,
achievementPath: item.path,
achievementVal: item.value,
},
msg: item.path,
});
// prepare the item's new value and path for being saved
this.hero.achievementPath = item.path;
this.hero.achievementVal = item.value;
await this.saveHero({ hero: this.hero, msg: item.path });
item.modified = false;
},
enableValueChange (item) {
@@ -229,85 +270,6 @@ export default {
item.value = !item.value;
}
},
resetData () {
this.collateItemData();
this.nestedAchievementKeys.forEach(itemType => { this.expandItemType[itemType] = false; });
},
collateItemData () {
const achievements = [];
const nestedAchievements = {};
const basePath = 'achievements';
const ownedAchievements = this.hero.achievements;
const allAchievements = content.achievements;
const ownedKeys = Object.keys(ownedAchievements).sort();
for (const key of ownedKeys) {
const value = ownedAchievements[key];
let contentKey = key;
if (this.cardTypes.indexOf(key) !== -1) {
contentKey += 'Cards';
}
if (typeof value === 'object') {
nestedAchievements[key] = [];
for (const nestedKey of Object.keys(value)) {
const valueIsInteger = this.integerTypes.includes(key);
let text = nestedKey;
if (allAchievements[key] && allAchievements[key][contentKey]) {
text = getText(allAchievements[key][contentKey]);
}
let notes = '';
if (allAchievements[key] && allAchievements[key][contentKey]) {
notes = getNotes(allAchievements[key][contentKey], ownedAchievements[key]);
}
nestedAchievements[key].push({
key: nestedKey,
text,
notes,
achievementType: key,
modified: false,
path: `${basePath}.${key}.${nestedKey}`,
value: value[nestedKey],
valueIsInteger,
});
}
} else {
const valueIsInteger = this.integerTypes.includes(key);
achievements.push({
key,
text: getText(allAchievements[contentKey]),
notes: getNotes(allAchievements[contentKey], ownedAchievements[key]),
modified: false,
path: `${basePath}.${key}`,
value: ownedAchievements[key],
valueIsInteger,
});
}
}
const allKeys = Object.keys(allAchievements).sort();
for (const key of allKeys) {
if (key !== '' && !key.endsWith('UltimateGear') && !key.endsWith('Quest')) {
const ownedKey = key.replace('Cards', '');
if (ownedAchievements[ownedKey] === undefined) {
const valueIsInteger = this.integerTypes.includes(ownedKey);
achievements.push({
key: ownedKey,
text: getText(allAchievements[key]),
notes: getNotes(allAchievements[key], 0),
modified: false,
path: `${basePath}.${ownedKey}`,
value: valueIsInteger ? 0 : false,
valueIsInteger,
neverOwned: true,
});
}
}
}
this.achievements = achievements;
this.nestedAchievements = nestedAchievements;
},
},
};
</script>

View File

@@ -1,12 +1,5 @@
<template>
<form
@submit.prevent="saveHero({ hero: {
_id: hero._id,
contributor: hero.contributor,
secret: hero.secret,
permissions: hero.permissions,
}, msg: 'Contributor details', clearData: true })"
>
<form @submit.prevent="saveHero({ hero, msg: 'Contributor details', clearData: true })">
<div class="card mt-2">
<div class="card-header">
<h3
@@ -15,12 +8,6 @@
@click="expand = !expand"
>
Contributor Details
<b
v-if="hasUnsavedChanges && !expand"
class="text-warning float-right"
>
Unsaved changes
</b>
</h3>
</div>
<div
@@ -38,17 +25,12 @@
>
<div class="custom-control custom-checkbox">
<input
:id="permission.key"
v-model="hero.permissions[permission.key]"
:disabled="!hasPermission(user, permission.key)
|| (hero.permissions.fullAccess && permission.key !== 'fullAccess')"
:disabled="!hasPermission(user, permission.key)"
class="custom-control-input"
type="checkbox"
>
<label
class="custom-control-label"
:for="permission.key"
>
<label class="custom-control-label">
{{ permission.name }}<br>
<small class="text-secondary">{{ permission.description }}</small>
</label>
@@ -122,19 +104,13 @@
</div>
<div
v-if="expand"
class="card-footer d-flex align-items-center justify-content-between"
class="card-footer"
>
<input
type="submit"
value="Save"
class="btn btn-primary mt-1"
>
<b
v-if="hasUnsavedChanges"
class="text-warning float-right"
>
Unsaved changes
</b>
</div>
</div>
</form>
@@ -155,7 +131,7 @@ import markdownDirective from '@/directives/markdown';
import saveHero from '../mixins/saveHero';
import { mapState } from '@/libs/store';
import { userStateMixin } from '../../../../mixins/userState';
import { userStateMixin } from '../../../mixins/userState';
const permissionList = [
{
@@ -183,11 +159,6 @@ const permissionList = [
name: 'Challenge Admin',
description: 'Can create official habitica challenges and admin all challenges',
},
{
key: 'accessControl',
name: 'Access Control',
description: 'Can manage IP-Address, Client and E-Mail blockers',
},
{
key: 'coupons',
name: 'Coupon Creator',
@@ -219,10 +190,6 @@ export default {
type: Object,
required: true,
},
hasUnsavedChanges: {
type: Boolean,
required: true,
},
},
data () {
return {

View File

@@ -1,11 +1,5 @@
<template>
<form
@submit.prevent="saveHero({ hero: {
_id: hero._id,
auth: hero.auth,
preferences: hero.preferences,
}, msg: 'Authentication' })"
>
<form @submit.prevent="saveHero({ hero, msg: 'Authentication' })">
<div class="card mt-2">
<div class="card-header">
<h3
@@ -44,10 +38,7 @@
<strong v-else>No</strong>
</div>
</div>
<div
v-if="cronError"
class="form-group row"
>
<div v-if="cronError" class="form-group row">
<label class="col-sm-3 col-form-label">lastCron value:</label>
<strong>{{ hero.lastCron | formatDate }}</strong>
<br>
@@ -62,12 +53,12 @@
<div class="col-sm-9 col-form-label">
<strong>
{{ hero.auth.timestamps.loggedin | formatDate }}</strong>
<a
<button
class="btn btn-warning btn-sm ml-4"
@click="resetCron()"
>
Reset Cron to Yesterday
</a>
</button>
</div>
</div>
<div class="form-group row">
@@ -119,14 +110,13 @@
<div class="form-group row">
<label class="col-sm-3 col-form-label">API Token</label>
<div class="col-sm-9">
<a
href="#"
<button
value="Change API Token"
class="btn btn-danger"
@click="changeApiToken()"
>
Change API Token
</a>
</button>
<div
v-if="tokenModified"
>
@@ -278,24 +268,13 @@ export default {
return false;
},
async changeApiToken () {
await this.saveHero({
hero: {
_id: this.hero._id,
changeApiToken: true,
},
msg: 'API Token',
});
this.hero.changeApiToken = true;
await this.saveHero({ hero: this.hero, msg: 'API Token' });
this.tokenModified = true;
},
resetCron () {
this.saveHero({
hero: {
_id: this.hero._id,
resetCron: true,
},
msg: 'Last Cron',
clearData: true,
});
this.hero.resetCron = true;
this.saveHero({ hero: this.hero, msg: 'Last Cron', clearData: true });
},
},
};

View File

@@ -46,7 +46,7 @@
:
<span :class="{ ownedItem: !item.neverOwned }">{{ item.text }}</span>
</span>
- {{ itemType }}.{{ item.key }} - <i> {{ item.set }}</i>
{{ item.set }}
<div
v-if="item.modified"
@@ -232,14 +232,11 @@ export default {
},
methods: {
async saveItem (item) {
await this.saveHero({
hero: {
_id: this.hero._id,
purchasedPath: item.path,
purchasedVal: item.value,
},
msg: item.path,
});
// prepare the item's new value and path for being saved
this.hero.purchasedPath = item.path;
this.hero.purchasedVal = item.value;
await this.saveHero({ hero: this.hero, msg: item.path });
item.modified = false;
},
enableValueChange (item) {

View File

@@ -15,17 +15,10 @@
<privileges-and-gems
:hero="hero"
:reset-counter="resetCounter"
:has-unsaved-changes="hasUnsavedChanges([hero.flags, unModifiedHero.flags],
[hero.auth, unModifiedHero.auth],
[hero.balance, unModifiedHero.balance],
[hero.secret, unModifiedHero.secret])"
/>
<subscription-and-perks
:hero="hero"
:group-plans="groupPlans"
:has-unsaved-changes="hasUnsavedChanges([hero.purchased.plan,
unModifiedHero.purchased.plan])"
/>
<cron-and-auth
@@ -36,7 +29,6 @@
<user-profile
:hero="hero"
:reset-counter="resetCounter"
:has-unsaved-changes="hasUnsavedChanges([hero.profile, unModifiedHero.profile])"
/>
<party-and-quest
@@ -55,12 +47,6 @@
:preferences="hero.preferences"
/>
<stats
:hero="hero"
:has-unsaved-changes="hasUnsavedChanges([hero.stats, unModifiedHero.stats])"
:reset-counter="resetCounter"
/>
<items-owned
:hero="hero"
:reset-counter="resetCounter"
@@ -81,18 +67,8 @@
:reset-counter="resetCounter"
/>
<user-history
:hero="hero"
:reset-counter="resetCounter"
/>
<contributor-details
:hero="hero"
:has-unsaved-changes="hasUnsavedChanges(
[hero.contributor, unModifiedHero.contributor],
[hero.permissions, unModifiedHero.permissions],
[hero.secret, unModifiedHero.secret],
)"
:reset-counter="resetCounter"
@clear-data="clearData"
/>
@@ -133,7 +109,6 @@
</style>
<script>
import isEqualWith from 'lodash/isEqualWith';
import BasicDetails from './basicDetails';
import ItemsOwned from './itemsOwned';
import CronAndAuth from './cronAndAuth';
@@ -146,10 +121,8 @@ import Transactions from './transactions';
import SubscriptionAndPerks from './subscriptionAndPerks';
import CustomizationsOwned from './customizationsOwned.vue';
import Achievements from './achievements.vue';
import UserHistory from './userHistory.vue';
import Stats from './stats.vue';
import { userStateMixin } from '../../../../mixins/userState';
import { userStateMixin } from '../../../mixins/userState';
export default {
components: {
@@ -162,8 +135,6 @@ export default {
PrivilegesAndGems,
ContributorDetails,
Transactions,
UserHistory,
Stats,
SubscriptionAndPerks,
UserProfile,
Achievements,
@@ -177,10 +148,8 @@ export default {
return {
userIdentifier: '',
resetCounter: 0,
unModifiedHero: {},
hero: {},
party: {},
groupPlans: [],
hasParty: false,
partyNotExistError: false,
adminHasPrivForParty: true,
@@ -199,7 +168,6 @@ export default {
},
methods: {
clearData () {
this.unModifiedHero = {};
this.hero = {};
},
@@ -208,7 +176,6 @@ export default {
this.$emit('changeUserIdentifier', id); // change user identifier in Admin Panel's form
this.hero = await this.$store.dispatch('hall:getHero', { uuid: id });
this.unModifiedHero = JSON.parse(JSON.stringify(this.hero));
if (!this.hero.flags) {
this.hero.flags = {
@@ -239,38 +206,8 @@ export default {
}
}
if (this.hero.purchased.plan.planId === 'group_plan_auto') {
try {
this.groupPlans = await this.$store.dispatch('hall:getHeroGroupPlans', { heroId: this.hero._id });
} catch (e) {
this.groupPlans = [];
}
}
this.resetCounter += 1; // tell child components to reinstantiate from scratch
},
hasUnsavedChanges (...comparisons) {
for (const index in comparisons) {
if (index && comparisons[index]) {
const objs = comparisons[index];
const obj1 = objs[0];
const obj2 = objs[1];
if (!isEqualWith(obj1, obj2, (x, y) => {
if (typeof x === 'object' && typeof y === 'object') {
return undefined;
}
if (x === false && y === undefined) {
// Special case for checkboxes
return true;
}
return x == y; // eslint-disable-line eqeqeq
})) {
return true;
}
}
}
return false;
},
},
};
</script>

View File

@@ -269,19 +269,16 @@ export default {
methods: {
async saveItem (item) {
// prepare the item's new value and path for being saved
const toSave = {
_id: this.hero._id,
};
toSave.itemPath = item.path;
this.hero.itemPath = item.path;
if (item.value === null) {
toSave.itemVal = 'null';
this.hero.itemVal = 'null';
} else if (item.value === false) {
toSave.itemVal = 'false';
this.hero.itemVal = 'false';
} else {
toSave.itemVal = item.value;
this.hero.itemVal = item.value;
}
await this.saveHero({ hero: toSave, msg: item.key });
await this.saveHero({ hero: this.hero, msg: item.key });
item.neverOwned = false;
item.modified = false;
},

View File

@@ -31,46 +31,22 @@
v-html="questErrors"
></p>
</div>
<div v-if="userHasParty">
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Party ID
</label>
<strong class="col-sm-9 col-form-label">
{{ groupPartyData._id }}
</strong>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Estimated Member Count
</label>
<strong class="col-sm-9 col-form-label">
{{ groupPartyData.memberCount }}
</strong>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Leader
</label>
<strong class="col-sm-9 col-form-label">
<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>
</strong>
</div>
<div
class="btn btn-danger"
@click="removeFromParty()"
>
Remove from Party
</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>
<strong v-else>User is not in a party.</strong>
<div class="subsection-start">
<p v-html="questStatus"></p>
</div>
@@ -80,7 +56,6 @@
<script>
import * as quests from '@/../../common/script/content/quests';
import saveHero from '../mixins/saveHero';
function determineQuestStatus (self) {
// Quest data is in the user doc and party doc. They can be out of sync.
@@ -296,7 +271,6 @@ function resetData (self) {
}
export default {
mixins: [saveHero],
props: {
resetCounter: {
type: Number,
@@ -344,14 +318,5 @@ export default {
mounted () {
resetData(this);
},
methods: {
removeFromParty () {
this.saveHero({
hero: { _id: this.userId, removeFromParty: true },
msg: 'Removed from party',
reloadData: true,
});
},
},
};
</script>

View File

@@ -1,13 +1,5 @@
<template>
<form
@submit.prevent="saveHero({hero: {
_id: hero._id,
flags: hero.flags,
balance: hero.balance,
auth: hero.auth,
secret: hero.secret,
}, msg: 'Privileges or Gems or Moderation Notes'})"
>
<form @submit.prevent="saveHero({hero, msg: 'Privileges or Gems or Moderation Notes'})">
<div class="card mt-2">
<div class="card-header">
<h3
@@ -16,12 +8,6 @@
@click="expand = !expand"
>
Privileges, Gem Balance
<b
v-if="hasUnsavedChanges && !expand"
class="text-warning float-right"
>
Unsaved changes
</b>
</h3>
</div>
<div
@@ -131,19 +117,13 @@
</div>
<div
v-if="expand"
class="card-footer d-flex align-items-center justify-content-between"
class="card-footer"
>
<input
type="submit"
value="Save"
class="btn btn-primary mt-1"
>
<b
v-if="hasUnsavedChanges"
class="text-warning float-right"
>
Unsaved changes
</b>
</div>
</div>
</form>
@@ -189,10 +169,6 @@ export default {
type: Object,
required: true,
},
hasUnsavedChanges: {
type: Boolean,
required: true,
},
},
data () {
return {

View File

@@ -0,0 +1,268 @@
<template>
<form @submit.prevent="saveHero({ hero, msg: 'Subscription Perks' })">
<div class="card mt-2">
<div class="card-header">
<h3
class="mb-0 mt-0"
:class="{ 'open': expand }"
@click="expand = !expand"
>
Subscription, Monthly Perks
</h3>
</div>
<div
v-if="expand"
class="card-body"
>
<div v-if="hero.purchased.plan.paymentMethod">
Payment method:
<strong>{{ hero.purchased.plan.paymentMethod }}</strong>
</div>
<div v-if="hero.purchased.plan.planId">
Payment schedule ("basic-earned" is monthly):
<strong>{{ hero.purchased.plan.planId }}</strong>
</div>
<div v-if="hero.purchased.plan.planId == 'group_plan_auto'">
Group plan ID:
<strong>{{ hero.purchased.plan.owner }}</strong>
</div>
<div
v-if="hero.purchased.plan.dateCreated"
class="form-group row"
>
<label class="col-sm-3 col-form-label">
Creation date:
</label>
<div class="col-sm-9">
<div class="input-group">
<input
v-model="hero.purchased.plan.dateCreated"
class="form-control"
type="text"
>
<div class="input-group-append">
<strong class="input-group-text">
{{ dateFormat(hero.purchased.plan.dateCreated) }}
</strong>
</div>
</div>
</div>
</div>
<div
v-if="hero.purchased.plan.dateCurrentTypeCreated"
class="form-group row"
>
<label class="col-sm-3 col-form-label">
Current sub start date:
</label>
<div class="col-sm-9">
<div class="input-group">
<input
v-model="hero.purchased.plan.dateCurrentTypeCreated"
class="form-control"
type="text"
>
<div class="input-group-append">
<strong class="input-group-text">
{{ dateFormat(hero.purchased.plan.dateCurrentTypeCreated) }}
</strong>
</div>
</div>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Termination date:
</label>
<div class="col-sm-9">
<div class="input-group">
<input
v-model="hero.purchased.plan.dateTerminated"
class="form-control"
type="text"
>
<div class="input-group-append">
<strong class="input-group-text">
{{ dateFormat(hero.purchased.plan.dateTerminated) }}
</strong>
</div>
</div>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Cumulative months:
</label>
<div class="col-sm-9">
<input
v-model="hero.purchased.plan.cumulativeCount"
class="form-control"
type="number"
min="0"
step="1"
>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Received hourglass bonus:
</label>
<div class="col-sm-9">
<div class="input-group">
<input
v-model="hero.purchased.plan.hourglassPromoReceived"
class="form-control"
type="text"
>
<div class="input-group-append">
<strong class="input-group-text">
{{ dateFormat(hero.purchased.plan.hourglassPromoReceived) }}
</strong>
</div>
</div>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Mystic Hourglasses:
</label>
<div class="col-sm-9">
<input
v-model="hero.purchased.plan.consecutive.trinkets"
class="form-control"
type="number"
min="0"
step="1"
>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Gem cap increase:
</label>
<div class="col-sm-9">
<input
v-model="hero.purchased.plan.consecutive.gemCapExtra"
class="form-control"
type="number"
min="0"
max="26"
step="2"
>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Total Gem cap:
</label>
<strong class="col-sm-9 col-form-label">
{{ Number(hero.purchased.plan.consecutive.gemCapExtra) + 24 }}
</strong>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Gems bought this month:
</label>
<div class="col-sm-9">
<input
v-model="hero.purchased.plan.gemsBought"
class="form-control"
type="number"
min="0"
:max="hero.purchased.plan.consecutive.gemCapExtra + 24"
step="1"
>
</div>
</div>
<div v-if="hero.purchased.plan.extraMonths > 0">
Additional credit (applied upon cancellation):
<strong>{{ hero.purchased.plan.extraMonths }}</strong>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Mystery Items:
</label>
<div class="col-sm-9 col-form-label">
<span v-if="hero.purchased.plan.mysteryItems.length > 0">
<span
v-for="(item, index) in hero.purchased.plan.mysteryItems"
:key="index"
>
<strong v-if="index < hero.purchased.plan.mysteryItems.length - 1">
{{ item }},
</strong>
<strong v-else> {{ item }} </strong>
</span>
</span>
<span v-else>
<strong>None</strong>
</span>
</div>
</div>
</div>
<div
v-if="expand"
class="card-footer"
>
<input
type="submit"
value="Save"
class="btn btn-primary mt-1"
>
</div>
</div>
</form>
</template>
<style lang="scss" scoped>
@import '~@/assets/scss/colors.scss';
.input-group-append {
width: auto;
.input-group-text {
border-bottom-right-radius: 2px;
border-top-right-radius: 2px;
font-weight: 600;
font-size: 0.8rem;
color: $gray-200;
}
}
</style>
<script>
import moment from 'moment';
import { getPlanContext } from '@/../../common/script/cron';
import saveHero from '../mixins/saveHero';
export default {
mixins: [saveHero],
props: {
hero: {
type: Object,
required: true,
},
},
data () {
return {
expand: false,
};
},
computed: {
nextHourglassDate () {
const currentPlanContext = getPlanContext(this.hero, new Date());
if (!currentPlanContext.nextHourglassDate) return 'N/A';
return currentPlanContext.nextHourglassDate.format('MMMM YYYY');
},
},
methods: {
dateFormat (date) {
if (!date) {
return '--';
}
return moment(date).format('YYYY/MM/DD');
},
},
};
</script>

View File

@@ -22,8 +22,8 @@
</template>
<script>
import PurchaseHistoryTable from '../../../ui/purchaseHistoryTable.vue';
import { userStateMixin } from '../../../../mixins/userState';
import PurchaseHistoryTable from '../../ui/purchaseHistoryTable.vue';
import { userStateMixin } from '../../../mixins/userState';
export default {
components: {

View File

@@ -1,10 +1,5 @@
<template>
<form
@submit.prevent="saveHero({hero: {
_id: hero._id,
profile: hero.profile
}, msg: 'Users Profile'})"
>
<form @submit.prevent="saveHero({hero, msg: 'Users Profile'})">
<div class="card mt-2">
<div class="card-header">
<h3
@@ -13,12 +8,6 @@
@click="expand = !expand"
>
User Profile
<b
v-if="hasUnsavedChanges && !expand"
class="text-warning float-right"
>
Unsaved changes
</b>
</h3>
</div>
<div
@@ -62,19 +51,13 @@
</div>
<div
v-if="expand"
class="card-footer d-flex align-items-center justify-content-between"
class="card-footer"
>
<input
type="submit"
value="Save"
class="btn btn-primary mt-1"
>
<b
v-if="hasUnsavedChanges"
class="text-warning float-right"
>
Unsaved changes
</b>
</div>
</div>
</form>
@@ -92,7 +75,7 @@ import markdownDirective from '@/directives/markdown';
import saveHero from '../mixins/saveHero';
import { mapState } from '@/libs/store';
import { userStateMixin } from '../../../../mixins/userState';
import { userStateMixin } from '../../../mixins/userState';
function resetData (self) {
self.expand = false;
@@ -118,10 +101,6 @@ export default {
type: Object,
required: true,
},
hasUnsavedChanges: {
type: Boolean,
required: true,
},
},
data () {
return {

View File

@@ -1,43 +0,0 @@
import VueRouter from 'vue-router';
const { isNavigationFailure, NavigationFailureType } = VueRouter;
export default {
methods: {
async saveHero ({
hero,
msg = 'User',
clearData,
reloadData,
}) {
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' });
} else if (reloadData) {
if (this.$router.currentRoute.name === 'adminPanelUser') {
await this.$router.push({
name: 'adminPanel',
});
}
await this.$router.push({
name: 'adminPanelUser',
params: { userIdentifier: hero._id },
}).catch(failure => {
if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
this.$router.go();
}
});
}
},
},
};

View File

@@ -1,72 +0,0 @@
<template>
<div class="form-group row">
<label
class="col-sm-3 col-form-label"
:class="color"
>{{ label }}</label>
<div class="col-sm-9">
<input
:value="value"
class="form-control"
type="number"
:step="step"
:max="max"
:min="min"
@input="$emit('input', parseInt($event.target.value, 10))"
>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.about-row {
margin-left: 0px;
margin-right: 0px;
}
.red-label {
color: $red_100;
}
.blue-label {
color: $blue_100;
}
.purple-label {
color: $purple_300;
}
.yellow-label {
color: $yellow_50;
}
</style>
<script>
export default {
model: {
prop: 'value',
event: 'input',
},
props: {
label: {
type: String,
required: true,
},
color: {
type: String,
default: 'text-label',
},
value: {
type: Number,
required: true,
},
step: {
type: String,
default: 'any',
},
min: {
},
max: {
},
},
};
</script>

View File

@@ -1,319 +0,0 @@
<template>
<form @submit.prevent="submitClicked()">
<div class="card mt-2">
<div class="card-header">
<h3
class="mb-0 mt-0"
:class="{'open': expand}"
@click="expand = !expand"
>
Stats
<b
v-if="hasUnsavedChanges && !expand"
class="text-warning float-right"
>
Unsaved changes
</b>
</h3>
</div>
<div
v-if="expand"
class="card-body"
>
<stats-row
v-model="hero.stats.hp"
label="Health"
color="red-label"
:max="maxHealth"
/>
<stats-row
v-model="hero.stats.exp"
label="Experience"
color="yellow-label"
min="0"
:max="maxFieldHardCap"
/>
<stats-row
v-model="hero.stats.mp"
label="Mana"
color="blue-label"
min="0"
:max="maxFieldHardCap"
/>
<stats-row
v-model="hero.stats.lvl"
label="Level"
step="1"
min="0"
:max="maxLevelHardCap"
/>
<stats-row
v-model="hero.stats.gp"
label="Gold"
min="0"
:max="maxFieldHardCap"
/>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Selected Class</label>
<div class="col-sm-9">
<select
id="selectedClass"
v-model="hero.stats.class"
class="form-control"
:disabled="hero.stats.lvl < 10"
>
<option value="warrior">
Warrior
</option>
<option value="wizard">
Mage
</option>
<option value="healer">
Healer
</option>
<option value="rogue">
Rogue
</option>
</select>
<small>
When changing class, players usually need stat points deallocated as well.
</small>
</div>
</div>
<h3>Stat Points</h3>
<stats-row
v-model="hero.stats.points"
label="Unallocated"
min="0"
step="1"
:max="maxStatPoints"
/>
<stats-row
v-model="hero.stats.str"
label="Strength"
color="red-label"
min="0"
:max="maxStatPoints"
step="1"
/>
<stats-row
v-model="hero.stats.int"
label="Intelligence"
color="blue-label"
min="0"
:max="maxStatPoints"
step="1"
/>
<stats-row
v-model="hero.stats.per"
label="Perception"
color="purple-label"
min="0"
:max="maxStatPoints"
step="1"
/>
<stats-row
v-model="hero.stats.con"
label="Constitution"
color="yellow-label"
min="0"
:max="maxStatPoints"
step="1"
/>
<div class="form-group row">
<div class="offset-sm-3 col-sm-9">
<button
type="button"
class="btn btn-warning btn-sm"
@click="deallocateStatPoints"
>
Deallocate all stat points
</button>
</div>
</div>
<div
v-if="statPointsIncorrect"
class="form-group row"
>
<div class="offset-sm-3 col-sm-9 text-danger">
Error: Sum of stat points should equal the users level
</div>
</div>
<h3>Buffs</h3>
<stats-row
v-model="hero.stats.buffs.str"
label="Strength"
color="red-label"
min="0"
step="1"
/>
<stats-row
v-model="hero.stats.buffs.int"
label="Intelligence"
color="blue-label"
min="0"
step="1"
/>
<stats-row
v-model="hero.stats.buffs.per"
label="Perception"
color="purple-label"
min="0"
step="1"
/>
<stats-row
v-model="hero.stats.buffs.con"
label="Constitution"
color="yellow-label"
min="0"
step="1"
/>
<div class="form-group row">
<div class="offset-sm-3 col-sm-9">
<button
type="button"
class="btn btn-warning btn-sm"
@click="resetBuffs"
>
Reset Buffs
</button>
</div>
</div>
</div>
<div
v-if="expand"
class="card-footer d-flex align-items-center justify-content-between"
>
<input
type="submit"
value="Save"
class="btn btn-primary mt-1"
>
<b
v-if="hasUnsavedChanges"
class="text-warning float-right"
>
Unsaved changes
</b>
</div>
</div>
</form>
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.about-row {
margin-left: 0px;
margin-right: 0px;
}
</style>
<script>
import {
MAX_HEALTH,
MAX_STAT_POINTS,
MAX_LEVEL_HARD_CAP,
MAX_FIELD_HARD_CAP,
} from '@/../../common/script/constants';
import markdownDirective from '@/directives/markdown';
import saveHero from '../mixins/saveHero';
import { mapState } from '@/libs/store';
import { userStateMixin } from '../../../../mixins/userState';
import StatsRow from './stats-row';
function resetData (self) {
self.expand = false;
}
export default {
directives: {
markdown: markdownDirective,
},
components: {
StatsRow,
},
mixins: [
userStateMixin,
saveHero,
],
computed: {
...mapState({ user: 'user.data' }),
statPointsIncorrect () {
if (this.hero.stats.lvl >= 10) {
return (parseInt(this.hero.stats.points, 10)
+ parseInt(this.hero.stats.str, 10)
+ parseInt(this.hero.stats.int, 10)
+ parseInt(this.hero.stats.per, 10)
+ parseInt(this.hero.stats.con, 10)
) !== this.hero.stats.lvl;
}
return false;
},
},
props: {
resetCounter: {
type: Number,
required: true,
},
hero: {
type: Object,
required: true,
},
hasUnsavedChanges: {
type: Boolean,
required: true,
},
},
data () {
return {
expand: false,
maxHealth: MAX_HEALTH,
maxStatPoints: MAX_STAT_POINTS,
maxLevelHardCap: MAX_LEVEL_HARD_CAP,
maxFieldHardCap: MAX_FIELD_HARD_CAP,
};
},
watch: {
resetCounter () {
resetData(this);
},
},
mounted () {
resetData(this);
},
methods: {
submitClicked () {
if (this.statPointsIncorrect) {
return;
}
this.saveHero({
hero: {
_id: this.hero._id,
stats: this.hero.stats,
},
msg: 'Stats',
});
},
resetBuffs () {
this.hero.stats.buffs = {
str: 0,
int: 0,
per: 0,
con: 0,
};
},
deallocateStatPoints () {
this.hero.stats.points = this.hero.stats.lvl;
this.hero.stats.str = 0;
this.hero.stats.int = 0;
this.hero.stats.per = 0;
this.hero.stats.con = 0;
},
},
};
</script>

View File

@@ -1,606 +0,0 @@
<template>
<form
@submit.prevent="saveHero({ hero: {
_id: hero._id,
purchased: hero.purchased
}, msg: 'Subscription Perks' })"
>
<div class="card mt-2">
<div
class="card-header"
@click="expand = !expand"
>
<h3
class="mb-0 mt-0"
:class="{ 'open': expand }"
>
Subscription, Monthly Perks
<b
v-if="hasUnsavedChanges && !expand"
class="text-warning float-right"
>
Unsaved changes
</b>
</h3>
</div>
<div
v-if="expand"
class="card-body"
>
<div
class="form-group row"
>
<label class="col-sm-3 col-form-label">
Payment method:
</label>
<div class="col-sm-9">
<input
v-if="!isRegularPaymentMethod"
v-model="hero.purchased.plan.paymentMethod"
class="form-control"
type="text"
>
<select
v-else
v-model="hero.purchased.plan.paymentMethod"
class="form-control"
type="text"
>
<option value="groupPlan">
Group Plan
</option>
<option value="Stripe">
Stripe
</option>
<option value="Apple">
Apple
</option>
<option value="Google">
Google
</option>
<option value="Amazon Payments">
Amazon
</option>
<option value="PayPal">
PayPal
</option>
<option value="Gift">
Gift
</option>
<option value="">
Clear out
</option>
</select>
</div>
</div>
<div
class="form-group row"
>
<label class="col-sm-3 col-form-label">
Payment schedule:
</label>
<div class="col-sm-9">
<input
v-if="!isRegularPlanId"
v-model="hero.purchased.plan.planId"
class="form-control"
type="text"
>
<select
v-else
v-model="hero.purchased.plan.planId"
class="form-control"
type="text"
>
<option value="basic_earned">
Monthly recurring
</option>
<option value="basic_3mo">
3 Months recurring
</option>
<option value="basic_6mo">
6 Months recurring
</option>
<option value="basic_12mo">
12 Months recurring
</option>
<option value="group_monthly">
Group Plan (legacy)
</option>
<option value="group_plan_auto">
Group Plan (auto)
</option>
<option value="">
Clear out
</option>
</select>
</div>
</div>
<div
class="form-group row"
>
<label class="col-sm-3 col-form-label">
Customer ID:
</label>
<div class="col-sm-9">
<input
v-model="hero.purchased.plan.customerId"
class="form-control"
type="text"
>
</div>
</div>
<div
v-if="hero.purchased.plan.planId === 'group_plan_auto'"
class="form-group row"
>
<label class="col-sm-3 col-form-label">
Group Plan Memberships:
</label>
<div class="col-sm-9 col-form-label">
<loading-spinner
v-if="!groupPlans"
dark-color="true"
/>
<b
v-else-if="groupPlans.length === 0"
class="text-danger col-form-label"
>User is not part of an active group plan!</b>
<div
v-for="group in groupPlans"
v-else
:key="group._id"
class="card mb-2"
>
<div class="card-body">
<h6 class="card-title">
{{ group.name }}
<small class="float-right">{{ group._id }}</small>
</h6>
<p class="card-text">
<strong>Leader: </strong>
<a
v-if="group.leader !== hero._id"
@click="switchUser(group.leader)"
>{{ group.leader }}</a>
<strong
v-else
class="text-success"
>This user</strong>
</p>
<p class="card-text">
<strong>Members: </strong> {{ group.memberCount }}
</p>
</div>
</div>
</div>
</div>
<div
v-if="hero.purchased.plan.dateCreated"
class="form-group row"
>
<label class="col-sm-3 col-form-label">
Creation date:
</label>
<div class="col-sm-9">
<div class="input-group">
<input
v-model="hero.purchased.plan.dateCreated"
class="form-control"
type="text"
>
<div class="input-group-append">
<strong class="input-group-text">
{{ dateFormat(hero.purchased.plan.dateCreated) }}
</strong>
</div>
</div>
</div>
</div>
<div
v-if="hero.purchased.plan.dateCurrentTypeCreated"
class="form-group row"
>
<label class="col-sm-3 col-form-label">
Current sub start date:
</label>
<div class="col-sm-9">
<div class="input-group">
<input
v-model="hero.purchased.plan.dateCurrentTypeCreated"
class="form-control"
type="text"
>
<div class="input-group-append">
<strong class="input-group-text">
{{ dateFormat(hero.purchased.plan.dateCurrentTypeCreated) }}
</strong>
</div>
</div>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Termination date:
</label>
<div class="col-sm-9">
<div class="input-group">
<input
v-model="hero.purchased.plan.dateTerminated"
class="form-control"
type="text"
>
<div class="input-group-append">
<strong class="input-group-text">
{{ dateFormat(hero.purchased.plan.dateTerminated) }}
</strong>
<a
v-if="!hero.purchased.plan.dateTerminated && hero.purchased.plan.planId"
v-b-modal.sub_termination_modal
class="btn btn-danger"
href="#"
>
Terminate
</a>
</div>
</div>
<small
v-if="!hero.purchased.plan.dateTerminated
&& hero.purchased.plan.planId"
class="text-success"
>
The subscription does not have a termination date and is active.
</small>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Cumulative months:
</label>
<div class="col-sm-9">
<input
v-model="hero.purchased.plan.cumulativeCount"
class="form-control"
type="number"
min="0"
step="1"
>
<small class="text-secondary">
Cumulative subscribed months across subscription periods.
</small>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Extra months:
</label>
<div class="col-sm-9">
<div class="input-group">
<input
v-model="hero.purchased.plan.extraMonths"
class="form-control"
type="number"
min="0"
step="any"
>
<div class="input-group-append">
<a
v-if="hero.purchased.plan.dateTerminated && hero.purchased.plan.extraMonths > 0"
class="btn btn-warning"
@click="applyExtraMonths"
>
Apply Credit
</a>
</div>
</div>
<small class="text-secondary">
Additional credit that is applied if a subscription is cancelled.
</small>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Received hourglass bonus:
</label>
<div class="col-sm-9">
<div class="input-group">
<input
v-model="hero.purchased.plan.hourglassPromoReceived"
class="form-control"
type="text"
>
<div class="input-group-append">
<strong class="input-group-text">
{{ dateFormat(hero.purchased.plan.hourglassPromoReceived) }}
</strong>
</div>
</div>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Mystic Hourglasses:
</label>
<div class="col-sm-9">
<input
v-model="hero.purchased.plan.consecutive.trinkets"
class="form-control"
type="number"
min="0"
step="1"
>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Gem cap increase:
</label>
<div class="col-sm-9">
<input
v-model="hero.purchased.plan.consecutive.gemCapExtra"
class="form-control"
type="number"
min="0"
max="26"
step="2"
>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Total Gem cap:
</label>
<strong class="col-sm-9 col-form-label">
{{ Number(hero.purchased.plan.consecutive.gemCapExtra) + 24 }}
</strong>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Gems bought this month:
</label>
<div class="col-sm-9">
<input
v-model="hero.purchased.plan.gemsBought"
class="form-control"
type="number"
min="0"
:max="hero.purchased.plan.consecutive.gemCapExtra + 24"
step="1"
>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Mystery Items:
</label>
<div class="col-sm-9 col-form-label">
<span v-if="hero.purchased.plan.mysteryItems.length > 0">
<span
v-for="(item, index) in hero.purchased.plan.mysteryItems"
:key="index"
>
<strong v-if="index < hero.purchased.plan.mysteryItems.length - 1">
{{ item }},
</strong>
<strong v-else> {{ item }} </strong>
</span>
</span>
<span v-else>
<strong>None</strong>
</span>
</div>
</div>
<div
v-if="!isConvertingToGroupPlan && hero.purchased.plan.planId !== 'group_plan_auto'"
class="form-group row"
>
<div class="offset-sm-3 col-sm-9">
<button
type="button"
class="btn btn-secondary btn-sm"
@click="beginGroupPlanConvert"
>
Begin converting to group plan subscription
</button>
</div>
</div>
<div
v-if="isConvertingToGroupPlan"
class="form-group row"
>
<label class="col-sm-3 col-form-label">
Group Plan group ID:
</label>
<div class="col-sm-9">
<input
v-model="groupPlanID"
class="form-control"
type="text"
>
</div>
</div>
</div>
<div
v-if="expand"
class="card-footer d-flex align-items-center justify-content-between"
>
<input
type="submit"
value="Save"
class="btn btn-primary mt-1"
@click="saveClicked"
>
<b
v-if="hasUnsavedChanges"
class="text-warning float-right"
>
Unsaved changes
</b>
</div>
</div>
<b-modal
id="sub_termination_modal"
title="Set Termination Date"
>
<p>
You can set the sub benefit termination date to today or to the last
day of the current billing cycle. Any extra subscription credit will
then be processed and automatically added onto the selected date.
</p>
<template #modal-footer>
<div
class="mt-3 btn btn-secondary"
@click="$bvModal.hide('sub_termination_modal')"
>
Close
</div>
<div
class="mt-3 btn btn-danger"
@click="terminateSubscription()"
>
Set to Today
</div>
<div
class="mt-3 btn btn-danger"
@click="terminateSubscription(todayWithRemainingCycle)"
>
Set to {{ todayWithRemainingCycle.utc().format('MM/DD/YYYY') }}
</div>
</template>
</b-modal>
</form>
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.input-group-append {
width: auto;
.input-group-text {
border-bottom-right-radius: 2px;
border-top-right-radius: 2px;
font-weight: 600;
font-size: 0.8rem;
color: $gray-200;
}
}
</style>
<script>
import isUUID from 'validator/es/lib/isUUID';
import moment from 'moment';
import { getPlanContext } from '@/../../common/script/cron';
import subscriptionBlocks from '@/../../common/script/content/subscriptionBlocks';
import saveHero from '../mixins/saveHero';
import LoadingSpinner from '@/components/ui/loadingSpinner';
export default {
components: {
LoadingSpinner,
},
mixins: [saveHero],
props: {
hero: {
type: Object,
required: true,
},
hasUnsavedChanges: {
type: Boolean,
required: true,
},
groupPlans: {
type: Array,
default: null,
},
},
data () {
return {
expand: false,
isConvertingToGroupPlan: false,
groupPlanID: '',
subscriptionBlocks,
};
},
computed: {
nextHourglassDate () {
const currentPlanContext = getPlanContext(this.hero, new Date());
if (!currentPlanContext.nextHourglassDate) return 'N/A';
return currentPlanContext.nextHourglassDate.format('MMMM YYYY');
},
isRegularPlanId () {
return this.subscriptionBlocks[this.hero.purchased.plan.planId] !== undefined;
},
isRegularPaymentMethod () {
return [
'groupPlan',
'Group Plan',
'Stripe',
'Apple',
'Google',
'Amazon Payments',
'PayPal',
'Gift',
].includes(this.hero.purchased.plan.paymentMethod);
},
todayWithRemainingCycle () {
const now = moment();
const monthCount = subscriptionBlocks[this.hero.purchased.plan.planId].months;
const terminationDate = moment(this.hero.purchased.plan.dateCurrentTypeCreated || new Date());
while (terminationDate.isBefore(now)) {
terminationDate.add(monthCount, 'months');
}
return terminationDate;
},
},
methods: {
dateFormat (date) {
if (!date) {
return '--';
}
return moment(date).format('YYYY/MM/DD');
},
terminateSubscription (terminationDate) {
if (terminationDate) {
this.hero.purchased.plan.dateTerminated = terminationDate.utc().format();
} else {
this.hero.purchased.plan.dateTerminated = moment(new Date()).utc().format();
}
this.applyExtraMonths();
this.saveHero({ hero: this.hero, msg: 'Subscription Termination', reloadData: true });
},
applyExtraMonths () {
if (this.hero.purchased.plan.extraMonths > 0 || this.hero.purchased.plan.extraMonths !== '0') {
const date = moment(this.hero.purchased.plan.dateTerminated || new Date());
const extraMonths = Math.max(this.hero.purchased.plan.extraMonths, 0);
const extraDays = Math.ceil(30.5 * extraMonths);
this.hero.purchased.plan.dateTerminated = date.add(extraDays, 'days').utc().format();
this.hero.purchased.plan.extraMonths = 0;
}
},
beginGroupPlanConvert () {
this.isConvertingToGroupPlan = true;
this.hero.purchased.plan.owner = '';
},
saveClicked (e) {
e.preventDefault();
if (this.isConvertingToGroupPlan) {
if (!isUUID(this.groupPlanID)) {
alert('Invalid group ID');
return;
}
this.hero.purchased.plan.convertToGroupPlan = this.groupPlanID;
this.saveHero({ hero: this.hero, msg: 'Group Plan Subscription', reloadData: true });
} else {
this.saveHero({ hero: this.hero, msg: 'Subscription Perks', reloadData: true });
}
},
switchUser (id) {
if (window.confirm('Switch to this user?')) {
this.$emit('changeUserIdentifier', id);
}
},
},
};
</script>

View File

@@ -1,263 +0,0 @@
<template>
<div class="card mt-2">
<div class="card-header">
<h3
class="mb-0 mt-0"
:class="{'open': expand}"
@click="toggleHistoryOpen"
>
User History
</h3>
</div>
<div
v-if="expand"
class="card-body"
>
<div>
<div class="clearfix">
<div class="mb-4 float-left">
<button
class="page-header btn-flat tab-button textCondensed"
:class="{'active': selectedTab === 'armoire'}"
@click="selectTab('armoire')"
>
Armoire
</button>
<button
class="page-header btn-flat tab-button textCondensed"
:class="{'active': selectedTab === 'questInvites'}"
@click="selectTab('questInvites')"
>
Quest Invitations
</button>
<button
class="page-header btn-flat tab-button textCondensed"
:class="{'active': selectedTab === 'cron'}"
@click="selectTab('cron')"
>
Cron
</button>
</div>
</div>
<div class="row">
<div
v-if="selectedTab === 'armoire'"
class="col-12"
>
<table class="table">
<tr>
<th
v-once
>
{{ $t('timestamp') }}
</th>
<th v-once>
Client
</th>
<th
v-once
>
Received
</th>
</tr>
<tr
v-for="entry in armoire"
:key="entry.timestamp"
>
<td>
<span
v-b-tooltip.hover="entry.timestamp"
>{{ entry.timestamp | timeAgo }}</span>
</td>
<td>{{ entry.client }}</td>
<td>{{ entry.reward }}</td>
</tr>
</table>
</div>
<div
v-if="selectedTab === 'questInvites'"
class="col-12"
>
<table class="table">
<tr>
<th
v-once
>
{{ $t('timestamp') }}
</th>
<th v-once>
Client
</th>
<th v-once>
Quest Key
</th>
<th v-once>
Response
</th>
</tr>
<tr
v-for="entry in questInviteResponses"
:key="entry.timestamp"
>
<td>
<span
v-b-tooltip.hover="entry.timestamp"
>{{ entry.timestamp | timeAgo }}</span>
</td>
<td>{{ entry.client }}</td>
<td>{{ entry.quest }}</td>
<td>{{ questInviteResponseText(entry.response) }}</td>
</tr>
</table>
</div>
<div
v-if="selectedTab === 'cron'"
class="col-12"
>
<table class="table">
<tr>
<th
v-once
>
{{ $t('timestamp') }}
</th>
<th v-once>
Client
</th>
<th v-once>
Checkin Count
</th>
</tr>
<tr
v-for="entry in cron"
:key="entry.timestamp"
>
<td>
<span
v-b-tooltip.hover="entry.timestamp"
>{{ entry.timestamp | timeAgo }}</span>
</td>
<td>{{ entry.client }}</td>
<td>{{ entry.checkinCount }}</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.page-header.btn-flat {
background: transparent;
}
.tab-button {
height: 2rem;
font-size: 24px;
font-weight: bold;
font-stretch: condensed;
line-height: 1.33;
letter-spacing: normal;
color: $gray-10;
margin-right: 1.125rem;
padding-left: 0;
padding-right: 0;
padding-bottom: 2.5rem;
&.active, &:hover {
color: $purple-300;
box-shadow: 0px -0.25rem 0px $purple-300 inset;
outline: none;
}
}
</style>
<script>
import moment from 'moment';
import { userStateMixin } from '../../../../mixins/userState';
export default {
filters: {
timeAgo (value) {
return moment(value).fromNow();
},
},
mixins: [userStateMixin],
props: {
hero: {
type: Object,
required: true,
},
resetCounter: {
type: Number,
required: true,
},
},
data () {
return {
expand: false,
selectedTab: 'armoire',
armoire: [],
questInviteResponses: [],
cron: [],
};
},
watch: {
resetCounter () {
if (this.expand) {
this.retrieveUserHistory();
}
},
},
methods: {
selectTab (type) {
this.selectedTab = type;
},
async toggleHistoryOpen () {
this.expand = !this.expand;
if (this.expand) {
this.retrieveUserHistory();
}
},
async retrieveUserHistory () {
const history = await this.$store.dispatch('adminPanel:getUserHistory', { userIdentifier: this.hero._id });
this.armoire = history.armoire;
this.questInviteResponses = history.questInviteResponses;
this.cron = history.cron;
},
questInviteResponseText (response) {
if (response === 'accept') {
return 'Accepted';
}
if (response === 'reject') {
return 'Rejected';
}
if (response === 'leave') {
return 'Left active quest';
}
if (response === 'invite') {
return 'Accepted as owner';
}
if (response === 'abort') {
return 'Aborted by owner';
}
if (response === 'abortByLeader') {
return 'Aborted by party leader';
}
if (response === 'cancel') {
return 'Cancelled before start';
}
if (response === 'cancelByLeader') {
return 'Cancelled before start by party leader';
}
return response;
},
},
};
</script>

View File

@@ -1,133 +0,0 @@
<template>
<div style="display: contents">
<td>
<select
v-model="blocker.type"
class="form-control"
@change="onTypeChanged"
>
<option value="ipaddress">
IP-Address
</option>
<option value="client">
Client Identifier
</option>
<option value="email">
E-Mail
</option>
</select>
</td>
<td>
<select
v-model="blocker.area"
class="form-control"
>
<option value="full">
Full
</option>
</select>
</td>
<td>
<input
v-model="blocker.value"
class="form-control"
autocorrect="off"
autocapitalize="off"
:class="{ 'is-invalid input-invalid': !isValid }"
@input="validateValue"
>
</td>
<td>
<input
v-model="blocker.reason"
class="form-control"
>
</td>
<td
colspan="3"
class="text-right"
>
<button
class="btn btn-primary mr-2"
:disabled="!isValid"
:class="{ disabled: !isValid }"
@click="$emit('save', blocker)"
>
<span>Save</span>
</button>
<button
class="btn btn-danger"
@click="$emit('cancel')"
>
<span>Cancel</span>
</button>
</td>
</div>
</template>
<style lang="scss" scoped>
.btn-primary.disabled {
background: #4F2A93;
color: white;
cursor: not-allowed;
opacity: 0.5;
}
</style>
<script>
import isIP from 'validator/es/lib/isIP';
export default {
name: 'BlockerForm',
props: {
isNew: {
type: Boolean,
default: false,
},
blocker: {
type: Object,
default: () => ({
type: '',
area: '',
value: '',
reason: '',
}),
},
},
data () {
return {
isValid: false,
};
},
mounted () {
this.validateValue();
},
methods: {
onTypeChanged () {
if (this.blocker.type === 'email') {
this.blocker.area = 'full';
}
this.validateValue();
},
validateValue () {
if (this.blocker.type === 'ipaddress') {
this.validateValueAsIpAddress();
} else if (this.blocker.type === 'client') {
this.validateValueAsClient();
} else if (this.blocker.type === 'email') {
this.validateValueAsEmail();
}
},
validateValueAsEmail () {
const emailRegex = /^([a-zA-Z0-9._%+-]*)@(?:[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})?$/;
this.isValid = emailRegex.test(this.blocker.value) && this.blocker.value.length > 3;
},
validateValueAsIpAddress () {
this.isValid = isIP(this.blocker.value);
},
validateValueAsClient () {
this.isValid = this.blocker.value.length > 0;
},
},
};
</script>

View File

@@ -1,238 +0,0 @@
<template>
<div class="row standard-page col-12 d-flex justify-content-center">
<div class="blocker-content">
<h1>
Blockers
<button
class="btn btn-primary float-right"
@click="showCreateForm = true"
>
Create
</button>
</h1>
<table class="table table-bordered">
<thead>
<tr>
<th>
Type <span
id="type_tooltip"
class="info-icon"
>?</span>
<b-tooltip
target="type_tooltip"
>
<b>IP-Address</b> - Block access for a specific IP-Address
<br>
<br>
<b>Client</b> - Block access for a client based on the "x-client" header.
<br>
<br>
<b>E-Mail</b> - Blocks e-mails from being used for signup.
</b-tooltip>
</th>
<th>
Area <span
id="area_tooltip"
class="info-icon"
>?</span>
<b-tooltip
target="area_tooltip"
>
<b>Full</b> - Block access to the entire site.
<br>
<br>
<b>Payments</b> - Block access to any payment related functionality.
</b-tooltip>
</th>
<th>Value</th>
<th>Reason</th>
<th>Source</th>
<th>Created at</th>
<th class="btncol"></th>
</tr>
</thead>
<tbody>
<tr v-if="showCreateForm">
<BlockerForm
:is-new="true"
:blocker="newBlocker"
@save="createBlocker"
@cancel="showCreateForm = false"
/>
</tr>
<tr
v-for="blocker in blockers"
:key="blocker._id"
>
<BlockerForm
v-if="blocker._id === editedBlockerId"
:blocker="blocker"
@save="saveBlocker(blocker)"
@cancel="editedBlockerId = null"
/>
<template v-else>
<td>{{ getTypeName(blocker.type) }}</td>
<td>{{ getAreaName(blocker.area) }}</td>
<td>{{ blocker.value }}</td>
<td>{{ blocker.reason || "--" }}</td>
<td>{{ blocker.blockSource }}</td>
<td>{{ blocker.createdAt }}</td>
<td>
<button
class="btn btn-primary mr-2"
@click="editBlocker(blocker._id)"
>
<span
v-once
class="svg-icon icon-16"
v-html="icons.editIcon"
></span>
</button>
<button
class="btn btn-danger"
@click="deleteBlocker(blocker._id)"
>
<span
v-once
class="svg-icon icon-16"
v-html="icons.deleteIcon"
></span>
</button>
</td>
</template>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.blocker-content {
flex: 0 0 100%;
max-width: 1200px;
}
.btn {
padding: 0.4rem 0.75rem;
}
.btncol {
width: 123px;
}
td {
font-size: 1rem;
}
.info-icon {
font-size: 0.8rem;
color: $purple-400;
cursor: pointer;
margin-left: 0.5rem;
background-color: $gray-500;
padding: 0.1rem 0.3rem;
border-radius: 0.2rem;
}
.info-icon:hover {
background-color: $purple-400;
color: white;
}
</style>
<script>
import { mapState } from '@/libs/store';
import editIcon from '@/assets/svg/edit.svg?raw';
import deleteIcon from '@/assets/svg/delete.svg?raw';
import BlockerForm from './blocker_form.vue';
export default {
components: {
BlockerForm,
},
data () {
return {
showCreateForm: false,
newBlocker: {
type: '',
area: 'full',
value: '',
reason: '',
},
blockers: [],
editedBlockerId: null,
icons: Object.freeze({
editIcon,
deleteIcon,
}),
};
},
computed: {
...mapState({ user: 'user.data' }),
},
mounted () {
this.$store.dispatch('common:setTitle', {
section: this.$t('siteBlockers'),
});
this.loadBlockers();
},
methods: {
async loadBlockers () {
this.blockers = await this.$store.dispatch('blockers:getBlockers');
},
editBlocker (id) {
this.editedBlockerId = id;
},
async saveBlocker (blocker) {
await this.$store.dispatch('blockers:updateBlocker', { blocker });
this.editedBlockerId = null;
this.loadBlockers();
},
async deleteBlocker (blockerId) {
if (!window.confirm('Are you sure you want to delete this blocker?')) {
return;
}
await this.$store.dispatch('blockers:deleteBlocker', { blockerId });
this.loadBlockers();
},
async createBlocker (blocker) {
await this.$store.dispatch('blockers:createBlocker', { blocker });
this.showCreateForm = false;
this.newBlocker = {
type: '',
area: 'full',
value: '',
reason: '',
};
this.loadBlockers();
},
getTypeName (type) {
switch (type) {
case 'ipaddress':
return 'IP-Address';
case 'email':
return 'E-Mail';
case 'client':
return 'Client Identifier';
default:
return type;
}
},
getAreaName (area) {
switch (area) {
case 'full':
return 'Full';
case 'payments':
return 'Payments';
default:
return area;
}
},
},
};
</script>

View File

@@ -1,40 +0,0 @@
<template>
<div class="row">
<secondary-menu class="col-12">
<router-link
v-if="hasPermission(user, 'userSupport')"
class="nav-link"
:to="{name: 'adminPanel'}"
>
{{ $t('adminPanel') }}
</router-link>
<router-link
v-if="hasPermission(user, 'accessControl')"
class="nav-link"
:to="{name: 'blockers'}"
>
{{ $t('siteBlockers') }}
</router-link>
</secondary-menu><div class="col-12">
<router-view />
</div>
</div>
</template>
<script>
import { mapState } from '@/libs/store';
import SecondaryMenu from '@/components/secondaryMenu';
import { userStateMixin } from '../../mixins/userState';
export default {
components: {
SecondaryMenu,
},
mixins: [
userStateMixin,
],
computed: {
...mapState({ user: 'user.data' }),
},
};
</script>

View File

@@ -55,9 +55,9 @@
</li>
<li>
<a
@click="showBailey()"
>
{{ $t('oldNews') }}
href="https://habitica.fandom.com/wiki/Whats_New"
target="_blank"
>{{ $t('oldNews') }}
</a>
</li>
</ul>
@@ -80,7 +80,7 @@
</li>
<li>
<a
href="https://github.com/HabitRPG/habitica/wiki/Contributing-to-Habitica"
href="https://habitica.fandom.com/wiki/Contributing_to_Habitica"
target="_blank"
>{{ $t('companyContribute') }}
</a>
@@ -158,6 +158,13 @@
>{{ $t('guidanceForBlacksmiths') }}
</a>
</li>
<li>
<a
href="https://habitica.fandom.com/wiki/Extensions,_Add-Ons,_and_Customizations"
target="_blank"
>{{ $t('communityExtensions') }}
</a>
</li>
</ul>
</div>
@@ -276,9 +283,9 @@
</div>
<div
class="time-travel"
v-if="TIME_TRAVEL_ENABLED && user?.permissions?.fullAccess"
:key="lastTimeJump"
class="time-travel"
>
<a
class="btn btn-secondary mr-1"
@@ -299,7 +306,7 @@
@click="resetTime()"
>
Reset
</a>
</a>
</div>
<a
class="btn btn-secondary mr-1"
@@ -403,7 +410,7 @@
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
@import '~@/assets/scss/colors.scss';
.footer-row {
margin: 0;
flex: 0 1 auto;
@@ -511,7 +518,7 @@ footer {
background-color: $gray-500;
color: $gray-50;
padding: 32px 142px 40px;
a, a:not([href]) {
a {
color: $gray-50;
}
a:hover {
@@ -838,12 +845,12 @@ import moment from 'moment';
import Vue from 'vue';
// images
import melior from '@/assets/svg/melior.svg?raw';
import bluesky from '@/assets/svg/bluesky.svg?raw';
import facebook from '@/assets/svg/facebook.svg?raw';
import instagram from '@/assets/svg/instagram.svg?raw';
import tumblr from '@/assets/svg/tumblr.svg?raw';
import heart from '@/assets/svg/heart.svg?raw';
import melior from '@/assets/svg/melior.svg';
import bluesky from '@/assets/svg/bluesky.svg';
import facebook from '@/assets/svg/facebook.svg';
import instagram from '@/assets/svg/instagram.svg';
import tumblr from '@/assets/svg/tumblr.svg';
import heart from '@/assets/svg/heart.svg';
// components & modals
import { mapState } from '@/libs/store';
@@ -851,14 +858,12 @@ import buyGemsModal from './payments/buyGemsModal.vue';
import reportBug from '@/mixins/reportBug.js';
import { worldStateMixin } from '@/mixins/worldState';
const DEBUG_ENABLED = import.meta.env.DEBUG_ENABLED === 'true';
const TIME_TRAVEL_ENABLED = import.meta.env.TIME_TRAVEL_ENABLED === 'true';
const DEBUG_ENABLED = process.env.DEBUG_ENABLED === 'true'; // eslint-disable-line no-process-env
const TIME_TRAVEL_ENABLED = process.env.TIME_TRAVEL_ENABLED === 'true'; // eslint-disable-line no-process-env
let sinon;
if (import.meta.env.TIME_TRAVEL_ENABLED === 'true') {
(async () => {
sinon = await import('sinon');
})();
if (TIME_TRAVEL_ENABLED) {
// eslint-disable-next-line global-require
sinon = await import('sinon');
}
export default {
@@ -915,13 +920,13 @@ export default {
await axios.post('/api/v4/debug/set-cron', {
lastCron: date,
});
// @TODO: Notification.text('-' + numberOfDays + ' day(s), remember to refresh');
window.alert(`Days reset by ${numberOfDays}.\nRemember to refresh.`);
// @TODO: Sync user?
},
async addTenGems () {
await axios.post('/api/v4/debug/add-ten-gems');
// @TODO: Notification.text('+10 Gems!');
this.user.balance += 2.5;
window.alert('+10 Gems!');
},
async addHourglass () {
await axios.post('/api/v4/debug/add-hourglass');
@@ -946,28 +951,24 @@ export default {
},
async jumpTime (amount) {
const response = await axios.post('/api/v4/debug/jump-time', { offsetDays: amount });
setTimeout(() => {
if (amount > 0) {
Vue.config.clock.jump(amount * 24 * 60 * 60 * 1000);
} else {
Vue.config.clock.setSystemTime(moment().add(amount, 'days').toDate());
}
this.lastTimeJump = response.data.data.time;
this.triggerGetWorldState(true);
}, 1000);
if (amount > 0) {
Vue.config.clock.jump(amount * 24 * 60 * 60 * 1000);
} else {
Vue.config.clock.setSystemTime(moment().add(amount, 'days').toDate());
}
this.lastTimeJump = response.data.data.time;
this.triggerGetWorldState(true);
},
async resetTime () {
const response = await axios.post('/api/v4/debug/jump-time', { reset: true });
const time = new Date(response.data.data.time);
setTimeout(() => {
Vue.config.clock.restore();
Vue.config.clock = sinon.useFakeTimers({
now: time,
shouldAdvanceTime: true,
});
this.lastTimeJump = response.data.data.time;
this.triggerGetWorldState(true);
}, 1000);
Vue.config.clock.restore();
Vue.config.clock = sinon.useFakeTimers({
now: time,
shouldAdvanceTime: true,
});
this.lastTimeJump = response.data.data.time;
this.triggerGetWorldState(true);
},
addExp () {
// @TODO: Name these variables better
@@ -989,24 +990,22 @@ export default {
},
async addQuestProgress () {
await axios.post('/api/v4/debug/quest-progress');
// @TODO: Notification.text('Quest progress increased');
window.alert('Quest progress increased.');
// @TODO: User.sync();
},
async bossRage () {
await axios.post('/api/v4/debug/boss-rage');
window.alert('Boss Rage increased.');
},
async makeAdmin () {
await axios.post('/api/v4/debug/make-admin');
// @TODO: Notification.text('You are now an admin!
// Reload the website then go to Help > Admin Panel to set contributor level, etc.');
window.alert('You are now an Admin!\nReload the website then go to Help > Admin to set contributor level, etc.');
// @TODO: sync()
},
donate () {
this.$root.$emit('bv::show::modal', 'buy-gems', { alreadyTracked: true });
},
showBailey () {
this.$root.$emit('bv::show::modal', 'new-stuff');
},
},
};
</script>

View File

@@ -168,7 +168,7 @@
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
@import '~@/assets/scss/colors.scss';
.form {
margin: 0 auto;
@@ -227,8 +227,8 @@ import debounce from 'lodash/debounce';
import isEmail from 'validator/es/lib/isEmail';
import { MINIMUM_PASSWORD_LENGTH } from '@/../../common/script/constants';
import { setUpAxios, buildAppleAuthUrl } from '@/libs/auth';
import googleIcon from '@/assets/svg/google.svg?raw';
import appleIcon from '@/assets/svg/apple_black.svg?raw';
import googleIcon from '@/assets/svg/google.svg';
import appleIcon from '@/assets/svg/apple_black.svg';
export default {
name: 'AuthForm',
@@ -290,7 +290,7 @@ export default {
},
mounted () {
hello.init({
google: import.meta.env.GOOGLE_CLIENT_ID, // eslint-disable-line
google: process.env.GOOGLE_CLIENT_ID, // eslint-disable-line
});
},
methods: {

View File

@@ -220,6 +220,7 @@
v-if="forgotPassword"
id="forgot-form"
@submit.prevent="handleSubmit"
@keyup.enter="handleSubmit"
>
<div class="text-center">
<div>
@@ -267,11 +268,12 @@
v-if="resetPasswordSetNewOne"
id="reset-password-set-new-one-form"
@submit.prevent="handleSubmit"
@keyup.enter="handleSubmit"
>
<div class="text-center">
<div>
<div
class="svg-icon habitica-logo"
class="svg-icon habitica-logo color"
v-html="icons.habiticaIcon"
></div>
</div>
@@ -353,7 +355,7 @@
</style>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
@import '~@/assets/scss/colors.scss';
@media only screen and (min-height: 1080px) {
.bottom-wrap-register {
@@ -489,7 +491,7 @@
#top-background {
.seamless_stars_varied_opacity_repeat {
background-image: url('@/assets/images/auth/seamless_stars_varied_opacity.png');
background-image: url('~@/assets/images/auth/seamless_stars_varied_opacity.png');
background-repeat: repeat-x;
position: absolute;
height: 500px;
@@ -508,7 +510,7 @@
position: relative;
.seamless_mountains_demo_repeat {
background-image: url('@/assets/images/auth/seamless_mountains_demo.png');
background-image: url('~@/assets/images/auth/seamless_mountains_demo.png');
background-repeat: repeat-x;
width: 100%;
height: 300px;
@@ -518,7 +520,7 @@
}
.midground_foreground_extended2 {
background-image: url('@/assets/images/auth/midground_foreground_extended2.png');
background-image: url('~@/assets/images/auth/midground_foreground_extended2.png');
position: relative;
width: 1500px;
max-width: 100%;
@@ -609,11 +611,11 @@ import isEmail from 'validator/es/lib/isEmail';
import { MINIMUM_PASSWORD_LENGTH } from '@/../../common/script/constants';
import { buildAppleAuthUrl } from '../../libs/auth';
import sanitizeRedirect from '@/mixins/sanitizeRedirect';
import exclamation from '@/assets/svg/exclamation.svg?raw';
import gryphon from '@/assets/svg/gryphon.svg?raw';
import habiticaIcon from '@/assets/svg/logo-horizontal.svg?raw';
import googleIcon from '@/assets/svg/google.svg?raw';
import appleIcon from '@/assets/svg/apple_black.svg?raw';
import exclamation from '@/assets/svg/exclamation.svg';
import gryphon from '@/assets/svg/gryphon.svg';
import habiticaIcon from '@/assets/svg/logo-horizontal.svg';
import googleIcon from '@/assets/svg/google.svg';
import appleIcon from '@/assets/svg/apple_black.svg';
export default {
mixins: [sanitizeRedirect],
@@ -724,13 +726,9 @@ export default {
},
mounted () {
this.forgotPassword = this.$route.path.startsWith('/forgot-password');
if (this.forgotPassword) {
if (this.$route.query.email) {
this.username = this.$route.query.email;
}
}
hello.init({
google: import.meta.env.GOOGLE_CLIENT_ID, // eslint-disable-line
google: process.env.GOOGLE_CLIENT_ID, // eslint-disable-line
});
},
methods: {

View File

@@ -3,7 +3,7 @@
v-if="member.preferences"
class="avatar"
:style="{width, height, paddingTop}"
:class="topLevelClassList"
:class="backgroundClass"
@click.prevent="castEnd()"
>
<div
@@ -55,11 +55,7 @@
<span :class="[getGearClass('eyewear'), specialMountClass]"></span>
<span :class="[getGearClass('head'), specialMountClass]"></span>
<span :class="[getGearClass('headAccessory'), specialMountClass]"></span>
<span
:class="[
'hair_flower_' + member.preferences.hair.flower, specialMountClass
]"
></span>
<span :class="['hair_flower_' + member.preferences.hair.flower, specialMountClass]"></span>
<span
v-if="!hideGear('shield')"
:class="[getGearClass('shield'), specialMountClass]"
@@ -67,7 +63,6 @@
<span
v-if="!hideGear('weapon')"
:class="[getGearClass('weapon'), specialMountClass]"
class="weapon"
></span>
</template>
<!-- Resting-->
@@ -97,27 +92,19 @@
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
@import '~@/assets/scss/colors.scss';
.avatar {
width: 141px;
height: 147px;
image-rendering: pixelated;
position: relative;
cursor: pointer;
&.centered-avatar {
margin: 0 auto;
}
// resetting the additional padding
margin-bottom: -0.5rem !important;
}
.character-sprites {
width: 90px;
height: 90px;
display: inline-flex;
}
.character-sprites span {
@@ -136,49 +123,22 @@
.invert {
filter: invert(100%);
}
.debug {
border: 1px solid red;
.character-sprites {
border: 1px solid blue;
}
.weapon {
border: 1px solid green;
}
span {
border: 1px solid yellow;
}
}
</style>
<script>
import some from 'lodash/some';
import moment from 'moment';
import { mapState } from '@/libs/store';
import foolPet from '../mixins/foolPet';
import ClassBadge from '@/components/members/classBadge';
/**
* TODO replace avatarOnly with multiple options like
* - showMount
* - showPet
* - showBackground
* - showWeapons
*/
export default {
components: {
ClassBadge,
},
mixins: [foolPet],
props: {
debugMode: {
type: Boolean,
default: false,
},
member: {
type: Object,
required: true,
@@ -196,21 +156,14 @@ export default {
},
overrideAvatarGear: {
type: Object,
default (data) {
return data;
},
},
width: {
type: String,
default: '141px',
type: Number,
default: 140,
},
height: {
type: String,
default: '147px',
},
centerAvatar: {
type: Boolean,
default: false,
type: Number,
default: 147,
},
spritesMargin: {
type: String,
@@ -218,16 +171,11 @@ export default {
},
overrideTopPadding: {
type: String,
default: null,
},
showVisualBuffs: {
type: Boolean,
default: true,
},
showWeapon: {
type: Boolean,
default: true,
},
},
computed: {
...mapState({
@@ -256,19 +204,6 @@ export default {
return val;
},
topLevelClassList () {
const classes = [this.backgroundClass];
if (this.debugMode) {
classes.push('debug');
}
if (this.centerAvatar) {
classes.push('centered-avatar');
}
return classes.join(' ');
},
backgroundClass () {
if (this.member) {
const { background } = this.member.preferences;
@@ -321,10 +256,11 @@ export default {
return null;
},
petClass () {
const foolEvent = this.currentEventList?.find(event => moment()
.isBetween(event.start, event.end) && event.aprilFools);
if (foolEvent) {
return this.foolPet(this.member.items.currentPet, foolEvent.aprilFools);
if (some(
this.currentEventList,
event => moment().isBetween(event.start, event.end) && event.aprilFools && event.aprilFools === 'Fungi',
)) {
return this.foolPet(this.member.items.currentPet);
}
if (this.member?.items.currentPet) return `Pet-${this.member.items.currentPet}`;
return '';
@@ -354,10 +290,6 @@ export default {
},
hideGear (gearType) {
if (!this.member) return true;
if (!this.showWeapon) {
return true;
}
if (gearType === 'weapon') {
const equippedWeapon = this.member.items.gear[this.costumeClass][gearType];

View File

@@ -27,7 +27,7 @@
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
@import '~@/assets/scss/colors.scss';
.bottom-banner {
background: linear-gradient(114.26deg, $purple-300 0%, $purple-200 100%);
@@ -55,7 +55,7 @@
</style>
<script>
import sparkles from '@/assets/svg/sparkles-left.svg?raw';
import sparkles from '@/assets/svg/sparkles-left.svg';
export default {
data () {

Some files were not shown because too many files have changed in this diff Show More