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