Privacy Controls (#15492)

* WIP(privacy): start of banner

* WIP(privacy): layout rough

* WIP(privacy): mobile layout, add modal

* fix(privacy): implement toggle disable and setting row fold

* fix(privacy): clean up a couple of styles

* fix(privacy): adjust banner width at mobile sizes

* WIP(privacy): remove Loggly echo of Amplitude data

* fix(banners): account for privacy in snackbar position

* WIP(privacy): dismiss banner

* chore(analytics): update to maintaned GA4 library

* fix(tests): lint, misuse of apiError

* fix(analytics): add debug mode

* fix(analytics): load new library on client

* WIP(privacy): gtag.js based implementation

* fix(analytics): lint issues

* fix(lint): one more unused

* fix(lint): client errors

* feat(privacy): draft workflows

* fix(analytics): linting, send needed user values

* fix(tests): use mock analytics service in test env

* fix(tests): restore previous logic for node env

* feat(intro): jump to page 2 onboarding

* WIP(auth): revisions to registration flow

* WIP(privacy): landing page and banner revisions

* WIP(signup): added new username, tos, privacy state

* fix(signup): revert debugging logic

* WIP(signup): add defaulting and checkbox

* wip(signup): move social auth behind username screen

* Squashed commit of the following:

commit ca0a238e5f008525ed154c5eaf12e44f2fc22b00
Author: Phillip Thelen <phillip@habitica.com>
Date:   Wed May 7 12:17:20 2025 +0200

    make emails lowercase

commit a2ce748558ce9134e6825208a7e66d78e720202e
Author: Phillip Thelen <phillip@habitica.com>
Date:   Wed Apr 9 13:27:01 2025 +0200

    remove unused import

commit cc6ce6c388d9693cf192c4bea733931fc8c31c37
Author: Phillip Thelen <phillip@habitica.com>
Date:   Wed Apr 9 13:13:03 2025 +0200

    add tests for new api route

commit 0d40a6230b548625482aa9f6831c93ed9d62533a
Author: Kalista Payne <sabrecat@gmail.com>
Date:   Wed Jun 18 15:50:22 2025 -0500

    update social tests

commit 79177d6754589b9e54682af8a531b63f60215dab
Author: Phillip Thelen <phillip@habitica.com>
Date:   Wed Apr 9 10:21:51 2025 +0200

    new api route to check if an email is available

commit 11df73fe07eeb730c2a95593e18e14a931f52429
Author: Phillip Thelen <phillip@habitica.com>
Date:   Wed Apr 9 10:21:39 2025 +0200

    Add field to not register social account when called

* Squashed commit of the following:

commit b8a2f0b8ee12d065e3eb3ebfb2e720353e45607c
Author: CuriousMagpie <eilatan@gmail.com>
Date:   Thu Jun 20 17:18:30 2024 -0400

    update privacy policy

* fix(vite): import syntax

* feat(auth): precheck on defaulted username

* feat(auth): add store action for check-email

* feat(auth): check email before proceeding

* WIP(login): refactor username screen

* WIP(auth): complete login/reg flow

* fix(auth): filter out expected 404

* fix(login): use allowRegister with Apple
and add z-index to component

* fix(login): style corrections and email passthru

* Fix edgecase

Signed-off-by: Kalista Payne <sabrecat@gmail.com>

* fix(auth): correct error behaviors

* fix(auth): rewire Apple auth

* make check-email check for restricted domains

Signed-off-by: Kalista Payne <kalista@habitica.com>

* fix(signup): all the style

* fix(express): return when responding

* fix(error): reduce specificity for restricted domain issue

* fix apple auth

Signed-off-by: Kalista Payne <kalista@habitica.com>

* fix(signup): change from blur to 500ms debounce

* fix(login): add missing 200 response in Apple flow

* fix(signup): more reconciliation with @phillipthelen's work

* fix(signup): now using token not code

* fix(reg): don't bail on Apple if we're allowing reg

* fix(auth): more reconciliation with @phillipthelen code

* feat(copy): privacy policy updates

* fix(copy): replace placeholder

* fix(vue): use Vite syntax for scss import

* fix(static): corrections to copy and css

* chore(style): remove excess whitespace

* use correct error

Signed-off-by: Kalista Payne <kalista@habitica.com>

* fix(layout): inputs, add privacy banner

* fix(login): button hover, more validation states

* fix(login): further layout and UX corrections

* fix(static): add back containing div for show/hide

* fix(apple): clean out Apple token

* fix(settings): only change preference on save

* fix(settings): correct save/cancel behavior

* fix(layout): consistent use of header/footer

* fix(layout): reposition mountains for reg/login/forgot

* fix(signup): partial rollback of /username route

* refactor(signup): move /username to page

* fix(apple): don't overwrite reg method

* fix(username): don't skip empty validation

* fix(input): don't show valid if no username

* fix(login): clean out Apple token if using another method

* fix(apple): possible race with token

* fix(tests): some housekeeping

* fix(config): copypasta

* fix(lint): various cleanup

* fix(lint): line squeeze

* fix(lint): one more v-for

* fix(groups): funnel invite flow to new username page

* Squashed commit of the following:

commit 3c5ba4bf24e4bb7996786520101f27ad66405bce
Author: Kalista Payne <kalista@habitica.com>
Date:   Mon Aug 18 14:38:31 2025 -0500

    fix(privacy): update link ref

commit 9d216f623b5dd65650726240f0197be96ec42075
Author: Kalista Payne <kalista@habitica.com>
Date:   Mon Aug 18 14:18:22 2025 -0500

    fix(privacy-tos): copy edits cont'd

commit d744f471404e6f83142c35fe603938dc07a66f9e
Author: Kalista Payne <kalista@habitica.com>
Date:   Mon Aug 18 13:43:22 2025 -0500

    fix(privacy): copy edits and ToC reflow

commit 2c3c3fc9ce982c07c6db3d2a13f23b5005b75383
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Aug 18 18:46:24 2025 +0200

    lint

commit cf363034d554428102299bc8ae7aa6a596b25522
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Aug 18 18:34:54 2025 +0200

    fix link

commit 3afacd2c0569e01d251f71a850d8d2d223eb7d8a
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Aug 18 18:34:42 2025 +0200

    add updated terms

commit 258b7224993f04362e6dce9afbb0959c35c95cd1
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Aug 18 17:58:42 2025 +0200

    put back button to show/hide third party info

commit 2992e0299b2d82fce4e5b153a3a9a3da1ee1c6b8
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Aug 18 17:58:32 2025 +0200

    minor edits

commit bb5e252299dcb6577a21ab092ac401420b1dc9aa
Author: Kalista Payne <kalista@habitica.com>
Date:   Sun Aug 17 21:01:50 2025 -0500

    fix(privacy): update Section 3

commit c79af7baa84bbdffe90e8aaa53da44501f5c2569
Author: Kalista Payne <kalista@habitica.com>
Date:   Fri Aug 15 17:28:49 2025 -0500

    fix(privacy): various copy edits

commit 100f2f4574fc47d1bcdbad4688616d0953a4051d
Author: Phillip Thelen <phillip@habitica.com>
Date:   Fri Aug 15 11:37:37 2025 +0200

    add newline

commit 11d1cfd0d94f164d7a1f8f67dea5e83f4d7f39cc
Author: Phillip Thelen <phillip@habitica.com>
Date:   Fri Aug 15 11:10:01 2025 +0200

    update privacy policy

commit 59b99badf3699b0d4f7a02ce4251ff1475878052
Author: Kalista Payne <kalista@habitica.com>
Date:   Fri Aug 8 14:04:19 2025 -0500

    5.38.2

commit 78daeb4191b55486a80282ce82bb6456bba95e2f
Author: Kalista Payne <kalista@habitica.com>
Date:   Fri Aug 8 13:36:19 2025 -0500

    fix(apple): don't run auth middleware during redirect

commit 93f8d60903383d1c888f901ae550d0410f89a855
Author: Weblate <noreply@weblate.org>
Date:   Fri Aug 8 10:12:25 2025 +0200

    Translated using Weblate (German)

    Currently translated at 99.4% (185 of 186 strings)

    Translated using Weblate (Chinese (Simplified))

    Currently translated at 100.0% (186 of 186 strings)

    Translated using Weblate (Spanish)

    Currently translated at 100.0% (186 of 186 strings)

    Translated using Weblate (Japanese)

    Currently translated at 100.0% (54 of 54 strings)

    Translated using Weblate (Japanese)

    Currently translated at 100.0% (243 of 243 strings)

    Translated using Weblate (Japanese)

    Currently translated at 100.0% (15 of 15 strings)

    Translated using Weblate (Japanese)

    Currently translated at 100.0% (47 of 47 strings)

    Translated using Weblate (Dutch)

    Currently translated at 78.0% (2643 of 3385 strings)

    Translated using Weblate (Dutch)

    Currently translated at 40.8% (100 of 245 strings)

    Translated using Weblate (Polish)

    Currently translated at 89.9% (233 of 259 strings)

    Translated using Weblate (Dutch)

    Currently translated at 67.5% (175 of 259 strings)

    Translated using Weblate (Japanese)

    Currently translated at 100.0% (914 of 914 strings)

    Translated using Weblate (Japanese)

    Currently translated at 100.0% (110 of 110 strings)

    Translated using Weblate (Japanese)

    Currently translated at 100.0% (914 of 914 strings)

    Translated using Weblate (Japanese)

    Currently translated at 100.0% (914 of 914 strings)

    Translated using Weblate (Chinese (Traditional))

    Currently translated at 20.8% (51 of 245 strings)

    Translated using Weblate (Turkish)

    Currently translated at 65.9% (60 of 91 strings)

    Translated using Weblate (Turkish)

    Currently translated at 65.9% (60 of 91 strings)

    Translated using Weblate (Chinese (Traditional))

    Currently translated at 17.9% (44 of 245 strings)

    Co-authored-by: FingerTiao <787170918@qq.com>
    Co-authored-by: Jaime Martí <jaumemarti77@icloud.com>
    Co-authored-by: Karmelkowy <kicimeow.karmelio@gmail.com>
    Co-authored-by: Linsey Dunya Pastoor <sekai.creations@gmail.com>
    Co-authored-by: Mete Olmez <metezori27@gmail.com>
    Co-authored-by: Sefa Uğurlu <ugurlusefa2@gmail.com>
    Co-authored-by: Summer_GUI <heyang94@163.com>
    Co-authored-by: Toro Mor <thomas.bizer@gmx.de>
    Co-authored-by: Weblate <noreply@weblate.org>
    Co-authored-by: innnko <ayakabooker@gmail.com>
    Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/ja/
    Translate-URL: https://translate.habitica.com/projects/habitica/challenge/ja/
    Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/tr/
    Translate-URL: https://translate.habitica.com/projects/habitica/contrib/ja/
    Translate-URL: https://translate.habitica.com/projects/habitica/death/ja/
    Translate-URL: https://translate.habitica.com/projects/habitica/defaulttasks/ja/
    Translate-URL: https://translate.habitica.com/projects/habitica/faq/nl/
    Translate-URL: https://translate.habitica.com/projects/habitica/faq/zh_Hant/
    Translate-URL: https://translate.habitica.com/projects/habitica/front/de/
    Translate-URL: https://translate.habitica.com/projects/habitica/front/es/
    Translate-URL: https://translate.habitica.com/projects/habitica/front/zh_Hans/
    Translate-URL: https://translate.habitica.com/projects/habitica/gear/nl/
    Translate-URL: https://translate.habitica.com/projects/habitica/generic/ja/
    Translate-URL: https://translate.habitica.com/projects/habitica/settings/nl/
    Translate-URL: https://translate.habitica.com/projects/habitica/settings/pl/
    Translation: Habitica/Backgrounds
    Translation: Habitica/Challenge
    Translation: Habitica/Communityguidelines
    Translation: Habitica/Contrib
    Translation: Habitica/Death
    Translation: Habitica/Defaulttasks
    Translation: Habitica/Faq
    Translation: Habitica/Front
    Translation: Habitica/Gear
    Translation: Habitica/Generic
    Translation: Habitica/Settings

commit eb16fec41eed42ac681ac0667ec7dc510670b5bc
Author: Phillip Thelen <phillip@habitica.com>
Date:   Wed Aug 6 22:08:07 2025 +0200

    Add interface to block ip-addresses or clients due to abuse (#15484)

    * Read IP blocks from database

    * begin building general blocking solution

    * add new frontend files

    * Add UI for managing blockers

    * correctly reset local data after creating blocker

    * Tweak wording

    * Add UI for managing blockers

    * restructure admin pages

    * improve test coverage

    * Improve blocker UI

    * add blocker to block emails from registration

    * lint fix

    * fix

    * lint fixes

    * fix import

    * add new permission for managing blockers

    * improve permission check

    * fix managing permissions from admin

    * improve navbar display for non fullAccess admin

    * update block error strings

    * lint fix

    * add option to errorHandler to skip logging

    * validate blocker value during input

    * improve blocker form display

    * chore(subproj): reconcile habitica-images

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

    * fix(whitespace): eof

    * documentation improvements

    * remove nconf import

    * remove old test

    ---------

    Co-authored-by: Kalista Payne <kalista@habitica.com>
    Co-authored-by: Kalista Payne <sabrecat@gmail.com>

commit 47d832bf12dfdf0e1eb98f431edf5487e3ce6c3f
Author: Fiz <34069775+Hafizzle@users.noreply.github.com>
Date:   Tue Aug 5 15:12:44 2025 -0500

    Add backend support for Hydra mount (#15482)

    * chore: update time travelers shop to display seasonal backgrounds

    * chore: update time travelers banner (note CSS borken rn)

    * chore: fix borken CSS and update logic in shop

    * chore: added isSubscribed function, not working

    * chore: isSubscribed working but no bg for subscribers

    * chore: logic and css updates

    * chore: update habitica-images

    * chore: add check for trinket

    * chore: more time traveler shop logicking

    * Add backend support for Hydra mount

    - Add Dragon-Hydra to special mounts in stable.js
      - Configure as contributor level 7 reward with canFind: true
      - Add GIF format support for mount sprites
      - Enable admin panel granting capability

    * Fix Vue template errors in timeTravelers component

    * Fix duplicate template block in timeTravelers component

    * add CSS for Hydra mount GIF sprites

    Added CSS rules for Mount_Head_Dragon-Hydra and Mount_Body_Dragon-Hydra GIF sprites

    * Remove the separate Hydra mount dimension declaration

    ---------

    Co-authored-by: CuriousMagpie <eilatan@gmail.com>

commit c03ab9855f055c1a5b44e1ca915af190764a2bad
Author: Kalista Payne <kalista@habitica.com>
Date:   Tue Aug 5 14:31:05 2025 -0500

    5.38.1

commit 8f96b7b7fdfc8a51db5b4cf8aa96d291e27388d4
Author: Weblate <noreply@weblate.org>
Date:   Tue Aug 5 13:02:45 2025 +0200

    Translated using Weblate (Chinese (Traditional))

    Currently translated at 17.1% (42 of 245 strings)

    Translated using Weblate (Chinese (Traditional))

    Currently translated at 16.7% (41 of 245 strings)

    Translated using Weblate (Chinese (Traditional))

    Currently translated at 16.3% (40 of 245 strings)

    Translated using Weblate (Polish)

    Currently translated at 100.0% (914 of 914 strings)

    Translated using Weblate (Japanese)

    Currently translated at 98.8% (425 of 430 strings)

    Translated using Weblate (French)

    Currently translated at 99.4% (184 of 185 strings)

    Translated using Weblate (Chinese (Traditional))

    Currently translated at 15.9% (39 of 245 strings)

    Translated using Weblate (Chinese (Simplified))

    Currently translated at 100.0% (268 of 268 strings)

    Translated using Weblate (Chinese (Simplified))

    Currently translated at 100.0% (3385 of 3385 strings)

    Translated using Weblate (Chinese (Simplified))

    Currently translated at 100.0% (185 of 185 strings)

    Translated using Weblate (Chinese (Simplified))

    Currently translated at 100.0% (914 of 914 strings)

    Translated using Weblate (Chinese (Simplified))

    Currently translated at 99.8% (3379 of 3385 strings)

    Translated using Weblate (Polish)

    Currently translated at 95.5% (128 of 134 strings)

    Translated using Weblate (Japanese)

    Currently translated at 94.7% (254 of 268 strings)

    Translated using Weblate (Polish)

    Currently translated at 94.0% (126 of 134 strings)

    Translated using Weblate (Japanese)

    Currently translated at 98.6% (424 of 430 strings)

    Translated using Weblate (Japanese)

    Currently translated at 98.3% (423 of 430 strings)

    Translated using Weblate (Japanese)

    Currently translated at 92.5% (798 of 862 strings)

    Translated using Weblate (Japanese)

    Currently translated at 92.4% (797 of 862 strings)

    Translated using Weblate (Japanese)

    Currently translated at 90.6% (781 of 862 strings)

    Translated using Weblate (Japanese)

    Currently translated at 91.9% (3112 of 3385 strings)

    Translated using Weblate (Japanese)

    Currently translated at 91.9% (3111 of 3385 strings)

    Translated using Weblate (Japanese)

    Currently translated at 94.0% (174 of 185 strings)

    Translated using Weblate (Japanese)

    Currently translated at 100.0% (259 of 259 strings)

    Translated using Weblate (Japanese)

    Currently translated at 100.0% (8 of 8 strings)

    Translated using Weblate (Chinese (Traditional))

    Currently translated at 15.5% (38 of 245 strings)

    Translated using Weblate (Japanese)

    Currently translated at 91.6% (3104 of 3385 strings)

    Translated using Weblate (Japanese)

    Currently translated at 93.5% (173 of 185 strings)

    Translated using Weblate (Japanese)

    Currently translated at 99.6% (279 of 280 strings)

    Translated using Weblate (Spanish)

    Currently translated at 100.0% (167 of 167 strings)

    Translated using Weblate (Japanese)

    Currently translated at 89.2% (769 of 862 strings)

    Translated using Weblate (Japanese)

    Currently translated at 100.0% (914 of 914 strings)

    Translated using Weblate (Japanese)

    Currently translated at 94.4% (253 of 268 strings)

    Translated using Weblate (Japanese)

    Currently translated at 91.8% (170 of 185 strings)

    Translated using Weblate (Japanese)

    Currently translated at 97.9% (421 of 430 strings)

    Translated using Weblate (Japanese)

    Currently translated at 91.6% (3104 of 3385 strings)

    Translated using Weblate (Japanese)

    Currently translated at 93.6% (251 of 268 strings)

    Translated using Weblate (Japanese)

    Currently translated at 90.8% (168 of 185 strings)

    Translated using Weblate (Japanese)

    Currently translated at 82.4% (202 of 245 strings)

    Translated using Weblate (French)

    Currently translated at 100.0% (268 of 268 strings)

    Translated using Weblate (French)

    Currently translated at 100.0% (3385 of 3385 strings)

    Translated using Weblate (Chinese (Traditional))

    Currently translated at 15.1% (37 of 245 strings)

    Translated using Weblate (French)

    Currently translated at 100.0% (914 of 914 strings)

    Translated using Weblate (Japanese)

    Currently translated at 91.3% (3092 of 3385 strings)

    Translated using Weblate (Japanese)

    Currently translated at 92.5% (248 of 268 strings)

    Translated using Weblate (Japanese)

    Currently translated at 92.5% (248 of 268 strings)

    Translated using Weblate (Japanese)

    Currently translated at 100.0% (193 of 193 strings)

    Translated using Weblate (Croatian)

    Currently translated at 100.0% (15 of 15 strings)

    Translated using Weblate (Spanish)

    Currently translated at 100.0% (167 of 167 strings)

    Translated using Weblate (Korean)

    Currently translated at 22.8% (56 of 245 strings)

    Translated using Weblate (Korean)

    Currently translated at 47.7% (128 of 268 strings)

    Translated using Weblate (Croatian)

    Currently translated at 45.1% (121 of 268 strings)

    Translated using Weblate (Korean)

    Currently translated at 71.9% (620 of 862 strings)

    Translated using Weblate (Croatian)

    Currently translated at 70.6% (609 of 862 strings)

    Translated using Weblate (Croatian)

    Currently translated at 75.0% (6 of 8 strings)

    Translated using Weblate (Korean)

    Currently translated at 67.6% (291 of 430 strings)

    Translated using Weblate (Korean)

    Currently translated at 52.8% (1788 of 3385 strings)

    Translated using Weblate (Croatian)

    Currently translated at 50.3% (1706 of 3385 strings)

    Translated using Weblate (Croatian)

    Currently translated at 51.7% (134 of 259 strings)

    Translated using Weblate (Czech)

    Currently translated at 92.8% (130 of 140 strings)

    Translated using Weblate (English (United Kingdom))

    Currently translated at 86.9% (233 of 268 strings)

    Translated using Weblate (Spanish)

    Currently translated at 100.0% (94 of 94 strings)

    Translated using Weblate (English (United Kingdom))

    Currently translated at 100.0% (94 of 94 strings)

    Translated using Weblate (Danish)

    Currently translated at 92.1% (105 of 114 strings)

    Translated using Weblate (Czech)

    Currently translated at 89.4% (102 of 114 strings)

    Translated using Weblate (Czech)

    Currently translated at 83.5% (112 of 134 strings)

    Translated using Weblate (Spanish (Latin America))

    Currently translated at 71.6% (308 of 430 strings)

    Translated using Weblate (Spanish (Latin America))

    Currently translated at 100.0% (245 of 245 strings)

    Translated using Weblate (Serbian)

    Currently translated at 84.4% (49 of 58 strings)

    Translated using Weblate (Bulgarian)

    Currently translated at 51.4% (144 of 280 strings)

    Translated using Weblate (Swedish)

    Currently translated at 66.5% (286 of 430 strings)

    Translated using Weblate (Serbian)

    Currently translated at 65.5% (282 of 430 strings)

    Translated using Weblate (Slovak)

    Currently translated at 65.5% (282 of 430 strings)

    Translated using Weblate (Romanian)

    Currently translated at 66.7% (287 of 430 strings)

    Translated using Weblate (English (United Kingdom))

    Currently translated at 100.0% (430 of 430 strings)

    Translated using Weblate (Danish)

    Currently translated at 66.0% (284 of 430 strings)

    Translated using Weblate (Czech)

    Currently translated at 69.7% (300 of 430 strings)

    Translated using Weblate (Chinese (Simplified))

    Currently translated at 99.7% (3377 of 3385 strings)

    Translated using Weblate (Swedish)

    Currently translated at 54.1% (1834 of 3385 strings)

    Translated using Weblate (Serbian)

    Currently translated at 50.6% (1714 of 3385 strings)

    Translated using Weblate (Slovak)

    Currently translated at 50.0% (1695 of 3385 strings)

    Translated using Weblate (Romanian)

    Currently translated at 60.5% (2050 of 3385 strings)

    Translated using Weblate (Hebrew)

    Currently translated at 38.4% (1301 of 3385 strings)

    Translated using Weblate (Spanish)

    Currently translated at 100.0% (3385 of 3385 strings)

    Translated using Weblate (Danish)

    Currently translated at 54.0% (1829 of 3385 strings)

    Translated using Weblate (Czech)

    Currently translated at 59.6% (2020 of 3385 strings)

    Translated using Weblate (Swedish)

    Currently translated at 75.6% (140 of 185 strings)

    Translated using Weblate (Serbian)

    Currently translated at 73.5% (136 of 185 strings)

    Translated using Weblate (Slovak)

    Currently translated at 84.8% (157 of 185 strings)

    Translated using Weblate (Romanian)

    Currently translated at 78.9% (146 of 185 strings)

    Translated using Weblate (Portuguese)

    Currently translated at 82.1% (152 of 185 strings)

    Translated using Weblate (Italian)

    Currently translated at 91.8% (170 of 185 strings)

    Translated using Weblate (Spanish)

    Currently translated at 100.0% (185 of 185 strings)

    Translated using Weblate (English (United Kingdom))

    Currently translated at 100.0% (185 of 185 strings)

    Translated using Weblate (English (United Kingdom))

    Currently translated at 100.0% (185 of 185 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (184 of 184 strings)

    Translated using Weblate (Danish)

    Currently translated at 77.2% (143 of 185 strings)

    Translated using Weblate (English (United Kingdom))

    Currently translated at 98.7% (242 of 245 strings)

    Translated using Weblate (Czech)

    Currently translated at 75.1% (139 of 185 strings)

    Translated using Weblate (Bulgarian)

    Currently translated at 74.5% (138 of 185 strings)

    Translated using Weblate (Czech)

    Currently translated at 8.1% (20 of 245 strings)

    Translated using Weblate (Swedish)

    Currently translated at 72.0% (621 of 862 strings)

    Translated using Weblate (Serbian)

    Currently translated at 65.1% (562 of 862 strings)

    Translated using Weblate (Slovak)

    Currently translated at 66.9% (577 of 862 strings)

    Translated using Weblate (Romanian)

    Currently translated at 77.7% (670 of 862 strings)

    Translated using Weblate (Portuguese)

    Currently translated at 70.0% (604 of 862 strings)

    Translated using Weblate (Polish)

    Currently translated at 67.1% (579 of 862 strings)

    Translated using Weblate (Italian)

    Currently translated at 86.8% (749 of 862 strings)

    Translated using Weblate (Indonesian)

    Currently translated at 86.0% (742 of 862 strings)

    Translated using Weblate (Hebrew)

    Currently translated at 66.1% (570 of 862 strings)

    Translated using Weblate (English (United Kingdom))

    Currently translated at 98.0% (845 of 862 strings)

    Translated using Weblate (Danish)

    Currently translated at 69.9% (603 of 862 strings)

    Translated using Weblate (Czech)

    Currently translated at 69.7% (601 of 862 strings)

    Translated using Weblate (Bulgarian)

    Currently translated at 66.3% (572 of 862 strings)

    Translated using Weblate (Serbian)

    Currently translated at 74.0% (305 of 412 strings)

    Translated using Weblate (Turkish)

    Currently translated at 100.0% (193 of 193 strings)

    Translated using Weblate (Danish)

    Currently translated at 90.0% (371 of 412 strings)

    Translated using Weblate (Ukrainian)

    Currently translated at 100.0% (259 of 259 strings)

    Translated using Weblate (Swedish)

    Currently translated at 53.6% (139 of 259 strings)

    Translated using Weblate (Spanish)

    Currently translated at 100.0% (259 of 259 strings)

    Translated using Weblate (English (United Kingdom))

    Currently translated at 100.0% (259 of 259 strings)

    Translated using Weblate (Danish)

    Currently translated at 62.1% (161 of 259 strings)

    Translated using Weblate (Bulgarian)

    Currently translated at 54.0% (140 of 259 strings)

    Translated using Weblate (English (United Kingdom))

    Currently translated at 82.8% (222 of 268 strings)

    Translated using Weblate (English (United Kingdom))

    Currently translated at 99.4% (184 of 185 strings)

    Translated using Weblate (English (United Kingdom))

    Currently translated at 98.3% (241 of 245 strings)

    Translated using Weblate (Japanese)

    Currently translated at 91.3% (3092 of 3385 strings)

    Translated using Weblate (Japanese)

    Currently translated at 88.4% (237 of 268 strings)

    Translated using Weblate (Japanese)

    Currently translated at 100.0% (134 of 134 strings)

    Translated using Weblate (Japanese)

    Currently translated at 100.0% (259 of 259 strings)

    Translated using Weblate (Japanese)

    Currently translated at 100.0% (914 of 914 strings)

    Translated using Weblate (Japanese)

    Currently translated at 100.0% (243 of 243 strings)

    Translated using Weblate (Japanese)

    Currently translated at 82.4% (202 of 245 strings)

    Translated using Weblate (English (United Kingdom))

    Currently translated at 100.0% (259 of 259 strings)

    Translated using Weblate (Japanese)

    Currently translated at 87.3% (234 of 268 strings)

    Translated using Weblate (Japanese)

    Currently translated at 86.4% (160 of 185 strings)

    Translated using Weblate (Japanese)

    Currently translated at 99.8% (913 of 914 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (268 of 268 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (3377 of 3377 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (914 of 914 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (259 of 259 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (259 of 259 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (259 of 259 strings)

    Translated using Weblate (Spanish)

    Currently translated at 100.0% (3385 of 3385 strings)

    Translated using Weblate (Spanish)

    Currently translated at 100.0% (914 of 914 strings)

    Translated using Weblate (Spanish)

    Currently translated at 100.0% (268 of 268 strings)

    Translated using Weblate (Russian)

    Currently translated at 88.5% (248 of 280 strings)

    Translated using Weblate (Spanish)

    Currently translated at 99.8% (3379 of 3385 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (862 of 862 strings)

    Co-authored-by: Ayaka Booker <ayakabooker@gmail.com>
    Co-authored-by: Chaotic Lawful <habitica@eusebius.fr>
    Co-authored-by: FingerTiao <787170918@qq.com>
    Co-authored-by: Jaime Martí <jaumemarti77@icloud.com>
    Co-authored-by: Jan Freihöfer <jan.stauch.is@gmail.com>
    Co-authored-by: Karmelkowy <kicimeow.karmelio@gmail.com>
    Co-authored-by: Lio Zam <zerofux@web.de>
    Co-authored-by: Mika <isekai.chr@gmail.com>
    Co-authored-by: Sophie LE MASLE <sophiesuff@gmail.com>
    Co-authored-by: Summer_GUI <heyang94@163.com>
    Co-authored-by: Vera <verasmolinap@gmail.com>
    Co-authored-by: Weblate <noreply@weblate.org>
    Co-authored-by: Zhi Hao Li <zhihaoli000@gmail.com>
    Co-authored-by: Zuz Q <zuzannakunik@gmail.com>
    Co-authored-by: innnko <ayakabooker@gmail.com>
    Co-authored-by: 吳昀錡 <J1120241@gm.fdhs.tyc.edu.tw>
    Co-authored-by: 潘致翰 <happyq0908@gmail.com>
    Translate-URL: https://translate.habitica.com/projects/habitica/achievements/es/
    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/ja/
    Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/pl/
    Translate-URL: https://translate.habitica.com/projects/habitica/backgrounds/zh_Hans/
    Translate-URL: https://translate.habitica.com/projects/habitica/character/ja/
    Translate-URL: https://translate.habitica.com/projects/habitica/character/tr/
    Translate-URL: https://translate.habitica.com/projects/habitica/content/da/
    Translate-URL: https://translate.habitica.com/projects/habitica/content/sr/
    Translate-URL: https://translate.habitica.com/projects/habitica/death/hr/
    Translate-URL: https://translate.habitica.com/projects/habitica/faq/cs/
    Translate-URL: https://translate.habitica.com/projects/habitica/faq/en_GB/
    Translate-URL: https://translate.habitica.com/projects/habitica/faq/es_419/
    Translate-URL: https://translate.habitica.com/projects/habitica/faq/ja/
    Translate-URL: https://translate.habitica.com/projects/habitica/faq/ko/
    Translate-URL: https://translate.habitica.com/projects/habitica/faq/zh_Hant/
    Translate-URL: https://translate.habitica.com/projects/habitica/front/bg/
    Translate-URL: https://translate.habitica.com/projects/habitica/front/cs/
    Translate-URL: https://translate.habitica.com/projects/habitica/front/da/
    Translate-URL: https://translate.habitica.com/projects/habitica/front/de/
    Translate-URL: https://translate.habitica.com/projects/habitica/front/en_GB/
    Translate-URL: https://translate.habitica.com/projects/habitica/front/es/
    Translate-URL: https://translate.habitica.com/projects/habitica/front/fr/
    Translate-URL: https://translate.habitica.com/projects/habitica/front/it/
    Translate-URL: https://translate.habitica.com/projects/habitica/front/ja/
    Translate-URL: https://translate.habitica.com/projects/habitica/front/pt/
    Translate-URL: https://translate.habitica.com/projects/habitica/front/ro/
    Translate-URL: https://translate.habitica.com/projects/habitica/front/sk/
    Translate-URL: https://translate.habitica.com/projects/habitica/front/sr/
    Translate-URL: https://translate.habitica.com/projects/habitica/front/sv/
    Translate-URL: https://translate.habitica.com/projects/habitica/front/zh_Hans/
    Translate-URL: https://translate.habitica.com/projects/habitica/gear/cs/
    Translate-URL: https://translate.habitica.com/projects/habitica/gear/da/
    Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/
    Translate-URL: https://translate.habitica.com/projects/habitica/gear/es/
    Translate-URL: https://translate.habitica.com/projects/habitica/gear/fr/
    Translate-URL: https://translate.habitica.com/projects/habitica/gear/he/
    Translate-URL: https://translate.habitica.com/projects/habitica/gear/hr/
    Translate-URL: https://translate.habitica.com/projects/habitica/gear/ja/
    Translate-URL: https://translate.habitica.com/projects/habitica/gear/ko/
    Translate-URL: https://translate.habitica.com/projects/habitica/gear/ro/
    Translate-URL: https://translate.habitica.com/projects/habitica/gear/sk/
    Translate-URL: https://translate.habitica.com/projects/habitica/gear/sr/
    Translate-URL: https://translate.habitica.com/projects/habitica/gear/sv/
    Translate-URL: https://translate.habitica.com/projects/habitica/gear/zh_Hans/
    Translate-URL: https://translate.habitica.com/projects/habitica/generic/ja/
    Translate-URL: https://translate.habitica.com/projects/habitica/groups/cs/
    Translate-URL: https://translate.habitica.com/projects/habitica/groups/da/
    Translate-URL: https://translate.habitica.com/projects/habitica/groups/en_GB/
    Translate-URL: https://translate.habitica.com/projects/habitica/groups/es_419/
    Translate-URL: https://translate.habitica.com/projects/habitica/groups/ja/
    Translate-URL: https://translate.habitica.com/projects/habitica/groups/ko/
    Translate-URL: https://translate.habitica.com/projects/habitica/groups/ro/
    Translate-URL: https://translate.habitica.com/projects/habitica/groups/sk/
    Translate-URL: https://translate.habitica.com/projects/habitica/groups/sr/
    Translate-URL: https://translate.habitica.com/projects/habitica/groups/sv/
    Translate-URL: https://translate.habitica.com/projects/habitica/limited/bg/
    Translate-URL: https://translate.habitica.com/projects/habitica/limited/ja/
    Translate-URL: https://translate.habitica.com/projects/habitica/limited/ru/
    Translate-URL: https://translate.habitica.com/projects/habitica/messages/sr/
    Translate-URL: https://translate.habitica.com/projects/habitica/npc/cs/
    Translate-URL: https://translate.habitica.com/projects/habitica/npc/ja/
    Translate-URL: https://translate.habitica.com/projects/habitica/npc/pl/
    Translate-URL: https://translate.habitica.com/projects/habitica/overview/hr/
    Translate-URL: https://translate.habitica.com/projects/habitica/overview/ja/
    Translate-URL: https://translate.habitica.com/projects/habitica/pets/cs/
    Translate-URL: https://translate.habitica.com/projects/habitica/pets/da/
    Translate-URL: https://translate.habitica.com/projects/habitica/quests/en_GB/
    Translate-URL: https://translate.habitica.com/projects/habitica/quests/es/
    Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/bg/
    Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/cs/
    Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/da/
    Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/de/
    Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/en_GB/
    Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/he/
    Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/hr/
    Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/id/
    Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/it/
    Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/ja/
    Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/ko/
    Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pl/
    Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pt/
    Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/ro/
    Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/sk/
    Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/sr/
    Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/sv/
    Translate-URL: https://translate.habitica.com/projects/habitica/settings/bg/
    Translate-URL: https://translate.habitica.com/projects/habitica/settings/da/
    Translate-URL: https://translate.habitica.com/projects/habitica/settings/de/
    Translate-URL: https://translate.habitica.com/projects/habitica/settings/en_GB/
    Translate-URL: https://translate.habitica.com/projects/habitica/settings/es/
    Translate-URL: https://translate.habitica.com/projects/habitica/settings/hr/
    Translate-URL: https://translate.habitica.com/projects/habitica/settings/ja/
    Translate-URL: https://translate.habitica.com/projects/habitica/settings/sv/
    Translate-URL: https://translate.habitica.com/projects/habitica/settings/uk/
    Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/de/
    Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/en_GB/
    Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/es/
    Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/fr/
    Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/hr/
    Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/ja/
    Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/ko/
    Translate-URL: https://translate.habitica.com/projects/habitica/subscriber/zh_Hans/
    Translate-URL: https://translate.habitica.com/projects/habitica/tasks/cs/
    Translation: Habitica/Achievements
    Translation: Habitica/Backgrounds
    Translation: Habitica/Character
    Translation: Habitica/Content
    Translation: Habitica/Death
    Translation: Habitica/Faq
    Translation: Habitica/Front
    Translation: Habitica/Gear
    Translation: Habitica/Generic
    Translation: Habitica/Groups
    Translation: Habitica/Limited
    Translation: Habitica/Messages
    Translation: Habitica/Npc
    Translation: Habitica/Overview
    Translation: Habitica/Pets
    Translation: Habitica/Quests
    Translation: Habitica/Questscontent
    Translation: Habitica/Settings
    Translation: Habitica/Subscriber
    Translation: Habitica/Tasks

commit 1dde2674f6fb4664920c003eb827c8431e380479
Author: Kalista Payne <sabrecat@gmail.com>
Date:   Mon Jun 16 16:43:56 2025 -0500

    fix(content): don't filter out the thing we want

commit 76122a8889a6780cd66ee76194e64dc47ce1223f
Author: Kalista Payne <sabrecat@gmail.com>
Date:   Wed Jun 4 14:28:27 2025 -0500

    fix(mobile): provide Challenge categories via API

commit 9e309a875e3025f3affb434a50c4dd3acaad64ac
Author: Kalista Payne <kalista@habitica.com>
Date:   Mon Jul 28 14:15:00 2025 -0500

    5.38.0

commit 09e3a394b810ad7820e4b100deb588586b304c23
Author: Kalista Payne <kalista@habitica.com>
Date:   Mon Jul 28 14:06:45 2025 -0500

    5.37.3

commit eba263360f50d529fb28e3b9cad1f8058d83764f
Author: Weblate <noreply@weblate.org>
Date:   Mon Jul 28 21:03:17 2025 +0200

    Translated using Weblate (German)

    Currently translated at 100.0% (134 of 134 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (134 of 134 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (3377 of 3377 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (3377 of 3377 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (3377 of 3377 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (243 of 243 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (184 of 184 strings)

    Translated using Weblate (German)

    Currently translated at 98.6% (850 of 862 strings)

    Translated using Weblate (German)

    Currently translated at 99.8% (3373 of 3377 strings)

    Translated using Weblate (German)

    Currently translated at 99.8% (3373 of 3377 strings)

    Translated using Weblate (German)

    Currently translated at 99.8% (3373 of 3377 strings)

    Translated using Weblate (German)

    Currently translated at 99.5% (3361 of 3377 strings)

    Translated using Weblate (German)

    Currently translated at 99.5% (3361 of 3377 strings)

    Translated using Weblate (German)

    Currently translated at 99.5% (3361 of 3377 strings)

    Translated using Weblate (German)

    Currently translated at 99.4% (3360 of 3377 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (184 of 184 strings)

    Translated using Weblate (Spanish)

    Currently translated at 100.0% (185 of 185 strings)

    Translated using Weblate (Polish)

    Currently translated at 67.1% (579 of 862 strings)

    Translated using Weblate (Polish)

    Currently translated at 67.1% (579 of 862 strings)

    Translated using Weblate (Polish)

    Currently translated at 100.0% (91 of 91 strings)

    Translated using Weblate (Polish)

    Currently translated at 100.0% (91 of 91 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (184 of 184 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (184 of 184 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (184 of 184 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (184 of 184 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (184 of 184 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (245 of 245 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (47 of 47 strings)

    Translated using Weblate (German)

    Currently translated at 100.0% (193 of 193 strings)

    Translated using Weblate (Chinese (Traditional))

    Currently translated at 14.2% (35 of 245 strings)

    Translated using Weblate (Chinese (Traditional))

    Currently translated at 13.8% (34 of 245 strings)

    Translated using Weblate (Chinese (Traditional))

    Currently translated at 13.0% (32 of 245 strings)

    Translated using Weblate (Hebrew)

    Currently translated at 2.0% (5 of 245 strings)

    Translated using Weblate (Hebrew)

    Currently translated at 66.1% (570 of 862 strings)

    Translated using Weblate (Portuguese)

    Currently translated at 54.1% (1830 of 3377 strings)

    Co-authored-by: FingerTiao <787170918@qq.com>
    Co-authored-by: Jaime Martí <jaumemarti77@icloud.com>
    Co-authored-by: Jan Freihöfer <jan.stauch.is@gmail.com>
    Co-authored-by: Jonathan Niessen <37.friedrich@gmail.com>
    Co-authored-by: Karmelkowy <kicimeow.karmelio@gmail.com>
    Co-authored-by: Katharina <katharinaanna.wilding@gmail.com>
    Co-authored-by: Laura Fleckenstein <fleckenstein_laura@web.de>
    Co-authored-by: Omer I.S <omeritzicschwartz@gmail.com>
    Co-authored-by: Remigiusz Haziak <haziakremigiusz@gmail.com>
    Co-authored-by: Uwe B <hbtca@tunixgut.de>
    Co-authored-by: Weblate <noreply@weblate.org>
    Co-authored-by: Wellinton Cardoso <wmcardoso1@hotmail.com>
    Co-authored-by: cloudzzy <truskawka412@gmail.com>
    Co-authored-by: 吳昀錡 <J1120241@gm.fdhs.tyc.edu.tw>
    Translate-URL: https://translate.habitica.com/projects/habitica/character/de/
    Translate-URL: https://translate.habitica.com/projects/habitica/communityguidelines/pl/
    Translate-URL: https://translate.habitica.com/projects/habitica/contrib/de/
    Translate-URL: https://translate.habitica.com/projects/habitica/faq/de/
    Translate-URL: https://translate.habitica.com/projects/habitica/faq/he/
    Translate-URL: https://translate.habitica.com/projects/habitica/faq/zh_Hant/
    Translate-URL: https://translate.habitica.com/projects/habitica/front/de/
    Translate-URL: https://translate.habitica.com/projects/habitica/front/es/
    Translate-URL: https://translate.habitica.com/projects/habitica/gear/de/
    Translate-URL: https://translate.habitica.com/projects/habitica/gear/pt/
    Translate-URL: https://translate.habitica.com/projects/habitica/generic/de/
    Translate-URL: https://translate.habitica.com/projects/habitica/npc/de/
    Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/de/
    Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/he/
    Translate-URL: https://translate.habitica.com/projects/habitica/questscontent/pl/
    Translation: Habitica/Character
    Translation: Habitica/Communityguidelines
    Translation: Habitica/Contrib
    Translation: Habitica/Faq
    Translation: Habitica/Front
    Translation: Habitica/Gear
    Translation: Habitica/Generic
    Translation: Habitica/Npc
    Translation: Habitica/Questscontent

commit 9550eec718d284d6ff74aec6264a1ca7042135be
Author: Phillip Thelen <phillip@habitica.com>
Date:   Mon Jul 28 16:50:38 2025 +0200

    Fix 500 when deleting a very old group plan account (#15481)

commit f267eb67e9af74129b86edf5092657377065b531
Author: Kalista Payne <kalista@habitica.com>
Date:   Tue Jul 29 14:12:35 2025 -0500

    fix(static): add back missing div for show/hide

commit 28251f42ab5f886ab7bae70c5a29563691c5c22c
Author: Kalista Payne <kalista@habitica.com>
Date:   Thu Jul 24 22:59:01 2025 -0500

    feat(privacy): preview page

* feat(privacy): respect Global Privacy Control

* fix(lint): remove unused component

* fix(test): test user opts in to tracking

* fix(test): add user pref to more contexts

* fix(test): final spot in api-unit

* fix(tests): update integrations

* chore(privacy): add paragraph to s1, retire separate preview pages

* fix(build): route copypasta

* fix(router): lingering dead import

---------

Signed-off-by: Kalista Payne <sabrecat@gmail.com>
Signed-off-by: Kalista Payne <kalista@habitica.com>
Co-authored-by: Phillip Thelen <phillip@habitica.com>
Co-authored-by: CuriousMagpie <eilatan@gmail.com>
This commit is contained in:
Kalista Payne
2025-08-22 10:08:33 -05:00
committed by Kalista Payne
parent 59ba07d4f3
commit dd97b11b60
96 changed files with 2990 additions and 2993 deletions
+8 -2
View File
@@ -1,5 +1,6 @@
import validator from 'validator';
import moment from 'moment';
import pick from 'lodash/pick';
import sortBy from 'lodash/sortBy';
import nconf from 'nconf';
import {
@@ -127,9 +128,9 @@ api.loginLocal = {
await user.save();
res.analytics.track('login', {
category: 'behaviour',
user: pick(user, ['preferences', 'registeredThrough']),
category: 'behavior',
type: 'local',
gaLabel: 'local',
uuid: user._id,
headers: req.headers,
});
@@ -158,6 +159,9 @@ api.redirectApple = {
async handler (req, res) {
if (req.body.id_token) {
req.body.network = 'apple';
if (!req.body.allowRegister) {
req.body.allowRegister = false;
}
return loginSocial(req, res);
}
let url = `/static/apple-redirect?code=${req.body.code}`;
@@ -181,6 +185,8 @@ api.loginApple = {
url: '/user/auth/apple',
async handler (req, res) {
req.body.network = 'apple';
req.body.allowRegister = req.query.allowRegister === 'true';
req.body.username = req.query.username;
return loginSocial(req, res);
},
};
@@ -1,5 +1,9 @@
import _ from 'lodash';
import cloneDeep from 'lodash/cloneDeep';
import escapeRegExp from 'lodash/escapeRegExp';
import merge from 'lodash/merge';
import pick from 'lodash/pick';
import reduce from 'lodash/reduce';
import times from 'lodash/times';
import { authWithHeaders, authWithSession } from '../../middlewares/auth';
import { model as Challenge } from '../../models/challenge';
import bannedWords from '../../libs/bannedWords';
@@ -287,6 +291,7 @@ api.createChallenge = {
response.group = getChallengeGroupResponse(group);
res.analytics.track('challenge create', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -355,6 +360,7 @@ api.joinChallenge = {
response.leader = chalLeader ? chalLeader.toJSON({ minimize: true }) : null;
res.analytics.track('challenge join', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -405,6 +411,7 @@ api.leaveChallenge = {
await challenge.unlinkTasks(user, keep);
res.analytics.track('challenge leave', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -519,7 +526,7 @@ api.getUserChallenges = {
if (search) {
const searchOr = { $or: [] };
const searchWords = _.escapeRegExp(search).split(' ').join('|');
const searchWords = escapeRegExp(search).split(' ').join('|');
const searchQuery = { $regex: new RegExp(`${searchWords}`, 'i') };
searchOr.$or.push({ name: searchQuery });
searchOr.$or.push({ description: searchQuery });
@@ -775,14 +782,14 @@ api.exportChallengeCsv = {
// The first row is going to be UUID name Task Value Notes
// repeated n times for the n challenge tasks
const challengeTasks = _.reduce(
const challengeTasks = reduce(
challenge.tasksOrder.toObject(),
(result, array) => result.concat(array),
[],
).sort();
resArray.unshift(['UUID', 'Display Name', 'Username']);
_.times(challengeTasks.length, () => resArray[0].push('Task', 'Value', 'Notes', 'Streak'));
times(challengeTasks.length, () => resArray[0].push('Task', 'Value', 'Notes', 'Streak'));
// Remove lines for users without tasks info
resArray = resArray.filter(line => {
@@ -847,7 +854,7 @@ api.updateChallenge = {
if (!group || !challenge.canView(user, group)) throw new NotFound(res.t('challengeNotFound'));
if (!challenge.canModify(user)) throw new NotAuthorized(res.t('onlyLeaderUpdateChal'));
group.purchased = undefined;
_.merge(challenge, Challenge.sanitizeUpdate(req.body));
merge(challenge, Challenge.sanitizeUpdate(req.body));
const savedChal = await challenge.save();
const response = savedChal.toJSON();
@@ -889,6 +896,7 @@ api.deleteChallenge = {
await challenge.closeChal({ broken: 'CHALLENGE_DELETED' });
res.analytics.track('challenge delete', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -949,6 +957,7 @@ api.selectChallengeWinner = {
await challenge.closeChal({ broken: 'CHALLENGE_CLOSED', winner });
res.analytics.track('challenge close', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -1,3 +1,4 @@
import pick from 'lodash/pick';
import moment from 'moment';
import nconf from 'nconf';
import { authWithHeaders } from '../../middlewares/auth';
@@ -186,6 +187,7 @@ api.postChat = {
// Check if account is newer than the minimum age for chat participation
if (moment().diff(user.auth.timestamps.created, 'minutes') < ACCOUNT_MIN_CHAT_AGE) {
analytics.track('chat age error', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -237,6 +239,7 @@ api.postChat = {
await Promise.all(toSave);
const analyticsObject = {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
+20 -9
View File
@@ -1,4 +1,12 @@
import _ from 'lodash';
import assign from 'lodash/assign';
import escapeRegExp from 'lodash/escapeRegExp';
import find from 'lodash/find';
import findIndex from 'lodash/findIndex';
import includes from 'lodash/includes';
import isArray from 'lodash/isArray';
import mergeWith from 'lodash/mergeWith';
import pick from 'lodash/pick';
import uniqBy from 'lodash/uniqBy';
import nconf from 'nconf';
import moment from 'moment';
import { authWithHeaders } from '../../middlewares/auth';
@@ -161,6 +169,7 @@ api.createGroup = {
};
const analyticsObject = {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -211,6 +220,7 @@ api.createGroupPlan = {
const savedGroup = results[1];
res.analytics.track('join group', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -322,7 +332,7 @@ api.getGroups = {
const types = req.query.type.split(',');
const paginate = req.query.paginate === 'true';
if (paginate && !_.includes(types, 'publicGuilds')) {
if (paginate && !includes(types, 'publicGuilds')) {
throw new BadRequest(apiError('guildsOnlyPaginate'));
}
@@ -356,7 +366,7 @@ api.getGroups = {
if (req.query.search) {
filters.$or = [];
const searchWords = _.escapeRegExp(req.query.search.trim()).split(/\s+/).join('|');
const searchWords = escapeRegExp(req.query.search.trim()).split(/\s+/).join('|');
const searchQuery = { $regex: new RegExp(`${searchWords}`, 'i') };
filters.$or.push({ name: searchQuery });
filters.$or.push({ summary: searchQuery });
@@ -497,16 +507,16 @@ api.updateGroup = {
}
const handleArrays = (currentValue, updatedValue) => {
if (!_.isArray(currentValue)) {
if (!isArray(currentValue)) {
return undefined;
}
// Previously, categories could get duplicated. By making the updated category list unique,
// the duplication issue is fixed on every group edit
return _.uniqBy(updatedValue, 'slug');
return uniqBy(updatedValue, 'slug');
};
_.assign(group, _.mergeWith(group.toObject(), Group.sanitizeUpdate(req.body), handleArrays));
assign(group, mergeWith(group.toObject(), Group.sanitizeUpdate(req.body), handleArrays));
const savedGroup = await group.save();
const response = await Group.toJSONCleanChat(savedGroup, user);
@@ -573,7 +583,7 @@ api.joinGroup = {
if (group.type === 'party') {
// Check if was invited to party
const inviterParty = _.find(user.invitations.parties, { id: group._id });
const inviterParty = find(user.invitations.parties, { id: group._id });
if (inviterParty) {
// Check if the user is already a member of the party or not. Only make the user leave the
// party if the user is not a member of the party. See #12291 for more details.
@@ -695,6 +705,7 @@ api.joinGroup = {
promises.push(group.save());
const analyticsObject = {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -920,9 +931,9 @@ api.removeGroupMember = {
}
let isInvited;
if (_.find(member.invitations.parties, { id: group._id })) {
if (find(member.invitations.parties, { id: group._id })) {
isInvited = 'party';
} else if (_.findIndex(member.invitations.guilds, { id: group._id }) !== -1) {
} else if (findIndex(member.invitations.guilds, { id: group._id }) !== -1) {
isInvited = 'guild';
}
+1 -1
View File
@@ -479,7 +479,7 @@ api.updateHero = {
}
if (updateData.auth.local && updateData.auth.local.email) {
hero.auth.local.email = updateData.auth.local.email;
hero.auth.local.email = updateData.auth.local.email.toLowerCase();
}
}
@@ -1,4 +1,5 @@
import escapeRegExp from 'lodash/escapeRegExp';
import pick from 'lodash/pick';
import { authWithHeaders } from '../../middlewares/auth';
import {
model as User,
@@ -736,6 +737,7 @@ api.transferGems = {
if (res.analytics) {
res.analytics.track('transfer gems', {
user: pick(sender, ['preferences', 'registeredThrough']),
uuid: sender._id,
hitType: 'event',
category: 'behavior',
+14 -11
View File
@@ -1,4 +1,7 @@
import _ from 'lodash';
import each from 'lodash/each';
import every from 'lodash/every';
import isBoolean from 'lodash/isBoolean';
import pick from 'lodash/pick';
import { authWithHeaders } from '../../middlewares/auth';
import { getAnalyticsServiceByEnvironment } from '../../libs/analyticsService';
import {
@@ -28,7 +31,7 @@ const questScrolls = common.content.quests;
function canStartQuestAutomatically (group) {
// If all members are either true (accepted) or false (rejected) return true
// If any member is null/undefined (undecided) return false
return _.every(group.quest.members, _.isBoolean);
return every(group.quest.members, isBoolean);
}
/**
@@ -104,7 +107,7 @@ api.inviteToQuest = {
},
}).exec();
_.each(members, member => {
each(members, member => {
group.quest.members[member._id] = null;
});
@@ -165,13 +168,13 @@ api.inviteToQuest = {
// track that the inviting user has accepted the quest
analytics.track('quest', {
category: 'behavior',
owner: true,
response: 'accept',
gaLabel: 'accept',
questName: questKey,
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
category: 'behavior',
headers: req.headers,
owner: true,
questName: questKey,
response: 'accept',
});
await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
@@ -230,10 +233,10 @@ api.acceptQuest = {
// track that a user has accepted the quest
analytics.track('quest', {
user: pick(user, ['preferences', 'registeredThrough']),
category: 'behavior',
owner: false,
response: 'accept',
gaLabel: 'accept',
questName: group.quest.key,
uuid: user._id,
headers: req.headers,
@@ -295,10 +298,10 @@ api.rejectQuest = {
res.respond(200, savedGroup.quest);
analytics.track('quest', {
user: pick(user, ['preferences', 'registeredThrough']),
category: 'behavior',
owner: false,
response: 'reject',
gaLabel: 'reject',
questName: group.quest.key,
uuid: user._id,
headers: req.headers,
@@ -359,10 +362,10 @@ api.forceStart = {
res.respond(200, savedGroup.quest);
analytics.track('quest', {
user: pick(user, ['preferences', 'registeredThrough']),
category: 'behavior',
owner: user._id === group.quest.leader,
response: 'force-start',
gaLabel: 'force-start',
questName: group.quest.key,
uuid: user._id,
headers: req.headers,
+11 -6
View File
@@ -1,4 +1,7 @@
import _ from 'lodash';
import assign from 'lodash/assign';
import find from 'lodash/find';
import merge from 'lodash/merge';
import pick from 'lodash/pick';
import moment from 'moment';
import { authWithHeaders } from '../../middlewares/auth';
import {
@@ -330,6 +333,7 @@ api.createChallengeTasks = {
tasks.forEach(task => {
res.analytics.track('challenge task created', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -645,7 +649,7 @@ api.updateTask = {
sanitizedObj = Tasks.Task.sanitize(updatedTaskObj);
}
_.assign(task, sanitizedObj);
assign(task, sanitizedObj);
// console.log(task.modifiedPaths(), task.toObject().repeat === tep)
// repeat is always among modifiedPaths because mongoose changes
@@ -699,6 +703,7 @@ api.updateTask = {
if (group) {
res.analytics.track('task edit', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -765,7 +770,7 @@ api.scoreTask = {
const userStats = user.stats.toJSON();
const resJsonData = _.assign({
const resJsonData = assign({
delta: taskResponse.delta,
_tmp: user._tmp,
}, userStats);
@@ -970,7 +975,7 @@ api.scoreCheckListItem = {
}
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
const item = _.find(task.checklist, { id: req.params.itemId });
const item = find(task.checklist, { id: req.params.itemId });
if (!item) throw new NotFound(res.t('checklistItemNotFound'));
item.completed = !item.completed;
@@ -1031,10 +1036,10 @@ api.updateChecklistItem = {
verifyTaskModification(task, user, group, challenge, res);
if (task.type !== 'daily' && task.type !== 'todo') throw new BadRequest(res.t('checklistOnlyDailyTodo'));
const item = _.find(task.checklist, { id: req.params.itemId });
const item = find(task.checklist, { id: req.params.itemId });
if (!item) throw new NotFound(res.t('checklistItemNotFound'));
_.merge(item, Tasks.Task.sanitizeChecklist(req.body));
merge(item, Tasks.Task.sanitizeChecklist(req.body));
const savedTask = await task.save();
res.respond(200, savedTask);
@@ -1,3 +1,4 @@
import pick from 'lodash/pick';
import isUUID from 'validator/lib/isUUID';
import { authWithHeaders } from '../../../middlewares/auth';
import * as Tasks from '../../../models/task';
@@ -63,6 +64,7 @@ api.createGroupTasks = {
tasks.forEach(task => {
res.analytics.track('team task created', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -251,6 +253,7 @@ api.assignTask = {
res.respond(200, task);
res.analytics.track('task assign', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
+16 -12
View File
@@ -1,4 +1,7 @@
import _ from 'lodash';
import cloneDeep from 'lodash/cloneDeep';
import forEach from 'lodash/forEach';
import isFunction from 'lodash/isFunction';
import pick from 'lodash/pick';
import nconf from 'nconf';
import get from 'lodash/get';
import { authWithHeaders } from '../../middlewares/auth';
@@ -124,13 +127,13 @@ api.getBuyList = {
middlewares: [authWithHeaders()],
url: '/user/inventory/buy',
async handler (req, res) {
const list = _.cloneDeep(common.updateStore(res.locals.user));
const list = cloneDeep(common.updateStore(res.locals.user));
// return text and notes strings
_.each(list, item => {
_.each(item, (itemPropVal, itemPropKey) => {
forEach(list, item => {
forEach(item, (itemPropVal, itemPropKey) => {
if (
_.isFunction(itemPropVal)
isFunction(itemPropVal)
&& itemPropVal.i18nLangFunc
) item[itemPropKey] = itemPropVal(req.language);
});
@@ -175,10 +178,10 @@ api.getInAppRewardsList = {
const list = common.inAppRewards(res.locals.user);
// return text and notes strings
_.each(list, item => {
_.each(item, (itemPropVal, itemPropKey) => {
forEach(list, item => {
forEach(item, (itemPropVal, itemPropKey) => {
if (
_.isFunction(itemPropVal)
isFunction(itemPropVal)
&& itemPropVal.i18nLangFunc
) item[itemPropKey] = itemPropVal(req.language);
});
@@ -323,6 +326,7 @@ api.deleteUser = {
}
res.analytics.track('account delete', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -333,7 +337,7 @@ api.deleteUser = {
};
function _cleanChecklist (task) {
_.forEach(task.checklist, (c, i) => {
forEach(task.checklist, (c, i) => {
c.text = `item ${i}`;
});
}
@@ -386,11 +390,11 @@ api.getUserAnonymized = {
delete user.secret;
delete user.permissions;
_.forEach(user.inbox.messages, msg => {
forEach(user.inbox.messages, msg => {
msg.text = 'inbox message text';
});
_.forEach(user.tags, tag => {
forEach(user.tags, tag => {
tag.name = 'tag';
tag.challenge = 'challenge';
});
@@ -404,7 +408,7 @@ api.getUserAnonymized = {
};
const tasks = await Tasks.Task.find(query).exec();
_.forEach(tasks, task => {
forEach(tasks, task => {
task.text = 'task text';
task.notes = 'task notes';
if (task.type === 'todo' || task.type === 'daily') {
+46
View File
@@ -4,6 +4,7 @@ import {
import * as authLib from '../../libs/auth';
import { model as User } from '../../models/user';
import { verifyUsername } from '../../libs/user/validation';
import { isRestrictedEmailDomain } from '../../libs/auth/utils';
const api = {};
@@ -83,4 +84,49 @@ api.registerLocal = {
},
};
/**
* @api {put} /api/v4/user/auth/check-email Check if email is used
* @apiDescription Check if the email is already used by another user
* @apiName CheckEmail
* @apiGroup User
*
* @apiParam (Body) {String} email The checked email address.
*
* @apiSuccess {String} data.email The checked email address
* @apiSuccess {Boolean} data.valid True if available, false if in use
*/
api.checkEmail = {
method: 'POST',
url: '/user/auth/check-email',
async handler (req, res) {
req.checkBody({
email: {
notEmpty: { errorMessage: res.t('missingEmail') },
},
});
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
const lowercaseEmail = req.body.email.toLowerCase();
if (isRestrictedEmailDomain(lowercaseEmail)) {
return res.respond(200, {
valid: false,
email: req.body.email,
error: res.t('cannotFulfillReq'),
});
}
const emailAlreadyInUse = await User.findOne({
'auth.local.email': lowercaseEmail,
}).select({ _id: 1 }).lean().exec();
if (emailAlreadyInUse) {
return res.respond(200, { valid: false, email: req.body.email, error: res.t('cannotFulfillReq') });
}
return res.respond(200, { valid: true, email: req.body.email });
},
};
export default api;
@@ -1,3 +1,4 @@
import pick from 'lodash/pick';
import {
NotAuthorized,
} from '../../libs/errors';
@@ -19,7 +20,7 @@ api.trackEvent = {
method: 'POST',
url: '/analytics/track/:eventName',
// we authenticate these requests to make sure they actually came from a real user
middlewares: [authWithHeaders({ optional: true })],
middlewares: [authWithHeaders()],
async handler (req, res) {
// As of now only web can track events using this route
if (req.headers['x-client'] !== 'habitica-web') {
@@ -30,11 +31,10 @@ api.trackEvent = {
const eventProperties = req.body;
res.analytics.track(req.params.eventName, {
uuid: user ? user._id : null,
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
headers: req.headers,
category: 'behaviour',
gaLabel: 'local',
// hitType: 'event', sent from the client
category: 'behavior',
...eventProperties,
});
+13 -113
View File
@@ -1,26 +1,19 @@
/* eslint-disable camelcase */
import nconf from 'nconf';
import Amplitude from 'amplitude';
import googleAnalytics from 'universal-analytics';
import useragent from 'useragent';
import {
each,
omit,
toArray,
} from 'lodash';
import common from '../../common';
import logger from './logger';
const AMPLITUDE_TOKEN = nconf.get('AMPLITUDE_KEY');
const GA_TOKEN = nconf.get('GA_ID');
const LOG_AMPLITUDE_EVENTS = nconf.get('LOG_AMPLITUDE_EVENTS') === 'true';
const GA_POSSIBLE_LABELS = ['gaLabel', 'itemKey'];
const GA_POSSIBLE_VALUES = ['gaValue', 'gemCost', 'goldCost'];
const AMPLITUDE_TOKEN = nconf.get('AMPLITUDE_KEY');
const AMPLITUDE_PROPERTIES_TO_SCRUB = [
'uuid', 'user', 'purchaseValue',
'gaLabel', 'gaValue', 'headers', 'registeredThrough',
'headers', 'registeredThrough',
];
const PLATFORM_MAP = Object.freeze({
@@ -32,8 +25,6 @@ const PLATFORM_MAP = Object.freeze({
let amplitude;
if (AMPLITUDE_TOKEN) amplitude = new Amplitude(AMPLITUDE_TOKEN);
const ga = googleAnalytics(GA_TOKEN);
const Content = common.content;
function _lookUpItemName (itemKey) {
@@ -192,64 +183,6 @@ function _sendDataToAmplitude (eventType, data, loggerOnly) {
.catch(err => logger.error(err, 'Error while sending data to Amplitude.'));
}
function _generateLabelForGoogleAnalytics (data) {
let label;
each(GA_POSSIBLE_LABELS, key => {
if (data[key]) {
label = data[key];
return false; // exit each early
}
return true;
});
return label;
}
function _generateValueForGoogleAnalytics (data) {
let value;
each(GA_POSSIBLE_VALUES, key => {
if (data[key]) {
value = data[key];
return false; // exit each early
}
return true;
});
return value;
}
function _sendDataToGoogle (eventType, data) {
const eventData = {
ec: data.gaCategory || data.category || 'behavior',
ea: eventType,
};
const label = _generateLabelForGoogleAnalytics(data);
if (label) {
eventData.el = label;
}
const value = _generateValueForGoogleAnalytics(data);
if (value) {
eventData.ev = value;
}
const promise = new Promise((resolve, reject) => {
ga.event(eventData, err => {
if (err) return reject(err);
return resolve();
});
});
return promise.catch(err => logger.error(err, 'Error while sending data to Google Analytics.'));
}
function _sendPurchaseDataToAmplitude (data) {
const amplitudeData = _formatDataForAmplitude(data);
@@ -271,45 +204,6 @@ function _sendPurchaseDataToAmplitude (data) {
.catch(err => logger.error(err, 'Error while sending data to Amplitude.'));
}
function _sendPurchaseDataToGoogle (data) {
const label = data.paymentMethod;
const type = data.purchaseType;
const price = data.purchaseValue;
const qty = data.quantity;
const { sku } = data;
const itemKey = data.itemPurchased;
let variation = type;
if (data.gift) variation += ' - Gift';
const eventData = {
ec: 'commerce',
ea: type,
el: label,
ev: price,
};
const eventPromise = new Promise((resolve, reject) => {
ga.event(eventData, err => {
if (err) return reject(err);
return resolve();
});
});
const transactionPromise = new Promise((resolve, reject) => {
ga.transaction(data.uuid, price)
.item(price, qty, sku, itemKey, variation)
.send(err => {
if (err) return reject(err);
return resolve();
});
});
return Promise
.all([eventPromise, transactionPromise])
.catch(err => logger.error(err, 'Error while sending data to Google Analytics.'));
}
function _setOnce (dataToSetOnce, uuid) {
return amplitude
.identify({
@@ -323,14 +217,17 @@ function _setOnce (dataToSetOnce, uuid) {
// There's no error handling directly here because it's handled inside _sendDataTo{Amplitude|Google}
async function track (eventType, data, loggerOnly = false) {
const { user } = data;
if (!user || !user.preferences || !user.preferences.analyticsConsent) {
return null;
}
const promises = [
_sendDataToAmplitude(eventType, data, loggerOnly),
_sendDataToGoogle(eventType, data),
];
if (data.user && data.user.registeredThrough) {
if (user.registeredThrough) {
promises.push(_setOnce({
registeredPlatform: data.user.registeredThrough,
}, data.uuid || data.user._id));
registeredPlatform: user.registeredThrough,
}, data.uuid || user._id));
}
return Promise.all(promises);
@@ -339,9 +236,12 @@ async function track (eventType, data, loggerOnly = false) {
// There's no error handling directly here because
// it's handled inside _sendPurchaseDataTo{Amplitude|Google}
async function trackPurchase (data) {
const { user } = data;
if (!user || !user.preferences || !user.preferences.analyticsConsent) {
return null;
}
return Promise.all([
_sendPurchaseDataToAmplitude(data),
_sendPurchaseDataToGoogle(data),
]);
}
+1
View File
@@ -48,5 +48,6 @@ export async function appleProfile (req) {
id: verifiedPayload.sub,
emails: [{ value: verifiedPayload.email }],
name: verifiedPayload.name || req.body.name || req.query.name,
idToken,
};
}
+2 -2
View File
@@ -1,4 +1,5 @@
import moment from 'moment';
import pick from 'lodash/pick';
import {
BadRequest,
NotAuthorized,
@@ -218,12 +219,11 @@ async function registerLocal (req, res, { isV3 = false }) {
if (!existingUser) {
res.analytics.track('register', {
user: pick(savedUser, ['preferences', 'registeredThrough']),
category: 'acquisition',
type: 'local',
gaLabel: 'local',
uuid: savedUser._id,
headers: req.headers,
user: savedUser,
});
}
+22 -10
View File
@@ -1,6 +1,7 @@
import pick from 'lodash/pick';
import passport from 'passport';
import common from '../../../common';
import { BadRequest, NotAuthorized } from '../errors';
import { BadRequest, NotAuthorized, NotFound } from '../errors';
import logger from '../logger';
import {
generateUsername,
@@ -10,6 +11,7 @@ import { appleProfile } from './apple';
import { model as User } from '../../models/user';
import { model as EmailUnsubscription } from '../../models/emailUnsubscription';
import { sendTxn as sendTxnEmail } from '../email';
import { apiError } from '../apiError';
function _passportProfile (network, accessToken) {
return new Promise((resolve, reject) => {
@@ -33,14 +35,14 @@ export async function socialEmailToLocal (user) {
{ 'auth.local.email': socialEmail },
{ _id: 1 },
).exec();
if (!conflictingUser) return socialEmail;
if (!conflictingUser) return socialEmail.toLowerCase();
}
return undefined;
}
export async function loginSocial (req, res) { // eslint-disable-line import/prefer-default-export
let existingUser = res.locals.user;
const { network } = req.body;
const { network, allowRegister = true, username = generateUsername() } = req.body;
const isSupportedNetwork = common.constants.SUPPORTED_SOCIAL_NETWORKS
.find(supportedNetwork => supportedNetwork.key === network);
@@ -83,6 +85,19 @@ export async function loginSocial (req, res) { // eslint-disable-line import/pre
existingUser = await User.findOne({ 'auth.local.email': email }).exec();
}
if (!allowRegister && !existingUser) {
if (network === 'apple') {
return res.status(200).send({
message: res.t('userNotFound'),
id_token: profile.idToken,
});
}
if (email) {
throw new NotFound(`${apiError('socialFlowUserNotFound')} ${email}`);
}
throw new NotFound(res.t('userNotFound'));
}
if (existingUser) {
existingUser.auth[network] = {
id: profile.id,
@@ -90,8 +105,6 @@ export async function loginSocial (req, res) { // eslint-disable-line import/pre
};
user = existingUser;
} else {
const generatedUsername = generateUsername();
user = {
auth: {
[network]: {
@@ -99,8 +112,8 @@ export async function loginSocial (req, res) { // eslint-disable-line import/pre
emails: profile.emails,
},
local: {
username: generatedUsername,
lowerCaseUsername: generatedUsername,
username,
lowerCaseUsername: username.toLowerCase(),
email,
},
},
@@ -145,12 +158,11 @@ export async function loginSocial (req, res) { // eslint-disable-line import/pre
if (!existingUser) {
res.analytics.track('register', {
user: pick(savedUser, ['preferences', 'registeredThrough']),
uuid: savedUser._id,
category: 'acquisition',
type: network,
gaLabel: network,
uuid: savedUser._id,
headers: req.headers,
user: savedUser,
});
}
+7
View File
@@ -4,6 +4,7 @@ import url from 'url';
import { NotAuthorized } from '../errors';
export const RESTRICTED_EMAIL_DOMAINS = Object.freeze(['habitica.com', 'habitrpg.com']);
const COMMUNITY_MANAGER_EMAIL = nconf.get('EMAILS_COMMUNITY_MANAGER_EMAIL');
const translator = shortid('0123456789abcdefghijklmnopqrstuvwxyz');
@@ -33,3 +34,9 @@ export function loginRes (user, req, res) {
};
return res.respond(200, responseData);
}
export function isRestrictedEmailDomain (email) {
if (!email) return false;
const domain = email.toLowerCase().split('@')[1];
return RESTRICTED_EMAIL_DOMAINS.includes(domain);
}
+1
View File
@@ -17,6 +17,7 @@ export async function bugReportLogic (user, userEmail, message, BROWSER_UA, ques
USER_CUSTOMER_ID: user.purchased.plan.customerId,
USER_CONSECUTIVE_MONTHS: user.purchased.plan.consecutive.count,
USER_HOURGLASSES: user.purchased.plan.consecutive.trinkets,
USER_ANALYTICS: user.preferences.analyticsConsent,
REPORT_MSG: message,
BROWSER_UA,
};
+2 -16
View File
@@ -1,5 +1,6 @@
import moment from 'moment';
import mongoose from 'mongoose';
import pick from 'lodash/pick';
import nconf from 'nconf';
import { model as User } from '../models/user';
import * as Tasks from '../models/task';
@@ -102,10 +103,8 @@ function processHabits (user, habits, now, daysMissed) {
function trackCronAnalytics (analytics, user, _progress, options) {
analytics.track('Cron', {
category: 'behavior',
gaLabel: 'Cron Count',
gaValue: user.flags.cronCount,
uuid: user._id,
user,
user: pick(user, ['preferences', 'registeredThrough']),
resting: user.preferences.sleep,
cronCount: user.flags.cronCount,
progressUp: Math.min(_progress.up, 900),
@@ -113,19 +112,6 @@ function trackCronAnalytics (analytics, user, _progress, options) {
headers: options.headers,
loginIncentives: user.loginIncentives,
});
if (
user.party && user.party.quest && !user.party.quest.RSVPNeeded
&& !user.party.quest.completed && user.party.quest.key && !user.preferences.sleep
) {
analytics.track('quest participation', {
category: 'behavior',
uuid: user._id,
user,
questName: user.party.quest.key,
headers: options.headers,
}, true);
}
}
function awardLoginIncentives (user) {
+13 -8
View File
@@ -1,4 +1,6 @@
import _ from 'lodash';
import find from 'lodash/find';
import includes from 'lodash/includes';
import pick from 'lodash/pick';
import { encrypt } from '../encryption';
import { sendNotification as sendPushNotification } from '../pushNotifications';
@@ -58,11 +60,11 @@ function sendInviteEmail (userToInvite, groupLabel, group, inviter) {
function inviteUserToGuild (userToInvite, group, inviter, publicGuild, res) {
const uuid = userToInvite._id;
if (_.includes(userToInvite.guilds, group._id)) {
if (includes(userToInvite.guilds, group._id)) {
throw new NotAuthorized(res.t('userAlreadyInGroup', { userId: uuid, username: userToInvite.profile.name }));
}
if (_.find(userToInvite.invitations.guilds, { id: group._id })) {
if (find(userToInvite.invitations.guilds, { id: group._id })) {
throw new NotAuthorized(res.t('userAlreadyInvitedToGroup', { userId: uuid, username: userToInvite.profile.name }));
}
@@ -82,7 +84,7 @@ async function inviteUserToParty (userToInvite, group, inviter, res) {
const uuid = userToInvite._id;
// Do not add to invitations.parties array if the user is already invited to that party
if (_.find(userToInvite.invitations.parties, { id: group._id })) {
if (find(userToInvite.invitations.parties, { id: group._id })) {
throw new NotAuthorized(res.t('userAlreadyPendingInvitation', { userId: uuid, username: userToInvite.profile.name }));
}
@@ -142,9 +144,10 @@ async function inviteByUUID (uuid, group, inviter, req, res) {
}
const analyticsObject = {
user: pick(inviter, ['preferences', 'registeredThrough']),
uuid: inviter._id,
hitType: 'event',
category: 'behavior',
uuid: inviter._id,
invitee: uuid,
groupId: group._id,
groupType: group.type,
@@ -189,7 +192,7 @@ async function inviteByEmail (invite, group, inviter, req, res) {
sentAt: Date.now(), // so we can let it expire
cancelledPlan,
});
const link = `/static/home?groupInvite=${encrypt(groupQueryString)}`;
const link = `/register?groupInvite=${encrypt(groupQueryString)}`;
const variables = [
{ name: 'LINK', content: link },
@@ -206,9 +209,10 @@ async function inviteByEmail (invite, group, inviter, req, res) {
if (!userIsUnsubscribed) sendTxnEmail(invite, `invite-friend${groupLabel}`, variables);
const analyticsObject = {
user: pick(inviter, ['preferences', 'registeredThrough']),
uuid: inviter._id,
hitType: 'event',
category: 'behavior',
uuid: inviter._id,
invitee: 'email',
groupId: group._id,
groupType: group.type,
@@ -243,9 +247,10 @@ async function inviteByUserName (username, group, inviter, req, res) {
}
const analyticsObject = {
user: pick(inviter, ['preferences', 'registeredThrough']),
uuid: inviter._id,
hitType: 'event',
category: 'behavior',
uuid: inviter._id,
invitee: userToInvite._id,
groupId: group._id,
groupType: group.type,
+2
View File
@@ -1,4 +1,5 @@
import find from 'lodash/find';
import pick from 'lodash/pick';
import { getAnalyticsServiceByEnvironment } from '../analyticsService';
import { getCurrentEventList } from '../worldState'; // eslint-disable-line import/no-cycle
import { // eslint-disable-line import/no-cycle
@@ -114,6 +115,7 @@ export async function buyGems (data) {
if (!data.gift) txnEmail(data.user, 'donation');
analytics.trackPurchase({
user: pick(data.user, ['preferences', 'registeredThrough']),
uuid: data.user._id,
itemPurchased: 'Gems',
sku: `${data.paymentMethod.toLowerCase()}-checkout`,
+2
View File
@@ -1,3 +1,4 @@
import pick from 'lodash/pick';
import moment from 'moment';
import {
BadRequest,
@@ -31,6 +32,7 @@ async function buyGryphatrice (data) {
data.user.purchased.txnCount += 1;
analytics.trackPurchase({
user: pick(data.user, ['preferences', 'registeredThrough']),
uuid: data.user._id,
itemPurchased: 'Gryphatrice',
sku: `${data.paymentMethod.toLowerCase()}-checkout`,
@@ -1,6 +1,9 @@
// TODO these files need to refactored.
import _ from 'lodash';
import defaults from 'lodash/defaults';
import each from 'lodash/each';
import find from 'lodash/find';
import pick from 'lodash/pick';
import moment from 'moment';
import { getAnalyticsServiceByEnvironment } from '../analyticsService';
@@ -30,7 +33,7 @@ const analytics = getAnalyticsServiceByEnvironment();
function _findMysteryItems (user, dateMoment) {
const pushedItems = [];
_.each(shared.content.gear.flat, item => {
each(shared.content.gear.flat, item => {
if (
item.klass === 'mystery'
&& shared.content.mystery[item.mystery]
@@ -200,7 +203,7 @@ async function prepareSubscriptionValues (data) {
owner: data.user._id,
});
_.defaults(plan, {
defaults(plan, {
gemsBought: 0,
dateCreated: today,
mysteryItems: [],
@@ -339,7 +342,7 @@ async function createSubscription (data) {
// Only send push notifications if sending to a user other than yourself
if (data.gift.member._id !== data.user._id) {
const currentEventList = getCurrentEventList();
const currentEvent = _.find(currentEventList, event => Boolean(event.promo));
const currentEvent = find(currentEventList, event => Boolean(event.promo));
if (currentEvent && currentEvent.promo === 'g1g1') {
const promoData = {
user: data.user,
@@ -462,9 +465,8 @@ async function cancelSubscription (data) {
analytics.track(cancelType, {
uuid: data.user._id,
user: pick(data.user, ['preferences', 'registeredThrough']),
groupId,
gaCategory: 'commerce',
gaLabel: data.paymentMethod,
paymentMethod: data.paymentMethod,
headers: data.headers,
});
+2
View File
@@ -3,6 +3,7 @@ import cloneDeep from 'lodash/cloneDeep';
import compact from 'lodash/compact';
import forEach from 'lodash/forEach';
import keys from 'lodash/keys';
import pick from 'lodash/pick';
import remove from 'lodash/remove';
import validator from 'validator';
import {
@@ -515,6 +516,7 @@ async function scoreTask (user, task, direction, req, res) {
role = 'member';
}
res.analytics.track('team task scored', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
+3
View File
@@ -117,6 +117,7 @@ export async function update (req, res, { isV3 = false }) {
user.invitations.party = {};
user.invitations.parties = [];
res.analytics.track('Starts Looking for Party', {
user: _.pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -200,6 +201,7 @@ export async function update (req, res, { isV3 = false }) {
if (key === 'party.seeking' && val === null) {
user.party.seeking = undefined;
res.analytics.track('Leaves Looking for Party', {
user: _.pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
@@ -290,6 +292,7 @@ export async function reset (req, res, { isV3 = false }) {
]);
res.analytics.track('account reset', {
user: _.pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
+6 -5
View File
@@ -4,6 +4,10 @@ import shared from '../../../common';
import { // eslint-disable-line import/no-cycle
getDefaultOwnedGear,
} from '../../libs/items/utils';
import {
RESTRICTED_EMAIL_DOMAINS,
isRestrictedEmailDomain,
} from '../../libs/auth/utils';
import { schema as PushDeviceSchema } from '../pushDevice';
import { schema as SubscriptionPlanSchema } from '../subscriptionPlan';
import { schema as TagSchema } from '../tag';
@@ -11,8 +15,6 @@ import { schema as UserNotificationSchema } from '../userNotification';
import { schema as WebhookSchema } from '../webhook';
import { model as Blocker } from '../blocker';
const RESTRICTED_EMAIL_DOMAINS = Object.freeze(['habitica.com', 'habitrpg.com']);
const BLOCKED_EMAILS = [];
Blocker.watchBlockers({
@@ -60,9 +62,7 @@ export const UserSchema = new Schema({
message: shared.i18n.t('invalidEmail'),
}, {
validator (email) {
const lowercaseEmail = email.toLowerCase();
return RESTRICTED_EMAIL_DOMAINS.every(domain => !lowercaseEmail.endsWith(`@${domain}`));
return !isRestrictedEmailDomain(email);
},
message: shared.i18n.t('invalidEmailDomain', { domains: RESTRICTED_EMAIL_DOMAINS.join(', ') }),
}, {
@@ -655,6 +655,7 @@ export const UserSchema = new Schema({
return isValidCategory;
},
},
analyticsConsent: Boolean,
},
profile: {
blurb: String,