diff --git a/habitica-images b/habitica-images index 8b0cae9e63..992d838120 160000 --- a/habitica-images +++ b/habitica-images @@ -1 +1 @@ -Subproject commit 8b0cae9e63a901a2a426924a082e27a64765ba36 +Subproject commit 992d8381200857d0962814f3435c4c7ce8fcbe30 diff --git a/package-lock.json b/package-lock.json index 4841fd7212..a998a40d36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "habitica", - "version": "5.36.6", + "version": "5.38.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "habitica", - "version": "5.36.6", + "version": "5.38.0", "hasInstallScript": true, "dependencies": { "@babel/core": "^7.22.10", diff --git a/package.json b/package.json index 5db16dbacb..22c7c191ed 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "habitica", "description": "A habit tracker app which treats your goals like a Role Playing Game.", - "version": "5.36.6", + "version": "5.38.0", "main": "./website/server/index.js", "dependencies": { "@babel/core": "^7.22.10", diff --git a/scripts/team-cron.js b/scripts/team-cron.js index 11f67fbf4f..6e84acaa5f 100644 --- a/scripts/team-cron.js +++ b/scripts/team-cron.js @@ -8,7 +8,17 @@ const TASK_VALUE_CHANGE_FACTOR = 0.9747; const MIN_TASK_VALUE = -47.27; async function updateTeamTasks (team) { + if (team.purchased.plan.dateTerminated) { + const dateTerminated = new Date(team.purchased.plan.dateTerminated); + if (dateTerminated < new Date()) { + team.purchased.plan.customerId = undefined; + team.markModified('purchased.plan'); + return team.save(); + } + } + const toSave = []; + let teamLeader = await User.findOne({ _id: team.leader }, 'preferences').exec(); if (!teamLeader) { // why would this happen? @@ -93,12 +103,7 @@ async function updateTeamTasks (team) { export default async function processTeamsCron () { const activeTeams = await Group.find({ 'purchased.plan.customerId': { $exists: true }, - $or: [ - { 'purchased.plan.dateTerminated': { $exists: false } }, - { 'purchased.plan.dateTerminated': null }, - { 'purchased.plan.dateTerminated': { $gt: new Date() } }, - ], - }).exec(); + }, { cron: 1, leader: 1, purchased: 1 }).exec(); const cronPromises = activeTeams.map(updateTeamTasks); return Promise.all(cronPromises); diff --git a/test/api/unit/middlewares/auth.test.js b/test/api/unit/middlewares/auth.test.js index f4a9324189..4ddd401abc 100644 --- a/test/api/unit/middlewares/auth.test.js +++ b/test/api/unit/middlewares/auth.test.js @@ -1,8 +1,11 @@ +import nconf from 'nconf'; +import requireAgain from 'require-again'; import { generateRes, generateReq, } from '../../../helpers/api-unit.helper'; -import { authWithHeaders as authWithHeadersFactory } from '../../../../website/server/middlewares/auth'; + +const authPath = '../../../../website/server/middlewares/auth'; describe('auth middleware', () => { let res; let req; let @@ -16,6 +19,7 @@ describe('auth middleware', () => { describe('auth with headers', () => { it('allows to specify a list of user field that we do not want to load', done => { + const authWithHeadersFactory = requireAgain(authPath).authWithHeaders; const authWithHeaders = authWithHeadersFactory({ userFieldsToExclude: ['items'], }); @@ -35,6 +39,7 @@ describe('auth middleware', () => { }); it('makes sure some fields are always included', done => { + const authWithHeadersFactory = requireAgain(authPath).authWithHeaders; const authWithHeaders = authWithHeadersFactory({ userFieldsToExclude: [ 'items', 'auth.timestamps', @@ -60,5 +65,57 @@ describe('auth middleware', () => { return done(); }); }); + + it('errors with InvalidCredentialsError and code when token is wrong', done => { + const authWithHeadersFactory = requireAgain(authPath).authWithHeaders; + const authWithHeaders = authWithHeadersFactory({ userFieldsToExclude: [] }); + + req.headers['x-api-user'] = user._id; + req.headers['x-api-key'] = 'totally-wrong-token'; + + authWithHeaders(req, res, err => { + expect(err).to.exist; + expect(err.name).to.equal('InvalidCredentialsError'); + expect(err.code).to.equal('invalid_credentials'); + expect(err.message).to.equal(res.t('invalidCredentials')); + return done(); + }); + }); + + describe('when ENFORCE_CLIENT_HEADER is true', () => { + let authFactory; + + beforeEach(() => { + sandbox.stub(nconf, 'get').withArgs('ENFORCE_CLIENT_HEADER').returns('true'); + authFactory = requireAgain(authPath).authWithHeaders; + }); + + it('errors with missingClientHeader when x-client header is not present', done => { + const authWithHeaders = authFactory({ userFieldsToExclude: [] }); + + req.headers['x-api-user'] = user._id; + req.headers['x-api-key'] = user; + authWithHeaders(req, res, err => { + expect(err).to.exist; + expect(err.name).to.equal('BadRequest'); + expect(err.message).to.equal(res.t('missingClientHeader')); + return done(); + }); + }); + + it('allows request to pass when x-client header is present', done => { + const authWithHeaders = authFactory({ userFieldsToExclude: [] }); + + req.headers['x-api-user'] = user._id; + req.headers['x-api-key'] = user.apiToken; + req.headers['x-client'] = 'habitica-web'; + + authWithHeaders(req, res, err => { + if (err) return done(err); + expect(res.locals.user).to.exist; + return done(); + }); + }); + }); }); }); diff --git a/test/api/v3/integration/user/auth/POST-auth_reset-password-set-new-one.js b/test/api/v3/integration/user/auth/POST-auth_reset-password-set-new-one.js index 472872988d..25ae9e7c5d 100644 --- a/test/api/v3/integration/user/auth/POST-auth_reset-password-set-new-one.js +++ b/test/api/v3/integration/user/auth/POST-auth_reset-password-set-new-one.js @@ -238,6 +238,28 @@ describe('POST /user/auth/reset-password-set-new-one', () => { expect(isPassValid).to.equal(true); }); + it('changes the apiToken on password reset', async () => { + const user = await generateUser(); + const previousToken = user.apiToken; + + const code = encrypt(JSON.stringify({ + userId: user._id, + expiresAt: moment().add({ days: 1 }), + })); + await user.updateOne({ + 'auth.local.passwordResetCode': code, + }); + + await api.post(`${endpoint}`, { + newPassword: 'my new password', + confirmPassword: 'my new password', + code, + }); + + await user.sync(); + expect(user.apiToken).to.not.eql(previousToken); + }); + it('renders the success page and convert the password from sha1 to bcrypt', async () => { const user = await generateUser(); diff --git a/test/api/v3/integration/user/auth/PUT-user_update_password.test.js b/test/api/v3/integration/user/auth/PUT-user_update_password.test.js index 916e377a7f..6b764d7e1b 100644 --- a/test/api/v3/integration/user/auth/PUT-user_update_password.test.js +++ b/test/api/v3/integration/user/auth/PUT-user_update_password.test.js @@ -27,11 +27,30 @@ describe('PUT /user/auth/update-password', async () => { newPassword, confirmPassword: newPassword, }); - expect(response).to.eql({}); + + expect(response).to.exist; + expect(response.apiToken).to.exist; + await user.sync(); expect(user.auth.local.hashed_password).to.not.eql(previousHashedPassword); }); + it('should change the apiToken on password change', async () => { + const previousToken = user.apiToken; + const response = await user.put(ENDPOINT, { + password, + newPassword, + confirmPassword: newPassword, + }); + + const newToken = response.apiToken; + expect(newToken).to.exist; + + await user.sync(); + expect(user.apiToken).to.eql(newToken); + expect(user.apiToken).to.not.eql(previousToken); + }); + it('returns an error when confirmPassword does not match newPassword', async () => { await expect(user.put(ENDPOINT, { password, diff --git a/website/client/src/app.vue b/website/client/src/app.vue index 2b1215b6df..d8f561c3c3 100644 --- a/website/client/src/app.vue +++ b/website/client/src/app.vue @@ -111,6 +111,7 @@ import axios from 'axios'; import * as Analytics from '@/libs/analytics'; import { mapState } from '@/libs/store'; import snackbars from '@/components/snackbars/notifications'; +import { LOCALSTORAGE_AUTH_KEY } from '@/libs/auth'; const COMMUNITY_MANAGER_EMAIL = import.meta.env.EMAILS_COMMUNITY_MANAGER_EMAIL; @@ -222,11 +223,10 @@ export default { const errorData = error.response.data; const errorMessage = errorData.message || errorData; + const errorCode = errorData.error; - // Check for conditions to reset the user auth - // TODO use a specific error like NotificationNotFound instead of checking for the string - const invalidUserMessage = [this.$t('invalidCredentials'), 'Missing authentication headers.']; - if (invalidUserMessage.indexOf(errorMessage) !== -1) { + // If 'invalid_credentials' signaled, force logout + if (error.response.status === 401 && errorCode === 'invalid_credentials') { this.$store.dispatch('auth:logout', { redirectToLogin: true }); return null; } @@ -269,6 +269,17 @@ export default { const loadingScreen = document.getElementById('loading-screen'); if (loadingScreen) document.body.removeChild(loadingScreen); + // Check if we need to show password change success message + if (sessionStorage.getItem('passwordChangeSuccess') === 'true') { + sessionStorage.removeItem('passwordChangeSuccess'); + this.$store.dispatch('snackbars:add', { + title: 'Habitica', + text: this.$t('passwordSuccess'), + type: 'success', + timeout: true, + }); + } + this.$router.onReady(() => { if (this.isStaticPage || !this.isUserLoggedIn) { this.hideLoadingScreen(); @@ -280,7 +291,7 @@ export default { this.loading = false; }, checkForBannedUser (error) { - const AUTH_SETTINGS = localStorage.getItem('habit-mobile-settings'); + const AUTH_SETTINGS = localStorage.getItem(LOCALSTORAGE_AUTH_KEY); const parseSettings = JSON.parse(AUTH_SETTINGS); const errorMessage = error.response.data.message; diff --git a/website/client/src/assets/scss/shops.scss b/website/client/src/assets/scss/shops.scss index afcb8353a4..7a8596cadd 100644 --- a/website/client/src/assets/scss/shops.scss +++ b/website/client/src/assets/scss/shops.scss @@ -46,13 +46,11 @@ .background { background-repeat: repeat-x; - + height:216px; width: 100%; position: absolute; - top: 0; left: 0; - display: flex; flex-direction: column; justify-content: center; @@ -67,6 +65,13 @@ flex-direction: column; } + .shop-message { + position: relative; + height: 76px; + margin: 71px auto; + width: 240px; + } + .npc { position: absolute; left: 0; diff --git a/website/client/src/components/achievements/questInvitation.vue b/website/client/src/components/achievements/questInvitation.vue index 0caa878667..cca4c60e30 100644 --- a/website/client/src/components/achievements/questInvitation.vue +++ b/website/client/src/components/achievements/questInvitation.vue @@ -97,9 +97,9 @@ import { mapState } from '@/libs/store'; import Sprite from '@/components/ui/sprite'; export default { - components: [ + components: { Sprite, - ], + }, data () { return { maxHealth, diff --git a/website/client/src/components/auth/registerLoginReset.vue b/website/client/src/components/auth/registerLoginReset.vue index 4eba28267e..e49713afe5 100644 --- a/website/client/src/components/auth/registerLoginReset.vue +++ b/website/client/src/components/auth/registerLoginReset.vue @@ -220,7 +220,6 @@ v-if="forgotPassword" id="forgot-form" @submit.prevent="handleSubmit" - @keyup.enter="handleSubmit" >