Merge branch 'sabrecat/usernames-master' into develop

This commit is contained in:
Sabe Jones
2018-11-14 08:21:49 -06:00
62 changed files with 1850 additions and 594 deletions

View File

@@ -569,7 +569,7 @@ describe('Group Model', () => {
}); });
it('throws an error if no uuids or emails are passed in', async () => { 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, httpCode: 400,
message: 'Bad request.', message: 'Bad request.',
name: 'BadRequest', 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 () => { 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, httpCode: 400,
message: 'Bad request.', message: 'Bad request.',
name: 'BadRequest', 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 () => { 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, httpCode: 400,
message: 'Bad request.', message: 'Bad request.',
name: 'BadRequest', 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 () => { 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, httpCode: 400,
message: 'Bad request.', message: 'Bad request.',
name: 'BadRequest', name: 'BadRequest',
}); });
expect(res.t).to.be.calledOnce; 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 () => { 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, httpCode: 400,
message: 'Bad request.', message: 'Bad request.',
name: 'BadRequest', name: 'BadRequest',
}); });
expect(res.t).to.be.calledOnce; 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 () => { 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, httpCode: 400,
message: 'Bad request.', message: 'Bad request.',
name: 'BadRequest', name: 'BadRequest',
@@ -639,7 +639,7 @@ describe('Group Model', () => {
uuids.push('one-more-uuid'); // to put it over the limit 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, httpCode: 400,
message: 'Bad request.', message: 'Bad request.',
name: 'BadRequest', name: 'BadRequest',
@@ -657,33 +657,33 @@ describe('Group Model', () => {
emails.push(`user-${i}@example.com`); 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; expect(res.t).to.not.be.called;
}); });
it('does not throw an error if only user ids are passed in', async () => { 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; expect(res.t).to.not.be.called;
}); });
it('does not throw an error if only emails are passed in', async () => { 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; expect(res.t).to.not.be.called;
}); });
it('does not throw an error if both uuids and emails are passed in', async () => { 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; 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 () => { 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; 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 () => { 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; expect(res.t).to.not.be.called;
}); });
}); });

View File

@@ -47,6 +47,14 @@ describe('GET /challenges/:challengeId', () => {
_id: groupLeader._id, _id: groupLeader._id,
id: groupLeader._id, id: groupLeader._id,
profile: {name: groupLeader.profile.name}, profile: {name: groupLeader.profile.name},
auth: {
local: {
username: groupLeader.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
}); });
expect(chal.group).to.eql({ expect(chal.group).to.eql({
_id: group._id, _id: group._id,
@@ -105,6 +113,14 @@ describe('GET /challenges/:challengeId', () => {
_id: challengeLeader._id, _id: challengeLeader._id,
id: challengeLeader._id, id: challengeLeader._id,
profile: {name: challengeLeader.profile.name}, profile: {name: challengeLeader.profile.name},
auth: {
local: {
username: challengeLeader.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
}); });
expect(chal.group).to.eql({ expect(chal.group).to.eql({
_id: group._id, _id: group._id,
@@ -131,6 +147,14 @@ describe('GET /challenges/:challengeId', () => {
_id: challengeLeader._id, _id: challengeLeader._id,
id: challengeLeader._id, id: challengeLeader._id,
profile: {name: challengeLeader.profile.name}, 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,
id: challengeLeader._id, id: challengeLeader._id,
profile: {name: challengeLeader.profile.name}, profile: {name: challengeLeader.profile.name},
auth: {
local: {
username: challengeLeader.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
}); });
expect(chal.group).to.eql({ expect(chal.group).to.eql({
_id: group._id, _id: group._id,
@@ -205,6 +237,14 @@ describe('GET /challenges/:challengeId', () => {
_id: challengeLeader._id, _id: challengeLeader._id,
id: challengeLeader._id, id: challengeLeader._id,
profile: {name: challengeLeader.profile.name}, profile: {name: challengeLeader.profile.name},
auth: {
local: {
username: challengeLeader.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
}); });
}); });
}); });

View File

@@ -60,6 +60,14 @@ describe('GET /challenges/:challengeId/members', () => {
_id: groupLeader._id, _id: groupLeader._id,
id: groupLeader._id, id: groupLeader._id,
profile: {name: groupLeader.profile.name}, 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,
id: leader._id, id: leader._id,
profile: {name: leader.profile.name}, 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']); expect(res[0].profile).to.have.all.keys(['name']);
}); });
@@ -88,8 +104,16 @@ describe('GET /challenges/:challengeId/members', () => {
_id: anotherUser._id, _id: anotherUser._id,
id: anotherUser._id, id: anotherUser._id,
profile: {name: anotherUser.profile.name}, 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']); 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`); let res = await user.get(`/challenges/${challenge._id}/members?includeAllMembers=not-true`);
expect(res.length).to.equal(30); expect(res.length).to.equal(30);
res.forEach(member => { 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']); 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`); let res = await user.get(`/challenges/${challenge._id}/members`);
expect(res.length).to.equal(30); expect(res.length).to.equal(30);
res.forEach(member => { 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']); 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`); let res = await user.get(`/challenges/${challenge._id}/members?includeAllMembers=true`);
expect(res.length).to.equal(32); expect(res.length).to.equal(32);
res.forEach(member => { 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']); expect(member.profile).to.have.all.keys(['name']);
}); });
}); });

View File

@@ -81,7 +81,7 @@ describe('GET /challenges/:challengeId/members/:memberId', () => {
await groupLeader.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: taskText}]); await groupLeader.post(`/tasks/challenge/${challenge._id}`, [{type: 'habit', text: taskText}]);
let memberProgress = await user.get(`/challenges/${challenge._id}/members/${groupLeader._id}`); 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.profile).to.have.all.keys(['name']);
expect(memberProgress.tasks.length).to.equal(1); expect(memberProgress.tasks.length).to.equal(1);
}); });

View File

@@ -39,6 +39,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: publicGuild.leader._id, _id: publicGuild.leader._id,
id: publicGuild.leader._id, id: publicGuild.leader._id,
profile: {name: user.profile.name}, profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
}); });
let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id });
expect(foundChallenge2).to.exist; expect(foundChallenge2).to.exist;
@@ -46,6 +54,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: publicGuild.leader._id, _id: publicGuild.leader._id,
id: publicGuild.leader._id, id: publicGuild.leader._id,
profile: {name: user.profile.name}, 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,
id: publicGuild.leader._id, id: publicGuild.leader._id,
profile: {name: user.profile.name}, profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
}); });
let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id });
expect(foundChallenge2).to.exist; expect(foundChallenge2).to.exist;
@@ -65,6 +89,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: publicGuild.leader._id, _id: publicGuild.leader._id,
id: publicGuild.leader._id, id: publicGuild.leader._id,
profile: {name: user.profile.name}, 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,
id: privateGuild.leader._id, id: privateGuild.leader._id,
profile: {name: user.profile.name}, profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
}); });
let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id });
expect(foundChallenge2).to.exist; expect(foundChallenge2).to.exist;
@@ -132,6 +172,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: privateGuild.leader._id, _id: privateGuild.leader._id,
id: privateGuild.leader._id, id: privateGuild.leader._id,
profile: {name: user.profile.name}, 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,
id: party.leader._id, id: party.leader._id,
profile: {name: user.profile.name}, profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
}); });
let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id });
expect(foundChallenge2).to.exist; expect(foundChallenge2).to.exist;
@@ -242,6 +298,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: party.leader._id, _id: party.leader._id,
id: party.leader._id, id: party.leader._id,
profile: {name: user.profile.name}, 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,
id: party.leader._id, id: party.leader._id,
profile: {name: user.profile.name}, profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
}); });
let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id });
expect(foundChallenge2).to.exist; expect(foundChallenge2).to.exist;
@@ -261,6 +333,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: party.leader._id, _id: party.leader._id,
id: party.leader._id, id: party.leader._id,
profile: {name: user.profile.name}, 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,
id: user._id, id: user._id,
profile: {name: user.profile.name}, profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
}); });
let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id });
expect(foundChallenge2).to.exist; expect(foundChallenge2).to.exist;
@@ -295,6 +383,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: user._id, _id: user._id,
id: user._id, id: user._id,
profile: {name: user.profile.name}, 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,
id: user._id, id: user._id,
profile: {name: user.profile.name}, profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
}); });
let foundChallenge2 = _.find(challenges, { _id: challenge2._id }); let foundChallenge2 = _.find(challenges, { _id: challenge2._id });
expect(foundChallenge2).to.exist; expect(foundChallenge2).to.exist;
@@ -314,6 +418,14 @@ describe('GET challenges/groups/:groupId', () => {
_id: user._id, _id: user._id,
id: user._id, id: user._id,
profile: {name: user.profile.name}, profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
}); });
}); });
}); });

View File

@@ -40,6 +40,14 @@ describe('GET challenges/user', () => {
_id: publicGuild.leader._id, _id: publicGuild.leader._id,
id: publicGuild.leader._id, id: publicGuild.leader._id,
profile: {name: user.profile.name}, profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
}); });
expect(foundChallenge.group).to.eql({ expect(foundChallenge.group).to.eql({
_id: publicGuild._id, _id: publicGuild._id,
@@ -62,6 +70,14 @@ describe('GET challenges/user', () => {
_id: publicGuild.leader._id, _id: publicGuild.leader._id,
id: publicGuild.leader._id, id: publicGuild.leader._id,
profile: {name: user.profile.name}, profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
}); });
expect(foundChallenge1.group).to.eql({ expect(foundChallenge1.group).to.eql({
_id: publicGuild._id, _id: publicGuild._id,
@@ -79,6 +95,14 @@ describe('GET challenges/user', () => {
_id: publicGuild.leader._id, _id: publicGuild.leader._id,
id: publicGuild.leader._id, id: publicGuild.leader._id,
profile: {name: user.profile.name}, profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
}); });
expect(foundChallenge2.group).to.eql({ expect(foundChallenge2.group).to.eql({
_id: publicGuild._id, _id: publicGuild._id,
@@ -101,6 +125,14 @@ describe('GET challenges/user', () => {
_id: publicGuild.leader._id, _id: publicGuild.leader._id,
id: publicGuild.leader._id, id: publicGuild.leader._id,
profile: {name: user.profile.name}, profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
}); });
expect(foundChallenge1.group).to.eql({ expect(foundChallenge1.group).to.eql({
_id: publicGuild._id, _id: publicGuild._id,
@@ -118,6 +150,14 @@ describe('GET challenges/user', () => {
_id: publicGuild.leader._id, _id: publicGuild.leader._id,
id: publicGuild.leader._id, id: publicGuild.leader._id,
profile: {name: user.profile.name}, profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
}); });
expect(foundChallenge2.group).to.eql({ expect(foundChallenge2.group).to.eql({
_id: publicGuild._id, _id: publicGuild._id,

View File

@@ -79,6 +79,14 @@ describe('POST /challenges/:challengeId/join', () => {
_id: groupLeader._id, _id: groupLeader._id,
id: groupLeader._id, id: groupLeader._id,
profile: {name: groupLeader.profile.name}, profile: {name: groupLeader.profile.name},
auth: {
local: {
username: groupLeader.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
}); });
expect(res.name).to.equal(challenge.name); expect(res.name).to.equal(challenge.name);
}); });

View File

@@ -79,6 +79,14 @@ describe('PUT /challenges/:challengeId', () => {
_id: member._id, _id: member._id,
id: member._id, id: member._id,
profile: {name: member.profile.name}, 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.name).to.equal('New Challenge Name');
expect(res.description).to.equal('New challenge description.'); expect(res.description).to.equal('New challenge description.');

View File

@@ -50,6 +50,14 @@ describe('GET /groups/:groupId/invites', () => {
_id: invited._id, _id: invited._id,
id: invited._id, id: invited._id,
profile: {name: invited.profile.name}, 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(); let invited = await generateUser();
await user.post(`/groups/${group._id}/invite`, {uuids: [invited._id]}); await user.post(`/groups/${group._id}/invite`, {uuids: [invited._id]});
let res = await user.get('/groups/party/invites'); 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']); 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`); let res = await leader.get(`/groups/${group._id}/invites`);
expect(res.length).to.equal(30); expect(res.length).to.equal(30);
res.forEach(member => { 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']); expect(member.profile).to.have.all.keys(['name']);
}); });
}).timeout(10000); }).timeout(10000);

View File

@@ -56,13 +56,21 @@ describe('GET /groups/:groupId/members', () => {
_id: user._id, _id: user._id,
id: user._id, id: user._id,
profile: {name: user.profile.name}, profile: {name: user.profile.name},
auth: {
local: {
username: user.auth.local.username,
},
},
flags: {
verifiedUsername: true,
},
}); });
}); });
it('populates only some fields', async () => { it('populates only some fields', async () => {
await generateGroup(user, {type: 'party', name: generateUUID()}); await generateGroup(user, {type: 'party', name: generateUUID()});
let res = await user.get('/groups/party/members'); 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']); 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', '_id', 'id', 'preferences', 'profile', 'stats', 'achievements', 'party',
'backer', 'contributor', 'auth', 'items', 'inbox', 'loginIncentives', 'flags', '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([ expect(Object.keys(memberRes.preferences).sort()).to.eql([
'size', 'hair', 'skin', 'shirt', 'size', 'hair', 'skin', 'shirt',
'chair', 'costume', 'sleep', 'background', 'tasks', 'disableClasses', 'chair', 'costume', 'sleep', 'background', 'tasks', 'disableClasses',
@@ -95,7 +103,7 @@ describe('GET /groups/:groupId/members', () => {
'_id', 'id', 'preferences', 'profile', 'stats', 'achievements', 'party', '_id', 'id', 'preferences', 'profile', 'stats', 'achievements', 'party',
'backer', 'contributor', 'auth', 'items', 'inbox', 'loginIncentives', 'flags', '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([ expect(Object.keys(memberRes.preferences).sort()).to.eql([
'size', 'hair', 'skin', 'shirt', 'size', 'hair', 'skin', 'shirt',
'chair', 'costume', 'sleep', 'background', 'tasks', 'disableClasses', 'chair', 'costume', 'sleep', 'background', 'tasks', 'disableClasses',
@@ -120,7 +128,7 @@ describe('GET /groups/:groupId/members', () => {
let res = await user.get('/groups/party/members'); let res = await user.get('/groups/party/members');
expect(res.length).to.equal(30); expect(res.length).to.equal(30);
res.forEach(member => { 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']); 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'); let res = await user.get('/groups/party/members?includeAllMembers=true');
expect(res.length).to.equal(30); expect(res.length).to.equal(30);
res.forEach(member => { 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']); expect(member.profile).to.have.all.keys(['name']);
}); });
}); });

View File

@@ -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', () => { describe('user id invites', () => {
it('returns an error when inviter has no chat privileges', async () => { it('returns an error when inviter has no chat privileges', async () => {
let inviterMuted = await inviter.update({'flags.chatRevoked': true}); let inviterMuted = await inviter.update({'flags.chatRevoked': true});
@@ -93,7 +160,7 @@ describe('Post /groups/:groupId/invite', () => {
.to.eventually.be.rejected.and.eql({ .to.eventually.be.rejected.and.eql({
code: 400, code: 400,
error: 'BadRequest', error: 'BadRequest',
message: t('inviteMissingUuid'), message: t('inviteMustNotBeEmpty'),
}); });
}); });
@@ -228,7 +295,7 @@ describe('Post /groups/:groupId/invite', () => {
.to.eventually.be.rejected.and.eql({ .to.eventually.be.rejected.and.eql({
code: 400, code: 400,
error: 'BadRequest', error: 'BadRequest',
message: t('inviteMissingEmail'), message: t('inviteMustNotBeEmpty'),
}); });
}); });

View File

@@ -34,7 +34,7 @@ describe('GET /members/:memberId', () => {
'_id', 'id', 'preferences', 'profile', 'stats', 'achievements', 'party', '_id', 'id', 'preferences', 'profile', 'stats', 'achievements', 'party',
'backer', 'contributor', 'auth', 'items', 'inbox', 'loginIncentives', 'flags', '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([ expect(Object.keys(memberRes.preferences).sort()).to.eql([
'size', 'hair', 'skin', 'shirt', 'size', 'hair', 'skin', 'shirt',
'chair', 'costume', 'sleep', 'background', 'tasks', 'disableClasses', 'chair', 'costume', 'sleep', 'background', 'tasks', 'disableClasses',

View File

@@ -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')] });
});
});
});

View File

@@ -70,3 +70,9 @@
width: 366px; width: 366px;
height: 285px; height: 285px;
} }
.scene_veteran_pets {
background-image: url('~assets/images/sprites/spritesmith-largeSprites-0.png');
background-position: -582px -727px;
width: 242px;
height: 62px;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 193 KiB

View File

@@ -20,7 +20,7 @@
@include btn-focus-hover-shadow(); @include btn-focus-hover-shadow();
} }
&:hover:not(.btn-flat) { &:hover:not(.btn-flat):not(.disabled) {
@include btn-focus-hover-shadow(); @include btn-focus-hover-shadow();
border-color: transparent; border-color: transparent;
} }
@@ -37,6 +37,7 @@
.btn:disabled, .btn.disabled { .btn:disabled, .btn.disabled {
box-shadow: none; box-shadow: none;
cursor: default;
opacity: 0.64; opacity: 0.64;
border-color: transparent; border-color: transparent;
} }

View File

@@ -11,7 +11,12 @@
} }
} }
.modal {
z-index: 1350;
}
.modal-dialog { .modal-dialog {
margin: 5.5rem auto 3rem;
width: auto; width: auto;
.title { .title {

View File

@@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="256" height="104" viewBox="0 0 256 104">
<defs>
<rect id="b" width="96" height="56" rx="6"/>
<filter id="a" width="112.5%" height="128.6%" x="-6.2%" y="-10.7%" filterUnits="objectBoundingBox">
<feMorphology in="SourceAlpha" operator="dilate" radius="4" result="shadowSpreadOuter1"/>
<feOffset dy="4" in="shadowSpreadOuter1" result="shadowOffsetOuter1"/>
<feComposite in="shadowOffsetOuter1" in2="SourceAlpha" operator="out" result="shadowOffsetOuter1"/>
<feColorMatrix in="shadowOffsetOuter1" values="0 0 0 0 0.407843137 0 0 0 0 0.384313725 0 0 0 0 0.454901961 0 0 0 0.24 0"/>
</filter>
</defs>
<g fill="none" fill-rule="evenodd">
<g opacity=".64">
<path fill="#3FDAA2" d="M194.359 74.179l3.074-1.341-2.917-1.655-1.341-3.075-1.655 2.918-3.075 1.34 2.918 1.656 1.34 3.074z" opacity=".96"/>
<path fill="#FF6165" d="M127.439 4.105l-.023 2.982 2.399-1.771 2.981.023-1.77-2.4.022-2.98-2.399 1.77-2.98-.022z" opacity=".84"/>
<path fill="#3FDAA2" d="M70.501 38.126l2.574.428-1.202-2.315.428-2.574-2.316 1.202-2.573-.427 1.202 2.315-.428 2.573z" opacity=".83"/>
<path fill="#50B5E9" d="M240.929 73.34l.173 6.333 4.962-3.939 6.334-.173-3.939-4.962-.173-6.334-4.962 3.939-6.334.173z" opacity=".73"/>
<path fill="#FFBE5D" d="M198.881 41.724l-3.984 5.397 6.708-.05 5.397 3.983-.05-6.708 3.983-5.397-6.708.051-5.397-3.984z" opacity=".82"/>
<path fill="#50B5E9" d="M81.165 96.829l-.589-4.433-3.193 3.13-4.433.59 3.13 3.193.59 4.433 3.193-3.13 4.433-.59z" opacity=".99"/>
<path fill="#50B5E9" d="M119.702 40.186l-3.901 5.91 7.068-.425 5.91 3.902-.425-7.069 3.901-5.909-7.068.425-5.909-3.902z"/>
<path fill="#FF6165" d="M162.14 91.367l3.404 2.9.278-4.463 2.9-3.404-4.463-.278-3.404-2.9-.278 4.463-2.9 3.404z" opacity=".84"/>
<path fill="#FF944C" d="M6.708 37.066l.62 3.675 2.568-2.7 3.675-.62-2.7-2.568-.62-3.675-2.568 2.7-3.675.62zM253.486 43.18l-.037-3.727-2.96 2.265-3.726.037 2.265 2.959.037 3.727 2.96-2.266 3.726-.037z" opacity=".93"/>
<path fill="#9A62FF" d="M51.481 70.952l5.13-.957-3.843-3.53-.957-5.128-3.53 3.842-5.128.957 3.843 3.53.956 5.128z" opacity=".99"/>
<path fill="#FFBE5D" d="M78.061 8.656l-.952 3.987 3.761-1.63 3.987.952-1.63-3.761.953-3.988-3.762 1.63-3.987-.952z"/>
<path fill="#3FDAA2" d="M5.863 70.74l4.692 1.207-1.849-4.478 1.208-4.692-4.478 1.849-4.692-1.208 1.849 4.478-1.208 4.692z" opacity=".96"/>
<path fill="#9A62FF" d="M182.63 14.447l5.026-1.4-4.135-3.18-1.4-5.027-3.181 4.136-5.026 1.4 4.135 3.18 1.4 5.027z" opacity=".99"/>
</g>
<g transform="translate(79 24)">
<use fill="#000" filter="url(#a)" xlink:href="#b"/>
<rect width="100" height="60" x="-2" y="-2" fill="#6133B4" stroke="#4F2A93" stroke-width="4" rx="6"/>
</g>
<path fill="#FFF" d="M99.91 39v-9.8h3.024v3.528h2.366V29.2h3.024V39H105.3v-3.584h-2.366V39H99.91zm12.138 0v-9.8h7.182v2.576h-4.186v1.12h3.878v2.352h-3.878v1.176h4.256V39h-7.252zm10.794 0v-9.8h3.024v7.14h3.738V39h-6.762zm10.178 0v-9.8h3.024v7.14h3.738V39h-6.762zm14.14.21c-2.688 0-4.634-2.03-4.634-4.97v-.252c0-2.94 1.96-4.998 4.648-4.998 2.702 0 4.648 2.03 4.648 4.97v.252c0 2.94-1.974 4.998-4.662 4.998zm.014-2.702c.98 0 1.582-.84 1.582-2.296v-.21c0-1.456-.616-2.31-1.596-2.31-.966 0-1.582.84-1.582 2.296v.21c0 1.456.616 2.31 1.596 2.31zM79 44h96v28H79z"/>
<path fill="#4E4A57" d="M102.99 64.82c.04-1.6.04-3.34-.12-4.96-.8-.18-1.76-.26-2.88-.1v5.18c0 .18-.38.28-.5.28-.24 0-.5-.14-.54-.4l-.24-14.22c0-.42.06-.76.52-.76.1 0 .72.1.72.28l.02 8.54c.74.1 2.34.06 2.98.14v-.84c0-2.74-.16-5.1-.16-7.48 0-.24.04-.64.34-.64.3 0 .74.14.78.52l.26 14.36c0 .34-.3.62-.68.62h-.1c-.14-.02-.36-.4-.4-.52zm7.64-3.18h-2.96l-.12.12-.34 2.26c-.04.3-.02 1.16-.6 1.16-.26 0-.44-.52-.44-.72 2.08-14.5 2.1-14.54 2.1-14.54.1-.38.24-.94.74-.94.22 0 .5.1.62.3l.16.42c1.16 4.58 1.7 9.28 2.24 14l.12 1.3c0 .02-.02.02-.02.04-.14.38-.22.54-.6.54h-.06c-.12 0-.32 0-.34-.14l-.5-3.8zm-1.5-10.52l-1.16 9.28 2.5.06-1.34-9.34zm4.3-.68c1.3-1.48 4.24-1.06 4.24 1.4 0 1.04-.4 1.98-1.08 2.74 2.28 1.42 3.18 3.12 3.18 5.8 0 2.86-1.58 5.3-4.72 5.3-.14 0-.54-.02-.7-.3-.22-3.08-.22-6.14-.52-9.76l-.4-4.76v-.42zm1.98 14.02c.12.02.24.02.36.02 2.12 0 2.82-2.18 2.82-4.1 0-2.22-1.02-4.92-3.64-4.92 0 3.02.22 6 .46 9zm-.82-13.58l.3 3.3c1.02 0 1.7-1.16 1.7-2.26 0-1.14-.94-1.54-2-1.04zm6.54 14.74l.54-14.84c.14-.4.24-.42.66-.42.58 0 .58.26.58.84 0 .06-.02.32-.02.36l-.6 13.68c-.04.88-1.16.96-1.16.38zm5.08-1.86l-.4-12.66c-1.24.08-1.8.06-1.84-.28.04-.68.04-.92 2.12-.8h1.68c.44.02 1.18.08 1.18.66 0 .18-.22.38-.4.38h-1.68l.38 12.7c0 .18-.4.28-.52.28s-.52-.1-.52-.28zm4.12 1.86l.54-14.84c.14-.4.24-.42.66-.42.58 0 .58.26.58.84 0 .06-.02.32-.02.36l-.6 13.68c-.04.88-1.16.96-1.16.38zm7.66-15.56c.42 0 .94.28.94.74 0 .7-1.16.56-1.44.56h-.12c-1.94 0-2.8 4.02-2.8 6.28 0 2.56.16 4.38 1.92 5.84.86.72 2.18-.02 2.48.52.4.7-.34.94-.98.94-4.54-.1-5.24-6.12-4.32-10.02.48-2.08 1.72-4.86 4.32-4.86zm7.4 11.58h-2.96l-.12.12-.34 2.26c-.04.3-.02 1.16-.6 1.16-.26 0-.44-.52-.44-.72 2.08-14.5 2.1-14.54 2.1-14.54.1-.38.24-.94.74-.94.22 0 .5.1.62.3l.16.42c1.16 4.58 1.7 9.28 2.24 14l.12 1.3c0 .02-.02.02-.02.04-.14.38-.22.54-.6.54h-.06c-.12 0-.32 0-.34-.14l-.5-3.8zm-1.5-10.52l-1.16 9.28 2.5.06-1.34-9.34zm5.02 14.24l.24-14.08c-.04-.8 1.06-1.28 1.48-.12l4.08 11.06c.02-.88.18-5.8.3-7.76.06-1.2-.02-2.42.24-3.54 0-.22.22-.22.52-.22.48.02.52.04.5.48-.06 2.08-.36 4.16-.44 6.24-.04 1.16-.24 6.7-.24 7.86-.14.42-.76.18-.92-.16-.2-.42-.36-.82-4.6-12.16l-.14 12.28c0 .6-1.02.54-1.02.12z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<path fill="#A5A1AC" fill-rule="evenodd" d="M11 10c0 1.654-1.346 3-3 3s-3-1.346-3-3h6zm2-3.5a1.5 1.5 0 1 1-3.001-.001A1.5 1.5 0 0 1 13 6.5zm-7 0a1.5 1.5 0 1 1-3.001-.001A1.5 1.5 0 0 1 6 6.5zM14 0H2a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/> <path fill-rule="evenodd" d="M11 10c0 1.654-1.346 3-3 3s-3-1.346-3-3h6zm2-3.5a1.5 1.5 0 1 1-3.001-.001A1.5 1.5 0 0 1 13 6.5zm-7 0a1.5 1.5 0 1 1-3.001-.001A1.5 1.5 0 0 1 6 6.5zM14 0H2a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 361 B

After

Width:  |  Height:  |  Size: 346 B

View File

@@ -82,6 +82,7 @@
import hello from 'hellojs'; import hello from 'hellojs';
import { setUpAxios } from 'client/libs/auth'; import { setUpAxios } from 'client/libs/auth';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import isEmail from 'validator/lib/isEmail';
import facebookSquareIcon from 'assets/svg/facebook-square.svg'; import facebookSquareIcon from 'assets/svg/facebook-square.svg';
import googleIcon from 'assets/svg/google.svg'; import googleIcon from 'assets/svg/google.svg';
@@ -115,13 +116,13 @@ export default {
computed: { computed: {
emailValid () { emailValid () {
if (this.email.length <= 3) return false; if (this.email.length <= 3) return false;
return this.validateEmail(this.email); return isEmail(this.email);
}, },
emailInvalid () { emailInvalid () {
return !this.emailValid; return !this.emailValid;
}, },
usernameValid () { usernameValid () {
if (this.username.length <= 3) return false; if (this.username.length < 1) return false;
return this.usernameIssues.length === 0; return this.usernameIssues.length === 0;
}, },
usernameInvalid () { usernameInvalid () {
@@ -143,7 +144,7 @@ export default {
methods: { methods: {
// eslint-disable-next-line func-names // eslint-disable-next-line func-names
validateUsername: debounce(function (username) { validateUsername: debounce(function (username) {
if (username.length <= 3) { if (username.length < 1) {
return; return;
} }
this.$store.dispatch('auth:verifyUsername', { this.$store.dispatch('auth:verifyUsername', {

View File

@@ -295,6 +295,7 @@
import axios from 'axios'; import axios from 'axios';
import hello from 'hellojs'; import hello from 'hellojs';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import isEmail from 'validator/lib/isEmail';
import gryphon from 'assets/svg/gryphon.svg'; import gryphon from 'assets/svg/gryphon.svg';
import habiticaIcon from 'assets/svg/habitica-logo.svg'; import habiticaIcon from 'assets/svg/habitica-logo.svg';
@@ -340,18 +341,18 @@ export default {
}, },
emailValid () { emailValid () {
if (this.email.length <= 3) return false; if (this.email.length <= 3) return false;
return this.validateEmail(this.email); return isEmail(this.email);
}, },
emailInvalid () { emailInvalid () {
if (this.email.length <= 3) return false; if (this.email.length <= 3) return false;
return !this.emailValid; return !this.emailValid;
}, },
usernameValid () { usernameValid () {
if (this.username.length <= 3) return false; if (this.username.length < 1) return false;
return this.usernameIssues.length === 0; return this.usernameIssues.length === 0;
}, },
usernameInvalid () { usernameInvalid () {
if (this.username.length <= 3) return false; if (this.username.length < 1) return false;
return !this.usernameValid; return !this.usernameValid;
}, },
passwordConfirmValid () { passwordConfirmValid () {

View File

@@ -1,26 +1,91 @@
<template lang="pug"> <template lang="pug">
div.autocomplete-selection(v-if='searchResults.length > 0', :style='autocompleteStyle') .autocomplete-selection(v-if='searchResults.length > 0', :style='autocompleteStyle')
.autocomplete-results(v-for='result in searchResults', @click='select(result)') {{ result }} .autocomplete-results.d-flex.align-items-center(
v-for='result in searchResults',
@click='select(result)',
@mouseenter='result.hover = true',
@mouseleave='result.hover = false',
:class='{"hover-background": result.hover}',
)
span
h3.profile-name(:class='userLevelStyle(result.msg)') {{ result.displayName }}
.svg-icon(v-html="tierIcon(result.msg)", v-if='showTierStyle(result.msg)')
span.username.ml-2(v-if='result.username', :class='{"hover-foreground": result.hover}') @{{ result.username }}
</template> </template>
<style scoped> <style lang="scss" scoped>
@import '~client/assets/scss/tiers.scss';
@import '~client/assets/scss/colors.scss';
.autocomplete-results { .autocomplete-results {
padding: .5em; padding: .5em;
}
.autocomplete-selection {
box-shadow: 1px 1px 1px #efefef; box-shadow: 1px 1px 1px #efefef;
} }
.hover-background {
background-color: rgba(213, 200, 255, 0.32);
}
.hover-foreground {
color: $purple-300 !important;
}
.profile-name {
display: inline-block;
font-size: 16px;
margin-bottom: 0rem;
}
.svg-icon {
width: 10px;
display: inline-block;
margin-left: .5em;
}
.username {
color: $gray-200;
}
</style> </style>
<script> <script>
import groupBy from 'lodash/groupBy'; import groupBy from 'lodash/groupBy';
import styleHelper from 'client/mixins/styleHelper';
import tier1 from 'assets/svg/tier-1.svg';
import tier2 from 'assets/svg/tier-2.svg';
import tier3 from 'assets/svg/tier-3.svg';
import tier4 from 'assets/svg/tier-4.svg';
import tier5 from 'assets/svg/tier-5.svg';
import tier6 from 'assets/svg/tier-6.svg';
import tier7 from 'assets/svg/tier-7.svg';
import tier8 from 'assets/svg/tier-mod.svg';
import tier9 from 'assets/svg/tier-staff.svg';
import tierNPC from 'assets/svg/tier-npc.svg';
export default { export default {
props: ['selections', 'text', 'coords', 'chat', 'textbox'], props: ['selections', 'text', 'caretPosition', 'coords', 'chat', 'textbox'],
data () { data () {
return { return {
atRegex: /(?!\b)@[\w-]*$/,
currentSearch: '', currentSearch: '',
searchActive: false, searchActive: false,
searchEscaped: false,
currentSearchPosition: 0, currentSearchPosition: 0,
tmpSelections: [], tmpSelections: [],
icons: Object.freeze({
tier1,
tier2,
tier3,
tier4,
tier5,
tier6,
tier7,
tier8,
tier9,
tierNPC,
}),
}; };
}, },
computed: { computed: {
@@ -36,32 +101,46 @@ export default {
marginTop: '28px', marginTop: '28px',
position: 'absolute', position: 'absolute',
minWidth: '100px', minWidth: '100px',
minHeight: '100px',
zIndex: 100, zIndex: 100,
backgroundColor: 'white', backgroundColor: 'white',
}; };
}, },
searchResults () { searchResults () {
if (!this.searchActive) return []; if (!this.searchActive) return [];
let currentSearch = this.text.substring(this.currentSearchPosition + 1, this.text.length); if (!this.atRegex.exec(this.text)) return [];
this.currentSearch = this.atRegex.exec(this.text)[0];
this.currentSearch = this.currentSearch.substring(1, this.currentSearch.length);
return this.tmpSelections.filter((option) => { return this.tmpSelections.filter((option) => {
return option.toLowerCase().indexOf(currentSearch.toLowerCase()) !== -1; return option.displayName.toLowerCase().indexOf(this.currentSearch.toLowerCase()) !== -1 || option.username && option.username.toLowerCase().indexOf(this.currentSearch.toLowerCase()) !== -1;
}); }).slice(0, 4);
}, },
}, },
mounted () { mounted () {
this.grabUserNames(); this.grabUserNames();
}, },
created () {
document.addEventListener('keyup', this.handleEsc);
},
destroyed () {
document.removeEventListener('keyup', this.handleEsc);
},
watch: { watch: {
text (newText) { text (newText) {
if (!newText[newText.length - 1] || newText[newText.length - 1] === ' ') { if (!newText[newText.length - 1] || newText[newText.length - 1] === ' ') {
this.searchActive = false; this.searchActive = false;
this.searchEscaped = false;
return;
} }
if (newText[newText.length - 1] === '@') {
this.searchEscaped = false;
}
if (this.searchEscaped) return;
if (!this.atRegex.test(newText)) return;
if (newText[newText.length - 1] !== '@') return;
this.searchActive = true; this.searchActive = true;
this.currentSearchPosition = newText.length - 1;
}, },
chat () { chat () {
this.resetDefaults(); this.resetDefaults();
@@ -72,25 +151,52 @@ export default {
resetDefaults () { resetDefaults () {
// Mounted is not called when switching between group pages because they have the // Mounted is not called when switching between group pages because they have the
// the same parent component. So, reset the data // the same parent component. So, reset the data
this.currentSearch = '';
this.searchActive = false; this.searchActive = false;
this.currentSearchPosition = 0; this.searchEscaped = false;
this.tmpSelections = []; this.tmpSelections = [];
}, },
grabUserNames () { grabUserNames () {
let usersThatMessage = groupBy(this.chat, 'user'); let usersThatMessage = groupBy(this.chat, 'user');
for (let userName in usersThatMessage) { for (let userKey in usersThatMessage) {
let systemMessage = userName === 'undefined'; let systemMessage = userKey === 'undefined';
if (!systemMessage && this.tmpSelections.indexOf(userName) === -1) { if (!systemMessage && this.tmpSelections.indexOf(userKey) === -1) {
this.tmpSelections.push(userName); this.tmpSelections.push({
displayName: userKey,
username: usersThatMessage[userKey][0].username,
msg: {
backer: usersThatMessage[userKey][0].backer,
contributor: usersThatMessage[userKey][0].contributor,
},
hover: false,
});
} }
} }
}, },
showTierStyle (message) {
const isContributor = Boolean(message.contributor && message.contributor.level);
const isNPC = Boolean(message.backer && message.backer.npc);
return isContributor || isNPC;
},
tierIcon (message) {
const isNPC = Boolean(message.backer && message.backer.npc);
if (isNPC) {
return this.icons.tierNPC;
}
return this.icons[`tier${message.contributor.level}`];
},
select (result) { select (result) {
let newText = this.text.slice(0, this.currentSearchPosition + 1) + result; let newText = this.text;
this.searchActive = false; const targetName = `${result.username || result.displayName} `;
newText = newText.replace(new RegExp(`${this.currentSearch}$`), targetName);
this.$emit('select', newText); this.$emit('select', newText);
}, },
handleEsc (e) {
if (e.keyCode === 27) {
this.searchActive = false;
this.searchEscaped = true;
}
},
}, },
mixins: [styleHelper],
}; };
</script> </script>

View File

@@ -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 flagged once, not hidden
.message-hidden(v-if='msg.flagCount > 1 && user.contributor.admin') Message hidden .message-hidden(v-if='msg.flagCount > 1 && user.contributor.admin') Message hidden
.card-body .card-body
h3.leader( h3.leader(
:class='userLevelStyle(msg)', :class='userLevelStyle(msg)',
@click="showMemberModal(msg.uuid)", @click="showMemberModal(msg.uuid)",
v-b-tooltip.hover.top="tierTitle", v-b-tooltip.hover.top="tierTitle",
) v-if="msg.user"
| {{msg.user}} )
.svg-icon(v-html="tierIcon", v-if='showShowTierStyle') | {{msg.user}}
p.time(v-b-tooltip="", :title="msg.timestamp | date") {{msg.timestamp | timeAgo}} .svg-icon(v-html="tierIcon")
.text(v-markdown='msg.text') p.time
hr span.mr-1(v-if="msg.username") @{{ msg.username }}
div(v-if='msg.id') span.mr-1(v-if="msg.username")
.action(@click='like()', v-if='!inbox && msg.likes', :class='{active: msg.likes[user._id]}') span(v-b-tooltip="", :title="msg.timestamp | date") {{ msg.timestamp | timeAgo }}
.svg-icon(v-html="icons.like") .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('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 }}
</template> </template>
<style lang="scss">
.at-highlight {
background-color: rgba(213, 200, 255, 0.32);
color: #6133b4;
padding: 0.1rem;
}
</style>
<style lang="scss" scoped> <style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
@import '~client/assets/scss/tiers.scss'; @import '~client/assets/scss/tiers.scss';
.mentioned-icon { .mentioned-icon {
@@ -54,7 +67,14 @@ div
color: red; color: red;
} }
hr {
margin-bottom: 0.5rem;
margin-top: 0.5rem;
}
.card-body { .card-body {
padding: 0.75rem 1.25rem 0.75rem 1.25rem;
.leader { .leader {
margin-bottom: 0; margin-bottom: 0;
} }
@@ -62,6 +82,7 @@ div
h3 { // this is the user name h3 { // this is the user name
cursor: pointer; cursor: pointer;
display: inline-block; display: inline-block;
font-size: 16px;
.svg-icon { .svg-icon {
width: 10px; width: 10px;
@@ -73,13 +94,15 @@ div
.time { .time {
font-size: 12px; font-size: 12px;
color: #878190; color: #878190;
width: 150px; margin-bottom: 0.5rem;
} }
.text { .text {
font-size: 14px; font-size: 14px;
color: #4e4a57; color: #4e4a57;
text-align: left !important; text-align: left !important;
min-height: 0rem;
margin-bottom: -0.5rem;
} }
} }
@@ -87,25 +110,25 @@ div
display: inline-block; display: inline-block;
color: #878190; color: #878190;
margin-right: 1em; margin-right: 1em;
font-size: 12px;
:hover {
cursor: pointer;
}
.svg-icon {
color: #A5A1AC;
margin-right: .2em;
width: 16px;
}
} }
.action:hover { .active {
cursor: pointer; color: $purple-300;
}
.liked:hover { .svg-icon {
cursor: default; color: $purple-400;
} }
.action .svg-icon {
margin-right: .2em;
width: 16px;
display: inline-block;
color: #A5A1AC;
}
.action.active, .active .svg-icon {
color: #46a7d9
} }
</style> </style>
@@ -114,8 +137,9 @@ import axios from 'axios';
import moment from 'moment'; import moment from 'moment';
import cloneDeep from 'lodash/cloneDeep'; import cloneDeep from 'lodash/cloneDeep';
import escapeRegExp from 'lodash/escapeRegExp'; 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 { mapState } from 'client/libs/store';
import styleHelper from 'client/mixins/styleHelper'; import styleHelper from 'client/mixins/styleHelper';
@@ -161,9 +185,6 @@ export default {
}), }),
}; };
}, },
directives: {
markdown: markdownDirective,
},
filters: { filters: {
timeAgo (value) { timeAgo (value) {
return moment(value).fromNow(); return moment(value).fromNow();
@@ -177,23 +198,24 @@ export default {
...mapState({user: 'user.data'}), ...mapState({user: 'user.data'}),
isUserMentioned () { isUserMentioned () {
const message = this.msg; const message = this.msg;
let user = this.user; const user = this.user;
if (message.hasOwnProperty('highlight')) return message.highlight; if (message.hasOwnProperty('highlight')) return message.highlight;
message.highlight = false; message.highlight = false;
let messagetext = message.text.toLowerCase(); const messageText = message.text.toLowerCase();
let username = user.profile.name; const displayName = user.profile.name;
let mentioned = messagetext.indexOf(username.toLowerCase()); const username = user.auth.local && user.auth.local.username;
let escapedUsername = escapeRegExp(username); const mentioned = max([messageText.indexOf(username.toLowerCase()), messageText.indexOf(displayName.toLowerCase())]);
let pattern = `@${escapedUsername}([^\w]|$){1}`;
if (mentioned === -1) return message.highlight; if (mentioned === -1) return message.highlight;
let preceedingchar = messagetext.substring(mentioned - 1, mentioned); const escapedDisplayName = escapeRegExp(displayName);
if (mentioned === 0 || preceedingchar.trim() === '' || preceedingchar === '@') { 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'); let regex = new RegExp(pattern, 'i');
message.highlight = regex.test(messagetext); message.highlight = regex.test(messageText);
} }
return message.highlight; return message.highlight;
@@ -209,12 +231,6 @@ export default {
} }
return likeCount; 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 () { tierIcon () {
const message = this.msg; const message = this.msg;
const isNPC = Boolean(message.backer && message.backer.npc); const isNPC = Boolean(message.backer && message.backer.npc);
@@ -244,6 +260,10 @@ export default {
} }
this.$emit('message-liked', message); this.$emit('message-liked', message);
this.$root.$emit('bv::hide::tooltip');
},
likeTooltip (likedStatus) {
if (!likedStatus) return this.$t('like');
}, },
copyAsTodo (message) { copyAsTodo (message) {
this.$root.$emit('habitica::copy-as-todo', message); this.$root.$emit('habitica::copy-as-todo', message);
@@ -273,6 +293,14 @@ export default {
showMemberModal (memberId) { showMemberModal (memberId) {
this.$emit('show-member-modal', memberId); this.$emit('show-member-modal', memberId);
}, },
atHighlight (text) {
return text.replace(new RegExp(/(?!\b)@[\w-]+/g), match => {
return `<span class="at-highlight">${match}</span>`;
});
},
parseMarkdown (text) {
return habiticaMarkdown.render(text);
},
}, },
}; };
</script> </script>

View File

@@ -14,6 +14,7 @@
v-if='msg.userStyles || (cachedProfileData[msg.uuid] && !cachedProfileData[msg.uuid].rejected)', v-if='msg.userStyles || (cachedProfileData[msg.uuid] && !cachedProfileData[msg.uuid].rejected)',
:member="msg.userStyles || cachedProfileData[msg.uuid]", :member="msg.userStyles || cachedProfileData[msg.uuid]",
:avatarOnly="true", :avatarOnly="true",
:overrideTopPadding='"14px"',
:hideClassBadge='true', :hideClassBadge='true',
@click.native="showMemberModal(msg.uuid)", @click.native="showMemberModal(msg.uuid)",
) )
@@ -40,6 +41,7 @@
:member="msg.userStyles || cachedProfileData[msg.uuid]", :member="msg.userStyles || cachedProfileData[msg.uuid]",
:avatarOnly="true", :avatarOnly="true",
:hideClassBadge='true', :hideClassBadge='true',
:overrideTopPadding='"14px"',
@click.native="showMemberModal(msg.uuid)", @click.native="showMemberModal(msg.uuid)",
) )
</template> </template>
@@ -47,6 +49,10 @@
<style lang="scss" scoped> <style lang="scss" scoped>
@import '~client/assets/scss/colors.scss'; @import '~client/assets/scss/colors.scss';
.avatar {
margin-left: -1.75rem;
}
.hr { .hr {
width: 100%; width: 100%;
height: 20px; height: 20px;
@@ -70,7 +76,9 @@
} }
.card { .card {
border: 0px;
margin-bottom: .5em; margin-bottom: .5em;
padding: 0rem;
} }
</style> </style>

View File

@@ -5,7 +5,7 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
h3(v-once) {{$t('welcomeTo')}} h3(v-once) {{$t('welcomeTo')}}
.svg-icon.logo(v-html='icons.logoPurple') .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 .col-6.offset-3
.user-creation-bg(v-if='!editing') .user-creation-bg(v-if='!editing')
avatar(:member='user', :avatarOnly='!editing', :class='{"edit-avatar": 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"') #extra.section.container.customize-section(v-if='activeTopPage === "extra"')
.row.sub-menu .row.sub-menu
.col-3.offset-1.text-center.sub-menu-item(@click='changeSubPage("glasses")', :class='{active: activeSubPage === "glasses"}') .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"}') .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"}') .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') .row.sub-menu(v-if='editing')
.col-4.text-center.sub-menu-item(@click='changeSubPage("ears")' :class='{active: activeSubPage === "ears"}') .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"}') .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"}') .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"') #glasses.row(v-if='activeSubPage === "glasses"')
.col-12.customize-options .col-12.customize-options
.option(v-for='option in eyewear', :class='{active: option.active}') .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") 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))') .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') .svg-icon.gem(v-html='icons.gem')
span.price 15 span.price 15
.row.customize-menu(v-if='filterBackgrounds') .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') .container.interests-section(v-if='modalPage === 3 && !editing')
.section.row .section.row
.col-12.text-center .col-12.text-center
h2 I want to work on: h2 {{ $t('wantToWorkOn') }}
.section.row .section.row
.col-6 .col-6
.task-option .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') 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') }} label.custom-control-label(v-once, for="self_care") {{ $t('self_care') }}
.section.row.justin-message-section(:class='{top: modalPage > 1}', v-if='!editing') .section.d-flex.justify-content-center(:class='{top: modalPage > 1}', v-if='!editing')
.col-12 .justin-section.d-flex.align-items-center
.justin-message.d-flex.flex-column.justify-content-center .featured-label
.featured-label span.rectangle
span.rectangle span.text Justin
span.text Justin span.rectangle
span.rectangle .justin-message
.npc_justin_textbox .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') div(v-if='modalPage === 1')
p(v-once) {{$t('justinIntroMessage1')}} p(v-once, v-html='$t("justinIntroMessage1")')
p(v-once) {{$t('justinIntroMessage2')}} p(v-once) {{ $t('justinIntroMessageUsername') }}
div(v-if='modalPage === 2') div(v-if='modalPage === 2')
p So how would you like to look? Dont worry, you can change this later. p {{ $t('justinIntroMessageAppearance') }}
div(v-if='modalPage === 3') 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') .section.container.footer
.row .row(v-if='!editing && !(modalPage === 1)')
.col-3.offset-1.text-center .col-3.offset-1.text-center
div(v-if='modalPage > 1', @click='prev()') div(v-if='modalPage > 1', @click='prev()')
.prev-arrow .prev-arrow
.prev(v-once) {{$t('prev')}} .prev(v-once) {{ $t('prev') }}
.col-4.text-center.circles .col-4.text-center.circles
.circle(:class="{active: modalPage === 1}") .circle(:class="{active: modalPage === 1}")
.circle(:class="{active: modalPage === 2}") .circle(:class="{active: modalPage === 2}")
@@ -390,12 +397,9 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
<style> <style>
/* @TODO do not rely on avatar-modal___BV_modal_body_, /* @TODO do not rely on avatar-modal___BV_modal_body_,
it already changed once when bootstrap-vue reached version 1 */ it already changed once when bootstrap-vue reached version 1 */
.page-2 #avatar-modal___BV_modal_body_ {
margin-top: 9em;
}
.page-2 .modal-content { .page-2 #avatar-modal___BV_modal_body_ {
margin-top: 7em; margin-top: 2rem;
} }
#avatar-modal___BV_modal_body_, #avatar-modal___BV_modal_body_ { #avatar-modal___BV_modal_body_, #avatar-modal___BV_modal_body_ {
@@ -421,6 +425,25 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
position: relative; position: relative;
} }
.corner-decoration {
position: absolute;
width: 6px;
height: 6px;
background-color: #ffbe5d;
border: inherit;
outline: inherit;
}
.small {
color: $gray-200;
}
h3 {
font-size: 20px;
font-weight: normal;
color: $gray-200;
}
.purchase-all { .purchase-all {
margin-bottom: 1em; margin-bottom: 1em;
} }
@@ -444,7 +467,7 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
.logo { .logo {
width: 190px; width: 190px;
margin: 0 auto; margin: 0 auto 1.25em;
} }
.user-creation-bg { .user-creation-bg {
@@ -464,32 +487,22 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
left: 9.2em; left: 9.2em;
} }
.justin-message { .justin-section {
background-image: url('~client/assets/svg/for-css/tutorial-border.svg');
height: 144px;
width: 400px;
padding: 2em;
margin: 0 auto;
position: relative; position: relative;
}
.featured-label { .justin-message {
position: absolute; border-color: #ffa623;
top: -1em; border-style: solid;
border-width: 2px;
.text { outline-color: #b36213;
min-height: auto; outline-style: solid;
color: $white; outline-width: 2px;
} position: relative;
} padding: 2em;
margin: 2px;
.npc_justin_textbox { height: 100%;
position: absolute; width: 400px;
right: 1em;
top: -3.6em;
width: 48px;
height: 52px;
background-image: url('~client/assets/images/justin_textbox.png');
}
p { p {
margin: auto; margin: auto;
@@ -500,15 +513,27 @@ b-modal#avatar-modal(title="", :size='editing ? "lg" : "md"', :hide-header='true
} }
} }
.justin-message-section { .npc-justin-textbox {
margin-top: 4em; position: absolute;
margin-bottom: 2em; right: 1rem;
top: -3.1rem;
width: 48px;
height: 48px;
background-image: url('~client/assets/images/justin_textbox.png');
} }
.justin-message-section.top { .featured-label {
position: absolute; position: absolute;
top: -16em; top: -1rem;
left: 3.5em; left: 1.5rem;
border-radius: 2px;
margin: auto;
.text {
font-size: 12px;
min-height: auto;
color: $white;
}
} }
.circles { .circles {
@@ -865,6 +890,7 @@ import get from 'lodash/get';
import groupBy from 'lodash/groupBy'; import groupBy from 'lodash/groupBy';
import { mapState } from 'client/libs/store'; import { mapState } from 'client/libs/store';
import avatar from './avatar'; import avatar from './avatar';
import usernameForm from './settings/usernameForm';
import { getBackgroundShopSets } from '../../common/script/libs/shops'; import { getBackgroundShopSets } from '../../common/script/libs/shops';
import unlock from '../../common/script/ops/unlock'; import unlock from '../../common/script/ops/unlock';
import buy from '../../common/script/ops/buy/buy'; import buy from '../../common/script/ops/buy/buy';
@@ -1022,6 +1048,7 @@ export default {
components: { components: {
avatar, avatar,
toggleSwitch, toggleSwitch,
usernameForm,
}, },
mounted () { mounted () {
if (this.editing) this.modalPage = 2; if (this.editing) this.modalPage = 2;

View File

@@ -19,6 +19,7 @@
v-on:select="selectedAutocomplete", v-on:select="selectedAutocomplete",
:textbox='textbox', :textbox='textbox',
:coords='coords', :coords='coords',
:caretPosition = 'caretPosition',
:chat='group.chat') :chat='group.chat')
.row.chat-actions .row.chat-actions
@@ -56,6 +57,7 @@
data () { data () {
return { return {
newMessage: '', newMessage: '',
caretPosition: 0,
chat: { chat: {
submitDisable: false, submitDisable: false,
submitTimeout: null, submitTimeout: null,
@@ -75,7 +77,7 @@
methods: { methods: {
// https://medium.com/@_jh3y/how-to-where-s-the-caret-getting-the-xy-position-of-the-caret-a24ba372990a // https://medium.com/@_jh3y/how-to-where-s-the-caret-getting-the-xy-position-of-the-caret-a24ba372990a
getCoord (e, text) { getCoord (e, text) {
let carPos = text.selectionEnd; this.caretPosition = text.selectionEnd;
let div = document.createElement('div'); let div = document.createElement('div');
let span = document.createElement('span'); let span = document.createElement('span');
let copyStyle = getComputedStyle(text); let copyStyle = getComputedStyle(text);
@@ -86,8 +88,8 @@
div.style.position = 'absolute'; div.style.position = 'absolute';
document.body.appendChild(div); document.body.appendChild(div);
div.textContent = text.value.substr(0, carPos); div.textContent = text.value.substr(0, this.caretPosition);
span.textContent = text.value.substr(carPos) || '.'; span.textContent = text.value.substr(this.caretPosition) || '.';
div.appendChild(span); div.appendChild(span);
this.coords = { this.coords = {
TOP: span.offsetTop, TOP: span.offsetTop,
@@ -194,7 +196,6 @@
width: 100%; width: 100%;
background-color: $white; background-color: $white;
border: solid 1px $gray-400; border: solid 1px $gray-400;
font-size: 16px;
font-style: italic; font-style: italic;
line-height: 1.43; line-height: 1.43;
color: $gray-300; color: $gray-300;

View File

@@ -1,152 +1,199 @@
<template lang="pug"> <template lang="pug">
b-modal#invite-modal(:title="$t('inviteFriends')", size='lg') b-modal#invite-modal(:title='$t(`inviteTo${groupType}`)', :hide-footer='true')
.modal-body div
p.alert.alert-info(v-html="$t('inviteAlertInfo')") strong {{ $t('inviteEmailUsername') }}
.form-horizontal .small {{ $t('inviteEmailUsernameInfo') }}
table.table.table-striped div(v-for='(invite, index) in invites')
thead .input-group
tr .d-flex.align-items-center.justify-content-center(v-if='index === invites.length - 1 && invite.text.length === 0')
th {{ $t('userId') }} .svg-icon.positive-icon(v-html='icons.positiveIcon')
tbody input.form-control(
tr(v-for='user in invitees') type='text',
td :placeholder='$t("emailOrUsernameInvite")',
input.form-control(type='text', v-model='user.uuid') v-model='invite.text',
tr v-on:keyup='expandInviteList',
td v-on:change='checkInviteList',
button.btn.btn-primary.pull-right(@click='addUuid()') :class='{"input-valid": invite.valid, "is-invalid input-invalid": invite.valid === false}',
i.glyphicon.glyphicon-plus )
| + .input-error.text-center.mt-2(v-if="invite.error") {{ invite.error }}
tr .modal-footer.d-flex.justify-content-center
td a.mr-3(@click='close()') {{ $t('cancel') }}
.col-6.col-offset-6 button.btn.btn-primary(@click='sendInvites()', :class='{disabled: cannotSubmit}', :disabled='cannotSubmit') {{ $t('sendInvitations') }}
button.btn.btn-primary.btn-block(@click='inviteNewUsers("uuid")') {{sendInviteText}}
hr
p.alert.alert-info {{ $t('inviteByEmail') }}
.form-horizontal
table.table.table-striped
thead
tr
th {{ $t('name') }}
th {{ $t('email') }}
tbody
tr(v-for='email in emails')
td
input.form-control(type='text', v-model='email.name')
td
input.form-control(type='email', v-model='email.email')
tr
td(colspan=2)
button.btn.btn-primary.pull-right(@click='addEmail()')
i.glyphicon.glyphicon-plus
| +
tr
td.form-group(colspan=2)
label.col-sm-1.control-label {{ $t('byColon') }}
.col-sm-5
input.form-control(type='text', v-model='inviter')
.col-sm-6
button.btn.btn-primary.btn-block(@click='inviteNewUsers("email")') {{sendInviteText}}
</template> </template>
<style lang="scss">
#invite-modal___BV_modal_outer_ {
.modal-content {
padding: 0rem 0.25rem;
}
}
#invite-modal___BV_modal_header_.modal-header {
border-bottom: 0px;
}
#invite-modal___BV_modal_header_ {
.modal-title {
color: #4F2A93;
font-size: 24px;
}
}
</style>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
a:not([href]) {
color: $blue-10;
font-size: 16px;
}
.form-control {
border: 0px;
color: $gray-50;
}
.input-error {
color: $red-50;
font-size: 90%;
}
.input-group {
border-radius: 2px;
border: solid 1px $gray-400;
margin-top: 0.5rem;
}
::placeholder {
color: $gray-200;
opacity: 1;
}
.input-group:focus-within {
border-color: $purple-500;
}
.modal-footer {
border: 0px;
}
.positive-icon {
color: $green-10;
width: 10px;
margin: auto 0rem auto 1rem;
}
.small {
color: $gray-200;
font-size: 12px;
margin: 0.5rem 0rem 1rem;
}
</style>
<script> <script>
import { mapState } from 'client/libs/store'; import { mapState } from 'client/libs/store';
import clone from 'lodash/clone';
import debounce from 'lodash/debounce';
import filter from 'lodash/filter';
import forEach from 'lodash/forEach';
import isEmail from 'validator/lib/isEmail';
import isUUID from 'validator/lib/isUUID';
import notifications from 'client/mixins/notifications';
import positiveIcon from 'assets/svg/positive.svg';
import filter from 'lodash/filter'; const INVITE_DEFAULTS = {text: '', error: null, valid: null};
import map from 'lodash/map';
import notifications from 'client/mixins/notifications';
export default { export default {
mixins: [notifications], computed: {
props: ['group'], ...mapState({user: 'user.data'}),
data () { cannotSubmit () {
return { const filteredInvites = filter(this.invites, (invite) => {
invitees: [], return invite.text.length > 0 && !invite.valid;
emails: [], });
}; if (filteredInvites.length > 0) return true;
}, },
computed: { inviter () {
...mapState({user: 'user.data'}), return this.user.profile.name;
inviter () { },
return this.user.profile.name;
}, },
sendInviteText () { data () {
return 'Send Invites'; return {
// if (!this.group) return 'Send Invites'; invites: [clone(INVITE_DEFAULTS), clone(INVITE_DEFAULTS)],
// return this.group.sendInviteText; icons: Object.freeze({
positiveIcon,
}),
};
}, },
}, methods: {
methods: { checkInviteList: debounce(function checkList () {
addUuid () { this.invites = filter(this.invites, (invite, index) => {
this.invitees.push({uuid: ''}); return invite.text.length > 0 || index === this.invites.length - 1;
});
while (this.invites.length < 2) this.invites.push(clone(INVITE_DEFAULTS));
forEach(this.invites, (value, index) => {
if (value.text.length < 1 || isEmail(value.text)) {
return this.fillErrors(index);
}
if (isUUID(value.text)) {
this.$store.dispatch('user:userLookup', {uuid: value.text})
.then(res => {
return this.fillErrors(index, res);
});
} else {
let searchUsername = value.text;
if (searchUsername[0] === '@') searchUsername = searchUsername.slice(1, searchUsername.length);
this.$store.dispatch('user:userLookup', {username: searchUsername})
.then(res => {
return this.fillErrors(index, res);
});
}
});
}, 250),
expandInviteList () {
if (this.invites[this.invites.length - 1].text.length > 0) this.invites.push(clone(INVITE_DEFAULTS));
},
fillErrors (index, res) {
if (!res || res.status === 200) {
this.invites[index].error = null;
if (this.invites[index].text.length < 1) return this.invites[index].valid = null;
return this.invites[index].valid = true;
}
this.invites[index].error = res.response.data.message;
return this.invites[index].valid = false;
},
close () {
this.invites = [clone(INVITE_DEFAULTS), clone(INVITE_DEFAULTS)];
this.$root.$emit('bv::hide::modal', 'invite-modal');
},
async sendInvites () {
let invitationDetails = {
inviter: this.inviter,
emails: [],
uuids: [],
usernames: [],
};
forEach(this.invites, (invite) => {
if (invite.text.length < 1) return;
if (isEmail(invite.text)) {
invitationDetails.emails.push({email: invite.text});
} else if (isUUID(invite.text)) {
invitationDetails.uuids.push(invite.text);
} else {
invitationDetails.usernames.push(invite.text);
}
});
await this.$store.dispatch('guilds:invite', {
invitationDetails,
groupId: this.group._id,
});
const invitesSent = invitationDetails.emails.length + invitationDetails.uuids.length + invitationDetails.usernames.length;
let invitationString = invitesSent > 1 ? 'invitationsSent' : 'invitationSent';
this.text(this.$t(invitationString));
this.close();
},
}, },
addEmail () { mixins: [notifications],
this.emails.push({name: '', email: ''}); props: ['group', 'groupType'],
}, };
inviteNewUsers (inviteMethod) {
if (!this.group._id) {
if (!this.group.name) this.group.name = this.$t('possessiveParty', {name: this.user.profile.name});
// @TODO: Add dispatch
// return Groups.Group.create(this.group)
// .then(function(response) {
// this.group = response.data.data;
// _inviteByMethod(inviteMethod);
// });
}
this.inviteByMethod(inviteMethod);
},
async inviteByMethod (inviteMethod) {
let invitationDetails;
if (inviteMethod === 'email') {
let emails = this.getEmails();
invitationDetails = { inviter: this.inviter, emails };
} else if (inviteMethod === 'uuid') {
let uuids = this.getOnlyUuids();
invitationDetails = { uuids };
} else {
return alert('Invalid invite method.');
}
await this.$store.dispatch('guilds:invite', {
invitationDetails,
groupId: this.group._id,
});
let invitesSent = invitationDetails.emails || invitationDetails.uuids;
let invitationString = invitesSent.length > 1 ? 'invitationsSent' : 'invitationSent';
this.text(this.$t(invitationString));
this.invitees = [];
this.emails = [];
// @TODO: This function didn't make it over this.resetInvitees();
// @TODO: Sync group invites?
// if (this.group.type === 'party') {
// this.$router.push('//party');
// } else {
// this.$router.push(`/groups/guilds/${this.group._id}`);
// }
this.$root.$emit('bv::hide::modal', 'invite-modal');
// @TODO: error?
// _resetInvitees();
},
getOnlyUuids () {
let uuids = map(this.invitees, 'uuid');
let filteredUuids = filter(uuids, (id) => {
return id !== '';
});
return filteredUuids;
},
getEmails () {
let emails = filter(this.emails, (obj) => {
return obj.email !== '';
});
return emails;
},
},
};
</script> </script>

View File

@@ -355,7 +355,10 @@ export default {
sendMessage (member) { sendMessage (member) {
this.$root.$emit('habitica::new-inbox-message', { this.$root.$emit('habitica::new-inbox-message', {
userIdToMessage: member._id, 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 = '') { async searchMembers (searchTerm = '') {

View File

@@ -1,6 +1,6 @@
<template lang="pug"> <template lang="pug">
div div
invite-modal(:group='inviteModalGroup') invite-modal(:group='inviteModalGroup', :groupType='inviteModalGroupType')
create-party-modal create-party-modal
#app-header.row(:class="{'hide-header': $route.name === 'groupPlan'}") #app-header.row(:class="{'hide-header': $route.name === 'groupPlan'}")
members-modal(:hide-badge="true") members-modal(:hide-badge="true")
@@ -115,6 +115,7 @@ export default {
expandedMember: null, expandedMember: null,
currentWidth: 0, currentWidth: 0,
inviteModalGroup: undefined, inviteModalGroup: undefined,
inviteModalGroupType: undefined,
}; };
}, },
computed: { computed: {
@@ -178,6 +179,7 @@ export default {
mounted () { mounted () {
this.$root.$on('inviteModal::inviteToGroup', (group) => { this.$root.$on('inviteModal::inviteToGroup', (group) => {
this.inviteModalGroup = group; this.inviteModalGroup = group;
this.inviteModalGroupType = group.type === 'guild' ? 'Guild' : 'Party';
this.$root.$emit('bv::show::modal', 'invite-modal'); this.$root.$emit('bv::show::modal', 'invite-modal');
}); });
}, },

View File

@@ -19,7 +19,10 @@
| {{member.profile.name}} | {{member.profile.name}}
.is-buffed(v-if="isBuffed", v-b-tooltip.hover.bottom="$t('buffed')") .is-buffed(v-if="isBuffed", v-b-tooltip.hover.bottom="$t('buffed')")
.svg-icon(v-html="icons.buff") .svg-icon(v-html="icons.buff")
span.small-text.character-level {{ characterLevel }} .small-text.character-level
span.mr-1(v-if="member.auth && member.auth.local && member.auth.local.username") @{{ member.auth.local.username }}
span.mr-1(v-if="member.auth && member.auth.local && member.auth.local.username")
span {{ characterLevel }}
.progress-container(v-b-tooltip.hover.bottom="$t('health')") .progress-container(v-b-tooltip.hover.bottom="$t('health')")
.svg-icon(v-html="icons.health") .svg-icon(v-html="icons.health")
.progress .progress

View File

@@ -25,6 +25,7 @@ div
login-incentives(:data='notificationData') login-incentives(:data='notificationData')
quest-completed quest-completed
quest-invitation quest-invitation
verify-username
</template> </template>
<style lang='scss'> <style lang='scss'>
@@ -118,6 +119,7 @@ import streak from './achievements/streak';
import ultimateGear from './achievements/ultimateGear'; import ultimateGear from './achievements/ultimateGear';
import wonChallenge from './achievements/wonChallenge'; import wonChallenge from './achievements/wonChallenge';
import loginIncentives from './achievements/login-incentives'; import loginIncentives from './achievements/login-incentives';
import verifyUsername from './settings/verifyUsername';
const NOTIFICATIONS = { const NOTIFICATIONS = {
CHALLENGE_JOINED_ACHIEVEMENT: { CHALLENGE_JOINED_ACHIEVEMENT: {
@@ -178,6 +180,7 @@ export default {
dropsEnabled, dropsEnabled,
contributor, contributor,
loginIncentives, loginIncentives,
verifyUsername,
}, },
data () { data () {
// Levels that already display modals and should not trigger generic Level Up // Levels that already display modals and should not trigger generic Level Up
@@ -314,16 +317,12 @@ export default {
this.$store.dispatch('user:fetch'), this.$store.dispatch('user:fetch'),
this.$store.dispatch('tasks:fetchUserTasks'), this.$store.dispatch('tasks:fetchUserTasks'),
]).then(() => { ]).then(() => {
this.debounceCheckUserAchievements();
// @TODO: This is a timeout to ensure dom is loaded // @TODO: This is a timeout to ensure dom is loaded
window.setTimeout(() => { window.setTimeout(() => {
this.initTour(); this.runForcedModals();
if (this.user.flags.tour.intro === this.TOUR_END || !this.user.flags.welcomed) return;
this.goto('intro', 0);
}, 2000); }, 2000);
this.runYesterDailies(); this.debounceCheckUserAchievements();
// Do not remove the event listener as it's live for the entire app lifetime // Do not remove the event listener as it's live for the entire app lifetime
document.addEventListener('mousemove', this.checkNextCron); document.addEventListener('mousemove', this.checkNextCron);
@@ -339,6 +338,11 @@ export default {
document.removeEventListener('keydown', this.checkNextCron); document.removeEventListener('keydown', this.checkNextCron);
}, },
methods: { methods: {
runForcedModals () {
if (!this.user.flags.verifiedUsername) return this.$root.$emit('bv::show::modal', 'verify-username');
return this.runYesterDailies();
},
showDeathModal () { showDeathModal () {
this.playSound('Death'); this.playSound('Death');
this.$root.$emit('bv::show::modal', 'death'); this.$root.$emit('bv::show::modal', 'death');
@@ -413,21 +417,25 @@ export default {
// List of prompts for user on changes. Sounds like we may need a refactor here, but it is clean for now // List of prompts for user on changes. Sounds like we may need a refactor here, but it is clean for now
if (!this.user.flags.welcomed) { if (!this.user.flags.welcomed) {
this.$store.state.avatarEditorOptions.editingUser = false; this.$store.state.avatarEditorOptions.editingUser = false;
this.$root.$emit('bv::show::modal', 'avatar-modal'); return this.$root.$emit('bv::show::modal', 'avatar-modal');
}
if (this.user.flags.newStuff) {
return this.$root.$emit('bv::show::modal', 'new-stuff');
} }
if (this.user.stats.hp <= 0) { if (this.user.stats.hp <= 0) {
this.showDeathModal(); return this.showDeathModal();
} }
if (this.questCompleted) { if (this.questCompleted) {
this.$root.$emit('bv::show::modal', 'quest-completed');
this.playSound('Achievement_Unlocked'); this.playSound('Achievement_Unlocked');
return this.$root.$emit('bv::show::modal', 'quest-completed');
} }
if (this.userClassSelect) { if (this.userClassSelect) {
this.$root.$emit('bv::show::modal', 'choose-class');
this.playSound('Achievement_Unlocked'); this.playSound('Achievement_Unlocked');
return this.$root.$emit('bv::show::modal', 'choose-class');
} }
}, },
showLevelUpNotifications (newlevel) { showLevelUpNotifications (newlevel) {
@@ -520,10 +528,6 @@ export default {
async handleUserNotifications (after) { async handleUserNotifications (after) {
if (this.$store.state.isRunningYesterdailies) return; if (this.$store.state.isRunningYesterdailies) return;
if (this.user.flags.newStuff) {
this.$root.$emit('bv::show::modal', 'new-stuff');
}
if (!after || after.length === 0 || !Array.isArray(after)) return; if (!after || after.length === 0 || !Array.isArray(after)) return;
let notificationsToRead = []; let notificationsToRead = [];

View File

@@ -130,8 +130,10 @@
h5 {{ $t('changeDisplayName') }} h5 {{ $t('changeDisplayName') }}
.form(name='changeDisplayName', novalidate) .form(name='changeDisplayName', novalidate)
.form-group .form-group
input#changeDisplayname.form-control(type='text', :placeholder="$t('newDisplayName')", v-model='temporaryDisplayName') input#changeDisplayname.form-control(type='text', :placeholder="$t('newDisplayName')", v-model='temporaryDisplayName', :class='{"is-invalid input-invalid": displayNameInvalid}')
button.btn.btn-primary(type='submit', @click='changeDisplayName(temporaryDisplayName)') {{ $t('submit') }} .mb-3(v-if="displayNameIssues.length > 0")
.input-error(v-for="issue in displayNameIssues") {{ issue }}
button.btn.btn-primary(type='submit', @click='changeDisplayName(temporaryDisplayName)', :disabled='displayNameCannotSubmit') {{ $t('submit') }}
h5 {{ $t('changeUsername') }} h5 {{ $t('changeUsername') }}
.form(name='changeUsername', novalidate) .form(name='changeUsername', novalidate)
@@ -252,6 +254,7 @@ export default {
password: '', password: '',
confirmPassword: '', confirmPassword: '',
}, },
displayNameIssues: [],
usernameIssues: [], usernameIssues: [],
}; };
}, },
@@ -312,6 +315,18 @@ export default {
verifiedUsername () { verifiedUsername () {
return this.user.flags.verifiedUsername; return this.user.flags.verifiedUsername;
}, },
displayNameInvalid () {
if (this.temporaryDisplayName.length <= 1) return false;
return !this.displayNameValid;
},
displayNameValid () {
if (this.temporaryDisplayName.length <= 1) return false;
return this.displayNameIssues.length === 0;
},
displayNameCannotSubmit () {
if (this.temporaryDisplayName.length <= 1) return true;
return !this.displayNameValid;
},
usernameValid () { usernameValid () {
if (this.usernameUpdates.username.length <= 1) return false; if (this.usernameUpdates.username.length <= 1) return false;
return this.usernameIssues.length === 0; return this.usernameIssues.length === 0;
@@ -332,10 +347,30 @@ export default {
}, },
deep: true, deep: true,
}, },
temporaryDisplayName: {
handler () {
this.validateDisplayName(this.temporaryDisplayName);
},
deep: true,
},
}, },
methods: { methods: {
// eslint-disable-next-line func-names validateDisplayName: debounce(function checkName (displayName) {
validateUsername: debounce(function (username) { if (displayName.length <= 1 || displayName === this.user.profile.name) {
this.displayNameIssues = [];
return;
}
this.$store.dispatch('auth:verifyDisplayName', {
displayName,
}).then(res => {
if (res.issues !== undefined) {
this.displayNameIssues = res.issues;
} else {
this.displayNameIssues = [];
}
});
}, 500),
validateUsername: debounce(function checkName (username) {
if (username.length <= 1 || username === this.user.auth.local.username) { if (username.length <= 1 || username === this.user.auth.local.username) {
this.usernameIssues = []; this.usernameIssues = [];
return; return;

View File

@@ -0,0 +1,221 @@
<template lang="pug">
div
.form-group
.d-flex.align-items-center
label.mr-3(for='displayName') {{ $t('displayName') }}
.flex-grow-1
input#displayName.form-control(
type='text',
:placeholder="$t('newDisplayName')",
v-model='temporaryDisplayName',
@blur='restoreEmptyDisplayName()',
:class='{"is-invalid input-invalid": displayNameInvalid, "input-valid": displayNameValid, "text-darker": temporaryDisplayName.length > 0}')
.mb-3(v-if="displayNameIssues.length > 0")
.input-error.text-center(v-for="issue in displayNameIssues") {{ issue }}
.form-group
.d-flex.align-items-center
label.mr-3(for='username') {{ $t('username') }}
.flex-grow-1
.input-group-prepend.input-group-text @
input#username.form-control(
type='text',
:placeholder="$t('newUsername')",
v-model='temporaryUsername',
@blur='restoreEmptyUsername()',
:class='{"is-invalid input-invalid": usernameInvalid, "input-valid": usernameValid, "text-darker": temporaryUsername.length > 0}')
.mb-3(v-if="usernameIssues.length > 0")
.input-error.text-center(v-for="issue in usernameIssues") {{ issue }}
.small.text-center.mb-3(v-if='!avatarIntro') {{ $t('usernameLimitations') }}
.row.justify-content-center
button.btn.btn-primary(type='submit', @click='submitNames()', :class='{disabled: usernameCannotSubmit}', :disabled='usernameCannotSubmit') {{ $t(avatarIntro ? 'getStarted' : 'saveAndConfirm') }}
</template>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
button {
margin: 0.25rem auto 1rem;
}
.col-3 {
padding-right: 0rem;
}
.form-control {
height: 2.25rem;
}
.form-group {
background-color: $gray-700;
border-radius: 2px;
border: solid 1px $gray-500;
}
input {
border: 0px;
}
.input-error {
color: $red-50;
font-size: 90%;
width: 100%;
}
.input-group-prepend {
margin-right: 0px;
}
.input-group-text {
background-color: $white;
border: 0px;
border-radius: 0px;
color: $gray-300;
padding: 0rem 0.1rem 0rem 0.75rem;
}
label {
color: $gray-100;
font-weight: bold;
margin-bottom: 0rem;
margin-left: 1rem;
min-width: 90px;
}
.small {
color: $gray-200;
}
.text-darker {
color: $gray-50;
}
#username {
padding-left: 0.25rem;
}
</style>
<script>
import axios from 'axios';
import debounce from 'lodash/debounce';
import { mapState } from 'client/libs/store';
export default {
computed: {
...mapState({
user: 'user.data',
}),
displayNameInvalid () {
if (this.temporaryDisplayName.length < 1) return false;
return !this.displayNameValid;
},
displayNameValid () {
if (this.temporaryDisplayName.length < 1) return false;
return this.displayNameIssues.length === 0;
},
usernameCannotSubmit () {
if (this.temporaryUsername.length < 1) return true;
return !this.usernameValid || !this.displayNameValid;
},
usernameInvalid () {
if (this.temporaryUsername.length < 1) return false;
return !this.usernameValid;
},
usernameValid () {
if (this.temporaryUsername.length < 1) return false;
return this.usernameIssues.length === 0;
},
},
data () {
return {
displayNameIssues: [],
temporaryDisplayName: '',
temporaryUsername: '',
usernameIssues: [],
};
},
methods: {
async close () {
this.$root.$emit('habitica::resync-requested');
await this.$store.dispatch('user:fetch', {forceLoad: true});
this.$root.$emit('habitica::resync-completed');
if (this.avatarIntro) {
this.$emit('usernameConfirmed');
} else {
this.$root.$emit('bv::hide::modal', 'verify-username');
this.$router.go(0);
}
},
restoreEmptyDisplayName () {
if (this.temporaryDisplayName.length < 1) {
this.temporaryDisplayName = this.user.profile.name;
}
},
restoreEmptyUsername () {
if (this.temporaryUsername.length < 1) {
this.temporaryUsername = this.user.auth.local.username;
}
},
async submitNames () {
if (this.temporaryDisplayName !== this.user.profile.name) {
await axios.put('/api/v4/user/', {'profile.name': this.temporaryDisplayName});
}
await axios.put('/api/v4/user/auth/update-username', {username: this.temporaryUsername});
this.close();
},
validateDisplayName: debounce(function checkName (displayName) {
if (displayName.length <= 1 || displayName === this.user.profile.name) {
this.displayNameIssues = [];
return;
}
this.$store.dispatch('auth:verifyDisplayName', {
displayName,
}).then(res => {
if (res.issues !== undefined) {
this.displayNameIssues = res.issues;
} else {
this.displayNameIssues = [];
}
});
}, 500),
validateUsername: debounce(function checkName (username) {
if (username.length <= 1 || username === this.user.auth.local.username) {
this.usernameIssues = [];
return;
}
this.$store.dispatch('auth:verifyUsername', {
username,
}).then(res => {
if (res.issues !== undefined) {
this.usernameIssues = res.issues;
} else {
this.usernameIssues = [];
}
});
}, 500),
},
mounted () {
this.temporaryDisplayName = this.user.profile.name;
this.temporaryUsername = this.user.auth.local.username;
},
props: {
avatarIntro: {
type: Boolean,
default: false,
},
},
watch: {
temporaryDisplayName: {
handler () {
this.validateDisplayName(this.temporaryDisplayName);
},
deep: true,
},
temporaryUsername: {
handler () {
this.validateUsername(this.temporaryUsername);
},
deep: true,
},
},
};
</script>

View File

@@ -0,0 +1,84 @@
<template lang="pug">
b-modal#verify-username(
size="m",
:no-close-on-backdrop="true",
:no-close-on-esc="true",
:hide-header="true",
:hide-footer="true",
@hide="$emit('hide')",
).d-flex
div.nametag-header(v-html='icons.helloNametag')
h2.text-center {{ $t('usernameTime') }}
p.text-center(v-html="$t('usernameInfo')")
username-form
.scene_veteran_pets.center-block
.small.text-center.mb-3 {{ $t('verifyUsernameVeteranPet') }}
.small.text-center.tos-footer(v-html="$t('usernameTOSRequirements')")
</template>
<style lang="scss">
#verify-username___BV_modal_outer_ {
.modal-content {
height: 100%;
width: 566px;
padding-left: 2rem;
padding-right: 2rem;
}
}
</style>
<style lang="scss" scoped>
@import '~client/assets/scss/colors.scss';
.center-block {
margin: 0 auto 1em auto;
}
h2 {
color: $purple-200;
margin-top: 1.5rem;
}
.nametag-header {
background-color: $gray-700;
border-radius: 0.3rem 0.3rem 0rem 0rem;
margin-left: -3rem;
margin-right: -3rem;
padding: 1rem 9rem 1rem 9rem;
}
p {
color: $gray-100;
}
.small {
color: $gray-200;
}
.tos-footer {
background-color: $gray-700;
border-radius: 0rem 0rem 0.3rem 0.3rem;
margin-left: -3rem;
margin-right: -3rem;
margin-top: -0.1rem;
padding: 1rem 4rem 1rem 4rem;
}
</style>
<script>
import helloNametag from 'assets/svg/hello-habitican.svg';
import usernameForm from './usernameForm';
export default {
components: {
usernameForm,
},
data () {
return {
icons: Object.freeze({
helloNametag,
}),
};
},
};
</script>

View File

@@ -9,7 +9,7 @@
position: fixed; position: fixed;
right: 10px; right: 10px;
width: 350px; width: 350px;
z-index: 1070; // 1070 is above modal backgrounds z-index: 1400; // 1400 is above modal backgrounds
&-top-pos { &-top-pos {
&-normal { &-normal {

View File

@@ -560,6 +560,7 @@
<script> <script>
import hello from 'hellojs'; import hello from 'hellojs';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import isEmail from 'validator/lib/isEmail';
import googlePlay from 'assets/images/home/google-play-badge.svg'; import googlePlay from 'assets/images/home/google-play-badge.svg';
import iosAppStore from 'assets/images/home/ios-app-store.svg'; import iosAppStore from 'assets/images/home/ios-app-store.svg';
import iphones from 'assets/images/home/iphones.svg'; import iphones from 'assets/images/home/iphones.svg';
@@ -626,18 +627,18 @@
computed: { computed: {
emailValid () { emailValid () {
if (this.email.length <= 3) return false; if (this.email.length <= 3) return false;
return this.validateEmail(this.email); return isEmail(this.email);
}, },
emailInvalid () { emailInvalid () {
if (this.email.length <= 3) return false; if (this.email.length <= 3) return false;
return !this.validateEmail(this.email); return !isEmail(this.email);
}, },
usernameValid () { usernameValid () {
if (this.username.length <= 3) return false; if (this.username.length < 1) return false;
return this.usernameIssues.length === 0; return this.usernameIssues.length === 0;
}, },
usernameInvalid () { usernameInvalid () {
if (this.username.length <= 3) return false; if (this.username.length < 1) return false;
return !this.usernameValid; return !this.usernameValid;
}, },
passwordConfirmValid () { passwordConfirmValid () {
@@ -655,13 +656,9 @@
}, },
}, },
methods: { methods: {
validateEmail (email) {
let re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return re.test(email);
},
// eslint-disable-next-line func-names // eslint-disable-next-line func-names
validateUsername: debounce(function (username) { validateUsername: debounce(function (username) {
if (username.length <= 3) { if (username.length < 1) {
return; return;
} }
this.$store.dispatch('auth:verifyUsername', { this.$store.dispatch('auth:verifyUsername', {

View File

@@ -30,8 +30,11 @@
.conversation(v-for='conversation in filtersConversations', @click='selectConversation(conversation.key)', .conversation(v-for='conversation in filtersConversations', @click='selectConversation(conversation.key)',
:class="{active: selectedConversation.key === conversation.key}") :class="{active: selectedConversation.key === conversation.key}")
div div
span(:class="userLevelStyle(conversation)") {{conversation.name}} h3(:class="userLevelStyle(conversation)") {{ conversation.name }}
span.timeago {{conversation.date | timeAgo}} .svg-icon(v-html="tierIcon(conversation)")
.time
span.mr-1(v-if='conversation.username') @{{ conversation.username }}
span {{ conversation.date | timeAgo }}
div {{conversation.lastMessageText ? conversation.lastMessageText.substring(0, 30) : ''}} div {{conversation.lastMessageText ? conversation.lastMessageText.substring(0, 30) : ''}}
.col-8.messages.d-flex.flex-column.justify-content-between .col-8.messages.d-flex.flex-column.justify-content-between
.empty-messages.text-center(v-if='!selectedConversation.key') .empty-messages.text-center(v-if='!selectedConversation.key')
@@ -63,6 +66,7 @@
<style lang="scss" scoped> <style lang="scss" scoped>
@import '~client/assets/scss/colors.scss'; @import '~client/assets/scss/colors.scss';
@import '~client/assets/scss/tiers.scss';
.header-wrap { .header-wrap {
padding: 0.5em; padding: 0.5em;
@@ -73,6 +77,16 @@
} }
} }
h3 {
margin: 0rem;
.svg-icon {
width: 10px;
display: inline-block;
margin-left: .5em;
}
}
.envelope { .envelope {
color: $gray-400 !important; color: $gray-400 !important;
margin: 0; margin: 0;
@@ -180,11 +194,6 @@
.conversation { .conversation {
padding: 1.5em; padding: 1.5em;
background: $white; background: $white;
height: 80px;
.timeago {
margin-left: 1em;
}
} }
.conversation.active { .conversation.active {
@@ -194,6 +203,12 @@
.conversation:hover { .conversation:hover {
cursor: pointer; cursor: pointer;
} }
.time {
font-size: 12px;
color: $gray-200;
margin-bottom: 0.5rem;
}
</style> </style>
<script> <script>
@@ -207,9 +222,19 @@ import styleHelper from 'client/mixins/styleHelper';
import toggleSwitch from 'client/components/ui/toggleSwitch'; import toggleSwitch from 'client/components/ui/toggleSwitch';
import axios from 'axios'; import axios from 'axios';
import messageIcon from 'assets/svg/message.svg';
import chatMessages from '../chat/chatMessages'; import chatMessages from '../chat/chatMessages';
import messageIcon from 'assets/svg/message.svg';
import svgClose from 'assets/svg/close.svg'; import svgClose from 'assets/svg/close.svg';
import tier1 from 'assets/svg/tier-1.svg';
import tier2 from 'assets/svg/tier-2.svg';
import tier3 from 'assets/svg/tier-3.svg';
import tier4 from 'assets/svg/tier-4.svg';
import tier5 from 'assets/svg/tier-5.svg';
import tier6 from 'assets/svg/tier-6.svg';
import tier7 from 'assets/svg/tier-7.svg';
import tier8 from 'assets/svg/tier-mod.svg';
import tier9 from 'assets/svg/tier-staff.svg';
import tierNPC from 'assets/svg/tier-npc.svg';
export default { export default {
mixins: [styleHelper], mixins: [styleHelper],
@@ -236,8 +261,11 @@ export default {
} }
this.initiatedConversation = { this.initiatedConversation = {
user: data.userName,
uuid: data.userIdToMessage, uuid: data.userIdToMessage,
user: data.displayName,
username: data.username,
backer: data.backer,
contributor: data.contributor,
}; };
this.selectConversation(data.userIdToMessage); this.selectConversation(data.userIdToMessage);
@@ -252,6 +280,16 @@ export default {
icons: Object.freeze({ icons: Object.freeze({
messageIcon, messageIcon,
svgClose, svgClose,
tier1,
tier2,
tier3,
tier4,
tier5,
tier6,
tier7,
tier8,
tier9,
tierNPC,
}), }),
displayCreate: true, displayCreate: true,
selectedConversation: {}, selectedConversation: {},
@@ -276,10 +314,11 @@ export default {
// Add placeholder for new conversations // Add placeholder for new conversations
if (this.initiatedConversation && this.initiatedConversation.uuid) { if (this.initiatedConversation && this.initiatedConversation.uuid) {
inboxGroup[this.initiatedConversation.uuid] = [{ inboxGroup[this.initiatedConversation.uuid] = [{
uuid: this.initiatedConversation.uuid,
user: this.initiatedConversation.user,
username: this.initiatedConversation.username,
id: '', id: '',
text: '', text: '',
user: this.initiatedConversation.user,
uuid: this.initiatedConversation.uuid,
timestamp: new Date(), timestamp: new Date(),
}]; }];
} }
@@ -296,8 +335,12 @@ export default {
if (newChat.sent) { if (newChat.sent) {
newChat.toUUID = newChat.uuid; newChat.toUUID = newChat.uuid;
newChat.toUser = newChat.user; newChat.toUser = newChat.user;
newChat.toUserName = newChat.username;
newChat.toUserContributor = newChat.contributor;
newChat.toUserBacker = newChat.backer;
newChat.uuid = this.user._id; newChat.uuid = this.user._id;
newChat.user = this.user.profile.name; newChat.user = this.user.profile.name;
newChat.username = this.user.auth.local.username;
newChat.contributor = this.user.contributor; newChat.contributor = this.user.contributor;
newChat.backer = this.user.backer; newChat.backer = this.user.backer;
} }
@@ -309,11 +352,12 @@ export default {
if (!recentMessage.text) newChatModels.splice(newChatModels.length - 1, 1); if (!recentMessage.text) newChatModels.splice(newChatModels.length - 1, 1);
const convoModel = { const convoModel = {
name: recentMessage.toUser ? recentMessage.toUser : recentMessage.user, // Handles case where from user sent the only message or the to user sent the only message
key: recentMessage.toUUID ? recentMessage.toUUID : recentMessage.uuid, key: recentMessage.toUUID ? recentMessage.toUUID : recentMessage.uuid,
messages: newChatModels, name: recentMessage.toUser ? recentMessage.toUser : recentMessage.user, // Handles case where from user sent the only message or the to user sent the only message
lastMessageText: recentMessage.text, username: !recentMessage.text ? recentMessage.username : recentMessage.toUserName,
date: recentMessage.timestamp, date: recentMessage.timestamp,
lastMessageText: recentMessage.text,
messages: newChatModels,
}; };
convos.push(convoModel); convos.push(convoModel);
@@ -382,8 +426,11 @@ export default {
const messageIndex = this.messages.findIndex(msg => msg.id === message.id); const messageIndex = this.messages.findIndex(msg => msg.id === message.id);
if (messageIndex !== -1) this.messages.splice(messageIndex, 1); if (messageIndex !== -1) this.messages.splice(messageIndex, 1);
if (this.selectedConversationMessages.length === 0) this.initiatedConversation = { if (this.selectedConversationMessages.length === 0) this.initiatedConversation = {
user: this.selectedConversation.name,
uuid: this.selectedConversation.key, uuid: this.selectedConversation.key,
user: this.selectedConversation.name,
username: this.selectedConversation.username,
backer: this.selectedConversation.backer,
contributor: this.selectedConversation.contributor,
}; };
}, },
toggleClick () { toggleClick () {
@@ -413,6 +460,7 @@ export default {
text: this.newMessage, text: this.newMessage,
timestamp: new Date(), timestamp: new Date(),
user: this.selectedConversation.name, user: this.selectedConversation.name,
username: this.selectedConversation.username,
uuid: this.selectedConversation.key, uuid: this.selectedConversation.key,
contributor: this.user.contributor, contributor: this.user.contributor,
}); });
@@ -444,6 +492,14 @@ export default {
close () { close () {
this.$root.$emit('bv::hide::modal', 'inbox-modal'); this.$root.$emit('bv::hide::modal', 'inbox-modal');
}, },
tierIcon (message) {
const isNPC = Boolean(message.backer && message.backer.npc);
if (isNPC) {
return this.icons.tierNPC;
}
if (!message.contributor) return;
return this.icons[`tier${message.contributor.level}`];
},
}, },
}; };
</script> </script>

View File

@@ -40,11 +40,12 @@ div
#userProfile.standard-page(v-show='selectedPage === "profile"', v-if='user.profile') #userProfile.standard-page(v-show='selectedPage === "profile"', v-if='user.profile')
.row .row
.col-12.col-md-8 .col-12.col-md-8
.header .header.mb-3
h1 {{user.profile.name}} h1 {{user.profile.name}}
h4 div
strong {{ $t('userId') }}:&nbsp; .name(v-if='user.auth && user.auth.local && user.auth.local.username') @{{ user.auth.local.username }}
| {{user._id}} div
.name {{ user._id }}
.col-12.col-md-4 .col-12.col-md-4
button.btn.btn-secondary(v-if='user._id === userLoggedIn._id', @click='editing = !editing') {{ $t('edit') }} button.btn.btn-secondary(v-if='user._id === userLoggedIn._id', @click='editing = !editing') {{ $t('edit') }}
.row(v-if='!editing') .row(v-if='!editing')
@@ -146,7 +147,7 @@ div
#profile { #profile {
.member-details { .member-details {
.character-name, small, .small-text { .character-name, small, .small-text {
color: #878190 color: #878190;
} }
} }
@@ -193,7 +194,7 @@ div
.gift-icon { .gift-icon {
width: 14px; width: 14px;
margin: auto; margin: auto;
color: #686274; color: $gray-100;
} }
.gift-icon { .gift-icon {
@@ -201,13 +202,13 @@ div
} }
.remove-icon { .remove-icon {
width:16px; width: 16px;
color: #686274; color: $gray-100;
} }
.positive-icon { .positive-icon {
width: 14px; width: 14px;
color: #686274; color: $gray-100;
} }
.photo img { .photo img {
@@ -216,11 +217,12 @@ div
.header { .header {
h1 { h1 {
color: #4f2a93; color: $purple-200;
margin-bottom: 0rem;
} }
h4 { h4 {
color: #686274; color: $gray-100;
} }
} }
@@ -241,6 +243,11 @@ div
cursor: pointer; cursor: pointer;
} }
.name {
color: $gray-200;
font-size: 16px;
}
#achievements { #achievements {
.box { .box {
margin: 0 auto; margin: 0 auto;
@@ -463,7 +470,10 @@ export default {
sendMessage () { sendMessage () {
this.$root.$emit('habitica::new-inbox-message', { this.$root.$emit('habitica::new-inbox-message', {
userIdToMessage: this.user._id, 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 () { getProgressDisplay () {

View File

@@ -55,6 +55,15 @@ export async function verifyUsername (store, params) {
return result.data.data; 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) { export async function socialAuth (store, params) {
let url = '/api/v4/user/auth/social'; let url = '/api/v4/user/auth/social';
let result = await axios.post(url, { let result = await axios.post(url, {

View File

@@ -164,6 +164,7 @@ export async function invite (store, payload) {
let response = await axios.post(`/api/v4/groups/${payload.groupId}/invite`, { let response = await axios.post(`/api/v4/groups/${payload.groupId}/invite`, {
uuids: payload.invitationDetails.uuids, uuids: payload.invitationDetails.uuids,
emails: payload.invitationDetails.emails, emails: payload.invitationDetails.emails,
usernames: payload.invitationDetails.usernames,
}); });
// @TODO: find guild and add invites // @TODO: find guild and add invites

View File

@@ -151,3 +151,14 @@ export async function togglePrivateMessagesOpt (store) {
store.state.user.data.inbox.optOut = !store.state.user.data.inbox.optOut; store.state.user.data.inbox.optOut = !store.state.user.data.inbox.optOut;
return response; 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;
}

View File

@@ -7,7 +7,7 @@
"noPhoto": "This Habitican hasn't added a photo.", "noPhoto": "This Habitican hasn't added a photo.",
"other": "Other", "other": "Other",
"fullName": "Full Name", "fullName": "Full Name",
"displayName": "Display Name", "displayName": "Display name",
"changeDisplayName": "Change Display Name", "changeDisplayName": "Change Display Name",
"newDisplayName": "New Display Name", "newDisplayName": "New Display Name",
"displayPhoto": "Photo", "displayPhoto": "Photo",

View File

@@ -271,15 +271,9 @@
"emailTaken": "Email address is already used in an account.", "emailTaken": "Email address is already used in an account.",
"newEmailRequired": "Missing new email address.", "newEmailRequired": "Missing new email address.",
"usernameTime": "It's time to set your username!", "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.<br><br>If you'd like to learn more about this change, visit the wiki's <a href='http://habitica.wikia.com/wiki/Player_Names' target='_blank'>Player Names</a> page.", "usernameInfo": "Login names are now unique usernames that will be visible beside your display name and used for invitations, chat @mentions, and messaging.<br><br>If you'd like to learn more about this change, <a href='http://habitica.wikia.com/wiki/Player_Names' target='_blank'>visit our wiki</a>.",
"usernameTOSRequirements": "Usernames must conform to our Terms of Service and Community Guidelines. If you didnt previously set a login name, your username was auto-generated.", "usernameTOSRequirements": "Usernames must conform to our <a href='/static/terms' target='_blank'>Terms of Service</a> and <a href='/static/community-guidelines' target='_blank'>Community Guidelines</a>. If you didnt previously set a login name, your username was auto-generated.",
"usernameTaken": "Username already taken.", "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.", "passwordConfirmationMatch": "Password confirmation doesn't match password.",
"invalidLoginCredentials": "Incorrect username and/or email and/or password.", "invalidLoginCredentials": "Incorrect username and/or email and/or password.",
"passwordResetPage": "Reset Password", "passwordResetPage": "Reset Password",
@@ -335,7 +329,7 @@
"joinToday": "Join Habitica Today", "joinToday": "Join Habitica Today",
"featuredIn": "Featured in", "featuredIn": "Featured in",
"signup": "Sign Up", "signup": "Sign Up",
"getStarted": "Get Started", "getStarted": "Get Started!",
"mobileApps": "Mobile Apps", "mobileApps": "Mobile Apps",
"learnMore": "Learn More" "learnMore": "Learn More"
} }

View File

@@ -254,6 +254,7 @@
"resetFilters": "Clear all filters", "resetFilters": "Clear all filters",
"applyFilters": "Apply Filters", "applyFilters": "Apply Filters",
"wantToWorkOn": "I want to work on:",
"categories": "Categories", "categories": "Categories",
"habiticaOfficial": "Habitica Official", "habiticaOfficial": "Habitica Official",
"animals": "Animals", "animals": "Animals",

View File

@@ -185,7 +185,7 @@
"inviteExistUser": "Invite Existing Users", "inviteExistUser": "Invite Existing Users",
"byColon": "By:", "byColon": "By:",
"inviteNewUsers": "Invite New Users", "inviteNewUsers": "Invite New Users",
"sendInvitations": "Send Invitations", "sendInvitations": "Send Invites",
"invitationsSent": "Invitations sent!", "invitationsSent": "Invitations sent!",
"invitationSent": "Invitation sent!", "invitationSent": "Invitation sent!",
"invitedFriend": "Invited a Friend", "invitedFriend": "Invited a Friend",
@@ -229,7 +229,7 @@
"memberCannotRemoveYourself": "You cannot remove yourself!", "memberCannotRemoveYourself": "You cannot remove yourself!",
"groupMemberNotFound": "User not found among group's members", "groupMemberNotFound": "User not found among group's members",
"mustBeGroupMember": "Must be member of the group.", "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.", "inviteMissingEmail": "Missing email address in invite.",
"inviteMissingUuid": "Missing user id in invite", "inviteMissingUuid": "Missing user id in invite",
"inviteMustNotBeEmpty": "Invite must not be empty.", "inviteMustNotBeEmpty": "Invite must not be empty.",
@@ -241,9 +241,11 @@
"userAlreadyPendingInvitation": "UserID: <%= userId %>, User \"<%= username %>\" already pending invitation.", "userAlreadyPendingInvitation": "UserID: <%= userId %>, User \"<%= username %>\" already pending invitation.",
"userAlreadyInAParty": "UserID: <%= userId %>, User \"<%= username %>\" already in a party. ", "userAlreadyInAParty": "UserID: <%= userId %>, User \"<%= username %>\" already in a party. ",
"userWithIDNotFound": "User with id \"<%= userId %>\" not found.", "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).", "userHasNoLocalRegistration": "User does not have a local registration (username, email, password).",
"uuidsMustBeAnArray": "User ID invites must be an array.", "uuidsMustBeAnArray": "User ID invites must be an array.",
"emailsMustBeAnArray": "Email address 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", "canOnlyInviteMaxInvites": "You can only invite \"<%= maxInvites %>\" at a time",
"partyExceedsMembersLimit": "Party size is limited to <%= maxMembersParty %> members", "partyExceedsMembersLimit": "Party size is limited to <%= maxMembersParty %> members",
"onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!", "onlyCreatorOrAdminCanDeleteChat": "Not authorized to delete this message!",
@@ -366,6 +368,10 @@
"liked": "Liked", "liked": "Liked",
"joinGuild": "Join Guild", "joinGuild": "Join Guild",
"inviteToGuild": "Invite to 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", "messageGuildLeader": "Message Guild Leader",
"donateGems": "Donate Gems", "donateGems": "Donate Gems",
"updateGuild": "Update Guild", "updateGuild": "Update Guild",

View File

@@ -70,5 +70,7 @@
"beginningOfConversation": "This is the beginning of your conversation with <%= userName %>. Remember to be kind, respectful, and follow the Community Guidelines!", "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."
} }

View File

@@ -6,9 +6,11 @@
"welcomeTo": "Welcome to", "welcomeTo": "Welcome to",
"welcomeBack": "Welcome back!", "welcomeBack": "Welcome back!",
"justin": "Justin", "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 <strong>Justin</strong>, and I'll be your guide in Habitica.",
"justinIntroMessage2": "To start, you'll need to create an avatar.", "justinIntroMessage2": "To start, you'll need to create an avatar.",
"justinIntroMessage3": "Great! Now, what are you interested in working on throughout this journey?", "justinIntroMessage3": "Great! Now, what are you interested in working on throughout this journey?",
"justinIntroMessageUsername": "Before we begin, lets figure out what to call you. Below youll find a display name and username Ive generated for you. After youve picked a display name and username, well get started by creating an avatar!",
"justinIntroMessageAppearance": "So how would you like to look? Dont 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!", "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", "prev": "Prev",
"next": "Next", "next": "Next",

View File

@@ -201,9 +201,10 @@
"usernameIssueInvalidCharacters": "Usernames can only contain letters a to z, numbers 0 to 9, hyphens, or underscores.", "usernameIssueInvalidCharacters": "Usernames can only contain letters a to z, numbers 0 to 9, hyphens, or underscores.",
"currentUsername": "Current username:", "currentUsername": "Current username:",
"displaynameIssueLength": "Display Names must be between 1 and 30 characters.", "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", "goToSettings": "Go to Settings",
"usernameVerifiedConfirmation": "Your username, <%= username %>, is confirmed!", "usernameVerifiedConfirmation": "Your username, <%= username %>, is confirmed!",
"usernameNotVerified": "Please confirm your username.", "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!"
} }

View File

@@ -210,7 +210,7 @@
"haveCouponCode": "Do you have a coupon code?", "haveCouponCode": "Do you have a coupon code?",
"subscriptionAlreadySubscribedLeadIn": "Thanks for subscribing!", "subscriptionAlreadySubscribedLeadIn": "Thanks for subscribing!",
"subscriptionAlreadySubscribed1": "To see your subscription details and cancel, renew, or change your subscription, please go to <a href='/user/settings/subscription'>User icon &gt; Settings &gt; Subscription</a>.", "subscriptionAlreadySubscribed1": "To see your subscription details and cancel, renew, or change your subscription, please go to <a href='/user/settings/subscription'>User icon &gt; Settings &gt; Subscription</a>.",
"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.", "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", "gemsRemaining": "gems remaining",
"notEnoughGemsToBuy": "You are unable to buy that amount of gems" "notEnoughGemsToBuy": "You are unable to buy that amount of gems"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -9,7 +9,6 @@ import {
model as User, model as User,
nameFields, nameFields,
} from '../../models/user'; } from '../../models/user';
import { model as EmailUnsubscription } from '../../models/emailUnsubscription';
import { import {
NotFound, NotFound,
BadRequest, BadRequest,
@@ -17,8 +16,11 @@ import {
} from '../../libs/errors'; } from '../../libs/errors';
import { removeFromArray } from '../../libs/collectionManipulators'; import { removeFromArray } from '../../libs/collectionManipulators';
import { sendTxn as sendTxnEmail } from '../../libs/email'; import { sendTxn as sendTxnEmail } from '../../libs/email';
import { encrypt } from '../../libs/encryption'; import {
import { sendNotification as sendPushNotification } from '../../libs/pushNotifications'; inviteByUUID,
inviteByEmail,
inviteByUserName,
} from '../../libs/invites';
import common from '../../../common'; import common from '../../../common';
import payments from '../../libs/payments/payments'; import payments from '../../libs/payments/payments';
import stripePayments from '../../libs/payments/stripe'; 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 * @api {post} /api/v3/groups/:groupId/invite Invite users to a group
* @apiName InviteToGroup * @apiName InviteToGroup
@@ -1147,7 +1007,7 @@ api.inviteToGroup = {
url: '/groups/:groupId/invite', url: '/groups/:groupId/invite',
middlewares: [authWithHeaders()], middlewares: [authWithHeaders()],
async handler (req, res) { async handler (req, res) {
let user = res.locals.user; const user = res.locals.user;
if (user.flags.chatRevoked) throw new NotAuthorized(res.t('cannotInviteWhenMuted')); 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 })); 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; 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) throw new NotFound(res.t('groupNotFound'));
if (group.purchased && group.purchased.plan.customerId && user._id !== group.leader) throw new NotAuthorized(res.t('onlyGroupLeaderCanInviteToGroupPlan')); if (group.purchased && group.purchased.plan.customerId && user._id !== group.leader) throw new NotAuthorized(res.t('onlyGroupLeaderCanInviteToGroupPlan'));
let uuids = req.body.uuids; const {
let emails = req.body.emails; 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) { if (uuids) {
let uuidInvites = uuids.map((uuid) => _inviteByUUID(uuid, group, user, req, res)); const uuidInvites = uuids.map((uuid) => inviteByUUID(uuid, group, user, req, res));
let uuidResults = await Promise.all(uuidInvites); const uuidResults = await Promise.all(uuidInvites);
results.push(...uuidResults); results.push(...uuidResults);
} }
if (emails) { 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; user.invitesSent += emails.length;
await user.save(); await user.save();
let emailResults = await Promise.all(emailInvites); const emailResults = await Promise.all(emailInvites);
results.push(...emailResults); 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 = { let analyticsObject = {
uuid: user._id, uuid: user._id,
hitType: 'event', hitType: 'event',

View File

@@ -109,6 +109,36 @@ api.getMember = {
if (!member) throw new NotFound(res.t('userWithIDNotFound', {userId: memberId})); 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 // manually call toJSON with minimize: true so empty paths aren't returned
let memberToJSON = member.toJSON({minimize: true}); let memberToJSON = member.toJSON({minimize: true});
User.addComputedStatsToJSONObj(memberToJSON.stats, member); User.addComputedStatsToJSONObj(memberToJSON.stats, member);
@@ -605,6 +635,7 @@ api.sendPrivateMessage = {
const message = req.body.message; const message = req.body.message;
const receiver = await User.findById(req.body.toUserId).exec(); const receiver = await User.findById(req.body.toUserId).exec();
if (!receiver) throw new NotFound(res.t('userNotFound')); 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); const objections = sender.getObjectionsToInteraction('send-private-message', receiver);
if (objections.length > 0 && !sender.isAdmin()) throw new NotAuthorized(res.t(objections[0])); if (objections.length > 0 && !sender.isAdmin()) throw new NotAuthorized(res.t(objections[0]));

View File

@@ -28,12 +28,14 @@ api.verifyUsername = {
const issues = verifyUsername(chosenUsername, res); const issues = verifyUsername(chosenUsername, res);
const existingUser = await User.findOne({ if (issues.length < 1) {
'auth.local.lowerCaseUsername': chosenUsername.toLowerCase(), const existingUser = await User.findOne({
}, {auth: 1}).exec(); 'auth.local.lowerCaseUsername': chosenUsername.toLowerCase(),
}, {auth: 1}).exec();
if (existingUser) { if (existingUser) {
if (!user || existingUser._id !== user._id) issues.push(res.t('usernameTaken')); if (!user || existingUser._id !== user._id) issues.push(res.t('usernameTaken'));
}
} }
if (issues.length > 0) { if (issues.length > 0) {

View File

@@ -1,5 +1,6 @@
import { authWithHeaders } from '../../middlewares/auth'; import { authWithHeaders } from '../../middlewares/auth';
import * as userLib from '../../libs/user'; import * as userLib from '../../libs/user';
import { verifyDisplayName } from '../../libs/user/validation';
const api = {}; 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; module.exports = api;

View File

@@ -79,8 +79,8 @@ async function registerLocal (req, res, { isV3 = false }) {
notEmpty: true, notEmpty: true,
errorMessage: res.t('missingUsername'), errorMessage: res.t('missingUsername'),
// TODO use the constants in the error message above // TODO use the constants in the error message above
isLength: {options: {min: USERNAME_LENGTH_MIN, max: USERNAME_LENGTH_MAX}, errorMessage: res.t('usernameWrongLength')}, isLength: {options: {min: USERNAME_LENGTH_MIN, max: USERNAME_LENGTH_MAX}, errorMessage: res.t('usernameIssueLength')},
matches: {options: /^[-_a-zA-Z0-9]+$/, errorMessage: res.t('usernameBadCharacters')}, matches: {options: /^[-_a-zA-Z0-9]+$/, errorMessage: res.t('usernameIssueInvalidCharacters')},
}, },
email: { email: {
notEmpty: true, notEmpty: true,
@@ -138,6 +138,9 @@ async function registerLocal (req, res, { isV3 = false }) {
preferences: { preferences: {
language: req.language, language: req.language,
}, },
flags: {
verifiedUsername: true,
},
}; };
if (existingUser) { if (existingUser) {
@@ -159,8 +162,6 @@ async function registerLocal (req, res, { isV3 = false }) {
await _handleGroupInvitation(newUser, req.query.groupInvite || req.query.partyInvite); await _handleGroupInvitation(newUser, req.query.groupInvite || req.query.partyInvite);
} }
newUser.flags.verifiedUsername = true;
let savedUser = await newUser.save(); let savedUser = await newUser.save();
let userToJSON; let userToJSON;

View File

@@ -61,6 +61,9 @@ async function loginSocial (req, res) {
preferences: { preferences: {
language: req.language, language: req.language,
}, },
flags: {
verifiedUsername: true,
},
}; };
if (existingUser) { if (existingUser) {

View File

@@ -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,
};

View File

@@ -26,6 +26,14 @@ function usernameContainsInvalidCharacters (username) {
return match !== null && match[0] !== null; 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) { export function verifyUsername (username, res) {
let issues = []; let issues = [];
if (username.length < 1 || username.length > 20) issues.push(res.t('usernameIssueLength')); if (username.length < 1 || username.length > 20) issues.push(res.t('usernameIssueLength'));

View File

@@ -350,40 +350,32 @@ schema.statics.toJSONCleanChat = async function groupToJSONCleanChat (group, use
return toJSON; return toJSON;
}; };
/** function getInviteError (uuids, emails, usernames) {
* Checks invitation uuids and emails for possible errors. const uuidsIsArray = Array.isArray(uuids);
* const emailsIsArray = Array.isArray(emails);
* @param uuids An array of user ids const usernamesIsArray = Array.isArray(usernames);
* @param emails An array of emails const emptyEmails = emailsIsArray && emails.length < 1;
* @param res Express res object for use with translations const emptyUuids = uuidsIsArray && uuids.length < 1;
* @throws BadRequest An error describing the issue with the invitations const emptyUsernames = usernamesIsArray && usernames.length < 1;
*/
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;
let errorString; let errorString;
if (!uuids && !emails) { if (!uuids && !emails && !usernames) {
errorString = 'canOnlyInviteEmailUuid'; errorString = 'canOnlyInviteEmailUuid';
} else if (uuids && !uuidsIsArray) { } else if (uuids && !uuidsIsArray) {
errorString = 'uuidsMustBeAnArray'; errorString = 'uuidsMustBeAnArray';
} else if (emails && !emailsIsArray) { } else if (emails && !emailsIsArray) {
errorString = 'emailsMustBeAnArray'; errorString = 'emailsMustBeAnArray';
} else if (!emails && emptyUuids) { } else if (usernames && !usernamesIsArray) {
errorString = 'inviteMissingUuid'; errorString = 'usernamesMustBeAnArray';
} else if (!uuids && emptyEmails) { } else if ((!emails || emptyEmails) && (!uuids || emptyUuids) && (!usernames || emptyUsernames)) {
errorString = 'inviteMissingEmail';
} else if (emptyEmails && emptyUuids) {
errorString = 'inviteMustNotBeEmpty'; errorString = 'inviteMustNotBeEmpty';
} }
if (errorString) { return errorString;
throw new BadRequest(res.t(errorString)); }
}
function getInviteCount (uuids, emails) {
let totalInvites = 0; let totalInvites = 0;
if (uuids) { if (uuids) {
@@ -394,6 +386,27 @@ schema.statics.validateInvitations = async function getInvitationError (uuids, e
totalInvites += emails.length; 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) { if (totalInvites > INVITES_LIMIT) {
throw new BadRequest(res.t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT})); throw new BadRequest(res.t('canOnlyInviteMaxInvites', {maxInvites: INVITES_LIMIT}));
} }

View File

@@ -9,7 +9,8 @@ const defaultSchema = () => ({
text: String, text: String,
// sender properties // sender properties
user: String, // profile name user: String, // profile name (unfortunately)
username: String,
contributor: {$type: mongoose.Schema.Types.Mixed}, contributor: {$type: mongoose.Schema.Types.Mixed},
backer: {$type: mongoose.Schema.Types.Mixed}, backer: {$type: mongoose.Schema.Types.Mixed},
uuid: String, // sender uuid uuid: String, // sender uuid
@@ -117,6 +118,7 @@ export function messageDefaults (msg, user) {
contributor: user.contributor && user.contributor.toObject(), contributor: user.contributor && user.contributor.toObject(),
backer: user.backer && user.backer.toObject(), backer: user.backer && user.backer.toObject(),
user: user.profile.name, user: user.profile.name,
username: user.flags && user.flags.verifiedUsername && user.auth && user.auth.local && user.auth.local.username,
}); });
} else { } else {
message.uuid = 'system'; message.uuid = 'system';

View File

@@ -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) // 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 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 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 // 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 }; export { schema };

View File

@@ -219,6 +219,9 @@ schema.statics.transformJSONUser = function transformJSONUser (jsonUser, addComp
// Add id property // Add id property
jsonUser.id = jsonUser._id; 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); if (addComputedStats) this.addComputedStatsToJSONObj(jsonUser.stats, jsonUser);
}; };