mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-10-28 11:42:29 +01:00
Compare commits
31 Commits
fiz/update
...
fiz/ToS-st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd06148422 | ||
|
|
a0b179561b | ||
|
|
9a1fb18959 | ||
|
|
876d5a67d6 | ||
|
|
3078af8f2a | ||
|
|
dad1440138 | ||
|
|
12773d539e | ||
|
|
2ea0b64603 | ||
|
|
bd1aa1e417 | ||
|
|
7c49b845d6 | ||
|
|
1ee172139d | ||
|
|
6447b9ab4b | ||
|
|
5c414099d9 | ||
|
|
5e8e1179aa | ||
|
|
7e86a62624 | ||
|
|
1ba9dda0ed | ||
|
|
227e5ceaa8 | ||
|
|
f77ab5a3ab | ||
|
|
1916faf647 | ||
|
|
80ecb5cef1 | ||
|
|
75c36e6622 | ||
|
|
78330c975a | ||
|
|
95266f6cb3 | ||
|
|
e9b2c1b51a | ||
|
|
2a2bea07ab | ||
|
|
ea60ddbf4c | ||
|
|
1c2ca0e478 | ||
|
|
ef2b7eb928 | ||
|
|
3d16387a61 | ||
|
|
93b7770eaa | ||
|
|
a9f84d3307 |
Submodule habitica-images updated: 992d838120...aa72332019
76
package-lock.json
generated
76
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
206
test/api/unit/middlewares/blocker.test.js
Normal file
206
test/api/unit/middlewares/blocker.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
@@ -126,7 +126,7 @@
|
||||
@click="changeApiToken()"
|
||||
>
|
||||
Change API Token
|
||||
</a>
|
||||
</a>
|
||||
<div
|
||||
v-if="tokenModified"
|
||||
>
|
||||
@@ -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"
|
||||
@@ -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: {
|
||||
@@ -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">
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
@@ -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: {
|
||||
@@ -180,7 +180,7 @@
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import { userStateMixin } from '../../../mixins/userState';
|
||||
import { userStateMixin } from '../../../../mixins/userState';
|
||||
|
||||
export default {
|
||||
filters: {
|
||||
@@ -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;
|
||||
133
website/client/src/components/admin/blocker/blocker_form.vue
Normal file
133
website/client/src/components/admin/blocker/blocker_form.vue
Normal 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>
|
||||
238
website/client/src/components/admin/blocker/index.vue
Normal file
238
website/client/src/components/admin/blocker/index.vue
Normal 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>
|
||||
40
website/client/src/components/admin/container.vue
Normal file
40
website/client/src/components/admin/container.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -286,7 +286,7 @@
|
||||
:to="{ name: 'adminPanelUser',
|
||||
params: { userIdentifier: hero._id } }"
|
||||
>
|
||||
admin panel
|
||||
{{ $t("adminPanel") }}
|
||||
</router-link>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
<Sprite
|
||||
slot="icon"
|
||||
class="mt-3"
|
||||
:image-name="notification.data.icon" />
|
||||
:image-name="notification.data.icon"
|
||||
/>
|
||||
</base-notification>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
></div>
|
||||
<Sprite
|
||||
slot="icon"
|
||||
:image-name="mysteryClass" />
|
||||
:image-name="mysteryClass"
|
||||
/>
|
||||
</base-notification>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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' });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
action () {
|
||||
this.$router.push(`/profile/${this.$store.state.user.data._id}#stats`);
|
||||
this.$router.push({ name: 'stats' });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
class="balance-info"
|
||||
:currency-needed="currencyNeeded"
|
||||
:amount-needed="amountNeeded"
|
||||
:neededCurrencyOnly="true"
|
||||
:needed-currency-only="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
19
website/client/src/store/actions/blockers.js
Normal file
19
website/client/src/store/actions/blockers.js
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
7
website/common/locales/en/admin.json
Normal file
7
website/common/locales/en/admin.json
Normal 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."
|
||||
}
|
||||
@@ -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!",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"backgroundHauntedHouseNotes": "幽霊屋敷をそっと通りぬけましょう。",
|
||||
"backgroundPumpkinPatchText": "カボチャ畑",
|
||||
"backgroundPumpkinPatchNotes": "カボチャ畑でジャック・オ・ランタンを作りましょう。",
|
||||
"backgrounds112014": "セット6: 2014年11月リリース",
|
||||
"backgrounds112014": "セット6:2014年11月リリース",
|
||||
"backgroundHarvestFeastText": "収穫祭",
|
||||
"backgroundHarvestFeastNotes": "収穫祭を楽しみましょう。",
|
||||
"backgroundStarrySkiesText": "星空",
|
||||
@@ -131,7 +131,7 @@
|
||||
"backgroundSunsetOasisNotes": "夕焼けの沃地で休もう。",
|
||||
"backgrounds122015": "セット19:2015年12月リリース",
|
||||
"backgroundAlpineSlopesText": "雪の山",
|
||||
"backgroundAlpineSlopesNotes": "雪の山にスキーする。",
|
||||
"backgroundAlpineSlopesNotes": "雪の山でスキーをしよう。",
|
||||
"backgroundSnowySunriseText": "雪の日出",
|
||||
"backgroundSnowySunriseNotes": "雪の日の出を見よう。",
|
||||
"backgroundWinterTownText": "都市の冬",
|
||||
@@ -206,7 +206,7 @@
|
||||
"backgroundStrangeSewersNotes": "奇妙な下水道で滑りましょう。",
|
||||
"backgroundRainyCityText": "雨の街",
|
||||
"backgroundRainyCityNotes": "雨の街をピチャピチャ歩きましょう。",
|
||||
"backgrounds112016": "セット30: 2016年11月リリース",
|
||||
"backgrounds112016": "セット30:2016年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": "セット36:2017年5月リリース",
|
||||
"backgroundGuardianStatuesText": "ガーディアンの像",
|
||||
"backgroundGuardianStatuesNotes": "ガーディアンの像の前で寝ずの番をしよう。",
|
||||
"backgroundHabitCityStreetsText": "Habit シティーの街並み",
|
||||
"backgroundHabitCityStreetsNotes": "Habit シティーの街並みを探検しましょう。",
|
||||
"backgroundOnATreeBranchText": "木の枝で",
|
||||
"backgroundHabitCityStreetsText": "ハビットシティの街並み",
|
||||
"backgroundHabitCityStreetsNotes": "ハビットシティの街並みを探検しましょう。",
|
||||
"backgroundOnATreeBranchText": "木の枝の上",
|
||||
"backgroundOnATreeBranchNotes": "木の枝の上にとまろう。",
|
||||
"backgrounds062017": "セット37:2017年6月リリース",
|
||||
"backgroundBuriedTreasureText": "埋もれた宝",
|
||||
@@ -357,7 +357,7 @@
|
||||
"backgroundDocksNotes": "造船ドックの上で魚釣りをしましょう。",
|
||||
"backgroundRowboatText": "小舟",
|
||||
"backgroundRowboatNotes": "小舟の上で輪唱しましょう。",
|
||||
"backgroundPirateFlagText": "海賊のフラッグ",
|
||||
"backgroundPirateFlagText": "海賊の旗",
|
||||
"backgroundPirateFlagNotes": "見る者に恐怖を与える海賊旗を掲げましょう。",
|
||||
"backgrounds072018": "セット50:2018年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": "セット73:2020年6月リリース",
|
||||
"backgroundStrawberryPatchNotes": "いちご畑から新鮮な喜びを摘もう。",
|
||||
"backgroundStrawberryPatchText": "いちご畑",
|
||||
|
||||
@@ -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": "終了日",
|
||||
|
||||
@@ -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": "伝説",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"lostAllHealth": "体力がなくなった!",
|
||||
"dontDespair": "がっかりしないで!",
|
||||
"deathPenaltyDetails": "レベル、ゴールド、装備の一部を失ってしまいました。しかし、がんばればすべてを取り戻せます! あなたなら、きっとやれる――幸あらんことを。",
|
||||
"deathPenaltyDetails": "レベル、ゴールド、装備の一部を失ってしまいました。しかし、努力すればすべて取り戻せます! あなたなら、きっとできる!頑張って!",
|
||||
"refillHealthTryAgain": "体力を復活させて、もう一度やってみよう",
|
||||
"dyingOftenTips": "よく体力がなくなってしまいますか? <a href='https://habitica.fandom.com/ja/wiki/死のしくみ#生き残るための戦略' target='_blank'>ここにヒントがあります! </a>",
|
||||
"losingHealthWarning": "気をつけて - 体力が減っています!",
|
||||
|
||||
@@ -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": "自分へのごほうび",
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
"greeting0": "こんにちは!",
|
||||
"greeting1": "ちょっとあいさつしてみただけ (^_^)",
|
||||
"greeting2": "(遠くから手をブンブン振る)",
|
||||
"greeting3": "そこのあなた!",
|
||||
"greeting3": "ヤッホー!",
|
||||
"greetingCardAchievementTitle": "Kawaii",
|
||||
"greetingCardAchievementText": "やあ!よう!こんにちは!<%= count %> 通のあいさつカードをやりとりしました。",
|
||||
"thankyouCard": "ありがとうのカード",
|
||||
|
||||
@@ -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?"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 > > 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"
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -183,5 +183,6 @@
|
||||
"incorrectResetPhrase": "请用大写字母输入 <%= magicWord %> 以重置你的账号。",
|
||||
"marketing3Lead1Title": "Android和iOS上的应用程序",
|
||||
"marketing4Lead3Button": "今日即刻启程",
|
||||
"missingClientHeader": "缺少 x-client 请求头。"
|
||||
"missingClientHeader": "缺少 x-client 请求头。",
|
||||
"emailBlockedRegistration": "该邮箱已被限制注册。若您认为此系误判,请发送邮件至 admin@habitica.com 联系我们。"
|
||||
}
|
||||
|
||||
@@ -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": "隊伍"
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
69
website/server/middlewares/blocker.js
Normal file
69
website/server/middlewares/blocker.js
Normal 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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
103
website/server/models/blocker.js
Normal file
103
website/server/models/blocker.js
Normal 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);
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user