Compare commits

..

46 Commits

Author SHA1 Message Date
Phillip Thelen
118840d6ce lint 2025-08-22 14:04:03 +02:00
Phillip Thelen
bd46740d0e handle case where users party no longer exists 2025-08-21 12:33:23 +02:00
Phillip Thelen
f1a1bc0c6f don’t show terminate button if in group plan 2025-08-21 12:28:36 +02:00
Phillip Thelen
d2ddab11e5 refactor sub admin cardto use new formRow 2025-08-21 12:25:17 +02:00
Phillip Thelen
d76ed9eaa9 Improve display of groups in admin area 2025-08-21 12:03:07 +02:00
Phillip Thelen
0677c4ab9c Improve group admin options 2025-08-20 19:45:34 +02:00
Phillip Thelen
faa1db4687 move delete button in admin panel 2025-08-20 12:01:09 +02:00
Phillip Thelen
92b4c10ed2 correctly call method 2025-08-20 11:57:05 +02:00
Phillip Thelen
58ec028d5d improve searching for email in admin panel 2025-08-20 11:46:53 +02:00
Phillip Thelen
02d4d2dd95 delete amplitude data by default 2025-08-19 17:49:34 +02:00
Phillip Thelen
375ca3e441 fix 2025-08-19 17:25:20 +02:00
Phillip Thelen
0e8ca110d3 lint fix 2025-08-19 17:15:38 +02:00
Phillip Thelen
ef66387c45 fix sub state display 2025-08-19 17:06:20 +02:00
Phillip Thelen
2e4c0e1fb9 fix long line 2025-08-19 15:00:29 +02:00
Phillip Thelen
5dbd10e53e fix lint 2025-08-19 14:22:32 +02:00
Phillip Thelen
5ba6d24503 improve sub status display 2025-08-19 14:12:15 +02:00
Phillip Thelen
6409c4c958 fix convertig sub to group plan 2025-08-19 14:03:16 +02:00
Phillip Thelen
91ccba9e8a begin building group admin panel 2025-08-19 13:18:36 +02:00
Phillip Thelen
18239b7828 fix deleting account 2025-08-18 17:24:27 +02:00
Phillip Thelen
72200060fa fix tests 2025-08-11 22:43:39 +02:00
Phillip Thelen
af3c37a059 fix imports 2025-08-11 22:03:27 +02:00
Phillip Thelen
c00aaec8e9 stripe payment details 2025-08-11 21:54:36 +02:00
Phillip Thelen
ba99a65bd4 Support paypal details for subscription in admin panel 2025-08-11 16:50:30 +02:00
Phillip Thelen
71c2e19330 add detailed information about sub payment for google and apple 2025-08-11 15:45:52 +02:00
Phillip Thelen
9b52198631 Merge branch 'phillip/admin_deleter' of github.com:HabitRPG/habitica into phillip/admin_deleter 2025-08-08 12:10:18 +02:00
Phillip Thelen
f4f964bfd8 Merge remote-tracking branch 'origin/develop' into phillip/admin_deleter 2025-08-08 12:09:07 +02:00
Phillip Thelen
f33e256b57 don’t use body with delete 2025-05-29 19:17:54 +02:00
Phillip Thelen
0a6f138de8 fix tests 2025-05-29 14:31:16 +02:00
Phillip Thelen
bc77c7698f remove unused priority 2025-05-29 13:49:30 +02:00
Phillip Thelen
1aba2be57f correct worker call 2025-05-29 13:46:39 +02:00
Phillip Thelen
7dd3ca485a Optimize database access for some use cases (#15444)
* optimize query when listing challenge tasks

* Optimize query for checking if user is party leader
2025-05-29 13:40:56 +02:00
dependabot[bot]
b9597319a3 chore(deps): bump http-proxy-middleware in /website/client (#15427)
Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.6 to 2.0.9.
- [Release notes](https://github.com/chimurai/http-proxy-middleware/releases)
- [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v2.0.9/CHANGELOG.md)
- [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v2.0.6...v2.0.9)

---
updated-dependencies:
- dependency-name: http-proxy-middleware
  dependency-version: 2.0.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-29 13:40:56 +02:00
dependabot[bot]
0e99142283 chore(deps): bump @babel/runtime from 7.23.9 to 7.26.10 (#15410)
Bumps [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime) from 7.23.9 to 7.26.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.26.10/packages/babel-runtime)

---
updated-dependencies:
- dependency-name: "@babel/runtime"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-29 13:40:56 +02:00
dependabot[bot]
a3b3b281a4 chore(deps): bump @babel/helpers in /website/client (#15407)
Bumps [@babel/helpers](https://github.com/babel/babel/tree/HEAD/packages/babel-helpers) from 7.23.6 to 7.26.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.26.10/packages/babel-helpers)

---
updated-dependencies:
- dependency-name: "@babel/helpers"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-29 13:40:56 +02:00
dependabot[bot]
787f64a8e5 chore(deps): bump @babel/runtime-corejs2 in /website/client (#15406)
Bumps [@babel/runtime-corejs2](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime-corejs2) from 7.23.6 to 7.26.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.26.10/packages/babel-runtime-corejs2)

---
updated-dependencies:
- dependency-name: "@babel/runtime-corejs2"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-29 13:40:56 +02:00
dependabot[bot]
625db6bc4e chore(deps): bump prismjs from 1.29.0 to 1.30.0 (#15403)
Bumps [prismjs](https://github.com/PrismJS/prism) from 1.29.0 to 1.30.0.
- [Release notes](https://github.com/PrismJS/prism/releases)
- [Changelog](https://github.com/PrismJS/prism/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PrismJS/prism/compare/v1.29.0...v1.30.0)

---
updated-dependencies:
- dependency-name: prismjs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-29 13:40:56 +02:00
dependabot[bot]
fcb1d06c7d chore(deps-dev): bump axios from 1.7.4 to 1.8.2 (#15401)
Bumps [axios](https://github.com/axios/axios) from 1.7.4 to 1.8.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.4...v1.8.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-29 13:40:56 +02:00
dependabot[bot]
cb2b837e2f chore(deps): bump serialize-javascript in /website/client (#15395)
Bumps [serialize-javascript](https://github.com/yahoo/serialize-javascript) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/yahoo/serialize-javascript/releases)
- [Commits](https://github.com/yahoo/serialize-javascript/compare/v6.0.1...v6.0.2)

---
updated-dependencies:
- dependency-name: serialize-javascript
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-29 13:40:56 +02:00
Kalista Payne
7c81b71c0c 5.36.4 2025-05-29 13:40:56 +02:00
Weblate
b2b2d0fac6 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (134 of 134 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (280 of 280 strings)

Translated using Weblate (French)

Currently translated at 100.0% (280 of 280 strings)

Translated using Weblate (Spanish)

Currently translated at 99.6% (279 of 280 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.4% (840 of 862 strings)

Translated using Weblate (German)

Currently translated at 99.8% (907 of 908 strings)

Translated using Weblate (Dutch)

Currently translated at 79.3% (219 of 276 strings)

Translated using Weblate (Dutch)

Currently translated at 28.1% (69 of 245 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.4% (840 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.5% (402 of 412 strings)

Translated using Weblate (Dutch)

Currently translated at 91.5% (377 of 412 strings)

Translated using Weblate (Dutch)

Currently translated at 85.2% (774 of 908 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (91 of 91 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (908 of 908 strings)

Translated using Weblate (Slovak)

Currently translated at 63.4% (106 of 167 strings)

Translated using Weblate (Hungarian)

Currently translated at 100.0% (908 of 908 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (908 of 908 strings)

Translated using Weblate (Slovak)

Currently translated at 2.0% (5 of 245 strings)

Translated using Weblate (French)

Currently translated at 100.0% (908 of 908 strings)

Translated using Weblate (Russian)

Currently translated at 64.4% (158 of 245 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.0% (837 of 862 strings)

Translated using Weblate (German)

Currently translated at 97.9% (844 of 862 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (91 of 91 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 97.3% (401 of 412 strings)

Translated using Weblate (Portuguese)

Currently translated at 95.3% (393 of 412 strings)

Translated using Weblate (Slovak)

Currently translated at 45.6% (413 of 905 strings)

Translated using Weblate (Slovak)

Currently translated at 50.8% (85 of 167 strings)

Translated using Weblate (Russian)

Currently translated at 99.1% (113 of 114 strings)

Translated using Weblate (Russian)

Currently translated at 64.0% (157 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 64.0% (157 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 62.0% (152 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 62.0% (152 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 60.8% (149 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 60.8% (149 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 60.4% (148 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 60.4% (148 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 60.0% (147 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 60.0% (147 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 57.9% (142 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 57.9% (142 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 56.7% (139 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 56.7% (139 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 56.3% (138 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 56.3% (138 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 53.8% (132 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 53.8% (132 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 53.4% (131 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 53.4% (131 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 48.9% (120 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 48.9% (120 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 48.5% (119 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 48.5% (119 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 46.9% (115 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 45.3% (111 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 45.3% (111 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 45.3% (111 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 45.3% (111 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 44.4% (109 of 245 strings)

Translated using Weblate (German)

Currently translated at 99.9% (3324 of 3325 strings)

Translated using Weblate (Russian)

Currently translated at 44.4% (109 of 245 strings)

Translated using Weblate (Russian)

Currently translated at 44.4% (109 of 245 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (91 of 91 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (94 of 94 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 93.8% (107 of 114 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (22 of 22 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.7% (429 of 430 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 95.1% (820 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (902 of 905 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (167 of 167 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 95.1% (820 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 95.1% (820 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 95.1% (820 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 93.8% (107 of 114 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 93.6% (3114 of 3325 strings)

Translated using Weblate (Portuguese)

Currently translated at 53.9% (1793 of 3325 strings)

Translated using Weblate (Dutch)

Currently translated at 78.1% (2600 of 3325 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.5% (242 of 243 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 95.1% (820 of 862 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 96.6% (398 of 412 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (902 of 905 strings)

Translated using Weblate (Italian)

Currently translated at 99.1% (113 of 114 strings)

Translated using Weblate (Italian)

Currently translated at 87.3% (2903 of 3325 strings)

Translated using Weblate (Italian)

Currently translated at 17.1% (42 of 245 strings)

Translated using Weblate (Italian)

Currently translated at 99.0% (408 of 412 strings)

Translated using Weblate (Italian)

Currently translated at 92.7% (102 of 110 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.0% (3292 of 3325 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.7% (3285 of 3325 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.7% (3285 of 3325 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (134 of 134 strings)

Translated using Weblate (Slovak)

Currently translated at 100.0% (412 of 412 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (91 of 91 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (905 of 905 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 98.1% (3262 of 3325 strings)

Co-authored-by: Andrea <goffopaguro@gmail.com>
Co-authored-by: Artem StolyROV <stolyarov11303@gmail.com>
Co-authored-by: Céu <marcel.ufscar@gmail.com>
Co-authored-by: David Kaya <david@kaya.sk>
Co-authored-by: Filip Betko <filipbetko@gmail.com>
Co-authored-by: FingerTiao <787170918@qq.com>
Co-authored-by: Irina  Shcherbinina <cat3dcat007@gmail.com>
Co-authored-by: Jaime Martí <jaumemarti77@icloud.com>
Co-authored-by: Mencius <beautyalinap@gmail.com>
Co-authored-by: Natalie Luhrs <eilatan@gmail.com>
Co-authored-by: Nikita Maximov <ruvemaximus@gmail.com>
Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
Co-authored-by: Summer_GUI <heyang94@163.com>
Co-authored-by: Tetiana <merekka13@gmail.com>
Co-authored-by: Tom <tompsognathus@gmail.com>
Co-authored-by: Toro Mor <thomas.bizer@gmx.de>
Co-authored-by: V Aar <v.vanderaar@gmail.com>
Co-authored-by: Viktor Révész <rviktor@ivankapal.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: razil <boss.razmarin@gmail.com>
Co-authored-by: Волкозмей <klippiky@gmail.com>
Co-authored-by: Данила Мальцев <maltsev-danila@inbox.ru>
Co-authored-by: Татьяна Куклева <klippiky@gmail.com>
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/achievements/sk/
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/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/challenge/it/
Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/content/it/
Translate-URL: https://translate.habitica.com/projects/habitica/content/nl/
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/content/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/it/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/faq/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/it/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt/
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/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/groups/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/es/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/fr/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/hu/
Translate-URL: https://translate.habitica.com/projects/habitica/limited/nl/
Translate-URL: https://translate.habitica.com/projects/habitica/loginincentives/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/sk/
Translate-URL: https://translate.habitica.com/projects/habitica/npc/uk/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/it/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/pets/ru/
Translate-URL: https://translate.habitica.com/projects/habitica/quests/pt_BR/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/de/
Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt_BR/
Translation: Habitica/Achievements
Translation: Habitica/Backgrounds
Translation: Habitica/Challenge
Translation: Habitica/Communityguidelines
Translation: Habitica/Content
Translation: Habitica/Faq
Translation: Habitica/Gear
Translation: Habitica/Generic
Translation: Habitica/Groups
Translation: Habitica/Limited
Translation: Habitica/Loginincentives
Translation: Habitica/Npc
Translation: Habitica/Pets
Translation: Habitica/Quests
Translation: Habitica/Questscontent
2025-05-29 13:40:56 +02:00
Kalista Payne
aa8af15cc6 fix(logging): don't spam empty error events 2025-05-29 13:40:56 +02:00
Kalista Payne
eede13f100 fix(script): don't use extremely costly regex 2025-05-29 13:40:56 +02:00
Natalie
5540c6e187 June 2025 content build (#15437)
* chore: June 2025 content build

* chore: typo fixing

* chore: corrections to summer 2025 mage armor, spritesheet

* fix(css): rebuild spritesmith-main

---------

Co-authored-by: Kalista Payne <sabrecat@gmail.com>
2025-05-29 13:40:56 +02:00
Phillip Thelen
6ebf3d0d03 add delete button to adminpanel 2025-05-29 13:37:25 +02:00
Phillip Thelen
6b8e6ed7a1 remove unused imports 2025-05-13 15:04:53 +02:00
Phillip Thelen
b00f463db5 refactor sending jobs to worker server 2025-05-13 15:02:23 +02:00
62 changed files with 1568 additions and 570 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "habitica",
"version": "5.38.2",
"version": "5.38.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "habitica",
"version": "5.38.2",
"version": "5.38.1",
"hasInstallScript": true,
"dependencies": {
"@babel/core": "^7.22.10",

View File

@@ -1,7 +1,7 @@
{
"name": "habitica",
"description": "A habit tracker app which treats your goals like a Role Playing Game.",
"version": "5.38.2",
"version": "5.38.1",
"main": "./website/server/index.js",
"dependencies": {
"@babel/core": "^7.22.10",

View File

@@ -150,7 +150,7 @@ describe('emails', () => {
sendTxn(mailingInfo, emailType);
expect(got.post).to.be.called;
expect(got.post).to.be.calledWith('undefined/job', sinon.match({
expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({
json: {
data: {
emailType: sinon.match.same(emailType),
@@ -234,7 +234,7 @@ describe('emails', () => {
sendTxn(mailingInfo, emailType);
expect(got.post).to.be.called;
expect(got.post).to.be.calledWith('undefined/job', sinon.match({
expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({
json: {
data: {
emailType: sinon.match.same(emailType),
@@ -254,7 +254,7 @@ describe('emails', () => {
sendTxn(mailingInfo, emailType, variables);
expect(got.post).to.be.called;
expect(got.post).to.be.calledWith('undefined/job', sinon.match({
expect(got.post).to.be.calledWith('http://example.com/job', sinon.match({
json: {
data: {
variables: sinon.match(value => value[0].name === 'BASE_URL', 'matches variables'),

View File

@@ -12,11 +12,33 @@ const { i18n } = common;
describe('Apple Payments', () => {
const subKey = 'basic_3mo';
let iapSetupStub;
let iapValidateStub;
let iapIsValidatedStub;
let iapIsCanceledStub;
let iapIsExpiredStub;
let paymentBuySkuStub;
let iapGetPurchaseDataStub;
let validateGiftMessageStub;
let paymentsCreateSubscritionStub;
beforeEach(() => {
iapSetupStub = sinon.stub(iap, 'setup').resolves();
iapValidateStub = sinon.stub(iap, 'validate').resolves({});
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.isExpired.restore();
iap.isCanceled.restore();
iap.getPurchaseData.restore();
});
describe('verifyPurchase', () => {
let sku; let user; let token; let receipt; let
headers;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let paymentBuySkuStub; let
iapGetPurchaseDataStub; let validateGiftMessageStub;
beforeEach(() => {
token = 'testToken';
@@ -25,13 +47,9 @@ describe('Apple Payments', () => {
receipt = `{"token": "${token}", "productId": "${sku}"}`;
headers = {};
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({});
iapIsValidatedStub = sinon.stub(iap, 'isValidated').returns(true);
sinon.stub(iap, 'isExpired').returns(false);
sinon.stub(iap, 'isCanceled').returns(false);
iapIsCanceledStub = sinon.stub(iap, 'isCanceled').returns(false);
iapIsExpiredStub = sinon.stub(iap, 'isExpired').returns(false);
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{
productId: 'com.habitrpg.ios.Habitica.21gems',
@@ -42,12 +60,6 @@ describe('Apple Payments', () => {
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.isExpired.restore();
iap.isCanceled.restore();
iap.getPurchaseData.restore();
payments.buySkuItem.restore();
gems.validateGiftMessage.restore();
});
@@ -209,9 +221,6 @@ describe('Apple Payments', () => {
describe('subscribe', () => {
let sub; let sku; let user; let token; let receipt; let headers; let
nextPaymentProcessing;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub;
let paymentsCreateSubscritionStub; let
iapGetPurchaseDataStub;
beforeEach(() => {
sub = common.content.subscriptionBlocks[subKey];
@@ -223,12 +232,10 @@ describe('Apple Payments', () => {
nextPaymentProcessing = moment.utc().add({ days: 2 });
user = new User();
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({});
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
.returns(true);
iapIsCanceledStub = sinon.stub(iap, 'isCanceled').returns(false);
iapIsExpiredStub = sinon.stub(iap, 'isExpired').returns(false);
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{
expirationDate: moment.utc().subtract({ day: 1 }).toDate(),
@@ -250,10 +257,6 @@ describe('Apple Payments', () => {
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.getPurchaseData.restore();
if (payments.createSubscription.restore) payments.createSubscription.restore();
});
@@ -270,6 +273,29 @@ describe('Apple Payments', () => {
});
});
it('should throw an error if no active subscription is found', async () => {
iap.isCanceled.restore();
iapIsCanceledStub = sinon.stub(iap, 'isCanceled')
.returns(true);
iap.getPurchaseData.restore();
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{
expirationDate: moment.utc().add({ day: -2 }).toDate(),
purchaseDate: new Date(),
productId: 'subscription1month',
transactionId: token,
originalTransactionId: token,
}]);
await expect(applePayments.subscribe(user, receipt, headers, nextPaymentProcessing))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: applePayments.constants.RESPONSE_NO_ITEM_PURCHASED,
});
});
const subOptions = [
{
sku: 'subscription1month',
@@ -574,8 +600,7 @@ describe('Apple Payments', () => {
describe('cancelSubscribe ', () => {
let user; let token; let receipt; let headers; let customerId; let
expirationDate;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let iapGetPurchaseDataStub; let
paymentCancelSubscriptionSpy;
let paymentCancelSubscriptionSpy;
beforeEach(async () => {
token = 'test-token';
@@ -584,8 +609,7 @@ describe('Apple Payments', () => {
customerId = 'test-customerId';
expirationDate = moment.utc();
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub.restore();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({
expirationDate,
@@ -593,8 +617,8 @@ describe('Apple Payments', () => {
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{ expirationDate: expirationDate.toDate() }]);
iapIsValidatedStub = sinon.stub(iap, 'isValidated').returns(true);
sinon.stub(iap, 'isCanceled').returns(false);
sinon.stub(iap, 'isExpired').returns(true);
iapIsCanceledStub = sinon.stub(iap, 'isCanceled').returns(false);
iapIsExpiredStub = sinon.stub(iap, 'isExpired').returns(true);
user = new User();
user.profile.name = 'sender';
user.purchased.plan.paymentMethod = applePayments.constants.PAYMENT_METHOD_APPLE;
@@ -606,13 +630,7 @@ describe('Apple Payments', () => {
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.isExpired.restore();
iap.isCanceled.restore();
iap.getPurchaseData.restore();
payments.cancelSubscription.restore();
paymentCancelSubscriptionSpy.restore();
});
it('should throw an error if we are missing a subscription', async () => {
@@ -695,6 +713,8 @@ describe('Apple Payments', () => {
expect(iapIsValidatedStub).to.be.calledWith({
expirationDate,
});
expect(iapIsCanceledStub).to.be.calledOnce;
expect(iapIsExpiredStub).to.be.calledOnce;
expect(iapGetPurchaseDataStub).to.be.calledOnce;
expect(paymentCancelSubscriptionSpy).to.be.calledOnce;

View File

@@ -11,25 +11,19 @@ const { i18n } = common;
describe('Google Payments', () => {
const subKey = 'basic_3mo';
describe('verifyPurchase', () => {
let sku; let user; let token; let receipt; let signature; let
headers;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let
paymentBuySkuStub; let validateGiftMessageStub;
let iapSetupStub;
let iapValidateStub;
let iapIsValidatedStub;
let paymentBuySkuStub;
let validateGiftMessageStub;
beforeEach(() => {
sku = 'com.habitrpg.android.habitica.iap.21gems';
user = new User();
receipt = `{"token": "${token}", "productId": "${sku}"}`;
signature = '';
headers = {};
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate').resolves({ productId: sku });
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
.returns(true);
sinon.stub(iap, 'isCanceled').returns(false);
sinon.stub(iap, 'isExpired').returns(false);
paymentBuySkuStub = sinon.stub(payments, 'buySkuItem').resolves({});
validateGiftMessageStub = sinon.stub(gems, 'validateGiftMessage');
});
@@ -38,10 +32,26 @@ describe('Google Payments', () => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.isCanceled.restore();
iap.isExpired.restore();
payments.buySkuItem.restore();
gems.validateGiftMessage.restore();
});
describe('verifyPurchase', () => {
let sku; let user; let token; let receipt; let signature; let
headers;
beforeEach(() => {
sku = 'com.habitrpg.android.habitica.iap.21gems';
user = new User();
receipt = `{"token": "${token}", "productId": "${sku}"}`;
signature = '';
headers = {};
iapValidateStub = sinon.stub(iap, 'validate').resolves({ productId: sku });
});
it('should throw an error if receipt is invalid', async () => {
iap.isValidated.restore();
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
@@ -160,8 +170,7 @@ describe('Google Payments', () => {
describe('subscribe', () => {
let sub; let sku; let user; let token; let receipt; let signature; let headers; let
nextPaymentProcessing;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let
paymentsCreateSubscritionStub;
let paymentsCreateSubscritionStub;
beforeEach(() => {
sub = common.content.subscriptionBlocks[subKey];
@@ -173,19 +182,12 @@ describe('Google Payments', () => {
signature = '';
nextPaymentProcessing = moment.utc().add({ days: 2 });
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({});
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
.returns(true);
paymentsCreateSubscritionStub = sinon.stub(payments, 'createSubscription').resolves({});
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
payments.createSubscription.restore();
});
@@ -243,7 +245,7 @@ describe('Google Payments', () => {
describe('cancelSubscribe ', () => {
let user; let token; let receipt; let signature; let headers; let customerId; let
expirationDate;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let iapGetPurchaseDataStub; let
let iapGetPurchaseDataStub; let
paymentCancelSubscriptionSpy;
beforeEach(async () => {
@@ -253,17 +255,12 @@ describe('Google Payments', () => {
signature = '';
customerId = 'test-customerId';
expirationDate = moment.utc();
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({
expirationDate,
});
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{ expirationDate: expirationDate.toDate(), autoRenewing: false }]);
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
.returns(true);
user = new User();
user.profile.name = 'sender';
@@ -276,9 +273,6 @@ describe('Google Payments', () => {
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.getPurchaseData.restore();
payments.cancelSubscription.restore();
});
@@ -308,6 +302,8 @@ describe('Google Payments', () => {
});
it('should cancel a user subscription', async () => {
iap.isCanceled.restore();
iap.isCanceled = sinon.stub(iap, 'isCanceled').returns(true);
await googlePayments.cancelSubscribe(user, headers);
expect(iapSetupStub).to.be.calledOnce;
@@ -332,11 +328,20 @@ describe('Google Payments', () => {
});
it('should cancel a user subscription with multiple inactive subscriptions', async () => {
iap.isCanceled.restore();
iap.isCanceled = sinon.stub(iap, 'isCanceled').returns(true);
const laterDate = moment.utc().add(7, 'days');
iap.getPurchaseData.restore();
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{ expirationDate, autoRenewing: false },
{ expirationDate: laterDate, autoRenewing: false },
.returns([{
startTimeMillis: expirationDate.valueOf(),
expirationDate,
autoRenewing: false,
}, {
startTimeMillis: laterDate.valueOf(),
expirationDate: laterDate,
autoRenewing: false,
},
]);
await googlePayments.cancelSubscribe(user, headers);
@@ -365,7 +370,12 @@ describe('Google Payments', () => {
iap.getPurchaseData.restore();
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{ autoRenewing: true }]);
await googlePayments.cancelSubscribe(user, headers);
await expect(googlePayments.cancelSubscribe(user, headers))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: googlePayments.constants.RESPONSE_STILL_VALID,
});
expect(iapSetupStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledOnce;
@@ -388,8 +398,12 @@ describe('Google Payments', () => {
.returns([{ expirationDate, autoRenewing: false },
{ autoRenewing: true },
{ expirationDate, autoRenewing: false }]);
await googlePayments.cancelSubscribe(user, headers);
await expect(googlePayments.cancelSubscribe(user, headers))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: googlePayments.constants.RESPONSE_STILL_VALID,
});
expect(iapSetupStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledWith(iap.GOOGLE, {

View File

@@ -117,15 +117,6 @@ describe('Blocker middleware', () => {
checkIPBlockedErrorThrown(next);
});
it('throws when the ip is blocked', () => {
req.ip = '192.168.1.1';
sandbox.stub(nconf, 'get').withArgs('BLOCKED_IPS').returns('192.168.1.1');
const attachBlocker = requireAgain(pathToBlocker).default;
attachBlocker(req, res, next);
checkIPBlockedErrorThrown(next);
});
});
describe('Blocking clients', () => {

View File

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

View File

@@ -81,7 +81,7 @@ export default {
watch: {
userIdentifier () {
this.isSearching = true;
this.$store.dispatch('adminPanel:searchUsers', { userIdentifier: this.userIdentifier }).then(users => {
this.$store.dispatch('admin:searchUsers', { userIdentifier: this.userIdentifier }).then(users => {
this.isSearching = false;
if (users.length === 1) {
this.loadUser(users[0]._id);

View File

@@ -5,6 +5,19 @@
class="row"
>
<div class="form col-12">
<div class="btn-group float-right">
<button
class="btn btn-danger"
@click="confirmDeleteHero"
>
<span
v-once
class="svg-icon icon-16 mt-1 mb-1"
v-html="icons.deleteIcon"
></span>
</button>
</div>
<basic-details
:user-id="hero._id"
:auth="hero.auth"
@@ -96,6 +109,53 @@
:reset-counter="resetCounter"
@clear-data="clearData"
/>
<b-modal
id="delete-member-modal"
title="Delete Member"
ok-title="Delete"
ok-variant="danger"
cancel-title="Cancel"
@ok="deleteHero"
>
<b-modal-body>
<p>
Are you sure you want to delete this member?
</p>
<p class="errorMessage">
Please note: This action cannot be undone!
</p>
<div class="ml-4">
<div class="form-check">
<input
id="deleteAccountCheck"
v-model="deleteHabiticaAccount"
class="form-check-input"
type="checkbox"
>
<label
class="form-check-label"
for="deleteAccountCheck"
>
Delete Habitica account
</label>
</div>
<div class="form-check">
<input
id="deleteAmplitudeCheck"
v-model="deleteAmplitudeData"
class="form-check-input"
type="checkbox"
>
<label
class="form-check-label"
for="deleteAmplitudeCheck"
>
Delete Amplitude data
</label>
</div>
</div>
</b-modal-body>
</b-modal>
</div>
</div>
</div>
@@ -148,6 +208,7 @@ import CustomizationsOwned from './customizationsOwned.vue';
import Achievements from './achievements.vue';
import UserHistory from './userHistory.vue';
import Stats from './stats.vue';
import deleteIcon from '@/assets/svg/delete.svg?raw';
import { userStateMixin } from '../../../../mixins/userState';
@@ -184,6 +245,11 @@ export default {
hasParty: false,
partyNotExistError: false,
adminHasPrivForParty: true,
deleteHabiticaAccount: true,
deleteAmplitudeData: true,
icons: Object.freeze({
deleteIcon,
}),
};
},
watch: {
@@ -249,6 +315,25 @@ export default {
this.resetCounter += 1; // tell child components to reinstantiate from scratch
},
confirmDeleteHero () {
if (this.hero._id === this.user._id) {
window.alert('You cannot delete your own account.');
return;
}
this.$root.$emit('bv::show::modal', 'delete-member-modal');
},
deleteHero () {
this.$store.dispatch('hall:deleteHero', {
uuid: this.hero._id,
deleteHabiticaAccount: this.deleteHabiticaAccount,
deleteAmplitudeData: this.deleteAmplitudeData,
}).then(() => {
this.$root.$emit('bv::hide::modal', 'delete-member-modal');
this.$router.push({ name: 'adminPanel' });
}).catch(err => {
window.alert(err);
});
},
hasUnsavedChanges (...comparisons) {
for (const index in comparisons) {
if (index && comparisons[index]) {

View File

@@ -22,9 +22,6 @@
>
<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"
@@ -37,7 +34,11 @@
Party ID
</label>
<strong class="col-sm-9 col-form-label">
<router-link
:to="{'name': 'groupAdminGroup', 'params': {'groupId': groupPartyData._id}}"
>
{{ groupPartyData._id }}
</router-link>
</strong>
</div>
<div class="form-group row">
@@ -63,6 +64,13 @@
</span>
</strong>
</div>
<div
v-if="!userIsPartyLeader"
class="btn btn-warning mr-2"
@click="makePartyLeader()"
>
Make Party Leader
</div>
<div
class="btn btn-danger"
@click="removeFromParty()"
@@ -284,8 +292,6 @@ function resetData (self) {
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)
@@ -329,13 +335,17 @@ export default {
},
data () {
return {
userIsPartyLeader: false,
questStatus: '',
questErrors: '',
errorsOrWarningsExist: false,
expand: false,
};
},
computed: {
userIsPartyLeader () {
return this.groupPartyData.leader === this.userId;
},
},
watch: {
resetCounter () {
resetData(this);
@@ -352,6 +362,14 @@ export default {
reloadData: true,
});
},
async makePartyLeader () {
await this.$store.dispatch('guilds:update', {
group: {
id: this.groupPartyData._id,
leader: this.userId,
},
});
},
},
};
</script>

View File

@@ -15,6 +15,25 @@
:class="{ 'open': expand }"
>
Subscription, Monthly Perks
<span
v-if="isSubscribed() && !isCancelled()"
class="text-success float-right ml-3"
>
Active
</span>
<span
v-else-if="isSubscribed() && isCancelled()"
class="text-success float-right ml-3"
>
Active until {{ dateFormat(hero.purchased.plan.dateTerminated) }}
</span>
<span
v-else-if="hero.purchased.plan.customerId && hero.purchased.plan.dateTerminated"
class="text-warning float-right ml-3"
>
Inactive
</span>
<b
v-if="hasUnsavedChanges && !expand"
class="text-warning float-right"
@@ -46,7 +65,7 @@
class="form-control"
type="text"
>
<option value="groupPlan">
<option value="Group Plan">
Group Plan
</option>
<option value="Stripe">
@@ -116,20 +135,10 @@
</select>
</div>
</div>
<div
class="form-group row"
>
<label class="col-sm-3 col-form-label">
Customer ID:
</label>
<div class="col-sm-9">
<input
<formRow
v-model="hero.purchased.plan.customerId"
class="form-control"
type="text"
>
</div>
</div>
label="Customer ID"
/>
<div
v-if="hero.purchased.plan.planId === 'group_plan_auto'"
class="form-group row"
@@ -154,7 +163,11 @@
>
<div class="card-body">
<h6 class="card-title">
<router-link
:to="{ name: 'groupAdminGroup', params: { groupId: group._id } }"
>
{{ group.name }}
</router-link>
<small class="float-right">{{ group._id }}</small>
</h6>
<p class="card-text">
@@ -175,177 +188,88 @@
</div>
</div>
</div>
<div
<formRow
v-if="hero.purchased.plan.dateCreated"
class="form-group row"
>
<label class="col-sm-3 col-form-label">
Creation date:
</label>
<div class="col-sm-9">
<div class="input-group">
<input
v-model="hero.purchased.plan.dateCreated"
class="form-control"
type="text"
>
<div class="input-group-append">
<strong class="input-group-text">
{{ dateFormat(hero.purchased.plan.dateCreated) }}
</strong>
</div>
</div>
</div>
</div>
<div
label="Creation date"
:suffix="dateFormat(hero.purchased.plan.dateCreated)"
/>
<formRow
v-if="hero.purchased.plan.dateCurrentTypeCreated"
class="form-group row"
>
<label class="col-sm-3 col-form-label">
Current sub start date:
</label>
<div class="col-sm-9">
<div class="input-group">
<input
v-model="hero.purchased.plan.dateCurrentTypeCreated"
class="form-control"
type="text"
>
<div class="input-group-append">
<strong class="input-group-text">
{{ dateFormat(hero.purchased.plan.dateCurrentTypeCreated) }}
</strong>
</div>
</div>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Termination date:
</label>
<div class="col-sm-9">
<div class="input-group">
<input
label="Current sub start date"
:suffix="dateFormat(hero.purchased.plan.dateCurrentTypeCreated)"
/>
<formRow
v-model="hero.purchased.plan.dateTerminated"
class="form-control"
type="text"
label="Termination date"
:suffix="dateFormat(hero.purchased.plan.dateTerminated)"
>
<div class="input-group-append">
<template #suffix>
<strong class="input-group-text">
{{ dateFormat(hero.purchased.plan.dateTerminated) }}
</strong>
<a
v-if="!hero.purchased.plan.dateTerminated && hero.purchased.plan.planId"
v-if="!hero.purchased.plan.dateTerminated && hero.purchased.plan.planId && !isGroupPlanMember"
v-b-modal.sub_termination_modal
class="btn btn-danger"
href="#"
>
Terminate
</a>
</div>
</div>
<small
v-if="!hero.purchased.plan.dateTerminated
&& hero.purchased.plan.planId"
class="text-success"
</template>
<template
v-if="isSubscribed() && !isCancelled()"
#helpText
>
<span class="text-success">
The subscription does not have a termination date and is active.
</small>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Cumulative months:
</label>
<div class="col-sm-9">
<input
</span>
</template>
</formRow>
<formRow
v-model="hero.purchased.plan.cumulativeCount"
class="form-control"
type="number"
min="0"
step="1"
>
<small class="text-secondary">
Cumulative subscribed months across subscription periods.
</small>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Extra months:
</label>
<div class="col-sm-9">
<div class="input-group">
<input
label="Cumulative months"
input-type="number"
help-text="Cumulative subscribed months across subscription periods."
/>
<formRow
v-model="hero.purchased.plan.extraMonths"
class="form-control"
type="number"
min="0"
step="any"
label="Extra months"
input-type="number"
help-text="Additional credit that is applied if a subscription is cancelled."
>
<div class="input-group-append">
<a
<template
v-if="hero.purchased.plan.dateTerminated && hero.purchased.plan.extraMonths > 0"
#suffix
>
<a
class="btn btn-warning"
@click="applyExtraMonths"
>
Apply Credit
</a>
</div>
</div>
<small class="text-secondary">
Additional credit that is applied if a subscription is cancelled.
</small>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Received hourglass bonus:
</label>
<div class="col-sm-9">
<div class="input-group">
<input
</template>
</formRow>
<formRow
v-model="hero.purchased.plan.hourglassPromoReceived"
class="form-control"
type="text"
>
<div class="input-group-append">
<strong class="input-group-text">
{{ dateFormat(hero.purchased.plan.hourglassPromoReceived) }}
</strong>
</div>
</div>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Mystic Hourglasses:
</label>
<div class="col-sm-9">
<input
label="Received hourglass bonus"
:suffix="dateFormat(hero.purchased.plan.hourglassPromoReceived)"
/>
<formRow
v-model="hero.purchased.plan.consecutive.trinkets"
class="form-control"
type="number"
label="Mystic Hourglasses"
input-type="number"
min="0"
step="1"
>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Gem cap increase:
</label>
<div class="col-sm-9">
<input
/>
<formRow
v-model="hero.purchased.plan.consecutive.gemCapExtra"
class="form-control"
type="number"
label="Gem cap increase"
input-type="number"
min="0"
max="26"
step="2"
>
</div>
</div>
/>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Total Gem cap:
@@ -354,21 +278,13 @@
{{ Number(hero.purchased.plan.consecutive.gemCapExtra) + 24 }}
</strong>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Gems bought this month:
</label>
<div class="col-sm-9">
<input
<formRow
v-model="hero.purchased.plan.gemsBought"
class="form-control"
type="number"
label="Gems bought this month"
input-type="number"
min="0"
:max="hero.purchased.plan.consecutive.gemCapExtra + 24"
step="1"
>
</div>
</div>
/>
<div class="form-group row">
<label class="col-sm-3 col-form-label">
Mystery Items:
@@ -391,7 +307,7 @@
</div>
</div>
<div
v-if="!isConvertingToGroupPlan && hero.purchased.plan.planId !== 'group_plan_auto'"
v-if="!isConvertingToGroupPlan && !isGroupPlanMember"
class="form-group row"
>
<div class="offset-sm-3 col-sm-9">
@@ -419,6 +335,79 @@
>
</div>
</div>
<div class="form-group row">
<h2>Payment Details</h2>
</div>
<div class="form-group row">
<div class="offset-sm-3 col-sm-9 mb-3">
<button
type="button"
class="btn btn-secondary btn-sm"
@click="getSubscriptionPaymentDetails"
>
Get Subscription Payment Details
</button>
</div>
</div>
<div
v-if="paymentDetails"
>
<div
v-for="(value, key) in paymentDetails"
:key="key"
class="form-group row"
>
<label class="col-sm-3 col-form-label">
{{ getHumandReadablePaymentDetails(key).label }}:
<span
:id="`${key}_tooltip`"
v-b-tooltip.hover.right="getHumandReadablePaymentDetails(key).help"
class="info-icon"
>?</span>
</label>
<strong class="col-sm-9 col-form-label">
<span v-if="value === true">Yes</span>
<span v-else-if="value === false">No</span>
<span
v-else-if="value instanceof String && isDate(value)"
v-b-tooltip.hover="value"
>
{{ formatDate(value) }}
</span>
<span v-else-if="value === null">---</span>
<span v-else>{{ value }}</span>
</strong>
</div>
<div class="form-group row">
<div class="offset-sm-3 col-sm-9">
<a
v-if="hero.purchased.plan.paymentMethod === 'Google'"
class="btn btn-primary btn-sm"
target="_blank"
:href="playOrdersUrl"
>
Play Console
</a>
<a
v-else-if="hero.purchased.plan.paymentMethod === 'Paypal'"
class="btn btn-primary btn-sm"
target="_blank"
:href="'https://www.paypal.com/billing/subscriptions/' + paymentDetails.customerId"
>
PayPal Dashboard
</a>
<a
v-else-if="hero.purchased.plan.paymentMethod === 'Stripe'"
class="btn btn-primary btn-sm"
target="_blank"
:href="'https://dashboard.stripe.com/customers/' + paymentDetails.customerId"
>
Stripe Dashboard
</a>
</div>
</div>
</div>
</div>
<div
v-if="expand"
@@ -474,7 +463,11 @@
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.input-group-append {
.form-group {
margin-bottom: 0.4rem;
}
.input-group-append {
width: auto;
.input-group-text {
@@ -484,7 +477,22 @@
font-size: 0.8rem;
color: $gray-200;
}
}
}
.info-icon {
font-size: 0.8rem;
color: $purple-400;
cursor: pointer;
margin-left: 0.2rem;
background-color: $gray-500;
padding: 0.1rem 0.3rem;
border-radius: 0.2rem;
}
.info-icon:hover {
background-color: $purple-400;
color: white;
}
</style>
<script>
@@ -494,10 +502,61 @@ import { getPlanContext } from '@/../../common/script/cron';
import subscriptionBlocks from '@/../../common/script/content/subscriptionBlocks';
import saveHero from '../mixins/saveHero';
import LoadingSpinner from '@/components/ui/loadingSpinner';
import FormRow from '../../formRow.vue';
const PLAY_CONSOLE_ORDERS_BASE_URL = import.meta.env.PLAY_CONSOLE_ORDERS_BASE_URL;
const humandReadablePaymentDetails = {
customerId: {
label: 'Customer ID',
help: 'The unique identifier for the customer in the payment system.',
},
purchaseDate: {
label: 'Purchase Date',
help: 'The date when the subscription was purchased or renewed.',
},
originalPurchaseDate: {
label: 'Original Purchase Date',
help: 'The date when the subscription was first purchased.',
},
productId: {
label: 'Product ID',
help: 'The identifier for the product associated with the subscription.',
},
transactionId: {
label: 'Transaction ID',
help: 'The unique identifier for the last transaction in the payment system.',
},
isCanceled: {
label: 'Is Canceled',
help: 'Indicates whether the subscription has been canceled by the user or the system.',
},
isExpired: {
label: 'Is Expired',
help: 'Indicates whether the subscription has expired. A cancelled subscription may still be active until the end of the billing cycle.',
},
expirationDate: {
label: 'Termination Date',
help: 'The date when the subscription will expire or has expired.',
},
nextPaymentDate: {
label: 'Next Payment Date',
help: 'The date when the next payment is due. If the subscription is canceled or expired, this may be null.',
},
lastPaymentDate: {
label: 'Last Payment Date',
help: 'The date when the lastpayment was made for the subscription.',
},
failedPayments: {
label: 'Failed Payments',
help: 'Number of times the payment failed for this subscription.',
},
};
export default {
components: {
LoadingSpinner,
FormRow,
},
mixins: [saveHero],
props: {
@@ -520,6 +579,7 @@ export default {
isConvertingToGroupPlan: false,
groupPlanID: '',
subscriptionBlocks,
paymentDetails: null,
};
},
computed: {
@@ -553,6 +613,12 @@ export default {
}
return terminationDate;
},
playOrdersUrl () {
return `${PLAY_CONSOLE_ORDERS_BASE_URL}${this.paymentDetails?.transactionId || ''}`;
},
isGroupPlanMember () {
return this.hero.purchased.plan.planId === 'group_plan_auto';
},
},
methods: {
dateFormat (date) {
@@ -583,6 +649,20 @@ export default {
this.isConvertingToGroupPlan = true;
this.hero.purchased.plan.owner = '';
},
getSubscriptionPaymentDetails () {
this.$store.dispatch('admin:getSubscriptionPaymentDetails', { userIdentifier: this.hero._id })
.then(details => {
if (details) {
this.paymentDetails = details;
} else {
alert('No payment details found.');
}
})
.catch(error => {
console.error('Error fetching subscription payment details:', error);
alert(`Failed to fetch payment details: ${error.message || 'Unknown error'}`);
});
},
saveClicked (e) {
e.preventDefault();
if (this.isConvertingToGroupPlan) {
@@ -601,6 +681,30 @@ export default {
this.$emit('changeUserIdentifier', id);
}
},
getHumandReadablePaymentDetails (key) {
return humandReadablePaymentDetails[key] || { label: key, help: '' };
},
isDate (date) {
return moment(date).isValid();
},
formatDate (date) {
return date ? moment(date).format('MM/DD/YYYY') : '---';
},
isSubscribed () {
return this.hero.purchased.plan
&& this.hero.purchased.plan.customerId
&& this.hero.purchased.plan.planId
&& this.hero.purchased.plan.paymentMethod
&& (
!this.hero.purchased.plan.dateTerminated
|| moment(this.hero.purchased.plan.dateTerminated).isAfter(moment())
);
},
isCancelled () {
return this.hero.purchased.plan
&& this.hero.purchased.plan.dateTerminated
&& this.hero.purchased.plan.dateTerminated !== '';
},
},
};
</script>

View File

@@ -226,7 +226,7 @@ export default {
}
},
async retrieveUserHistory () {
const history = await this.$store.dispatch('adminPanel:getUserHistory', { userIdentifier: this.hero._id });
const history = await this.$store.dispatch('admin:getUserHistory', { userIdentifier: this.hero._id });
this.armoire = history.armoire;
this.questInviteResponses = history.questInviteResponses;
this.cron = history.cron;

View File

@@ -8,6 +8,13 @@
>
{{ $t('adminPanel') }}
</router-link>
<router-link
v-if="hasPermission(user, 'groupSupport')"
class="nav-link"
:to="{name: 'groupAdmin'}"
>
{{ $t('groupAdmin') }}
</router-link>
<router-link
v-if="hasPermission(user, 'accessControl')"
class="nav-link"

View File

@@ -0,0 +1,129 @@
<template>
<div class="form-group row">
<label class="col-sm-3 col-form-label"><slot name="label">{{ label }}:</slot></label>
<div
class="col-sm-9"
:class="editable ? 'editable' : 'col-form-label'"
>
<slot>
<div class="input-group">
<strong v-if="!editable">
{{ value || "---" }}
</strong>
<textarea
v-else-if="inputType === 'textarea'"
:value="value"
class="form-control"
:rows="rows"
@input="$emit('input', $event.target.value)"
></textarea>
<input
v-else
:value="value"
class="form-control"
:type="inputType"
:min="min"
:max="max"
:step="step"
@input="$emit('input', $event.target.value)"
>
<div
v-if="suffix || $slots.suffix"
class="input-group-append"
>
<slot name="suffix">
<strong class="input-group-text">
{{ suffix }}
</strong>
</slot>
</div>
</div>
</slot>
<div
v-if="helpText || $slots.helpText"
class="form-text text-muted"
>
<slot name="helpText">
{{ helpText }}
</slot>
</div>
<div
v-if="$slots.subtitle"
class="form-text text-muted mt-1"
>
<slot name="subtitle"></slot>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '@/assets/scss/colors.scss';
.input-group-append {
width: auto;
.input-group-text {
border-bottom-right-radius: 2px;
border-top-right-radius: 2px;
font-weight: 600;
font-size: 0.8rem;
color: $gray-200;
}
}
</style>
<script>
import { max, min } from 'lodash';
export default {
model: {
prop: 'value',
event: 'input',
},
props: {
label: {
type: String,
},
value: {
type: [String, Boolean, Number],
},
inputType: {
type: String,
default: 'text',
},
editable: {
type: Boolean,
default: true,
},
helpText: {
type: String,
},
suffix: {
type: String,
},
rows: {
default: 3,
},
min: {
type: [Number, String],
default: 0,
validator (value) {
return !isNaN(value) && min([value, 0]) === 0;
},
},
max: {
type: [Number, String],
validator (value) {
return !isNaN(value) && max([value, 100]) === 100;
},
},
step: {
type: [Number, String],
default: 1,
validator (value) {
return !isNaN(value) && min([value, 1]) === 1;
},
},
},
};
</script>

View File

@@ -0,0 +1,55 @@
<template>
<form>
<form-row
v-model="group.type"
label="Group Type"
:editable="false"
/>
<form-row
v-model="group.name"
:label="$t('groupName')"
/>
<form-row
v-model="group.summary"
:label="$t('guildSummary')"
input-type="textarea"
/>
<form-row
v-model="group.chatLimitCount"
label="Chat limit"
input-type="number"
/>
<form-row
v-model="group.description"
:label="$t('groupDescription')"
input-type="textarea"
rows="6"
/>
<form-row
v-model="group.bannedWordsAllowed"
:label="$t('bannedWordsAllowed')"
input-type="checkbox"
/>
<form-row
v-model="group.leaderOnly.challenges"
:label="$t('leaderOnlyChallenges')"
input-type="checkbox"
/>
</form>
</template>
<script>
import formRow from '@/components/admin/formRow.vue';
export default {
components: {
formRow,
},
props: {
group: {
type: Object,
required: true,
},
},
};
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div v-if="group.purchased.plan">
<form-row
v-model="group.purchased.plan.paymentMethod"
label="Payment Method"
:editable="false"
/>
<form-row
v-model="group.purchased.plan.planId"
label="Plan ID"
:editable="false"
/>
<form-row
v-model="group.purchased.plan.customerId"
label="Customer ID"
:editable="false"
/>
<form-row
v-model="group.purchased.plan.dateCreated"
label="Creation Date"
:editable="false"
/>
<form-row
v-model="group.purchased.plan.dateTerminated"
label="Termination Date"
:editable="false"
/>
</div>
</template>
<script>
import formRow from '@/components/admin/formRow.vue';
export default {
components: {
formRow,
},
props: {
group: {
type: Object,
required: true,
},
},
};
</script>

View File

@@ -0,0 +1,105 @@
<template>
<div v-if="hasPermission(user, 'groupSupport')">
<h2>{{ group.name }}</h2>
<router-link
v-if="isGroupPlan"
:to="{'name': 'groupPlanDetail', 'params': {'groupId': groupId}}"
>
Group Plan Page
</router-link>
<supportContainer
:title="$t('groupData')"
:on-save="updateGroup"
>
<groupData
:group="group"
/>
</supportContainer>
<supportContainer
:title="$t('groupPlanSubscription')"
>
<groupPlan
:group="group"
/>
</supportContainer>
<supportContainer
v-if="group.type === 'party'"
:title="$t('questDetails')"
>
<quest
:group="group"
/>
</supportContainer>
<supportContainer
:title="$t('members')"
>
<members
:group="group"
/>
</supportContainer>
</div>
</template>
<script>
import { userStateMixin } from '../../../../mixins/userState';
import supportContainer from '../../supportContainer.vue';
import groupData from './groupData.vue';
import members from './members.vue';
import groupPlan from './groupPlan.vue';
import quest from './quest.vue';
export default {
components: {
supportContainer,
groupData,
members,
groupPlan,
quest,
},
mixins: [userStateMixin],
data () {
return {
groupId: '',
group: {},
};
},
computed: {
isGroupPlan () {
return this.group
&& this.group.purchased
&& this.group.purchased.plan
&& this.group.purchased.plan.planId;
},
},
watch: {
groupId () {
this.loadGroup(this.groupId);
},
},
mounted () {
this.groupId = this.$route.params.groupId;
},
methods: {
clearData () {
this.group = {};
},
async loadGroup (groupId) {
this.$emit('changeGroupId', groupId);
this.group = await this.$store.dispatch('admin:getGroup', { groupId });
},
async updateGroup () {
if (this.group && !this.group.id) {
this.group.id = this.group._id || this.groupId; // Ensure group has an id property
}
await this.$store.dispatch('guilds:update', { group: this.group });
this.group = await this.$store.dispatch('admin:getGroup', { groupId: this.group.id });
await this.$store.dispatch('snackbars:add', {
title: '',
text: 'Group updated',
type: 'info',
});
},
},
};
</script>

View File

@@ -0,0 +1,29 @@
<template>
<form-row
:label="$t('groupLeader')"
>
<strong class="col-form-label">
<router-link
:to="{'name': 'adminPanelUser', 'params': {'userIdentifier': group.leader }}"
>
{{ group.leader }}
</router-link>
</strong>
</form-row>
</template>
<script>
import formRow from '@/components/admin/formRow.vue';
export default {
components: {
formRow,
},
props: {
group: {
type: Object,
required: true,
},
},
};
</script>

View File

@@ -0,0 +1,43 @@
<template>
<div>
<form-row
v-model="group.quest.key"
label="Quest Identifier"
:editable="false"
/>
<form-row
v-model="group.quest.leader"
label="Quest Leader"
:editable="false"
>
<template slot="subtitle">
<router-link
:to="{'name': 'adminPanelUser', 'params': {'userIdentifier': group.quest.leader}}"
>
{{ group.quest.leader }}
</router-link>
</template>
</form-row>
<form-row
v-model="group.quest.active"
label="Is Quest Active"
input-type="checkbox"
/>
</div>
</template>
<script>
import formRow from '@/components/admin/formRow.vue';
export default {
components: {
formRow,
},
props: {
group: {
type: Object,
required: true,
},
},
};
</script>

View File

@@ -0,0 +1,93 @@
<template>
<div class="row standard-page col-12 d-flex justify-content-center">
<div class="group-admin-content">
<h1>{{ $t("groupAdmin") }}</h1>
<form
class="form-inline"
@submit.prevent="loadGroup(groupID)"
>
<div class="input-group col pl-0 pr-0">
<input
v-model="groupID"
class="form-control"
type="text"
placeholder="Group ID"
>
<div class="input-group-append">
<button
class="btn btn-primary"
type="button"
:disabled="!groupID"
@click="loadGroup(groupID)"
>
Load
</button>
</div>
</div>
</form>
<router-view
class="mt-3"
@changeGroupId="changeGroupId"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.uidField {
min-width: 45ch;
}
.input-group-append {
width:auto;
}
.group-admin-content {
flex: 0 0 800px;
max-width: 800px;
}
</style>
<script>
import VueRouter from 'vue-router';
import { mapState } from '@/libs/store';
const { isNavigationFailure, NavigationFailureType } = VueRouter;
export default {
data () {
return {
groupID: '',
};
},
computed: {
...mapState({ user: 'user.data' }),
},
mounted () {
this.$store.dispatch('common:setTitle', {
section: this.$t('groupAdmin'),
});
},
methods: {
changeGroupId (id) {
this.groupID = id;
},
async loadGroup (groupId) {
if (this.$router.currentRoute.name === 'groupAdminGroup') {
await this.$router.push({
name: 'groupAdmin',
});
}
await this.$router.push({
name: 'groupAdminGroup',
params: { groupId },
}).catch(failure => {
if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
this.$router.go();
}
});
},
},
};
</script>

View File

@@ -0,0 +1,53 @@
<template>
<div class="card mt-2">
<div class="card-header">
<h3
class="mb-0 mt-0"
:class="{'open': expand}"
@click="expand = !expand"
>
<slot name="title">
{{ title }}
</slot>
</h3>
</div>
<div
v-if="expand"
class="card-body"
>
<slot></slot>
</div>
<div
v-if="expand && onSave"
class="card-footer"
>
<button
class="btn btn-primary mt-1"
@click="onSave"
>
{{ $t('save') }}
</button>
</div>
</div>
</template>
<script>
export default {
props: {
title: {
type: String,
required: false,
},
onSave: {
type: Function,
required: false,
},
},
data () {
return {
expand: false,
};
},
};
</script>

View File

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

View File

@@ -1330,7 +1330,7 @@ export default {
},
openAdminPanel () {
this.$router.push(`/admin-panel/${this.hero._id}`);
this.$router.push(`/admin/panel/${this.hero._id}`);
},
},
};

View File

@@ -24,6 +24,8 @@ const AdminContainerPage = () => import(/* webpackChunkName: "admin-panel" */'@/
const AdminPanelPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/admin-panel');
const AdminPanelUserPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/admin-panel/user-support');
const AdminPanelSearchPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/admin-panel/search');
const GroupAdminPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/groups');
const GroupAdminGroupPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/groups/group-support');
const BlockerPage = () => import(/* webpackChunkName: "admin-panel" */'@/components/admin/blocker');
// Tasks
@@ -222,6 +224,28 @@ const router = new VueRouter({
},
],
},
{
name: 'groupAdmin',
path: 'groups',
component: GroupAdminPage,
meta: {
privilegeNeeded: [ // any one of these is enough to give access
'groupSupport',
],
},
children: [
{
name: 'groupAdminGroup',
path: ':groupId',
component: GroupAdminGroupPage,
meta: {
privilegeNeeded: [
'groupsSupport',
],
},
},
],
},
{
name: 'blockers',
path: 'blockers',

View File

@@ -0,0 +1,31 @@
import axios from 'axios';
export async function searchUsers (store, payload) {
const url = `/api/v4/admin/search/${payload.userIdentifier}`;
const response = await axios.get(url);
return response.data.data;
}
export async function getUserHistory (store, payload) {
const url = `/api/v4/admin/user/${payload.userIdentifier}/history`;
const response = await axios.get(url);
return response.data.data;
}
export async function getSubscriptionPaymentDetails (store, payload) {
const url = `/api/v4/admin/user/${payload.userIdentifier}/subscription-payment-details`;
const response = await axios.get(url);
return response.data.data;
}
export async function getGroup (store, payload) {
const url = `/api/v4/admin/groups/${payload.groupId}`;
const response = await axios.get(url);
return response.data.data;
}
export async function updateGroup (store, payload) {
const url = `/api/v4/admin/groups/${payload.groupId || payload.group._id}`;
const response = await axios.put(url, payload.group);
return response.data.data;
}

View File

@@ -1,13 +0,0 @@
import axios from 'axios';
export async function searchUsers (store, payload) {
const url = `/api/v4/admin/search/${payload.userIdentifier}`;
const response = await axios.get(url);
return response.data.data;
}
export async function getUserHistory (store, payload) {
const url = `/api/v4/admin/user/${payload.userIdentifier}/history`;
const response = await axios.get(url);
return response.data.data;
}

View File

@@ -38,3 +38,9 @@ export async function getHeroGroupPlans (store, payload) {
const response = await axios.get(url);
return response.data.data;
}
export async function deleteHero (store, payload) {
const url = `/api/v4/members/${payload.uuid}?deleteAccount=${payload.deleteHabiticaAccount}&deleteAmplitude=${payload.deleteAmplitudeData}`;
const response = await axios.delete(url);
return response.data.data;
}

View File

@@ -1,6 +1,6 @@
import { flattenAndNamespace } from '@/libs/store/helpers/internals';
import * as adminPanel from './adminPanel';
import * as admin from './admin';
import * as common from './common';
import * as user from './user';
import * as tasks from './tasks';
@@ -26,7 +26,7 @@ import * as blockers from './blockers';
// Example: fetch in user.js -> 'user:fetch'
const actions = flattenAndNamespace({
adminPanel,
admin,
common,
user,
tasks,

View File

@@ -36,7 +36,7 @@ const envVars = [
'TIME_TRAVEL_ENABLED',
'DEBUG_ENABLED',
'CONTENT_SWITCHOVER_TIME_OFFSET',
// TODO necessary? if yes how not to mess up with vue cli? 'NODE_ENV'
'PLAY_CONSOLE_ORDERS_BASE_URL',
];
const envObject = {};

View File

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

View File

@@ -3,5 +3,9 @@
"siteBlockers": "Site Blockers",
"newsroom": "Newsroom",
"adminBlockerTypeDescription": "<b>IP-Address</b> - Block access for a specific IP-Address\n\nClient - Block access for a client based on the \"x-client\" header.\n\nE-Mail - Blocks e-mails from being used for signup.",
"adminBlockerAreaDescription": "A blocker can either apply to the full site, completely blocking any access. Or it can apply to purchases, which still allows the site to be accessed."
"adminBlockerAreaDescription": "A blocker can either apply to the full site, completely blocking any access. Or it can apply to purchases, which still allows the site to be accessed.",
"groupAdmin": "Group Admin",
"groupSupportDescription": "Manage groups and their members. You can search for groups by ID, or load your own group by leaving the field blank.",
"groupData": "Group Data",
"groupPlanSubscription": "Group Plan Subscription"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -320,16 +320,16 @@ api.updateHero = {
if (plan.extraMonths || plan.extraMonths === 0) {
hero.purchased.plan.extraMonths = plan.extraMonths;
}
if (plan.customerId) {
if (plan.customerId || plan.customerId === '') {
hero.purchased.plan.customerId = plan.customerId;
}
if (plan.paymentMethod) {
if (plan.paymentMethod || plan.customerId === '') {
hero.purchased.plan.paymentMethod = plan.paymentMethod;
}
if (plan.planId) {
if (plan.planId || plan.customerId === '') {
hero.purchased.plan.planId = plan.planId;
}
if (plan.owner) {
if (plan.owner || plan.customerId === '') {
hero.purchased.plan.owner = plan.owner;
}
if (plan.hourglassPromoReceived) {
@@ -341,8 +341,7 @@ api.updateHero = {
const group = await Group.getGroup({ user: hero, groupId: groupID });
if (!group) throw new NotFound(res.t('groupNotFound'));
if (group.hasNotCancelled()) {
hero.purchased.plan.customerId = null;
hero.purchased.plan.paymentMethod = null;
hero.purchased.plan.paymentMethod = 'groupPlan';
await addSubToGroupUser(hero, group);
await group.updateGroupPlan();
} else {
@@ -352,34 +351,34 @@ api.updateHero = {
}
if (updateData.stats) {
if (updateData.stats.hp) {
if (updateData.stats.hp || updateData.stats.hp === 0) {
hero.stats.hp = updateData.stats.hp;
}
if (updateData.stats.mp) {
if (updateData.stats.mp || updateData.stats.mp === 0) {
hero.stats.mp = updateData.stats.mp;
}
if (updateData.stats.exp) {
if (updateData.stats.exp || updateData.stats.exp === 0) {
hero.stats.exp = updateData.stats.exp;
}
if (updateData.stats.gp) {
if (updateData.stats.gp || updateData.stats.gp === 0) {
hero.stats.gp = updateData.stats.gp;
}
if (updateData.stats.lvl) {
if (updateData.stats.lvl || updateData.stats.lvl === 0) {
hero.stats.lvl = updateData.stats.lvl;
}
if (updateData.stats.points) {
if (updateData.stats.points || updateData.stats.points === 0) {
hero.stats.points = updateData.stats.points;
}
if (updateData.stats.str) {
if (updateData.stats.str || updateData.stats.str === 0) {
hero.stats.str = updateData.stats.str;
}
if (updateData.stats.int) {
if (updateData.stats.int || updateData.stats.int === 0) {
hero.stats.int = updateData.stats.int;
}
if (updateData.stats.per) {
if (updateData.stats.per || updateData.stats.per === 0) {
hero.stats.per = updateData.stats.per;
}
if (updateData.stats.con) {
if (updateData.stats.con || updateData.stats.con === 0) {
hero.stats.con = updateData.stats.con;
}
if (updateData.stats.buffs) {
@@ -511,6 +510,7 @@ api.updateHero = {
const savedHero = await hero.save();
if (updateData.removeFromParty) {
try {
await leaveGroup({
user: savedHero,
groupId: savedHero.party._id,
@@ -518,6 +518,14 @@ api.updateHero = {
keep: false,
keepChallenges: false,
});
} catch (err) {
if (err instanceof NotFound) {
savedHero.party = null; // Party does not exist, so just unset it
await savedHero.save();
} else {
throw err; // re-throw other errors
}
}
}
const heroJSON = savedHero.toJSON();

View File

@@ -1,14 +1,22 @@
import validator from 'validator';
import merge from 'lodash/merge';
import uniqBy from 'lodash/uniqBy';
import { v4 as uuid } from 'uuid';
import { authWithHeaders } from '../../middlewares/auth';
import { ensurePermission } from '../../middlewares/ensureAccessRight';
import { model as User } from '../../models/user';
import { model as UserHistory } from '../../models/userHistory';
import { model as Group } from '../../models/group';
import { model as Blocker } from '../../models/blocker';
import {
NotFound,
} from '../../libs/errors';
import apple from '../../libs/payments/apple';
import google from '../../libs/payments/google';
import paypal from '../../libs/payments/paypal';
import {
getSubscriptionPaymentDetails as getStripeSubscriptionPaymentDetails,
} from '../../libs/payments/stripe/subscriptions';
const api = {};
@@ -40,8 +48,6 @@ api.searchHero = {
const { userIdentifier } = req.params;
const re = new RegExp(String.raw`^${userIdentifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`);
let query;
let users = [];
if (validator.isUUID(userIdentifier)) {
@@ -54,7 +60,7 @@ api.searchHero = {
'auth.facebook.emails.value',
];
for (const field of emailFields) {
const emailQuery = { [field]: userIdentifier };
const emailQuery = { [field]: userIdentifier.toLowerCase() };
// eslint-disable-next-line no-await-in-loop
const found = await User.findOne(emailQuery)
.select('contributor backer profile auth')
@@ -65,6 +71,7 @@ api.searchHero = {
}
}
} else {
const re = new RegExp(String.raw`^${userIdentifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`);
query = { 'auth.local.lowerCaseUsername': { $regex: re, $options: 'i' } };
}
@@ -76,7 +83,8 @@ api.searchHero = {
.lean()
.exec();
}
res.respond(200, users);
res.respond(200, uniqBy(users, '_id'));
},
};
@@ -187,4 +195,69 @@ api.deleteBlocker = {
res.respond(200, savedBlocker);
},
};
api.validateSubscriptionPaymentDetails = {
method: 'GET',
url: '/admin/user/:userId/subscription-payment-details',
middlewares: [authWithHeaders(), ensurePermission('userSupport')],
async handler (req, res) {
req.checkParams('userId', res.t('heroIdRequired')).notEmpty().isUUID();
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
const { userId } = req.params;
const user = await User.findById(userId)
.select('purchased')
.lean()
.exec();
if (!user) throw new NotFound(res.t('userWithIDNotFound', { userId }));
if (!user.purchased || !user.purchased.plan || !user.purchased.plan.paymentMethod || !user.purchased.plan.paymentMethod === '') {
throw new NotFound(res.t('subscriptionNotFoundForUser', { userId }));
}
let paymentDetails;
if (user.purchased.plan.paymentMethod === 'Apple') {
paymentDetails = await apple.getSubscriptionPaymentDetails(userId, user.purchased.plan);
} else if (user.purchased.plan.paymentMethod === 'Google') {
paymentDetails = await google.getSubscriptionPaymentDetails(userId, user.purchased.plan);
} else if (user.purchased.plan.paymentMethod === 'Paypal') {
paymentDetails = await paypal.getSubscriptionPaymentDetails({ user });
} else if (user.purchased.plan.paymentMethod === 'Stripe') {
paymentDetails = await getStripeSubscriptionPaymentDetails(user);
} else if (user.purchased.plan.paymentMethod === 'Amazon Payments') {
throw new NotFound(res.t('amazonSubscriptionNotValidated'));
} else if (user.purchased.plan.paymentMethod === 'Gift') {
throw new NotFound(res.t('giftSubscriptionNotValidated'));
} else {
throw new NotFound(res.t('unknownSubscriptionPaymentMethod', { method: user.purchased.paymentMethod }));
}
res.respond(200, paymentDetails);
},
};
api.getGroup = {
method: 'GET',
url: '/admin/groups/:groupId',
middlewares: [authWithHeaders(), ensurePermission('groupSupport')],
async handler (req, res) {
req.checkParams('groupId', res.t('groupIdRequired')).notEmpty().isUUID();
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
const { groupId } = req.params;
const group = await Group.findById(groupId)
.lean()
.exec();
if (!group) throw new NotFound(res.t('groupNotFound'));
res.respond(200, group);
},
};
export default api;

View File

@@ -1,3 +1,4 @@
import { sendJob } from '../../libs/worker';
import { authWithHeaders } from '../../middlewares/auth';
import { ensurePermission } from '../../middlewares/ensureAccessRight';
import { TransactionModel as Transaction } from '../../models/transaction';
@@ -5,9 +6,9 @@ import { TransactionModel as Transaction } from '../../models/transaction';
const api = {};
/**
* @api {get} /api/v4/user/purchase-history Get users purchase history
* @apiName UserGetPurchaseHistory
* @apiGroup User
* @api {get} /api/v4/members/:memberId/purchase-history Get members purchase history
* @apiName MemberGetPurchaseHistory
* @apiGroup Member
*
*/
api.purchaseHistory = {
@@ -31,4 +32,31 @@ api.purchaseHistory = {
},
};
/**
* @api {delete} /api/v4/members/:memberId Delete a user
* @apiName DeleteMember
* @apiGroup Member
*
*/
api.deleteMember = {
method: 'DELETE',
middlewares: [authWithHeaders(), ensurePermission('userSupport')],
url: '/members/:memberId',
async handler (req, res) {
req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID();
req.checkQuery('deleteAccount').optional().isIn(['true', 'false']);
req.checkQuery('deleteAmplitude').optional().isIn(['true', 'false']);
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
sendJob('delete-user', {
data: {
userId: req.params.memberId,
deleteAccount: req.query.deleteAccount === 'true',
deleteAmplitude: req.query.deleteAmplitude === 'true',
},
});
res.respond(200, {});
},
};
export default api;

View File

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

View File

@@ -1,18 +1,10 @@
import nconf from 'nconf';
import got from 'got';
import { TAVERN_ID } from '../models/group'; // eslint-disable-line import/no-cycle
import { encrypt } from './encryption';
import logger from './logger';
import common from '../../common';
import { sendJob } from './worker';
const IS_PROD = nconf.get('IS_PROD');
const EMAIL_SERVER = {
url: nconf.get('EMAIL_SERVER_URL'),
auth: {
user: nconf.get('EMAIL_SERVER_AUTH_USER'),
password: nconf.get('EMAIL_SERVER_AUTH_PASSWORD'),
},
};
const BASE_URL = nconf.get('BASE_URL');
export function getUserInfo (user, fields = []) {
@@ -156,29 +148,14 @@ export async function sendTxn (mailingInfoArray, emailType, variables, personalV
}
if (IS_PROD && mailingInfoArray.length > 0) {
return got.post(`${EMAIL_SERVER.url}/job`, {
retry: 5, // retry the http request to the email server 5 times
timeout: 60000, // wait up to 60s before timing out
username: EMAIL_SERVER.auth.user,
password: EMAIL_SERVER.auth.password,
json: {
type: 'email',
return sendJob('email', {
data: {
emailType,
to: mailingInfoArray,
variables,
personalVariables,
},
options: {
priority: 'high',
attempts: 5,
backoff: { delay: 10 * 60 * 1000, type: 'fixed' },
},
},
}).json().catch(err => logger.error(err, {
extraMessage: 'Error while sending an email.',
emailType,
}));
});
}
return null;

View File

@@ -74,7 +74,7 @@ api.verifyPurchase = async function verifyPurchase (options) {
return appleRes;
};
api.subscribe = async function subscribe (user, receipt, headers, nextPaymentProcessing) {
async function findSubscriptionPurchase (receipt, onlyActive = true) {
await iap.setup();
const appleRes = await iap.validate(iap.APPLE, receipt);
@@ -85,18 +85,56 @@ api.subscribe = async function subscribe (user, receipt, headers, nextPaymentPro
if (purchaseDataList.length === 0) {
throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED);
}
let purchase;
let newestDate;
for (const purchaseData of purchaseDataList) {
const datePurchased = new Date(Number(purchaseData.purchaseDate));
const dateTerminated = new Date(Number(purchaseData.expirationDate));
if ((!newestDate || datePurchased > newestDate) && dateTerminated > new Date()) {
let datePurchased;
if (purchaseData.purchaseDate instanceof Date) {
datePurchased = purchaseData.purchaseDate;
} else {
datePurchased = new Date(Number(purchaseData.purchaseDateMs || purchaseData.purchaseDate));
}
const dateTerminated = new Date(Number(purchaseData.expirationDate || 0));
if ((!newestDate || datePurchased > newestDate)) {
if (!onlyActive || dateTerminated > new Date()) {
purchase = purchaseData;
newestDate = datePurchased;
}
}
}
if (!purchase) {
throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED);
}
return {
purchase,
isCanceled: iap.isCanceled(purchase),
isExpired: iap.isExpired(purchase),
expirationDate: new Date(Number(purchase.expirationDate)),
};
}
api.getSubscriptionPaymentDetails = async function getDetails (userId, subscriptionPlan) {
if (!subscriptionPlan || !subscriptionPlan.additionalData) {
throw new NotAuthorized(shared.i18n.t('missingSubscription'));
}
const details = await findSubscriptionPurchase(subscriptionPlan.additionalData);
return {
customerId: details.purchase.originalTransactionId || details.purchase.transactionId,
purchaseDate: new Date(Number(details.purchase.purchaseDateMs)),
originalPurchaseDate: new Date(Number(details.purchase.originalPurchaseDateMs)),
expirationDate: details.isCanceled || details.isExpired ? details.expirationDate : null,
nextPaymentDate: details.isCanceled || details.isExpired ? null : details.expirationDate,
productId: details.purchase.productId,
transactionId: details.purchase.transactionId,
isCanceled: details.isCanceled,
isExpired: details.isExpired,
};
};
api.subscribe = async function subscribe (user, receipt, headers, nextPaymentProcessing) {
const details = await findSubscriptionPurchase(receipt);
const { purchase } = details;
let subCode;
switch (purchase.productId) { // eslint-disable-line default-case
@@ -250,37 +288,17 @@ api.noRenewSubscribe = async function noRenewSubscribe (options) {
api.cancelSubscribe = async function cancelSubscribe (user, headers) {
const { plan } = user.purchased;
if (plan.paymentMethod !== api.constants.PAYMENT_METHOD_APPLE) throw new NotAuthorized(shared.i18n.t('missingSubscription'));
await iap.setup();
try {
const appleRes = await iap.validate(iap.APPLE, plan.additionalData);
const isValidated = iap.isValidated(appleRes);
if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
const purchases = iap.getPurchaseData(appleRes);
if (purchases.length === 0) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
let newestDate;
let newestPurchase;
for (const purchaseData of purchases) {
const datePurchased = new Date(Number(purchaseData.purchaseDate));
if (!newestDate || datePurchased > newestDate) {
newestDate = datePurchased;
newestPurchase = purchaseData;
}
}
if (!iap.isCanceled(newestPurchase) && !iap.isExpired(newestPurchase)) {
const details = await findSubscriptionPurchase(plan.additionalData, false);
if (!details.isCanceled && !details.isExpired) {
throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID);
}
await payments.cancelSubscription({
user,
nextBill: new Date(Number(newestPurchase.expirationDate)),
nextBill: new Date(Number(details.expirationDate)),
paymentMethod: this.constants.PAYMENT_METHOD_APPLE,
headers,
});

View File

@@ -72,6 +72,53 @@ api.verifyPurchase = async function verifyPurchase (options) {
return googleRes;
};
async function findSubscriptionPurchase (additionalData) {
const googleRes = await iap.validate(iap.GOOGLE, additionalData);
const isValidated = iap.isValidated(googleRes);
if (!isValidated) throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT);
const purchases = iap.getPurchaseData(googleRes);
if (purchases.length === 0) throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT);
let purchase;
let newestDate;
for (const i in purchases) {
if (Object.prototype.hasOwnProperty.call(purchases, i)) {
const thisPurchase = purchases[i];
const purchaseDate = new Date(Number(thisPurchase.startTimeMillis));
if (!newestDate || purchaseDate > newestDate) {
newestDate = purchaseDate;
purchase = purchases[i];
}
}
}
return {
purchase,
isCanceled: iap.isCanceled(purchase),
isExpired: iap.isExpired(purchase),
expirationDate: new Date(Number(purchase.expirationDate)),
};
}
api.getSubscriptionPaymentDetails = async function getDetails (userId, subscriptionPlan) {
if (!subscriptionPlan || !subscriptionPlan.additionalData) {
throw new NotAuthorized(shared.i18n.t('missingSubscription'));
}
const details = await findSubscriptionPurchase(subscriptionPlan.additionalData);
return {
customerId: details.purchase.purchaseToken,
originalPurchaseDate: new Date(Number(details.purchase.startTimeMillis)),
expirationDate: details.isCanceled || details.isExpired ? details.expirationDate : null,
nextPaymentDate: details.isCanceled || details.isExpired ? null : details.expirationDate,
productId: details.purchase.productId,
transactionId: details.purchase.orderId,
isCanceled: details.isCanceled,
isExpired: details.isExpired,
};
};
api.subscribe = async function subscribe (
sku,
user,
@@ -213,22 +260,11 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) {
let dateTerminated;
try {
const googleRes = await iap.validate(iap.GOOGLE, plan.additionalData);
const isValidated = iap.isValidated(googleRes);
if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
const purchases = iap.getPurchaseData(googleRes);
if (purchases.length === 0) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
for (const i in purchases) {
if (Object.prototype.hasOwnProperty.call(purchases, i)) {
const purchase = purchases[i];
if (purchase.autoRenewing !== false) return;
if (!dateTerminated || Number(purchase.expirationDate) > Number(dateTerminated)) {
dateTerminated = new Date(Number(purchase.expirationDate));
}
}
const details = await findSubscriptionPurchase(plan.additionalData);
if (!details.isCanceled && !details.isExpired) {
throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID);
}
dateTerminated = details.expirationDate;
} catch (err) {
// Status:410 means that the subsctiption isn't active anymore and we can safely delete it
if (err && err.message === 'Status:410') {

View File

@@ -180,7 +180,6 @@ async function addSubToGroupUser (member, group) {
}
// save unused hourglass and mystery items
plan.perkMonthCount = memberPlan.perkMonthCount;
plan.consecutive.trinkets = memberPlan.consecutive.trinkets;
plan.mysteryItems = memberPlan.mysteryItems;

View File

@@ -223,6 +223,51 @@ api.subscribeSuccess = async function subscribeSuccess (options = {}) {
});
};
api.getSubscriptionPaymentDetails = async function getSubscriptionPaymentDetails (options = {}) {
const { user, groupId } = options;
let customerId;
if (groupId) {
const groupFields = basicGroupFields.concat(' purchased');
const group = await Group.getGroup({
user, groupId, populateLeader: false, groupFields,
});
if (!group) {
throw new NotFound(i18n.t('groupNotFound'));
}
if (group.leader !== user._id) {
throw new NotAuthorized(i18n.t('onlyGroupLeaderCanManageSubscription'));
}
customerId = group.purchased.plan.customerId;
} else {
customerId = user.purchased.plan.customerId;
}
if (!customerId) throw new NotAuthorized(i18n.t('missingSubscription'));
const customer = await this.paypalBillingAgreementGet(customerId);
if (!customer) throw new NotFound(i18n.t('subscriptionNotFound'));
console.log('PayPal subscription details:', customer);
return {
customerId: customer.id,
originalPurchaseDate: customer.start_date,
expirationDate: customer.agreement_details.ended_at
? customer.agreement_details.ended_at
: null,
nextPaymentDate: customer.agreement_details.next_billing_date
? customer.agreement_details.next_billing_date
: null,
lastPaymentDate: customer.agreement_details.last_payment_date
? customer.agreement_details.last_payment_date
: null,
productId: customer.description,
transactionId: customer.id,
isCanceled: customer.agreement_details.state === 'Inactive',
failedPayments: customer.agreement_details.failed_payment_count,
};
};
/**
* Cancel a PayPal Subscription
*

View File

@@ -33,6 +33,26 @@ export async function checkSubData (sub, isGroup = false, coupon) {
}
}
export async function getSubscriptionPaymentDetails (user) {
const stripeApi = getStripeApi();
const { plan } = user.purchased;
const customer = await stripeApi.customers.retrieve(plan.customerId);
const paymentIntents = await stripeApi.paymentIntents.search({
query: `customer:'${plan.customerId}'`,
});
const lastPayment = paymentIntents.data.length > 0
? paymentIntents.data[0]
: null;
console.log(paymentIntents.data);
console.log(customer);
return {
customerId: customer.id,
originalPurchaseDate: new Date(Number(customer.created) * 1000),
lastPaymentDate: new Date(Number(lastPayment.created) * 1000),
};
}
export async function applySubscription (session) {
const { metadata, customer: customerId, subscription: subscriptionId } = session;
const {

View File

@@ -0,0 +1,33 @@
import got from 'got';
import nconf from 'nconf';
import logger from './logger';
const EMAIL_SERVER = {
url: nconf.get('EMAIL_SERVER_URL'),
auth: {
user: nconf.get('EMAIL_SERVER_AUTH_USER'),
password: nconf.get('EMAIL_SERVER_AUTH_PASSWORD'),
},
};
export function sendJob (type, config) {
const { data, options } = config;
const usedOptions = {
backoff: { delay: 10 * 60 * 1000, type: 'exponential' },
...options,
};
return got.post(`${EMAIL_SERVER.url}/job`, {
retry: 5, // retry the http request to the email server 5 times
timeout: 60000, // wait up to 60s before timing out
username: EMAIL_SERVER.auth.user,
password: EMAIL_SERVER.auth.password,
json: {
type,
data,
options: usedOptions,
},
}).json().catch(err => logger.error(err, {
extraMessage: 'Error while sending an email.',
}));
}

View File

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

View File

@@ -1,4 +1,3 @@
import nconf from 'nconf';
import {
Forbidden,
} from '../libs/errors';
@@ -10,19 +9,7 @@ import { model as Blocker } from '../models/blocker';
// NOTE: it's meant to be used behind a proxy (for example a load balancer)
// that uses the 'x-forwarded-for' header to forward the original IP addresses.
// A list of comma separated IPs to block
// It works fine as long as the list is short,
// if the list becomes too long for an env variable we'll switch to Redis.
const BLOCKED_IPS_RAW = nconf.get('BLOCKED_IPS');
const blockedIps = BLOCKED_IPS_RAW
? BLOCKED_IPS_RAW
.trim()
.split(',')
.map(blockedIp => blockedIp.trim())
.filter(blockedIp => Boolean(blockedIp))
: [];
const blockedIps = [];
const blockedClients = [];
Blocker.watchBlockers({