From 46ed1813c6168487a68a894e6a9ae71f8524bf7c Mon Sep 17 00:00:00 2001 From: Sabe Jones Date: Tue, 30 May 2017 11:54:42 -0500 Subject: [PATCH] Optional feedback on account deletion (#8750) * Fixed rebase. * Removed commented out mail sending to pass linting. Styles from settings.styl still not propagating to app.css * fix(feedback): address PR comments * fix(style): linting errors --- .../v3/integration/user/DELETE-user.test.js | 56 ++++++++++++++++++- website/client-old/css/index.styl | 1 + website/client-old/css/settings.styl | 2 + .../client-old/js/controllers/settingsCtrl.js | 4 +- website/common/locales/en/settings.json | 1 + website/server/controllers/api-v3/user.js | 30 ++++++++-- website/views/shared/modals/settings.jade | 9 ++- 7 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 website/client-old/css/settings.styl diff --git a/test/api/v3/integration/user/DELETE-user.test.js b/test/api/v3/integration/user/DELETE-user.test.js index de559c430d..140577af95 100644 --- a/test/api/v3/integration/user/DELETE-user.test.js +++ b/test/api/v3/integration/user/DELETE-user.test.js @@ -16,6 +16,7 @@ import { sha1MakeSalt, sha1Encrypt as sha1EncryptPassword, } from '../../../../../website/server/libs/password'; +import * as email from '../../../../../website/server/libs/email'; describe('DELETE /user', () => { let user; @@ -25,7 +26,7 @@ describe('DELETE /user', () => { user = await generateUser({balance: 10}); }); - it('returns an errors if password is wrong', async () => { + it('returns an error if password is wrong', async () => { await expect(user.del('/user', { password: 'wrong-password', })).to.eventually.be.rejected.and.eql({ @@ -35,6 +36,33 @@ describe('DELETE /user', () => { }); }); + it('returns an error if password is not supplied', async () => { + await expect(user.del('/user', { + password: '', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('missingPassword'), + }); + }); + + it('returns an error if excessive feedback is supplied', async () => { + let feedbackText = 'spam feedback '; + let feedback = feedbackText; + while (feedback.length < 10000) { + feedback = feedback + feedbackText; + } + + await expect(user.del('/user', { + password, + feedback, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Account deletion feedback is limited to 10,000 characters. For lengthy feedback, email admin@habitica.com.', + }); + }); + it('returns an error if user has active subscription', async () => { let userWithSubscription = await generateUser({'purchased.plan.customerId': 'fake-customer-id'}); @@ -96,6 +124,32 @@ describe('DELETE /user', () => { await expect(checkExistence('users', user._id)).to.eventually.eql(false); }); + it('sends feedback to the admin email', async () => { + sandbox.spy(email, 'sendTxn'); + + let feedback = 'Reasons for Deletion'; + await user.del('/user', { + password, + feedback, + }); + + expect(email.sendTxn).to.be.calledOnce; + + sandbox.restore(); + }); + + it('does not send email if no feedback is supplied', async () => { + sandbox.spy(email, 'sendTxn'); + + await user.del('/user', { + password, + }); + + expect(email.sendTxn).to.not.be.called; + + sandbox.restore(); + }); + it('deletes the user with a legacy sha1 password', async () => { let textPassword = 'mySecretPassword'; let salt = sha1MakeSalt(); diff --git a/website/client-old/css/index.styl b/website/client-old/css/index.styl index 6c5159986e..180b6aacbd 100644 --- a/website/client-old/css/index.styl +++ b/website/client-old/css/index.styl @@ -32,6 +32,7 @@ @import "./options.styl" @import "./no-script.styl" @import "./loading-screen.styl" +@import "./settings.styl" html,body,p,h1,ul,li,table,tr,th,td margin: 0 diff --git a/website/client-old/css/settings.styl b/website/client-old/css/settings.styl new file mode 100644 index 0000000000..e09b265277 --- /dev/null +++ b/website/client-old/css/settings.styl @@ -0,0 +1,2 @@ +#feedback label + font-weight: normal \ No newline at end of file diff --git a/website/client-old/js/controllers/settingsCtrl.js b/website/client-old/js/controllers/settingsCtrl.js index bfd47ac7c0..852b556a9e 100644 --- a/website/client-old/js/controllers/settingsCtrl.js +++ b/website/client-old/js/controllers/settingsCtrl.js @@ -187,11 +187,11 @@ habitrpg.controller('SettingsCtrl', $rootScope.$state.go('tasks'); } - $scope['delete'] = function(password) { + $scope.delete = function(password, feedback) { $http({ url: ApiUrl.get() + '/api/v3/user', method: 'DELETE', - data: {password: password}, + data: {password: password, feedback: feedback}, }) .then(function(res, code) { localStorage.clear(); diff --git a/website/common/locales/en/settings.json b/website/common/locales/en/settings.json index 0ea0066772..3fba9d8a70 100644 --- a/website/common/locales/en/settings.json +++ b/website/common/locales/en/settings.json @@ -34,6 +34,7 @@ "resetAccPop": "Start over, removing all levels, gold, gear, history, and tasks.", "deleteAccount": "Delete Account", "deleteAccPop": "Cancel and remove your Habitica account.", + "feedback": "If you'd like to give us feedback, please enter it below - we'd love to know what you liked or didn't like about Habitica! It will be anonymous unless you choose to enter your contact details. Don't speak English well? No problem! Use the language you prefer.", "qrCode": "QR Code", "dataExport": "Data Export", "saveData": "Here are a few options for saving your data.", diff --git a/website/server/controllers/api-v3/user.js b/website/server/controllers/api-v3/user.js index e475e95e02..45a0d6b269 100644 --- a/website/server/controllers/api-v3/user.js +++ b/website/server/controllers/api-v3/user.js @@ -14,6 +14,13 @@ import { model as User } from '../../models/user'; import Bluebird from 'bluebird'; import _ from 'lodash'; import * as passwordUtils from '../../libs/password'; +import { + getUserInfo, + sendTxn as txnEmail, +} from '../../libs/email'; +import nconf from 'nconf'; + +const TECH_ASSISTANCE_EMAIL = nconf.get('EMAILS:TECH_ASSISTANCE_EMAIL'); /** * @apiDefine UserNotFound @@ -252,6 +259,7 @@ api.updateUser = { * @apiGroup User * * @apiParam {String} password The user's password if the account uses local authentication + * @apiParam {String} feedback User's optional feedback explaining reasons for deletion * * @apiSuccess {Object} data An empty Object * @@ -262,6 +270,7 @@ api.updateUser = { * } * * @apiError {BadRequest} MissingPassword The password was not included in the request + * @apiError {BadRequest} LengthExceeded The feedback provided is longer than 10K * @apiError {BadRequest} NotAuthorized There is no account that uses those credentials. * * @apiErrorExample {json} @@ -286,16 +295,15 @@ api.deleteUser = { let user = res.locals.user; let plan = user.purchased.plan; - req.checkBody({ - password: { - notEmpty: {errorMessage: res.t('missingPassword')}, - }, - }); + let password = req.body.password; + if (!password) throw new BadRequest(res.t('missingPassword')); + + let feedback = req.body.feedback; + if (feedback && feedback.length > 10000) throw new BadRequest(`Account deletion feedback is limited to 10,000 characters. For lengthy feedback, email ${TECH_ASSISTANCE_EMAIL}.`); let validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let password = req.body.password; let isValidPassword = await passwordUtils.compare(user, password); if (!isValidPassword) throw new NotAuthorized(res.t('wrongPassword')); @@ -320,6 +328,16 @@ api.deleteUser = { await user.remove(); + if (feedback) { + txnEmail(TECH_ASSISTANCE_EMAIL, 'admin-feedback', [ + {name: 'PROFILE_NAME', content: user.profile.name}, + {name: 'UUID', content: user._id}, + {name: 'EMAIL', content: getUserInfo(user, ['email']).email}, + {name: 'FEEDBACK_SOURCE', content: 'from deletion form'}, + {name: 'FEEDBACK', content: feedback}, + ]); + } + res.respond(200, {}); }, }; diff --git a/website/views/shared/modals/settings.jade b/website/views/shared/modals/settings.jade index 5f4017b730..d100c8a36e 100644 --- a/website/views/shared/modals/settings.jade +++ b/website/views/shared/modals/settings.jade @@ -61,11 +61,16 @@ script(type='text/ng-template', id='modals/delete.html') .modal-header h4=env.t('deleteAccount') .modal-body - p!=env.t('deleteLocalAccountText') + strong=env.t('deleteLocalAccountText') br .row .col-md-6 input.form-control(type='password', ng-model='_deleteAccount') + br + .row + #feedback.col-xs-12.form-group + label(for='feedbackTextArea')=env.t('feedback') + textarea#feedbackTextArea.form-control(ng-model='feedback') .modal-footer button.btn.btn-default(ng-click='$close()')=env.t('neverMind') - button.btn.btn-danger(ng-disabled='!_deleteAccount', ng-click='$close(); delete(_deleteAccount)')=env.t('deleteDo') + button.btn.btn-danger(ng-disabled='!_deleteAccount', ng-click='$close(); delete(_deleteAccount, feedback)')=env.t('deleteDo')