mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-17 14:47:53 +01:00
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
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
|||||||
sha1MakeSalt,
|
sha1MakeSalt,
|
||||||
sha1Encrypt as sha1EncryptPassword,
|
sha1Encrypt as sha1EncryptPassword,
|
||||||
} from '../../../../../website/server/libs/password';
|
} from '../../../../../website/server/libs/password';
|
||||||
|
import * as email from '../../../../../website/server/libs/email';
|
||||||
|
|
||||||
describe('DELETE /user', () => {
|
describe('DELETE /user', () => {
|
||||||
let user;
|
let user;
|
||||||
@@ -25,7 +26,7 @@ describe('DELETE /user', () => {
|
|||||||
user = await generateUser({balance: 10});
|
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', {
|
await expect(user.del('/user', {
|
||||||
password: 'wrong-password',
|
password: 'wrong-password',
|
||||||
})).to.eventually.be.rejected.and.eql({
|
})).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 () => {
|
it('returns an error if user has active subscription', async () => {
|
||||||
let userWithSubscription = await generateUser({'purchased.plan.customerId': 'fake-customer-id'});
|
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);
|
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 () => {
|
it('deletes the user with a legacy sha1 password', async () => {
|
||||||
let textPassword = 'mySecretPassword';
|
let textPassword = 'mySecretPassword';
|
||||||
let salt = sha1MakeSalt();
|
let salt = sha1MakeSalt();
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
@import "./options.styl"
|
@import "./options.styl"
|
||||||
@import "./no-script.styl"
|
@import "./no-script.styl"
|
||||||
@import "./loading-screen.styl"
|
@import "./loading-screen.styl"
|
||||||
|
@import "./settings.styl"
|
||||||
|
|
||||||
html,body,p,h1,ul,li,table,tr,th,td
|
html,body,p,h1,ul,li,table,tr,th,td
|
||||||
margin: 0
|
margin: 0
|
||||||
|
|||||||
2
website/client-old/css/settings.styl
Normal file
2
website/client-old/css/settings.styl
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#feedback label
|
||||||
|
font-weight: normal
|
||||||
@@ -187,11 +187,11 @@ habitrpg.controller('SettingsCtrl',
|
|||||||
$rootScope.$state.go('tasks');
|
$rootScope.$state.go('tasks');
|
||||||
}
|
}
|
||||||
|
|
||||||
$scope['delete'] = function(password) {
|
$scope.delete = function(password, feedback) {
|
||||||
$http({
|
$http({
|
||||||
url: ApiUrl.get() + '/api/v3/user',
|
url: ApiUrl.get() + '/api/v3/user',
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
data: {password: password},
|
data: {password: password, feedback: feedback},
|
||||||
})
|
})
|
||||||
.then(function(res, code) {
|
.then(function(res, code) {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"resetAccPop": "Start over, removing all levels, gold, gear, history, and tasks.",
|
"resetAccPop": "Start over, removing all levels, gold, gear, history, and tasks.",
|
||||||
"deleteAccount": "Delete Account",
|
"deleteAccount": "Delete Account",
|
||||||
"deleteAccPop": "Cancel and remove your Habitica 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",
|
"qrCode": "QR Code",
|
||||||
"dataExport": "Data Export",
|
"dataExport": "Data Export",
|
||||||
"saveData": "Here are a few options for saving your data.",
|
"saveData": "Here are a few options for saving your data.",
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ import { model as User } from '../../models/user';
|
|||||||
import Bluebird from 'bluebird';
|
import Bluebird from 'bluebird';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import * as passwordUtils from '../../libs/password';
|
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
|
* @apiDefine UserNotFound
|
||||||
@@ -252,6 +259,7 @@ api.updateUser = {
|
|||||||
* @apiGroup User
|
* @apiGroup User
|
||||||
*
|
*
|
||||||
* @apiParam {String} password The user's password if the account uses local authentication
|
* @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
|
* @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} 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.
|
* @apiError {BadRequest} NotAuthorized There is no account that uses those credentials.
|
||||||
*
|
*
|
||||||
* @apiErrorExample {json}
|
* @apiErrorExample {json}
|
||||||
@@ -286,16 +295,15 @@ api.deleteUser = {
|
|||||||
let user = res.locals.user;
|
let user = res.locals.user;
|
||||||
let plan = user.purchased.plan;
|
let plan = user.purchased.plan;
|
||||||
|
|
||||||
req.checkBody({
|
let password = req.body.password;
|
||||||
password: {
|
if (!password) throw new BadRequest(res.t('missingPassword'));
|
||||||
notEmpty: {errorMessage: 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();
|
let validationErrors = req.validationErrors();
|
||||||
if (validationErrors) throw validationErrors;
|
if (validationErrors) throw validationErrors;
|
||||||
|
|
||||||
let password = req.body.password;
|
|
||||||
let isValidPassword = await passwordUtils.compare(user, password);
|
let isValidPassword = await passwordUtils.compare(user, password);
|
||||||
if (!isValidPassword) throw new NotAuthorized(res.t('wrongPassword'));
|
if (!isValidPassword) throw new NotAuthorized(res.t('wrongPassword'));
|
||||||
|
|
||||||
@@ -320,6 +328,16 @@ api.deleteUser = {
|
|||||||
|
|
||||||
await user.remove();
|
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, {});
|
res.respond(200, {});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,11 +61,16 @@ script(type='text/ng-template', id='modals/delete.html')
|
|||||||
.modal-header
|
.modal-header
|
||||||
h4=env.t('deleteAccount')
|
h4=env.t('deleteAccount')
|
||||||
.modal-body
|
.modal-body
|
||||||
p!=env.t('deleteLocalAccountText')
|
strong=env.t('deleteLocalAccountText')
|
||||||
br
|
br
|
||||||
.row
|
.row
|
||||||
.col-md-6
|
.col-md-6
|
||||||
input.form-control(type='password', ng-model='_deleteAccount')
|
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
|
.modal-footer
|
||||||
button.btn.btn-default(ng-click='$close()')=env.t('neverMind')
|
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')
|
||||||
|
|||||||
Reference in New Issue
Block a user