Compare commits

..

17 Commits

Author SHA1 Message Date
Phillip Thelen
e10b7deb4b attempt implementing prerendering 2025-02-04 17:43:30 +01:00
Phillip Thelen
d46fa98390 tell vite to ignore browser-script url 2025-02-04 17:42:55 +01:00
Phillip Thelen
3d6afa9f11 fix setupNconf imports 2025-02-04 12:29:33 +01:00
Phillip Thelen
e7616cae8d setup nconf correctly for client build 2025-02-04 11:21:08 +01:00
Phillip Thelen
3a457f69a9 set serving url for vite content 2025-02-03 18:22:17 +01:00
Phillip Thelen
a4297283cb fix lint 2025-02-03 17:45:09 +01:00
Phillip Thelen
ca08e3ef81 fix import 2025-02-03 17:41:41 +01:00
Phillip Thelen
c582dbd169 use vitest for client spec tests 2025-02-03 17:37:11 +01:00
Phillip Thelen
56ef07c8d2 fix various import issues 2025-02-03 16:05:03 +01:00
Phillip Thelen
896836f807 eslint fixes 2025-02-03 14:02:46 +01:00
Phillip Thelen
b5458bb604 remove gulp-nodemon and update nodemon 2025-02-03 14:00:04 +01:00
Phillip Thelen
2edb255e55 move from vue-cli-service/webpack to vite 2025-01-23 15:11:10 +01:00
Rafał Jagielski
200d917582 Fix intro guide (#15247)
* Provide window.jquery to modules in vue.config.js

* Fix curly-spacing lint error
2025-01-22 14:53:24 -06:00
negue
73082a8cf0 fix toggle disable icon position 2025-01-21 22:34:34 +01:00
Kalista Payne
dd08eee20c chore(images): update css 2025-01-21 08:52:21 -06:00
negue
895241b7fa show date tooltip above system / skill messages 2025-01-20 21:08:55 +01:00
negue
2535fd7095 Combined Message Pages/Redesign (#15310)
* split component prepare new views / states

* extract empty and disabled state as components

* fix empty state mail icon

* first logic switching between modes, move page to /private-messages/index.vue

* extract autoCompleteHelper.js

* style header + start new message input

* style plus button + focus input

* state logic, types for sanity

* WIP PM new Message started

* add /members/username test

* first design changes to messageCard

* delete private message or chat - based on the mode

* copy as todo

* mention links to modal

* report chat or private message

* WIP likeButton

* likeButton styling

* hide like on private message cards

* fix unit test

* replace copy as todo - to just a copy to clipboard

* style changes

* menu position + like button width

* dropdown items background + like font

* fix like button padding

* move api endpoints and tests around to group inbox methods  + like for inbox private messages

* restyle system messages

* Dropdown Radius and Padding

* WIP system messages

* fix lint

* copy delta commit of allowing liking own private messages

* enable liking private messages

* fix menu non hovered item icon color

* fix import path

* ignore background on system messages

* requested changes + migration

* update migration to update the unique id to some messages and delete the duplicates

* migration based on users pagination

* fix(migration): use Promise.all

* change to bulkWrites per User, and all messages in one run (of a user)

* check for array

* use rest operator ...

* skip sorting to get the users

* remove migration, disable like for private messages without uniqueMessageId

* lean+bulkWrite for likes, add time checks for like and auth for further debugging

* add a limit 2 get the messages by uniqueId

* Adding a simple server start script

* remove pinned nodemon dep

* fix inbox controller/tests

* fix / requested style changes

* fix empty state padding /

* hide avatar weapons on messages - fix avatar spacing on messages

* Hourglass Simplification (#15323)

* begin removing obsolete tests

* begin refactoring

* update cron tests

* cleanup

* finish basic implementation of new logic

* add more subscription tests

* subscription test improvements

* return nextHourglassDate again

* fix gem limit

* fix(test): short circuit this.

* fix(admin): correct logic and style for shrimple subs

* WIP(frontend): draft of main subs page view

* fix hourglass count

* Fix hourglass logic for upgrades

* fix admin panel display

* WIP(subs): extant Stripe state

* fix admin panel strings

* fix missing transaction type

* add new field for cumulative subscription count

* show date for hourglass bonus if it was received

* fix test

* feat(subscription): max Gems progress readout

* fix(css): correct and refactor heights and selection states

* fix(subs): correct border-radius and redirect

* fix(stripe): correct redirect after success

* Admin panel display fixes

* don’t give additional HG for new sub if they already got one this month

* fix issue with promo hourglasses

* fix(subscription): update layout when gifting

* fix(subscriptions): more gift layout revisions

* fix(subscriptions): minor visual updates

* fix(subs): pass autoRenews through Stripe

* fix(subs): gifts DON't renew

* fix(lint): unnecessary ternary

* fix(lint): do negate object ig

* fix(subs): try again on gifts

* fix(subs): unhovery and un-12-monthy

* fix bug with incorrectly giving HG bonus

* remove only

* fix test

* fix test

* fix(subs): also redirect to subs after gift sub

* fix(subs): fix typeError

* fix(g1g1): don't try to find Gems promo during bogo

---------

Co-authored-by: Phillip Thelen <phillip@habitica.com>
Co-authored-by: Kalista Payne <sabe@habitica.com>

* chore(sprites): update subproject

* fix(layout): tighten cancellation note

* fix(subs): Google wording and HG escape

* chore(testing): fake g1g1 dates

* fix(subs): don't hide HG preview entirely

* fix(subs): center next hourglass message

* working validatedTextInput.vue within start-new-conversation-input-header.vue 🎉

* fix(git): remove changes from old develop

* Revert "fix(git): remove changes from old develop"

This reverts commit 0e30f7df00.

* fix(git): no actually just this file i guesss

* adding an empty loading state, hiding

* fought the avatar arch nemesis again

* fix chatMessages (party chat) message spacing

* move disabled text back to above the input area - re-enable input area

* show disabled private messages top panel

* fix font color

* fixing uiStates - removing disabled - moving the own user check to the last

* fix(lint): add missing prop defaults

* fix(lint): object default should be fn

* fix(chat): correct grammar in error

---------

Co-authored-by: SabreCat <sabe@habitica.com>
Co-authored-by: Kalista Payne <sabrecat@gmail.com>
Co-authored-by: Phillip Thelen <phillip@habitica.com>
2025-01-16 16:52:24 -06:00
673 changed files with 17448 additions and 24043 deletions

View File

@@ -1,12 +1,12 @@
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": true
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": true
}
}
}
]
]
]
}
}

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

View File

@@ -2,4 +2,4 @@
This webpage includes the documentation for version 3 of the [Habitica](https://habitica.com) API.
If you're developing a 3rd party tool that uses the Habitica API, read the [API Usage Guidelines](https://github.com/HabitRPG/habitica/wiki/API-Usage-Guidelines), which describe how to be a responsible user of our server resources!
If you're developing a 3rd party tool that uses the Habitica API you should read the [Guidance for Comrades](https://habitica.fandom.com/wiki/Guidance_for_Comrades) and in particular the section called [Rules for Third-Party Tools](https://habitica.fandom.com/wiki/Guidance_for_Comrades#Rules_for_Third-Party_Tools) which includes suggestions on how to best use the API and the rules to follow when interacting with it.

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

765
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.32.5",
"main": "./website/server/index.js",
"dependencies": {
"@babel/core": "^7.22.10",
@@ -50,10 +50,11 @@
"method-override": "^3.0.0",
"moment": "^2.29.4",
"moment-recur": "git://github.com/HabitRPG/moment-recur.git#d3e8e6da0806f13b74dd2e4d7d9053e6a63db119",
"mongoose": "^8.9.5",
"mongoose": "^7.8.3",
"morgan": "^1.10.0",
"nconf": "^0.12.1",
"node-gcm": "^1.0.5",
"nodemon": "^3.1.9",
"on-headers": "^1.0.2",
"passport": "^0.5.3",
"passport-facebook": "^3.0.0",
@@ -106,14 +107,14 @@
"start": "node --watch ./website/server/index.js",
"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",
"mongo:dev": "run-rs -v 5.0.23 -l ubuntu1804 --keep --dbpath mongodb-data --number 1 --quiet",
"mongo:test": "run-rs -v 5.0.23 -l ubuntu1804 --keep --dbpath mongodb-data-testing --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 () => {
@@ -61,12 +60,12 @@ describe('PUT /heroes/:heroId', () => {
expect(heroRes.profile).to.have.all.keys(['name']);
// test response values
expect(heroRes.balance).to.equal(3 + 2.5); // 3+2.5 for first contrib level
expect(heroRes.balance).to.equal(3 + 0.75); // 3+0.75 for first contrib level
expect(heroRes.contributor.level).to.equal(1);
expect(heroRes.purchased.ads).to.equal(true);
// test hero values
await hero.sync();
expect(hero.balance).to.equal(3 + 2.5); // 3+2.5 for first contrib level
expect(hero.balance).to.equal(3 + 0.75); // 3+0.75 for first contrib level
expect(hero.contributor.level).to.equal(1);
expect(hero.purchased.ads).to.equal(true);
expect(hero.auth.blocked).to.equal(prevBlockState);
@@ -137,12 +136,12 @@ describe('PUT /heroes/:heroId', () => {
expect(heroRes.profile).to.have.all.keys(['name']);
// test response values
expect(heroRes.balance).to.equal(15); // 0+15 for sixth contrib level
expect(heroRes.balance).to.equal(1); // 0+1 for sixth contrib level
expect(heroRes.contributor.level).to.equal(6);
expect(heroRes.items.pets['Dragon-Hydra']).to.equal(5);
// test hero values
await hero.sync();
expect(hero.balance).to.equal(15); // 0+15 for sixth contrib level
expect(hero.balance).to.equal(1); // 0+1 for sixth contrib level
expect(hero.contributor.level).to.equal(6);
expect(hero.items.pets['Dragon-Hydra']).to.equal(5);
});

View File

@@ -19,7 +19,7 @@ describe('GET /members/username/:username', () => {
});
});
it('returns a member\'s public data only', async () => {
it('returns a member 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 },

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

@@ -26,7 +26,7 @@ describe('POST /inbox/like-private-message/:messageId', () => {
userToSendMessage = await generateUser();
});
it('returns an error when private message is not found', async () => {
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,
@@ -35,7 +35,7 @@ describe('POST /inbox/like-private-message/:messageId', () => {
});
});
it('likes a message', async () => {
it('Likes a message', async () => {
const receiver = await generateUser();
const sentMessageResult = await userToSendMessage.post('/members/send-private-message', {
@@ -57,7 +57,7 @@ describe('POST /inbox/like-private-message/:messageId', () => {
expectMessagesLikeStatus(receiversMessages, uniqueMessageId, receiver._id, true);
});
it('allows a user to like their own private message', async () => {
it('Allows to likes their own private message', async () => {
const receiver = await generateUser();
const sentMessageResult = await userToSendMessage.post('/members/send-private-message', {
@@ -78,7 +78,7 @@ describe('POST /inbox/like-private-message/:messageId', () => {
expectMessagesLikeStatus(receiversMessages, uniqueMessageId, userToSendMessage._id, true);
});
it('unlikes a message', async () => {
it('Unlikes a message', async () => {
const receiver = await generateUser();
const sentMessageResult = await userToSendMessage.post('/members/send-private-message', {

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

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

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Habitica - Gamify Your Life</title>
<meta name="description" content="Habitica is a free habit and productivity app that treats your real life like a game. Habitica can help you achieve your goals to become healthy and happy.">
<meta name="keywords" content="Habits,Goals,Todo,Gamification,Health,Fitness,School,Work">
<link href="https://fonts.googleapis.com/css?family=Roboto+Condensed:400,400i,700,700i|Roboto:400,400i,700,700i" rel="stylesheet">
<link rel="shortcut icon" sizes="48x48" href="/static/icons/favicon.ico">
<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-static.js"></script>
</head>
<body>
<div id="loading-screen">
<svg width="80" height="80" viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg">
<path
xmlns="http://www.w3.org/2000/svg"
fill-rule="evenodd"
clip-rule="evenodd"
d="M79.05 72.15c-.8-1.766-2.643-2.62-3.845-1.766-1.201.855-2.867.985-4.448.602-1.584-.385-1.885-4.01-1.543-8.195.342-4.184.909-5.795 1.267-7.314.404-1.524 2.191-1.404 2.405-.209.215 1.196 1.454 1.196 3.266-.979 1.811-2.175 1.543-8.52-.546-13.684-2.088-5.163.817-4.661 1.66-4.149.844.513 1.362-.255 1.156-3.2-.204-2.945-2.916-5.247-5.096-6.657-2.184-1.41-4.842-2.967-4.78-6.745.063-3.777 5.2-3.658 5.897-3.596.697.063 2.037-.233 1.264-4.157-.773-3.924-3.575-4.673-5.332-4.567-1.758.106-2.943 1.071-5.427.133-2.484-.938-4.136-.572-6.45-.057-2.313.515-5.343 1.94-9.112 2.959-1.989.545-2.661.683-4.828.718-1.33.02-1.885 1.633-.106 3.61 1.408 1.608 4.597 2.036 6.515 1.768 1.236-.174 1.521.645 1.407 1.85a20.023 20.023 0 0 0-.024 4.488c.198 1.5.45 4.051-.258 5.713-.35.817-1.361 1.693-2.449 1.633-1.413-.084-2.555-1.75-3.537-3.726-2.06-4.152-4.48-5.033-13.509-8.835-8.12-3.417-12.516-8.749-15.24-12.185-2.421-3.042-4.846-1.89-4.626.855.179 2.128 1.48 9.008 4.781 13.141 4.058 6.314 10.32 9.177 17.534 9.739 1.885.149 3.065.52 3.225 1.383.236 1.835-1.557 3.11-4.898 2.722-3.341-.39-4.768.22-4.103 2.121 2.123 4.477 7.021 4.672 9.058 4.857.686.122 3.114 0 4.41.355 1.51.418 1.836 2.514-.353 3.648-3.892 1.903-5.59 3.479-7.561 7.075-1.486 2.826-2.77 7.555-1.435 14.365 1.283 6.62-8.342 6.83-12.497 5.89-1.793-.377-3.675-3.778.716-6.625 3.553-2.305 4.269-3.724 4.111-6.642-.184-3.4-2.058-3.644-2.053-6.598v-7.05c0-.602-.488-1.088-1.087-1.088h-3.334a1.087 1.087 0 0 1-1.087-1.087v-4.25c0-.602-.488-1.087-1.088-1.087h-3.317a1.087 1.087 0 0 1-1.087-1.088v-3.81c0-.602-.489-1.087-1.088-1.087h-4.04a1.087 1.087 0 0 1-1.089-1.088V26.25c0-.602-.488-1.088-1.087-1.088H1.088C.485 25.161 0 25.65 0 26.25v4.26c0 .602.488 1.087 1.088 1.087h4.049c.602 0 1.087.489 1.087 1.088v15.192c0 .602.489 1.087 1.088 1.087h4.277c.602 0 1.088.489 1.088 1.088v4.968c0 .602.488 1.087 1.087 1.087h6.005c1.836-.13 2.156 2.335 2.137 3.214-.04 2.007-2.308 2.652-3.382 3.487-2.861 2.21-5.077 4.459-3.78 8.781l.032.09c2.362 5.017 8.855 4.499 12.956 4.499h25.817c1.459 0 2.959.339 2.614-1.362-.342-1.7-1.063-4.024-3.162-4.024-2.1 0-1.758 1.166-3.81.57-2.054-.597-2.057-3.371 1.027-8.198 3.19-4.122 8.652-3.81 11.952-.895 3.301 2.915 2.325 7.978 1.633 10.885-.396 2.048.545 3.06 1.67 3.032H78.58c2.015-.035 1.62-1.391.464-4.035h.008z"
fill="#fff"
>
</path>
</svg>
</div>
<div id="app"></div>
<script type="text/javascript" src="//cloudfront.loggly.com/js/loggly.tracker-latest.min.js" async></script>
<!-- Translations -->
</body>
</html>

View File

@@ -0,0 +1,64 @@
import fs from 'fs';
import path from 'path';
import { approvedLanguages } from '../common/script/libs/i18n';
const localePath = path.join(__dirname, '../common/locales/');
const translations = {};
const momentLangs = {};
function loadFile (file) {
const contents = fs.readFileSync(file);
return JSON.parse(contents);
}
function _loadTranslations (locale) {
const files = fs.readdirSync(path.join(localePath, locale));
translations[locale] = {};
files.forEach(file => {
if (path.extname(file) !== '.json') return;
const t = loadFile(path.join(localePath, locale, file))
translations[locale] = {
...translations[locale],
...t,
}
});
}
// First fetch English strings so we can merge them with missing strings in other languages
_loadTranslations('en');
// Then load all other languages
approvedLanguages.forEach(file => {
if (file === 'en' || fs.statSync(path.join(localePath, file)).isDirectory() === false) return;
_loadTranslations(file);
// Strip empty strings, then merge missing strings from english
translations[file] = {
...translations.en,
...translations[file],
}
});
export default function localePlugin() {
const virtualModuleId = 'virtual:translations'
const resolvedVirtualModuleId = '\0' + virtualModuleId
return {
name: 'locale', // required, will show up in warnings and errors
resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId
}
},
load(id) {
if (id === resolvedVirtualModuleId) {
return `export default ${JSON.stringify(translations)}`;
}
},
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,6 @@
"postinstall": "node ./scripts/npm-postinstall.js"
},
"dependencies": {
"@froxz/vite-plugin-s3": "^1.6.0",
"@vitejs/plugin-vue2": "^2.3.3",
"@vue/test-utils": "1.0.0-beta.29",
"amplitude-js": "^8.21.3",
@@ -32,7 +31,6 @@
"intro.js": "^7.2.0",
"jquery": "^3.7.1",
"lodash": "^4.17.21",
"markdown-it": "^14.0.0",
"moment": "^2.29.4",
"nconf": "^0.12.1",
"sass": "^1.63.4",
@@ -42,7 +40,7 @@
"uuid": "^9.0.1",
"validator": "^13.9.0",
"vite": "^6.0.0",
"vite-plugin-compression2": "^1.3.3",
"vite-plugin-prerender": "^1.0.8",
"vue": "^2.7.10",
"vue-fragment": "^1.6.0",
"vue-mugen-scroll": "^0.2.6",
@@ -52,6 +50,8 @@
},
"devDependencies": {
"@babel/plugin-proposal-optional-chaining": "^7.21.0",
"@prerenderer/renderer-puppeteer": "^1.2.4",
"@prerenderer/rollup-plugin": "^0.3.12",
"@vitest/browser": "^3.0.5",
"babel-plugin-lodash": "^3.3.4",
"inspectpack": "^4.7.1",

View File

@@ -29,9 +29,7 @@
</div>
<snackbars />
<router-view v-if="!isUserLoggedIn || isStaticPage" />
<div v-else>
<user-main />
</div>
<user-main v-else />
</div>
</template>
@@ -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 = import.meta.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

@@ -316,9 +316,3 @@
line-height: 2;
padding: 2px 2px;
}
.btn-lg {
font-size: 1.25rem;
line-height: 1.5;
padding: .5rem 1rem;
}

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,3 +0,0 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M4,0C1.79,0,0,1.79,0,4v16c0,2.21,1.79,4,4,4h16c2.21,0,4-1.79,4-4V4c0-2.21-1.79-4-4-4H4ZM12,11.57c-.72-1.49-2.7-4.26-4.53-5.63-1.32-.99-3.47-1.75-3.47.68,0,.49.28,4.08.44,4.66.57,2.03,2.65,2.55,4.5,2.23-3.24.55-4.06,2.36-2.28,4.17,3.38,3.44,4.85-.86,5.23-1.97h0s0,0,0,0c.07-.2.1-.29.1-.21,0-.08.03.01.1.22h0c.38,1.1,1.85,5.41,5.23,1.97,1.78-1.81.95-3.63-2.28-4.17,1.85.31,3.93-.2,4.5-2.23.16-.58.44-4.18.44-4.66,0-2.43-2.14-1.67-3.47-.68-1.83,1.37-3.81,4.14-4.53,5.63Z" fill-rule="evenodd"/>
</svg>

Before

Width:  |  Height:  |  Size: 572 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M20,0H4A4,4,0,0,0,0,4V20a4,4,0,0,0,4,4H20a4,4,0,0,0,4-4V4A4,4,0,0,0,20,0ZM18.36,8.74c0,.14,0,.29,0,.43A9.34,9.34,0,0,1,4,17a6.85,6.85,0,0,0,.79,0,6.57,6.57,0,0,0,4.07-1.4A3.29,3.29,0,0,1,5.8,13.39a4.1,4.1,0,0,0,.62,0,3.49,3.49,0,0,0,.86-.11,3.28,3.28,0,0,1-2.63-3.22v0a3.35,3.35,0,0,0,1.48.42A3.29,3.29,0,0,1,4.67,7.76,3.22,3.22,0,0,1,5.12,6.1a9.3,9.3,0,0,0,6.76,3.43,3.67,3.67,0,0,1-.08-.75,3.28,3.28,0,0,1,5.67-2.24,6.54,6.54,0,0,0,2.08-.79,3.22,3.22,0,0,1-1.44,1.8A6.67,6.67,0,0,0,20,7.05,7.31,7.31,0,0,1,18.36,8.74Z" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 622 B

View File

@@ -25,9 +25,9 @@
<router-link to="/">
Homepage
</router-link>or
<a href="mailto:admin@habitica.com">
<router-link :to="contactUsLink">
Contact Us
</a>about the issue.
</router-link>about the issue.
</p>
</div>
</div>
@@ -40,6 +40,12 @@ import { mapState } from '@/libs/store';
export default {
computed: {
...mapState(['isUserLoggedIn']),
contactUsLink () {
if (this.isUserLoggedIn) {
return { name: 'guild', params: { groupId: 'a29da26b-37de-4a71-b0c6-48e72a900dac' } };
}
return { name: 'contact' };
},
retiredChatPage () {
return this.$route.fullPath.indexOf('/groups') !== -1;
},

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>

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()')
@@ -150,12 +150,15 @@ 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';
@@ -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

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

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

@@ -55,7 +55,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 +63,6 @@ export default {
components: {
LoadingSpinner,
},
beforeRouteUpdate (to, from, next) {
this.userIdentifier = to.params.userIdentifier;
next();
},
data () {
return {
userIdentifier: '',
@@ -78,6 +74,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
@@ -62,12 +56,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 +113,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 +271,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

@@ -37,9 +37,9 @@
<h3>{{ $t('footerCompany') }}</h3>
<ul>
<li>
<a href="mailto:admin@habitica.com">
<router-link to="/static/contact">
{{ $t('contactUs') }}
</a>
</router-link>
</li>
<li>
<router-link to="/static/press-kit">
@@ -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>
@@ -198,12 +205,12 @@
</a>
<a
class="social-circle"
href="https://bsky.app/profile/habitica.com"
href="https://twitter.com/habitica/"
target="_blank"
>
<div
class="social-icon svg-icon bluesky"
v-html="icons.bluesky"
class="social-icon svg-icon twitter"
v-html="icons.twitter"
></div>
</a>
<a
@@ -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 {
@@ -800,7 +807,7 @@ h3 {
}
}
.bluesky svg {
.twitter svg {
background-color: #e1e0e3;
fill: #878190;
height: 24px;
@@ -839,7 +846,7 @@ import Vue from 'vue';
// images
import melior from '@/assets/svg/melior.svg?raw';
import bluesky from '@/assets/svg/bluesky.svg?raw';
import twitter from '@/assets/svg/twitter.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';
@@ -851,15 +858,13 @@ 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 = import.meta.env.DEBUG_ENABLED === 'true'; // eslint-disable-line no-process-env
const TIME_TRAVEL_ENABLED = import.meta.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 {
components: {
@@ -873,7 +878,7 @@ export default {
return {
icons: Object.freeze({
melior,
bluesky,
twitter,
facebook,
instagram,
tumblr,
@@ -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
@@ -995,6 +996,7 @@ export default {
async bossRage () {
await axios.post('/api/v4/debug/boss-rage');
},
async makeAdmin () {
await axios.post('/api/v4/debug/make-admin');
// @TODO: Notification.text('You are now an admin!
@@ -1004,9 +1006,6 @@ export default {
donate () {
this.$root.$emit('bv::show::modal', 'buy-gems', { alreadyTracked: true });
},
showBailey () {
this.$root.$emit('bv::show::modal', 'new-stuff');
},
},
};
</script>

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>
@@ -724,11 +726,7 @@ 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
});

View File

@@ -1,98 +1,100 @@
<template>
<div
v-if="member.preferences"
class="avatar"
:style="{width, height, paddingTop}"
:class="topLevelClassList"
@click.prevent="castEnd()"
>
<div class="avatar-wrapper">
<div
class="character-sprites"
:style="{margin: spritesMargin}"
v-if="member.preferences"
class="avatar"
:style="{width, height, paddingTop}"
:class="topLevelClassList"
@click.prevent="castEnd()"
>
<template v-if="!avatarOnly">
<!-- Mount Body-->
<span
v-if="member.items.currentMount"
:class="'Mount_Body_' + member.items.currentMount"
></span>
</template>
<!-- Buffs that cause visual changes to avatar: Snowman, Ghost, Flower, etc-->
<template v-for="(klass, item) in visualBuffs">
<span
v-if="member.stats.buffs[item] && showVisualBuffs"
:key="item"
:class="klass"
></span>
</template>
<!-- Show flower ALL THE TIME!!!-->
<!-- See https://github.com/HabitRPG/habitica/issues/7133-->
<span :class="'hair_flower_' + member.preferences.hair.flower"></span>
<!-- Show avatar only if not currently affected by visual buff-->
<template v-if="showAvatar()">
<span :class="['chair_' + member.preferences.chair, specialMountClass]"></span>
<span :class="[getGearClass('back'), specialMountClass]"></span>
<span :class="[skinClass, specialMountClass]"></span>
<!-- eslint-disable max-len-->
<span
:class="[shirtClass, specialMountClass]"
></span>
<!-- eslint-enable max-len-->
<span :class="['head_0', specialMountClass]"></span>
<!-- eslint-disable max-len-->
<span :class="[member.preferences.size + '_' + getGearClass('armor'), specialMountClass]"></span>
<!-- eslint-enable max-len-->
<span :class="[getGearClass('back_collar'), specialMountClass]"></span>
<template
v-for="type in ['bangs', 'base', 'mustache', 'beard']"
>
<div
class="character-sprites"
:style="{margin: spritesMargin}"
>
<template v-if="!avatarOnly">
<!-- Mount Body-->
<span
:key="type"
:class="[hairClass(type), specialMountClass]"
v-if="member.items.currentMount"
:class="'Mount_Body_' + member.items.currentMount"
></span>
</template>
<span :class="[getGearClass('body'), specialMountClass]"></span>
<span :class="[getGearClass('eyewear'), specialMountClass]"></span>
<span :class="[getGearClass('head'), specialMountClass]"></span>
<span :class="[getGearClass('headAccessory'), specialMountClass]"></span>
<!-- Buffs that cause visual changes to avatar: Snowman, Ghost, Flower, etc-->
<template v-for="(klass, item) in visualBuffs">
<span
v-if="member.stats.buffs[item] && showVisualBuffs"
:key="item"
:class="klass"
></span>
</template>
<!-- Show flower ALL THE TIME!!!-->
<!-- See https://github.com/HabitRPG/habitica/issues/7133-->
<span :class="'hair_flower_' + member.preferences.hair.flower"></span>
<!-- Show avatar only if not currently affected by visual buff-->
<template v-if="showAvatar()">
<span :class="['chair_' + member.preferences.chair, specialMountClass]"></span>
<span :class="[getGearClass('back'), specialMountClass]"></span>
<span :class="[skinClass, specialMountClass]"></span>
<!-- eslint-disable max-len-->
<span
:class="[shirtClass, specialMountClass]"
></span>
<!-- eslint-enable max-len-->
<span :class="['head_0', specialMountClass]"></span>
<!-- eslint-disable max-len-->
<span :class="[member.preferences.size + '_' + getGearClass('armor'), specialMountClass]"></span>
<!-- eslint-enable max-len-->
<span :class="[getGearClass('back_collar'), specialMountClass]"></span>
<template
v-for="type in ['bangs', 'base', 'mustache', 'beard']"
>
<span
:key="type"
:class="[hairClass(type), specialMountClass]"
></span>
</template>
<span :class="[getGearClass('body'), specialMountClass]"></span>
<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
v-if="!hideGear('shield')"
:class="[getGearClass('shield'), specialMountClass]"
></span>
<span
v-if="!hideGear('weapon')"
:class="[getGearClass('weapon'), specialMountClass]"
class="weapon"
></span>
</template>
<!-- Resting-->
<span
:class="[
'hair_flower_' + member.preferences.hair.flower, specialMountClass
]"
v-if="member.preferences.sleep"
class="zzz"
></span>
<span
v-if="!hideGear('shield')"
:class="[getGearClass('shield'), specialMountClass]"
></span>
<span
v-if="!hideGear('weapon')"
:class="[getGearClass('weapon'), specialMountClass]"
class="weapon"
></span>
</template>
<!-- Resting-->
<span
v-if="member.preferences.sleep"
class="zzz"
></span>
<template v-if="!avatarOnly">
<!-- Mount Head-->
<span
v-if="member.items.currentMount"
:class="'Mount_Head_' + member.items.currentMount"
></span>
<!-- Pet-->
<span
class="current-pet"
:class="petClass"
></span>
</template>
<template v-if="!avatarOnly">
<!-- Mount Head-->
<span
v-if="member.items.currentMount"
:class="'Mount_Head_' + member.items.currentMount"
></span>
<!-- Pet-->
<span
class="current-pet"
:class="petClass"
></span>
</template>
</div>
<class-badge
v-if="hasClass && !hideClassBadge"
class="under-avatar"
:member-class="member.stats.class"
/>
</div>
<class-badge
v-if="hasClass && !hideClassBadge"
class="under-avatar"
:member-class="member.stats.class"
/>
</div>
</template>
@@ -137,6 +139,11 @@
filter: invert(100%);
}
.weapon {
// the only one that is relative so that it fits into the parent div
position: relative !important;
}
.debug {
border: 1px solid red;
@@ -155,6 +162,7 @@
</style>
<script>
import some from 'lodash/some';
import moment from 'moment';
import { mapState } from '@/libs/store';
import foolPet from '../mixins/foolPet';
@@ -202,11 +210,11 @@ export default {
},
width: {
type: String,
default: '141px',
default: '140px',
},
height: {
type: String,
default: '147px',
default: undefined,
},
centerAvatar: {
type: Boolean,
@@ -321,10 +329,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 '';

View File

@@ -30,7 +30,6 @@
<script>
import markdownDirective from '@/directives/markdown';
import { LOCALSTORAGE_AUTH_KEY } from '@/libs/auth';
const COMMUNITY_MANAGER_EMAIL = import.meta.env.EMAILS_COMMUNITY_MANAGER_EMAIL; // eslint-disable-line
@@ -40,7 +39,7 @@ export default {
},
computed: {
bannedMessage () {
const AUTH_SETTINGS = localStorage.getItem(LOCALSTORAGE_AUTH_KEY);
const AUTH_SETTINGS = localStorage.getItem('habit-mobile-settings');
const parseSettings = JSON.parse(AUTH_SETTINGS);
const userId = parseSettings ? parseSettings.auth.apiId : '';

View File

@@ -31,7 +31,6 @@
<avatar
v-if="user._id !== msg.uuid && msg.uuid !== 'system'"
class="avatar-left"
:height="null"
:class="{ invisible: avatarUnavailable(msg) }"
:member="msg.userStyles || cachedProfileData[msg.uuid] || {}"
:avatar-only="true"
@@ -51,7 +50,6 @@
v-if="user._id === msg.uuid"
:class="{ invisible: avatarUnavailable(msg) }"
:member="msg.userStyles || cachedProfileData[msg.uuid] || {}"
:height="null"
:avatar-only="true"
:hide-class-badge="true"
:override-top-padding="'14px'"
@@ -98,6 +96,11 @@
}
}
.avatar-left {
margin-left: -1.5rem;
margin-right: 2rem;
}
.hr {
width: 100%;
height: 20px;

View File

@@ -45,9 +45,6 @@
<div class="col-5">
<select-list
:items="sortOptions"
:hide-icon="false"
:inline-dropdown="false"
:direct-select="true"
:value="optionEntryBySelectedValue"
key-prop="value"
@select="changeSortOption($event)"
@@ -64,9 +61,6 @@
<select-list
:items="sortDirections"
:value="directionEntryBySelectedValue"
:hide-icon="false"
:inline-dropdown="false"
:direct-select="true"
key-prop="value"
@select="changeSortDirection($event)"
>

View File

@@ -225,10 +225,10 @@
</a>
</div>
<div class="quest-icon">
<Sprite
<div
class="quest"
:image-name="`inventory_quest_scroll_${questData.key}`"
/>
:class="`inventory_quest_scroll_${questData.key}`"
></div>
</div>
</div>
<div

View File

@@ -286,7 +286,7 @@
:to="{ name: 'adminPanelUser',
params: { userIdentifier: hero._id } }"
>
{{ $t("adminPanel") }}
admin panel
</router-link>
</span>
</td>

View File

@@ -295,6 +295,14 @@
{{ $t('help') }}
</router-link>
<div class="topbar-dropdown">
<router-link
v-if="user.permissions.fullAccess ||
user.permissions.userSupport || user.permissions.newsPoster"
class="topbar-dropdown-item dropdown-item"
:to="{name: 'adminPanel'}"
>
Admin Panel
</router-link>
<router-link
class="topbar-dropdown-item dropdown-item"
:to="{name: 'faq'}"
@@ -328,61 +336,6 @@
>{{ $t('requestFeature') }}</a>
</div>
</li>
<li
v-if="hasElevatedPrivileges"
class="topbar-item droppable"
:class="{
'active': $route.path.startsWith('/admin')}"
>
<div
class="chevron rotate"
@click="dropdownMobile($event)"
>
<div
v-once
class="chevron-icon-down"
v-html="icons.chevronDown"
></div>
</div>
<router-link
v-if="hasPermission(user, 'userSupport')"
class="nav-link"
:to="{name: 'adminPanel'}"
>
{{ $t('admin') }}
</router-link>
<a
v-else
href="#"
class="nav-link"
>
{{ $t('admin') }}
</a>
<div class="topbar-dropdown">
<router-link
v-if="hasPermission(user, 'userSupport')"
class="topbar-dropdown-item dropdown-item"
:to="{name: 'adminPanel'}"
>
{{ $t("adminPanel") }}
</router-link>
<router-link
v-if="hasPermission(user, 'accessControl')"
class="topbar-dropdown-item dropdown-item"
:to="{name: 'blockers'}"
>
{{ $t("siteBlockers") }}
</router-link>
<a
v-if="hasPermission(user, 'news')"
class="topbar-dropdown-item dropdown-item"
target="_blank"
href="https://panel.habitica.com"
>
{{ $t('newsroom') }}
</a>
</div>
</li>
</b-navbar-nav>
<div class="currency-tray form-inline">
<div
@@ -804,7 +757,6 @@ import selectUserModal from '@/components/payments/selectUserModal';
import sync from '@/mixins/sync';
import userDropdown from './userDropdown';
import reportBug from '@/mixins/reportBug.js';
import { userStateMixin } from '../../mixins/userState';
export default {
components: {
@@ -817,7 +769,7 @@ export default {
selectUserModal,
userDropdown,
},
mixins: [sync, reportBug, userStateMixin],
mixins: [sync, reportBug],
data () {
return {
isUserDropdownOpen: false,
@@ -850,12 +802,6 @@ export default {
params: { groupId: this.groupPlans[0]._id },
};
},
hasElevatedPrivileges () {
return this.user.permissions.fullAccess
|| this.user.permissions.userSupport
|| this.user.permissions.accessControl
|| this.user.permissions.news;
},
},
async mounted () {
await this.getUserGroupPlans();

View File

@@ -12,22 +12,20 @@
<strong> {{ notification.data.title }} </strong>
<span> {{ notification.data.text }} </span>
</div>
<Sprite
<div
slot="icon"
class="mt-3"
:image-name="notification.data.icon"
/>
:class="notification.data.icon"
></div>
</base-notification>
</template>
<script>
import BaseNotification from './base';
import Sprite from '@/components/ui/sprite.vue';
export default {
components: {
BaseNotification,
Sprite,
},
props: {
notification: {

View File

@@ -10,22 +10,20 @@
slot="content"
v-html="$t('newSubscriberItem')"
></div>
<Sprite
<div
slot="icon"
:image-name="mysteryClass"
/>
:class="mysteryClass"
></div>
</base-notification>
</template>
<script>
import moment from 'moment';
import BaseNotification from './base';
import Sprite from '@/components/ui/sprite.vue';
export default {
components: {
BaseNotification,
Sprite,
},
props: ['notification', 'canRemove'],
computed: {

View File

@@ -410,8 +410,7 @@ export default {
const ownedItem = this.flatGear[gearKey];
const isSearched = !searchText
|| ownedItem.text().toLowerCase().indexOf(searchText) !== -1
|| ownedItem.notes().toLowerCase().indexOf(searchText) !== -1;
|| ownedItem.text().toLowerCase().indexOf(searchText) !== -1;
if (ownedItem.klass !== 'base' && isSearched) {
const { type } = ownedItem;

View File

@@ -343,10 +343,9 @@ export default {
if (itemQuantity > 0 && isAllowed) {
const item = contentItems[itemKey];
const isSearched = !searchText
|| item.text().toLowerCase().indexOf(searchText) !== -1
|| item.notes().toLowerCase().indexOf(searchText) !== -1;
const isSearched = !searchText || item.text()
.toLowerCase()
.indexOf(searchText) !== -1;
if (isSearched && item) {
itemsArray.push({
...item,

View File

@@ -628,8 +628,6 @@ export default {
animals.push({
key: specialKey,
eggKey,
eggName: text(),
mountName: text(),
potionKey,
name: text(),
canFind,
@@ -660,6 +658,7 @@ export default {
}
this.cachedAnimalList[key] = animals;
return animals;
},
listAnimals (animalGroup, type, hideMissing, sort, searchText) {
@@ -672,9 +671,7 @@ export default {
}
if (searchText && searchText !== '') {
animals = _filter(animals, a => a.name.toLowerCase().indexOf(searchText) !== -1
|| a.eggName.toLowerCase().indexOf(searchText) !== -1
|| a.mountName.toLowerCase().indexOf(searchText) !== -1);
animals = _filter(animals, a => a.name.toLowerCase().indexOf(searchText) !== -1);
}
// 2. Sort
@@ -697,6 +694,7 @@ export default {
}
this.viewOptions[animalGroup.key].animalCount = animals.length;
return animals;
},
countOwnedAnimals (animalGroup, type) {

View File

@@ -14,7 +14,7 @@
<div class="pet-background">
<Sprite
class="mount"
:image-name="`stable_Mount_Icon_${mount.key}`"
:image-name="`Mount_Icon_${mount.key}`"
/>
</div>
<h4 class="title">
@@ -66,7 +66,6 @@
.mount {
margin: 0 auto;
margin-top: 22px;
}
.dialog-header {

View File

@@ -114,6 +114,7 @@
</style>
<script>
import some from 'lodash/some';
import moment from 'moment';
import { v4 as uuid } from 'uuid';
import { mapState } from '@/libs/store';
@@ -182,12 +183,13 @@ export default {
return 'GreyedOut';
},
imageName () {
const foolEvent = this.currentEventList?.find(event => moment()
.isBetween(event.start, event.end) && event.aprilFools);
if (this.isOwned() && foolEvent) {
if (this.isSpecial()) return `stable_${this.foolPet(this.item.key, foolEvent.aprilFools)}`;
if (this.isOwned() && some(
this.currentEventList,
event => moment().isBetween(event.start, event.end) && event.aprilFools && event.aprilFools === 'Fungi',
)) {
if (this.isSpecial()) return `stable_${this.foolPet(this.item.key)}`;
const petString = `${this.item.eggKey}-${this.item.key}`;
return `stable_${this.foolPet(petString, foolEvent.aprilFools)}`;
return `stable_${this.foolPet(petString)}`;
}
if (this.isOwned() || (this.mountOwned() && this.isHatchable())) {

View File

@@ -15,7 +15,7 @@
<div
class="svg-icon mr-1"
:title="$t('liked')"
v-html="likedIcon"
v-html="icons.liked"
></div>
+{{ likeCount }}
</div>
@@ -47,7 +47,7 @@
width: 16px;
}
&.isLiked.currentUserLiked {
&.isLiked {
color: $purple-200;
font-weight: bold;
@@ -95,11 +95,7 @@ export default {
}),
};
},
computed: {
likedIcon () {
return this.likedByCurrentUser ? this.icons.liked : this.icons.like;
},
},
methods: {
async like () {
this.$emit('toggle-like');

View File

@@ -39,9 +39,8 @@
class="avatar-left"
:member="conversationOpponentUser"
:avatar-only="true"
:show-weapon="true"
:show-weapon="false"
:debug-mode="false"
:height="null"
:override-top-padding="'0'"
:hide-class-badge="true"
@click.native="showMemberModal(msg.uuid)"
@@ -60,9 +59,8 @@
v-if="user && user._id === msg.uuid"
class="avatar-right"
:member="user"
:height="null"
:avatar-only="true"
:show-weapon="true"
:show-weapon="false"
:debug-mode="false"
:hide-class-badge="true"
:override-top-padding="'0'"
@@ -91,6 +89,19 @@
}
}
.avatar-left {
margin-right: 1.5rem;
}
.avatar-right {
overflow: clip;
margin-left: 1.5rem;
::v-deep .character-sprites {
margin-right: 1rem !important;
}
}
.card {
border: 0px;
margin-bottom: 1rem;

View File

@@ -692,7 +692,7 @@
<div class="form-inline clearfix">
<Sprite
class="pull-left"
:image-name="'inventory_quest_scroll_' + item.key"
:class="'inventory_quest_scroll_' + item.key"
style="margin-right: 10px"
/>
<p>{{ item.text() }}</p>

View File

@@ -81,10 +81,9 @@ import moment from 'moment';
import habiticaMarkdown from 'habitica-markdown';
import { mapState } from '@/libs/store';
import seasonalNPC from '@/mixins/seasonalNPC';
import { userStateMixin } from '../../mixins/userState';
export default {
mixins: [seasonalNPC, userStateMixin],
mixins: [seasonalNPC],
data () {
return {
posts: [],
@@ -108,7 +107,7 @@ export default {
if (lastPublishedPost) this.posts.push(lastPublishedPost);
// If the user is authorized, show any draft
if (this.user && (this.hasPermission(this.user, 'news'))) {
if (this.user && this.user.contributor.newsPoster) {
this.posts.unshift(
...postsFromServer
.filter(p => !p.published || moment().isBefore(p.publishDate)),

View File

@@ -872,6 +872,7 @@ export default {
purchasedPlanIdInfo () {
if (!this.subscriptionBlocks[this.user.purchased.plan.planId]) {
// @TODO: find which subs are in the common
// console.log(this.subscriptionBlocks
// [this.user.purchased.plan.planId]); // eslint-disable-line
return {
price: 0,

View File

@@ -3,7 +3,6 @@
<div
v-for="currency of currencies"
:key="currency.key"
:needed-currency-only="neededCurrencyOnly"
class="d-flex align-items-center"
>
<div
@@ -55,9 +54,6 @@ export default {
amountNeeded: {
type: Number,
},
neededCurrencyOnly: {
type: Boolean,
},
},
data () {
return {
@@ -70,34 +66,34 @@ export default {
},
computed: {
currencies () {
const currencies = [{
const currencies = [];
currencies.push({
type: 'hourglasses',
icon: this.icons.hourglasses,
value: this.userHourglasses,
},
});
{
currencies.push({
type: 'gems',
icon: this.icons.gem,
value: this.userGems,
},
});
{
currencies.push({
type: 'gold',
icon: this.icons.gold,
value: this.userGold,
}];
});
for (const currency of currencies) {
if (currency.type === this.currencyNeeded
&& !this.enoughCurrency(this.currencyNeeded, this.amountNeeded)
if (
currency.type === this.currencyNeeded
&& !this.enoughCurrency(this.currencyNeeded, this.amountNeeded)
) {
currency.notEnough = true;
}
}
if (this.neededCurrencyOnly) {
return currencies.filter(curr => curr.type === this.currencyNeeded);
}
return currencies;
},
},

View File

@@ -19,7 +19,7 @@ export const QuestHelperMixin = {
case 'quests':
return `inventory_quest_scroll_${drop.key}`;
case 'mounts':
return `stable_Mount_Icon_${drop.key}`;
return `Mount_Icon_${drop.key}`;
case 'pets':
return `stable_Pet-${drop.key}`;
default:

View File

@@ -41,58 +41,30 @@
<div class="standard-page">
<div class="featuredItems">
<div
v-if="isSubscribed || (hasTrinket && !isSubscribed)"
class="background"
:class="{'background-closed': closed, 'background-open': !closed }"
:style="{'background-image': imageURLs.background}"
>
<div
class="npc"
:class="{'closed': closed }"
:style="{'background-image': imageURLs.npc}"
>
<div class="featured-label">
<span class="rectangle"></span>
<span
<span class="rectangle"></span><span
v-once
class="text"
>
{{ $t('timeTravelers') }}
</span>
<span class="rectangle"></span>
>{{ $t('timeTravelers') }}</span><span class="rectangle"></span>
</div>
</div>
</div>
<div class="content">
<div
class="background"
:style="{'background-image': imageURLs.background}"
</div><div
v-if="closed"
class="content"
>
<div
class="npc"
:style="{'background-image': imageURLs.npc}"
>
<div class="featured-label">
<span class="rectangle"></span>
<span
v-once
class="text"
>
{{ $t('timeTravelers') }}
</span>
<span class="rectangle"></span>
</div>
</div>
<div
v-if="!isSubscribed && !hasTrinket"
class="shop-message featured-label with-border closed"
>
<span class="rectangle"></span>
<span
<div class="featured-label with-border closed">
<span class="rectangle"></span><span
v-once
class="text"
>
{{ $t('timeTravelersPopoverNoSubMobile') }}
</span>
<span class="rectangle"></span>
>{{ $t('timeTravelersPopoverNoSubMobile') }}</span><span class="rectangle"></span>
</div>
</div>
</div>
@@ -154,8 +126,7 @@
</template>
</itemRows>
</div>
</div>
<buyQuestModal
</div><buyQuestModal
:item="selectedItemToBuy || {}"
:price-type="selectedItemToBuy ? selectedItemToBuy.currency : ''"
:with-pin="true"
@@ -192,7 +163,6 @@ import _throttle from 'lodash/throttle';
import _groupBy from 'lodash/groupBy';
import _map from 'lodash/map';
import _find from 'lodash/find';
import moment from 'moment';
import isPinned from '@/../../common/script/libs/isPinned';
import shops from '@/../../common/script/libs/shops';
import { mapState } from '@/libs/store';
@@ -263,18 +233,15 @@ export default {
userItems: 'user.data.items',
currentEventList: 'worldState.data.currentEventList',
}),
isSubscribed () {
const now = new Date();
const { plan } = this.user.purchased;
return plan && plan.customerId
&& (!plan.dateTerminated || moment(plan.dateTerminated).isAfter(now));
},
hasTrinket () {
return this.user.purchased.plan.consecutive.trinkets > 0;
closed () {
return this.user.purchased.plan.consecutive.trinkets === 0;
},
shop () {
return shops.getTimeTravelersShop(this.user);
},
categories () {
const apiCategories = this.shop.categories;
@@ -335,8 +302,10 @@ export default {
}
});
this.currentEvent = _find(this.currentEventList, event => Boolean(['winter', 'spring', 'summer', 'fall'].includes(event.season)));
if (!this.currentEvent || !this.currentEvent.season || this.currentEvent.season === 'thanksgiving') {
if (!this.currentEvent || !this.currentEvent.season || this.currentEvent.season === 'thanksgiving' || this.closed) {
this.imageURLs.background = 'url(/static/npc/normal/time_travelers_background.png)';
this.imageURLs.npc = this.closed ? 'url(/static/npc/normal/time_travelers_closed_banner.png)'
: 'url(/static/npc/normal/time_travelers_open_banner.png)';
} else {
this.imageURLs.background = `url(/static/npc/${this.currentEvent.season}/time_travelers_background.png)`;
this.imageURLs.npc = `url(/static/npc/${this.currentEvent.season}/time_travelers_open_banner.png)`;

View File

@@ -31,6 +31,13 @@
&colon;&nbsp;
<a href="mailto:admin@habitica.com">admin&commat;habitica&period;com</a>
<br>
{{ $t('generalQuestionsSite') }}
&colon;&nbsp;
<a
target="_blank"
@click.prevent="openBugReportModal(true)"
> {{ $t('askQuestion') }}</a>
<br>
{{ $t('businessInquiries') }}
&colon;&nbsp;
<a href="mailto:admin@habitica.com">admin@habitica.com</a>
@@ -47,8 +54,10 @@
<script>
import { mapState } from '@/libs/store';
import { goToModForm } from '@/libs/modform';
import reportBug from '@/mixins/reportBug.js';
export default {
mixins: [reportBug],
computed: {
...mapState({
user: 'user.data',

View File

@@ -6,171 +6,127 @@
</div>
</div>
<div class="row">
<div class="col-12 mb-5 mb-md-0">
<img
:src="makeUrl('features_taskboard.png')"
class="img-fluid"
>
<div class="col-md-6">
<img src="@/assets/images/marketing/screenshot.png">
<h2>{{ $t('marketing1Lead1Title') }}</h2>
<div class="row justify-content-md-center">
<p class="col col-lg-8 col-xl-6 margin-auto description">
{{ $t('marketing1Lead1') }}
</p>
</div>
<p>{{ $t('marketing1Lead1') }}</p>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-5 mb-md-0">
<img :src="makeUrl('features_gear.png')">
<div class="col-md-6">
<img src="@/assets/images/marketing/gear.png">
<h2>{{ $t('marketing1Lead2Title') }}</h2>
<p class="description">
{{ $t('marketing1Lead2') }}
</p>
</div>
<div class="col-md-6 mb-5 mb-md-0">
<img :src="makeUrl('features_items.png')">
<p>{{ $t('marketing1Lead2') }}</p>
<img
src="@/assets/images/marketing/drops.png"
style="max-height: 200px;"
>
<h2>{{ $t('marketing1Lead3Title') }}</h2>
<p class="description">
{{ $t('marketing1Lead3') }}
</p>
<p>{{ $t('marketing1Lead3') }}</p>
</div>
</div>
<hr>
<div class="row">
<div class="col-12">
<div class="col-md-12">
<h1>{{ $t('marketing2Header') }}</h1>
</div>
</div>
<div class="row mb-5">
<div class="col-12">
<h2>{{ $t('marketing2Lead1Title') }}</h2>
<p class="description">
{{ $t('marketing2Lead1') }}
</p>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-5 mb-md-0">
<img :src="makeUrl('features_monsters.png')">
<div class="col-md-6">
<h2>{{ $t('marketing2Lead1Title') }}</h2>
<p>{{ $t('marketing2Lead1') }}</p>
<img src="@/assets/images/marketing/vice3.png">
<h2>{{ $t('marketing2Lead2Title') }}</h2>
<p
v-markdown="$t('marketing2Lead2')"
class="description"
></p>
<p v-markdown="$t('marketing2Lead2')"></p>
</div>
<div class="col-md-6 mb-5 mb-md-0">
<img :src="makeUrl('features_challenges.png')">
<div class="col-md-6">
<img src="@/assets/images/marketing/challenge.png">
<h2>{{ $t('marketing2Lead3Title') }}</h2>
<p class="description">
{{ $t('marketing2Lead3') }}
</p>
<p>{{ $t('marketing2Lead3') }}</p>
</div>
</div>
<hr>
<div class="row">
<div class="col-12">
<div class="col-md-12">
<h1>{{ $t('marketing3Header') }}</h1>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-5 mb-md-0">
<img :src="makeUrl('features_mobile.png')">
<h2>{{ $t('marketing3Lead1Title') }}</h2>
<p
v-markdown="$t('marketing3Lead1')"
class="description"
></p>
<div class="col-md-6">
<img src="@/assets/images/marketing/android_iphone.png">
<h2>{{ $t('footerMobile') }}</h2>
<p v-markdown="$t('marketing3Lead1')"></p>
</div>
<div class="col-md-6 mb-5 mb-md-0">
<img :src="makeUrl('features_opensource.png')">
<div class="col-md-6">
<img src="@/assets/images/marketing/integration.png">
<h2>{{ $t('marketing3Lead2Title') }}</h2>
<p
v-markdown="$t('marketing3Lead2')"
class="description"
></p>
<p v-markdown="$t('marketing3Lead2')"></p>
</div>
</div>
<hr>
<div class="row">
<div class="col-12">
<div class="col-md-12">
<h1>{{ $t('marketing4Header') }}</h1>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-5 mb-md-0">
<div class="col-md-6">
<div class="media">
<img src="@/assets/images/marketing/education.png">
<div class="media-body">
<h2>{{ $t('marketing4Lead1Title') }}</h2>
<p class="description">
{{ $t('marketing4Lead1') }}
</p>
<p>{{ $t('marketing4Lead1') }}</p>
</div>
</div>
</div>
<div class="col-md-6 mb-5 mb-md-0">
<div class="col-md-6">
<div class="media">
<img src="@/assets/images/marketing/wellness.png">
<div class="media-body">
<h2>{{ $t('marketing4Lead2Title') }}</h2>
<p class="description">
{{ $t('marketing4Lead2') }}
</p>
<p>{{ $t('marketing4Lead2') }}</p>
</div>
</div>
</div>
</div>
<div class="row mt-5">
<div class="col-md-6 offset-md-3 mb-5 mb-md-0">
<h1>{{ $t('marketing4Lead3Title') }}</h1>
<img :src="makeUrl('features_gamify.png')">
<div class="cta-container">
<h2>{{ $t('marketing4Lead3-1') }}</h2>
<div class="row">
<div class="col-md-6 offset-md-3">
<h2>{{ $t('marketing4Lead3Title') }}</h2>
<img src="@/assets/images/marketing/lefnire.png">
<p class="span">
<span>{{ $t('marketing4Lead3-1') }}</span>
<button
class="btn btn-lg btn-primary"
class="btn btn-primary"
@click="playButtonClick()"
>
{{ $t('marketing4Lead3Button') }}
{{ $t('playButton') }}
</button>
</div>
</p>
<p class="span">
<span>{{ $t('marketing4Lead3-2') }}</span>
<a
class="btn btn-primary"
href="/static/plans"
target="_blank"
>{{ $t('contactUs') }}</a>
</p>
</div>
</div>
</div>
</template>
<style lang='scss' scoped>
.btn {
margin-left: 1em;
}
img {
max-width: 350px;
box-shadow: 0 0 10px 5px #888;
margin: 0.5em;
max-width: 500px;
@media (max-width: 1200px) {
width: 100%;
max-width: 100%;
}
}
.img-fluid {
width: 100%;
max-width: 900px;
}
.cta-container {
text-align: center;
margin: 2em auto;
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.5rem;
}
.description {
max-width: 600px;
margin: auto;
font-size: 1.1rem;
line-height: 1.6rem;
}
</style>
<script>
@@ -189,9 +145,6 @@ export default {
playButtonClick () {
this.$router.push('/register');
},
makeUrl (filename) {
return `https://habitica-assets.s3.amazonaws.com/assets/${filename}`;
},
},
};
</script>

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