Compare commits

..

102 Commits

Author SHA1 Message Date
SabreCat 0a22038d05 4.230.0 2022-05-10 13:56:00 -05:00
SabreCat 8e1bc6bcd7 chore(sprites): compile
also fix a few string typos
2022-05-09 14:23:51 -05:00
CuriousMagpie beb51fb00d 2022-05 backgrounds and enchanted 2022-05-09 14:23:45 -05:00
CuriousMagpie 7548834442 2022-05 backgrounds and enchanted armoire images 2022-05-09 14:23:37 -05:00
Alys 2a4886b325 merge User ID column into Name column in Hall of Heroes 2022-05-09 13:53:51 -05:00
Alys a1d0403782 fix bug in hasPermissions call to stop normal users seeing UserID column 2022-05-09 13:53:40 -05:00
Sabe Jones 9de6f7b3bc fix(Docker): include failsafe for Git HTTPS 2022-05-06 17:26:48 -05:00
SabreCat d6bd10fa25 4.229.2 2022-05-05 13:56:08 -05:00
SabreCat 55f3792c96 Merge branch 'develop' into release 2022-05-05 13:56:02 -05:00
dependabot[bot] a390dd82a7 build(deps): bump superagent from 7.1.2 to 7.1.3 (#13963)
Bumps [superagent](https://github.com/visionmedia/superagent) from 7.1.2 to 7.1.3.
- [Release notes](https://github.com/visionmedia/superagent/releases)
- [Changelog](https://github.com/visionmedia/superagent/blob/master/HISTORY.md)
- [Commits](https://github.com/visionmedia/superagent/commits/v7.1.3)

---
updated-dependencies:
- dependency-name: superagent
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-04 17:10:52 -04:00
dependabot[bot] d47f30fc10 build(deps): bump jwks-rsa from 2.0.5 to 2.1.0 (#13958)
Bumps [jwks-rsa](https://github.com/auth0/node-jwks-rsa) from 2.0.5 to 2.1.0.
- [Release notes](https://github.com/auth0/node-jwks-rsa/releases)
- [Changelog](https://github.com/auth0/node-jwks-rsa/blob/master/CHANGELOG.md)
- [Commits](https://github.com/auth0/node-jwks-rsa/compare/v2.0.5...v2.1.0)

---
updated-dependencies:
- dependency-name: jwks-rsa
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-04 17:09:20 -04:00
dependabot[bot] 03744c63f6 build(deps): bump rate-limiter-flexible from 2.3.6 to 2.3.7 (#13956)
Bumps [rate-limiter-flexible](https://github.com/animir/node-rate-limiter-flexible) from 2.3.6 to 2.3.7.
- [Release notes](https://github.com/animir/node-rate-limiter-flexible/releases)
- [Commits](https://github.com/animir/node-rate-limiter-flexible/commits)

---
updated-dependencies:
- dependency-name: rate-limiter-flexible
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-04 17:09:04 -04:00
dependabot[bot] f2a418e3fa build(deps): bump @babel/core from 7.17.9 to 7.17.10 (#13955)
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.17.9 to 7.17.10.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.17.10/packages/babel-core)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-05-04 17:08:44 -04:00
Sabe Jones 1477326351 Log analytics event when cron fails (#13945)
* feat(debug): log analytics event when cron fails

* feat(debug): include status code

Co-authored-by: SabreCat <sabe@habitica.com>
2022-05-03 14:42:45 -05:00
Phillip Thelen 38b39b600c Adminpanel and revamped permissions (#13843)
* create Admin Panel page with initial content from Hall's admin section

* reorganise Admin Panel form and add more accordians

* add lastCron to fields returned by api.getHeroes

* improve timestamps and authentication section

* add party and quest info to Admin Panel, add party to heroAdminFields

* move Admin Panel menu item to top of menu, make invisible to non-admins

* remove code used for displaying all Heroes

* add avatar appearance and drops section in Admin Panel

* allow logged-in user to be the default hero loaded

* add time zones to timestamp/authentication section

* rename Items to Update Items

This will allow a new Items section to be added.

* add read-only Items display with button to copy data to Update Items section

* remove never-used allItemsPaths code that had been copied from Hall

* update tests for the attributes added to heroAdminFields

* supply names for items and also set information for gear/equipment

* remove code that loads subsections of content

We use enough of the content that it's easier to load it all and
access it through the content object, especially when we're looping
through different item types.

* add gear names and set details to Avatar Costume/Battle Gear section

* make the wiki URLs clickable and make minor item format improvements

* add gear sets for Check-In Incentives and animal ears and tails

* add gear set for Gold-Purchasable Quest Lines

Also merges the existing Mystery of the Masterclassers quest set into it.

* fix error with Kickstarter gear set and include wiki link

* improve description of check-in incentive gear set

* fix description of Items section

* fix lint warnings

* update another test for the attributes added to heroAdminFields

* allow "@" to be included when specifying Username to load

* create GetHeroParty API v3 route to fetch a given user's party data

Only some data from the party will be loaded (e.g., not private
data such as name, description).

Includes tests for the route.

See the next commit for front-end changes that use this.

* display data from a given user's party in admin panel

Only some data from the party will be loaded (e.g., not private
data such as name, description).

Also adds support for finding and displaying errors from the
user's data.

* use new error handling method for other sections

- Time zone differences
- Cron bugs
- Privilege removal (mute/block) - not a bug but needs to be highlighted

* redirect non-admin users away from admin-only page (WIP)

This needs more work. Currently, admin users are also redirected
if they access the page by direct URL or after reload.

* clarify source of items from Check-In Incentives and Lunar Battle quests

* replace non-standard form fields with HTML forms

* add user's language, remove unused export blocks

* convert functions to filters: formatDate, formatTimeZone

* improve display of minutes portion of time zone in Admin Panel

* move basic details about user to a new component

* move Timestamp/Cron/Auth/etc details to a new component - WIP, has errors

The automatic expand and error warnings don't reset themselves when
you fetch data for a new user.

* replace non-standard form fields with HTML forms

Most of this was done in 26fdcbbee5

* move Timestamp/Cron/Auth/etc details to a new component (fixed)

* move Avatar and Drops section to a new component

* move Party and Quest section to a new component

* move Contributor Details to new component, add checkbox for admin, add preview

This adds a markdown-enabled preview of the Contributions textarea.

It also removes the code that automatically set contributor.admin
to true when the Tier was above 7.
That feature wasn't secure because the Tier can be accidentally
changed if you scroll while the cursor is over the Tier form field
(we accidentally demoted a Socialite once by doing that and if
we'd scrolled in the other direction we would have given her
admin privileges).

Instead there's now a checkbox for giving moderator-level privileges.
We'll want that anyway when we move to a system of selected
privileges for each admin instead of all admin privileges being
given to all mods/staff.

There's also a commented-out checkbox for giving Bailey CMS
privileges, for when we're ready to use that. The User model doesn't
yet have support for it.

* move Privileges and Gems section to a new component

* rename formatItems to getItemDescription; make other minor fixes

* remove an outdated test description

This "pended" explanation probably wasn't needed after "x" was
removed from "describe" in 2ab76db27c

* add newsPoster Bailey CMS permission to User model and Admin Panel

* move formatDate from mixins to filters

* make lint fixes

* remove development comments from hall.js

I'll be handling the TODO comment and I've left in my "XXX" marker
to remind me

* fix bug in Hall's castItemVal: mounts are null not false

* move Items section to a new component and delete Update Items section

The Update Items section is no longer needed because the new Items
component has in-place editing.

* remove unused imports

* add "secret" field to "Privileges, Gem Balance" section.

Also move the markdownPreview style from contributorDetails.vue to
index.vue since it's used in two components now.

* show non-Standard never-owned Pets and Mounts in Items section

* redirect non-admin users away from admin-only page

This completes the work started in commit a4f9c754ad

It now allows admins to access the page when coming from another
page on the site or from a direct link, including if the admin user
isn't logged in yet.

* display memberCount for party

* add secret.text field to Contributor Details

This is in addition to showing it in the Privileges section because
the secret text could be about either troublesome behaviour or
contributions.

* allow user to be loaded into Admin Panel via a URL

This includes:

- router config has a child route for the admin panel with a
Username/ID as a parameter
- loadHero code moved from top-level index page into a new
"user support" index page
- links in the Hall changed to point to admin panel route
- admin panel link added to admin section of user profile modal

* keep list of known titles on their own lines

* sort heroFields alphabetically

No actual changes.

* return all flags for use in Admin Panel and fix Hall tests for flags

Future Admin Panel changes will display more flags.

NB 'flags' wasn't in the tests before, even though two optional
flags were being fetched.
The tests weren't failing because the test users hadn't been given
data for those optional flags.

The primary reason for this change now is to fix the tests.

* show part of the API Token in the Admin Panel

* send full hero object into cronAndAuth.vue

This is a prelude to allowing this component to change the hero.

* split heroAdminFields string into two: one for fetching data and one for showing it

This is because apiToken must be fetched but not shown,
while apiTokenObscured is calculated (not fetched) and shown.

* let admin change a user's API Token

* restore sanity

* remove code to show obscured version of API Token

It will return with tighter permissions for viewing it.

* add Custom Day Start time (CDS) to Timestamps, Time Zone... section

* commit lint's automatic fixes - one for admin-panel changes in hall.js

The other fixes aren't related to this PR but I figured they may
as well go live.

* apply fixes from paglias's comments, excluding style/CSS changesd

The comments that this PR fixes start at
https://github.com/HabitRPG/habitica/pull/12035#pullrequestreview-500422316

Style fixes will be in a future commit.

* fix styles/CSS

* allow profile modal to close when using admin panel link

Also removes an empty components block.

* prevent Admin Panel being used without new userSupport privilege

Also adds initial support for other contributor.priv privileges
and changes Debug Menu to add userSupport privilege

* don't do this: this.hero = { ...hero };

* enhance quest error messages

* redirect to admin-panel home page when using "Save and Clear Data"

The user's ID / name is still in the form for easy refetching.

* create ensurePriv function, use in api.getHeroParty

* fix lint problems and integration tests

* add page title to top-level Admin Panel

Also add more details to a router comment (consistent with a similar
comment) in case it helps anyone.

* fix tests

* display Moderation Notes above Contributions

* lint fix

* remove placeholder code for new privileges

I had planned to have each of these implemented in stages, but
paglias wanted it all done at once. I'm afraid that's too big a
project for me to take on in a single PR so I'm cancelling
the plans for adjusting the privileges.

* Improve permission handling

* Don't report timezone error on first day

* fix lint error

* .

* Fix lint error

* fix failing tests

* Fix more tests

* .

* ..

* ...

* fix(admin): always include permissions when querying user
also remove unnecessary failing test case

* permission improvements

* show transactions in admin panel

* fix lint errors

* fix permission check

* fix(panel): missing mixin, handle empty perms object

Co-authored-by: Alys <alice.harris@oldgods.net>
Co-authored-by: SabreCat <sabe@habitica.com>
2022-05-03 14:40:56 -05:00
SabreCat 864293b62b 4.229.1 2022-05-02 14:26:47 -05:00
SabreCat bada094139 chore(login): remove FB login option on web client 2022-05-02 14:26:42 -05:00
Aleksandr Saitgalin 1823f658c6 update user and group in a transaction when creating a group. fixes #12124 (#13730)
* fix #12124

add a transaction for updating user and group
so the user doesn't lose gems when saving the group fails

* use mongoose transaction helper

use the helper instead of manually commiting/aborting
to deal with transient transaction errors

* increase timeout and add console.log outputs

add some logging to a failing test

* Revert "increase timeout and add console.log outputs"

This reverts commit 0c36aaa55f.

* add a test for gems when guild creation fails

test the transaction in createGroup()
make sure user keeps the gems if group.save() rejects

* fix(test): try suggested delay from PR discussion

Co-authored-by: SabreCat <sabe@habitica.com>
2022-04-29 16:47:17 -05:00
dependabot[bot] 9a3e3c93eb build(deps): bump glob from 7.2.0 to 8.0.1 (#13938)
Bumps [glob](https://github.com/isaacs/node-glob) from 7.2.0 to 8.0.1.
- [Release notes](https://github.com/isaacs/node-glob/releases)
- [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/node-glob/compare/v7.2.0...v8.0.1)

---
updated-dependencies:
- dependency-name: glob
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-29 16:16:23 -05:00
Vanathi G 7a94b031e0 Fix char values issue (#13946)
* Fix selection highlight in avatar editor

* Add validation for Fix Character Values fields
2022-04-29 15:57:32 -05:00
SabreCat 22b5a5e6f2 Merge branch 'release' into develop 2022-04-29 14:27:18 -05:00
SabreCat 1d68a95b64 4.229.0 2022-04-29 14:24:24 -05:00
SabreCat 88999a0751 Merge branch 'develop' into release 2022-04-29 14:22:07 -05:00
SabreCat 2666c93e5f chore(css): compile images 2022-04-29 14:21:53 -05:00
Weblate 132918419a Translated using Weblate (Filipino)
Currently translated at 82.0% (110 of 134 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 94.1% (2432 of 2583 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (212 of 212 strings)

Translated using Weblate (Filipino)

Currently translated at 88.0% (118 of 134 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (Filipino)

Currently translated at 95.9% (358 of 373 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (220 of 220 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (373 of 373 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (690 of 690 strings)

Translated using Weblate (French)

Currently translated at 100.0% (690 of 690 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 93.9% (2426 of 2583 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (690 of 690 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (French)

Currently translated at 100.0% (220 of 220 strings)

Translated using Weblate (French)

Currently translated at 100.0% (2583 of 2583 strings)

Translated using Weblate (French)

Currently translated at 99.2% (685 of 690 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 96.2% (664 of 690 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 100.0% (689 of 689 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (220 of 220 strings)

Translated using Weblate (Filipino)

Currently translated at 97.0% (362 of 373 strings)

Translated using Weblate (Filipino)

Currently translated at 97.3% (363 of 373 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 98.2% (677 of 689 strings)

Translated using Weblate (Spanish)

Currently translated at 99.8% (689 of 690 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 97.5% (672 of 689 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (690 of 690 strings)

Translated using Weblate (Filipino)

Currently translated at 97.3% (363 of 373 strings)

Translated using Weblate (Filipino)

Currently translated at 88.0% (118 of 134 strings)

Translated using Weblate (Filipino)

Currently translated at 97.3% (363 of 373 strings)

Translated using Weblate (Indonesian)

Currently translated at 95.4% (125 of 131 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (134 of 134 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (129 of 129 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (690 of 690 strings)

Translated using Weblate (Filipino)

Currently translated at 89.5% (120 of 134 strings)

Translated using Weblate (Filipino)

Currently translated at 96.2% (129 of 134 strings)

Translated using Weblate (Chinese (Traditional))

Currently translated at 96.0% (663 of 690 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 97.2% (670 of 689 strings)

Translated using Weblate (Filipino)

Currently translated at 45.0% (50 of 111 strings)

Translated using Weblate (Filipino)

Currently translated at 45.0% (50 of 111 strings)

Translated using Weblate (Filipino)

Currently translated at 45.9% (51 of 111 strings)

Translated using Weblate (Filipino)

Currently translated at 80.8% (76 of 94 strings)

Translated using Weblate (Filipino)

Currently translated at 90.0% (118 of 131 strings)

Translated using Weblate (Filipino)

Currently translated at 98.6% (368 of 373 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (220 of 220 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (197 of 197 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 100.0% (220 of 220 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (220 of 220 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (180 of 180 strings)

Translated using Weblate (German)

Currently translated at 100.0% (180 of 180 strings)

Translated using Weblate (German)

Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (German)

Currently translated at 100.0% (373 of 373 strings)

Translated using Weblate (German)

Currently translated at 100.0% (689 of 689 strings)

Co-authored-by: Benoit Hetru <me+hbtc@gahanka.net>
Co-authored-by: Bo-Hsiang Chen <rubybhchen@gmail.com>
Co-authored-by: Chap <chalda82+nogravatar@gmail.com>
Co-authored-by: Heriastuti Puteri Utami <putchayviolet@yahoo.co.id>
Co-authored-by: Hexe des Windes (she/her) <krausanna1@gmail.com>
Co-authored-by: Ike Osenberg <ike.osenberg@gmail.com>
Co-authored-by: Jerry Chen <minecjraft@qq.com>
Co-authored-by: Nazar Paruna <nazarparuna@gmail.com>
Co-authored-by: Sandra Marcial <sandramarcial80@gmail.com>
Co-authored-by: Sciuridae <sweetvshoney@163.com>
Co-authored-by: Thiago Monteiro <thiagoasmonteiro@gmail.com>
Co-authored-by: Vince Vilan <vincemorel.vilan.889@my.csun.edu>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: mattya 226 <worldworld1114@gmail.com>
Co-authored-by: 普通学生何某人 <hewei5002@hotmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/fil/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/id/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/de/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/es/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/it/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/zh_Hant/
Translate-URL: https://translate.habitica.com/projects/habitica/content/de/
Translate-URL: https://translate.habitica.com/projects/habitica/content/fil/
Translate-URL: https://translate.habitica.com/projects/habitica/content/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/front/de/
Translate-URL: https://translate.habitica.com/projects/habitica/front/es/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/es/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/it/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/fil/
Translate-URL: https://translate.habitica.com/projects/habitica/quests/fil/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/de/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/it/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/fil/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/zh_Hans/
Translation: Habitica/Achievements
Translation: Habitica/Backgrounds
Translation: Habitica/Content
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Limited
Translation: Habitica/Npc
Translation: Habitica/Pets
Translation: Habitica/Quests
Translation: Habitica/Questscontent
Translation: Habitica/Settings
Translation: Habitica/Subscriber
Translation: Habitica/Tasks
2022-04-29 06:28:10 +02:00
CuriousMagpie 5faa49d032 2022-05 Subscriber Items 2022-04-28 15:54:09 -04:00
dependabot[bot] 55c800cb4b build(deps): bump stripe from 8.217.0 to 8.219.0 (#13949)
Bumps [stripe](https://github.com/stripe/stripe-node) from 8.217.0 to 8.219.0.
- [Release notes](https://github.com/stripe/stripe-node/releases)
- [Changelog](https://github.com/stripe/stripe-node/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-node/compare/v8.217.0...v8.219.0)

---
updated-dependencies:
- dependency-name: stripe
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-25 12:53:12 -04:00
Natalie L deee147928 Corrected spelling of Wotchimon and description of rage effect. (#13943) 2022-04-21 16:39:54 -05:00
SabreCat a9cd36c109 Merge branch 'background-toggle' into develop 2022-04-21 16:24:04 -05:00
SabreCat 4c1c00b0a3 fix(bgs): revert wording change and tokenize 2022-04-21 16:22:36 -05:00
SabreCat 245e135be8 4.228.4 2022-04-21 14:09:48 -05:00
Weblate 978c707e17 Translated using Weblate (German)
Currently translated at 99.7% (687 of 689 strings)

Translated using Weblate (German)

Currently translated at 99.5% (686 of 689 strings)

Translated using Weblate (German)

Currently translated at 100.0% (220 of 220 strings)

Translated using Weblate (German)

Currently translated at 99.9% (2581 of 2583 strings)

Translated using Weblate (German)

Currently translated at 99.2% (684 of 689 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (134 of 134 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (134 of 134 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (197 of 197 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (220 of 220 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.1% (210 of 214 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.1% (210 of 214 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (180 of 180 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (689 of 689 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (689 of 689 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (212 of 212 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 96.5% (2494 of 2583 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (180 of 180 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (373 of 373 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 91.5% (194 of 212 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 95.0% (2456 of 2583 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (134 of 134 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (197 of 197 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 96.2% (663 of 689 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.7% (364 of 365 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 94.8% (2450 of 2583 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (56 of 56 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (Japanese)

Currently translated at 97.6% (207 of 212 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 95.2% (656 of 689 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 93.7% (2422 of 2583 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 100.0% (220 of 220 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (220 of 220 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (220 of 220 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (186 of 186 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (212 of 212 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (212 of 212 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (2579 of 2579 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (2579 of 2579 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 97.7% (215 of 220 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.0% (2533 of 2583 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (2579 of 2579 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Filipino)

Currently translated at 90.0% (191 of 212 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (56 of 56 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (129 of 129 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (61 of 61 strings)

Translated using Weblate (Japanese)

Currently translated at 99.5% (219 of 220 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (365 of 365 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.0% (2533 of 2583 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (2579 of 2579 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (214 of 214 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (180 of 180 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (56 of 56 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (186 of 186 strings)

Translated using Weblate (Japanese)

Currently translated at 97.6% (207 of 212 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.7% (372 of 373 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (2583 of 2583 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (2583 of 2583 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (373 of 373 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (373 of 373 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 93.8% (199 of 212 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 96.8% (212 of 219 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (219 of 219 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (Japanese)

Currently translated at 99.0% (748 of 755 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.6% (752 of 755 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.6% (752 of 755 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (373 of 373 strings)

Co-authored-by: Chap <chalda82+nogravatar@gmail.com>
Co-authored-by: Céu <marcel.ufscar@gmail.com>
Co-authored-by: Heitor Menezes Gomes <heitorgmenezes@gmail.com>
Co-authored-by: Hexe des Windes (she/her) <krausanna1@gmail.com>
Co-authored-by: Ike Osenberg <ike.osenberg@gmail.com>
Co-authored-by: Jerry Chen <minecjraft@qq.com>
Co-authored-by: Sandra Marcial <sandramarcial80@gmail.com>
Co-authored-by: Sciuridae <sweetvshoney@163.com>
Co-authored-by: Vince Vilan <vincemorel.vilan.889@my.csun.edu>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: blacksheep47 <1760906326@qq.com>
Co-authored-by: mattya 226 <worldworld1114@gmail.com>
Co-authored-by: そら <comi4work@gmail.com>
Co-authored-by: 普通学生何某人 <hewei5002@hotmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/de/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/character/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/content/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/content/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/content/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/front/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/front/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/front/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/it/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/de/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/es/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/it/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/messages/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/it/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/fil/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/spells/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/tasks/zh_Hans/
Translation: Habitica/Achievements
Translation: Habitica/Backgrounds
Translation: Habitica/Character
Translation: Habitica/Content
Translation: Habitica/Faq
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Generic
Translation: Habitica/Groups
Translation: Habitica/Limited
Translation: Habitica/Messages
Translation: Habitica/Npc
Translation: Habitica/Questscontent
Translation: Habitica/Settings
Translation: Habitica/Spells
Translation: Habitica/Subscriber
Translation: Habitica/Tasks
2022-04-21 21:08:52 +02:00
SabreCat 623d38f281 feat(nav): clicking "Group" goes to first group 2022-04-19 15:38:15 -05:00
dependabot[bot] c88e458a97 build(deps-dev): bump sinon from 13.0.1 to 13.0.2 (#13940)
Bumps [sinon](https://github.com/sinonjs/sinon) from 13.0.1 to 13.0.2.
- [Release notes](https://github.com/sinonjs/sinon/releases)
- [Changelog](https://github.com/sinonjs/sinon/blob/main/docs/changelog.md)
- [Commits](https://github.com/sinonjs/sinon/compare/v13.0.1...v13.0.2)

---
updated-dependencies:
- dependency-name: sinon
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-18 16:27:57 -04:00
dependabot[bot] 9957db0669 build(deps): bump nconf from 0.11.4 to 0.12.0 (#13939)
Bumps [nconf](https://github.com/flatiron/nconf) from 0.11.4 to 0.12.0.
- [Release notes](https://github.com/flatiron/nconf/releases)
- [Changelog](https://github.com/indexzero/nconf/blob/master/CHANGELOG.md)
- [Commits](https://github.com/flatiron/nconf/compare/v0.11.4...v0.12.0)

---
updated-dependencies:
- dependency-name: nconf
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-18 16:21:35 -04:00
dependabot[bot] 2e21f58ae5 build(deps): bump moment from 2.29.2 to 2.29.3 (#13936)
Bumps [moment](https://github.com/moment/moment) from 2.29.2 to 2.29.3.
- [Release notes](https://github.com/moment/moment/releases)
- [Changelog](https://github.com/moment/moment/blob/2.29.3/CHANGELOG.md)
- [Commits](https://github.com/moment/moment/compare/2.29.2...2.29.3)

---
updated-dependencies:
- dependency-name: moment
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-18 16:20:39 -04:00
dependabot[bot] 04be6d0744 build(deps): bump stripe from 8.216.0 to 8.217.0 (#13935)
Bumps [stripe](https://github.com/stripe/stripe-node) from 8.216.0 to 8.217.0.
- [Release notes](https://github.com/stripe/stripe-node/releases)
- [Changelog](https://github.com/stripe/stripe-node/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-node/compare/v8.216.0...v8.217.0)

---
updated-dependencies:
- dependency-name: stripe
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-18 16:19:48 -04:00
dependabot[bot] 8c43164e60 build(deps): bump apidoc from 0.51.0 to 0.51.1 (#13934)
Bumps [apidoc](https://github.com/apidoc/apidoc) from 0.51.0 to 0.51.1.
- [Release notes](https://github.com/apidoc/apidoc/releases)
- [Changelog](https://github.com/apidoc/apidoc/blob/master/CHANGELOG.md)
- [Commits](https://github.com/apidoc/apidoc/compare/0.51.0...0.51.1)

---
updated-dependencies:
- dependency-name: apidoc
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-18 16:19:28 -04:00
SabreCat 3fa940bdfd Merge remote-tracking branch 'CuriousMagpie/quest-refactors' into develop 2022-04-15 16:35:27 -05:00
Natalie L 3492549081 changed update to updateOne (#13932) 2022-04-15 16:20:37 -05:00
SabreCat b7a6dd9706 4.228.3 2022-04-15 14:24:08 -05:00
SabreCat 78bdb52f8f Merge branch 'develop' into release 2022-04-15 14:24:02 -05:00
SabreCat d80aaf3ab0 Merge branch 'develop' into release 2022-04-15 14:23:46 -05:00
negue 31cac936c8 Sprites: re-add previous offsets to sprites (#13933)
* Sprites: re-add previous offsets to sprites

* fix lint
2022-04-15 14:14:48 -05:00
Weblate 3b54064a1f Merge branch 'origin/develop' into Weblate. 2022-04-14 22:34:46 +02:00
Weblate 8b46df757f Translated using Weblate (Spanish (Latin America))
Currently translated at 94.1% (649 of 689 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (689 of 689 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (689 of 689 strings)

Translated using Weblate (Tagalog)

Currently translated at 1.1% (2 of 179 strings)

Translated using Weblate (Malayalam)

Currently translated at 60.9% (420 of 689 strings)

Translated using Weblate (Malayalam)

Currently translated at 38.1% (50 of 131 strings)

Translated using Weblate (Tagalog)

Currently translated at 87.5% (7 of 8 strings)

Translated using Weblate (Tagalog)

Currently translated at 76.4% (162 of 212 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.0% (2533 of 2583 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.0% (2533 of 2583 strings)

Translated using Weblate (Tagalog)

Currently translated at 75.9% (161 of 212 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 100.0% (373 of 373 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 93.0% (641 of 689 strings)

Translated using Weblate (French)

Currently translated at 100.0% (373 of 373 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (373 of 373 strings)

Translated using Weblate (Spanish)

Currently translated at 99.7% (687 of 689 strings)

Translated using Weblate (Spanish)

Currently translated at 99.7% (687 of 689 strings)

Translated using Weblate (Tagalog)

Currently translated at 75.9% (161 of 212 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.9% (747 of 755 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (755 of 755 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (373 of 373 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (689 of 689 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.8% (746 of 755 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (752 of 755 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 87.2% (185 of 212 strings)

Co-authored-by: Chap <chalda82+nogravatar@gmail.com>
Co-authored-by: Ike Osenberg <ike.osenberg@gmail.com>
Co-authored-by: JoanZeppeli <x17501668978@163.com>
Co-authored-by: Linda Li <wli62442@gmail.com>
Co-authored-by: Salman Mujeeb <kingleopard22@gmail.com>
Co-authored-by: Sara López <sarayupy@gmail.com>
Co-authored-by: Sealeo Wu <anitayuanli@gmail.com>
Co-authored-by: Thiago Monteiro <thiagoasmonteiro@gmail.com>
Co-authored-by: Vince Vilan <vincemorel.vilan.889@my.csun.edu>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: mattya 226 <worldworld1114@gmail.com>
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/ml/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/es/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ml/
Translate-URL: https://translate.habitica.com/projects/habitica/content/es/
Translate-URL: https://translate.habitica.com/projects/habitica/content/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/content/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/content/it/
Translate-URL: https://translate.habitica.com/projects/habitica/front/tl/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/inventory/tl/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/it/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/zh_Hans/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/tl/
Translation: Habitica/Achievements
Translation: Habitica/Backgrounds
Translation: Habitica/Content
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Inventory
Translation: Habitica/Questscontent
Translation: Habitica/Settings
2022-04-14 22:30:28 +02:00
SabreCat 4a9040aefb 4.228.2 2022-04-14 15:28:51 -05:00
SabreCat 723249e60d Merge branch 'develop' into release 2022-04-14 15:28:47 -05:00
SabreCat 8b17ebd1f1 fix(settings): don't show email change without email, refresh page 2022-04-14 14:51:11 -05:00
SabreCat 1c4c7b9f1e fix(settings): correct show/hide for email and pass scenarios 2022-04-14 14:21:39 -05:00
SabreCat 58887d9a3c chore(build): ignore non-deployable folders 2022-04-14 13:35:30 -05:00
Phillip Thelen 664f960a8b Pull in missing changes from FB removal PR (#13931)
* fix(auth): hide post hoc Facebook reg

* Pull in missing changes

* fix(lint): whitespace

* fix(strings): missing error message

* fix(tests): update to match functionality

Co-authored-by: SabreCat <sabe@habitica.com>
2022-04-14 12:58:37 -05:00
will yang dfe53e8b68 Hide Locked Background Year Tab 2022-04-13 17:14:14 -04:00
SabreCat b608f0ad9c fix(potion): correct year 2022-04-12 16:01:27 -05:00
Weblate afd1248ea3 Merge branch 'origin/develop' into Weblate. 2022-04-12 21:43:47 +02:00
Weblate 0708829b2a Translated using Weblate (Spanish (Latin America))
Currently translated at 100.0% (214 of 214 strings)

Translated using Weblate (French)

Currently translated at 100.0% (212 of 212 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (372 of 372 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 100.0% (372 of 372 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 93.4% (2415 of 2583 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 100.0% (98 of 98 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (186 of 186 strings)

Translated using Weblate (Japanese)

Currently translated at 97.6% (207 of 212 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 92.8% (640 of 689 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 94.4% (2439 of 2583 strings)

Translated using Weblate (Portuguese)

Currently translated at 84.7% (111 of 131 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.2% (213 of 219 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 94.0% (2429 of 2583 strings)

Translated using Weblate (Malayalam)

Currently translated at 14.5% (19 of 131 strings)

Translated using Weblate (Arabic)

Currently translated at 92.3% (121 of 131 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 93.9% (2428 of 2583 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 93.9% (2428 of 2583 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 93.9% (2428 of 2583 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.7% (744 of 746 strings)

Translated using Weblate (Portuguese)

Currently translated at 99.7% (371 of 372 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.6% (673 of 689 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.6% (673 of 689 strings)

Translated using Weblate (Japanese)

Currently translated at 96.6% (205 of 212 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.6% (673 of 689 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (689 of 689 strings)

Translated using Weblate (Japanese)

Currently translated at 99.8% (688 of 689 strings)

Translated using Weblate (German)

Currently translated at 100.0% (212 of 212 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 92.1% (635 of 689 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (2583 of 2583 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (2583 of 2583 strings)

Translated using Weblate (Italian)

Currently translated at 98.7% (2552 of 2583 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (179 of 179 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (689 of 689 strings)

Translated using Weblate (Italian)

Currently translated at 98.2% (677 of 689 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (219 of 219 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (746 of 746 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (210 of 210 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (197 of 197 strings)

Translated using Weblate (Italian)

Currently translated at 99.5% (209 of 210 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (2579 of 2579 strings)

Translated using Weblate (Japanese)

Currently translated at 99.8% (2574 of 2579 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 88.0% (185 of 210 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.6% (673 of 689 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 93.3% (2412 of 2583 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 99.2% (130 of 131 strings)

Translated using Weblate (Spanish)

Currently translated at 98.9% (2557 of 2583 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 87.5% (7 of 8 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 96.4% (54 of 56 strings)

Translated using Weblate (Spanish (Latin America))

Currently translated at 83.9% (47 of 56 strings)

Translated using Weblate (Spanish)

Currently translated at 99.5% (209 of 210 strings)

Translated using Weblate (Hindi)

Currently translated at 50.4% (56 of 111 strings)

Translated using Weblate (Arabic)

Currently translated at 68.4% (76 of 111 strings)

Translated using Weblate (Arabic)

Currently translated at 87.5% (113 of 129 strings)

Translated using Weblate (Arabic)

Currently translated at 59.5% (410 of 689 strings)

Translated using Weblate (Spanish)

Currently translated at 98.9% (2556 of 2583 strings)

Translated using Weblate (Italian)

Currently translated at 96.1% (199 of 207 strings)

Translated using Weblate (Spanish)

Currently translated at 98.7% (2550 of 2583 strings)

Translated using Weblate (Spanish)

Currently translated at 99.4% (685 of 689 strings)

Translated using Weblate (Spanish)

Currently translated at 99.2% (684 of 689 strings)

Translated using Weblate (Spanish)

Currently translated at 99.2% (684 of 689 strings)

Translated using Weblate (Japanese)

Currently translated at 99.7% (2573 of 2579 strings)

Translated using Weblate (Malayalam)

Currently translated at 19.8% (22 of 111 strings)

Translated using Weblate (Malayalam)

Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (197 of 197 strings)

Translated using Weblate (German)

Currently translated at 100.0% (197 of 197 strings)

Translated using Weblate (Spanish)

Currently translated at 98.6% (2548 of 2583 strings)

Translated using Weblate (German)

Currently translated at 99.8% (2579 of 2583 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (179 of 179 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (746 of 746 strings)

Translated using Weblate (Spanish)

Currently translated at 99.1% (683 of 689 strings)

Translated using Weblate (German)

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (207 of 207 strings)

Translated using Weblate (German)

Currently translated at 100.0% (197 of 197 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.5% (672 of 689 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (German)

Currently translated at 99.7% (2577 of 2583 strings)

Translated using Weblate (German)

Currently translated at 100.0% (179 of 179 strings)

Translated using Weblate (German)

Currently translated at 100.0% (127 of 127 strings)

Translated using Weblate (German)

Currently translated at 100.0% (207 of 207 strings)

Co-authored-by: Benoit Hetru <me+hbtc@gahanka.net>
Co-authored-by: Chap <chalda82+nogravatar@gmail.com>
Co-authored-by: Céu <marcel.ufscar@gmail.com>
Co-authored-by: Heitor Menezes Gomes <heitorgmenezes@gmail.com>
Co-authored-by: Hexe des Windes (she/her) <krausanna1@gmail.com>
Co-authored-by: Ike Osenberg <ike.osenberg@gmail.com>
Co-authored-by: Mara Dolichotis <marascherzer@gmail.com>
Co-authored-by: PenariaToji <tojipeh@gmail.com>
Co-authored-by: Raithe <RaitheOfDureya@gmail.com>
Co-authored-by: Salman Mujeeb <kingleopard22@gmail.com>
Co-authored-by: Sandra Marcial <sandramarcial80@gmail.com>
Co-authored-by: Sara López <sarayupy@gmail.com>
Co-authored-by: Vinicius Rodrigues <suburbanizar@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: citrusella <citrusellaflugpucker@yahoo.com>
Co-authored-by: mattya 226 <worldworld1114@gmail.com>
Co-authored-by: sbrcrbac <fh0f0c9s9@relay.firefox.com>
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/ar/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/de/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/es/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/ml/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/achievements/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ar/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/es/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/it/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/character/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/de/
Translate-URL: https://translate.habitica.com/projects/habitica/content/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/content/pt/
Translate-URL: https://translate.habitica.com/projects/habitica/content/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/front/de/
Translate-URL: https://translate.habitica.com/projects/habitica/front/es/
Translate-URL: https://translate.habitica.com/projects/habitica/front/it/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/es/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/it/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/generic/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/inventory/ml/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/it/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/ar/
Translate-URL: https://translate.habitica.com/projects/habitica/overview/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/ar/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/hi/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/ml/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/es/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/it/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/de/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/es/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/es_419/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/it/
Translate-URL: https://translate.habitica.com/projects/habitica/settings/ja/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/de/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/es/
Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/it/
Translation: Habitica/Achievements
Translation: Habitica/Backgrounds
Translation: Habitica/Challenge
Translation: Habitica/Character
Translation: Habitica/Communityguidelines
Translation: Habitica/Content
Translation: Habitica/Faq
Translation: Habitica/Front
Translation: Habitica/Gear
Translation: Habitica/Generic
Translation: Habitica/Inventory
Translation: Habitica/Limited
Translation: Habitica/Npc
Translation: Habitica/Overview
Translation: Habitica/Pets
Translation: Habitica/Questscontent
Translation: Habitica/Settings
Translation: Habitica/Subscriber
2022-04-12 21:43:37 +02:00
SabreCat afa9a65933 Merge branch 'release' into develop 2022-04-12 14:41:03 -05:00
CuriousMagpie c8ee51b741 move quest constants to their own folder 2022-04-11 17:10:01 -04:00
CuriousMagpie 2bf63847c9 Merge branch 'quest-refactors' of https://github.com/CuriousMagpie/habitica into quest-refactors 2022-04-11 13:44:15 -04:00
CuriousMagpie 1d1b66d25a Merge remote-tracking branch 'upstream/develop' into quest-refactors 2022-04-11 13:44:03 -04:00
dependabot[bot] 9ce4482040 build(deps): bump winston from 3.6.0 to 3.7.2 (#13927)
Bumps [winston](https://github.com/winstonjs/winston) from 3.6.0 to 3.7.2.
- [Release notes](https://github.com/winstonjs/winston/releases)
- [Changelog](https://github.com/winstonjs/winston/blob/master/CHANGELOG.md)
- [Commits](https://github.com/winstonjs/winston/commits)

---
updated-dependencies:
- dependency-name: winston
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-11 12:38:23 -04:00
dependabot[bot] 28660c0bea build(deps): bump @babel/core from 7.17.8 to 7.17.9 (#13926)
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.17.8 to 7.17.9.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.17.9/packages/babel-core)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-11 12:37:12 -04:00
dependabot[bot] bc15d530e5 build(deps): bump stripe from 8.215.0 to 8.216.0 (#13925)
Bumps [stripe](https://github.com/stripe/stripe-node) from 8.215.0 to 8.216.0.
- [Release notes](https://github.com/stripe/stripe-node/releases)
- [Changelog](https://github.com/stripe/stripe-node/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-node/compare/v8.215.0...v8.216.0)

---
updated-dependencies:
- dependency-name: stripe
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-11 12:35:01 -04:00
dependabot[bot] 34b7acb246 build(deps): bump nconf from 0.11.3 to 0.11.4 (#13924)
Bumps [nconf](https://github.com/flatiron/nconf) from 0.11.3 to 0.11.4.
- [Release notes](https://github.com/flatiron/nconf/releases)
- [Changelog](https://github.com/indexzero/nconf/blob/master/CHANGELOG.md)
- [Commits](https://github.com/flatiron/nconf/compare/v0.11.3...v0.11.4)

---
updated-dependencies:
- dependency-name: nconf
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-11 12:16:00 -04:00
SabreCat 4ef369c6f5 chore(submodule): update habitica-images 2022-04-08 15:45:32 -05:00
SabreCat 405721602f fix(quests): correct events import, prod bug with seasonal availability 2022-04-07 15:51:14 -05:00
SabreCat 19cf89baec fix(pr): remove unrelated change 2022-04-07 15:29:52 -05:00
SabreCat 8a4e9888dd Merge branch 'develop' into quest-refactors 2022-04-07 15:27:19 -05:00
SabreCat e17b86a1f6 fix(bug-report): relevant change from #13922 2022-04-07 14:48:26 -05:00
negue 2181ab9713 Redesign: "Day Start Adjustment" (#13910)
* Merge Custom Day Start and Timezone into one Component

* ui changes

* change translation text

* typo
2022-04-07 14:37:07 -05:00
negue 8cb8411cc6 Show "Next Hourglass" Month (#13860)
* Show "Next Hourglass" Month

* fix lint

* lint,

* lint

* lint..

* linting bad

* ui fixes

* remove additional margin

* show next hourglass date to debug further

* WIP tests - maybe broken logic

* flex:1 doesn't work - so stats columns now at 33% width

* fix(cron): lint and short circuit

* refactor logic

* update test dates using timezone

* also check for the timezone date

* fix timezone for tests

* fixing the test dates?

* fixing the test dates?

* change nextHourglass logic + update gem cap label / value

* fix lint

* dont add gemsBought to it

* remove tooltip

Co-authored-by: SabreCat <sabe@habitica.com>
2022-04-06 16:30:13 -05:00
dependabot[bot] 05cf0cb50d build(deps): bump stripe from 8.212.0 to 8.215.0 (#13919)
Bumps [stripe](https://github.com/stripe/stripe-node) from 8.212.0 to 8.215.0.
- [Release notes](https://github.com/stripe/stripe-node/releases)
- [Changelog](https://github.com/stripe/stripe-node/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-node/compare/v8.212.0...v8.215.0)

---
updated-dependencies:
- dependency-name: stripe
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-06 11:27:13 -04:00
Weblate eed6cfaf6d Translated using Weblate (Japanese)
Currently translated at 99.5% (2567 of 2579 strings)

Translated using Weblate (Japanese)

Currently translated at 99.4% (2566 of 2579 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.0% (2528 of 2579 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 97.8% (2524 of 2579 strings)

Translated using Weblate (Hebrew)

Currently translated at 100.0% (134 of 134 strings)

Translated using Weblate (Hebrew)

Currently translated at 58.9% (33 of 56 strings)

Translated using Weblate (Hebrew)

Currently translated at 84.6% (11 of 13 strings)

Translated using Weblate (Hebrew)

Currently translated at 46.8% (44 of 94 strings)

Translated using Weblate (Hebrew)

Currently translated at 57.6% (64 of 111 strings)

Translated using Weblate (Hebrew)

Currently translated at 73.7% (45 of 61 strings)

Translated using Weblate (Hebrew)

Currently translated at 18.1% (4 of 22 strings)

Translated using Weblate (Japanese)

Currently translated at 99.3% (2563 of 2579 strings)

Translated using Weblate (Hebrew)

Currently translated at 49.0% (1264 of 2579 strings)

Translated using Weblate (Hebrew)

Currently translated at 70.0% (150 of 214 strings)

Translated using Weblate (Hebrew)

Currently translated at 46.4% (59 of 127 strings)

Translated using Weblate (Hebrew)

Currently translated at 66.9% (249 of 372 strings)

Translated using Weblate (Hebrew)

Currently translated at 71.5% (133 of 186 strings)

Translated using Weblate (Hebrew)

Currently translated at 98.9% (97 of 98 strings)

Translated using Weblate (Hebrew)

Currently translated at 45.0% (307 of 682 strings)

Translated using Weblate (Hebrew)

Currently translated at 67.9% (89 of 131 strings)

Translated using Weblate (Hebrew)

Currently translated at 72.9% (151 of 207 strings)

Translated using Weblate (Hebrew)

Currently translated at 98.5% (132 of 134 strings)

Translated using Weblate (Hebrew)

Currently translated at 91.2% (333 of 365 strings)

Translated using Weblate (Hebrew)

Currently translated at 100.0% (98 of 98 strings)

Translated using Weblate (Hebrew)

Currently translated at 97.7% (131 of 134 strings)

Translated using Weblate (Hebrew)

Currently translated at 61.9% (122 of 197 strings)

Translated using Weblate (Hebrew)

Currently translated at 98.2% (55 of 56 strings)

Translated using Weblate (Hebrew)

Currently translated at 68.0% (64 of 94 strings)

Translated using Weblate (Hebrew)

Currently translated at 91.8% (102 of 111 strings)

Translated using Weblate (Hebrew)

Currently translated at 100.0% (22 of 22 strings)

Translated using Weblate (Hebrew)

Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Hebrew)

Currently translated at 93.2% (167 of 179 strings)

Translated using Weblate (Hebrew)

Currently translated at 81.4% (44 of 54 strings)

Translated using Weblate (Hebrew)

Currently translated at 76.2% (569 of 746 strings)

Translated using Weblate (Hebrew)

Currently translated at 74.8% (95 of 127 strings)

Translated using Weblate (Hebrew)

Currently translated at 83.8% (312 of 372 strings)

Translated using Weblate (Hebrew)

Currently translated at 96.7% (180 of 186 strings)

Translated using Weblate (Hebrew)

Currently translated at 100.0% (98 of 98 strings)

Translated using Weblate (Hebrew)

Currently translated at 69.4% (91 of 131 strings)

Translated using Weblate (Hebrew)

Currently translated at 88.4% (183 of 207 strings)

Translated using Weblate (Japanese)

Currently translated at 99.1% (2557 of 2579 strings)

Translated using Weblate (Japanese)

Currently translated at 99.1% (2556 of 2579 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (179 of 179 strings)

Translated using Weblate (Japanese)

Currently translated at 99.4% (178 of 179 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (French)

Currently translated at 100.0% (179 of 179 strings)

Translated using Weblate (French)

Currently translated at 100.0% (131 of 131 strings)

Translated using Weblate (French)

Currently translated at 100.0% (207 of 207 strings)

Translated using Weblate (Japanese)

Currently translated at 98.9% (2551 of 2579 strings)

Translated using Weblate (French)

Currently translated at 100.0% (2579 of 2579 strings)

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

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