diff --git a/app/assets/javascripts/discourse/app/components/invite-link-panel.js b/app/assets/javascripts/discourse/app/components/invite-link-panel.js new file mode 100644 index 00000000000..e2691e36fac --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/invite-link-panel.js @@ -0,0 +1,98 @@ +import I18n from "I18n"; +import Component from "@ember/component"; +import Group from "discourse/models/group"; +import { readOnly } from "@ember/object/computed"; +import { action } from "@ember/object"; +import discourseComputed from "discourse-common/utils/decorators"; +import Invite from "discourse/models/invite"; + +export default Component.extend({ + inviteModel: readOnly("panel.model.inviteModel"), + userInvitedShow: readOnly("panel.model.userInvitedShow"), + isStaff: readOnly("currentUser.staff"), + maxRedemptionAllowed: 5, + inviteExpiresAt: moment() + .add(1, "month") + .format("YYYY-MM-DD"), + + willDestroyElement() { + this._super(...arguments); + + this.reset(); + }, + + @discourseComputed("isStaff", "inviteModel.saving", "maxRedemptionAllowed") + disabled(isStaff, saving, canInviteTo, maxRedemptionAllowed) { + if (saving) return true; + if (!isStaff) return true; + if (maxRedemptionAllowed < 2) return true; + + return false; + }, + + groupFinder(term) { + return Group.findAll({ term, ignore_automatic: true }); + }, + + errorMessage: I18n.t("user.invited.invite_link.error"), + + reset() { + this.set("maxRedemptionAllowed", 5); + + this.inviteModel.setProperties({ + groupNames: null, + error: false, + saving: false, + finished: false, + inviteLink: null + }); + }, + + @action + generateMultipleUseInviteLink() { + if (this.disabled) { + return; + } + + const groupNames = this.get("inviteModel.groupNames"); + const maxRedemptionAllowed = this.maxRedemptionAllowed; + const inviteExpiresAt = this.inviteExpiresAt; + const userInvitedController = this.userInvitedShow; + const model = this.inviteModel; + model.setProperties({ saving: true, error: false }); + + return model + .generateMultipleUseInviteLink( + groupNames, + maxRedemptionAllowed, + inviteExpiresAt + ) + .then(result => { + model.setProperties({ + saving: false, + finished: true, + inviteLink: result + }); + + if (userInvitedController) { + Invite.findInvitedBy( + this.currentUser, + userInvitedController.filter + ).then(inviteModel => { + userInvitedController.setProperties({ + model: inviteModel, + totalInvites: inviteModel.invites.length + }); + }); + } + }) + .catch(e => { + if (e.jqXHR.responseJSON && e.jqXHR.responseJSON.errors) { + this.set("errorMessage", e.jqXHR.responseJSON.errors[0]); + } else { + this.set("errorMessage", I18n.t("user.invited.invite_link.error")); + } + model.setProperties({ saving: false, error: true }); + }); + } +}); diff --git a/app/assets/javascripts/discourse/app/controllers/invites-show.js b/app/assets/javascripts/discourse/app/controllers/invites-show.js index b421b770583..a38ec6e1a93 100644 --- a/app/assets/javascripts/discourse/app/controllers/invites-show.js +++ b/app/assets/javascripts/discourse/app/controllers/invites-show.js @@ -1,16 +1,18 @@ import I18n from "I18n"; import { isEmpty } from "@ember/utils"; -import { alias, notEmpty } from "@ember/object/computed"; +import { alias, notEmpty, or, readOnly } from "@ember/object/computed"; import Controller from "@ember/controller"; import discourseComputed from "discourse-common/utils/decorators"; import getUrl from "discourse-common/lib/get-url"; import DiscourseURL from "discourse/lib/url"; import { ajax } from "discourse/lib/ajax"; +import { emailValid } from "discourse/lib/utilities"; import PasswordValidation from "discourse/mixins/password-validation"; import UsernameValidation from "discourse/mixins/username-validation"; import NameValidation from "discourse/mixins/name-validation"; import UserFieldsValidation from "discourse/mixins/user-fields-validation"; import { findAll as findLoginMethods } from "discourse/models/login-method"; +import EmberObject from "@ember/object"; export default Controller.extend( PasswordValidation, @@ -18,7 +20,7 @@ export default Controller.extend( NameValidation, UserFieldsValidation, { - invitedBy: alias("model.invited_by"), + invitedBy: readOnly("model.invited_by"), email: alias("model.email"), accountUsername: alias("model.username"), passwordRequired: notEmpty("accountPassword"), @@ -26,6 +28,21 @@ export default Controller.extend( errorMessage: null, userFields: null, inviteImageUrl: getUrl("/images/envelope.svg"), + isInviteLink: readOnly("model.is_invite_link"), + submitDisabled: or( + "emailValidation.failed", + "usernameValidation.failed", + "passwordValidation.failed", + "nameValidation.failed", + "userFieldsValidation.failed" + ), + rejectedEmails: null, + + init() { + this._super(...arguments); + + this.rejectedEmails = []; + }, @discourseComputed welcomeTitle() { @@ -44,21 +61,6 @@ export default Controller.extend( return findLoginMethods().length > 0; }, - @discourseComputed( - "usernameValidation.failed", - "passwordValidation.failed", - "nameValidation.failed", - "userFieldsValidation.failed" - ) - submitDisabled( - usernameFailed, - passwordFailed, - nameFailed, - userFieldsFailed - ) { - return usernameFailed || passwordFailed || nameFailed || userFieldsFailed; - }, - @discourseComputed fullnameRequired() { return ( @@ -66,6 +68,35 @@ export default Controller.extend( ); }, + @discourseComputed("email", "rejectedEmails.[]") + emailValidation(email, rejectedEmails) { + // If blank, fail without a reason + if (isEmpty(email)) { + return EmberObject.create({ + failed: true + }); + } + + if (rejectedEmails.includes(email)) { + return EmberObject.create({ + failed: true, + reason: I18n.t("user.email.invalid") + }); + } + + if (emailValid(email)) { + return EmberObject.create({ + ok: true, + reason: I18n.t("user.email.ok") + }); + } + + return EmberObject.create({ + failed: true, + reason: I18n.t("user.email.invalid") + }); + }, + actions: { submit() { const userFields = this.userFields; @@ -80,6 +111,7 @@ export default Controller.extend( url: `/invites/show/${this.get("model.token")}.json`, type: "PUT", data: { + email: this.email, username: this.accountUsername, name: this.accountName, password: this.accountPassword, @@ -97,6 +129,14 @@ export default Controller.extend( DiscourseURL.redirectTo(result.redirect_to); } } else { + if ( + result.errors && + result.errors.email && + result.errors.email.length > 0 && + result.values + ) { + this.rejectedEmails.pushObject(result.values.email); + } if ( result.errors && result.errors.password && diff --git a/app/assets/javascripts/discourse/app/controllers/user-invited-show.js b/app/assets/javascripts/discourse/app/controllers/user-invited-show.js index eb3a42fd145..7e8f67ebe28 100644 --- a/app/assets/javascripts/discourse/app/controllers/user-invited-show.js +++ b/app/assets/javascripts/discourse/app/controllers/user-invited-show.js @@ -1,5 +1,5 @@ import I18n from "I18n"; -import { equal, reads, gte } from "@ember/object/computed"; +import { equal, reads } from "@ember/object/computed"; import Controller from "@ember/controller"; import Invite from "discourse/models/invite"; import discourseDebounce from "discourse/lib/debounce"; @@ -35,21 +35,30 @@ export default Controller.extend({ }, INPUT_DELAY), inviteRedeemed: equal("filter", "redeemed"), + invitePending: equal("filter", "pending"), + + @discourseComputed("filter") + inviteLinks(filter) { + return filter === "links" && this.currentUser.staff; + }, @discourseComputed("filter") showBulkActionButtons(filter) { return ( filter === "pending" && this.model.invites.length > 4 && - this.currentUser.get("staff") + this.currentUser.staff ); }, canInviteToForum: reads("currentUser.can_invite_to_forum"), - canBulkInvite: reads("currentUser.admin"), + canSendInviteLink: reads("currentUser.staff"), - showSearch: gte("totalInvites", 10), + @discourseComputed("totalInvites", "inviteLinks") + showSearch(totalInvites, inviteLinks) { + return totalInvites >= 10 && !inviteLinks; + }, @discourseComputed("invitesCount.total", "invitesCount.pending") pendingLabel(invitesCountTotal, invitesCountPending) { @@ -73,6 +82,17 @@ export default Controller.extend({ } }, + @discourseComputed("invitesCount.total", "invitesCount.links") + linksLabel(invitesCountTotal, invitesCountLinks) { + if (invitesCountTotal > 50) { + return I18n.t("user.invited.links_tab_with_count", { + count: invitesCountLinks + }); + } else { + return I18n.t("user.invited.links_tab"); + } + }, + actions: { rescind(invite) { invite.rescind(); diff --git a/app/assets/javascripts/discourse/app/models/invite.js b/app/assets/javascripts/discourse/app/models/invite.js index 7eb142c0abb..347ca71d680 100644 --- a/app/assets/javascripts/discourse/app/models/invite.js +++ b/app/assets/javascripts/discourse/app/models/invite.js @@ -10,7 +10,7 @@ const Invite = EmberObject.extend({ rescind() { ajax("/invites", { type: "DELETE", - data: { email: this.email } + data: { id: this.id } }); this.set("rescinded", true); }, @@ -42,7 +42,14 @@ Invite.reopenClass({ if (!isNone(search)) data.search = search; data.offset = offset || 0; - return ajax(userPath(`${user.username_lower}/invited.json`), { + let path; + if (filter === "links") { + path = userPath(`${user.username_lower}/invite_links.json`); + } else { + path = userPath(`${user.username_lower}/invited.json`); + } + + return ajax(path, { data }).then(result => { result.invites = result.invites.map(i => Invite.create(i)); diff --git a/app/assets/javascripts/discourse/app/models/user.js b/app/assets/javascripts/discourse/app/models/user.js index a6356182f33..113b592c277 100644 --- a/app/assets/javascripts/discourse/app/models/user.js +++ b/app/assets/javascripts/discourse/app/models/user.js @@ -654,6 +654,17 @@ const User = RestModel.extend({ }); }, + generateMultipleUseInviteLink( + group_names, + max_redemptions_allowed, + expires_at + ) { + return ajax("/invites/link", { + type: "POST", + data: { group_names, max_redemptions_allowed, expires_at } + }); + }, + @observes("muted_category_ids") updateMutedCategories() { this.set("mutedCategories", Category.findByIds(this.muted_category_ids)); diff --git a/app/assets/javascripts/discourse/app/routes/user-invited-show.js b/app/assets/javascripts/discourse/app/routes/user-invited-show.js index 3cd83f79ceb..7af69e14dfd 100644 --- a/app/assets/javascripts/discourse/app/routes/user-invited-show.js +++ b/app/assets/javascripts/discourse/app/routes/user-invited-show.js @@ -30,18 +30,51 @@ export default DiscourseRoute.extend({ actions: { showInvite() { + const panels = [ + { + id: "invite", + title: "user.invited.single_user", + model: { + inviteModel: this.currentUser, + userInvitedShow: this.controllerFor("user-invited-show") + } + } + ]; + + if (this.get("currentUser.staff")) { + panels.push({ + id: "invite-link", + title: "user.invited.multiple_user", + model: { + inviteModel: this.currentUser, + userInvitedShow: this.controllerFor("user-invited-show") + } + }); + } + showModal("share-and-invite", { modalClass: "share-and-invite", - panels: [ - { - id: "invite", - title: "user.invited.create", - model: { - inviteModel: this.currentUser, - userInvitedShow: this.controllerFor("user-invited-show") - } + panels + }); + }, + + editInvite(inviteKey) { + const inviteLink = `${Discourse.BaseUrl}/invites/${inviteKey}`; + this.currentUser.setProperties({ finished: true, inviteLink }); + const panels = [ + { + id: "invite-link", + title: "user.invited.generate_link", + model: { + inviteModel: this.currentUser, + userInvitedShow: this.controllerFor("user-invited-show") } - ] + } + ]; + + showModal("share-and-invite", { + modalClass: "share-and-invite", + panels }); } } diff --git a/app/assets/javascripts/discourse/app/templates/components/future-date-input.hbs b/app/assets/javascripts/discourse/app/templates/components/future-date-input.hbs index 14da87147f3..6e6b117e087 100644 --- a/app/assets/javascripts/discourse/app/templates/components/future-date-input.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/future-date-input.hbs @@ -31,7 +31,7 @@
{{i18n "user.invited.link_generated"}}
- +
-{{i18n "user.invited.valid_for" email=email}}
+{{#if email}} +{{i18n "user.invited.valid_for" email=email}}
+{{/if}} diff --git a/app/assets/javascripts/discourse/app/templates/components/invite-link-panel.hbs b/app/assets/javascripts/discourse/app/templates/components/invite-link-panel.hbs new file mode 100644 index 00000000000..cc1b30041c0 --- /dev/null +++ b/app/assets/javascripts/discourse/app/templates/components/invite-link-panel.hbs @@ -0,0 +1,59 @@ +{{#if inviteModel.error}} +{{i18n "invites.invited_by"}}
{{user-info user=invitedBy}}
-- {{html-safe yourEmailMessage}} - {{#if externalAuthsEnabled}} - {{i18n "invites.social_login_available"}} - {{/if}} -
+ {{#unless isInviteLink}} ++ {{html-safe yourEmailMessage}} + {{#if externalAuthsEnabled}} + {{i18n "invites.social_login_available"}} + {{/if}} +
+ {{/unless}}