+
+
+
+
+
{{errors.password}}
-
-
+ |
{{message.task}}
{{message.content}}
diff --git a/admin/tests/unit/controllers/edit-user.js b/admin/tests/unit/controllers/edit-user.js
index bcf249ff6f4..5b7b53c7078 100644
--- a/admin/tests/unit/controllers/edit-user.js
+++ b/admin/tests/unit/controllers/edit-user.js
@@ -149,33 +149,38 @@ describe('EditUserCtrl controller', () => {
describe('initialisation', () => {
it('edits the given user', done => {
mockEditAUser(userToEdit);
- setTimeout(() => {
- chai.expect(scope.enabledLocales.length).to.equal(2);
- chai.expect(scope.enabledLocales[0].code).to.equal('en');
- chai.expect(scope.enabledLocales[1].code).to.equal('fr');
- chai.expect(translationsDbQuery.callCount).to.equal(1);
- chai
- .expect(translationsDbQuery.args[0][0])
- .to.equal('medic-client/doc_by_type');
- chai
- .expect(translationsDbQuery.args[0][1].key[0])
- .to.equal('translations');
- chai.expect(translationsDbQuery.args[0][1].key[1]).to.equal(true);
- chai.expect(scope.editUserModel).to.deep.equal({
- id: userToEdit._id,
- username: userToEdit.name,
- fullname: userToEdit.fullname,
- email: userToEdit.email,
- phone: userToEdit.phone,
- facilitySelect: userToEdit.facility_id,
- place: userToEdit.facility_id,
- role: userToEdit.roles[0],
- language: { code: userToEdit.language },
- contactSelect: userToEdit.contact_id,
- contact: userToEdit.contact_id,
+ try {
+ setTimeout(() => {
+ chai.expect(scope.enabledLocales.length).to.equal(2);
+ chai.expect(scope.enabledLocales[0].code).to.equal('en');
+ chai.expect(scope.enabledLocales[1].code).to.equal('fr');
+ chai.expect(translationsDbQuery.callCount).to.equal(1);
+ chai
+ .expect(translationsDbQuery.args[0][0])
+ .to.equal('medic-client/doc_by_type');
+ chai
+ .expect(translationsDbQuery.args[0][1].key[0])
+ .to.equal('translations');
+ chai.expect(translationsDbQuery.args[0][1].key[1]).to.equal(true);
+ chai.expect(scope.editUserModel).to.deep.equal({
+ id: userToEdit._id,
+ username: userToEdit.name,
+ fullname: userToEdit.fullname,
+ email: userToEdit.email,
+ phone: userToEdit.phone,
+ facilitySelect: userToEdit.facility_id,
+ place: userToEdit.facility_id,
+ role: userToEdit.roles[0],
+ language: { code: userToEdit.language },
+ contactSelect: userToEdit.contact_id,
+ contact: userToEdit.contact_id,
+ tokenLoginEnabled: undefined,
+ });
+ done();
});
- done();
- });
+ } catch(err) {
+ done(err);
+ }
});
});
@@ -183,71 +188,86 @@ describe('EditUserCtrl controller', () => {
it('username must be present', done => {
mockEditAUser(userToEdit);
Translate.fieldIsRequired.withArgs('User Name').returns(Promise.resolve('User Name field must be filled'));
- setTimeout(() => {
- scope.editUserModel.id = null;
- scope.editUserModel.username = '';
-
- scope.editUserModel.password = '1QrAs$$3%%kkkk445234234234';
- scope.editUserModel.passwordConfirm = scope.editUserModel.password;
- scope.editUser();
+ try {
setTimeout(() => {
- chai.expect(scope.errors.username).to.equal('User Name field must be filled');
- done();
+ scope.editUserModel.id = null;
+ scope.editUserModel.username = '';
+
+ scope.editUserModel.password = '1QrAs$$3%%kkkk445234234234';
+ scope.editUserModel.passwordConfirm = scope.editUserModel.password;
+ scope.editUser();
+ setTimeout(() => {
+ chai.expect(scope.errors.username).to.equal('User Name field must be filled');
+ done();
+ });
});
- });
+ } catch(err) {
+ done(err);
+ }
});
it('password must be filled when creating new user', done => {
mockCreateNewUser();
- setTimeout(() => {
- scope.editUserModel.username = 'newuser';
- scope.editUserModel.role = 'data-entry';
- Translate.fieldIsRequired.withArgs('Password').returns(Promise.resolve('Password field is a required field'));
+ try {
setTimeout(() => {
- scope.editUser();
+ scope.editUserModel.username = 'newuser';
+ scope.editUserModel.role = 'data-entry';
+ Translate.fieldIsRequired.withArgs('Password').returns(Promise.resolve('Password field is a required field'));
setTimeout(() => {
- chai
- .expect(scope.errors.password)
- .to.equal('Password field is a required field');
- done();
+ scope.editUser();
+ setTimeout(() => {
+ chai
+ .expect(scope.errors.password)
+ .to.equal('Password field is a required field');
+ done();
+ });
});
});
- });
+ } catch(err) {
+ done(err);
+ }
});
it(`password doesn't need to be filled when editing user`, done => {
mockEditAUser(userToEdit);
translate.returns(Promise.resolve('something'));
Translate.fieldIsRequired = key => Promise.resolve(key);
- setTimeout(() => {
- chai.expect(scope.editUserModel).not.to.have.property('password');
- scope.editUser();
+ try {
setTimeout(() => {
- chai.expect(scope.errors).not.to.have.property('password');
- done();
+ chai.expect(scope.editUserModel).not.to.have.property('password');
+ scope.editUser();
+ setTimeout(() => {
+ chai.expect(scope.errors).not.to.have.property('password');
+ done();
+ });
});
- });
+ } catch(err) {
+ done(err);
+ }
});
it('error if password and confirm do not match when creating new user', done => {
mockEditCurrentUser();
-
- setTimeout(() => {
- scope.editUserModel.username = 'newuser';
- scope.editUserModel.role = 'data-entry';
- translate.withArgs('Passwords must match').returns(
- Promise.resolve('wrong')
- );
+ try {
setTimeout(() => {
- const password = '1QrAs$$3%%kkkk445234234234';
- scope.editUserModel.password = password;
- scope.editUser();
+ scope.editUserModel.username = 'newuser';
+ scope.editUserModel.role = 'data-entry';
+ translate.withArgs('Passwords must match').returns(
+ Promise.resolve('wrong')
+ );
setTimeout(() => {
- chai.expect(scope.errors.password).to.equal('wrong');
- done();
+ const password = '1QrAs$$3%%kkkk445234234234';
+ scope.editUserModel.password = password;
+ scope.editUser();
+ setTimeout(() => {
+ chai.expect(scope.errors.password).to.equal('wrong');
+ done();
+ });
});
});
- });
+ } catch(err) {
+ done(err);
+ }
});
it('should not change password when none is supplied', done => {
@@ -256,127 +276,145 @@ describe('EditUserCtrl controller', () => {
mockContact(userToEdit.contact_id);
mockFacility(userToEdit.facility_id);
mockContactGet(userToEdit.contact_id);
+ try {
+ setTimeout(() => {
+ scope.editUserModel.currentPassword = 'blah';
+ scope.editUserModel.password = '';
+ scope.editUserModel.passwordConfirm = '';
- setTimeout(() => {
- scope.editUserModel.currentPassword = 'blah';
- scope.editUserModel.password = '';
- scope.editUserModel.passwordConfirm = '';
-
- // when
- scope.editUser();
+ // when
+ scope.editUser();
- // then
- setTimeout(() => {
- chai.expect(UpdateUser.called).to.equal(false);
+ // then
+ setTimeout(() => {
+ chai.expect(UpdateUser.called).to.equal(false);
- done();
+ done();
+ });
});
- });
+ } catch(err) {
+ done(err);
+ }
});
it('must have associated place if user type is offline user', done => {
mockEditAUser(userToEdit);
+ try {
+ setTimeout(() => {
+ scope.editUserModel.role = 'district-manager';
+ mockFacility(null);
+ mockContact(userToEdit.contact_id);
+ Translate.fieldIsRequired.withArgs('Facility').returns(Promise.resolve('Facility field is a required field'));
- setTimeout(() => {
- scope.editUserModel.role = 'district-manager';
- mockFacility(null);
- mockContact(userToEdit.contact_id);
- Translate.fieldIsRequired.withArgs('Facility').returns(Promise.resolve('Facility field is a required field'));
-
- // when
- scope.editUser();
+ // when
+ scope.editUser();
- // expect
- setTimeout(() => {
- chai.expect(scope.errors.place).to.equal('Facility field is a required field');
- done();
+ // expect
+ setTimeout(() => {
+ chai.expect(scope.errors.place).to.equal('Facility field is a required field');
+ done();
+ });
});
- });
+ } catch(err) {
+ done(err);
+ }
});
it('must have associated contact if user type is offline user', done => {
mockEditAUser(userToEdit);
+ try {
+ setTimeout(() => {
+ scope.editUserModel.role = 'district-manager';
+ mockFacility(userToEdit.facility_id);
+ mockContact(null);
+ Translate.fieldIsRequired.withArgs('associated.contact')
+ .returns(Promise.resolve('An associated contact is required'));
- setTimeout(() => {
- scope.editUserModel.role = 'district-manager';
- mockFacility(userToEdit.facility_id);
- mockContact(null);
- Translate.fieldIsRequired.withArgs('associated.contact')
- .returns(Promise.resolve('An associated contact is required'));
-
- // when
- scope.editUser();
+ // when
+ scope.editUser();
- // expect
- setTimeout(() => {
- chai.expect(scope.errors.contact).to.equal('An associated contact is required');
- done();
+ // expect
+ setTimeout(() => {
+ chai.expect(scope.errors.contact).to.equal('An associated contact is required');
+ done();
+ });
});
- });
+ } catch(err) {
+ done(err);
+ }
});
it('must have associated place and contact if user type is offline user', done => {
mockEditAUser(userToEdit);
-
- setTimeout(() => {
- scope.editUserModel.role = 'district-manager';
- mockFacility(null);
- mockContact(null);
- Translate.fieldIsRequired.withArgs('associated.contact')
- .returns(Promise.resolve('An associated contact is required'));
- Translate.fieldIsRequired.withArgs('Facility').returns(Promise.resolve('Facility field is required'));
-
- // when
- scope.editUser();
-
- // expect
+ try {
setTimeout(() => {
- chai.expect(scope.errors.place).to.equal('Facility field is required');
- chai.expect(scope.errors.contact).to.equal('An associated contact is required');
- done();
+ scope.editUserModel.role = 'district-manager';
+ mockFacility(null);
+ mockContact(null);
+ Translate.fieldIsRequired.withArgs('associated.contact')
+ .returns(Promise.resolve('An associated contact is required'));
+ Translate.fieldIsRequired.withArgs('Facility').returns(Promise.resolve('Facility field is required'));
+
+ // when
+ scope.editUser();
+
+ // expect
+ setTimeout(() => {
+ chai.expect(scope.errors.place).to.equal('Facility field is required');
+ chai.expect(scope.errors.contact).to.equal('An associated contact is required');
+ done();
+ });
});
- });
+ } catch(err) {
+ done(err);
+ }
});
it(`doesn't need associated place and contact if user type is not restricted user`, done => {
mockEditAUser(userToEdit);
+ try {
+ setTimeout(() => {
+ scope.editUserModel.role = 'some-other-type';
+ mockFacility(null);
+ mockContact(null);
- setTimeout(() => {
- scope.editUserModel.role = 'some-other-type';
- mockFacility(null);
- mockContact(null);
-
- // when
- scope.editUser();
+ // when
+ scope.editUser();
- // expect
- chai.expect(scope.errors).not.to.have.property('facility_id');
- chai.expect(scope.errors).not.to.have.property('contact_id');
- done();
- });
+ // expect
+ chai.expect(scope.errors).not.to.have.property('facility_id');
+ chai.expect(scope.errors).not.to.have.property('contact_id');
+ done();
+ });
+ } catch(err) {
+ done(err);
+ }
});
it('associated place must be parent of contact', done => {
mockEditAUser(userToEdit);
-
- setTimeout(() => {
- scope.editUserModel.type = 'district-manager';
- mockContact(userToEdit.contact_id);
- mockFacility(userToEdit.facility_id);
- mockContactGet('some-other-id');
- translate.withArgs('configuration.user.place.contact').returns(
- Promise.resolve('outside')
- );
-
- // when
- scope.editUser();
-
- // expect
+ try {
setTimeout(() => {
- chai.expect(scope.errors.contact).to.equal('outside');
- done();
+ scope.editUserModel.type = 'district-manager';
+ mockContact(userToEdit.contact_id);
+ mockFacility(userToEdit.facility_id);
+ mockContactGet('some-other-id');
+ translate.withArgs('configuration.user.place.contact').returns(
+ Promise.resolve('outside')
+ );
+
+ // when
+ scope.editUser();
+
+ // expect
+ setTimeout(() => {
+ chai.expect(scope.errors.contact).to.equal('outside');
+ done();
+ });
});
- });
+ } catch(err) {
+ done(err);
+ }
});
it('user is updated', done => {
@@ -385,67 +423,74 @@ describe('EditUserCtrl controller', () => {
mockFacility(userToEdit.facility_id);
mockContactGet(userToEdit.contact_id);
http.get.withArgs('/api/v1/users-info').resolves({ data: { total_docs: 1000, warn: false, limit: 10000 }});
+ try {
+ setTimeout(() => {
+ scope.editUserModel.fullname = 'fullname';
+ scope.editUserModel.email = 'email@email.com';
+ scope.editUserModel.phone = 'phone';
+ scope.editUserModel.facilitySelect = 'facility_id';
+ scope.editUserModel.contactSelect = 'contact_id';
+ scope.editUserModel.language.code = 'language-code';
+ scope.editUserModel.password = 'medic.1234';
+ scope.editUserModel.passwordConfirm = 'medic.1234';
+ scope.editUserModel.role = 'supervisor';
- setTimeout(() => {
- scope.editUserModel.fullname = 'fullname';
- scope.editUserModel.email = 'email@email.com';
- scope.editUserModel.phone = 'phone';
- scope.editUserModel.facilitySelect = 'facility_id';
- scope.editUserModel.contactSelect = 'contact_id';
- scope.editUserModel.language.code = 'language-code';
- scope.editUserModel.password = 'medic.1234';
- scope.editUserModel.passwordConfirm = 'medic.1234';
- scope.editUserModel.role = 'supervisor';
+ scope.editUser();
- scope.editUser();
+ setTimeout(() => {
+ chai.expect(UpdateUser.called).to.equal(true);
+ const updateUserArgs = UpdateUser.getCall(0).args;
- setTimeout(() => {
- chai.expect(UpdateUser.called).to.equal(true);
- const updateUserArgs = UpdateUser.getCall(0).args;
-
- chai.expect(updateUserArgs[0]).to.equal('user.name');
-
- const updates = updateUserArgs[1];
- chai.expect(updates.fullname).to.equal(scope.editUserModel.fullname);
- chai.expect(updates.email).to.equal(scope.editUserModel.email);
- chai.expect(updates.phone).to.equal(scope.editUserModel.phone);
- chai.expect(updates.place).to.equal(scope.editUserModel.facility_id);
- chai.expect(updates.contact).to.equal(scope.editUserModel.contact_id);
- chai.expect(updates.language).to.equal(scope.editUserModel.language.code);
- chai.expect(updates.roles[0]).to.equal(scope.editUserModel.role);
- chai.expect(updates.password).to.deep.equal(scope.editUserModel.password);
- chai.expect(http.get.callCount).to.equal(1);
- chai.expect(http.get.args[0]).to.deep.equal([
- '/api/v1/users-info',
- { params: {
- role: 'supervisor',
- facility_id: scope.editUserModel.place,
- contact_id: scope.editUserModel.contact
- }}
- ]);
- done();
+ chai.expect(updateUserArgs[0]).to.equal('user.name');
+
+ const updates = updateUserArgs[1];
+ chai.expect(updates.fullname).to.equal(scope.editUserModel.fullname);
+ chai.expect(updates.email).to.equal(scope.editUserModel.email);
+ chai.expect(updates.phone).to.equal(scope.editUserModel.phone);
+ chai.expect(updates.place).to.equal(scope.editUserModel.facility_id);
+ chai.expect(updates.contact).to.equal(scope.editUserModel.contact_id);
+ chai.expect(updates.language).to.equal(scope.editUserModel.language.code);
+ chai.expect(updates.roles[0]).to.equal(scope.editUserModel.role);
+ chai.expect(updates.password).to.deep.equal(scope.editUserModel.password);
+ chai.expect(http.get.callCount).to.equal(1);
+ chai.expect(http.get.args[0]).to.deep.equal([
+ '/api/v1/users-info',
+ { params: {
+ role: 'supervisor',
+ facility_id: scope.editUserModel.place,
+ contact_id: scope.editUserModel.contact
+ }}
+ ]);
+ done();
+ });
});
- });
+ } catch(err) {
+ done(err);
+ }
});
it('associated contact must have place when creating a new user', done => {
mockCreateNewUser();
- setTimeout(() => {
- scope.editUserModel.username = 'newuser';
- scope.editUserModel.role = 'data-entry';
- mockContact('xyz');
-
- Translate.fieldIsRequired.withArgs('Facility').returns(Promise.resolve('Facility field is a required field'));
+ try {
setTimeout(() => {
- scope.editUser();
+ scope.editUserModel.username = 'newuser';
+ scope.editUserModel.role = 'data-entry';
+ mockContact('xyz');
+
+ Translate.fieldIsRequired.withArgs('Facility').returns(Promise.resolve('Facility field is a required field'));
setTimeout(() => {
- chai
- .expect(scope.errors.place)
- .to.equal('Facility field is a required field');
- done();
+ scope.editUser();
+ setTimeout(() => {
+ chai
+ .expect(scope.errors.place)
+ .to.equal('Facility field is a required field');
+ done();
+ });
});
});
- });
+ } catch(err) {
+ done(err);
+ }
});
it('should not query users-info when user role is not offline', done => {
@@ -453,37 +498,40 @@ describe('EditUserCtrl controller', () => {
mockContact(userToEdit.contact_id);
mockFacility(userToEdit.facility_id);
mockContactGet(userToEdit.contact_id);
+ try {
+ setTimeout(() => {
+ scope.editUserModel.fullname = 'fullname';
+ scope.editUserModel.email = 'email@email.com';
+ scope.editUserModel.phone = 'phone';
+ scope.editUserModel.facilitySelect = 'facility_id';
+ scope.editUserModel.contactSelect = 'contact_id';
+ scope.editUserModel.language.code = 'language-code';
+ scope.editUserModel.password = 'medic.1234';
+ scope.editUserModel.passwordConfirm = 'medic.1234';
+ scope.editUserModel.role = 'national-manager';
- setTimeout(() => {
- scope.editUserModel.fullname = 'fullname';
- scope.editUserModel.email = 'email@email.com';
- scope.editUserModel.phone = 'phone';
- scope.editUserModel.facilitySelect = 'facility_id';
- scope.editUserModel.contactSelect = 'contact_id';
- scope.editUserModel.language.code = 'language-code';
- scope.editUserModel.password = 'medic.1234';
- scope.editUserModel.passwordConfirm = 'medic.1234';
- scope.editUserModel.role = 'national-manager';
-
- scope.editUser();
+ scope.editUser();
- setTimeout(() => {
- chai.expect(UpdateUser.called).to.equal(true);
- chai.expect(http.get.callCount).to.equal(0);
- chai.expect(UpdateUser.args[0]).to.deep.equal([
- 'user.name',
- {
- fullname: 'fullname',
- email: 'email@email.com',
- phone: 'phone',
- roles: ['national-manager'],
- language: 'language-code',
- password: 'medic.1234'
- }
- ]);
- done();
+ setTimeout(() => {
+ chai.expect(UpdateUser.called).to.equal(true);
+ chai.expect(http.get.callCount).to.equal(0);
+ chai.expect(UpdateUser.args[0]).to.deep.equal([
+ 'user.name',
+ {
+ fullname: 'fullname',
+ email: 'email@email.com',
+ phone: 'phone',
+ roles: ['national-manager'],
+ language: 'language-code',
+ password: 'medic.1234'
+ }
+ ]);
+ done();
+ });
});
- });
+ } catch(err) {
+ done(err);
+ }
});
it('should not save user if offline and is warned by users-info', done => {
@@ -492,30 +540,33 @@ describe('EditUserCtrl controller', () => {
mockFacility('new_facility_id');
mockContactGet(userToEdit.contact_id);
http.get.withArgs('/api/v1/users-info').resolves({ data: { warn: true, total_docs: 10200, limit: 10000 } });
+ try {
+ setTimeout(() => {
+ scope.editUserModel.fullname = 'fullname';
+ scope.editUserModel.email = 'email@email.com';
+ scope.editUserModel.phone = 'phone';
+ scope.editUserModel.facilitySelect = 'new_facility';
+ scope.editUserModel.contactSelect = 'new_contact';
+ scope.editUserModel.language.code = 'language-code';
+ scope.editUserModel.password = 'medic.1234';
+ scope.editUserModel.passwordConfirm = 'medic.1234';
+ scope.editUserModel.role = 'supervisor';
- setTimeout(() => {
- scope.editUserModel.fullname = 'fullname';
- scope.editUserModel.email = 'email@email.com';
- scope.editUserModel.phone = 'phone';
- scope.editUserModel.facilitySelect = 'new_facility';
- scope.editUserModel.contactSelect = 'new_contact';
- scope.editUserModel.language.code = 'language-code';
- scope.editUserModel.password = 'medic.1234';
- scope.editUserModel.passwordConfirm = 'medic.1234';
- scope.editUserModel.role = 'supervisor';
-
- scope.editUser();
+ scope.editUser();
- setTimeout(() => {
- chai.expect(UpdateUser.callCount).to.equal(0);
- chai.expect(http.get.callCount).to.equal(1);
- chai.expect(http.get.args[0]).to.deep.equal([
- '/api/v1/users-info',
- { params: { role: 'supervisor', facility_id: 'new_facility_id', contact_id: 'new_contact_id' }}
- ]);
- done();
+ setTimeout(() => {
+ chai.expect(UpdateUser.callCount).to.equal(0);
+ chai.expect(http.get.callCount).to.equal(1);
+ chai.expect(http.get.args[0]).to.deep.equal([
+ '/api/v1/users-info',
+ { params: { role: 'supervisor', facility_id: 'new_facility_id', contact_id: 'new_contact_id' }}
+ ]);
+ done();
+ });
});
- });
+ } catch(err) {
+ done(err);
+ }
});
it('should save user if offline and warned when user clicks on submit the 2nd time', done => {
@@ -524,47 +575,275 @@ describe('EditUserCtrl controller', () => {
mockFacility('new_facility_id');
mockContactGet(userToEdit.contact_id);
http.get.withArgs('/api/v1/users-info').resolves({ data: { warn: true, total_docs: 10200, limit: 10000 } });
+ try {
+ setTimeout(() => {
+ scope.editUserModel.fullname = 'fullname';
+ scope.editUserModel.email = 'email@email.com';
+ scope.editUserModel.phone = 'phone';
+ scope.editUserModel.facilitySelect = 'new_facility';
+ scope.editUserModel.contactSelect = 'new_contact';
+ scope.editUserModel.language.code = 'language-code';
+ scope.editUserModel.password = 'medic.1234';
+ scope.editUserModel.passwordConfirm = 'medic.1234';
+ scope.editUserModel.role = 'supervisor';
+
+ scope.editUser();
- setTimeout(() => {
- scope.editUserModel.fullname = 'fullname';
- scope.editUserModel.email = 'email@email.com';
- scope.editUserModel.phone = 'phone';
- scope.editUserModel.facilitySelect = 'new_facility';
- scope.editUserModel.contactSelect = 'new_contact';
- scope.editUserModel.language.code = 'language-code';
- scope.editUserModel.password = 'medic.1234';
- scope.editUserModel.passwordConfirm = 'medic.1234';
- scope.editUserModel.role = 'supervisor';
+ setTimeout(() => {
+ chai.expect(UpdateUser.callCount).to.equal(0);
+ chai.expect(http.get.callCount).to.equal(1);
+ chai.expect(http.get.args[0]).to.deep.equal([
+ '/api/v1/users-info',
+ { params: { role: 'supervisor', facility_id: 'new_facility_id', contact_id: 'new_contact_id' }}
+ ]);
+
+ scope.editUser();
+ setTimeout(() => {
+ chai.expect(UpdateUser.callCount).to.equal(1);
+ chai.expect(http.get.callCount).to.equal(1);
+
+ const updateUserArgs = UpdateUser.args[0];
+ chai.expect(updateUserArgs[0]).to.equal('user.name');
+ const updates = updateUserArgs[1];
+ chai.expect(updates.fullname).to.equal(scope.editUserModel.fullname);
+ chai.expect(updates.email).to.equal(scope.editUserModel.email);
+ chai.expect(updates.phone).to.equal(scope.editUserModel.phone);
+ chai.expect(updates.language).to.equal(scope.editUserModel.language.code);
+ chai.expect(updates.roles[0]).to.equal(scope.editUserModel.role);
+ chai.expect(updates.password).to.deep.equal(scope.editUserModel.password);
+
+ done();
+ });
+ });
+ });
+ } catch(err) {
+ done(err);
+ }
+ });
- scope.editUser();
+ it('should require phone when token_login is enabled for new user', done => {
+ Settings = sinon.stub().resolves({
+ roles: {
+ 'district-manager': { name: 'xyz', offline: true }, 'data-entry': { name: 'abc' },
+ supervisor: { name: 'qrt', offline: true },
+ 'national-manager': { name: 'national-manager', offline: false }
+ },
+ token_login: {
+ translation_key: 'key',
+ app_url: 'url',
+ }
+ });
+ mockCreateNewUser();
+ try {
setTimeout(() => {
- chai.expect(UpdateUser.callCount).to.equal(0);
- chai.expect(http.get.callCount).to.equal(1);
- chai.expect(http.get.args[0]).to.deep.equal([
- '/api/v1/users-info',
- { params: { role: 'supervisor', facility_id: 'new_facility_id', contact_id: 'new_contact_id' }}
- ]);
+ scope.editUserModel.username = 'newuser';
+ scope.editUserModel.role = 'data-entry';
+ scope.editUserModel.token_login = true;
+
+ translate.withArgs('configuration.enable.token.login.phone').resolves('phone required');
- scope.editUser();
setTimeout(() => {
- chai.expect(UpdateUser.callCount).to.equal(1);
- chai.expect(http.get.callCount).to.equal(1);
+ scope.editUser();
+ setTimeout(() => {
+ chai.expect(scope.errors.phone).to.equal('phone required');
+ done();
+ });
+ });
+ });
+ } catch(err) {
+ done(err);
+ }
+ });
- const updateUserArgs = UpdateUser.args[0];
- chai.expect(updateUserArgs[0]).to.equal('user.name');
- const updates = updateUserArgs[1];
- chai.expect(updates.fullname).to.equal(scope.editUserModel.fullname);
- chai.expect(updates.email).to.equal(scope.editUserModel.email);
- chai.expect(updates.phone).to.equal(scope.editUserModel.phone);
- chai.expect(updates.language).to.equal(scope.editUserModel.language.code);
- chai.expect(updates.roles[0]).to.equal(scope.editUserModel.role);
- chai.expect(updates.password).to.deep.equal(scope.editUserModel.password);
+ it('should require phone when token_login is enabled for existent user', (done) => {
+ Settings = sinon.stub().resolves({
+ roles: {
+ 'district-manager': { name: 'xyz', offline: true }, 'data-entry': { name: 'abc' },
+ supervisor: { name: 'qrt', offline: true },
+ 'national-manager': { name: 'national-manager', offline: false }
+ },
+ token_login: {
+ translation_key: 'key',
+ app_url: 'url',
+ }
+ });
- done();
+ mockEditAUser(userToEdit);
+ try {
+ setTimeout(() => {
+ scope.editUserModel.username = 'newuser';
+ scope.editUserModel.role = 'data-entry';
+ scope.editUserModel.token_login = true;
+ scope.editUserModel.phone = '';
+
+ translate.withArgs('configuration.enable.token.login.phone').resolves('phone required');
+
+ setTimeout(() => {
+ scope.editUser();
+ setTimeout(() => {
+ chai.expect(scope.errors.phone).to.equal('phone required');
+ done();
+ });
});
});
+ } catch(err) {
+ done(err);
+ }
+ });
+
+ it('should require valid phone when token_login is enabled', (done) => {
+ Settings = sinon.stub().resolves({
+ roles: {
+ 'district-manager': { name: 'xyz', offline: true }, 'data-entry': { name: 'abc' },
+ supervisor: { name: 'qrt', offline: true },
+ 'national-manager': { name: 'national-manager', offline: false }
+ },
+ token_login: {
+ translation_key: 'key',
+ app_url: 'url',
+ }
});
+
+ mockEditAUser(userToEdit);
+ try {
+ setTimeout(() => {
+ scope.editUserModel.username = 'newuser';
+ scope.editUserModel.role = 'data-entry';
+ scope.editUserModel.token_login = true;
+ scope.editUserModel.phone = 'gfdkjlg';
+
+ translate.withArgs('configuration.enable.token.login.phone').resolves('phone required');
+
+ setTimeout(() => {
+ scope.editUser();
+ setTimeout(() => {
+ chai.expect(scope.errors.phone).to.equal('phone required');
+ done();
+ });
+ });
+ });
+ } catch(err) {
+ done(err);
+ }
+ });
+
+ it('should not require password when token_login is enabled for new users', (done) => {
+ Settings = sinon.stub().resolves({
+ roles: {
+ 'district-manager': { name: 'xyz', offline: true }, 'data-entry': { name: 'abc' },
+ supervisor: { name: 'qrt', offline: true },
+ 'national-manager': { name: 'national-manager', offline: false }
+ },
+ token_login: {
+ translation_key: 'key',
+ app_url: 'url',
+ }
+ });
+
+ mockCreateNewUser();
+ http.get.withArgs('/api/v1/users-info').resolves({ data: { warn: false, total_docs: 100, limit: 10000 } });
+ try {
+ setTimeout(() => {
+ scope.editUserModel.username = 'newuser';
+ scope.editUserModel.role = 'data-entry';
+ scope.editUserModel.token_login = true;
+ scope.editUserModel.phone = '+40755696969';
+
+ setTimeout(() => {
+ scope.editUser();
+ setTimeout(() => {
+ chai.expect(CreateUser.callCount).to.equal(1);
+ chai.expect(CreateUser.args[0][0]).to.deep.include({
+ username: 'newuser',
+ phone: '+40755696969',
+ roles: ['data-entry'],
+ token_login: true,
+ });
+ done();
+ }, 10);
+ });
+ });
+ } catch(err) {
+ done(err);
+ }
+ });
+
+ it('should not overwrite token_login when editing and making no changes', (done) => {
+ Settings = sinon.stub().resolves({
+ roles: {
+ 'district-manager': { name: 'xyz', offline: true }, 'data-entry': { name: 'abc' },
+ supervisor: { name: 'qrt', offline: true },
+ 'national-manager': { name: 'national-manager', offline: false }
+ },
+ token_login: {
+ translation_key: 'key',
+ app_url: 'url',
+ }
+ });
+ http.get.withArgs('/api/v1/users-info').resolves({ data: { warn: false, total_docs: 100, limit: 10000 } });
+ Translate.fieldIsRequired.resolves('Facility field is a required field');
+
+ userToEdit.token_login = true;
+ mockEditAUser(userToEdit);
+ try {
+ setTimeout(() => {
+ scope.editUserModel.phone = '+40755696969';
+ scope.editUserModel.role = 'data-entry';
+
+ setTimeout(() => {
+ scope.editUser();
+ setTimeout(() => {
+ chai.expect(UpdateUser.callCount).to.equal(1);
+ chai.expect(UpdateUser.args[0][1]).to.deep.include({
+ phone: '+40755696969',
+ roles: ['data-entry']
+ });
+ done();
+ }, 10);
+ });
+ });
+ } catch(err) {
+ done(err);
+ }
+ });
+
+ it('should require password when disabling token_login', (done) => {
+ Settings = sinon.stub().resolves({
+ roles: {
+ 'district-manager': { name: 'xyz', offline: true }, 'data-entry': { name: 'abc' },
+ supervisor: { name: 'qrt', offline: true },
+ 'national-manager': { name: 'national-manager', offline: false }
+ },
+ token_login: {
+ translation_key: 'key',
+ app_url: 'url',
+ }
+ });
+ http.get.withArgs('/api/v1/users-info').resolves({ data: { warn: false, total_docs: 100, limit: 10000 } });
+ Translate.fieldIsRequired.withArgs('Password').resolves('password required');
+
+ userToEdit.token_login = true;
+ mockEditAUser(userToEdit);
+ try {
+ setTimeout(() => {
+ scope.editUserModel.token_login = false;
+ scope.editUserModel.phone = '';
+ scope.editUserModel.role = 'data-entry';
+
+
+ setTimeout(() => {
+ scope.editUser();
+ setTimeout(() => {
+ chai.expect(UpdateUser.callCount).to.equal(0);
+ chai.expect(scope.errors.password).to.equal('password required');
+ done();
+ }, 10);
+ });
+ });
+ } catch(err) {
+ done(err);
+ }
});
});
});
diff --git a/api/src/config.js b/api/src/config.js
index 26851bd81b8..b927eab15bd 100644
--- a/api/src/config.js
+++ b/api/src/config.js
@@ -171,7 +171,8 @@ module.exports = {
logger.error('Failed to reload settings: %o', err);
process.exit(1);
})
- .then(() => initTransitionLib());
+ .then(() => initTransitionLib())
+ .then(() => logger.debug('Settings updated'));
} else if (change.id.startsWith('messages-')) {
logger.info('Detected translations change - reloading');
return loadTranslations().then(() => initTransitionLib());
diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js
index f16023ec297..d2e506ddf7d 100644
--- a/api/src/controllers/login.js
+++ b/api/src/controllers/login.js
@@ -8,6 +8,7 @@ const auth = require('../auth');
const environment = require('../environment');
const config = require('../config');
const users = require('../services/users');
+const tokenLogin = require('../services/token-login');
const SESSION_COOKIE_RE = /AuthSession=([^;]*);/;
const ONE_YEAR = 31536000000;
const logger = require('../logger');
@@ -15,7 +16,34 @@ const db = require('../db');
const production = process.env.NODE_ENV === 'production';
const localeUtils = require('locale');
-let loginTemplate;
+const templates = {
+ login: {
+ content: null,
+ file: 'index.html',
+ translationStrings: [
+ 'login',
+ 'login.error',
+ 'login.incorrect',
+ 'online.action.message',
+ 'User Name',
+ 'Password'
+ ],
+ },
+ tokenLogin: {
+ content: null,
+ file: 'token-login.html',
+ translationStrings: [
+ 'login.token.missing',
+ 'login.token.expired',
+ 'login.token.invalid',
+ 'login.token.timeout',
+ 'login.token.general.error',
+ 'login.token.loading',
+ 'login.token.redirect.login.info',
+ 'login.token.redirect.login',
+ ],
+ },
+};
const getHomeUrl = userCtx => {
// https://github.com/medic/medic/issues/5035
@@ -55,25 +83,19 @@ const getEnabledLocales = () => {
});
};
-const getLoginTemplate = () => {
- if (loginTemplate) {
- return loginTemplate;
+const getTemplate = (page) => {
+ if (templates[page].content) {
+ return templates[page].content;
}
- const filepath = path.join(__dirname, '..', 'templates', 'login', 'index.html');
- loginTemplate = promisify(fs.readFile)(filepath, { encoding: 'utf-8' })
+ const filepath = path.join(__dirname, '..', 'templates', 'login', templates[page].file);
+ templates[page].content = promisify(fs.readFile)(filepath, { encoding: 'utf-8' })
.then(file => _.template(file));
- return loginTemplate;
+ return templates[page].content;
};
-const getTranslationsString = () => {
- return encodeURIComponent(JSON.stringify(config.getTranslationValues([
- 'login',
- 'login.error',
- 'login.incorrect',
- 'online.action.message',
- 'User Name',
- 'Password'
- ])));
+const getTranslationsString = page => {
+ const translationStrings = templates[page].translationStrings;
+ return encodeURIComponent(JSON.stringify(config.getTranslationValues(translationStrings)));
};
const getBestLocaleCode = (acceptedLanguages, locales, defaultLocale) => {
@@ -82,18 +104,23 @@ const getBestLocaleCode = (acceptedLanguages, locales, defaultLocale) => {
return headerLocales.best(supportedLocales).language;
};
-const renderLogin = (req, branding) => {
- return Promise.all([
- getLoginTemplate(),
- getEnabledLocales()
- ])
+const render = (page, req, branding, extras = {}) => {
+ return Promise
+ .all([
+ getTemplate(page),
+ getEnabledLocales(),
+ ])
.then(([ template, locales ]) => {
- return template({
- branding: branding,
- defaultLocale: getBestLocaleCode(req.headers['accept-language'], locales, config.get('locale')),
- locales: locales,
- translations: getTranslationsString()
- });
+ const options = Object.assign(
+ {
+ branding: branding,
+ locales: locales,
+ defaultLocale: getBestLocaleCode(req.headers['accept-language'], locales, config.get('locale')),
+ translations: getTranslationsString(page)
+ },
+ extras
+ );
+ return template(options);
});
};
@@ -165,8 +192,7 @@ const updateUserLanguageIfRequired = (user, current, selected) => {
const setCookies = (req, res, sessionRes) => {
const sessionCookie = getSessionCookie(sessionRes);
if (!sessionCookie) {
- res.status(401).json({ error: 'Not logged in' });
- return;
+ throw { status: 401, error: 'Not logged in' };
}
const options = { headers: { Cookie: sessionCookie } };
return auth
@@ -191,13 +217,11 @@ const setCookies = (req, res, sessionRes) => {
setLocaleCookie(res, selectedLocale);
return updateUserLanguageIfRequired(req.body.user, language, selectedLocale);
})
- .then(() => {
- res.status(302).send(getRedirectUrl(userCtx, req.body.redirect));
- });
+ .then(() => getRedirectUrl(userCtx, req.body.redirect));
})
.catch(err => {
logger.error(`Error getting authCtx %o`, err);
- res.status(401).json({ error: 'Error getting authCtx' });
+ throw { status: 401, error: 'Error getting authCtx' };
});
};
@@ -229,10 +253,72 @@ const getBranding = () => {
});
};
+const renderTokenLogin = (req, res) => {
+ return getBranding()
+ .then(branding => render('tokenLogin', req, branding, { tokenUrl: req.url }))
+ .then(body => res.send(body));
+};
+
+const createSessionRetry = (req, retry=10) => {
+ return createSession(req).then(sessionRes => {
+ if (sessionRes.statusCode === 200) {
+ return sessionRes;
+ }
+
+ if (retry > 0) {
+ return new Promise((resolve, reject) => {
+ setTimeout(() => {
+ createSessionRetry(req, retry - 1).then(resolve).catch(reject);
+ }, 10);
+ });
+ }
+
+ throw { status: 408, message: 'Login failed after 10 retries' };
+ });
+};
+
+/**
+ * Generates a session cookie for a user identified by supplied token and hash request params.
+ * The user's password is reset in the process.
+ */
+const loginByToken = (req, res) => {
+ if (!tokenLogin.isTokenLoginEnabled()) {
+ return res.status(400).json({ error: 'disabled', reason: 'Token login disabled' });
+ }
+
+ if (!req.params || !req.params.token) {
+ return res.status(400).json({ error: 'missing', reason: 'Missing required param' });
+ }
+
+ return tokenLogin
+ .getUserByToken(req.params.token)
+ .then(userId => {
+ if (!userId) {
+ throw { status: 401, error: 'invalid' };
+ }
+
+ return tokenLogin.resetPassword(userId).then(({ user, password }) => {
+ req.body = { user, password };
+
+ return createSessionRetry(req)
+ .then(sessionRes => setCookies(req, res, sessionRes))
+ .then(redirectUrl => {
+ return tokenLogin.deactivateTokenLogin(userId).then(() => res.status(302).send(redirectUrl));
+ });
+ });
+ })
+ .catch((err = {}) => {
+ logger.error('Error while logging in with token', err);
+ const status = err.status || err.code || 400;
+ const message = err.error || err.message || 'Unexpected error logging in';
+ res.status(status).json({ error: message });
+ });
+};
+
module.exports = {
get: (req, res, next) => {
return getBranding()
- .then(branding => renderLogin(req, branding))
+ .then(branding => render('login', req, branding))
.then(body => res.send(body))
.catch(next);
},
@@ -243,7 +329,14 @@ module.exports = {
res.status(sessionRes.statusCode).json({ error: 'Not logged in' });
return;
}
- return setCookies(req, res, sessionRes);
+ return setCookies(req, res, sessionRes)
+ .then(redirectUrl => res.status(302).send(redirectUrl))
+ .catch(err => {
+ if (err.status === 401) {
+ return res.status(err.status).json({ error: err.error });
+ }
+ throw err;
+ });
})
.catch(err => {
logger.error('Error logging in: %o', err);
@@ -263,7 +356,26 @@ module.exports = {
return res.send();
});
},
+
+ tokenGet: (req, res, next) => renderTokenLogin(req, res).catch(next),
+ tokenPost: (req, res, next) => {
+ return auth
+ .getUserCtx(req)
+ .then(userCtx => {
+ return res.status(302).send(getRedirectUrl(userCtx));
+ })
+ .catch(err => {
+ if (err.code === 401) {
+ return loginByToken(req, res);
+ }
+ next(err);
+ });
+ },
+
// exposed for testing
_safePath: getRedirectUrl,
- _reset: () => { loginTemplate = null; }
+ _reset: () => {
+ templates.login.content = null;
+ templates.tokenLogin.content = null;
+ },
};
diff --git a/api/src/controllers/users.js b/api/src/controllers/users.js
index 168d1a2c0f4..1c9a44578fd 100644
--- a/api/src/controllers/users.js
+++ b/api/src/controllers/users.js
@@ -101,6 +101,10 @@ const getAllowedDocIds = userCtx => {
.then(allowedDocIds => purgedDocs.getUnPurgedIds(userCtx.roles, allowedDocIds));
};
+// this might not be correct.
+// In express4, req.host strips off the port number: https://expressjs.com/en/guide/migrating-5.html#req.host
+const getAppUrl = (req) => `${req.protocol}://${req.hostname}`;
+
module.exports = {
get: (req, res) => {
auth
@@ -110,9 +114,9 @@ module.exports = {
.catch(err => serverUtils.error(err, req, res));
},
create: (req, res) => {
- auth
+ return auth
.check(req, 'can_create_users')
- .then(() => usersService.createUser(req.body))
+ .then(() => usersService.createUser(req.body, getAppUrl(req)))
.then(body => res.json(body))
.catch(err => serverUtils.error(err, req, res));
},
@@ -124,7 +128,7 @@ module.exports = {
const username = req.params.username;
const credentials = auth.basicAuthCredentials(req);
- Promise.all([
+ return Promise.all([
hasFullPermission(req),
isUpdatingSelf(req, credentials, username),
basicAuthValid(credentials, username),
@@ -169,7 +173,7 @@ module.exports = {
}
return usersService
- .updateUser(username, req.body, !!fullPermission)
+ .updateUser(username, req.body, !!fullPermission, getAppUrl(req))
.then(result => {
logger.info(
`REQ ${
diff --git a/api/src/public/login/script.js b/api/src/public/login/script.js
index 7f4de93e5e3..a51ed3c7995 100644
--- a/api/src/public/login/script.js
+++ b/api/src/public/login/script.js
@@ -5,6 +5,10 @@ const setState = function(className) {
document.getElementById('form').className = className;
};
+const setTokenState = className => {
+ document.getElementById('wrapper').className = `has-error ${className}`;
+};
+
const request = function(method, url, payload, callback) {
const xmlhttp = new XMLHttpRequest();
xmlhttp.onreadystatechange = function() {
@@ -46,6 +50,37 @@ const submit = function(e) {
});
};
+const requestTokenLogin = (retry = 20) => {
+ const url = document.getElementById('tokenLogin').action;
+ request('POST', url, '', xmlhttp => {
+ let response = {};
+ try {
+ response = JSON.parse(xmlhttp.responseText);
+ } catch (err) {
+ // no body
+ }
+ switch (xmlhttp.status) {
+ case 302:
+ window.location = xmlhttp.response;
+ break;
+ case 401:
+ setTokenState(response && response.error === 'expired' ? 'tokenexpired' : 'tokeninvalid');
+ break;
+ case 408:
+ if (retry <= 0) {
+ return setTokenState('tokentimeout');
+ }
+ requestTokenLogin(retry - 1);
+ break;
+ case 400:
+ setTokenState(response && response.error === 'missing' ? 'tokenmissing' : 'tokenerror');
+ break;
+ default:
+ setTokenState('tokenerror');
+ }
+ });
+};
+
const focusOnPassword = function(e) {
if (e.keyCode === 13) {
e.preventDefault();
@@ -146,24 +181,26 @@ const checkSession = function() {
};
document.addEventListener('DOMContentLoaded', function() {
- checkSession();
-
translations = parseTranslations();
selectedLocale = getLocale();
translate();
- document.getElementById('login').addEventListener('click', submit, false);
+ document.getElementById('locale').addEventListener('click', handleLocaleSelection, false);
- const user = document.getElementById('user');
- user.addEventListener('keydown', focusOnPassword, false);
- user.focus();
+ if (document.getElementById('tokenLogin')) {
+ requestTokenLogin();
+ } else {
+ checkSession();
+ document.getElementById('login').addEventListener('click', submit, false);
- document.getElementById('password').addEventListener('keydown', focusOnSubmit, false);
-
- document.getElementById('locale').addEventListener('click', handleLocaleSelection, false);
+ const user = document.getElementById('user');
+ user.addEventListener('keydown', focusOnPassword, false);
+ user.focus();
- if ('serviceWorker' in navigator) {
- navigator.serviceWorker.register('/service-worker.js');
+ document.getElementById('password').addEventListener('keydown', focusOnSubmit, false);
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.register('/service-worker.js');
+ }
}
});
diff --git a/api/src/public/login/style.css b/api/src/public/login/style.css
index 15e493730c3..5ba0b88fa06 100644
--- a/api/src/public/login/style.css
+++ b/api/src/public/login/style.css
@@ -40,10 +40,13 @@ input {
line-height: 1.42857;
border-radius: 4px;
display: block;
- margin-top: 2em;
+ margin: 2em auto;
font-size: 1em;
width: 302px;
padding: 6px 10px;
+ text-decoration: none;
+ max-width: 100%;
+ box-sizing: border-box;
}
.loading .btn {
cursor: not-allowed;
@@ -79,12 +82,34 @@ form {
color: #DA4548;
display: none;
}
+.has-error .loading {
+ display: none;
+}
+
.loading .loader,
.loginoffline .error.offline,
.loginincorrect .error.incorrect,
-.loginerror .error.unknown {
+.loginerror .error.unknown,
+.tokeninvalid .error.invalid,
+.tokenmissing .error.missing,
+.tokentimeout .error.timeout,
+.tokenexpired .error.expired,
+.tokenerror .error.unknown
+{
+ display: block;
+}
+
+.login-link {
+ display: none;
+}
+.tokenerror .login-link,
+.tokeninvalid .login-link,
+.tokenmissing .login-link,
+.tokentimeout .login-link,
+.tokenexpired .login-link {
display: block;
}
+
.loader {
display: none;
margin: 1em auto;
diff --git a/api/src/routing.js b/api/src/routing.js
index 5bd11e584d1..113db05a70b 100644
--- a/api/src/routing.js
+++ b/api/src/routing.js
@@ -235,6 +235,8 @@ app.use(express.static(extractedResourceDirectory));
app.get(routePrefix + 'login', login.get);
app.get(routePrefix + 'login/identity', login.getIdentity);
app.postJson(routePrefix + 'login', login.post);
+app.get(routePrefix + 'login/token/:token?', login.tokenGet);
+app.postJson(routePrefix + 'login/token/:token?', login.tokenPost);
// saves CouchDB _session information as `userCtx` in the `req` object
app.use(authorization.getUserCtx);
diff --git a/api/src/services/token-login.js b/api/src/services/token-login.js
new file mode 100644
index 00000000000..d5bd7e02d24
--- /dev/null
+++ b/api/src/services/token-login.js
@@ -0,0 +1,371 @@
+const _ = require('lodash');
+const passwordTester = require('simple-password-tester');
+const db = require('../db');
+const config = require('../config');
+const taskUtils = require('@medic/task-utils');
+const phoneNumber = require('@medic/phone-number');
+const TOKEN_EXPIRE_TIME = 24 * 60 * 60 * 1000; // 24 hours
+const PASSWORD_MINIMUM_LENGTH = 20;
+const PASSWORD_MINIMUM_SCORE = 50;
+const TOKEN_LENGTH = 64;
+
+/**
+ * Generates a complex enough password with a given length
+ * @param {Number} length
+ * @returns {string} - the generated password
+ */
+const generatePassword = (length = PASSWORD_MINIMUM_LENGTH) => {
+ const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,:!$-=';
+ let password;
+ do {
+ password = Array.from({ length }).map(() => _.sample(chars)).join('');
+ } while (passwordTester(password) < PASSWORD_MINIMUM_SCORE);
+
+ return password;
+};
+
+/**
+ * Given user data, validates whether the phone field is filled with a valid phone number.
+ * Throws an error if the phone number field is empty or value is not valid.
+ * If valid, normalizes the field value.
+ * @param {Object} data - the user data
+ * @param {String} data.phone - the phone number to validate
+ */
+const validateAndNormalizePhone = (data) => {
+ const settings = config.get();
+ if (!phoneNumber.validate(settings, data.phone)) {
+ return {
+ msg: 'A valid phone number is required for SMS login.',
+ key: 'configuration.enable.token.login.phone'
+ };
+ }
+
+ data.phone = phoneNumber.normalize(settings, data.phone);
+};
+
+/**
+ * Generates a unique 64 character token.
+ * @returns {String}
+ */
+const generateToken = () => {
+ const tokens = Array.from({ length: 10 }).map(() => generatePassword(TOKEN_LENGTH));
+ const docIds = tokens.map(token => getTokenLoginDocId(token));
+ return db.medic.allDocs({ keys: docIds }).then(results => {
+ const idx = results.rows.findIndex(row => row.error);
+ if (idx === -1) {
+ throw new Error('Failed to generate unique token');
+ }
+ return tokens[idx];
+ });
+};
+
+/**
+ * Enables token-login for a user
+ * - a new document is created in the `medic` database that contains tasks (SMSs) to be sent to the phone number
+ * belonging to the current user (userSettings.phone) that contain instructions and a url to login in the app
+ * - if the user already had token-login enabled, the previous sms document's tasks are cleared
+ * - updates the user document to contain information about the active `token-login` context for the user
+ * - updates the user-settings document to contain some information to be displayed in the admin page
+ * @param {String} appUrl - the base URL of the application
+ * @param {Object} response - the response of previous actions
+ * @returns {Promise<{Object}>} - updated response to be sent to the client
+ */
+const enableTokenLogin = (appUrl, response) => {
+ return Promise
+ .all([
+ db.users.get(response.user.id),
+ db.medic.get(response['user-settings'].id),
+ generateToken()
+ ])
+ .then(([ user, userSettings, token ]) => {
+ return generateTokenLoginDoc(user, userSettings, token, appUrl)
+ .then(() => {
+ user.token_login = {
+ active: true,
+ token,
+ expiration_date: new Date().getTime() + TOKEN_EXPIRE_TIME,
+ };
+
+ userSettings.token_login = {
+ active: true,
+ expiration_date: user.token_login.expiration_date,
+ };
+
+ response.token_login = { expiration_date: user.token_login.expiration_date };
+
+ return Promise.all([ db.users.put(user), db.medic.put(userSettings) ]);
+ });
+ })
+ .then(() => response);
+};
+
+/**
+ * Disables token-login for a user.
+ * Deletes the `token_login` properties from the user and userSettings doc.
+ * Clears pending tasks in existent SMSs
+ *
+ * @param {Object} response - the response of previous actions
+ * @returns {Promise<{Object}>} - updated response to be sent to the client
+ */
+const disableTokenLogin = (response) => {
+ return Promise
+ .all([
+ db.users.get(response.user.id),
+ db.medic.get(response['user-settings'].id),
+ ])
+ .then(([ user, userSettings ]) => {
+ if (!user.token_login) {
+ return;
+ }
+
+ return clearOldTokenLoginDoc(user).then(() => {
+ delete user.token_login;
+ delete userSettings.token_login;
+
+ return Promise.all([
+ db.medic.put(userSettings),
+ db.users.put(user),
+ ]);
+ });
+ })
+ .then(() => response);
+};
+
+/**
+ * Enables or disables token-login for a user
+ * When enabling, if `token-login` configuration is missing or invalid, no changes are made.
+ * @param {Object} data - the request body
+ * @param {String} appUrl - the base URL of the application
+ * @param {Object} response - the response of previous actions
+ * @returns {Promise<{Object}>} - updated response to be sent to the client
+ */
+const manageTokenLogin = (data, appUrl, response) => {
+ if (shouldDisableTokenLogin(data)) {
+ return disableTokenLogin(response);
+ }
+
+ if (!shouldEnableTokenLogin(data)) {
+ return Promise.resolve(response);
+ }
+
+ return enableTokenLogin(appUrl, response);
+};
+
+const getTokenLoginDocId = token => `token:login:${token}`;
+
+/**
+ * Clears pending tasks in the currently assigned `token_login_sms` doc
+ * Used to either disable or refresh token-login
+ *
+ * @param {Object} user - the _users doc
+ * @param {Object} user.token_login - token-login information
+ * @param {Boolean} user.token_login.active - whether the token-login is active or not
+ * @param {String} user.token_login.token - the current token
+ * @returns {Promise}
+ */
+const clearOldTokenLoginDoc = ({ token_login: { token }={} }={}) => {
+ if (!token) {
+ return Promise.resolve();
+ }
+
+ return db.medic
+ .get(getTokenLoginDocId(token))
+ .then(doc => {
+ if (!doc || !doc.tasks) {
+ return;
+ }
+
+ const pendingTasks = doc.tasks.filter(task => task.state === 'pending');
+ if (!pendingTasks.length) {
+ return;
+ }
+
+ pendingTasks.forEach(task => taskUtils.setTaskState(task, 'cleared'));
+ return db.medic.put(doc);
+ })
+ .catch(err => {
+ if (err.status === 404) {
+ return;
+ }
+ throw err;
+ });
+};
+
+/**
+ * Generates a doc of type `token_login` that contains the two tasks required for token login: one reserved
+ * for the URL and one containing instructions for the user.
+ * If the user already had token-login activated, the pending tasks of the existent `token_login` doc are cleared.
+ * This `token_login` doc also connects the user to the token and is an admin-only editable doc.
+ * @param {Object} user - the _users document
+ * @param {Object} userSettings - the user-settings document from the main database
+ * @param {String} token - the randomly generated token
+ * @param {String} appUrl - the base URL of the application
+ * @returns {Promise |