Compare commits

..

31 Commits

Author SHA1 Message Date
Hafiz
cd06148422 lint fix 2025-08-12 12:28:10 -05:00
Hafiz
a0b179561b Update ToS error message
- Updated account suspension message from "This account, User ID..." to "Your account @[username] has been
  blocked..."
- Modified server auth middleware to pass username parameter when throwing account suspended error
-Modified auth utils loginRes function to include username in suspended account error
- Updated client bannedAccountModal component to pass username (empty string if unavailable)
- Updated login test to expect username in account suspended message
2025-08-12 12:23:46 -05:00
Hafiz
9a1fb18959 Merge remote-tracking branch 'origin/develop' into qa/bat 2025-08-12 09:46:07 -05:00
Kalista Payne
876d5a67d6 5.38.2 2025-08-08 14:04:19 -05:00
Kalista Payne
3078af8f2a fix(apple): don't run auth middleware during redirect 2025-08-08 14:04:13 -05:00
Weblate
dad1440138 Translated using Weblate (German)
Currently translated at 99.4% (185 of 186 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (186 of 186 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (186 of 186 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (54 of 54 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (243 of 243 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (15 of 15 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (47 of 47 strings)

Translated using Weblate (Dutch)

Currently translated at 78.0% (2643 of 3385 strings)

Translated using Weblate (Dutch)

Currently translated at 40.8% (100 of 245 strings)

Translated using Weblate (Polish)

Currently translated at 89.9% (233 of 259 strings)

Translated using Weblate (Dutch)

Currently translated at 67.5% (175 of 259 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (914 of 914 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (110 of 110 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (914 of 914 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (914 of 914 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 20.8% (51 of 245 strings)

Translated using Weblate (Turkish)

Currently translated at 65.9% (60 of 91 strings)

Translated using Weblate (Turkish)

Currently translated at 65.9% (60 of 91 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 17.9% (44 of 245 strings)

Co-authored-by: FingerTiao <787170918@qq.com>
Co-authored-by: Jaime Martí <jaumemarti77@icloud.com>
Co-authored-by: Karmelkowy <kicimeow.karmelio@gmail.com>
Co-authored-by: Linsey Dunya Pastoor <sekai.creations@gmail.com>
Co-authored-by: Mete Olmez <metezori27@gmail.com>
Co-authored-by: Sefa Uğurlu <ugurlusefa2@gmail.com>
Co-authored-by: Summer_GUI <heyang94@163.com>
Co-authored-by: Toro Mor <thomas.bizer@gmx.de>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: innnko <ayakabooker@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/tr/
Translate-URL: https://translate.habitica.com/projects/habitica/contrib/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/death/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/defaulttasks/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/front/de/
Translate-URL: https://translate.habitica.com/projects/habitica/front/es/
Translate-URL: https://translate.habitica.com/projects/habitica/front/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/pl/
Translation: Habitica/Backgrounds
Translation: Habitica/Challenge
Translation: Habitica/Communityguidelines
Translation: Habitica/Contrib
Translation: Habitica/Death
Translation: Habitica/Defaulttasks
Translation: Habitica/Faq
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Generic
Translation: Habitica/Settings
2025-08-08 10:12:25 +02:00
Phillip Thelen
12773d539e Add interface to block ip-addresses or clients due to abuse (#15484)
* Read IP blocks from database

* begin building general blocking solution

* add new frontend files

* Add UI for managing blockers

* correctly reset local data after creating blocker

* Tweak wording

* Add UI for managing blockers

* restructure admin pages

* improve test coverage

* Improve blocker UI

* add blocker to block emails from registration

* lint fix

* fix

* lint fixes

* fix import

* add new permission for managing blockers

* improve permission check

* fix managing permissions from admin

* improve navbar display for non fullAccess admin

* update block error strings

* lint fix

* add option to errorHandler to skip logging

* validate blocker value during input

* improve blocker form display

* chore(subproj): reconcile habitica-images

* fix(scripts): use same Mongo version for dev/test

* fix(whitespace): eof

* documentation improvements

* remove nconf import

* remove old test

---------

Co-authored-by: Kalista Payne <kalista@habitica.com>
Co-authored-by: Kalista Payne <sabrecat@gmail.com>
2025-08-06 15:08:07 -05:00
Phillip Thelen
2ea0b64603 improve blocker form display 2025-08-05 14:57:25 +02:00
Phillip Thelen
bd1aa1e417 validate blocker value during input 2025-08-05 14:45:23 +02:00
Phillip Thelen
7c49b845d6 add option to errorHandler to skip logging 2025-08-04 17:40:26 +02:00
Phillip Thelen
1ee172139d lint fix 2025-08-04 16:32:40 +02:00
Phillip Thelen
6447b9ab4b update block error strings 2025-08-04 16:03:55 +02:00
Phillip Thelen
5c414099d9 improve navbar display for non fullAccess admin 2025-08-04 14:46:05 +02:00
Phillip Thelen
5e8e1179aa fix managing permissions from admin 2025-08-04 14:45:47 +02:00
Phillip Thelen
7e86a62624 improve permission check 2025-08-04 14:33:09 +02:00
Phillip Thelen
1ba9dda0ed add new permission for managing blockers 2025-08-04 14:21:36 +02:00
Phillip Thelen
227e5ceaa8 fix import 2025-07-30 11:26:55 +02:00
Phillip Thelen
f77ab5a3ab lint fixes 2025-07-30 11:26:55 +02:00
Phillip Thelen
1916faf647 fix 2025-07-30 11:26:55 +02:00
Phillip Thelen
80ecb5cef1 lint fix 2025-07-30 11:26:55 +02:00
Phillip Thelen
75c36e6622 add blocker to block emails from registration 2025-07-30 11:26:55 +02:00
Phillip Thelen
78330c975a Improve blocker UI 2025-07-30 11:26:55 +02:00
Phillip Thelen
95266f6cb3 improve test coverage 2025-07-30 11:26:55 +02:00
Phillip Thelen
e9b2c1b51a restructure admin pages 2025-07-30 11:26:54 +02:00
Phillip Thelen
2a2bea07ab Add UI for managing blockers 2025-07-30 11:26:54 +02:00
Phillip Thelen
ea60ddbf4c Tweak wording 2025-07-30 11:25:51 +02:00
Phillip Thelen
1c2ca0e478 correctly reset local data after creating blocker 2025-07-30 11:25:51 +02:00
Phillip Thelen
ef2b7eb928 Add UI for managing blockers 2025-07-30 11:25:51 +02:00
Phillip Thelen
3d16387a61 add new frontend files 2025-07-30 11:25:41 +02:00
Phillip Thelen
93b7770eaa begin building general blocking solution 2025-07-30 11:25:41 +02:00
Phillip Thelen
a9f84d3307 Read IP blocks from database 2025-07-30 11:25:41 +02:00
90 changed files with 1644 additions and 1017 deletions

76
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "habitica",
"version": "5.38.1",
"version": "5.38.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "habitica",
"version": "5.38.1",
"version": "5.38.2",
"hasInstallScript": true,
"dependencies": {
"@babel/core": "^7.22.10",
@@ -59,7 +59,6 @@
"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",
@@ -16011,55 +16010,6 @@
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
"integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw=="
},
"node_modules/nodemon": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
"integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==",
"dependencies": {
"chokidar": "^3.5.2",
"debug": "^4",
"ignore-by-default": "^1.0.1",
"minimatch": "^3.1.2",
"pstree.remy": "^1.1.8",
"semver": "^7.5.3",
"simple-update-notifier": "^2.0.0",
"supports-color": "^5.5.0",
"touch": "^3.1.0",
"undefsafe": "^2.0.5"
},
"bin": {
"nodemon": "bin/nodemon.js"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/nodemon"
}
},
"node_modules/nodemon/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/nodemon/node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dependencies": {
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/noop-logger": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz",
@@ -19423,28 +19373,6 @@
"is-arrayish": "^0.3.1"
}
},
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
}
},
"node_modules/simple-update-notifier/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sinon": {
"version": "15.2.0",
"resolved": "https://registry.npmjs.org/sinon/-/sinon-15.2.0.tgz",

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.1",
"version": "5.38.2",
"main": "./website/server/index.js",
"dependencies": {
"@babel/core": "^7.22.10",
@@ -106,8 +106,8 @@
"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 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",
"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",
"postinstall": "git config --global url.\"https://\".insteadOf git:// && gulp build && cd website/client && npm install",
"apidoc": "gulp apidoc",
"heroku-postbuild": ".heroku/report_deploy.sh"

View File

@@ -47,12 +47,6 @@ describe('highlightMentions', () => {
expect(result[0]).to.equal('[@user-dash](/profile/444): message [@user_underscore](/profile/555)');
});
it('highlights users with case-insensitive matching', async () => {
const text = '@USER: message @User2 @USER3';
const result = await highlightMentions(text);
expect(result[0]).to.equal('[@USER](/profile/111): message [@User2](/profile/222) [@USER3](/profile/333)');
});
it('doesn\'t highlight nonexisting users', async () => {
const text = '@nouser message';
const result = await highlightMentions(text);

View File

@@ -0,0 +1,206 @@
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);
});
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 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

@@ -1,76 +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';
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,9 +1,13 @@
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', () => {
@@ -912,4 +916,73 @@ 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

@@ -238,18 +238,6 @@ describe('POST /chat', () => {
expect(groupMessages[0].id).to.exist;
});
it('creates a chat with case-insensitive mentions', async () => {
const originalUsername = member.auth.local.username;
const uppercaseUsername = originalUsername.toUpperCase();
const messageWithMentions = `hi @${uppercaseUsername}`;
const newMessage = await user.post(`/groups/${groupWithChat._id}/chat`, { message: messageWithMentions });
const groupMessages = await user.get(`/groups/${groupWithChat._id}/chat`);
expect(newMessage.message.id).to.exist;
expect(newMessage.message.text).to.include(`[@${uppercaseUsername}](/profile/${member._id})`);
expect(groupMessages[0].id).to.exist;
});
it('creates a chat with a max length of 3000 chars', async () => {
const veryLongMessage = `
123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789.

View File

@@ -1,7 +1,6 @@
import {
requester,
translate as t,
generateUser,
} from '../../../../helpers/api-integration/v3';
import i18n from '../../../../../website/common/script/i18n';
@@ -57,28 +56,4 @@ describe('GET /content', () => {
const res = await requester().get('/content?filter=backgroundsFlat,invalid');
expect(res).to.not.have.property('backgroundsFlat');
});
describe('authenticated user', () => {
let user;
it('returns content in user\'s preferred language when no language parameter is provided', async () => {
user = await generateUser({ 'preferences.language': 'de' });
const res = await user.get('/content');
expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach');
expect(res.backgrounds.backgrounds062014.beach.text).to.equal(i18n.t('backgroundBeachText', 'de'));
});
it('respects language parameter over user\'s preferred language', async () => {
user = await generateUser({ 'preferences.language': 'de' });
const res = await user.get('/content?language=fr');
expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach');
expect(res.backgrounds.backgrounds062014.beach.text).to.equal(i18n.t('backgroundBeachText', 'fr'));
});
it('falls back to English if user\'s preferred language is invalid', async () => {
user = await generateUser({ 'preferences.language': 'invalid_lang' });
const res = await user.get('/content');
expect(res).to.have.nested.property('backgrounds.backgrounds062014.beach');
expect(res.backgrounds.backgrounds062014.beach.text).to.equal(t('backgroundBeachText'));
});
});
});

View File

@@ -44,7 +44,7 @@ describe('POST /user/auth/local/login', () => {
})).to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('accountSuspended', { communityManagerEmail: nconf.get('EMAILS_COMMUNITY_MANAGER_EMAIL'), userId: user._id }),
message: t('accountSuspended', { communityManagerEmail: nconf.get('EMAILS_COMMUNITY_MANAGER_EMAIL'), userId: user._id, username: user.auth.local.username }),
});
});

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -1,29 +0,0 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M58.1792 31.6843L46.8536 22.3769L23.918 28.6988L18.861 42.5218L44.341 58.5813L58.1792 31.6843Z" fill="#FF944C"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M46.6218 34.5148L46.1108 26.1328L36.2812 28.8422L46.6218 34.5148Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M30.2393 39.0304L26.4518 31.5515L36.2813 28.8422L30.2393 39.0304Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M46.6218 34.5148L36.2813 28.8422L30.2393 39.0304L46.6218 34.5148Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M53.8301 32.5279L46.1108 26.1328L46.6218 34.5148L53.8301 32.5279Z" fill="white"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M23.0309 41.0173L26.4518 31.5516L30.2393 39.0304L23.0309 41.0173Z" fill="#FA8537"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M53.8301 32.5279L46.6218 34.5148L43.0424 53.79L53.8301 32.5279Z" fill="#FA8537"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M23.0309 41.0173L30.2393 39.0304L43.0425 53.79L23.0309 41.0173Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M46.6218 34.5148L30.2393 39.0304L43.0425 53.79L46.6218 34.5148Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M50.555 4.15937L47.026 0.420004L38.7773 1.59601L36.4144 6.17539L44.5675 12.8919L50.555 4.15937Z" fill="#FFBE5D"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M46.414 4.62854L46.6034 1.6924L43.0682 2.1964L46.414 4.62854Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M40.5221 5.46854L39.5331 2.7004L43.0682 2.1964L40.5221 5.46854Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M46.414 4.62854L43.0683 2.1964L40.5221 5.46855L46.414 4.62854Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M49.0064 4.25894L46.6034 1.6924L46.414 4.62854L49.0064 4.25894Z" fill="white"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M37.9296 5.83815L39.5331 2.70041L40.5221 5.46855L37.9296 5.83815Z" fill="#FFA624"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M49.0064 4.25893L46.414 4.62853L44.3259 11.1688L49.0064 4.25893Z" fill="#FFA624"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M37.9297 5.83815L40.5221 5.46855L44.326 11.1688L37.9297 5.83815Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M46.414 4.62854L40.5221 5.46855L44.326 11.1688L46.414 4.62854Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.2986 16.7775L24.6513 8.36623L11.1016 3.94533L4.07056 9.19883L11.614 25.6769L27.2986 16.7775Z" fill="#FF6165"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M20.5864 14.3719L23.0573 10.0026L17.2502 8.10789L20.5864 14.3719Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M10.908 11.2141L11.4432 6.21322L17.2502 8.10789L10.908 11.2141Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M20.5864 14.3719L17.2502 8.10789L10.9081 11.2141L20.5864 14.3719Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M24.8449 15.7613L23.0573 10.0026L20.5864 14.3719L24.8449 15.7613Z" fill="white"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M6.64955 9.82464L11.4432 6.21321L10.908 11.2141L6.64955 9.82464Z" fill="#F23035"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M24.8449 15.7613L20.5864 14.3719L12.5221 22.8464L24.8449 15.7613Z" fill="#F23035"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M6.64959 9.82464L10.9081 11.2141L12.5221 22.8463L6.64959 9.82464Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M20.5864 14.3719L10.9081 11.2141L12.5221 22.8463L20.5864 14.3719Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,29 +0,0 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.82083 31.6843L17.1464 22.3769L40.082 28.6988L45.139 42.5218L19.659 58.5813L5.82083 31.6843Z" fill="#24CC8F"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M17.3782 34.5148L17.8892 26.1328L27.7188 28.8422L17.3782 34.5148Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M33.7607 39.0304L37.5482 31.5515L27.7187 28.8422L33.7607 39.0304Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M17.3782 34.5148L27.7187 28.8422L33.7607 39.0304L17.3782 34.5148Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M10.1699 32.5279L17.8892 26.1328L17.3782 34.5148L10.1699 32.5279Z" fill="white"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M40.9691 41.0173L37.5482 31.5516L33.7607 39.0304L40.9691 41.0173Z" fill="#1CA372"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M10.1699 32.5279L17.3782 34.5148L20.9576 53.79L10.1699 32.5279Z" fill="#1CA372"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M40.9691 41.0173L33.7607 39.0304L20.9575 53.79L40.9691 41.0173Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M17.3782 34.5148L33.7607 39.0304L20.9575 53.79L17.3782 34.5148Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.445 4.15937L16.974 0.420004L25.2227 1.59601L27.5856 6.17539L19.4325 12.8919L13.445 4.15937Z" fill="#925CF3"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M17.586 4.62854L17.3966 1.6924L20.9318 2.1964L17.586 4.62854Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M23.4779 5.46854L24.4669 2.7004L20.9318 2.1964L23.4779 5.46854Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M17.586 4.62854L20.9317 2.1964L23.4779 5.46855L17.586 4.62854Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M14.9936 4.25894L17.3966 1.6924L17.586 4.62854L14.9936 4.25894Z" fill="white"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M26.0704 5.83815L24.4669 2.70041L23.4779 5.46855L26.0704 5.83815Z" fill="#4F2A93"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M14.9936 4.25893L17.586 4.62853L19.6741 11.1688L14.9936 4.25893Z" fill="#4F2A93"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M26.0703 5.83815L23.4779 5.46855L19.674 11.1688L26.0703 5.83815Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M17.586 4.62854L23.4779 5.46855L19.674 11.1688L17.586 4.62854Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M36.7014 16.7775L39.3487 8.36623L52.8984 3.94533L59.9294 9.19883L52.386 25.6769L36.7014 16.7775Z" fill="#50B5E9"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M43.4136 14.3719L40.9427 10.0026L46.7498 8.10789L43.4136 14.3719Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M53.092 11.2141L52.5568 6.21322L46.7498 8.10789L53.092 11.2141Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M43.4136 14.3719L46.7498 8.10789L53.0919 11.2141L43.4136 14.3719Z" fill="white"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M39.1551 15.7613L40.9427 10.0026L43.4136 14.3719L39.1551 15.7613Z" fill="white"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M57.3504 9.82464L52.5568 6.21321L53.092 11.2141L57.3504 9.82464Z" fill="#46A7D9"/>
<path opacity="0.35" fill-rule="evenodd" clip-rule="evenodd" d="M39.1551 15.7613L43.4136 14.3719L51.4779 22.8464L39.1551 15.7613Z" fill="#46A7D9"/>
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M57.3504 9.82464L53.0919 11.2141L51.4779 22.8463L57.3504 9.82464Z" fill="white"/>
<path opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M43.4136 14.3719L53.0919 11.2141L51.4779 22.8463L43.4136 14.3719Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -117,7 +117,7 @@ export default {
closeWithAction () {
this.close();
setTimeout(() => {
this.$router.push(`/profile/${this.$store.state.user.data._id}#achievements`);
this.$router.push({ name: 'achievements' });
}, 200);
},
},

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>Admin Panel</h1>
<h1>{{ $t("adminPanel") }}</h1>
<form
class="form-inline"
@submit.prevent="searchUsers(userIdentifier)"
@@ -72,7 +72,7 @@ export default {
},
mounted () {
this.$store.dispatch('common:setTitle', {
section: 'Admin Panel',
section: this.$t('adminPanel'),
});
},
methods: {

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;

View File

@@ -38,12 +38,17 @@
>
<div class="custom-control custom-checkbox">
<input
:id="permission.key"
v-model="hero.permissions[permission.key]"
:disabled="!hasPermission(user, permission.key)"
:disabled="!hasPermission(user, permission.key)
|| (hero.permissions.fullAccess && permission.key !== 'fullAccess')"
class="custom-control-input"
type="checkbox"
>
<label class="custom-control-label">
<label
class="custom-control-label"
:for="permission.key"
>
{{ permission.name }}<br>
<small class="text-secondary">{{ permission.description }}</small>
</label>
@@ -124,7 +129,10 @@
value="Save"
class="btn btn-primary mt-1"
>
<b v-if="hasUnsavedChanges" class="text-warning float-right">
<b
v-if="hasUnsavedChanges"
class="text-warning float-right"
>
Unsaved changes
</b>
</div>
@@ -147,7 +155,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 = [
{
@@ -175,6 +183,11 @@ 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',

View File

@@ -126,7 +126,7 @@
@click="changeApiToken()"
>
Change API Token
</a>
</a>
<div
v-if="tokenModified"
>

View File

@@ -46,7 +46,7 @@
:
<span :class="{ ownedItem: !item.neverOwned }">{{ item.text }}</span>
</span>
- {{ itemType }}.{{item.key}} - <i> {{ item.set }}</i>
- {{ itemType }}.{{ item.key }} - <i> {{ item.set }}</i>
<div
v-if="item.modified"

View File

@@ -16,9 +16,9 @@
: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])"
[hero.auth, unModifiedHero.auth],
[hero.balance, unModifiedHero.balance],
[hero.secret, unModifiedHero.secret])"
/>
<subscription-and-perks
@@ -88,7 +88,7 @@
<contributor-details
:hero="hero"
:hasUnsavedChanges="hasUnsavedChanges(
:has-unsaved-changes="hasUnsavedChanges(
[hero.contributor, unModifiedHero.contributor],
[hero.permissions, unModifiedHero.permissions],
[hero.secret, unModifiedHero.secret],
@@ -149,7 +149,7 @@ 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: {

View File

@@ -32,38 +32,43 @@
></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 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>
<strong v-else>User is not in a party.</strong>
<div class="subsection-start">

View File

@@ -1,11 +1,13 @@
<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: {
_id: hero._id,
flags: hero.flags,
balance: hero.balance,
auth: hero.auth,
secret: hero.secret,
}, msg: 'Privileges or Gems or Moderation Notes'})"
>
<div class="card mt-2">
<div class="card-header">
<h3
@@ -14,9 +16,12 @@
@click="expand = !expand"
>
Privileges, Gem Balance
<b v-if="hasUnsavedChanges && !expand" class="text-warning float-right">
Unsaved changes
</b>
<b
v-if="hasUnsavedChanges && !expand"
class="text-warning float-right"
>
Unsaved changes
</b>
</h3>
</div>
<div
@@ -133,7 +138,10 @@
value="Save"
class="btn btn-primary mt-1"
>
<b v-if="hasUnsavedChanges" class="text-warning float-right">
<b
v-if="hasUnsavedChanges"
class="text-warning float-right"
>
Unsaved changes
</b>
</div>

View File

@@ -8,9 +8,12 @@
@click="expand = !expand"
>
Stats
<b v-if="hasUnsavedChanges && !expand" class="text-warning float-right">
Unsaved changes
</b>
<b
v-if="hasUnsavedChanges && !expand"
class="text-warning float-right"
>
Unsaved changes
</b>
</h3>
</div>
<div
@@ -18,47 +21,60 @@
class="card-body"
>
<stats-row
v-model="hero.stats.hp"
label="Health"
color="red-label"
:max="maxHealth"
v-model="hero.stats.hp" />
/>
<stats-row
v-model="hero.stats.exp"
label="Experience"
color="yellow-label"
min="0"
:max="maxFieldHardCap"
v-model="hero.stats.exp" />
/>
<stats-row
v-model="hero.stats.mp"
label="Mana"
color="blue-label"
min="0"
:max="maxFieldHardCap"
v-model="hero.stats.mp" />
/>
<stats-row
v-model="hero.stats.lvl"
label="Level"
step="1"
min="0"
:max="maxLevelHardCap"
v-model="hero.stats.lvl" />
/>
<stats-row
v-model="hero.stats.gp"
label="Gold"
min="0"
:max="maxFieldHardCap"
v-model="hero.stats.gp" />
/>
<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>
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>
@@ -67,50 +83,59 @@
<h3>Stat Points</h3>
<stats-row
v-model="hero.stats.points"
label="Unallocated"
min="0"
step="1"
:max="maxStatPoints"
v-model="hero.stats.points" />
/>
<stats-row
v-model="hero.stats.str"
label="Strength"
color="red-label"
min="0"
:max="maxStatPoints"
step="1"
v-model="hero.stats.str" />
/>
<stats-row
v-model="hero.stats.int"
label="Intelligence"
color="blue-label"
min="0"
:max="maxStatPoints"
step="1"
v-model="hero.stats.int" />
/>
<stats-row
v-model="hero.stats.per"
label="Perception"
color="purple-label"
min="0"
:max="maxStatPoints"
step="1"
v-model="hero.stats.per" />
/>
<stats-row
v-model="hero.stats.con"
label="Constitution"
color="yellow-label"
min="0"
:max="maxStatPoints"
step="1"
v-model="hero.stats.con" />
/>
<div class="form-group row">
<div class="offset-sm-3 col-sm-9">
<button
type="button"
class="btn btn-warning btn-sm"
@click="deallocateStatPoints">
@click="deallocateStatPoints"
>
Deallocate all stat points
</button>
</div>
</div>
<div class="form-group row" v-if="statPointsIncorrect">
<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>
@@ -118,35 +143,40 @@
<h3>Buffs</h3>
<stats-row
v-model="hero.stats.buffs.str"
label="Strength"
color="red-label"
min="0"
step="1"
v-model="hero.stats.buffs.str" />
/>
<stats-row
v-model="hero.stats.buffs.int"
label="Intelligence"
color="blue-label"
min="0"
step="1"
v-model="hero.stats.buffs.int" />
/>
<stats-row
v-model="hero.stats.buffs.per"
label="Perception"
color="purple-label"
min="0"
step="1"
v-model="hero.stats.buffs.per" />
/>
<stats-row
v-model="hero.stats.buffs.con"
label="Constitution"
color="yellow-label"
min="0"
step="1"
v-model="hero.stats.buffs.con" />
/>
<div class="form-group row">
<div class="offset-sm-3 col-sm-9">
<button
type="button"
class="btn btn-warning btn-sm"
@click="resetBuffs">
@click="resetBuffs"
>
Reset Buffs
</button>
</div>
@@ -161,7 +191,10 @@
value="Save"
class="btn btn-primary mt-1"
>
<b v-if="hasUnsavedChanges" class="text-warning float-right">
<b
v-if="hasUnsavedChanges"
class="text-warning float-right"
>
Unsaved changes
</b>
</div>
@@ -189,7 +222,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';
import StatsRow from './stats-row';

View File

@@ -6,49 +6,71 @@
}, msg: 'Subscription Perks' })"
>
<div class="card mt-2">
<div class="card-header"
@click="expand = !expand">
<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>
<b
v-if="hasUnsavedChanges && !expand"
class="text-warning float-right"
>
Unsaved changes
</b>
</h3>
</div>
<div
v-if="expand"
class="card-body"
>
<div
<div
class="form-group row"
>
<label class="col-sm-3 col-form-label">
Payment method:
</label>
<div class="col-sm-9">
<input v-model="hero.purchased.plan.paymentMethod"
<input
v-if="!isRegularPaymentMethod"
v-model="hero.purchased.plan.paymentMethod"
class="form-control"
type="text"
v-if="!isRegularPaymentMethod"
>
<select
<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>
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
@@ -58,25 +80,40 @@
Payment schedule:
</label>
<div class="col-sm-9">
<input v-model="hero.purchased.plan.planId"
<input
v-if="!isRegularPlanId"
v-model="hero.purchased.plan.planId"
class="form-control"
type="text"
v-if="!isRegularPlanId"
>
<select
<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>
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
@@ -86,43 +123,50 @@
Customer ID:
</label>
<div class="col-sm-9">
<input
v-model="hero.purchased.plan.customerId"
class="form-control"
type="text"
>
<input
v-model="hero.purchased.plan.customerId"
class="form-control"
type="text"
>
</div>
</div>
<div class="form-group row"
v-if="hero.purchased.plan.planId === 'group_plan_auto'">
<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
/>
v-if="!groupPlans"
dark-color="true"
/>
<b
v-else-if="groupPlans.length === 0"
class="text-danger col-form-label"
v-else-if="groupPlans.length === 0"
class="text-danger col-form-label"
>User is not part of an active group plan!</b>
<div
v-else
v-for="group in groupPlans"
v-else
:key="group._id"
class="card mb-2">
class="card mb-2"
>
<div class="card-body">
<h6 class="card-title">{{ group.name }}
<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>
<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 }}
@@ -190,16 +234,21 @@
<strong class="input-group-text">
{{ dateFormat(hero.purchased.plan.dateTerminated) }}
</strong>
<a class="btn btn-danger"
href="#"
<a
v-if="!hero.purchased.plan.dateTerminated && hero.purchased.plan.planId"
v-b-modal.sub_termination_modal
v-if="!hero.purchased.plan.dateTerminated && hero.purchased.plan.planId">
class="btn btn-danger"
href="#"
>
Terminate
</a>
</a>
</div>
</div>
<small v-if="!hero.purchased.plan.dateTerminated
&& hero.purchased.plan.planId" class="text-success">
<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>
@@ -235,11 +284,13 @@
step="any"
>
<div class="input-group-append">
<a class="btn btn-warning"
<a
v-if="hero.purchased.plan.dateTerminated && hero.purchased.plan.extraMonths > 0"
class="btn btn-warning"
@click="applyExtraMonths"
v-if="hero.purchased.plan.dateTerminated && hero.purchased.plan.extraMonths > 0">
>
Apply Credit
</a>
</a>
</div>
</div>
<small class="text-secondary">
@@ -339,19 +390,24 @@
</span>
</div>
</div>
<div class="form-group row"
v-if="!isConvertingToGroupPlan && hero.purchased.plan.planId !== 'group_plan_auto'">
<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">
@click="beginGroupPlanConvert"
>
Begin converting to group plan subscription
</button>
</div>
</div>
<div class="form-group row"
v-if="isConvertingToGroupPlan">
<div
v-if="isConvertingToGroupPlan"
class="form-group row"
>
<label class="col-sm-3 col-form-label">
Group Plan group ID:
</label>
@@ -374,25 +430,40 @@
class="btn btn-primary mt-1"
@click="saveClicked"
>
<b v-if="hasUnsavedChanges" class="text-warning float-right">
<b
v-if="hasUnsavedChanges"
class="text-warning float-right"
>
Unsaved changes
</b>
</div>
</div>
<b-modal id="sub_termination_modal" title="Set Termination Date">
<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')">
<div
class="mt-3 btn btn-secondary"
@click="$bvModal.hide('sub_termination_modal')"
>
Close
</div>
<div class="mt-3 btn btn-danger" @click="terminateSubscription()">
<div
class="mt-3 btn btn-danger"
@click="terminateSubscription()"
>
Set to Today
</div>
<div class="mt-3 btn btn-danger" @click="terminateSubscription(todayWithRemainingCycle)">
<div
class="mt-3 btn btn-danger"
@click="terminateSubscription(todayWithRemainingCycle)"
>
Set to {{ todayWithRemainingCycle.utc().format('MM/DD/YYYY') }}
</div>
</template>
@@ -420,15 +491,15 @@
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 subscriptionBlocks from '../../../../../common/script/content/subscriptionBlocks';
import LoadingSpinner from '@/components/ui/loadingSpinner';
export default {
mixins: [saveHero],
components: {
LoadingSpinner,
},
mixins: [saveHero],
props: {
hero: {
type: Object,

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

@@ -180,7 +180,7 @@
<script>
import moment from 'moment';
import { userStateMixin } from '../../../mixins/userState';
import { userStateMixin } from '../../../../mixins/userState';
export default {
filters: {

View File

@@ -13,9 +13,12 @@
@click="expand = !expand"
>
User Profile
<b v-if="hasUnsavedChanges && !expand" class="text-warning float-right">
Unsaved changes
</b>
<b
v-if="hasUnsavedChanges && !expand"
class="text-warning float-right"
>
Unsaved changes
</b>
</h3>
</div>
<div
@@ -66,7 +69,10 @@
value="Save"
class="btn btn-primary mt-1"
>
<b v-if="hasUnsavedChanges" class="text-warning float-right">
<b
v-if="hasUnsavedChanges"
class="text-warning float-right"
>
Unsaved changes
</b>
</div>
@@ -86,7 +92,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;

View File

@@ -0,0 +1,133 @@
<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

@@ -0,0 +1,238 @@
<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

@@ -0,0 +1,40 @@
<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

@@ -276,9 +276,9 @@
</div>
<div
class="time-travel"
v-if="TIME_TRAVEL_ENABLED && user?.permissions?.fullAccess"
:key="lastTimeJump"
class="time-travel"
>
<a
class="btn btn-secondary mr-1"
@@ -299,7 +299,7 @@
@click="resetTime()"
>
Reset
</a>
</a>
</div>
<a
class="btn btn-secondary mr-1"

View File

@@ -43,9 +43,11 @@ export default {
const AUTH_SETTINGS = localStorage.getItem(LOCALSTORAGE_AUTH_KEY);
const parseSettings = JSON.parse(AUTH_SETTINGS);
const userId = parseSettings ? parseSettings.auth.apiId : '';
const username = this.$store?.state?.user?.data?.auth?.local?.username || '';
return this.$t('accountSuspended', {
userId,
username,
communityManagerEmail: COMMUNITY_MANAGER_EMAIL,
});
},

View File

@@ -75,20 +75,16 @@
class="box member-count"
@click="showMemberModal()"
>
<div class="box-content">
<div class="icon-number-row">
<div
class="svg-icon member-icon"
v-html="icons.memberIcon"
></div>
<span class="number">{{ challenge.memberCount }}</span>
</div>
<div
v-once
class="details"
>
{{ $t('participantsTitle') }}
</div>
<div
class="svg-icon member-icon"
v-html="icons.memberIcon"
></div>
{{ challenge.memberCount }}
<div
v-once
class="details"
>
{{ $t('participantsTitle') }}
</div>
</div>
<div class="box">
@@ -308,7 +304,7 @@
.box {
display: inline-block;
padding: 0.5em;
padding: 1em;
border-radius: 2px;
background-color: $white;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
@@ -318,69 +314,22 @@
text-align: center;
font-size: 20px;
vertical-align: bottom;
overflow: hidden;
position: relative;
&.member-count:hover {
cursor: pointer;
}
.box-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
.icon-number-row {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.1em;
.number {
font-size: 20px;
font-weight: normal;
margin-left: 0.2em;
}
}
.svg-icon {
width: 30px;
display: inline-block;
margin-right: .2em;
vertical-align: bottom;
}
.details {
font-size: 12px;
margin-top: 0.4em;
color: $gray-200;
width: 100%;
padding: 0 4px;
line-height: 1.15;
word-break: break-word;
max-height: 2.3em;
overflow: visible;
}
&.member-count {
.icon-number-row {
.svg-icon {
width: 24px;
height: 24px;
}
.number {
font-size: 18px;
}
}
.details {
font-size: 11px;
line-height: 1.1;
max-height: 2.2em;
}
}
}

View File

@@ -4,7 +4,6 @@
id="close-challenge-modal"
:title="$t('endChallenge')"
size="md"
:hide-header="false"
>
<div
slot="modal-header"
@@ -16,15 +15,6 @@
>
{{ $t('endChallenge') }}
</h2>
<button
class="close-button"
@click="$root.$emit('bv::hide::modal', 'close-challenge-modal')"
>
<div
class="svg-icon"
v-html="icons.close"
></div>
</button>
</div>
<div class="row text-center">
<span
@@ -38,67 +28,28 @@
class="col-12"
>
<div class="col-12">
<div class="badge-section">
<div
class="gems-left"
v-html="icons.gemsOrange"
></div>
<div
class="challenge-badge"
v-html="icons.endChallengeBadge"
></div>
<div
class="gems-right"
v-html="icons.gemsPurple"
></div>
<div class="support-habitica">
<!-- @TODO: Add challenge achievement badge here-->
</div>
</div>
<div class="col-12">
<strong v-once>{{ $t('selectChallengeWinnersDescription') }}</strong>
</div>
<div class="col-12">
<div class="search-input-wrapper">
<div
class="search-icon"
v-html="icons.search"
></div>
<input
v-model="searchTerm"
class="search-input"
type="text"
:placeholder="'@' + $t('username')"
@input="searchMembers"
@focus="showResults = true"
@blur="handleBlur"
>
<div
v-if="showResults && filteredMembers.length > 0"
class="search-results"
>
<div
v-for="member in filteredMembers"
:key="member._id"
class="search-result-item"
@mousedown="selectMember(member)"
>
{{ getMemberDisplayName(member) }}
</div>
</div>
</div>
<member-search-dropdown
:text="winnerText"
:members="members"
:challenge-id="challengeId"
@member-selected="selectMember"
/>
</div>
<div class="col-12">
<button
class="btn award-winner-btn"
:class="{'has-winner': winner._id}"
:disabled="!winner._id"
v-once
class="btn btn-primary"
@click="closeChallenge"
>
<span>{{ $t('awardWinners') }}</span>
<div
class="gem-icon"
v-html="icons.gem"
></div>
<span>{{ prize }} {{ prize === 1 ? $t('gem') : $t('gems') }}</span>
{{ $t('awardWinners') }}
</button>
</div>
</span>
@@ -109,24 +60,14 @@
</div>
</div>
<div class="col-12">
<strong
v-once
class="delete-challenge-text"
>{{ $t('doYouWantedToDeleteChallenge') }}</strong>
</div>
<div class="col-12 refund-text">
{{ $t('deleteChallengeRefundDescription') }}
<strong v-once>{{ $t('doYouWantedToDeleteChallenge') }}</strong>
</div>
<div class="col-12">
<button
v-once
class="btn btn-danger delete-challenge-btn"
class="btn btn-danger"
@click="deleteChallenge()"
>
<div
class="delete-icon"
v-html="icons.deleteIcon"
></div>
{{ $t('deleteChallenge') }}
</button>
</div>
@@ -154,185 +95,13 @@
.header-wrap {
width: 100%;
padding-top: 2em;
position: relative;
}
.close-button {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
padding: 0.5rem;
cursor: pointer;
opacity: 0.5;
transition: opacity 0.2s ease;
&:hover {
opacity: 1;
}
.svg-icon {
width: 16px;
height: 16px;
color: $gray-10;
}
}
.search-input-wrapper {
position: relative;
width: 384px;
.support-habitica {
background-image: url('@/assets/svg/for-css/support-habitica-gems.svg?raw');
width: 325px;
height: 89px;
margin: 0 auto;
.search-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: $gray-200;
pointer-events: none;
}
.search-input {
width: 100%;
height: 32px;
padding-left: 36px;
padding-right: 12px;
border: 2px solid $gray-400;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.2s ease;
&:focus {
outline: none;
border-color: $purple-300;
}
&::placeholder {
color: $gray-300;
}
}
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: $white;
border: 1px solid $gray-400;
border-top: none;
border-radius: 0 0 4px 4px;
max-height: 200px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.search-result-item {
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: $gray-700;
}
}
}
}
.delete-challenge-text {
color: $maroon-50;
}
.refund-text {
font-family: 'Roboto', sans-serif;
font-size: 14px;
line-height: 24px;
font-weight: 400;
color: $gray-50;
margin-top: 0.5em !important;
}
.delete-challenge-btn {
font-family: 'Roboto', sans-serif;
font-size: 14px;
font-weight: 700;
line-height: 24px;
display: inline-flex;
align-items: center;
gap: 8px;
.delete-icon {
width: 16px;
height: 16px;
display: inline-flex;
color: $white;
}
}
.award-winner-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background-color: $white;
color: $gray-200;
border: 1px solid $gray-400;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
transition: all 0.2s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
&:hover {
background-color: $white;
}
}
&.has-winner {
background-color: $purple-200;
color: $white;
border-color: $purple-200;
.gem-icon {
color: $white;
}
}
&:hover:not(.has-winner):not(:disabled) {
background-color: $gray-700;
}
.gem-icon {
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
color: $gems-color;
}
}
.badge-section {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
margin: 0 auto;
padding: 1rem 0;
.gems-left, .gems-right {
width: 64px;
height: 64px;
flex-shrink: 0;
}
.challenge-badge {
width: 48px;
height: 52px;
flex-shrink: 0;
}
}
.modal-footer, .modal-header {
@@ -354,37 +123,21 @@
margin-right: auto;
margin-left: auto;
font-weight: bold;
color: $gray-100;
}
}
</style>
<script>
import searchIcon from '@/assets/svg/for-css/search.svg?raw';
import deleteIcon from '@/assets/svg/delete.svg?raw';
import closeIcon from '@/assets/svg/close.svg?raw';
import gemIcon from '@/assets/svg/gem.svg?raw';
import endChallengeBadge from '@/assets/svg/for-css/end_challenge_badge.svg?raw';
import gemsOrange from '@/assets/svg/for-css/orange100_red100_yellow100_gems.svg?raw';
import gemsPurple from '@/assets/svg/for-css/purple200_green10_blue100_gems.svg?raw';
import memberSearchDropdown from '@/components/members/memberSearchDropdown';
export default {
components: {
memberSearchDropdown,
},
props: ['challengeId', 'members', 'prize', 'flagCount'],
data () {
return {
winner: {},
searchTerm: '',
showResults: false,
filteredMembers: [],
icons: Object.freeze({
search: searchIcon,
deleteIcon,
close: closeIcon,
gem: gemIcon,
endChallengeBadge,
gemsOrange,
gemsPurple,
}),
};
},
computed: {
@@ -397,35 +150,8 @@ export default {
},
},
methods: {
searchMembers () {
if (!this.searchTerm) {
this.filteredMembers = [];
return;
}
const searchLower = this.searchTerm.toLowerCase().replace('@', '');
this.filteredMembers = this.members.filter(member => {
const username = member.auth?.local?.username || '';
const displayName = member.profile?.name || '';
return username.toLowerCase().includes(searchLower)
|| displayName.toLowerCase().includes(searchLower);
}).slice(0, 10);
},
getMemberDisplayName (member) {
if (member.auth?.local?.username) {
return `@${member.auth.local.username}`;
}
return member.profile?.name || '';
},
selectMember (member) {
this.winner = member;
this.searchTerm = this.getMemberDisplayName(member);
this.showResults = false;
},
handleBlur () {
setTimeout(() => {
this.showResults = false;
}, 200);
},
async closeChallenge () {
this.challenge = await this.$store.dispatch('challenges:selectChallengeWinner', {

View File

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

View File

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

View File

@@ -295,14 +295,6 @@
{{ $t('help') }}
</router-link>
<div class="topbar-dropdown">
<router-link
v-if="user.permissions.fullAccess ||
user.permissions.userSupport"
class="topbar-dropdown-item dropdown-item"
:to="{name: 'adminPanel'}"
>
Admin Panel
</router-link>
<router-link
class="topbar-dropdown-item dropdown-item"
:to="{name: 'faq'}"
@@ -336,6 +328,61 @@
>{{ $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
@@ -757,6 +804,7 @@ 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: {
@@ -769,7 +817,7 @@ export default {
selectUserModal,
userDropdown,
},
mixins: [sync, reportBug],
mixins: [sync, reportBug, userStateMixin],
data () {
return {
isUserDropdownOpen: false,
@@ -802,6 +850,12 @@ 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

@@ -70,19 +70,13 @@
}
.btn-secondary {
min-width: 5.75rem;
width: auto;
max-width: calc(100% - 2rem);
width: 5.75rem;
min-height: 1.5rem;
padding: 0.25rem 0.75rem;
border-radius: 2px;
border-color: $white;
box-shadow: 0 2px 2px 0 rgba(26, 24, 29, 0.16), 0 1px 4px 0 rgba(26, 24, 29, 0.12);
font-size: 12px;
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -15,7 +15,8 @@
<Sprite
slot="icon"
class="mt-3"
:image-name="notification.data.icon" />
:image-name="notification.data.icon"
/>
</base-notification>
</template>

View File

@@ -12,7 +12,8 @@
></div>
<Sprite
slot="icon"
:image-name="mysteryClass" />
:image-name="mysteryClass"
/>
</base-notification>
</template>

View File

@@ -71,7 +71,7 @@ export default {
props: ['notification', 'canRemove'],
methods: {
action () {
this.$router.push(`/profile/${this.$store.state.user.data._id}#achievements`);
this.$router.push({ name: 'achievements' });
},
},
};

View File

@@ -43,7 +43,7 @@ export default {
},
methods: {
action () {
this.$router.push(`/profile/${this.$store.state.user.data._id}#stats`);
this.$router.push({ name: 'stats' });
},
},
};

View File

@@ -176,12 +176,7 @@ export default {
}
},
showProfile (startingPage) {
const userId = this.$store.state.user.data._id;
let path = `/profile/${userId}`;
if (startingPage !== 'profile') {
path += `#${startingPage}`;
}
this.$router.push(path);
this.$router.push({ name: startingPage });
},
toLearnMore () {
this.$router.push({ name: 'subscription' });

View File

@@ -81,9 +81,10 @@ 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],
mixins: [seasonalNPC, userStateMixin],
data () {
return {
posts: [],
@@ -107,7 +108,7 @@ export default {
if (lastPublishedPost) this.posts.push(lastPublishedPost);
// If the user is authorized, show any draft
if (this.user && (this.user.permissions.news || this.user.permissions.fullAccess)) {
if (this.user && (this.hasPermission(this.user, 'news'))) {
this.posts.unshift(
...postsFromServer
.filter(p => !p.published || moment().isBefore(p.publishDate)),

View File

@@ -7,10 +7,15 @@
</div>
<div class="row">
<div class="col-12 mb-5 mb-md-0">
<img :src="makeUrl('features_taskboard.png')" class="img-fluid">
<img
:src="makeUrl('features_taskboard.png')"
class="img-fluid"
>
<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>
<p class="col col-lg-8 col-xl-6 margin-auto description">
{{ $t('marketing1Lead1') }}
</p>
</div>
</div>
</div>
@@ -18,12 +23,16 @@
<div class="col-md-6 mb-5 mb-md-0">
<img :src="makeUrl('features_gear.png')">
<h2>{{ $t('marketing1Lead2Title') }}</h2>
<p class="description">{{ $t('marketing1Lead2') }}</p>
<p class="description">
{{ $t('marketing1Lead2') }}
</p>
</div>
<div class="col-md-6 mb-5 mb-md-0">
<img :src="makeUrl('features_items.png')">
<h2>{{ $t('marketing1Lead3Title') }}</h2>
<p class="description">{{ $t('marketing1Lead3') }}</p>
<p class="description">
{{ $t('marketing1Lead3') }}
</p>
</div>
</div>
<hr>
@@ -35,19 +44,26 @@
<div class="row mb-5">
<div class="col-12">
<h2>{{ $t('marketing2Lead1Title') }}</h2>
<p class="description">{{ $t('marketing2Lead1') }}</p>
<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')">
<h2>{{ $t('marketing2Lead2Title') }}</h2>
<p class="description" v-markdown="$t('marketing2Lead2')"></p>
<p
v-markdown="$t('marketing2Lead2')"
class="description"
></p>
</div>
<div class="col-md-6 mb-5 mb-md-0">
<img :src="makeUrl('features_challenges.png')">
<h2>{{ $t('marketing2Lead3Title') }}</h2>
<p class="description">{{ $t('marketing2Lead3') }}</p>
<p class="description">
{{ $t('marketing2Lead3') }}
</p>
</div>
</div>
<hr>
@@ -60,12 +76,18 @@
<div class="col-md-6 mb-5 mb-md-0">
<img :src="makeUrl('features_mobile.png')">
<h2>{{ $t('marketing3Lead1Title') }}</h2>
<p class="description" v-markdown="$t('marketing3Lead1')"></p>
<p
v-markdown="$t('marketing3Lead1')"
class="description"
></p>
</div>
<div class="col-md-6 mb-5 mb-md-0">
<img :src="makeUrl('features_opensource.png')">
<h2>{{ $t('marketing3Lead2Title') }}</h2>
<p class="description" v-markdown="$t('marketing3Lead2')"></p>
<p
v-markdown="$t('marketing3Lead2')"
class="description"
></p>
</div>
</div>
<hr>
@@ -80,7 +102,9 @@
<img src="@/assets/images/marketing/education.png">
<div class="media-body">
<h2>{{ $t('marketing4Lead1Title') }}</h2>
<p class="description">{{ $t('marketing4Lead1') }}</p>
<p class="description">
{{ $t('marketing4Lead1') }}
</p>
</div>
</div>
</div>
@@ -89,7 +113,9 @@
<img src="@/assets/images/marketing/wellness.png">
<div class="media-body">
<h2>{{ $t('marketing4Lead2Title') }}</h2>
<p class="description">{{ $t('marketing4Lead2') }}</p>
<p class="description">
{{ $t('marketing4Lead2') }}
</p>
</div>
</div>
</div>

View File

@@ -1126,12 +1126,7 @@ export default {
this.loadUser();
this.oldTitle = this.$store.state.title;
this.handleExternalLinks();
// Check if there's a hash in the URL to determine the starting page
let pageToSelect = this.startingPage;
if (window.location.hash && (window.location.hash === '#stats' || window.location.hash === '#achievements')) {
pageToSelect = window.location.hash.substring(1);
}
this.selectPage(pageToSelect);
this.selectPage(this.startingPage);
this.$root.$on('habitica:report-profile-result', () => {
this.loadUser();
});
@@ -1216,15 +1211,10 @@ export default {
},
selectPage (page) {
this.selectedPage = page || 'profile';
const profileUserId = this.userId || this.userLoggedIn._id;
let newPath = `/profile/${profileUserId}`;
if (page !== 'profile') {
newPath += `#${page}`;
}
window.history.replaceState(null, null, newPath);
window.history.replaceState(null, null, '');
this.$store.dispatch('common:setTitle', {
section: this.$t('user'),
subSection: this.$t(page),
subSection: this.$t(this.startingPage),
});
},
getNextIncentive () {

View File

@@ -11,7 +11,7 @@
class="balance-info"
:currency-needed="currencyNeeded"
:amount-needed="amountNeeded"
:neededCurrencyOnly="true"
:needed-currency-only="true"
/>
</div>
</template>

View File

@@ -19,16 +19,12 @@ const HallPage = () => import(/* webpackChunkName: "hall" */'@/components/hall/i
const PatronsPage = () => import(/* webpackChunkName: "hall" */'@/components/hall/patrons');
const HeroesPage = () => import(/* webpackChunkName: "hall" */'@/components/hall/heroes');
// Admin Panel
const AdminPanelPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin-panel');
const AdminPanelUserPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin-panel/user-support');
const AdminPanelSearchPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin-panel/search');
// Except for tasks that are always loaded all the other main level
// All the main level
// components are loaded in separate webpack chunks.
// See https://webpack.js.org/guides/code-splitting-async/
// for docs
// Admin Pages
const AdminContainerPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/container');
const AdminPanelPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/admin-panel');
const AdminPanelUserPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/admin-panel/user-support');
const AdminPanelSearchPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/admin-panel/search');
const BlockerPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/blocker');
// Tasks
const UserTasks = () => import(/* webpackChunkName: "userTasks" */'@/components/tasks/user');
@@ -98,9 +94,6 @@ const router = new VueRouter({
path: '/profile/:userId',
props: true,
},
{ name: 'profile', path: '/user/profile' },
{ name: 'stats', path: '/user/stats' },
{ name: 'achievements', path: '/user/achievements' },
{
path: '/inventory',
component: InventoryContainer,
@@ -187,32 +180,55 @@ const router = new VueRouter({
},
{
name: 'adminPanel',
path: '/admin-panel',
component: AdminPanelPage,
name: 'adminSection',
path: '/admin',
component: AdminContainerPage,
meta: {
privilegeNeeded: [ // any one of these is enough to give access
'userSupport',
'accessControl',
],
},
children: [
{
name: 'adminPanelSearch',
path: 'search/:userIdentifier',
component: AdminPanelSearchPage,
name: 'adminPanel',
path: 'panel',
component: AdminPanelPage,
meta: {
privilegeNeeded: [
privilegeNeeded: [ // any one of these is enough to give access
'userSupport',
],
},
children: [
{
name: 'adminPanelSearch',
path: 'search/:userIdentifier',
component: AdminPanelSearchPage,
meta: {
privilegeNeeded: [
'userSupport',
],
},
},
{
name: 'adminPanelUser',
path: ':userIdentifier',
component: AdminPanelUserPage,
meta: {
privilegeNeeded: [
'userSupport',
],
},
},
],
},
{
name: 'adminPanelUser',
path: ':userIdentifier',
component: AdminPanelUserPage,
name: 'blockers',
path: 'blockers',
component: BlockerPage,
meta: {
privilegeNeeded: [
'userSupport',
privilegeNeeded: [ // any one of these is enough to give access
'accessControl',
],
},
},
@@ -335,10 +351,6 @@ router.beforeEach(async (to, from, next) => {
if (to.params.startingPage !== undefined) {
startingPage = to.params.startingPage;
}
// Check if there's a hash in the URL for stats or achievements
if (to.hash === '#stats' || to.hash === '#achievements') {
startingPage = to.hash.substring(1);
}
if (from.name === null) {
store.state.postLoadModal = `profile/${to.params.userId}`;
return next({ name: 'tasks' });
@@ -359,18 +371,10 @@ router.beforeEach(async (to, from, next) => {
}
if ((to.name === 'stats' || to.name === 'achievements' || to.name === 'profile') && from.name !== null) {
const userId = store.state.user.data._id;
let redirectPath = `/profile/${userId}`;
if (to.name === 'stats') {
redirectPath += '#stats';
} else if (to.name === 'achievements') {
redirectPath += '#achievements';
}
router.app.$emit('habitica:show-profile', {
userId,
startingPage: to.name,
fromPath: from.path,
toPath: redirectPath,
toPath: to.path,
});
return null;
}

View File

@@ -0,0 +1,19 @@
import axios from 'axios';
export async function getBlockers () {
const response = await axios.get('/api/v4/admin/blockers');
return response.data.data;
}
export async function createBlocker (store, payload) {
const response = await axios.post('/api/v4/admin/blockers', payload.blocker);
return response.data.data;
}
export async function updateBlocker (store, payload) {
const response = await axios.put(`/api/v4/admin/blockers/${payload.blocker._id}`, payload.blocker);
return response.data.data;
}
export async function deleteBlocker (store, payload) {
const response = await axios.delete(`/api/v4/admin/blockers/${payload.blockerId}`);
return response.data.data;
}

View File

@@ -20,6 +20,7 @@ import * as worldState from './worldState';
import * as news from './news';
import * as analytics from './analytics';
import * as faq from './faq';
import * as blockers from './blockers';
// Actions should be named as 'actionName' and can be accessed as 'namespace:actionName'
// Example: fetch in user.js -> 'user:fetch'
@@ -45,6 +46,7 @@ const actions = flattenAndNamespace({
news,
analytics,
faq,
blockers,
});
export default actions;

View File

@@ -182,5 +182,6 @@
"incorrectResetPhrase": "Bitte tippe <%= magicWord %> in Großbuchstaben um deinen Account zurückzusetzen.",
"translateHabitica": "Habitica übersetzen",
"marketing3Lead1Title": "Android & iOS Apps",
"marketing4Lead3Button": "Starte noch heute"
"marketing4Lead3Button": "Starte noch heute",
"emailBlockedRegistration": "Diese E-Mail ist für die Registrierung blockiert. Wenn du denkst, dass das ein Fehler ist, kontaktiere uns bitte unter admin@habitica.com."
}

View File

@@ -0,0 +1,7 @@
{
"adminPanel": "Admin Panel",
"siteBlockers": "Site Blockers",
"newsroom": "Newsroom",
"adminBlockerTypeDescription": "<b>IP-Address</b> - Block access for a specific IP-Address\n\nClient - Block access for a client based on the \"x-client\" header.\n\nE-Mail - Blocks e-mails from being used for signup.",
"adminBlockerAreaDescription": "A blocker can either apply to the full site, completely blocking any access. Or it can apply to purchases, which still allows the site to be accessed."
}

View File

@@ -69,7 +69,6 @@
"awardWinners": "Award Winner",
"doYouWantedToDeleteChallenge": "Do you want to delete this Challenge?",
"deleteChallenge": "Delete Challenge",
"deleteChallengeRefundDescription": "If you delete this Challenge, you will be refunded the Gem prize and the Challenge tasks will remain on the participants' task boards.",
"challengeNamePlaceholder": "What is your Challenge name?",
"challengeSummary": "Summary",
"challengeSummaryPlaceholder": "Write a short description advertising your Challenge to other Habiticans. What is the main purpose of your Challenge and why should people join it? Try to include useful keywords in the description so that Habiticans can easily find it when they search!",

View File

@@ -116,6 +116,7 @@
"missingPassword": "Missing password.",
"missingNewPassword": "Missing new password.",
"invalidEmailDomain": "You cannot register with emails with the following domains: <%= domains %>",
"emailBlockedRegistration": "This E-Mail is blocked from registration. If you think this is a mistake, please contact us at admin@habitica.com.",
"wrongPassword": "Password is incorrect. If you forgot your password, click \"Forgot Password.\"",
"incorrectDeletePhrase": "Please type <%= magicWord %> in all capital letters to delete your account.",
"incorrectResetPhrase": "Please type <%= magicWord %> in all capital letters to reset your account.",
@@ -132,7 +133,7 @@
"passwordReset": "If we have your email or username on file, instructions for setting a new password have been sent to your email.",
"invalidLoginCredentialsLong": "Uh-oh - your email address / username or password is incorrect.\n- Make sure they are typed correctly. Your username and password are case-sensitive.\n- You may have signed up with Facebook or Google-sign-in, not email so double-check by trying them.\n- If you forgot your password, click \"Forgot Password\".",
"invalidCredentials": "There is no account that uses those credentials.",
"accountSuspended": "This account, User ID \"<%= userId %>\", has been blocked for breaking the Community Guidelines (https://habitica.com/static/community-guidelines) or Terms of Service (https://habitica.com/static/terms). For details or to ask to be unblocked, please email our Community Manager at <%= communityManagerEmail %> or ask your parent or guardian to email them. Please include your @Username in the email.",
"accountSuspended": "Your account @<%= username %> has been blocked. For additional information, or to request an appeal, email admin@habitica.com with your Habitica username or User ID.",
"accountSuspendedTitle": "Account has been suspended",
"unsupportedNetwork": "This network is not currently supported.",
"cantDetachSocial": "Account lacks another authentication method; can't detach this authentication method.",

View File

@@ -51,7 +51,6 @@
"notEnoughGems": "Not enough Gems",
"alreadyHave": "Whoops! You already have this item. No need to buy it again!",
"delete": "Delete",
"gem": "Gem",
"gems": "Gems",
"needMoreGems": "Need More Gems?",
"needMoreGemsInfo": "Purchase Gems now, or become a subscriber to buy Gems with Gold, get monthly mystery items, enjoy increased drop caps and more!",

View File

@@ -183,5 +183,6 @@
"incorrectResetPhrase": "Por favor, teclea <%= magicWord %> en mayúsculas para reiniciar tu cuenta.",
"marketing3Lead1Title": "Aplicaciones para Android y iOS",
"marketing4Lead3Button": "Empieza Hoy Mismo",
"missingClientHeader": "Faltan los encabezados x-client."
"missingClientHeader": "Faltan los encabezados x-client.",
"emailBlockedRegistration": "Esta cuenta de E-Mail está bloqueada desde el registro. Si crees que es un error, por favor contacta con nosotros por medio de admin@habitica.com."
}

View File

@@ -38,7 +38,7 @@
"backgroundHauntedHouseNotes": "幽霊屋敷をそっと通りぬけましょう。",
"backgroundPumpkinPatchText": "カボチャ畑",
"backgroundPumpkinPatchNotes": "カボチャ畑でジャック・オ・ランタンを作りましょう。",
"backgrounds112014": "セット6 2014年11月リリース",
"backgrounds112014": "セット62014年11月リリース",
"backgroundHarvestFeastText": "収穫祭",
"backgroundHarvestFeastNotes": "収穫祭を楽しみましょう。",
"backgroundStarrySkiesText": "星空",
@@ -131,7 +131,7 @@
"backgroundSunsetOasisNotes": "夕焼けの沃地で休もう。",
"backgrounds122015": "セット192015年12月リリース",
"backgroundAlpineSlopesText": "雪の山",
"backgroundAlpineSlopesNotes": "雪の山スキーする。",
"backgroundAlpineSlopesNotes": "雪の山スキーをしよう。",
"backgroundSnowySunriseText": "雪の日出",
"backgroundSnowySunriseNotes": "雪の日の出を見よう。",
"backgroundWinterTownText": "都市の冬",
@@ -206,7 +206,7 @@
"backgroundStrangeSewersNotes": "奇妙な下水道で滑りましょう。",
"backgroundRainyCityText": "雨の街",
"backgroundRainyCityNotes": "雨の街をピチャピチャ歩きましょう。",
"backgrounds112016": "セット30 2016年11月リリース",
"backgrounds112016": "セット302016年11月リリース",
"backgroundMidnightCloudsText": "闇夜の雲海",
"backgroundMidnightCloudsNotes": "闇夜の雲海を飛びまわろう。",
"backgroundStormyRooftopsText": "嵐の屋上",
@@ -252,8 +252,8 @@
"backgroundMagicBeanstalkNotes": "魔法の豆の木を登ろう。",
"backgroundMeanderingCaveText": "曲がりくねった洞窟",
"backgroundMeanderingCaveNotes": "曲がりくねった洞窟を探検しよう。",
"backgroundMistiflyingCircusText": "幻想的なサーカス",
"backgroundMistiflyingCircusNotes": "幻想的なサーカスで酔い騒ごう。",
"backgroundMistiflyingCircusText": "マドワシティーのサーカス",
"backgroundMistiflyingCircusNotes": "マドワシティーのサーカスで酔い騒ごう。",
"backgrounds042017": "セット35 2017年4月リリース",
"backgroundBugCoveredLogText": "虫だらけの丸太",
"backgroundBugCoveredLogNotes": "虫だらけの丸太を調査しよう。",
@@ -264,9 +264,9 @@
"backgrounds052017": "セット362017年5月リリース",
"backgroundGuardianStatuesText": "ガーディアンの像",
"backgroundGuardianStatuesNotes": "ガーディアンの像の前で寝ずの番をしよう。",
"backgroundHabitCityStreetsText": "Habit シティの街並み",
"backgroundHabitCityStreetsNotes": "Habit シティの街並みを探検しましょう。",
"backgroundOnATreeBranchText": "木の枝",
"backgroundHabitCityStreetsText": "ハビットシティの街並み",
"backgroundHabitCityStreetsNotes": "ハビットシティの街並みを探検しましょう。",
"backgroundOnATreeBranchText": "木の枝の上",
"backgroundOnATreeBranchNotes": "木の枝の上にとまろう。",
"backgrounds062017": "セット372017年6月リリース",
"backgroundBuriedTreasureText": "埋もれた宝",
@@ -357,7 +357,7 @@
"backgroundDocksNotes": "造船ドックの上で魚釣りをしましょう。",
"backgroundRowboatText": "小舟",
"backgroundRowboatNotes": "小舟の上で輪唱しましょう。",
"backgroundPirateFlagText": "海賊のフラッグ",
"backgroundPirateFlagText": "海賊の",
"backgroundPirateFlagNotes": "見る者に恐怖を与える海賊旗を掲げましょう。",
"backgrounds072018": "セット502018年7月リリース",
"backgroundDarkDeepText": "暗い深海",
@@ -451,7 +451,7 @@
"backgroundUnderwaterVentsText": "海底の熱水噴出孔",
"backgroundSeasideCliffsNotes": "そびえる断崖の美観とともに海辺に立ちましょう。",
"backgroundTreehouseText": "ツリーハウス",
"backgroundTreehouseNotes": "あなた達だけの樹木のアジト、あなた達専用のツリーハウスに集まって遊びましょう。",
"backgroundTreehouseNotes": "林の中に隠れた、自分専用のツリーハウス遊びましょう。",
"backgroundGiantDandelionsNotes": "巨大なタンポポに囲まれてのんびり時を過ごしましょう。",
"backgroundGiantDandelionsText": "巨大なタンポポ",
"backgroundAmidAncientRuinsText": "古代遺跡の真ん中",
@@ -516,8 +516,8 @@
"backgroundRainyBarnyardText": "雨降る納屋の前庭",
"backgroundHabitCityRooftopsNotes": "ハビットシティーの屋根と屋根の間を大胆に跳ぼう。",
"backgroundHabitCityRooftopsText": "ハビットシティーの屋上",
"backgroundHotAirBalloonNotes": "気球で景色の上まで舞い上がろう。",
"backgroundHotAirBalloonText": "気球",
"backgroundHotAirBalloonNotes": "気球に乗って空から景色を眺めよう。",
"backgroundHotAirBalloonText": "気球",
"backgrounds062020": "セット732020年6月リリース",
"backgroundStrawberryPatchNotes": "いちご畑から新鮮な喜びを摘もう。",
"backgroundStrawberryPatchText": "いちご畑",

View File

@@ -2,11 +2,11 @@
"challenge": "チャレンジ",
"challengeDetails": "チャレンジはプレイヤー同士で競争し、一連の関連したタスクを完了させることによって賞品を獲得するコミュニティのイベントです。",
"brokenChaLink": "チャレンジのリンク切れ",
"brokenTask": "チャレンジのリンク切れ: このタスクはもともとチャレンジの一部でしたが、チャレンジから削除されました。どうしますか?",
"brokenTask": "チャレンジのリンク切れ:このタスクはもともとチャレンジの一部でしたが、チャレンジから削除されました。どうしますか?",
"keepIt": "このまま残す",
"removeIt": "消す",
"brokenChallenge": "チャレンジのリンク切れ:このタスクはもともとチャレンジの一部でしたが、チャレンジ(もしくはグループ)が削除されました。残されたタスクはどうしますか?",
"challengeCompleted": "チャレンジ終了です! <span class=\"badge\"><%- user %></span>が優勝しました! 残ったタスクはどうしますか?",
"challengeCompleted": "チャレンジ終了です!<span class=\"badge\"><%- user %></span>が優勝しました!残ったタスクはどうしますか?",
"unsubChallenge": "チャレンジのリンク切れ:このタスクはもともとチャレンジの一部でしたが、あなたがチャレンジ登録を取り消しました。残されたタスクはどうしますか?",
"challenges": "チャレンジ",
"endDate": "終了日",

View File

@@ -1,5 +1,5 @@
{
"playerTiersDesc": "チャットで見られる色のついたユーザー名は、その人の貢献者段位を表しています。段位が高いほど、その人がHabiticaのピクセルアート、コード、コミュニティなどに貢献していることを示します",
"playerTiersDesc": "チャットで見られる色のついたユーザー名は、その人の貢献者段位を表しています。段位が高いほど、その人がHabiticaのピクセルアート、コード、コミュニティなどに貢献していることを示します",
"tier1": "初段 (友人)",
"tier2": "2段 (友人)",
"tier3": "3段 (エリート)",
@@ -10,7 +10,7 @@
"tierModerator": "モデレーター",
"tierStaff": "スタッフ",
"tierNPC": "NPC",
"friend": "友",
"friend": "友",
"elite": "エリート",
"champion": "チャンピオン",
"legendary": "伝説",

View File

@@ -1,7 +1,7 @@
{
"lostAllHealth": "体力がなくなった!",
"dontDespair": "がっかりしないで!",
"deathPenaltyDetails": "レベル、ゴールド、装備の一部を失ってしまいました。しかし、がんばればすべて取り戻せます! あなたなら、きっとやれる――幸あらんことを。",
"deathPenaltyDetails": "レベル、ゴールド、装備の一部を失ってしまいました。しかし、努力すればすべて取り戻せます! あなたなら、きっとできる!頑張って!",
"refillHealthTryAgain": "体力を復活させて、もう一度やってみよう",
"dyingOftenTips": "よく体力がなくなってしまいますか? <a href='https://habitica.fandom.com/ja/wiki/死のしくみ#生き残るための戦略' target='_blank'>ここにヒントがあります! </a>",
"losingHealthWarning": "気をつけて - 体力が減っています!",

View File

@@ -1,10 +1,10 @@
{
"defaultHabit1Text": "生産的な仕事 (鉛筆をクリックして編集)",
"defaultHabit2Text": "ジャンクフードを食べる(鉛筆をクリックして編集)",
"defaultHabit3Text": "階段・エレベーター(鉛筆をクリックして編集)",
"defaultHabit1Text": "生産的な仕事 鉛筆をクリックして編集",
"defaultHabit2Text": "ジャンクフードを食べる鉛筆をクリックして編集",
"defaultHabit3Text": "階段・エレベーター鉛筆をクリックして編集",
"defaultHabit4Text": "Habiticaにタスクを追加しましょう",
"defaultHabit4Notes": "習慣、日課、To Doのどれでも",
"defaultTodo1Text": "Habiticaに参加する(チェックして完了しましょう!)",
"defaultTodo1Text": "Habiticaに参加するチェックして完了しましょう!",
"defaultTodoNotes": "このTo Doを完了にする、または編集、削除できます。",
"defaultReward1Text": "15分間の休憩",
"defaultReward2Text": "自分へのごほうび",

View File

@@ -107,7 +107,7 @@
"greeting0": "こんにちは!",
"greeting1": "ちょっとあいさつしてみただけ (^_^)",
"greeting2": "(遠くから手をブンブン振る)",
"greeting3": "そこのあなた",
"greeting3": "ヤッホー",
"greetingCardAchievementTitle": "Kawaii",
"greetingCardAchievementText": "やあ!よう!こんにちは!<%= count %> 通のあいさつカードをやりとりしました。",
"thankyouCard": "ありがとうのカード",

View File

@@ -49,7 +49,7 @@
"sunsetFaqList10": "Spelers worden tevens aangemoedigd te mailen naar <a href='mailto:admin@habitica.com'>admin@habitica.com</a>met de vragen waarop ze geen antwoord konden vinden in bovenstaande links.",
"sunsetFaqPara20": "Habitica's Gemeenschapsrichtlijnen zullen bijgewerkt worden op het moment dat de service Herberg en Gilden ophouden te bestaan. Ze zullen de klemtoon leggen op gemeenschapsregels die in relatie staan tot het spelersprofiel, Uitdagingen en berichten in private ruimtes. Onze Gebruikersvoorwaarden waren steeds van toepassing op zowel publieke als private ruimtes, en vereisen niet meteen een update hierdoor.",
"contentQuestion0": "Wat veranderd er?",
"commonQuestions": "Veelvoorkomende vragen",
"commonQuestions": "Veelvoorkomende Vragen",
"faqQuestion25": "Wat zijn de verschillende type taken?",
"faqQuestion40": "Wat zijn Edelstenen en hoe kan ik ze krijgen?",
"faqQuestion48": "Kan ik Habitica spelen met anderen?",
@@ -90,5 +90,13 @@
"faqQuestion43": "Hoe kan ik Quests starten?",
"webFaqAnswer43": "Om een Quest te starten, moet je lid zijn van een groep. Groepen kunnen solo-avonturen zijn waarin je Quests alleen aangaat, of je kunt andere Habitica-spelers uitnodigen om Quests sneller aan te pakken!\n\nKies een Quest-scroll uit je inventaris door op de knop “Quest starten” te klikken in je groep. Voltooi je taken zoals je normaal doet om voortgang te boeken in de Quest! Je bouwt schade op tegen een monster als je een Baas-Quest doet, of je maakt kans om voorwerpen te vinden als je een Verzamel-Quest doet. Alle opgebouwde voortgang wordt de volgende dag toegepast.\n\nWanneer je genoeg schade hebt aangericht of alle voorwerpen hebt verzameld, is de Quest voltooid en ontvang je je beloningen!",
"faqQuestion44": "Hoe kan ik Challenge-taken verwijderen?",
"webFaqAnswer44": "Je moet de Challenge verlaten of wachten tot de Challenge wordt afgesloten om de bijbehorende taken te kunnen verwijderen. Een rood megafoonpictogram betekent dat de Challenge is afgesloten, terwijl een grijze megafoon betekent dat de Challenge nog loopt.\n\nOm Challenge-taken te verwijderen in de Android-app:\n1. Tik op een taak die bij de Challenge hoort.\n2. Tik rechtsboven op het scherm op “Verwijderen”.\n3. Kies om de Challenge-taken van je takenlijst te verwijderen.\n\nOm Challenge-taken te verwijderen in de iOS-app:\n1. Zoek de Challenge-taak die je wilt verwijderen en kijk naar het megafoonpictogram.\n2. Als het megafoonpictogram rood is, tik op de taak en selecteer onderaan “Verwijderen”.\n3. Als het megafoonpictogram grijs is, moet je de Challenge opzoeken en deze verlaten om de taak te verwijderen.\n\nOm Challenge-taken te verwijderen op de website:\n1. Zoek de Challenge-taak die je wilt verwijderen en kijk naar het megafoonpictogram.\n2. Als het megafoonpictogram rood is, klik erop en kies om de taken van je takenlijst te verwijderen.\n3. Als het megafoonpictogram grijs is, moet je de Challenge opzoeken en deze verlaten om de taak te verwijderen."
"webFaqAnswer44": "Je moet de Challenge verlaten of wachten tot de Challenge wordt afgesloten om de bijbehorende taken te kunnen verwijderen. Een rood megafoonpictogram betekent dat de Challenge is afgesloten, terwijl een grijze megafoon betekent dat de Challenge nog loopt.\n\nOm Challenge-taken te verwijderen in de Android-app:\n1. Tik op een taak die bij de Challenge hoort.\n2. Tik rechtsboven op het scherm op “Verwijderen”.\n3. Kies om de Challenge-taken van je takenlijst te verwijderen.\n\nOm Challenge-taken te verwijderen in de iOS-app:\n1. Zoek de Challenge-taak die je wilt verwijderen en kijk naar het megafoonpictogram.\n2. Als het megafoonpictogram rood is, tik op de taak en selecteer onderaan “Verwijderen”.\n3. Als het megafoonpictogram grijs is, moet je de Challenge opzoeken en deze verlaten om de taak te verwijderen.\n\nOm Challenge-taken te verwijderen op de website:\n1. Zoek de Challenge-taak die je wilt verwijderen en kijk naar het megafoonpictogram.\n2. Als het megafoonpictogram rood is, klik erop en kies om de taken van je takenlijst te verwijderen.\n3. Als het megafoonpictogram grijs is, moet je de Challenge opzoeken en deze verlaten om de taak te verwijderen.",
"faqQuestion45": "Mijn personage is veranderd in een sneeuwpop, zeester, bloem of geest. Hoe kan ik terug veranderen?",
"webFaqAnswer45": "Één van jouw groepsleden heeft een Seizoens Winkel transformatie voorwerp op jou gebruikt! Jouw personage zal de volgende dag weer normaal worden. Als je de transformatie eerder wilt verwijderen, kan je een tegengif (Zout, Zand, Bloemblaadjes-vrij of Ondoorzichtig drankje) kopen bij Beloningen.",
"faqQuestion46": "Hoe meld ik een bug?",
"webFaqAnswer46": "Als je denkt dat je een bug bent tegengekomen, laat het ons dan weten!\n\nOm een bug in de mobiele apps te melden:\n *In het menu, selecteer Ondersteuning en tik op ''Hulp krijgen'' en scroll omlaag naar ''Meld een bug''\n\nOm een bug op de website te melden:\n *In het menu, selecteer ''Meld een bug''",
"faqQuestion47": "Kan ik gegevens zien om te bekijken hoe goed ik mijn taken en gewoontes heb gedaan?",
"webFaqAnswer47": "Op dit moment heeft Habitica geen visuele weergave van je taak gegevens in de loop van de tijd. Echter kan je op de Habitica website je taak gegevens exporteren via het tabblad ''Site Gegevens'' in Instellingen.",
"webFaqAnswer48": "Ja, met Groepen! Je kunt je eigen Groep starten of je aansluiten bij een bestaande Groep. Samen spelen met andere Habitica spelers is een geweldige manier om Queestes aan te gaan, versterking te ontvangen door de vaardigheden van Groepsleden en je motivatie een boost te geven door extra verantwoordelijkheid.",
"faqQuestion49": "Hoe vind ik een Groep als ik niet bij een Groep ben aangesloten?"
}

View File

@@ -2601,5 +2601,48 @@
"armorSpecialSummer2022WarriorNotes": "Maak je klaar voor een waterige strijd terwijl je jezelf omringt met deze wervelende, spiralerende kolom van lucht en mist. Verhoogt Constitutie met <%= con %>. Beperkte Editie 2022 Zomeruitrusting.",
"armorSpecialSummer2022MageNotes": "Wanneer u dit pantser draagt, glijdt u gemakkelijk door uw werk zoals de mantarog door water glijdt. Verhoogt Intelligentie met <%= int %>. Beperkte Editie 2022 Zomeruitrusting.",
"gearItemsCompleted": "Je bezit alle <%= klass %> uitrusting! Nieuwe uitrustingen worden uitgebracht tijdens de seizoensgebonden Gala's.",
"moreArmoireGearAvailable": "Tot dan kan je <%= armoireCount %> stukken uitrusting in de Betoverde Kast vinden!"
"moreArmoireGearAvailable": "Tot dan kan je <%= armoireCount %> stukken uitrusting in de Betoverde Kast vinden!",
"headAccessoryMystery202307Notes": "Deze machtige Kroon roept cyclonen en stormachtig weer op! Verleent geen voordeel. Juli 2023 Item voor Abonnees.",
"headAccessoryMystery202505Text": "Hoogvliegende zwaluwstaartantennes",
"headAccessoryMystery202410Text": "Snoepmaïsoorjes",
"eyewearMystery202312Text": "Winterse Blauwe Ogen",
"headAccessoryMystery202212Notes": "Versterk je warmte en vriendschap tot nieuwe hoogten met deze sierlijke gouden tiara. Verleent geen voordeel. December 2022 Item voor Abonnees.",
"headAccessoryMystery202310Text": "Kroon van spookachtige lichten",
"eyewearArmoireRoseColoredGlassesText": "Roze bril",
"headAccessoryMystery202305Text": "Eventide-hoorns",
"headAccessoryMystery202405Text": "Vergulde drakenhoorns",
"headAccessoryMystery202302Notes": "Het purr-fecte accessoire om je betoverende glimlach te accentueren. Verleent geen voordeel. Februari 2023 Item voor Abonnees.",
"eyewearMystery202503Notes": "Deze doordringende blik zal elke vechter die je durft uit te dagen met angst vervullen! Geeft geen voordeel. Maart 2025 voorwerp voor Abonnees.",
"eyewearMystery202503Text": "Jade Juggernaut Ogen",
"eyewearMystery202406Text": "Spookpiratenmasker",
"headAccessoryMystery202212Text": "Gletsjer Tiara",
"headAccessoryMystery202205Text": "Schemervleugelige drakenhoorns",
"headAccessoryMystery202203Text": "Onverschrokken Libelle Kroon",
"headAccessoryMystery202203Notes": "Heb je een extra snelheidsboost nodig? De kleine decoratieve vleugeltjes op deze Kroon zijn krachtiger dan ze lijken! Verleent geen voordeel. Maart 2022 Item voor Abonnees.",
"headAccessoryMystery202307Text": "Kraken's Kroon",
"eyewearMystery202201Text": "Middernachtelijk feestvierdersmasker",
"eyewearArmoireComedyMaskText": "Komedie Masker",
"eyewearArmoireTragedyMaskText": "Tragedie Masker",
"eyewearMystery202108Text": "Vurige Ogen",
"eyewearMystery202208Text": "Sprankelende Ogen",
"eyewearMystery202303Text": "Dromerige Ogen",
"eyewearSpecialAnniversaryText": "Habitica Helden Masker",
"headAccessorySpecialHeroicCircletText": "Heroïsche Kroon",
"bodyArmoireKarateOrangeBeltText": "Oranje band",
"headAccessoryMystery202305Notes": "Deze hoorns gloeien door het weerkaatste maanlicht. Verleent geen voordeel. Mei 2023 Item voor Abonnees.",
"headAccessoryMystery202205Notes": "Deze schitterende hoorns zijn zo helder als een zonsondergang in de woestijn. Verleent geen voordeel. Mei 2022 Item voor Abonnees.",
"eyewearMystery202202Text": "Turquoise Ogen met Blush",
"eyewearMystery202204BText": "Virtueel Gezicht",
"headAccessoryMystery202302Text": "Bedrieglijke tabbyoren",
"bodyArmoireKarateGreenBeltText": "Groene band",
"bodyArmoireKaratePurpleBeltText": "Paarse band",
"bodyArmoireKarateBlueBeltText": "Blauwe band",
"bodyArmoireKarateBrownBeltText": "Bruine band",
"bodyArmoireKarateRedBeltText": "Rode band",
"headAccessorySpecialHeroicCircletNotes": "Zwaar is het hoofd dat de kroon draagt, maar deze Kroon is zo licht als je genereuze geest. Verhoogt alle statistieken met <%= attrs %>.",
"bodyArmoireKarateBlackBeltText": "Zwarte band",
"eyewearArmoireJewelersEyeLoupeText": "Juweliersloep",
"eyewearArmoireJewelersEyeLoupeNotes": "Deze oogloep vergroot wat je aan het doen bent, zodat je absoluut elk detail kunt zien. Verhoogt de waarneming met <%= per %>. Betoverd Kabinet: juweliersset (item 2 van 4).",
"eyewearMystery202308Text": "Slaperige Ogen",
"headAccessoryMystery202309Text": "Gigantische antennes van de kometenmot"
}

View File

@@ -10,9 +10,9 @@
"showTour": "Rondleiding starten",
"showBailey": "Bailey laten zien",
"showBaileyPop": "Breng Bailey de Stadsomroeper uit haar schuilplaats zodat je nieuws uit het verleden kunt nalezen.",
"fixVal": "Personagewaarden bijstellen",
"fixValPop": "Handmatig veranderen van waarden zoals gezondheidspunten, niveau en goud.",
"invalidLevel": "Ongeldige waarde: Niveau moet 1 of groter zijn.",
"fixVal": "Personage waarden bijstellen",
"fixValPop": "Handmatig veranderen van waarden zoals Gezondheidspunten, Niveau en Goud.",
"invalidLevel": "Ongeldige waarde: Niveau moet 1 of hoger zijn.",
"enableClass": "Klassensysteem aanzetten",
"enableClassPop": "Aanvankelijk had je geen klasse gekozen. Wil je er nu een kiezen?",
"resetAccPop": "Opnieuw starten, en alle niveaus, goud, uitrusting, geschiedenis en taken verliezen.",
@@ -195,12 +195,12 @@
"passwordSuccess": "Wachtwoord succesvol aangepast",
"transaction_admin_update_balance": "Door beheerder gegeven",
"giftSubscriptionRateText": "<strong>$<%= price %> USD</strong> voor <strong><%= months %> maanden</strong>",
"generalSettings": "Algemene instellingen",
"taskSettings": "Taak instellingen",
"generalSettings": "Algemene Instellingen",
"taskSettings": "Taak Instellingen",
"confirmCancelChanges": "Weet je het zeker? Je wijzigingen worden niet opgeslagen.",
"loginMethods": "Inlog manieren",
"loginMethods": "Inlog Methodes",
"character": "Karakter",
"siteData": "Website informatie",
"siteData": "Website Informatie",
"account": "Account",
"siteLanguage": "Websitetaal"
}

View File

@@ -27,7 +27,7 @@
"userData": "Dane użytkownika",
"exportUserData": "Eksportuj dane użytkownika:",
"export": "Eksport",
"xml": "(XML)",
"xml": "XML",
"json": "JSON",
"customDayStart": "Własny początek dnia",
"sureChangeCustomDayStartTime": "Jesteś pewien, że chcesz zmienić Czas Początku Dnia? Twoje Codzienne będą się resetować, kiedy pierwszy raz włączysz Habitikę po <%= time %>. Upewnij się, że wykonałeś wszystkie swoje Codzienne przed tym czasem!",
@@ -97,9 +97,9 @@
"unsubscribedSuccessfully": "Poprawnie zrezygnowano z subskrypcji!",
"unsubscribedTextUsers": "Poprawnie zrezygnowałeś z subskrypcji wszystkich e-maili od Habitiki. W opcji <a href=\"/user/settings/notifications\">Ustawienia > &gt; Powiadomienia</a> możesz włączyć przesyłanie wybranych powiadomień, które chcesz otrzymywać (wymaga zalogowania).",
"unsubscribedTextOthers": "Nie otrzymasz więcej żadnych e-maili od Habitica.",
"unsubscribeAllEmails": "Zaznacz by zrezygnować z subskrypcji e-maili",
"unsubscribeAllEmails": "Zrezygnuj z subskrypcji e-maili",
"unsubscribeAllEmailsText": "Przez zaznaczenie tego pola poświadczam, że rozumiem że po zrezygnowaniu z subskrypcji wszystkich e-maili, Habitica nie będzie w stanie powiadamiać mnie przez e-mail o ważnych zmianach na stronie lub na moim koncie.",
"unsubscribeAllPush": "Zaznacz aby zrezygnować z subskrypcji wszystkich powiadomień",
"unsubscribeAllPush": "Zrezygnuj z subskrypcji wszystkich powiadomień",
"correctlyUnsubscribedEmailType": "Poprawnie zrezygnowano z subskrypcji e-maili \"<%= emailType %>\".",
"subscriptionRateText": "Okresowa opłata <strong>$<%= price %> USD</strong> co <strong><%= months %> miesięcy</strong>",
"benefits": "Korzyści",
@@ -235,5 +235,6 @@
"developerMode": "Tryb Dewelopera",
"developerModeTooltip": "Habitica zapewnia tryb dewelopera w celu umożliwienia korzystania z dodatkowych funkcji współpracujących z API Habitica.",
"resetDetail2": "Zachowasz swoją obecną klasę, osiągnięcia oraz swoje chowańce i wierzchowce.",
"resetDetail3": "Wszystkie Twoje zadania (poza zadaniami z wyzwań) zostaną trwale usunięte, a Ty utracisz wszystkie dane dotyczące ich historii."
"resetDetail3": "Wszystkie Twoje zadania (poza zadaniami z wyzwań) zostaną trwale usunięte, a Ty utracisz wszystkie dane dotyczące ich historii.",
"contentRelease": "Publikacja treści + Wydarzenia"
}

View File

@@ -2,9 +2,9 @@
"tavernCommunityGuidelinesPlaceholder": "Dostane bir uyarı: bu tüm yaş gruplarına açık bir sohbettir, bu yüzden mesajlarının içeriğini ve üslubunu uygun sınırlar içerisinde tutmaya özen göster! Aklına takılan bir şeyler olursa aşağıdan Topluluk Kuralları'na göz at.",
"lastUpdated": "Son güncelleme:",
"commGuideHeadingWelcome": "Habitica'ya hoş geldin!",
"commGuidePara001": "Selamlar, maceracı! Habitica'ya hoş geldin, burası üretkenlik, sağlıklı yaşam ve zaman zaman deli gibi saldıran gryphon'ların diyarı.",
"commGuidePara002": "Herkesi güvende, mutlu ve üretken tutmak için, Zorluklar, oyuncu profilleri, Parti sohbeti ve özel mesajlar için bazı yönergelerimiz bulunmaktadır. Bu Yönergeleri, olabildiğince dostça ve okunması kolay olacak şekilde dikkatlice hazırladık. Lütfen, diğer oyuncularla etkileşime başlamadan önce bunları okuma zaman ayırın.",
"commGuidePara003": "Bu kurallar zaman zaman uyarlanabilir. Burada listelenen topluluk kurallarında önemli değişiklikler olduğunda, bunu Bailey duyurusunda ve/veya sosyal medya hesaplarımızda duyacaksınız!",
"commGuidePara001": "Merhaba, maceracı! Üretkenliğin, sağlıklı yaşamın ve nadiren de olsa öfkeli griffonların diyarı Habitica'ya hoş geldin.",
"commGuidePara002": "herkesi güvende, mutlu ve üretken olmasına yardım etmek amacıyla; Mücadeleler, Oyuncu Profilleri, Parti Sohbeti ve gizli mesajlar için bazı kurallarımız var. Bu kuralları içten olmaları ve kolaylıkla okunabilmeleri için özenerek hazırladık. Lütfen sohbet etmeye başlamadan önce kuralları okumaya vakit ayır.",
"commGuidePara003": "Bu kurallar arada sırada uyarlanabilir. Burada listelenen topluluk kurallarında önemli değişiklikler olduğunda, bunu Bailey duyurusunda ve/veya sosyal medya hesaplarımızda duyacaksınız!",
"commGuideHeadingInteractions": "Habitica'daki Etkileşimler",
"commGuidePara015": "Habitica, diğer oyuncularla etkileşimde bulunabileceğiniz birkaç alana sahiptir. Bunlar, özel sohbet bağlamları (özel mesajlar ve Parti sohbeti) ile Looking for Party özelliği ve Zorluklar gibi alanlardır.",
"commGuidePara016": "Habitica'nın sosyal bileşenlerinde gezinirken, herkesin güvende ve mutlu olmasını sağlamak için bazı genel kurallar vardır.",

View File

@@ -183,5 +183,6 @@
"incorrectResetPhrase": "请用大写字母输入 <%= magicWord %> 以重置你的账号。",
"marketing3Lead1Title": "Android和iOS上的应用程序",
"marketing4Lead3Button": "今日即刻启程",
"missingClientHeader": "缺少 x-client 请求头。"
"missingClientHeader": "缺少 x-client 请求头。",
"emailBlockedRegistration": "该邮箱已被限制注册。若您认为此系误判,请发送邮件至 admin@habitica.com 联系我们。"
}

View File

@@ -88,5 +88,14 @@
"webFaqAnswer42": "激勵自己去完成各種挑戰任務的最好的方法之一就是——去加入一個隊伍和其他的Habitica玩家一起接收任務贏得寵物和裝備收穫隊伍成員技能加成激發你的動力。\n\n另一種增加動力的方法是加入一個挑戰。挑戰會自動添加在您的任務和目標欄挑戰也會添加一些競賽元素可以給您動力去贏取寶石。這些挑戰既有Habitica官方創建的也有其他玩家創建的。",
"webFaqAnswer43": "如果想開啟副本你需要加入一個隊伍。隊伍可以獨自展開冒險也可以邀請其他的Habitica玩家一起組隊\n\n點擊“開啟副本”並從你的物品中選擇一個副本捲軸。像平常一樣完成你的任務就能作用於副本如果你開啟的是打Boss的副本就能給怪物造成傷害如果開啟的是收集副本就能找到相應的物品。所有打副本的結果將在第二天結算。\n\n當你給予boss足夠的傷害或者收集到足夠的物品後就能完成副本攻略並收穫你的報酬",
"webFaqAnswer44": "你需要離開挑戰或者等待挑戰被關閉才能刪除關聯的任務。紅色的喇叭圖標說明該挑戰已關閉,灰色的喇叭圖標說明挑戰還在進行中。\n\n在**Android**app中刪除挑戰任務需要\n 1. 點擊任務所屬於的挑戰。\n 2. 點擊屏幕右上角的“刪除”按鈕。\n 3. 從任務欄中選擇並移除挑戰任務。\n\n在**iOS**app中刪除挑戰任務需要\n 1. 通過喇叭圖標找到你希望刪除的挑戰任務。\n 2. 如果喇叭圖標是紅色的,點擊任務並選擇“刪除”按鈕\n 3. 如果喇叭圖標是灰色的,你需要找到該挑戰並離開挑戰來移除任務。\n\n在**網頁端**刪除挑戰任務需要:\n 1. 通過喇叭圖標找到你希望刪除的挑戰任務。\n 2. 如果喇叭圖標是紅色的,點擊並從你得任務欄中移除該任務。\n 3. 如果喇叭圖標是灰色的,你需要找到該挑戰並離開挑戰來移除任務。",
"webFaqAnswer45": "可以對其中一名隊伍成員使用季節限定的變身道具!你的形象將在次日從變身恢復。你也可以購買解毒劑(鹽、沙子、無花瓣或不透明藥水)來更快地移除變身形象。"
"webFaqAnswer45": "可以對其中一名隊伍成員使用季節限定的變身道具!你的形象將在次日從變身恢復。你也可以購買解毒劑(鹽、沙子、無花瓣或不透明藥水)來更快地移除變身形象。",
"webFaqAnswer46": "如果你遇到了報錯,請讓我們知道!\n\n在移動端報告報錯\n * 從菜單進入,選擇支持並點擊“尋求幫助”,滾動菜單到“報告錯誤”\n\n在網頁端報告報錯\n * 從幫助菜單進入,選擇“報告錯誤”",
"faqQuestion47": "我能查看有關我的任務和習慣完成情況的數據嗎?",
"webFaqAnswer47": "目前Habitica沒有展示你的任務數據進度的功能在Habitica的網頁端你可以通過設置中的網站數據按鈕導出你的任務數據。",
"webFaqAnswer48": "是的和隊伍一起你可以建立自己的隊伍或者加入一個隊伍。和其他Habitica玩家一起參與副本通過隊員技能得到增益加成來增強你打卡的動力。",
"faqQuestion49": "我該在哪裡找到隊伍去加入?",
"webFaqAnswer49": "如果你想要別人一起體驗Habitica但不認識其他玩家搜索一個隊伍是最好的選擇如果你已經知道某個已經在隊伍裡的玩家你可以分享自己的@用戶名來獲取邀請。同樣,你也能創建一個新的隊伍,通過@用戶名或電郵地址來邀請別人。\n\n創建或搜索隊伍需要在導航欄選擇“隊伍”然後選擇對應的選項。",
"faqQuestion50": "該怎麼搜索隊伍?",
"webFaqAnswer50": "在選擇“尋找隊伍”之後,你將被添加到想要加入隊伍的玩家清單中。隊長能夠看到這個清單並發送邀請。當你收到邀請,你就能在通知欄中選擇想要加入的隊伍!\n\n你也許會收到來自不同隊伍的多個邀請。但是你只能成為其中一個隊伍的隊員。",
"parties": "隊伍"
}

View File

@@ -32,7 +32,8 @@ export default {
postIdRequired: '"postId" must be a valid UUID.',
noNewsPosterAccess: 'You don\'t have news poster access.',
ipAddressBlocked: 'Your access to Habitica has been blocked. This may be due to a breach of our Terms of Service or for other reasons. For details or to ask to be unblocked, please email admin@habitica.com or ask your parent or guardian to email them. Include your Habitica @Username or User Id in the email if you know it.',
ipAddressBlocked: 'Your connection to Habitica has been blocked. For additional information, or to request an appeal, email admin@habitica.com with your Habitica username or User ID.',
clientBlocked: 'This client or third-party tool has been blocked. For additional information, email admin@habitica.com with your Habitica username or User ID.',
clientRateLimited: 'This User ID or IP address has been rate limited due to an excess amount of requests to the Habitica API v3. More info can be found in the response headers and at https://github.com/HabitRPG/habitica/wiki/API-Usage-Guidelines#rate-limiting .',
invalidPlatform: 'Invalid platform specified',

View File

@@ -151,9 +151,7 @@ api.loginSocial = {
// Called by apple for web authentication.
api.redirectApple = {
method: 'POST',
middlewares: [authWithHeaders({
optional: true,
})],
middlewares: [],
url: '/user/auth/apple',
async handler (req, res) {
if (req.body.id_token) {

View File

@@ -1,7 +1,6 @@
import nconf from 'nconf';
import { langCodes } from '../../libs/i18n';
import { serveContent } from '../../libs/content';
import { authWithHeaders } from '../../middlewares/auth';
const IS_PROD = nconf.get('IS_PROD');
@@ -67,21 +66,12 @@ api.getContent = {
method: 'GET',
url: '/content',
noLanguage: true,
middlewares: [authWithHeaders({ optional: true })],
async handler (req, res) {
let language = 'en';
const proposedLang = req.query.language;
if (proposedLang && langCodes.includes(proposedLang)) {
language = proposedLang;
} else if (res.locals.user
&& res.locals.user.preferences
&& res.locals.user.preferences.language
) {
const userLang = res.locals.user.preferences.language;
if (langCodes.includes(userLang)) {
language = userLang;
}
}
let filter = req.query.filter || '';

View File

@@ -1,8 +1,11 @@
import validator from 'validator';
import merge from 'lodash/merge';
import { v4 as uuid } from 'uuid';
import { authWithHeaders } from '../../middlewares/auth';
import { ensurePermission } from '../../middlewares/ensureAccessRight';
import { model as User } from '../../models/user';
import { model as UserHistory } from '../../models/userHistory';
import { model as Blocker } from '../../models/blocker';
import {
NotFound,
} from '../../libs/errors';
@@ -116,4 +119,72 @@ api.getUserHistory = {
},
};
api.getBlockers = {
method: 'GET',
url: '/admin/blockers',
middlewares: [authWithHeaders(), ensurePermission('accessControl')],
async handler (req, res) {
const blockers = await Blocker
.find({ disabled: false })
.lean()
.exec();
res.respond(200, blockers);
},
};
api.createBlocker = {
method: 'POST',
url: '/admin/blockers',
middlewares: [authWithHeaders(), ensurePermission('accessControl')],
async handler (req, res) {
const id = uuid();
const blocker = await Blocker({
_id: id,
...Blocker.sanitize(req.body),
}).save();
res.respond(200, blocker);
},
};
api.updateBlocker = {
method: 'PUT',
url: '/admin/blockers/:blockerId',
middlewares: [authWithHeaders(), ensurePermission('accessControl')],
async handler (req, res) {
req.checkParams('blockerId', res.t('blockerIdRequired')).notEmpty().isUUID();
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
const blocker = await Blocker.findById(req.params.blockerId).exec();
if (!blocker) throw new NotFound(res.t('blockerNotFound'));
merge(blocker, Blocker.sanitize(req.body));
const savedBlocker = await blocker.save();
res.respond(200, savedBlocker);
},
};
api.deleteBlocker = {
method: 'DELETE',
url: '/admin/blockers/:blockerId',
middlewares: [authWithHeaders(), ensurePermission('accessControl')],
async handler (req, res) {
req.checkParams('blockerId', res.t('blockerIdRequired')).notEmpty().isUUID();
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
const blocker = await Blocker.findById(req.params.blockerId).exec();
if (!blocker) throw new NotFound(res.t('blockerNotFound'));
blocker.disabled = true;
const savedBlocker = await blocker.save();
res.respond(200, savedBlocker);
},
};
export default api;

View File

@@ -16,7 +16,11 @@ export function loginRes (user, req, res) {
if (user.auth.blocked) {
throw new NotAuthorized(res.t(
'accountSuspended',
{ communityManagerEmail: COMMUNITY_MANAGER_EMAIL, userId: user._id },
{
communityManagerEmail: COMMUNITY_MANAGER_EMAIL,
userId: user._id,
username: user.auth.local.username,
},
));
}
const urlPath = url.parse(req.url).pathname;

View File

@@ -164,24 +164,18 @@ export default async function highlightMentions (text) {
if (mentions && mentions.length <= 5) {
const usernames = mentions.map(mention => mention.substr(1));
const usernameRegexes = usernames.map(username => new RegExp(`^${escapeRegExp(username)}$`, 'i'));
members = await User
.find({
$or: usernameRegexes.map(regex => ({ 'auth.local.username': regex })),
'flags.verifiedUsername': true,
})
.find({ 'auth.local.username': { $in: usernames }, 'flags.verifiedUsername': true })
.select(['auth.local.username', '_id', 'preferences.pushNotifications', 'pushDevices', 'party', 'guilds'])
.lean()
.exec();
const baseUrl = determineBaseUrl();
members.forEach(member => {
const { username } = member.auth.local;
const regex = new RegExp(`@${escapeRegExp(username)}(?![\\-\\w])`, 'gi');
const regex = new RegExp(`@${username}(?![\\-\\w])`, 'g');
const replacement = `[@${username}](${baseUrl}/profile/${member._id})`;
textBlocks.transformValidBlocks(blockText => blockText.replace(regex, match => {
const mentionedUsername = match.substr(1);
return `[@${mentionedUsername}](${baseUrl}/profile/${member._id})`;
}));
textBlocks.transformValidBlocks(blockText => blockText.replace(regex, replacement));
});
}

View File

@@ -100,6 +100,7 @@ export function authWithHeaders (options = {}) {
throw new NotAuthorized(common.i18n.t('accountSuspended', {
communityManagerEmail: COMMUNITY_MANAGER_EMAIL,
userId: user._id,
username: user.auth.local.username,
}, language));
}

View File

@@ -0,0 +1,69 @@
import nconf from 'nconf';
import {
Forbidden,
} from '../libs/errors';
import { apiError } from '../libs/apiError';
import { model as Blocker } from '../models/blocker';
// Middleware to block unwanted IP addresses and clients
// NOTE: it's meant to be used behind a proxy (for example a load balancer)
// that uses the 'x-forwarded-for' header to forward the original IP addresses.
// A list of comma separated IPs to block
// It works fine as long as the list is short,
// if the list becomes too long for an env variable we'll switch to Redis.
const BLOCKED_IPS_RAW = nconf.get('BLOCKED_IPS');
const blockedIps = BLOCKED_IPS_RAW
? BLOCKED_IPS_RAW
.trim()
.split(',')
.map(blockedIp => blockedIp.trim())
.filter(blockedIp => Boolean(blockedIp))
: [];
const blockedClients = [];
Blocker.watchBlockers({
$or: [
{ type: 'ipaddress' },
{ type: 'client' },
],
area: 'full',
}, {
initial: true,
}).on('change', async change => {
const { operation, blocker } = change;
const checkedList = blocker.type === 'ipaddress' ? blockedIps : blockedClients;
if (operation === 'add') {
if (blocker.value && !checkedList.includes(blocker.value)) {
checkedList.push(blocker.value);
}
} else if (operation === 'delete') {
const index = checkedList.indexOf(blocker.value);
if (index !== -1) {
checkedList.splice(index, 1);
}
}
});
export default function ipBlocker (req, res, next) {
if (blockedIps.length === 0 && blockedClients.length === 0) return next();
const ipMatch = blockedIps.find(blockedIp => blockedIp === req.ip) !== undefined;
if (ipMatch === true) {
const error = new Forbidden(apiError('ipAddressBlocked'));
error.skipLogging = true;
return next(error);
}
const clientMatch = blockedClients.find(blockedClient => blockedClient === req.headers['x-client']) !== undefined;
if (clientMatch === true) {
const error = new Forbidden(apiError('clientBlocked'));
error.skipLogging = true;
return next(error);
}
return next();
}

View File

@@ -66,19 +66,21 @@ export default function errorHandler (err, req, res, next) { // eslint-disable-l
responseErr = new InternalServerError();
}
// log the error
logger.error(err, {
method: req.method,
originalUrl: req.originalUrl,
if (!err.skipLogging) {
// log the error
logger.error(err, {
method: req.method,
originalUrl: req.originalUrl,
// don't send sensitive information that only adds noise
headers: omit(req.headers, ['x-api-key', 'cookie', 'password', 'confirmPassword']),
body: omit(req.body, ['password', 'confirmPassword']),
query: omit(req.query, ['password', 'confirmPassword']),
// don't send sensitive information that only adds noise
headers: omit(req.headers, ['x-api-key', 'cookie', 'password', 'confirmPassword']),
body: omit(req.body, ['password', 'confirmPassword']),
query: omit(req.query, ['password', 'confirmPassword']),
httpCode: responseErr.httpCode,
isHandledError: responseErr.httpCode < 500,
});
httpCode: responseErr.httpCode,
isHandledError: responseErr.httpCode < 500,
});
}
const jsonRes = {
success: false,

View File

@@ -23,7 +23,7 @@ import {
forceSSL,
forceHabitica,
} from './redirects';
import ipBlocker from './ipBlocker';
import blocker from './blocker';
import v1 from './v1';
import v2 from './v2';
import appRoutes from './appRoutes';
@@ -81,7 +81,7 @@ export default function attachMiddlewares (app, server) {
app.use(maintenanceMode);
app.use(ipBlocker);
app.use(blocker);
app.use(cors);
app.use(forceSSL);

View File

@@ -1,38 +0,0 @@
import nconf from 'nconf';
import {
Forbidden,
} from '../libs/errors';
import { apiError } from '../libs/apiError';
// Middleware to block unwanted IP addresses
// NOTE: it's meant to be used behind a proxy (for example a load balancer)
// that uses the 'x-forwarded-for' header to forward the original IP addresses.
// A list of comma separated IPs to block
// It works fine as long as the list is short,
// if the list becomes too long for an env variable we'll switch to Redis.
const BLOCKED_IPS_RAW = nconf.get('BLOCKED_IPS');
const blockedIps = BLOCKED_IPS_RAW
? BLOCKED_IPS_RAW
.trim()
.split(',')
.map(blockedIp => blockedIp.trim())
.filter(blockedIp => Boolean(blockedIp))
: [];
export default function ipBlocker (req, res, next) {
// If there are no IPs to block, skip the middleware
if (blockedIps.length === 0) return next();
// Is the client IP, req.ip, blocked?
const match = blockedIps.find(blockedIp => blockedIp === req.ip) !== undefined;
if (match === true) {
// Not translated because no user is loaded at this point
return next(new Forbidden(apiError('ipAddressBlocked')));
}
return next();
}

View File

@@ -0,0 +1,103 @@
/* eslint-disable camelcase */
import mongoose from 'mongoose';
import EventEmitter from 'events';
import baseModel from '../libs/baseModel';
export const blockTypes = [
'ipaddress',
'email',
'client',
];
export const blockArea = [
'full',
'payments',
];
export const schema = new mongoose.Schema({
disabled: {
$type: Boolean, default: false, // If true, the block is disabled
},
type: {
$type: String, enum: blockTypes, required: true,
},
area: {
$type: String, enum: blockArea, default: 'full', // full or payment
},
value: {
$type: String, required: true, // e.g. IP address
},
blockSource: {
$type: String, enum: ['administrator', 'system', 'worker'], default: 'administrator', // who created the block
},
reason: {
$type: String, required: false, // e.g. 'abusive behavior'
},
}, {
strict: true,
minimize: false, // So empty objects are returned
typeKey: '$type', // So that we can use fields named `type`
});
schema.plugin(baseModel, {
timestamps: true,
});
schema.statics.watchBlockers = function watchBlockers (query, options) {
const emitter = new EventEmitter();
const matchQuery = {
$match: {},
};
if (query) {
if (query.type) {
matchQuery.$match['fullDocument.type'] = query.type;
}
if (query.area) {
matchQuery.$match['fullDocument.area'] = query.area;
}
}
process.nextTick(() => {
this.watch([matchQuery], {
fullDocument: 'updateLookup',
})
.on('change', change => {
if (!change.fullDocument) {
return; // Ignore changes that don't have a fullDocument
}
if (change.operationType === 'insert' || !change.fullDocument.disabled) {
emitter.emit('change', {
operation: 'add',
blocker: change.fullDocument,
});
} else if (change.operationType === 'update' && change.fullDocument.disabled) {
emitter.emit('change', {
operation: 'delete',
blocker: change.fullDocument,
});
}
})
.on('error', error => {
emitter.emit('error', error);
});
if (options.initial) {
const initialQuery = {
disabled: false,
...query,
};
this.find(initialQuery).then(docs => {
for (const doc of docs) {
emitter.emit('change', {
operation: 'add',
blocker: doc,
});
}
}).catch(error => {
emitter.emit('error', error);
});
}
});
return emitter;
};
export const model = mongoose.model('Blocker', schema);

View File

@@ -9,9 +9,31 @@ import { schema as SubscriptionPlanSchema } from '../subscriptionPlan';
import { schema as TagSchema } from '../tag';
import { schema as UserNotificationSchema } from '../userNotification';
import { schema as WebhookSchema } from '../webhook';
import { model as Blocker } from '../blocker';
const RESTRICTED_EMAIL_DOMAINS = Object.freeze(['habitica.com', 'habitrpg.com']);
const BLOCKED_EMAILS = [];
Blocker.watchBlockers({
type: 'email',
area: 'full',
}, {
initial: true,
}).on('change', async change => {
const { operation, blocker: { value } } = change;
if (operation === 'add') {
if (value && !BLOCKED_EMAILS.includes(value)) {
BLOCKED_EMAILS.push(value);
}
} else if (operation === 'delete') {
const index = BLOCKED_EMAILS.indexOf(value);
if (index !== -1) {
BLOCKED_EMAILS.splice(index, 1);
}
}
});
// User schema definition
export const UserSchema = new Schema({
apiToken: {
@@ -43,6 +65,12 @@ export const UserSchema = new Schema({
return RESTRICTED_EMAIL_DOMAINS.every(domain => !lowercaseEmail.endsWith(`@${domain}`));
},
message: shared.i18n.t('invalidEmailDomain', { domains: RESTRICTED_EMAIL_DOMAINS.join(', ') }),
}, {
validator (email) {
const lowercaseEmail = email.toLowerCase();
return BLOCKED_EMAILS.every(block => lowercaseEmail.indexOf(block) === -1);
},
message: shared.i18n.t('emailBlockedRegistration'),
}],
},
username: {
@@ -196,6 +224,7 @@ export const UserSchema = new Schema({
userSupport: Boolean, // access User Support feature in Admin Panel
challengeAdmin: Boolean, // Can manage and administrate challenges
moderator: Boolean, // Can ban, flag users and manage social spaces
accessControl: Boolean, // Can manage IP and client blockers
coupons: Boolean, // Can generate and request coupons
},
balance: { $type: Number, default: 0 },