diff --git a/test/api/unit/models/group.test.js b/test/api/unit/models/group.test.js index e965ca29ff..a9df620f56 100644 --- a/test/api/unit/models/group.test.js +++ b/test/api/unit/models/group.test.js @@ -569,7 +569,7 @@ describe('Group Model', () => { }); it('throws an error if no uuids or emails are passed in', async () => { - await expect(Group.validateInvitations(null, null, res)).to.eventually.be.rejected.and.eql({ + await expect(Group.validateInvitations({}, res)).to.eventually.be.rejected.and.eql({ httpCode: 400, message: 'Bad request.', name: 'BadRequest', @@ -579,7 +579,7 @@ describe('Group Model', () => { }); it('throws an error if only uuids are passed in, but they are not an array', async () => { - await expect(Group.validateInvitations({ uuid: 'user-id'}, null, res)).to.eventually.be.rejected.and.eql({ + await expect(Group.validateInvitations({ uuids: 'user-id'}, res)).to.eventually.be.rejected.and.eql({ httpCode: 400, message: 'Bad request.', name: 'BadRequest', @@ -589,7 +589,7 @@ describe('Group Model', () => { }); it('throws an error if only emails are passed in, but they are not an array', async () => { - await expect(Group.validateInvitations(null, { emails: 'user@example.com'}, res)).to.eventually.be.rejected.and.eql({ + await expect(Group.validateInvitations({emails: 'user@example.com'}, res)).to.eventually.be.rejected.and.eql({ httpCode: 400, message: 'Bad request.', name: 'BadRequest', @@ -599,27 +599,27 @@ describe('Group Model', () => { }); it('throws an error if emails are not passed in, and uuid array is empty', async () => { - await expect(Group.validateInvitations([], null, res)).to.eventually.be.rejected.and.eql({ + await expect(Group.validateInvitations({uuids: []}, res)).to.eventually.be.rejected.and.eql({ httpCode: 400, message: 'Bad request.', name: 'BadRequest', }); expect(res.t).to.be.calledOnce; - expect(res.t).to.be.calledWith('inviteMissingUuid'); + expect(res.t).to.be.calledWith('inviteMustNotBeEmpty'); }); it('throws an error if uuids are not passed in, and email array is empty', async () => { - await expect(Group.validateInvitations(null, [], res)).to.eventually.be.rejected.and.eql({ + await expect(Group.validateInvitations({emails: []}, res)).to.eventually.be.rejected.and.eql({ httpCode: 400, message: 'Bad request.', name: 'BadRequest', }); expect(res.t).to.be.calledOnce; - expect(res.t).to.be.calledWith('inviteMissingEmail'); + expect(res.t).to.be.calledWith('inviteMustNotBeEmpty'); }); it('throws an error if uuids and emails are passed in as empty arrays', async () => { - await expect(Group.validateInvitations([], [], res)).to.eventually.be.rejected.and.eql({ + await expect(Group.validateInvitations({emails: [], uuids: []}, res)).to.eventually.be.rejected.and.eql({ httpCode: 400, message: 'Bad request.', name: 'BadRequest', @@ -639,7 +639,7 @@ describe('Group Model', () => { uuids.push('one-more-uuid'); // to put it over the limit - await expect(Group.validateInvitations(uuids, emails, res)).to.eventually.be.rejected.and.eql({ + await expect(Group.validateInvitations({uuids, emails}, res)).to.eventually.be.rejected.and.eql({ httpCode: 400, message: 'Bad request.', name: 'BadRequest', @@ -657,33 +657,33 @@ describe('Group Model', () => { emails.push(`user-${i}@example.com`); } - await Group.validateInvitations(uuids, emails, res); + await Group.validateInvitations({uuids, emails}, res); expect(res.t).to.not.be.called; }); it('does not throw an error if only user ids are passed in', async () => { - await Group.validateInvitations(['user-id', 'user-id2'], null, res); + await Group.validateInvitations({uuids: ['user-id', 'user-id2']}, res); expect(res.t).to.not.be.called; }); it('does not throw an error if only emails are passed in', async () => { - await Group.validateInvitations(null, ['user1@example.com', 'user2@example.com'], res); + await Group.validateInvitations({emails: ['user1@example.com', 'user2@example.com']}, res); expect(res.t).to.not.be.called; }); it('does not throw an error if both uuids and emails are passed in', async () => { - await Group.validateInvitations(['user-id', 'user-id2'], ['user1@example.com', 'user2@example.com'], res); + await Group.validateInvitations({uuids: ['user-id', 'user-id2'], emails: ['user1@example.com', 'user2@example.com']}, res); expect(res.t).to.not.be.called; }); it('does not throw an error if uuids are passed in and emails are an empty array', async () => { - await Group.validateInvitations(['user-id', 'user-id2'], [], res); + await Group.validateInvitations({uuids: ['user-id', 'user-id2'], emails: []}, res); expect(res.t).to.not.be.called; }); it('does not throw an error if emails are passed in and uuids are an empty array', async () => { - await Group.validateInvitations([], ['user1@example.com', 'user2@example.com'], res); + await Group.validateInvitations({uuids: [], emails: ['user1@example.com', 'user2@example.com']}, res); expect(res.t).to.not.be.called; }); }); diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId.test.js index 6a9cd68429..2f3e219592 100644 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId.test.js @@ -47,6 +47,14 @@ describe('GET /challenges/:challengeId', () => { _id: groupLeader._id, id: groupLeader._id, profile: {name: groupLeader.profile.name}, + auth: { + local: { + username: groupLeader.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); expect(chal.group).to.eql({ _id: group._id, @@ -105,6 +113,14 @@ describe('GET /challenges/:challengeId', () => { _id: challengeLeader._id, id: challengeLeader._id, profile: {name: challengeLeader.profile.name}, + auth: { + local: { + username: challengeLeader.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); expect(chal.group).to.eql({ _id: group._id, @@ -131,6 +147,14 @@ describe('GET /challenges/:challengeId', () => { _id: challengeLeader._id, id: challengeLeader._id, profile: {name: challengeLeader.profile.name}, + auth: { + local: { + username: challengeLeader.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); }); }); @@ -179,6 +203,14 @@ describe('GET /challenges/:challengeId', () => { _id: challengeLeader._id, id: challengeLeader._id, profile: {name: challengeLeader.profile.name}, + auth: { + local: { + username: challengeLeader.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); expect(chal.group).to.eql({ _id: group._id, @@ -205,6 +237,14 @@ describe('GET /challenges/:challengeId', () => { _id: challengeLeader._id, id: challengeLeader._id, profile: {name: challengeLeader.profile.name}, + auth: { + local: { + username: challengeLeader.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); }); }); diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js index 2b0e006d38..97ed8236fc 100644 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_members.test.js @@ -60,6 +60,14 @@ describe('GET /challenges/:challengeId/members', () => { _id: groupLeader._id, id: groupLeader._id, profile: {name: groupLeader.profile.name}, + auth: { + local: { + username: groupLeader.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); }); @@ -73,8 +81,16 @@ describe('GET /challenges/:challengeId/members', () => { _id: leader._id, id: leader._id, profile: {name: leader.profile.name}, + auth: { + local: { + username: leader.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); - expect(res[0]).to.have.all.keys(['_id', 'id', 'profile']); + expect(res[0]).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']); expect(res[0].profile).to.have.all.keys(['name']); }); @@ -88,8 +104,16 @@ describe('GET /challenges/:challengeId/members', () => { _id: anotherUser._id, id: anotherUser._id, profile: {name: anotherUser.profile.name}, + auth: { + local: { + username: anotherUser.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); - expect(res[0]).to.have.all.keys(['_id', 'id', 'profile']); + expect(res[0]).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']); expect(res[0].profile).to.have.all.keys(['name']); }); @@ -107,7 +131,7 @@ describe('GET /challenges/:challengeId/members', () => { let res = await user.get(`/challenges/${challenge._id}/members?includeAllMembers=not-true`); expect(res.length).to.equal(30); res.forEach(member => { - expect(member).to.have.all.keys(['_id', 'id', 'profile']); + expect(member).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']); expect(member.profile).to.have.all.keys(['name']); }); }); @@ -126,7 +150,7 @@ describe('GET /challenges/:challengeId/members', () => { let res = await user.get(`/challenges/${challenge._id}/members`); expect(res.length).to.equal(30); res.forEach(member => { - expect(member).to.have.all.keys(['_id', 'id', 'profile']); + expect(member).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']); expect(member.profile).to.have.all.keys(['name']); }); }); @@ -145,7 +169,7 @@ describe('GET /challenges/:challengeId/members', () => { let res = await user.get(`/challenges/${challenge._id}/members?includeAllMembers=true`); expect(res.length).to.equal(32); res.forEach(member => { - expect(member).to.have.all.keys(['_id', 'id', 'profile']); + expect(member).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']); expect(member.profile).to.have.all.keys(['name']); }); }); diff --git a/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js b/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js index 47a7adbf39..eef16de7e1 100644 --- a/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_challengeId_members_memberId.test.js @@ -81,7 +81,7 @@ describe('GET /challenges/:challengeId/members/:memberId', () => { await groupLeader.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: taskText}]); let memberProgress = await user.get(`/challenges/${challenge._id}/members/${groupLeader._id}`); - expect(memberProgress).to.have.all.keys(['_id', 'id', 'profile', 'tasks']); + expect(memberProgress).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile', 'tasks']); expect(memberProgress.profile).to.have.all.keys(['name']); expect(memberProgress.tasks.length).to.equal(1); }); diff --git a/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js index 8ad7b76ed8..9fc903c925 100644 --- a/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_group_groupid.test.js @@ -39,6 +39,14 @@ describe('GET challenges/groups/:groupId', () => { _id: publicGuild.leader._id, id: publicGuild.leader._id, profile: {name: user.profile.name}, + auth: { + local: { + username: user.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); expect(foundChallenge2).to.exist; @@ -46,6 +54,14 @@ describe('GET challenges/groups/:groupId', () => { _id: publicGuild.leader._id, id: publicGuild.leader._id, profile: {name: user.profile.name}, + auth: { + local: { + username: user.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); }); @@ -58,6 +74,14 @@ describe('GET challenges/groups/:groupId', () => { _id: publicGuild.leader._id, id: publicGuild.leader._id, profile: {name: user.profile.name}, + auth: { + local: { + username: user.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); expect(foundChallenge2).to.exist; @@ -65,6 +89,14 @@ describe('GET challenges/groups/:groupId', () => { _id: publicGuild.leader._id, id: publicGuild.leader._id, profile: {name: user.profile.name}, + auth: { + local: { + username: user.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); }); @@ -125,6 +157,14 @@ describe('GET challenges/groups/:groupId', () => { _id: privateGuild.leader._id, id: privateGuild.leader._id, profile: {name: user.profile.name}, + auth: { + local: { + username: user.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); expect(foundChallenge2).to.exist; @@ -132,6 +172,14 @@ describe('GET challenges/groups/:groupId', () => { _id: privateGuild.leader._id, id: privateGuild.leader._id, profile: {name: user.profile.name}, + auth: { + local: { + username: user.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); }); }); @@ -235,6 +283,14 @@ describe('GET challenges/groups/:groupId', () => { _id: party.leader._id, id: party.leader._id, profile: {name: user.profile.name}, + auth: { + local: { + username: user.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); expect(foundChallenge2).to.exist; @@ -242,6 +298,14 @@ describe('GET challenges/groups/:groupId', () => { _id: party.leader._id, id: party.leader._id, profile: {name: user.profile.name}, + auth: { + local: { + username: user.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); }); @@ -254,6 +318,14 @@ describe('GET challenges/groups/:groupId', () => { _id: party.leader._id, id: party.leader._id, profile: {name: user.profile.name}, + auth: { + local: { + username: user.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); expect(foundChallenge2).to.exist; @@ -261,6 +333,14 @@ describe('GET challenges/groups/:groupId', () => { _id: party.leader._id, id: party.leader._id, profile: {name: user.profile.name}, + auth: { + local: { + username: user.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); }); }); @@ -288,6 +368,14 @@ describe('GET challenges/groups/:groupId', () => { _id: user._id, id: user._id, profile: {name: user.profile.name}, + auth: { + local: { + username: user.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); expect(foundChallenge2).to.exist; @@ -295,6 +383,14 @@ describe('GET challenges/groups/:groupId', () => { _id: user._id, id: user._id, profile: {name: user.profile.name}, + auth: { + local: { + username: user.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); }); @@ -307,6 +403,14 @@ describe('GET challenges/groups/:groupId', () => { _id: user._id, id: user._id, profile: {name: user.profile.name}, + auth: { + local: { + username: user.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); expect(foundChallenge2).to.exist; @@ -314,6 +418,14 @@ describe('GET challenges/groups/:groupId', () => { _id: user._id, id: user._id, profile: {name: user.profile.name}, + auth: { + local: { + username: user.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); }); }); diff --git a/test/api/v3/integration/challenges/GET-challenges_user.test.js b/test/api/v3/integration/challenges/GET-challenges_user.test.js index e1b1c32143..9aa06541de 100644 --- a/test/api/v3/integration/challenges/GET-challenges_user.test.js +++ b/test/api/v3/integration/challenges/GET-challenges_user.test.js @@ -40,6 +40,14 @@ describe('GET challenges/user', () => { _id: publicGuild.leader._id, id: publicGuild.leader._id, profile: {name: user.profile.name}, + auth: { + local: { + username: user.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); expect(foundChallenge.group).to.eql({ _id: publicGuild._id, @@ -62,6 +70,14 @@ describe('GET challenges/user', () => { _id: publicGuild.leader._id, id: publicGuild.leader._id, profile: {name: user.profile.name}, + auth: { + local: { + username: user.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); expect(foundChallenge1.group).to.eql({ _id: publicGuild._id, @@ -79,6 +95,14 @@ describe('GET challenges/user', () => { _id: publicGuild.leader._id, id: publicGuild.leader._id, profile: {name: user.profile.name}, + auth: { + local: { + username: user.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); expect(foundChallenge2.group).to.eql({ _id: publicGuild._id, @@ -101,6 +125,14 @@ describe('GET challenges/user', () => { _id: publicGuild.leader._id, id: publicGuild.leader._id, profile: {name: user.profile.name}, + auth: { + local: { + username: user.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); expect(foundChallenge1.group).to.eql({ _id: publicGuild._id, @@ -118,6 +150,14 @@ describe('GET challenges/user', () => { _id: publicGuild.leader._id, id: publicGuild.leader._id, profile: {name: user.profile.name}, + auth: { + local: { + username: user.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); expect(foundChallenge2.group).to.eql({ _id: publicGuild._id, diff --git a/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js b/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js index 1ca9a434c3..8385d63e36 100644 --- a/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js +++ b/test/api/v3/integration/challenges/POST-challenges_challengeId_join.test.js @@ -79,6 +79,14 @@ describe('POST /challenges/:challengeId/join', () => { _id: groupLeader._id, id: groupLeader._id, profile: {name: groupLeader.profile.name}, + auth: { + local: { + username: groupLeader.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); expect(res.name).to.equal(challenge.name); }); diff --git a/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js index fc3155c6e0..4e6f4dad7c 100644 --- a/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js +++ b/test/api/v3/integration/challenges/PUT-challenges_challengeId.test.js @@ -79,6 +79,14 @@ describe('PUT /challenges/:challengeId', () => { _id: member._id, id: member._id, profile: {name: member.profile.name}, + auth: { + local: { + username: member.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); expect(res.name).to.equal('New Challenge Name'); expect(res.description).to.equal('New challenge description.'); diff --git a/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js b/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js index 06cbf4974a..832efe66e9 100644 --- a/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js +++ b/test/api/v3/integration/groups/GET-groups_groupId_invites.test.js @@ -50,6 +50,14 @@ describe('GET /groups/:groupId/invites', () => { _id: invited._id, id: invited._id, profile: {name: invited.profile.name}, + auth: { + local: { + username: invited.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); }); @@ -58,7 +66,7 @@ describe('GET /groups/:groupId/invites', () => { let invited = await generateUser(); await user.post(`/groups/${group._id}/invite`, {uuids: [invited._id]}); let res = await user.get('/groups/party/invites'); - expect(res[0]).to.have.all.keys(['_id', 'id', 'profile']); + expect(res[0]).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']); expect(res[0].profile).to.have.all.keys(['name']); }); @@ -76,7 +84,7 @@ describe('GET /groups/:groupId/invites', () => { let res = await leader.get(`/groups/${group._id}/invites`); expect(res.length).to.equal(30); res.forEach(member => { - expect(member).to.have.all.keys(['_id', 'id', 'profile']); + expect(member).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']); expect(member.profile).to.have.all.keys(['name']); }); }).timeout(10000); diff --git a/test/api/v3/integration/groups/GET-groups_groupId_members.test.js b/test/api/v3/integration/groups/GET-groups_groupId_members.test.js index cbbe45b546..603b2f64b1 100644 --- a/test/api/v3/integration/groups/GET-groups_groupId_members.test.js +++ b/test/api/v3/integration/groups/GET-groups_groupId_members.test.js @@ -56,13 +56,21 @@ describe('GET /groups/:groupId/members', () => { _id: user._id, id: user._id, profile: {name: user.profile.name}, + auth: { + local: { + username: user.auth.local.username, + }, + }, + flags: { + verifiedUsername: true, + }, }); }); it('populates only some fields', async () => { await generateGroup(user, {type: 'party', name: generateUUID()}); let res = await user.get('/groups/party/members'); - expect(res[0]).to.have.all.keys(['_id', 'id', 'profile']); + expect(res[0]).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']); expect(res[0].profile).to.have.all.keys(['name']); }); @@ -74,7 +82,7 @@ describe('GET /groups/:groupId/members', () => { '_id', 'id', 'preferences', 'profile', 'stats', 'achievements', 'party', 'backer', 'contributor', 'auth', 'items', 'inbox', 'loginIncentives', 'flags', ]); - expect(Object.keys(memberRes.auth)).to.eql(['timestamps']); + expect(Object.keys(memberRes.auth)).to.eql(['local', 'timestamps']); expect(Object.keys(memberRes.preferences).sort()).to.eql([ 'size', 'hair', 'skin', 'shirt', 'chair', 'costume', 'sleep', 'background', 'tasks', 'disableClasses', @@ -95,7 +103,7 @@ describe('GET /groups/:groupId/members', () => { '_id', 'id', 'preferences', 'profile', 'stats', 'achievements', 'party', 'backer', 'contributor', 'auth', 'items', 'inbox', 'loginIncentives', 'flags', ]); - expect(Object.keys(memberRes.auth)).to.eql(['timestamps']); + expect(Object.keys(memberRes.auth)).to.eql(['local', 'timestamps']); expect(Object.keys(memberRes.preferences).sort()).to.eql([ 'size', 'hair', 'skin', 'shirt', 'chair', 'costume', 'sleep', 'background', 'tasks', 'disableClasses', @@ -120,7 +128,7 @@ describe('GET /groups/:groupId/members', () => { let res = await user.get('/groups/party/members'); expect(res.length).to.equal(30); res.forEach(member => { - expect(member).to.have.all.keys(['_id', 'id', 'profile']); + expect(member).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']); expect(member.profile).to.have.all.keys(['name']); }); }); @@ -137,7 +145,7 @@ describe('GET /groups/:groupId/members', () => { let res = await user.get('/groups/party/members?includeAllMembers=true'); expect(res.length).to.equal(30); res.forEach(member => { - expect(member).to.have.all.keys(['_id', 'id', 'profile']); + expect(member).to.have.all.keys(['_id', 'auth', 'flags', 'id', 'profile']); expect(member.profile).to.have.all.keys(['name']); }); }); diff --git a/test/api/v3/integration/groups/POST-groups_invite.test.js b/test/api/v3/integration/groups/POST-groups_invite.test.js index fcc282bc50..56f8236752 100644 --- a/test/api/v3/integration/groups/POST-groups_invite.test.js +++ b/test/api/v3/integration/groups/POST-groups_invite.test.js @@ -23,6 +23,73 @@ describe('Post /groups/:groupId/invite', () => { }); }); + describe('username invites', () => { + it('returns an error when invited user is not found', async () => { + const fakeID = 'fakeuserid'; + + await expect(inviter.post(`/groups/${group._id}/invite`, { + usernames: [fakeID], + })) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userWithUsernameNotFound', {username: fakeID}), + }); + }); + + it('returns an error when inviting yourself to a group', async () => { + await expect(inviter.post(`/groups/${group._id}/invite`, { + usernames: [inviter.auth.local.lowerCaseUsername], + })) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('cannotInviteSelfToGroup'), + }); + }); + + it('invites a user to a group by username', async () => { + const userToInvite = await generateUser(); + + await expect(inviter.post(`/groups/${group._id}/invite`, { + usernames: [userToInvite.auth.local.lowerCaseUsername], + })).to.eventually.deep.equal([{ + id: group._id, + name: groupName, + inviter: inviter._id, + publicGuild: false, + }]); + + await expect(userToInvite.get('/user')) + .to.eventually.have.nested.property('invitations.guilds[0].id', group._id); + }); + + it('invites multiple users to a group by uuid', async () => { + const userToInvite = await generateUser(); + const userToInvite2 = await generateUser(); + + await expect(inviter.post(`/groups/${group._id}/invite`, { + usernames: [userToInvite.auth.local.lowerCaseUsername, userToInvite2.auth.local.lowerCaseUsername], + })).to.eventually.deep.equal([ + { + id: group._id, + name: groupName, + inviter: inviter._id, + publicGuild: false, + }, + { + id: group._id, + name: groupName, + inviter: inviter._id, + publicGuild: false, + }, + ]); + + await expect(userToInvite.get('/user')).to.eventually.have.nested.property('invitations.guilds[0].id', group._id); + await expect(userToInvite2.get('/user')).to.eventually.have.nested.property('invitations.guilds[0].id', group._id); + }); + }); + describe('user id invites', () => { it('returns an error when inviter has no chat privileges', async () => { let inviterMuted = await inviter.update({'flags.chatRevoked': true}); @@ -93,7 +160,7 @@ describe('Post /groups/:groupId/invite', () => { .to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - message: t('inviteMissingUuid'), + message: t('inviteMustNotBeEmpty'), }); }); @@ -228,7 +295,7 @@ describe('Post /groups/:groupId/invite', () => { .to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - message: t('inviteMissingEmail'), + message: t('inviteMustNotBeEmpty'), }); }); diff --git a/test/api/v3/integration/members/GET-members_id.test.js b/test/api/v3/integration/members/GET-members_id.test.js index 1dca0284b9..f93ea28498 100644 --- a/test/api/v3/integration/members/GET-members_id.test.js +++ b/test/api/v3/integration/members/GET-members_id.test.js @@ -34,7 +34,7 @@ describe('GET /members/:memberId', () => { '_id', 'id', 'preferences', 'profile', 'stats', 'achievements', 'party', 'backer', 'contributor', 'auth', 'items', 'inbox', 'loginIncentives', 'flags', ]); - expect(Object.keys(memberRes.auth)).to.eql(['timestamps']); + expect(Object.keys(memberRes.auth)).to.eql(['local', 'timestamps']); expect(Object.keys(memberRes.preferences).sort()).to.eql([ 'size', 'hair', 'skin', 'shirt', 'chair', 'costume', 'sleep', 'background', 'tasks', 'disableClasses', diff --git a/test/api/v4/user/auth/POST-user_verify_display_name.test.js b/test/api/v4/user/auth/POST-user_verify_display_name.test.js new file mode 100644 index 0000000000..8fa1b9d5b9 --- /dev/null +++ b/test/api/v4/user/auth/POST-user_verify_display_name.test.js @@ -0,0 +1,57 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v4'; + +const ENDPOINT = '/user/auth/verify-display-name'; + +describe('POST /user/auth/verify-display-name', async () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('successfully verifies display name including funky characters', async () => { + let newDisplayName = 'Sabé 🤬'; + let response = await user.post(ENDPOINT, { + displayName: newDisplayName, + }); + expect(response).to.eql({ isUsable: true }); + }); + + context('errors', async () => { + it('errors if display name is not provided', async () => { + await expect(user.post(ENDPOINT, { + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('errors if display name is a slur', async () => { + await expect(user.post(ENDPOINT, { + displayName: 'TESTPLACEHOLDERSLURWORDHERE', + })).to.eventually.eql({ isUsable: false, issues: [t('displaynameIssueSlur')] }); + }); + + it('errors if display name contains a slur', async () => { + await expect(user.post(ENDPOINT, { + displayName: 'TESTPLACEHOLDERSLURWORDHERE_otherword', + })).to.eventually.eql({ isUsable: false, issues: [t('displaynameIssueLength'), t('displaynameIssueSlur')] }); + await expect(user.post(ENDPOINT, { + displayName: 'something_TESTPLACEHOLDERSLURWORDHERE', + })).to.eventually.eql({ isUsable: false, issues: [t('displaynameIssueLength'), t('displaynameIssueSlur')] }); + await expect(user.post(ENDPOINT, { + displayName: 'somethingTESTPLACEHOLDERSLURWORDHEREotherword', + })).to.eventually.eql({ isUsable: false, issues: [t('displaynameIssueLength'), t('displaynameIssueSlur')] }); + }); + + it('errors if display name has incorrect length', async () => { + await expect(user.post(ENDPOINT, { + displayName: 'this is a very long display name over 30 characters', + })).to.eventually.eql({ isUsable: false, issues: [t('displaynameIssueLength')] }); + }); + }); +}); diff --git a/website/client/assets/css/sprites/spritesmith-largeSprites-0.css b/website/client/assets/css/sprites/spritesmith-largeSprites-0.css index 3cdfe69240..d545545317 100644 --- a/website/client/assets/css/sprites/spritesmith-largeSprites-0.css +++ b/website/client/assets/css/sprites/spritesmith-largeSprites-0.css @@ -70,3 +70,9 @@ width: 366px; height: 285px; } +.scene_veteran_pets { + background-image: url('~assets/images/sprites/spritesmith-largeSprites-0.png'); + background-position: -582px -727px; + width: 242px; + height: 62px; +} diff --git a/website/client/assets/images/sprites/spritesmith-largeSprites-0.png b/website/client/assets/images/sprites/spritesmith-largeSprites-0.png index eeea526388..4a0780a0eb 100644 Binary files a/website/client/assets/images/sprites/spritesmith-largeSprites-0.png and b/website/client/assets/images/sprites/spritesmith-largeSprites-0.png differ diff --git a/website/client/assets/scss/button.scss b/website/client/assets/scss/button.scss index 843bc24e62..a278ded2d1 100644 --- a/website/client/assets/scss/button.scss +++ b/website/client/assets/scss/button.scss @@ -20,7 +20,7 @@ @include btn-focus-hover-shadow(); } - &:hover:not(.btn-flat) { + &:hover:not(.btn-flat):not(.disabled) { @include btn-focus-hover-shadow(); border-color: transparent; } @@ -37,6 +37,7 @@ .btn:disabled, .btn.disabled { box-shadow: none; + cursor: default; opacity: 0.64; border-color: transparent; } diff --git a/website/client/assets/scss/modal.scss b/website/client/assets/scss/modal.scss index e61bfccb30..60c0274d13 100644 --- a/website/client/assets/scss/modal.scss +++ b/website/client/assets/scss/modal.scss @@ -11,7 +11,12 @@ } } +.modal { + z-index: 1350; +} + .modal-dialog { + margin: 5.5rem auto 3rem; width: auto; .title { @@ -117,4 +122,4 @@ } } } -} \ No newline at end of file +} diff --git a/website/client/assets/svg/hello-habitican.svg b/website/client/assets/svg/hello-habitican.svg new file mode 100644 index 0000000000..292b792a79 --- /dev/null +++ b/website/client/assets/svg/hello-habitican.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/website/client/assets/svg/liked.svg b/website/client/assets/svg/liked.svg index 44dd5ec3d5..b422a81e83 100644 --- a/website/client/assets/svg/liked.svg +++ b/website/client/assets/svg/liked.svg @@ -1,3 +1,3 @@ - + diff --git a/website/client/components/auth/authForm.vue b/website/client/components/auth/authForm.vue index 5c55c800cb..5489cf4f10 100644 --- a/website/client/components/auth/authForm.vue +++ b/website/client/components/auth/authForm.vue @@ -82,6 +82,7 @@ import hello from 'hellojs'; import { setUpAxios } from 'client/libs/auth'; import debounce from 'lodash/debounce'; +import isEmail from 'validator/lib/isEmail'; import facebookSquareIcon from 'assets/svg/facebook-square.svg'; import googleIcon from 'assets/svg/google.svg'; @@ -115,13 +116,13 @@ export default { computed: { emailValid () { if (this.email.length <= 3) return false; - return this.validateEmail(this.email); + return isEmail(this.email); }, emailInvalid () { return !this.emailValid; }, usernameValid () { - if (this.username.length <= 3) return false; + if (this.username.length < 1) return false; return this.usernameIssues.length === 0; }, usernameInvalid () { @@ -143,7 +144,7 @@ export default { methods: { // eslint-disable-next-line func-names validateUsername: debounce(function (username) { - if (username.length <= 3) { + if (username.length < 1) { return; } this.$store.dispatch('auth:verifyUsername', { diff --git a/website/client/components/auth/registerLoginReset.vue b/website/client/components/auth/registerLoginReset.vue index ffaa3b6c93..d131dc76a5 100644 --- a/website/client/components/auth/registerLoginReset.vue +++ b/website/client/components/auth/registerLoginReset.vue @@ -295,6 +295,7 @@ import axios from 'axios'; import hello from 'hellojs'; import debounce from 'lodash/debounce'; +import isEmail from 'validator/lib/isEmail'; import gryphon from 'assets/svg/gryphon.svg'; import habiticaIcon from 'assets/svg/habitica-logo.svg'; @@ -340,18 +341,18 @@ export default { }, emailValid () { if (this.email.length <= 3) return false; - return this.validateEmail(this.email); + return isEmail(this.email); }, emailInvalid () { if (this.email.length <= 3) return false; return !this.emailValid; }, usernameValid () { - if (this.username.length <= 3) return false; + if (this.username.length < 1) return false; return this.usernameIssues.length === 0; }, usernameInvalid () { - if (this.username.length <= 3) return false; + if (this.username.length < 1) return false; return !this.usernameValid; }, passwordConfirmValid () { diff --git a/website/client/components/chat/autoComplete.vue b/website/client/components/chat/autoComplete.vue index 8b5cc3cf43..fbce94d1e4 100644 --- a/website/client/components/chat/autoComplete.vue +++ b/website/client/components/chat/autoComplete.vue @@ -1,26 +1,91 @@ - diff --git a/website/client/components/chat/chatCard.vue b/website/client/components/chat/chatCard.vue index a52824ec37..f992148b26 100644 --- a/website/client/components/chat/chatCard.vue +++ b/website/client/components/chat/chatCard.vue @@ -4,37 +4,50 @@ div .message-hidden(v-if='msg.flagCount === 1 && user.contributor.admin') Message flagged once, not hidden .message-hidden(v-if='msg.flagCount > 1 && user.contributor.admin') Message hidden .card-body - h3.leader( - :class='userLevelStyle(msg)', - @click="showMemberModal(msg.uuid)", - v-b-tooltip.hover.top="tierTitle", - ) - | {{msg.user}} - .svg-icon(v-html="tierIcon", v-if='showShowTierStyle') - p.time(v-b-tooltip="", :title="msg.timestamp | date") {{msg.timestamp | timeAgo}} - .text(v-markdown='msg.text') - hr - div(v-if='msg.id') - .action(@click='like()', v-if='!inbox && msg.likes', :class='{active: msg.likes[user._id]}') - .svg-icon(v-html="icons.like") + h3.leader( + :class='userLevelStyle(msg)', + @click="showMemberModal(msg.uuid)", + v-b-tooltip.hover.top="tierTitle", + v-if="msg.user" + ) + | {{msg.user}} + .svg-icon(v-html="tierIcon") + p.time + span.mr-1(v-if="msg.username") @{{ msg.username }} + span.mr-1(v-if="msg.username") • + span(v-b-tooltip="", :title="msg.timestamp | date") {{ msg.timestamp | timeAgo }} + .text(v-html='atHighlight(parseMarkdown(msg.text))') + hr + .d-flex(v-if='msg.id') + .action.d-flex.align-items-center(v-if='!inbox', @click='copyAsTodo(msg)') + .svg-icon(v-html="icons.copy") + div {{$t('copyAsTodo')}} + .action.d-flex.align-items-center(v-if='!inbox && user.flags.communityGuidelinesAccepted && msg.uuid !== "system"', @click='report(msg)') + .svg-icon(v-html="icons.report") + div {{$t('report')}} + // @TODO make flagging/reporting work in the inbox. NOTE: it must work even if the communityGuidelines are not accepted and it MUST work for messages that you have SENT as well as received. -- Alys + .action.d-flex.align-items-center(v-if='msg.uuid === user._id || inbox || user.contributor.admin', @click='remove()') + .svg-icon(v-html="icons.delete") + | {{$t('delete')}} + .ml-auto.d-flex(v-b-tooltip="{title: likeTooltip(msg.likes[user._id])}", v-if='!inbox') + .action.d-flex.align-items-center.mr-0(@click='like()', v-if='likeCount > 0', :class='{active: msg.likes[user._id]}') + .svg-icon(v-html="icons.liked", :title='$t("liked")') + | +{{ likeCount }} + .action.d-flex.align-items-center.mr-0(@click='like()', v-if='likeCount === 0', :class='{active: msg.likes[user._id]}') + .svg-icon(v-html="icons.like", :title='$t("like")') span(v-if='!msg.likes[user._id]') {{ $t('like') }} - span(v-if='msg.likes[user._id]') {{ $t('liked') }} - span.action(v-if='!inbox', @click='copyAsTodo(msg)') - .svg-icon(v-html="icons.copy") - | {{$t('copyAsTodo')}} - span.action(v-if='!inbox && user.flags.communityGuidelinesAccepted && msg.uuid !== "system"', @click='report(msg)') - .svg-icon(v-html="icons.report") - | {{$t('report')}} - // @TODO make flagging/reporting work in the inbox. NOTE: it must work even if the communityGuidelines are not accepted and it MUST work for messages that you have SENT as well as received. -- Alys - span.action(v-if='msg.uuid === user._id || inbox || user.contributor.admin', @click='remove()') - .svg-icon(v-html="icons.delete") - | {{$t('delete')}} - span.action.float-right.liked(v-if='likeCount > 0') - .svg-icon(v-html="icons.liked") - | + {{ likeCount }} + + @@ -114,8 +137,9 @@ import axios from 'axios'; import moment from 'moment'; import cloneDeep from 'lodash/cloneDeep'; import escapeRegExp from 'lodash/escapeRegExp'; +import max from 'lodash/max'; -import markdownDirective from 'client/directives/markdown'; +import habiticaMarkdown from 'habitica-markdown'; import { mapState } from 'client/libs/store'; import styleHelper from 'client/mixins/styleHelper'; @@ -161,9 +185,6 @@ export default { }), }; }, - directives: { - markdown: markdownDirective, - }, filters: { timeAgo (value) { return moment(value).fromNow(); @@ -177,23 +198,24 @@ export default { ...mapState({user: 'user.data'}), isUserMentioned () { const message = this.msg; - let user = this.user; + const user = this.user; if (message.hasOwnProperty('highlight')) return message.highlight; message.highlight = false; - let messagetext = message.text.toLowerCase(); - let username = user.profile.name; - let mentioned = messagetext.indexOf(username.toLowerCase()); - let escapedUsername = escapeRegExp(username); - let pattern = `@${escapedUsername}([^\w]|$){1}`; - + const messageText = message.text.toLowerCase(); + const displayName = user.profile.name; + const username = user.auth.local && user.auth.local.username; + const mentioned = max([messageText.indexOf(username.toLowerCase()), messageText.indexOf(displayName.toLowerCase())]); if (mentioned === -1) return message.highlight; - let preceedingchar = messagetext.substring(mentioned - 1, mentioned); - if (mentioned === 0 || preceedingchar.trim() === '' || preceedingchar === '@') { + const escapedDisplayName = escapeRegExp(displayName); + const escapedUsername = escapeRegExp(username); + const pattern = `@(${escapedUsername}|${escapedDisplayName})([^\w]|$)`; + const precedingChar = messageText.substring(mentioned - 1, mentioned); + if (mentioned === 0 || precedingChar.trim() === '' || precedingChar === '@') { let regex = new RegExp(pattern, 'i'); - message.highlight = regex.test(messagetext); + message.highlight = regex.test(messageText); } return message.highlight; @@ -209,12 +231,6 @@ export default { } return likeCount; }, - showShowTierStyle () { - const message = this.msg; - const isContributor = Boolean(message.contributor && message.contributor.level); - const isNPC = Boolean(message.backer && message.backer.npc); - return isContributor || isNPC; - }, tierIcon () { const message = this.msg; const isNPC = Boolean(message.backer && message.backer.npc); @@ -244,6 +260,10 @@ export default { } this.$emit('message-liked', message); + this.$root.$emit('bv::hide::tooltip'); + }, + likeTooltip (likedStatus) { + if (!likedStatus) return this.$t('like'); }, copyAsTodo (message) { this.$root.$emit('habitica::copy-as-todo', message); @@ -273,6 +293,14 @@ export default { showMemberModal (memberId) { this.$emit('show-member-modal', memberId); }, + atHighlight (text) { + return text.replace(new RegExp(/(?!\b)@[\w-]+/g), match => { + return `${match}`; + }); + }, + parseMarkdown (text) { + return habiticaMarkdown.render(text); + }, }, }; diff --git a/website/client/components/chat/chatMessages.vue b/website/client/components/chat/chatMessages.vue index 22af3fdfd9..b9fe937aa9 100644 --- a/website/client/components/chat/chatMessages.vue +++ b/website/client/components/chat/chatMessages.vue @@ -14,6 +14,7 @@ v-if='msg.userStyles || (cachedProfileData[msg.uuid] && !cachedProfileData[msg.uuid].rejected)', :member="msg.userStyles || cachedProfileData[msg.uuid]", :avatarOnly="true", + :overrideTopPadding='"14px"', :hideClassBadge='true', @click.native="showMemberModal(msg.uuid)", ) @@ -40,6 +41,7 @@ :member="msg.userStyles || cachedProfileData[msg.uuid]", :avatarOnly="true", :hideClassBadge='true', + :overrideTopPadding='"14px"', @click.native="showMemberModal(msg.uuid)", ) @@ -47,6 +49,10 @@ diff --git a/website/client/components/creatorIntro.vue b/website/client/components/creatorIntro.vue index 9f9702f91e..8f33a6601c 100644 --- a/website/client/components/creatorIntro.vue +++ b/website/client/components/creatorIntro.vue @@ -5,7 +5,7 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true h3(v-once) {{$t('welcomeTo')}} .svg-icon.logo(v-html='icons.logoPurple') - .avatar-section.row(:class='{"page-2": modalPage === 2}') + .avatar-section.row(v-if='modalPage > 1', :class='{"page-2": modalPage === 2}') .col-6.offset-3 .user-creation-bg(v-if='!editing') avatar(:member='user', :avatarOnly='!editing', :class='{"edit-avatar": editing}') @@ -187,18 +187,18 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true #extra.section.container.customize-section(v-if='activeTopPage === "extra"') .row.sub-menu .col-3.offset-1.text-center.sub-menu-item(@click='changeSubPage("glasses")', :class='{active: activeSubPage === "glasses"}') - strong(v-once) {{$t('glasses')}} + strong(v-once) {{ $t('glasses') }} .col-4.text-center.sub-menu-item(@click='changeSubPage("wheelchair")', :class='{active: activeSubPage === "wheelchair"}') - strong(v-once) {{$t('wheelchair')}} + strong(v-once) {{ $t('wheelchair') }} .col-3.text-center.sub-menu-item(@click='changeSubPage("flower")', :class='{active: activeSubPage === "flower"}') - strong(v-once) {{$t('accent')}} + strong(v-once) {{ $t('accent') }} .row.sub-menu(v-if='editing') .col-4.text-center.sub-menu-item(@click='changeSubPage("ears")' :class='{active: activeSubPage === "ears"}') - strong(v-once) {{$t('animalEars')}} + strong(v-once) {{ $t('animalEars') }} .col-4.text-center.sub-menu-item(@click='changeSubPage("tails")' :class='{active: activeSubPage === "tails"}') - strong(v-once) {{$t('animalTails')}} + strong(v-once) {{ $t('animalTails') }} .col-4.text-center.sub-menu-item(@click='changeSubPage("headband")' :class='{active: activeSubPage === "headband"}') - strong(v-once) {{$t('headband')}} + strong(v-once) {{ $t('headband') }} #glasses.row(v-if='activeSubPage === "glasses"') .col-12.customize-options .option(v-for='option in eyewear', :class='{active: option.active}') @@ -305,7 +305,7 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true ) span.svg-icon.inline.icon-12.color(v-html="icons.pin") .purchase-background.set(v-if='!ownsSet("background", set.items) && set.identifier !== "incentiveBackgrounds"' @click='unlock(setKeys("background", set.items))') - span.label Purchase Set + span.label {{ $t('purchaseAll') }} .svg-icon.gem(v-html='icons.gem') span.price 15 .row.customize-menu(v-if='filterBackgrounds') @@ -320,7 +320,7 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true .container.interests-section(v-if='modalPage === 3 && !editing') .section.row .col-12.text-center - h2 I want to work on: + h2 {{ $t('wantToWorkOn') }} .section.row .col-6 .task-option @@ -353,28 +353,35 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true input.custom-control-input#self_care(type="checkbox", value='self_care', v-model='taskCategories') label.custom-control-label(v-once, for="self_care") {{ $t('self_care') }} - .section.row.justin-message-section(:class='{top: modalPage > 1}', v-if='!editing') - .col-12 - .justin-message.d-flex.flex-column.justify-content-center - .featured-label - span.rectangle - span.text Justin - span.rectangle - .npc_justin_textbox + .section.d-flex.justify-content-center(:class='{top: modalPage > 1}', v-if='!editing') + .justin-section.d-flex.align-items-center + .featured-label + span.rectangle + span.text Justin + span.rectangle + .justin-message + .corner-decoration(:style="{top: '-2px', right: '-2px'}") + .corner-decoration(:style="{top: '-2px', left: '-2px'}") + .corner-decoration(:style="{bottom: '-2px', right: '-2px'}") + .corner-decoration(:style="{bottom: '-2px', left: '-2px'}") div(v-if='modalPage === 1') - p(v-once) {{$t('justinIntroMessage1')}} - p(v-once) {{$t('justinIntroMessage2')}} + p(v-once, v-html='$t("justinIntroMessage1")') + p(v-once) {{ $t('justinIntroMessageUsername') }} div(v-if='modalPage === 2') - p So how would you like to look? Don’t worry, you can change this later. + p {{ $t('justinIntroMessageAppearance') }} div(v-if='modalPage === 3') - p(v-once) {{$t('justinIntroMessage3')}} + p(v-once) {{ $t('justinIntroMessage3') }} + .npc-justin-textbox + .section.mr-5.ml-5(v-if='modalPage === 1') + username-form(@usernameConfirmed='modalPage += 1', :avatarIntro='true') + .small.text-center(v-html="$t('usernameTOSRequirements')") - .section.container.footer(v-if='!editing') - .row + .section.container.footer + .row(v-if='!editing && !(modalPage === 1)') .col-3.offset-1.text-center div(v-if='modalPage > 1', @click='prev()') .prev-arrow - .prev(v-once) {{$t('prev')}} + .prev(v-once) {{ $t('prev') }} .col-4.text-center.circles .circle(:class="{active: modalPage === 1}") .circle(:class="{active: modalPage === 2}") @@ -390,12 +397,9 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true + + + diff --git a/website/client/components/groups/membersModal.vue b/website/client/components/groups/membersModal.vue index 0e76a99f33..f10c4bc956 100644 --- a/website/client/components/groups/membersModal.vue +++ b/website/client/components/groups/membersModal.vue @@ -355,7 +355,10 @@ export default { sendMessage (member) { this.$root.$emit('habitica::new-inbox-message', { userIdToMessage: member._id, - userName: member.profile.name, + displayName: member.profile.name, + username: member.auth.local.username, + backer: member.backer, + contributor: member.contributor, }); }, async searchMembers (searchTerm = '') { diff --git a/website/client/components/header/index.vue b/website/client/components/header/index.vue index dffdef7046..af6f3ad020 100644 --- a/website/client/components/header/index.vue +++ b/website/client/components/header/index.vue @@ -1,6 +1,6 @@ + + diff --git a/website/client/components/settings/verifyUsername.vue b/website/client/components/settings/verifyUsername.vue new file mode 100644 index 0000000000..b096c5f033 --- /dev/null +++ b/website/client/components/settings/verifyUsername.vue @@ -0,0 +1,84 @@ + + + + + + + diff --git a/website/client/components/snackbars/notifications.vue b/website/client/components/snackbars/notifications.vue index 22b0d78030..af44e65a39 100644 --- a/website/client/components/snackbars/notifications.vue +++ b/website/client/components/snackbars/notifications.vue @@ -9,7 +9,7 @@ position: fixed; right: 10px; width: 350px; - z-index: 1070; // 1070 is above modal backgrounds + z-index: 1400; // 1400 is above modal backgrounds &-top-pos { &-normal { diff --git a/website/client/components/static/home.vue b/website/client/components/static/home.vue index 7050a08e32..a6acf92a55 100644 --- a/website/client/components/static/home.vue +++ b/website/client/components/static/home.vue @@ -560,6 +560,7 @@ diff --git a/website/client/components/userMenu/profile.vue b/website/client/components/userMenu/profile.vue index f3d1e65ea6..9d4c61544c 100644 --- a/website/client/components/userMenu/profile.vue +++ b/website/client/components/userMenu/profile.vue @@ -40,11 +40,12 @@ div #userProfile.standard-page(v-show='selectedPage === "profile"', v-if='user.profile') .row .col-12.col-md-8 - .header + .header.mb-3 h1 {{user.profile.name}} - h4 - strong {{ $t('userId') }}:  - | {{user._id}} + div + .name(v-if='user.auth && user.auth.local && user.auth.local.username') @{{ user.auth.local.username }} + div + .name {{ user._id }} .col-12.col-md-4 button.btn.btn-secondary(v-if='user._id === userLoggedIn._id', @click='editing = !editing') {{ $t('edit') }} .row(v-if='!editing') @@ -146,7 +147,7 @@ div #profile { .member-details { .character-name, small, .small-text { - color: #878190 + color: #878190; } } @@ -193,7 +194,7 @@ div .gift-icon { width: 14px; margin: auto; - color: #686274; + color: $gray-100; } .gift-icon { @@ -201,13 +202,13 @@ div } .remove-icon { - width:16px; - color: #686274; + width: 16px; + color: $gray-100; } .positive-icon { width: 14px; - color: #686274; + color: $gray-100; } .photo img { @@ -216,11 +217,12 @@ div .header { h1 { - color: #4f2a93; + color: $purple-200; + margin-bottom: 0rem; } h4 { - color: #686274; + color: $gray-100; } } @@ -241,6 +243,11 @@ div cursor: pointer; } + .name { + color: $gray-200; + font-size: 16px; + } + #achievements { .box { margin: 0 auto; @@ -463,7 +470,10 @@ export default { sendMessage () { this.$root.$emit('habitica::new-inbox-message', { userIdToMessage: this.user._id, - userName: this.user.profile.name, + displayName: this.user.profile.name, + username: this.user.auth.local.username, + backer: this.user.backer, + contributor: this.user.contributor, }); }, getProgressDisplay () { diff --git a/website/client/store/actions/auth.js b/website/client/store/actions/auth.js index 968c92766f..fad46d295f 100644 --- a/website/client/store/actions/auth.js +++ b/website/client/store/actions/auth.js @@ -55,6 +55,15 @@ export async function verifyUsername (store, params) { return result.data.data; } +export async function verifyDisplayName (store, params) { + let url = '/api/v4/user/auth/verify-display-name'; + let result = await axios.post(url, { + displayName: params.displayName, + }); + + return result.data.data; +} + export async function socialAuth (store, params) { let url = '/api/v4/user/auth/social'; let result = await axios.post(url, { diff --git a/website/client/store/actions/guilds.js b/website/client/store/actions/guilds.js index 3f2599850d..ea4e680440 100644 --- a/website/client/store/actions/guilds.js +++ b/website/client/store/actions/guilds.js @@ -164,6 +164,7 @@ export async function invite (store, payload) { let response = await axios.post(`/api/v4/groups/${payload.groupId}/invite`, { uuids: payload.invitationDetails.uuids, emails: payload.invitationDetails.emails, + usernames: payload.invitationDetails.usernames, }); // @TODO: find guild and add invites diff --git a/website/client/store/actions/user.js b/website/client/store/actions/user.js index 9ae2ef3f3c..d15bd3e805 100644 --- a/website/client/store/actions/user.js +++ b/website/client/store/actions/user.js @@ -151,3 +151,14 @@ export async function togglePrivateMessagesOpt (store) { store.state.user.data.inbox.optOut = !store.state.user.data.inbox.optOut; return response; } + +export async function userLookup (store, params) { + let response; + if (params.uuid) { + response = await axios.get(`/api/v4/members/${params.uuid}`); + } + if (params.username) { + response = await axios.get(`/api/v4/members/username/${params.username}`); + } + return response; +} diff --git a/website/common/locales/en/character.json b/website/common/locales/en/character.json index 75b255069b..919594aa21 100644 --- a/website/common/locales/en/character.json +++ b/website/common/locales/en/character.json @@ -7,7 +7,7 @@ "noPhoto": "This Habitican hasn't added a photo.", "other": "Other", "fullName": "Full Name", - "displayName": "Display Name", + "displayName": "Display name", "changeDisplayName": "Change Display Name", "newDisplayName": "New Display Name", "displayPhoto": "Photo", diff --git a/website/common/locales/en/front.json b/website/common/locales/en/front.json index 056f5754fc..35b55ea0f8 100644 --- a/website/common/locales/en/front.json +++ b/website/common/locales/en/front.json @@ -271,15 +271,9 @@ "emailTaken": "Email address is already used in an account.", "newEmailRequired": "Missing new email address.", "usernameTime": "It's time to set your username!", - "usernameInfo": "Your display name hasn't changed, but your old login name will now become your public username. This username will be used for invitations, @mentions in chat, and messaging.

If you'd like to learn more about this change, visit the wiki's Player Names page.", - "usernameTOSRequirements": "Usernames must conform to our Terms of Service and Community Guidelines. If you didn’t previously set a login name, your username was auto-generated.", + "usernameInfo": "Login names are now unique usernames that will be visible beside your display name and used for invitations, chat @mentions, and messaging.

If you'd like to learn more about this change, visit our wiki.", + "usernameTOSRequirements": "Usernames must conform to our Terms of Service and Community Guidelines. If you didn’t previously set a login name, your username was auto-generated.", "usernameTaken": "Username already taken.", - "usernameWrongLength": "Username must be between 1 and 20 characters long.", - "displayNameWrongLength": "Display names must be between 1 and 30 characters long.", - "usernameBadCharacters": "Usernames can only contain letters a to z, numbers 0 to 9, hyphens, or underscores.", - "nameBadWords": "Names cannot include any inappropriate words.", - "confirmUsername": "Confirm Username", - "usernameConfirmed": "Username Confirmed", "passwordConfirmationMatch": "Password confirmation doesn't match password.", "invalidLoginCredentials": "Incorrect username and/or email and/or password.", "passwordResetPage": "Reset Password", @@ -335,7 +329,7 @@ "joinToday": "Join Habitica Today", "featuredIn": "Featured in", "signup": "Sign Up", - "getStarted": "Get Started", + "getStarted": "Get Started!", "mobileApps": "Mobile Apps", "learnMore": "Learn More" } diff --git a/website/common/locales/en/generic.json b/website/common/locales/en/generic.json index 202b0e0ba6..4e967ee950 100644 --- a/website/common/locales/en/generic.json +++ b/website/common/locales/en/generic.json @@ -254,6 +254,7 @@ "resetFilters": "Clear all filters", "applyFilters": "Apply Filters", + "wantToWorkOn": "I want to work on:", "categories": "Categories", "habiticaOfficial": "Habitica Official", "animals": "Animals", diff --git a/website/common/locales/en/groups.json b/website/common/locales/en/groups.json index 3ee4429947..876b4e1886 100644 --- a/website/common/locales/en/groups.json +++ b/website/common/locales/en/groups.json @@ -185,7 +185,7 @@ "inviteExistUser": "Invite Existing Users", "byColon": "By:", "inviteNewUsers": "Invite New Users", - "sendInvitations": "Send Invitations", + "sendInvitations": "Send Invites", "invitationsSent": "Invitations sent!", "invitationSent": "Invitation sent!", "invitedFriend": "Invited a Friend", @@ -229,7 +229,7 @@ "memberCannotRemoveYourself": "You cannot remove yourself!", "groupMemberNotFound": "User not found among group's members", "mustBeGroupMember": "Must be member of the group.", - "canOnlyInviteEmailUuid": "Can only invite using uuids or emails.", + "canOnlyInviteEmailUuid": "Can only invite using user IDs, emails, or usernames.", "inviteMissingEmail": "Missing email address in invite.", "inviteMissingUuid": "Missing user id in invite", "inviteMustNotBeEmpty": "Invite must not be empty.", @@ -241,9 +241,11 @@ "userAlreadyPendingInvitation": "UserID: <%= userId %>, User \"<%= username %>\" already pending invitation.", "userAlreadyInAParty": "UserID: <%= userId %>, User \"<%= username %>\" already in a party. ", "userWithIDNotFound": "User with id \"<%= userId %>\" not found.", + "userWithUsernameNotFound": "User with username \"<%= username %>\" not found.", "userHasNoLocalRegistration": "User does not have a local registration (username, email, password).", "uuidsMustBeAnArray": "User ID invites must be an array.", "emailsMustBeAnArray": "Email address invites must be an array.", + "usernamesMustBeAnArray": "Username invites must be an array.", "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time", "partyExceedsMembersLimit": "Party size is limited to <%= maxMembersParty %> members", "onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!", @@ -366,6 +368,10 @@ "liked": "Liked", "joinGuild": "Join Guild", "inviteToGuild": "Invite to Guild", + "inviteToParty": "Invite to Party", + "inviteEmailUsername": "Invite via Email or Username", + "inviteEmailUsernameInfo": "Invite users via a valid email or username. If an email isn't registered yet, we'll invite them to join.", + "emailOrUsernameInvite": "Email address or username", "messageGuildLeader": "Message Guild Leader", "donateGems": "Donate Gems", "updateGuild": "Update Guild", diff --git a/website/common/locales/en/messages.json b/website/common/locales/en/messages.json index 2ddeef84fc..0a0e9b0c15 100644 --- a/website/common/locales/en/messages.json +++ b/website/common/locales/en/messages.json @@ -70,5 +70,7 @@ "beginningOfConversation": "This is the beginning of your conversation with <%= userName %>. Remember to be kind, respectful, and follow the Community Guidelines!", - "messageDeletedUser": "Sorry, this user has deleted their account." + "messageDeletedUser": "Sorry, this user has deleted their account.", + + "messageMissingDisplayName": "Missing display name." } diff --git a/website/common/locales/en/npc.json b/website/common/locales/en/npc.json index 5c404f64d4..f9e862ccd3 100644 --- a/website/common/locales/en/npc.json +++ b/website/common/locales/en/npc.json @@ -6,9 +6,11 @@ "welcomeTo": "Welcome to", "welcomeBack": "Welcome back!", "justin": "Justin", - "justinIntroMessage1": "Hello there! You must be new here. My name is Justin, your guide to Habitica.", + "justinIntroMessage1": "Hello there! You must be new here. My name is Justin, and I'll be your guide in Habitica.", "justinIntroMessage2": "To start, you'll need to create an avatar.", "justinIntroMessage3": "Great! Now, what are you interested in working on throughout this journey?", + "justinIntroMessageUsername": "Before we begin, let’s figure out what to call you. Below you’ll find a display name and username I’ve generated for you. After you’ve picked a display name and username, we’ll get started by creating an avatar!", + "justinIntroMessageAppearance": "So how would you like to look? Don’t worry, you can change this later.", "introTour": "Here we are! I've filled out some Tasks for you based on your interests, so you can get started right away. Click a Task to edit or add new Tasks to fit your routine!", "prev": "Prev", "next": "Next", diff --git a/website/common/locales/en/settings.json b/website/common/locales/en/settings.json index 32192d85e6..3ee48d25de 100644 --- a/website/common/locales/en/settings.json +++ b/website/common/locales/en/settings.json @@ -201,9 +201,10 @@ "usernameIssueInvalidCharacters": "Usernames can only contain letters a to z, numbers 0 to 9, hyphens, or underscores.", "currentUsername": "Current username:", "displaynameIssueLength": "Display Names must be between 1 and 30 characters.", - "displaynameIssueSlur": "Display Names may not contain inappropriate language", + "displaynameIssueSlur": "Display Names may not contain inappropriate language.", "goToSettings": "Go to Settings", "usernameVerifiedConfirmation": "Your username, <%= username %>, is confirmed!", "usernameNotVerified": "Please confirm your username.", - "changeUsernameDisclaimer": "We will be transitioning login names to unique, public usernames soon. This username will be used for invitations, @mentions in chat, and messaging." + "changeUsernameDisclaimer": "We will be transitioning login names to unique, public usernames soon. This username will be used for invitations, @mentions in chat, and messaging.", + "verifyUsernameVeteranPet": "One of these Veteran Pets will be waiting for you after you've finished confirming!" } diff --git a/website/common/locales/en/subscriber.json b/website/common/locales/en/subscriber.json index 046341dd3b..973ba65172 100644 --- a/website/common/locales/en/subscriber.json +++ b/website/common/locales/en/subscriber.json @@ -210,7 +210,7 @@ "haveCouponCode": "Do you have a coupon code?", "subscriptionAlreadySubscribedLeadIn": "Thanks for subscribing!", "subscriptionAlreadySubscribed1": "To see your subscription details and cancel, renew, or change your subscription, please go to User icon > Settings > Subscription.", - "purchaseAll": "Purchase All", + "purchaseAll": "Purchase Set", "gemsPurchaseNote": "Subscribers can buy gems for gold in the Market! For easy access, you can also pin the gem to your Rewards column.", "gemsRemaining": "gems remaining", "notEnoughGemsToBuy": "You are unable to buy that amount of gems" diff --git a/website/raw_sprites/spritesmith_large/scene_veteran_pets.png b/website/raw_sprites/spritesmith_large/scene_veteran_pets.png new file mode 100644 index 0000000000..60512eedc0 Binary files /dev/null and b/website/raw_sprites/spritesmith_large/scene_veteran_pets.png differ diff --git a/website/server/controllers/api-v3/groups.js b/website/server/controllers/api-v3/groups.js index cf8bc4a034..e17362c0f6 100644 --- a/website/server/controllers/api-v3/groups.js +++ b/website/server/controllers/api-v3/groups.js @@ -9,7 +9,6 @@ import { model as User, nameFields, } from '../../models/user'; -import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; import { NotFound, BadRequest, @@ -17,8 +16,11 @@ import { } from '../../libs/errors'; import { removeFromArray } from '../../libs/collectionManipulators'; import { sendTxn as sendTxnEmail } from '../../libs/email'; -import { encrypt } from '../../libs/encryption'; -import { sendNotification as sendPushNotification } from '../../libs/pushNotifications'; +import { + inviteByUUID, + inviteByEmail, + inviteByUserName, +} from '../../libs/invites'; import common from '../../../common'; import payments from '../../libs/payments/payments'; import stripePayments from '../../libs/payments/stripe'; @@ -919,148 +921,6 @@ api.removeGroupMember = { }, }; -async function _inviteByUUID (uuid, group, inviter, req, res) { - let userToInvite = await User.findById(uuid).exec(); - const publicGuild = group.type === 'guild' && group.privacy === 'public'; - - if (!userToInvite) { - throw new NotFound(res.t('userWithIDNotFound', {userId: uuid})); - } else if (inviter._id === userToInvite._id) { - throw new BadRequest(res.t('cannotInviteSelfToGroup')); - } - - const objections = inviter.getObjectionsToInteraction('group-invitation', userToInvite); - if (objections.length > 0) { - throw new NotAuthorized(res.t(objections[0], { userId: uuid, username: userToInvite.profile.name})); - } - - if (group.type === 'guild') { - 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})) { - throw new NotAuthorized(res.t('userAlreadyInvitedToGroup', { userId: uuid, username: userToInvite.profile.name})); - } - - let guildInvite = { - id: group._id, - name: group.name, - inviter: inviter._id, - publicGuild, - }; - if (group.isSubscribed() && !group.hasNotCancelled()) guildInvite.cancelledPlan = true; - userToInvite.invitations.guilds.push(guildInvite); - } else if (group.type === 'party') { - // Do not add to invitations.parties array if the user is already invited to that party - if (_.find(userToInvite.invitations.parties, {id: group._id})) { - throw new NotAuthorized(res.t('userAlreadyPendingInvitation', { userId: uuid, username: userToInvite.profile.name})); - } - - if (userToInvite.party._id) { - let userParty = await Group.getGroup({user: userToInvite, groupId: 'party', fields: 'memberCount'}); - - // Allow user to be invited to a new party when they're partying solo - if (userParty && userParty.memberCount !== 1) throw new NotAuthorized(res.t('userAlreadyInAParty', { userId: uuid, username: userToInvite.profile.name})); - } - - let partyInvite = {id: group._id, name: group.name, inviter: inviter._id}; - if (group.isSubscribed() && !group.hasNotCancelled()) partyInvite.cancelledPlan = true; - - userToInvite.invitations.parties.push(partyInvite); - userToInvite.invitations.party = partyInvite; - } - - let groupLabel = group.type === 'guild' ? 'Guild' : 'Party'; - let groupTemplate = group.type === 'guild' ? 'guild' : 'party'; - if (userToInvite.preferences.emailNotifications[`invited${groupLabel}`] !== false) { - let emailVars = [ - {name: 'INVITER', content: inviter.profile.name}, - ]; - - if (group.type === 'guild') { - emailVars.push( - {name: 'GUILD_NAME', content: group.name}, - {name: 'GUILD_URL', content: '/groups/discovery'} - ); - } else { - emailVars.push( - {name: 'PARTY_NAME', content: group.name}, - {name: 'PARTY_URL', content: '/party'} - ); - } - - sendTxnEmail(userToInvite, `invited-${groupTemplate}`, emailVars); - } - - if (userToInvite.preferences.pushNotifications[`invited${groupLabel}`] !== false) { - let identifier = group.type === 'guild' ? 'invitedGuild' : 'invitedParty'; - sendPushNotification( - userToInvite, - { - title: group.name, - message: res.t(identifier), - identifier, - payload: {groupID: group._id, publicGuild}, - } - ); - } - - let userInvited = await userToInvite.save(); - if (group.type === 'guild') { - return userInvited.invitations.guilds[userToInvite.invitations.guilds.length - 1]; - } else if (group.type === 'party') { - return userInvited.invitations.parties[userToInvite.invitations.parties.length - 1]; - } -} - -async function _inviteByEmail (invite, group, inviter, req, res) { - let userReturnInfo; - - if (!invite.email) throw new BadRequest(res.t('inviteMissingEmail')); - - let userToContact = await User.findOne({$or: [ - {'auth.local.email': invite.email}, - {'auth.facebook.emails.value': invite.email}, - {'auth.google.emails.value': invite.email}, - ]}) - .select({_id: true, 'preferences.emailNotifications': true}) - .exec(); - - if (userToContact) { - userReturnInfo = await _inviteByUUID(userToContact._id, group, inviter, req, res); - } else { - userReturnInfo = invite.email; - - let cancelledPlan = false; - if (group.isSubscribed() && !group.hasNotCancelled()) cancelledPlan = true; - - const groupQueryString = JSON.stringify({ - id: group._id, - inviter: inviter._id, - publicGuild: group.type === 'guild' && group.privacy === 'public', - sentAt: Date.now(), // so we can let it expire - cancelledPlan, - }); - let link = `/static/front?groupInvite=${encrypt(groupQueryString)}`; - - let variables = [ - {name: 'LINK', content: link}, - {name: 'INVITER', content: req.body.inviter || inviter.profile.name}, - ]; - - if (group.type === 'guild') { - variables.push({name: 'GUILD_NAME', content: group.name}); - } - - // Check for the email address not to be unsubscribed - let userIsUnsubscribed = await EmailUnsubscription.findOne({email: invite.email}).exec(); - let groupLabel = group.type === 'guild' ? '-guild' : ''; - if (!userIsUnsubscribed) sendTxnEmail(invite, `invite-friend${groupLabel}`, variables); - } - - return userReturnInfo; -} - /** * @api {post} /api/v3/groups/:groupId/invite Invite users to a group * @apiName InviteToGroup @@ -1147,7 +1007,7 @@ api.inviteToGroup = { url: '/groups/:groupId/invite', middlewares: [authWithHeaders()], async handler (req, res) { - let user = res.locals.user; + const user = res.locals.user; if (user.flags.chatRevoked) throw new NotAuthorized(res.t('cannotInviteWhenMuted')); @@ -1155,35 +1015,48 @@ api.inviteToGroup = { if (user.invitesSent >= MAX_EMAIL_INVITES_BY_USER) throw new NotAuthorized(res.t('inviteLimitReached', { techAssistanceEmail: TECH_ASSISTANCE_EMAIL })); - let validationErrors = req.validationErrors(); + const validationErrors = req.validationErrors(); if (validationErrors) throw validationErrors; - let group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); + const group = await Group.getGroup({user, groupId: req.params.groupId, fields: '-chat'}); if (!group) throw new NotFound(res.t('groupNotFound')); if (group.purchased && group.purchased.plan.customerId && user._id !== group.leader) throw new NotAuthorized(res.t('onlyGroupLeaderCanInviteToGroupPlan')); - let uuids = req.body.uuids; - let emails = req.body.emails; + const { + uuids, + emails, + usernames, + } = req.body; - await Group.validateInvitations(uuids, emails, res, group); + await Group.validateInvitations({ + uuids, + emails, + usernames, + }, res, group); - let results = []; + const results = []; if (uuids) { - let uuidInvites = uuids.map((uuid) => _inviteByUUID(uuid, group, user, req, res)); - let uuidResults = await Promise.all(uuidInvites); + const uuidInvites = uuids.map((uuid) => inviteByUUID(uuid, group, user, req, res)); + const uuidResults = await Promise.all(uuidInvites); results.push(...uuidResults); } if (emails) { - let emailInvites = emails.map((invite) => _inviteByEmail(invite, group, user, req, res)); + const emailInvites = emails.map((invite) => inviteByEmail(invite, group, user, req, res)); user.invitesSent += emails.length; await user.save(); - let emailResults = await Promise.all(emailInvites); + const emailResults = await Promise.all(emailInvites); results.push(...emailResults); } + if (usernames) { + const usernameInvites = usernames.map((username) => inviteByUserName(username, group, user, req, res)); + const usernameResults = await Promise.all(usernameInvites); + results.push(...usernameResults); + } + let analyticsObject = { uuid: user._id, hitType: 'event', diff --git a/website/server/controllers/api-v3/members.js b/website/server/controllers/api-v3/members.js index 4e77b51636..1723c925b2 100644 --- a/website/server/controllers/api-v3/members.js +++ b/website/server/controllers/api-v3/members.js @@ -109,6 +109,36 @@ api.getMember = { if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId})); + if (!member.flags.verifiedUsername) member.auth.local.username = null; + + // manually call toJSON with minimize: true so empty paths aren't returned + let memberToJSON = member.toJSON({minimize: true}); + User.addComputedStatsToJSONObj(memberToJSON.stats, member); + + res.respond(200, memberToJSON); + }, +}; + +api.getMemberByUsername = { + method: 'GET', + url: '/members/username/:username', + middlewares: [], + async handler (req, res) { + req.checkParams('username', res.t('invalidReqParams')).notEmpty(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let username = req.params.username.toLowerCase(); + if (username[0] === '@') username = username.slice(1, username.length); + + let member = await User + .findOne({'auth.local.lowerCaseUsername': username, 'flags.verifiedUsername': true}) + .select(memberFields) + .exec(); + + if (!member) throw new NotFound(res.t('userNotFound')); + // manually call toJSON with minimize: true so empty paths aren't returned let memberToJSON = member.toJSON({minimize: true}); User.addComputedStatsToJSONObj(memberToJSON.stats, member); @@ -605,6 +635,7 @@ api.sendPrivateMessage = { const message = req.body.message; const receiver = await User.findById(req.body.toUserId).exec(); if (!receiver) throw new NotFound(res.t('userNotFound')); + if (!receiver.flags.verifiedUsername) delete receiver.auth.local.username; const objections = sender.getObjectionsToInteraction('send-private-message', receiver); if (objections.length > 0 && !sender.isAdmin()) throw new NotAuthorized(res.t(objections[0])); diff --git a/website/server/controllers/api-v4/auth.js b/website/server/controllers/api-v4/auth.js index 11ec5f6f3f..05f8b6dd9b 100644 --- a/website/server/controllers/api-v4/auth.js +++ b/website/server/controllers/api-v4/auth.js @@ -28,12 +28,14 @@ api.verifyUsername = { const issues = verifyUsername(chosenUsername, res); - const existingUser = await User.findOne({ - 'auth.local.lowerCaseUsername': chosenUsername.toLowerCase(), - }, {auth: 1}).exec(); + if (issues.length < 1) { + const existingUser = await User.findOne({ + 'auth.local.lowerCaseUsername': chosenUsername.toLowerCase(), + }, {auth: 1}).exec(); - if (existingUser) { - if (!user || existingUser._id !== user._id) issues.push(res.t('usernameTaken')); + if (existingUser) { + if (!user || existingUser._id !== user._id) issues.push(res.t('usernameTaken')); + } } if (issues.length > 0) { diff --git a/website/server/controllers/api-v4/user.js b/website/server/controllers/api-v4/user.js index 77183bdf98..2474c31dc0 100644 --- a/website/server/controllers/api-v4/user.js +++ b/website/server/controllers/api-v4/user.js @@ -1,5 +1,6 @@ import { authWithHeaders } from '../../middlewares/auth'; import * as userLib from '../../libs/user'; +import { verifyDisplayName } from '../../libs/user/validation'; const api = {}; @@ -206,4 +207,32 @@ api.userReset = { }, }; +api.verifyDisplayName = { + method: 'POST', + url: '/user/auth/verify-display-name', + middlewares: [authWithHeaders({ + optional: true, + })], + async handler (req, res) { + req.checkBody({ + displayName: { + notEmpty: {errorMessage: res.t('messageMissingDisplayName')}, + }, + }); + + const validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + const chosenDisplayName = req.body.displayName; + + const issues = verifyDisplayName(chosenDisplayName, res); + + if (issues.length > 0) { + res.respond(200, { isUsable: false, issues }); + } else { + res.respond(200, { isUsable: true }); + } + }, +}; + module.exports = api; diff --git a/website/server/libs/auth/index.js b/website/server/libs/auth/index.js index f783c59b40..1cdcf3fd29 100644 --- a/website/server/libs/auth/index.js +++ b/website/server/libs/auth/index.js @@ -79,8 +79,8 @@ async function registerLocal (req, res, { isV3 = false }) { notEmpty: true, errorMessage: res.t('missingUsername'), // TODO use the constants in the error message above - isLength: {options: {min: USERNAME_LENGTH_MIN, max: USERNAME_LENGTH_MAX}, errorMessage: res.t('usernameWrongLength')}, - matches: {options: /^[-_a-zA-Z0-9]+$/, errorMessage: res.t('usernameBadCharacters')}, + isLength: {options: {min: USERNAME_LENGTH_MIN, max: USERNAME_LENGTH_MAX}, errorMessage: res.t('usernameIssueLength')}, + matches: {options: /^[-_a-zA-Z0-9]+$/, errorMessage: res.t('usernameIssueInvalidCharacters')}, }, email: { notEmpty: true, @@ -138,6 +138,9 @@ async function registerLocal (req, res, { isV3 = false }) { preferences: { language: req.language, }, + flags: { + verifiedUsername: true, + }, }; if (existingUser) { @@ -159,8 +162,6 @@ async function registerLocal (req, res, { isV3 = false }) { await _handleGroupInvitation(newUser, req.query.groupInvite || req.query.partyInvite); } - newUser.flags.verifiedUsername = true; - let savedUser = await newUser.save(); let userToJSON; diff --git a/website/server/libs/auth/social.js b/website/server/libs/auth/social.js index 63edb96085..882554818f 100644 --- a/website/server/libs/auth/social.js +++ b/website/server/libs/auth/social.js @@ -61,6 +61,9 @@ async function loginSocial (req, res) { preferences: { language: req.language, }, + flags: { + verifiedUsername: true, + }, }; if (existingUser) { diff --git a/website/server/libs/invites/index.js b/website/server/libs/invites/index.js new file mode 100644 index 0000000000..d5225338ef --- /dev/null +++ b/website/server/libs/invites/index.js @@ -0,0 +1,211 @@ +import _ from 'lodash'; + +import { encrypt } from '../encryption'; +import { sendNotification as sendPushNotification } from '../pushNotifications'; +import { + NotFound, + BadRequest, + NotAuthorized, +} from '../errors'; +import { sendTxn as sendTxnEmail } from '../email'; +import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; +import { + model as User, +} from '../../models/user'; +import { + model as Group, +} from '../../models/group'; + +function sendInvitePushNotification (userToInvite, groupLabel, group, publicGuild, res) { + if (userToInvite.preferences.pushNotifications[`invited${groupLabel}`] === false) return; + + const identifier = group.type === 'guild' ? 'invitedGuild' : 'invitedParty'; + + sendPushNotification( + userToInvite, + { + title: group.name, + message: res.t(identifier), + identifier, + payload: {groupID: group._id, publicGuild}, + } + ); +} + +function sendInviteEmail (userToInvite, groupLabel, group, inviter) { + if (userToInvite.preferences.emailNotifications[`invited${groupLabel}`] === false) return; + const groupTemplate = group.type === 'guild' ? 'guild' : 'party'; + + const emailVars = [ + {name: 'INVITER', content: inviter.profile.name}, + ]; + + if (group.type === 'guild') { + emailVars.push( + {name: 'GUILD_NAME', content: group.name}, + {name: 'GUILD_URL', content: '/groups/discovery'} + ); + } else { + emailVars.push( + {name: 'PARTY_NAME', content: group.name}, + {name: 'PARTY_URL', content: '/party'} + ); + } + + sendTxnEmail(userToInvite, `invited-${groupTemplate}`, emailVars); +} + +function inviteUserToGuild (userToInvite, group, inviter, publicGuild, res) { + const uuid = userToInvite._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})) { + throw new NotAuthorized(res.t('userAlreadyInvitedToGroup', { userId: uuid, username: userToInvite.profile.name})); + } + + const guildInvite = { + id: group._id, + name: group.name, + inviter: inviter._id, + publicGuild, + }; + + if (group.isSubscribed() && !group.hasNotCancelled()) guildInvite.cancelledPlan = true; + + userToInvite.invitations.guilds.push(guildInvite); +} + +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})) { + throw new NotAuthorized(res.t('userAlreadyPendingInvitation', { userId: uuid, username: userToInvite.profile.name})); + } + + if (userToInvite.party._id) { + let userParty = await Group.getGroup({user: userToInvite, groupId: 'party', fields: 'memberCount'}); + + // Allow user to be invited to a new party when they're partying solo + if (userParty && userParty.memberCount !== 1) throw new NotAuthorized(res.t('userAlreadyInAParty', { userId: uuid, username: userToInvite.profile.name})); + } + + let partyInvite = {id: group._id, name: group.name, inviter: inviter._id}; + if (group.isSubscribed() && !group.hasNotCancelled()) partyInvite.cancelledPlan = true; + + userToInvite.invitations.parties.push(partyInvite); + userToInvite.invitations.party = partyInvite; +} + +async function addInvitationToUser (userToInvite, group, inviter, res) { + const publicGuild = group.type === 'guild' && group.privacy === 'public'; + + if (group.type === 'guild') { + inviteUserToGuild(userToInvite, group, inviter, publicGuild, res); + } else if (group.type === 'party') { + await inviteUserToParty(userToInvite, group, inviter, res); + } + + const groupLabel = group.type === 'guild' ? 'Guild' : 'Party'; + sendInviteEmail(userToInvite, groupLabel, group, inviter); + sendInvitePushNotification(userToInvite, groupLabel, group, publicGuild, res); + + const userInvited = await userToInvite.save(); + if (group.type === 'guild') { + return userInvited.invitations.guilds[userToInvite.invitations.guilds.length - 1]; + } + + if (group.type === 'party') { + return userInvited.invitations.parties[userToInvite.invitations.parties.length - 1]; + } +} + +async function inviteByUUID (uuid, group, inviter, req, res) { + const userToInvite = await User.findById(uuid).exec(); + + if (!userToInvite) { + throw new NotFound(res.t('userWithIDNotFound', {userId: uuid})); + } else if (inviter._id === userToInvite._id) { + throw new BadRequest(res.t('cannotInviteSelfToGroup')); + } + + const objections = inviter.getObjectionsToInteraction('group-invitation', userToInvite); + if (objections.length > 0) { + throw new NotAuthorized(res.t(objections[0], { userId: uuid, username: userToInvite.profile.name})); + } + + return await addInvitationToUser(userToInvite, group, inviter, res); +} + +async function inviteByEmail (invite, group, inviter, req, res) { + let userReturnInfo; + + if (!invite.email) throw new BadRequest(res.t('inviteMissingEmail')); + + let userToContact = await User.findOne({$or: [ + {'auth.local.email': invite.email}, + {'auth.facebook.emails.value': invite.email}, + {'auth.google.emails.value': invite.email}, + ]}) + .select({_id: true, 'preferences.emailNotifications': true}) + .exec(); + + if (userToContact) { + userReturnInfo = await inviteByUUID(userToContact._id, group, inviter, req, res); + } else { + userReturnInfo = invite.email; + + let cancelledPlan = false; + if (group.isSubscribed() && !group.hasNotCancelled()) cancelledPlan = true; + + const groupQueryString = JSON.stringify({ + id: group._id, + inviter: inviter._id, + publicGuild: group.type === 'guild' && group.privacy === 'public', + sentAt: Date.now(), // so we can let it expire + cancelledPlan, + }); + let link = `/static/front?groupInvite=${encrypt(groupQueryString)}`; + + let variables = [ + {name: 'LINK', content: link}, + {name: 'INVITER', content: req.body.inviter || inviter.profile.name}, + ]; + + if (group.type === 'guild') { + variables.push({name: 'GUILD_NAME', content: group.name}); + } + + // Check for the email address not to be unsubscribed + let userIsUnsubscribed = await EmailUnsubscription.findOne({email: invite.email}).exec(); + let groupLabel = group.type === 'guild' ? '-guild' : ''; + if (!userIsUnsubscribed) sendTxnEmail(invite, `invite-friend${groupLabel}`, variables); + } + + return userReturnInfo; +} + +async function inviteByUserName (username, group, inviter, req, res) { + if (username.indexOf('@') === 0) username = username.slice(1, username.length); + username = username.toLowerCase(); + const userToInvite = await User.findOne({'auth.local.lowerCaseUsername': username}).exec(); + + if (!userToInvite) { + throw new NotFound(res.t('userWithUsernameNotFound', { username })); + } + + if (inviter._id === userToInvite._id) { + throw new BadRequest(res.t('cannotInviteSelfToGroup')); + } + + return await addInvitationToUser(userToInvite, group, inviter, res); +} + +module.exports = { + inviteByUUID, + inviteByEmail, + inviteByUserName, +}; diff --git a/website/server/libs/user/validation.js b/website/server/libs/user/validation.js index a56446bc91..097ef70cdf 100644 --- a/website/server/libs/user/validation.js +++ b/website/server/libs/user/validation.js @@ -26,6 +26,14 @@ function usernameContainsInvalidCharacters (username) { return match !== null && match[0] !== null; } +export function verifyDisplayName (displayName, res) { + let issues = []; + if (displayName.length < 1 || displayName.length > 30) issues.push(res.t('displaynameIssueLength')); + if (nameContainsSlur(displayName)) issues.push(res.t('displaynameIssueSlur')); + + return issues; +} + export function verifyUsername (username, res) { let issues = []; if (username.length < 1 || username.length > 20) issues.push(res.t('usernameIssueLength')); diff --git a/website/server/models/group.js b/website/server/models/group.js index 1064213db0..8876e03db1 100644 --- a/website/server/models/group.js +++ b/website/server/models/group.js @@ -350,40 +350,32 @@ schema.statics.toJSONCleanChat = async function groupToJSONCleanChat (group, use return toJSON; }; -/** - * Checks invitation uuids and emails for possible errors. - * - * @param uuids An array of user ids - * @param emails An array of emails - * @param res Express res object for use with translations - * @throws BadRequest An error describing the issue with the invitations - */ -schema.statics.validateInvitations = async function getInvitationError (uuids, emails, res, group = null) { - let uuidsIsArray = Array.isArray(uuids); - let emailsIsArray = Array.isArray(emails); - let emptyEmails = emailsIsArray && emails.length < 1; - let emptyUuids = uuidsIsArray && uuids.length < 1; +function getInviteError (uuids, emails, usernames) { + const uuidsIsArray = Array.isArray(uuids); + const emailsIsArray = Array.isArray(emails); + const usernamesIsArray = Array.isArray(usernames); + const emptyEmails = emailsIsArray && emails.length < 1; + const emptyUuids = uuidsIsArray && uuids.length < 1; + const emptyUsernames = usernamesIsArray && usernames.length < 1; let errorString; - if (!uuids && !emails) { + if (!uuids && !emails && !usernames) { errorString = 'canOnlyInviteEmailUuid'; } else if (uuids && !uuidsIsArray) { errorString = 'uuidsMustBeAnArray'; } else if (emails && !emailsIsArray) { errorString = 'emailsMustBeAnArray'; - } else if (!emails && emptyUuids) { - errorString = 'inviteMissingUuid'; - } else if (!uuids && emptyEmails) { - errorString = 'inviteMissingEmail'; - } else if (emptyEmails && emptyUuids) { + } else if (usernames && !usernamesIsArray) { + errorString = 'usernamesMustBeAnArray'; + } else if ((!emails || emptyEmails) && (!uuids || emptyUuids) && (!usernames || emptyUsernames)) { errorString = 'inviteMustNotBeEmpty'; } - if (errorString) { - throw new BadRequest(res.t(errorString)); - } + return errorString; +} +function getInviteCount (uuids, emails) { let totalInvites = 0; if (uuids) { @@ -394,6 +386,27 @@ schema.statics.validateInvitations = async function getInvitationError (uuids, e totalInvites += emails.length; } + return totalInvites; +} + +/** + * Checks invitation uuids and emails for possible errors. + * + * @param uuids An array of user ids + * @param emails An array of emails + * @param res Express res object for use with translations + * @throws BadRequest An error describing the issue with the invitations + */ +schema.statics.validateInvitations = async function getInvitationError (invites, res, group = null) { + const { + uuids, + emails, + usernames, + } = invites; + const errorString = getInviteError(uuids, emails, usernames); + if (errorString) throw new BadRequest(res.t(errorString)); + + const totalInvites = getInviteCount(uuids, emails); if (totalInvites > INVITES_LIMIT) { throw new BadRequest(res.t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT})); } diff --git a/website/server/models/message.js b/website/server/models/message.js index eeecaef74e..2ba3235c39 100644 --- a/website/server/models/message.js +++ b/website/server/models/message.js @@ -9,7 +9,8 @@ const defaultSchema = () => ({ text: String, // sender properties - user: String, // profile name + user: String, // profile name (unfortunately) + username: String, contributor: {$type: mongoose.Schema.Types.Mixed}, backer: {$type: mongoose.Schema.Types.Mixed}, uuid: String, // sender uuid @@ -117,6 +118,7 @@ export function messageDefaults (msg, user) { contributor: user.contributor && user.contributor.toObject(), backer: user.backer && user.backer.toObject(), user: user.profile.name, + username: user.flags && user.flags.verifiedUsername && user.auth && user.auth.local && user.auth.local.username, }); } else { message.uuid = 'system'; diff --git a/website/server/models/user/index.js b/website/server/models/user/index.js index 5521f21290..75870bf58b 100644 --- a/website/server/models/user/index.js +++ b/website/server/models/user/index.js @@ -8,10 +8,11 @@ require('./methods'); // A list of publicly accessible fields (not everything from preferences because there are also a lot of settings tha should remain private) export let publicFields = `preferences.size preferences.hair preferences.skin preferences.shirt preferences.chair preferences.costume preferences.sleep preferences.background preferences.tasks preferences.disableClasses profile stats - achievements party backer contributor auth.timestamps items inbox.optOut loginIncentives flags.classSelected`; + achievements party backer contributor auth.timestamps items inbox.optOut loginIncentives flags.classSelected + flags.verifiedUsername auth.local.username`; // The minimum amount of data needed when populating multiple users -export let nameFields = 'profile.name'; +export let nameFields = 'profile.name auth.local.username flags.verifiedUsername'; export { schema }; diff --git a/website/server/models/user/methods.js b/website/server/models/user/methods.js index 28a51fb158..9bf3a5e975 100644 --- a/website/server/models/user/methods.js +++ b/website/server/models/user/methods.js @@ -219,6 +219,9 @@ schema.statics.transformJSONUser = function transformJSONUser (jsonUser, addComp // Add id property jsonUser.id = jsonUser._id; + // Remove username if not verified + if (!jsonUser.flags.verifiedUsername) jsonUser.auth.local.username = null; + if (addComputedStats) this.addComputedStatsToJSONObj(jsonUser.stats, jsonUser); };