diff --git a/app/assets/javascripts/discourse/app/components/copy-button.js b/app/assets/javascripts/discourse/app/components/copy-button.js new file mode 100644 index 00000000000..ca1409b1b8f --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/copy-button.js @@ -0,0 +1,16 @@ +import Component from "@ember/component"; +import { action } from "@ember/object"; + +export default Component.extend({ + tagName: "", + + @action + copy() { + const target = document.querySelector(this.selector); + target.select(); + target.setSelectionRange(0, target.value.length); + try { + document.execCommand("copy"); + } catch (err) {} + }, +}); diff --git a/app/assets/javascripts/discourse/app/components/create-invite-uploader.js b/app/assets/javascripts/discourse/app/components/create-invite-uploader.js new file mode 100644 index 00000000000..79fe2a1c956 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/create-invite-uploader.js @@ -0,0 +1,111 @@ +import Component from "@ember/component"; +import getUrl from "discourse-common/lib/get-url"; +import discourseComputed from "discourse-common/utils/decorators"; +import { + displayErrorForUpload, + validateUploadedFiles, +} from "discourse/lib/uploads"; + +export default Component.extend({ + tagName: "", + + data: null, + uploading: false, + progress: 0, + uploaded: null, + + @discourseComputed("messageBus.clientId") + clientId() { + return this.messageBus && this.messageBus.clientId; + }, + + @discourseComputed("data", "uploading") + submitDisabled(data, uploading) { + return !data || uploading; + }, + + didInsertElement() { + this._super(...arguments); + + this.setProperties({ + data: null, + uploading: false, + progress: 0, + uploaded: null, + }); + + const $upload = $("#csv-file"); + + $upload.fileupload({ + url: getUrl("/invites/upload_csv.json") + "?client_id=" + this.clientId, + dataType: "json", + dropZone: null, + replaceFileInput: false, + autoUpload: false, + }); + + $upload.on("fileuploadadd", (e, data) => { + this.set("data", data); + }); + + $upload.on("fileuploadsubmit", (e, data) => { + const isValid = validateUploadedFiles(data.files, { + user: this.currentUser, + siteSettings: this.siteSettings, + bypassNewUserRestriction: true, + csvOnly: true, + }); + + data.formData = { type: "csv" }; + this.setProperties({ progress: 0, uploading: isValid }); + + return isValid; + }); + + $upload.on("fileuploadprogress", (e, data) => { + const progress = parseInt((data.loaded / data.total) * 100, 10); + this.set("progress", progress); + }); + + $upload.on("fileuploaddone", (e, data) => { + const upload = data.result; + this.set("uploaded", upload); + this.reset(); + }); + + $upload.on("fileuploadfail", (e, data) => { + if (data.errorThrown !== "abort") { + displayErrorForUpload(data, this.siteSettings); + } + this.reset(); + }); + }, + + willDestroyElement() { + this._super(...arguments); + + if (this.messageBus) { + this.messageBus.unsubscribe("/uploads/csv"); + } + + const $upload = $(this.element); + + try { + $upload.fileupload("destroy"); + } catch (e) { + /* wasn't initialized yet */ + } finally { + $upload.off(); + } + }, + + reset() { + this.setProperties({ + data: null, + uploading: false, + progress: 0, + }); + + document.getElementById("csv-file").value = ""; + }, +}); diff --git a/app/assets/javascripts/discourse/app/components/csv-uploader.js b/app/assets/javascripts/discourse/app/components/csv-uploader.js deleted file mode 100644 index 9ab2c015b99..00000000000 --- a/app/assets/javascripts/discourse/app/components/csv-uploader.js +++ /dev/null @@ -1,49 +0,0 @@ -import Component from "@ember/component"; -import I18n from "I18n"; -import UploadMixin from "discourse/mixins/upload"; -import bootbox from "bootbox"; -import discourseComputed from "discourse-common/utils/decorators"; -import { on } from "@ember/object/evented"; - -export default Component.extend(UploadMixin, { - type: "csv", - tagName: "span", - uploadUrl: "/invites/upload_csv", - i18nPrefix: "user.invited.bulk_invite", - - validateUploadedFilesOptions() { - return { csvOnly: true }; - }, - - @discourseComputed("uploading") - uploadButtonText(uploading) { - return uploading ? I18n.t("uploading") : I18n.t(`${this.i18nPrefix}.text`); - }, - - @discourseComputed("uploading") - uploadButtonDisabled(uploading) { - // https://github.com/emberjs/ember.js/issues/10976#issuecomment-132417731 - return uploading ? true : null; - }, - - uploadDone() { - bootbox.alert(I18n.t(`${this.i18nPrefix}.success`)); - }, - - uploadOptions() { - return { autoUpload: false }; - }, - - _init: on("didInsertElement", function () { - const $upload = $(this.element); - - $upload.on("fileuploadadd", (e, data) => { - bootbox.confirm( - I18n.t(`${this.i18nPrefix}.confirmation_message`), - I18n.t("cancel"), - I18n.t("go_ahead"), - (result) => (result ? data.submit() : data.abort()) - ); - }); - }), -}); diff --git a/app/assets/javascripts/discourse/app/controllers/create-invite-bulk.js b/app/assets/javascripts/discourse/app/controllers/create-invite-bulk.js new file mode 100644 index 00000000000..0ebeb099924 --- /dev/null +++ b/app/assets/javascripts/discourse/app/controllers/create-invite-bulk.js @@ -0,0 +1,24 @@ +import Controller from "@ember/controller"; +import { action } from "@ember/object"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; + +export default Controller.extend(ModalFunctionality, { + data: null, + + onShow() { + this.set("data", null); + }, + + onClose() { + if (this.data) { + this.data.abort(); + this.set("data", null); + } + }, + + @action + submit(data) { + this.set("data", data); + data.submit(); + }, +}); diff --git a/app/assets/javascripts/discourse/app/controllers/create-invite.js b/app/assets/javascripts/discourse/app/controllers/create-invite.js new file mode 100644 index 00000000000..008e2d76b9c --- /dev/null +++ b/app/assets/javascripts/discourse/app/controllers/create-invite.js @@ -0,0 +1,144 @@ +import Controller from "@ember/controller"; +import { action } from "@ember/object"; +import { equal } from "@ember/object/computed"; +import discourseComputed from "discourse-common/utils/decorators"; +import { extractError } from "discourse/lib/ajax-error"; +import { bufferedProperty } from "discourse/mixins/buffered-content"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import Group from "discourse/models/group"; +import Invite from "discourse/models/invite"; +import I18n from "I18n"; + +export default Controller.extend( + ModalFunctionality, + bufferedProperty("invite"), + { + allGroups: null, + + invite: null, + invites: null, + + autogenerated: false, + showAdvanced: false, + showOnly: false, + + type: "link", + + topicId: null, + topicTitle: null, + groupIds: null, + + onShow() { + Group.findAll().then((groups) => { + this.set("allGroups", groups.filterBy("automatic", false)); + }); + + this.setProperties({ + autogenerated: false, + showAdvanced: false, + showOnly: false, + }); + + this.setInvite(Invite.create()); + }, + + onClose() { + if (this.autogenerated) { + this.invite + .destroy() + .then(() => this.invites.removeObject(this.invite)); + } + }, + + setInvite(invite) { + this.setProperties({ + invite, + type: invite.email ? "email" : "link", + groupIds: invite.groups ? invite.groups.map((g) => g.id) : null, + }); + + if (invite.topics && invite.topics.length > 0) { + this.setProperties({ + topicId: invite.topics[0].id, + topicTitle: invite.topics[0].title, + }); + } else { + this.setProperties({ topicId: null, topicTitle: null }); + } + }, + + save(autogenerated) { + this.set("autogenerated", autogenerated); + + const data = { + group_ids: this.groupIds, + topic_id: this.topicId, + expires_at: this.buffered.get("expires_at"), + }; + + if (this.type === "link") { + data.max_redemptions_allowed = this.buffered.get( + "max_redemptions_allowed" + ); + } else if (this.type === "email") { + data.email = this.buffered.get("email"); + data.custom_message = this.buffered.get("custom_message"); + } + + const newRecord = !this.invite.id; + return this.invite + .save(data) + .then(() => { + this.rollbackBuffer(); + + if (newRecord) { + this.invites.unshiftObject(this.invite); + } + + if (!this.autogenerated) { + this.appEvents.trigger("modal-body:flash", { + text: I18n.t("user.invited.invite.invite_saved"), + messageClass: "success", + }); + } + }) + .catch((e) => + this.appEvents.trigger("modal-body:flash", { + text: extractError(e), + messageClass: "error", + }) + ); + }, + + isLink: equal("type", "link"), + isEmail: equal("type", "email"), + + @discourseComputed("buffered.expires_at") + expiresAtRelative(expires_at) { + return moment.duration(moment(expires_at) - moment()).humanize(); + }, + + @discourseComputed("type", "buffered.email") + disabled(type, email) { + if (type === "email") { + return !email; + } + + return false; + }, + + @discourseComputed("type", "invite.email", "buffered.email") + saveLabel(type, email, bufferedEmail) { + return type === "email" && email !== bufferedEmail + ? "user.invited.invite.send_invite_email" + : "user.invited.invite.save_invite"; + }, + + @action + saveInvite() { + this.appEvents.trigger("modal-body:clearFlash"); + + this.save(); + }, + } +); 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 eb933835358..1d5df0fdb3a 100644 --- a/app/assets/javascripts/discourse/app/controllers/user-invited-show.js +++ b/app/assets/javascripts/discourse/app/controllers/user-invited-show.js @@ -1,23 +1,24 @@ -import discourseComputed, { observes } from "discourse-common/utils/decorators"; -import { equal, reads } from "@ember/object/computed"; import Controller from "@ember/controller"; -import I18n from "I18n"; -import { INPUT_DELAY } from "discourse-common/config/environment"; -import Invite from "discourse/models/invite"; +import { action } from "@ember/object"; +import { equal, reads } from "@ember/object/computed"; import bootbox from "bootbox"; +import { INPUT_DELAY } from "discourse-common/config/environment"; import discourseDebounce from "discourse-common/lib/debounce"; +import discourseComputed, { observes } from "discourse-common/utils/decorators"; import { popupAjaxError } from "discourse/lib/ajax-error"; +import showModal from "discourse/lib/show-modal"; +import Invite from "discourse/models/invite"; +import I18n from "I18n"; export default Controller.extend({ user: null, model: null, filter: null, - totalInvites: null, invitesCount: null, canLoadMore: true, invitesLoading: false, reinvitedAll: false, - rescindedAll: false, + removedAll: false, searchTerm: null, init() { @@ -43,32 +44,26 @@ export default Controller.extend({ 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.model.invites.length > 1 && this.currentUser.staff ); }, canInviteToForum: reads("currentUser.can_invite_to_forum"), canBulkInvite: reads("currentUser.admin"), - canSendInviteLink: reads("currentUser.staff"), - @discourseComputed("totalInvites", "inviteLinks") - showSearch(totalInvites, inviteLinks) { - return totalInvites >= 10 && !inviteLinks; + @discourseComputed("invitesCount.total") + showSearch(invitesCountTotal) { + return invitesCountTotal > 0; }, @discourseComputed("invitesCount.total", "invitesCount.pending") pendingLabel(invitesCountTotal, invitesCountPending) { - if (invitesCountTotal > 50) { + if (invitesCountTotal > 0) { return I18n.t("user.invited.pending_tab_with_count", { count: invitesCountPending, }); @@ -79,7 +74,7 @@ export default Controller.extend({ @discourseComputed("invitesCount.total", "invitesCount.redeemed") redeemedLabel(invitesCountTotal, invitesCountRedeemed) { - if (invitesCountTotal > 50) { + if (invitesCountTotal > 0) { return I18n.t("user.invited.redeemed_tab_with_count", { count: invitesCountRedeemed, }); @@ -88,74 +83,89 @@ 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, + @action + createInvite() { + const controller = showModal("create-invite"); + controller.set("invites", this.model.invites); + controller.save(true); + }, + + @action + createInviteCsv() { + showModal("create-invite-bulk"); + }, + + @action + editInvite(invite) { + const controller = showModal("create-invite"); + controller.set("showAdvanced", true); + controller.setInvite(invite); + }, + + @action + showInvite(invite) { + const controller = showModal("create-invite"); + controller.set("showOnly", true); + controller.setInvite(invite); + }, + + @action + destroyInvite(invite) { + invite.destroy(); + this.model.invites.removeObject(invite); + }, + + @action + destroyAllExpired() { + bootbox.confirm(I18n.t("user.invited.remove_all_confirm"), (confirm) => { + if (confirm) { + Invite.destroyAllExpired() + .then(() => { + this.set("removedAll", true); + }) + .catch(popupAjaxError); + } + }); + }, + + @action + reinvite(invite) { + invite.reinvite(); + return false; + }, + + @action + reinviteAll() { + bootbox.confirm(I18n.t("user.invited.reinvite_all_confirm"), (confirm) => { + if (confirm) { + Invite.reinviteAll() + .then(() => this.set("reinvitedAll", true)) + .catch(popupAjaxError); + } + }); + }, + + @action + loadMore() { + const model = this.model; + + if (this.canLoadMore && !this.invitesLoading) { + this.set("invitesLoading", true); + Invite.findInvitedBy( + this.user, + this.filter, + this.searchTerm, + model.invites.length + ).then((invite_model) => { + this.set("invitesLoading", false); + model.invites.pushObjects(invite_model.invites); + if ( + invite_model.invites.length === 0 || + invite_model.invites.length < this.siteSettings.invites_per_page + ) { + this.set("canLoadMore", false); + } }); - } else { - return I18n.t("user.invited.links_tab"); } }, - - actions: { - rescind(invite) { - invite.rescind(); - return false; - }, - - rescindAll() { - bootbox.confirm(I18n.t("user.invited.rescind_all_confirm"), (confirm) => { - if (confirm) { - Invite.rescindAll() - .then(() => { - this.set("rescindedAll", true); - }) - .catch(popupAjaxError); - } - }); - }, - - reinvite(invite) { - invite.reinvite(); - return false; - }, - - reinviteAll() { - bootbox.confirm( - I18n.t("user.invited.reinvite_all_confirm"), - (confirm) => { - if (confirm) { - Invite.reinviteAll() - .then(() => this.set("reinvitedAll", true)) - .catch(popupAjaxError); - } - } - ); - }, - - loadMore() { - const model = this.model; - - if (this.canLoadMore && !this.invitesLoading) { - this.set("invitesLoading", true); - Invite.findInvitedBy( - this.user, - this.filter, - this.searchTerm, - model.invites.length - ).then((invite_model) => { - this.set("invitesLoading", false); - model.invites.pushObjects(invite_model.invites); - if ( - invite_model.invites.length === 0 || - invite_model.invites.length < this.siteSettings.invites_per_page - ) { - this.set("canLoadMore", false); - } - }); - } - }, - }, }); diff --git a/app/assets/javascripts/discourse/app/models/invite.js b/app/assets/javascripts/discourse/app/models/invite.js index aef88d10ab5..41f4e651ca5 100644 --- a/app/assets/javascripts/discourse/app/models/invite.js +++ b/app/assets/javascripts/discourse/app/models/invite.js @@ -7,12 +7,19 @@ import { popupAjaxError } from "discourse/lib/ajax-error"; import { userPath } from "discourse/lib/url"; const Invite = EmberObject.extend({ - rescind() { - ajax("/invites", { + save(data) { + const promise = this.id + ? ajax(`/invites/${this.id}`, { type: "PUT", data }) + : ajax("/invites", { type: "POST", data }); + + return promise.then((result) => this.setProperties(result)); + }, + + destroy() { + return ajax("/invites", { type: "DELETE", data: { id: this.id }, - }); - this.set("rescinded", true); + }).then(() => this.set("destroyed", true)); }, reinvite() { @@ -48,14 +55,7 @@ Invite.reopenClass({ } data.offset = offset || 0; - let path; - if (filter === "links") { - path = userPath(`${user.username_lower}/invite_links.json`); - } else { - path = userPath(`${user.username_lower}/invited.json`); - } - - return ajax(path, { + return ajax(userPath(`${user.username_lower}/invited.json`), { data, }).then((result) => { result.invites = result.invites.map((i) => Invite.create(i)); @@ -63,22 +63,12 @@ Invite.reopenClass({ }); }, - findInvitedCount(user) { - if (!user) { - Promise.resolve(); - } - - return ajax( - userPath(`${user.username_lower}/invited_count.json`) - ).then((result) => EmberObject.create(result.counts)); - }, - reinviteAll() { return ajax("/invites/reinvite-all", { type: "POST" }); }, - rescindAll() { - return ajax("/invites/rescind-all", { type: "POST" }); + destroyAllExpired() { + return ajax("/invites/destroy-all-expired", { type: "POST" }); }, }); diff --git a/app/assets/javascripts/discourse/app/models/topic.js b/app/assets/javascripts/discourse/app/models/topic.js index 572a4168b11..621a680f4fe 100644 --- a/app/assets/javascripts/discourse/app/models/topic.js +++ b/app/assets/javascripts/discourse/app/models/topic.js @@ -426,9 +426,9 @@ const Topic = RestModel.extend({ }, generateInviteLink(email, group_ids, topic_id) { - return ajax("/invites/link", { + return ajax("/invites", { type: "POST", - data: { email, group_ids, topic_id }, + data: { email, skip_email: true, group_ids, topic_id }, }); }, diff --git a/app/assets/javascripts/discourse/app/models/user.js b/app/assets/javascripts/discourse/app/models/user.js index dc08ed3f302..5f7892bee53 100644 --- a/app/assets/javascripts/discourse/app/models/user.js +++ b/app/assets/javascripts/discourse/app/models/user.js @@ -711,9 +711,9 @@ const User = RestModel.extend({ }, generateInviteLink(email, group_ids, topic_id) { - return ajax("/invites/link", { + return ajax("/invites", { type: "POST", - data: { email, group_ids, topic_id }, + data: { email, skip_email: true, group_ids, topic_id }, }); }, @@ -722,7 +722,7 @@ const User = RestModel.extend({ max_redemptions_allowed, expires_at ) { - return ajax("/invites/link", { + return ajax("/invites", { type: "POST", data: { group_ids, max_redemptions_allowed, expires_at }, }); 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 aaac65bb68d..c728db85912 100644 --- a/app/assets/javascripts/discourse/app/routes/user-invited-show.js +++ b/app/assets/javascripts/discourse/app/routes/user-invited-show.js @@ -1,13 +1,8 @@ import DiscourseRoute from "discourse/routes/discourse"; import Invite from "discourse/models/invite"; -import { getAbsoluteURL } from "discourse-common/lib/get-url"; -import showModal from "discourse/lib/show-modal"; export default DiscourseRoute.extend({ model(params) { - Invite.findInvitedCount(this.modelFor("user")).then((result) => - this.set("invitesCount", result) - ); this.inviteFilter = params.filter; return Invite.findInvitedBy(this.modelFor("user"), params.filter); }, @@ -21,63 +16,10 @@ export default DiscourseRoute.extend({ setupController(controller, model) { controller.setProperties({ model, + invitesCount: model.counts, user: this.controllerFor("user").get("model"), filter: this.inviteFilter, searchTerm: "", - totalInvites: model.invites.length, - invitesCount: this.invitesCount, }); }, - - 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"), - }, - }); - panels.reverse(); - } - - showModal("share-and-invite", { - modalClass: "share-and-invite", - panels, - }); - }, - - editInvite(inviteKey) { - const inviteLink = getAbsoluteURL(`/invites/${inviteKey}`); - this.currentUser.setProperties({ finished: true, inviteLink }); - const panels = [ - { - id: "invite-link", - title: "user.invited.invite_link.title", - 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/copy-button.hbs b/app/assets/javascripts/discourse/app/templates/components/copy-button.hbs new file mode 100644 index 00000000000..37971e40fc5 --- /dev/null +++ b/app/assets/javascripts/discourse/app/templates/components/copy-button.hbs @@ -0,0 +1 @@ +{{d-button icon="copy" action=(action "copy")}} diff --git a/app/assets/javascripts/discourse/app/templates/components/create-invite-uploader.hbs b/app/assets/javascripts/discourse/app/templates/components/create-invite-uploader.hbs new file mode 100644 index 00000000000..acc33b06b85 --- /dev/null +++ b/app/assets/javascripts/discourse/app/templates/components/create-invite-uploader.hbs @@ -0,0 +1,5 @@ +{{yield (hash data=data + uploading=uploading + progress=progress + uploaded=uploaded + submitDisabled=submitDisabled)}} diff --git a/app/assets/javascripts/discourse/app/templates/components/csv-uploader.hbs b/app/assets/javascripts/discourse/app/templates/components/csv-uploader.hbs deleted file mode 100644 index 4d03e30953e..00000000000 --- a/app/assets/javascripts/discourse/app/templates/components/csv-uploader.hbs +++ /dev/null @@ -1,7 +0,0 @@ - -{{#if uploading}} - {{i18n "upload_selector.uploading"}} {{uploadProgress}}% -{{/if}} diff --git a/app/assets/javascripts/discourse/app/templates/modal/create-invite-bulk.hbs b/app/assets/javascripts/discourse/app/templates/modal/create-invite-bulk.hbs new file mode 100644 index 00000000000..3973a3912e5 --- /dev/null +++ b/app/assets/javascripts/discourse/app/templates/modal/create-invite-bulk.hbs @@ -0,0 +1,28 @@ +{{#create-invite-uploader as |status|}} + {{#d-modal-body title="user.invited.bulk_invite.text"}} + {{#if status.uploaded}} + {{i18n "user.invited.bulk_invite.success"}} + {{else}} + {{html-safe (i18n "user.invited.bulk_invite.instructions")}} + + + {{/if}} + {{/d-modal-body}} + + +{{/create-invite-uploader}} diff --git a/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs b/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs new file mode 100644 index 00000000000..1987d4b1853 --- /dev/null +++ b/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs @@ -0,0 +1,118 @@ +{{#d-modal-body title=(if showOnly "user.invited.invite.show_link" (if inviteId "user.invited.invite.edit_title" "user.invited.invite.new_title"))}} +
+ + {{input name="invite_link" + class="invite-link" + value=invite.link + readonly=true}} + {{copy-button selector="input.invite-link"}} +
+ +

{{i18n "user.invited.invite.expires_at_time" time=expiresAtRelative}}

+ + {{#unless showOnly}} +

+ {{#if showAdvanced}} + {{d-icon "caret-down"}} + {{i18n "user.invited.invite.hide_advanced"}} + {{else}} + {{d-icon "caret-right"}} + {{i18n "user.invited.invite.show_advanced"}} + {{/if}} +

+ {{/unless}} + + {{#if showAdvanced}} +
+
+ {{radio-button id="invite-type-link" name="invite-type" value="link" selection=type}} + +
+ +
+ {{radio-button id="invite-type-email" name="invite-type" value="email" selection=type}} + +
+
+ + {{#if isLink}} +
+ + {{input + id="invite-max-redemptions" + type="number" + value=buffered.max_redemptions_allowed + min="1" + max=siteSettings.invite_link_max_redemptions_limit + }} +
+ {{/if}} + + {{#if isEmail}} +
+ + {{input + id="invite-email" + value=buffered.email + placeholderKey="topic.invite_reply.email_placeholder" + }} +
+ {{/if}} + + {{#if currentUser.staff}} +
+ + {{group-chooser + content=allGroups + value=groupIds + labelProperty="name" + onChange=(action (mut groupIds)) + }} +
+ +
+ {{choose-topic + selectedTopicId=topicId + topicTitle=topicTitle + additionalFilters="status:public" + label="user.invited.invite.invite_to_topic" + }} +
+ +
+ {{future-date-input + displayLabel=(i18n "user.invited.invite.expires_at") + includeDateTime=true + includeMidFuture=true + clearable=true + onChangeInput=(action (mut buffered.expires_at)) + }} +
+ + {{#if isEmail}} +
+ + {{textarea id="invite-message" value=buffered.custom_message}} +
+ {{/if}} + {{/if}} + {{/if}} +{{/d-modal-body}} + + diff --git a/app/assets/javascripts/discourse/app/templates/user-invited-show.hbs b/app/assets/javascripts/discourse/app/templates/user-invited-show.hbs index 2079edb3e28..3b3ab896a17 100644 --- a/app/assets/javascripts/discourse/app/templates/user-invited-show.hbs +++ b/app/assets/javascripts/discourse/app/templates/user-invited-show.hbs @@ -2,30 +2,25 @@ {{#if canInviteToForum}} {{#load-more class="user-content" selector=".user-invite-list tr" action=(action "loadMore")}}
-

{{i18n "user.invited.title"}}

- {{#if model.can_see_invite_details}}
- {{d-button icon="plus" action=(route-action "showInvite") label="user.invited.create"}} + {{d-button icon="plus" action=(action "createInvite") label="user.invited.create"}} {{#if canBulkInvite}} - {{csv-uploader uploading=uploading}} + {{d-button icon="upload" action=(action "createInviteCsv") label="user.invited.bulk_invite.text"}} {{/if}} {{#if showBulkActionButtons}} - {{#if rescindedAll}} - {{i18n "user.invited.rescinded_all"}} + {{#if removedAll}} + {{i18n "user.invited.removed_all"}} {{else}} - {{d-button icon="times" action=(action "rescindAll") label="user.invited.rescind_all"}} + {{d-button icon="times" action=(action "destroyAllExpired") label="user.invited.remove_all"}} {{/if}} {{#if reinvitedAll}} {{i18n "user.invited.reinvited_all"}} @@ -44,10 +39,10 @@ {{/if}} {{#if model.invites}} - - - - {{#if inviteRedeemed}} + {{#if inviteRedeemed}} +
+ + {{#if model.can_see_invite_details}} @@ -56,25 +51,13 @@ - {{#if canSendInviteLink}} - - {{/if}} + {{/if}} - {{else if invitePending}} - - - {{else if inviteLinks}} - - - - - - - {{/if}} - - {{#each model.invites as |invite|}} - - {{#if inviteRedeemed}} + + + + {{#each model.invites as |invite|}} + - {{#if canSendInviteLink}} - - {{/if}} + + {{/if}} + + {{/each}} + +
{{i18n "user.invited.user"}} {{i18n "user.invited.redeemed_at"}}{{i18n "user.invited.posts_read_count"}} {{i18n "user.invited.time_read"}} {{i18n "user.invited.days_visited"}}{{i18n "user.invited.source"}}{{i18n "user.invited.invited_via"}}{{i18n "user.invited.user"}}{{i18n "user.invited.sent"}}{{i18n "user.invited.link_url"}}{{i18n "user.invited.link_created_at"}}{{i18n "user.invited.link_redemption_stats"}}{{i18n "user.invited.link_groups"}}{{i18n "user.invited.link_expires_at"}}
{{#link-to "user" invite.user}}{{avatar invite.user imageSize="tiny"}}{{/link-to}} {{#link-to "user" invite.user}}{{invite.user.username}}{{/link-to}} @@ -90,50 +73,67 @@ / {{html-safe invite.user.days_since_created}} {{html-safe invite.invite_source}}{{html-safe invite.invite_source}}
+ {{else}} + + + + + {{#if currentUser.staff}} + + {{/if}} + + + + + + + {{#each model.invites as |invite|}} + + + {{#if currentUser.staff}} + {{/if}} - {{else if invitePending}} - - - {{else if inviteLinks}} - - - - - - - {{/if}} - - {{/each}} - -
{{i18n "user.invited.invited_via"}}{{i18n "user.invited.groups"}}{{i18n "user.invited.sent"}}{{i18n "user.invited.expires_at"}}
+ {{#if invite.email}} + {{invite.email}} + {{else}} + {{i18n "user.invited.invited_via_link" count=invite.redemption_count max=invite.max_redemptions_allowed}} + {{/if}} + + {{#each invite.groups as |g|}} + {{g.name}} + {{else}} + — + {{/each}} + {{invite.email}} {{format-date invite.updated_at}} {{#if invite.expired}} -
{{i18n "user.invited.expired"}}
- {{/if}} - {{#if invite.rescinded}} - {{i18n "user.invited.rescinded"}} + {{i18n "user.invited.expired"}} {{else}} - {{d-button icon="times" action=(action "rescind") actionParam=invite label="user.invited.rescind"}} + {{raw-date invite.expires_at}} {{/if}}
- {{#if invite.reinvited}} -
{{i18n "user.invited.reinvited"}}
- {{else}} - {{d-button icon="sync" action=(action "reinvite") actionParam=invite label="user.invited.reinvite"}} +
+ {{d-button icon="pencil-alt" action=(action "editInvite" invite) title="user.invited.edit"}} + {{d-button icon="trash-alt" class="cancel" action=(action "destroyInvite" invite) title=(if invite.destroyed "user.invited.removed" "user.invited.remove")}} + {{d-button icon="link" action=(action "showInvite" invite) title="user.invited.copy_link"}} + {{#if invite.email}} + {{d-button icon="sync" action=(action "reinvite" invite) disabled=invite.reinvited label=(if invite.reinvited "user.invited.reinvited" "user.invited.reinvite")}} {{/if}} {{d-button icon="link" action=(route-action "editInvite" invite.invite_key) label="user.invited.copy_link"}}{{format-date invite.created_at}}{{number invite.redemption_count}} / {{number invite.max_redemptions_allowed}}{{ invite.group_names }}{{raw-date invite.expires_at leaveAgo="true"}} - {{#if invite.rescinded}} - {{i18n "user.invited.rescinded"}} - {{else}} - {{d-button icon="times" action=(action "rescind") actionParam=invite label="user.invited.rescind"}} - {{/if}} -
- {{conditional-loading-spinner condition=invitesLoading}} + + {{/each}} + + + {{/if}} + {{conditional-loading-spinner condition=invitesLoading}} {{else}}
{{#if canBulkInvite}} diff --git a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js index 08fbde994ec..04e1178d441 100644 --- a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js +++ b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js @@ -188,12 +188,6 @@ export function applyDefaultHandlers(pretender) { }); }); - pretender.get("/u/eviltrout/invited_count.json", () => { - return response({ - counts: { pending: 1, redeemed: 0, total: 0 }, - }); - }); - pretender.get("/u/eviltrout/invited.json", () => { return response({ invites: [{ id: 1 }] }); }); diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss index 1c8fa7e9469..d7717460ff9 100644 --- a/app/assets/stylesheets/common/base/modal.scss +++ b/app/assets/stylesheets/common/base/modal.scss @@ -766,3 +766,137 @@ text-align: right; } } + +.json-schema-editor-modal { + h3.card-title { + margin-top: 0; + label { + display: none; + } + } + + .card .je-object__container { + border-bottom: 1px dashed var(--primary-low); + padding-bottom: 1em; + margin-bottom: 1em; + position: relative; + + .card-title label { + display: inline-block; + font-size: $font-down-1; + color: var(--primary-medium); + } + .form-group { + label { + display: inline-block; + width: 33%; + } + .form-control { + width: 66%; + } + } + .btn-group:last-child { + position: absolute; + right: 0px; + top: 0px; + .btn { + font-size: $font-down-2; + } + } + } + + .btn-group { + margin-top: 0; + } + + .json-editor-btn-delete { + @extend .btn-danger !optional; + @extend .no-text !optional; + .d-icon + span { + display: none; + } + } + + .card-body > .btn-group { + // !important needed to override inline style :-( + display: block !important; + text-align: right; + } +} + +.create-invite-modal { + .input-group { + margin-bottom: 1em; + + &:last-child { + margin-bottom: 0; + } + + input[type="text"] { + width: 100%; + + &.invite-link { + width: 85%; + } + } + } + + .radio-group { + input[type="radio"] { + display: table-cell; + vertical-align: middle; + margin-top: -1px; + } + + label { + display: inline-block; + } + } + + .group-chooser, + .future-date-input-selector { + width: 100%; + } + + .input-group input[type="text"], + .input-group .btn, + .future-date-input .select-kit-header, + .control-group:nth-child(2) input, + .control-group:nth-child(3) input { + height: 34px; + } + + .input-group .btn { + vertical-align: top; + } + + .future-date-input { + .date-picker-wrapper { + input { + margin: 0; + } + } + + .control-group:nth-child(2), + .control-group:nth-child(3) { + display: inline-block; + margin-bottom: 0; + width: 49%; + + input { + margin-bottom: 0; + width: 150px; + } + } + } + + .invite-max-redemptions { + label { + display: inline; + } + + input { + width: 80px; + } + } +} diff --git a/app/assets/stylesheets/desktop/modal.scss b/app/assets/stylesheets/desktop/modal.scss index cf13d930ebb..5c22640ffeb 100644 --- a/app/assets/stylesheets/desktop/modal.scss +++ b/app/assets/stylesheets/desktop/modal.scss @@ -107,3 +107,10 @@ min-width: 500px; } } + +.create-invite-modal, +.create-invite-bulk-modal { + .modal-inner-container { + width: 700px; + } +} diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index ddb0c6f36bf..988be6e14ba 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -90,6 +90,10 @@ tr { td { padding: 0.667em; + &.actions { + white-space: nowrap; + width: 100px; + } } } } diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index c9f236bac4a..8d2becaaae2 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -374,7 +374,7 @@ class GroupsController < ApplicationController end emails.each do |email| - Invite.invite_by_email(email, current_user, nil, [group.id]) + Invite.generate(current_user, email: email, group_ids: [group.id]) end render json: success_json.merge!( diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 5d083158578..17f39297b70 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -2,10 +2,7 @@ class InvitesController < ApplicationController - requires_login only: [ - :destroy, :create, :create_invite_link, :rescind_all_invites, - :resend_invite, :resend_all_invites, :upload_csv - ] + requires_login only: [:create, :destroy, :destroy_all, :resend_invite, :resend_all_invites, :upload_csv] skip_before_action :check_xhr, except: [:perform_accept_invitation] skip_before_action :preload_json, except: [:show] @@ -23,84 +20,55 @@ class InvitesController < ApplicationController invited_by: UserNameSerializer.new(invite.invited_by, scope: guardian, root: false), email: invite.email, username: UserNameSuggester.suggest(invite.email), - is_invite_link: invite.is_invite_link?) - ) + is_invite_link: invite.is_invite_link? + )) render layout: 'application' else - flash.now[:error] = if invite.present? && invite.expired? + flash.now[:error] = if invite&.expired? I18n.t('invite.expired', base_url: Discourse.base_url) - elsif invite.present? && invite.redeemed? + elsif invite&.redeemed? I18n.t('invite.not_found_template', site_name: SiteSetting.title, base_url: Discourse.base_url) else I18n.t('invite.not_found', base_url: Discourse.base_url) end + render layout: 'no_ember' end end - def perform_accept_invitation - params.require(:id) - params.permit(:email, :username, :name, :password, :timezone, user_custom_fields: {}) - invite = Invite.find_by(invite_key: params[:id]) - - if invite.present? - begin - user = if invite.is_invite_link? - invite.redeem_invite_link(email: params[:email], username: params[:username], name: params[:name], password: params[:password], user_custom_fields: params[:user_custom_fields], ip_address: request.remote_ip) - else - invite.redeem(username: params[:username], name: params[:name], password: params[:password], user_custom_fields: params[:user_custom_fields], ip_address: request.remote_ip) - end - - if user.present? - log_on_user(user) if user.active? - user.update_timezone_if_missing(params[:timezone]) - post_process_invite(user) - response = { success: true } - else - response = { success: false, message: I18n.t('invite.not_found_json') } - end - - if user.present? && user.active? - topic = invite.topics.first - response[:redirect_to] = topic.present? ? path("#{topic.relative_url}") : path("/") - elsif user.present? - response[:message] = I18n.t('invite.confirm_email') - end - - render json: response - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e - render json: { - success: false, - errors: e.record&.errors&.to_hash || {}, - message: I18n.t('invite.error_message') - } - rescue Invite::UserExists => e - render json: { success: false, message: [e.message] } - end - else - render json: { success: false, message: I18n.t('invite.not_found_json') } - end - end - def create - params.require(:email) - - groups = Group.lookup_groups( - group_ids: params[:group_ids], - group_names: params[:group_names] - ) - - guardian.ensure_can_invite_to_forum!(groups) - group_ids = groups.map(&:id) - - if Invite.exists?(email: params[:email]) + if params[:email].present? && Invite.exists?(email: params[:email]) return render json: failed_json, status: 422 end + if params[:topic_id].present? + topic = Topic.find_by(id: params[:topic_id]) + raise Discourse::InvalidParameters.new(:topic_id) if topic.blank? + guardian.ensure_can_invite_to!(topic) + end + + if params[:group_ids].present? || params[:group_names].present? + groups = Group.lookup_groups(group_ids: params[:group_ids], group_names: params[:group_names]) + end + + guardian.ensure_can_invite_to_forum!(groups) + begin - if Invite.invite_by_email(params[:email], current_user, nil, group_ids, params[:custom_message]) - render json: success_json + invite = Invite.generate(current_user, + invite_key: params[:invite_key], + email: params[:email], + skip_email: params[:skip_email], + invited_by: current_user, + custom_message: params[:custom_message], + max_redemptions_allowed: params[:max_redemptions_allowed], + topic_id: topic&.id, + group_ids: groups&.map(&:id), + expires_at: params[:expires_at], + ) + + if invite.present? + render_serialized(invite, InviteSerializer, scope: guardian, root: nil, show_emails: params.has_key?(:email)) else render json: failed_json, status: 422 end @@ -109,57 +77,53 @@ class InvitesController < ApplicationController end end - def create_invite_link - params.permit(:email, :max_redemptions_allowed, :expires_at, :group_ids, :group_names, :topic_id) + def update + invite = Invite.find_by(invited_by: current_user, id: params[:id]) + raise Discourse::InvalidParameters.new(:id) if invite.blank? - is_single_invite = params[:email].present? - unless is_single_invite - guardian.ensure_can_send_invite_links!(current_user) + if params[:topic_id].present? + topic = Topic.find_by(id: params[:topic_id]) + raise Discourse::InvalidParameters.new(:topic_id) if topic.blank? + guardian.ensure_can_invite_to!(topic) end - groups = Group.lookup_groups( - group_ids: params[:group_ids], - group_names: params[:group_names] - ) - if !guardian.can_invite_to_forum?(groups) - raise StandardError.new I18n.t("invite.cant_invite_to_group") + if params[:group_ids].present? || params[:group_names].present? + groups = Group.lookup_groups(group_ids: params[:group_ids], group_names: params[:group_names]) end - group_ids = groups.map(&:id) - if is_single_invite - invite_exists = Invite.exists?(email: params[:email], invited_by_id: current_user.id) - if invite_exists && !guardian.can_send_multiple_invites?(current_user) - return render json: failed_json, status: 422 + guardian.ensure_can_invite_to_forum!(groups) + + Invite.transaction do + if params.has_key?(:topic_id) + invite.topic_invites.destroy_all + invite.topic_invites.create!(topic_id: topic.id) if topic.present? end - if params[:topic_id].present? - topic = Topic.find_by(id: params[:topic_id]) + if params.has_key?(:group_ids) || params.has_key?(:group_names) + invite.invited_groups.destroy_all + groups.each { |group| invite.invited_groups.find_or_create_by!(group_id: group.id) } if groups.present? + end - if topic.present? - guardian.ensure_can_invite_to!(topic) - else - raise Discourse::InvalidParameters.new(:topic_id) + if params.has_key?(:email) + old_email = invite.email.presence + new_email = params[:email].presence + + if old_email != new_email + invite.emailed_status = Invite.emailed_status_types[new_email ? :pending : :not_required] end + + invite.email = new_email end + + invite.update!(params.permit(:custom_message, :max_redemptions_allowed, :expires_at)) end - invite_link = if is_single_invite - Invite.generate_single_use_invite_link(params[:email], current_user, topic, group_ids) - else - Invite.generate_multiple_use_invite_link( - invited_by: current_user, - max_redemptions_allowed: params[:max_redemptions_allowed], - expires_at: params[:expires_at], - group_ids: group_ids - ) + if invite.emailed_status == Invite.emailed_status_types[:pending] + invite.update_column(:emailed_status, Invite.emailed_status_types[:sending]) + Jobs.enqueue(:invite_email, invite_id: invite.id) end - if invite_link.present? - render_json_dump(invite_link) - else - render json: failed_json, status: 422 - end - rescue => e - render json: { errors: [e.message] }, status: 422 + + render_serialized(invite, InviteSerializer, scope: guardian, root: nil, show_emails: params.has_key?(:email)) end def destroy @@ -167,15 +131,66 @@ class InvitesController < ApplicationController invite = Invite.find_by(invited_by_id: current_user.id, id: params[:id]) raise Discourse::InvalidParameters.new(:id) if invite.blank? + invite.trash!(current_user) render json: success_json end - def rescind_all_invites - guardian.ensure_can_rescind_all_invites!(current_user) + def perform_accept_invitation + params.require(:id) + params.permit(:email, :username, :name, :password, :timezone, user_custom_fields: {}) + + invite = Invite.find_by(invite_key: params[:id]) + + if invite.present? + begin + user = invite.redeem( + email: invite.is_invite_link? ? params[:email] : invite.email, + username: params[:username], + name: params[:name], + password: params[:password], + user_custom_fields: params[:user_custom_fields], + ip_address: request.remote_ip + ) + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + return render json: failed_json.merge(errors: e.record&.errors&.to_hash, message: I18n.t('invite.error_message')), status: 412 + rescue Invite::UserExists => e + return render json: failed_json.merge(message: e.message), status: 412 + end + + if user.blank? + return render json: failed_json.merge(message: I18n.t('invite.not_found_json')), status: 404 + end + + log_on_user(user) if user.active? + user.update_timezone_if_missing(params[:timezone]) + post_process_invite(user) + + topic = invite.topics.first + response = {} + + if user.present? && user.active? + response[:redirect_to] = topic.present? ? path(topic.relative_url) : path("/") + elsif user.present? + response[:message] = I18n.t('invite.confirm_email') + cookies[:destination_url] = path(topic.relative_url) if topic.present? + end + + render json: success_json.merge(response) + else + render json: failed_json.merge(message: I18n.t('invite.not_found_json')), status: 404 + end + end + + def destroy_all_expired + guardian.ensure_can_destroy_all_invites!(current_user) + + Invite + .where(invited_by: current_user) + .where('expires_at < ?', Time.zone.now) + .find_each { |invite| invite.trash!(current_user) } - Invite.rescind_all_expired_invites_from(current_user) render json: success_json end @@ -195,7 +210,14 @@ class InvitesController < ApplicationController def resend_all_invites guardian.ensure_can_resend_all_invites!(current_user) - Invite.resend_all_invites_from(current_user.id) + Invite + .left_outer_joins(:invited_users) + .where(invited_by: current_user) + .where('invites.email IS NOT NULL') + .where('invited_users.user_id IS NULL') + .group('invites.id') + .find_each { |invite| invite.resend_invite } + render json: success_json end @@ -233,15 +255,7 @@ class InvitesController < ApplicationController end end - def fetch_username - params.require(:username) - params[:username] - end - - def fetch_email - params.require(:email) - params[:email] - end + private def ensure_new_registrations_allowed unless SiteSetting.allow_new_registrations @@ -259,8 +273,6 @@ class InvitesController < ApplicationController end end - private - def post_process_invite(user) user.enqueue_welcome_message('welcome_invite') if user.send_welcome_message diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 35397b8084b..cf2ddc4bf73 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -11,7 +11,7 @@ class UsersController < ApplicationController :update_second_factor, :create_second_factor_backup, :select_avatar, :notification_level, :revoke_auth_token, :register_second_factor_security_key, :create_second_factor_security_key, :feature_topic, :clear_featured_topic, - :bookmarks, :invited, :invite_links, :check_sso_email, :check_sso_payload + :bookmarks, :invited, :check_sso_email, :check_sso_payload ] skip_before_action :check_xhr, only: [ @@ -402,18 +402,19 @@ class UsersController < ApplicationController def invited if guardian.can_invite_to_forum? - offset = params[:offset].to_i || 0 - filter_by = params[:filter] || "redeemed" + filter = params[:filter] || "redeemed" inviter = fetch_user_from_params(include_inactive: current_user.staff? || SiteSetting.show_inactive_accounts) - invites = if guardian.can_see_invite_details?(inviter) && filter_by == "pending" - Invite.find_pending_invites_from(inviter, offset) - elsif filter_by == "redeemed" - Invite.find_redeemed_invites_from(inviter, offset) + invites = if filter == "pending" && guardian.can_see_invite_details?(inviter) + Invite.includes(:topics, :groups).pending(inviter) + elsif filter == "redeemed" + Invite.redeemed_users(inviter) else - [] + Invite.none end + invites = invites.offset(params[:offset].to_i || 0).limit(SiteSetting.invites_per_page) + show_emails = guardian.can_see_invite_emails?(inviter) if params[:search].present? && invites.present? filter_sql = '(LOWER(users.username) LIKE :filter)' @@ -421,66 +422,34 @@ class UsersController < ApplicationController invites = invites.where(filter_sql, filter: "%#{params[:search].downcase}%") end + pending_count = Invite.pending(inviter).reorder(nil).count.to_i + redeemed_count = Invite.redeemed_users(inviter).reorder(nil).count.to_i + render json: MultiJson.dump(InvitedSerializer.new( - OpenStruct.new(invite_list: invites.to_a, show_emails: show_emails, inviter: inviter, type: filter_by), + OpenStruct.new( + invite_list: invites.to_a, + show_emails: show_emails, + inviter: inviter, + type: filter, + counts: { + pending: pending_count, + redeemed: redeemed_count, + total: pending_count + redeemed_count + } + ), scope: guardian, root: false )) - else - if current_user&.staff? - message = if SiteSetting.enable_discourse_connect - I18n.t("invite.disabled_errors.discourse_connect_enabled") - elsif !SiteSetting.enable_local_logins - I18n.t("invite.disabled_errors.local_logins_disabled") - end - - render_invite_error(message) - else - render_json_error(I18n.t("invite.disabled_errors.invalid_access")) + elsif current_user&.staff? + message = if SiteSetting.enable_discourse_connect + I18n.t("invite.disabled_errors.discourse_connect_enabled") + elsif !SiteSetting.enable_local_logins + I18n.t("invite.disabled_errors.local_logins_disabled") end - end - end - def invite_links - if guardian.can_invite_to_forum? - inviter = fetch_user_from_params(include_inactive: current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts)) - guardian.ensure_can_see_invite_details!(inviter) - - offset = params[:offset].to_i || 0 - invites = Invite.find_links_invites_from(inviter, offset) - - render json: MultiJson.dump(invites: serialize_data(invites.to_a, InviteLinkSerializer), can_see_invite_details: guardian.can_see_invite_details?(inviter)) + render_invite_error(message) else - if current_user&.staff? - message = if SiteSetting.enable_discourse_connect - I18n.t("invite.disabled_errors.discourse_connect_enabled") - elsif !SiteSetting.enable_local_logins - I18n.t("invite.disabled_errors.local_logins_disabled") - end - - render_invite_error(message) - else - render_json_error(I18n.t("invite.disabled_errors.invalid_access")) - end - end - end - - def invited_count - if guardian.can_invite_to_forum? - inviter = fetch_user_from_params(include_inactive: current_user.try(:staff?) || (current_user && SiteSetting.show_inactive_accounts)) - - pending_count = Invite.find_pending_invites_count(inviter) - redeemed_count = Invite.find_redeemed_invites_count(inviter) - links_count = Invite.find_links_invites_count(inviter) - - render json: { counts: { pending: pending_count, redeemed: redeemed_count, links: links_count, - total: (pending_count.to_i + redeemed_count.to_i) } } - else - if current_user&.staff? - render json: { counts: 0 } - else - render_json_error(I18n.t("invite.disabled_errors.invalid_access")) - end + render_json_error(I18n.t("invite.disabled_errors.invalid_access")) end end diff --git a/app/jobs/regular/bulk_invite.rb b/app/jobs/regular/bulk_invite.rb index a87344f079e..2965abc4067 100644 --- a/app/jobs/regular/bulk_invite.rb +++ b/app/jobs/regular/bulk_invite.rb @@ -107,13 +107,14 @@ module Jobs end else if @total_invites > Invite::BULK_INVITE_EMAIL_LIMIT - invite = Invite.create_invite_by_email(email, @current_user, + invite = Invite.generate(@current_user, + email: email, topic: topic, group_ids: groups.map(&:id), emailed_status: Invite.emailed_status_types[:bulk_pending] ) else - Invite.invite_by_email(email, @current_user, topic, groups.map(&:id)) + Invite.generate(@current_user, email: email, topic: topic, group_ids: groups.map(&:id)) end end rescue => e diff --git a/app/models/invite.rb b/app/models/invite.rb index 491189202e5..f33eeb667be 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -2,6 +2,7 @@ class Invite < ActiveRecord::Base class UserExists < StandardError; end + include RateLimiter::OnCreateRecord include Trashable @@ -25,8 +26,12 @@ class Invite < ActiveRecord::Base has_many :groups, through: :invited_groups has_many :topic_invites has_many :topics, through: :topic_invites, source: :topic + validates_presence_of :invited_by_id validates :email, email: true, allow_blank: true + validate :ensure_max_redemptions_allowed + validate :user_doesnt_already_exist + validate :ensure_no_invalid_email_invites before_create do self.invite_key ||= SecureRandom.hex @@ -37,14 +42,8 @@ class Invite < ActiveRecord::Base self.email = Email.downcase(email) unless email.nil? end - validate :ensure_max_redemptions_allowed - validate :user_doesnt_already_exist - validate :ensure_no_invalid_email_invites attr_accessor :email_already_exists - scope :single_use_invites, -> { where('invites.max_redemptions_allowed = 1') } - scope :multiple_use_invites, -> { where('invites.max_redemptions_allowed > 1') } - def self.emailed_status_types @emailed_status_types ||= Enum.new(not_required: 0, pending: 1, bulk_pending: 2, sending: 3, sent: 4) end @@ -76,228 +75,127 @@ class Invite < ActiveRecord::Base expires_at < Time.zone.now end - # link_valid? indicates whether the invite link can be used to log in to the site + def link + "#{Discourse.base_url}/invites/#{invite_key}" + end + def link_valid? invalidated_at.nil? end - def redeem(username: nil, name: nil, password: nil, user_custom_fields: nil, ip_address: nil) - if !expired? && !destroyed? && link_valid? - InviteRedeemer.new(invite: self, email: self.email, username: username, name: name, password: password, user_custom_fields: user_custom_fields, ip_address: ip_address).redeem - end - end - - def self.invite_by_email(email, invited_by, topic = nil, group_ids = nil, custom_message = nil) - create_invite_by_email(email, invited_by, - topic: topic, - group_ids: group_ids, - custom_message: custom_message, - emailed_status: emailed_status_types[:pending] - ) - end - - def self.generate_single_use_invite_link(email, invited_by, topic = nil, group_ids = nil) - invite = create_invite_by_email(email, invited_by, - topic: topic, - group_ids: group_ids, - emailed_status: emailed_status_types[:not_required] - ) - - "#{Discourse.base_url}/invites/#{invite.invite_key}" if invite - end - - # Create an invite for a user, supplying an optional topic - # - # Return the previously existing invite if already exists. Returns nil if the invite can't be created. - def self.create_invite_by_email(email, invited_by, opts = nil) + def self.generate(invited_by, opts = nil) opts ||= {} - topic = opts[:topic] - group_ids = opts[:group_ids] - custom_message = opts[:custom_message] - emailed_status = opts[:emailed_status] || emailed_status_types[:pending] - lower_email = Email.downcase(email) + email = Email.downcase(opts[:email]) if opts[:email].present? - if user = find_user_by_email(lower_email) - raise UserExists.new(I18n.t("invite.user_exists", - email: lower_email, + if user = find_user_by_email(email) + raise UserExists.new(I18n.t( + "invite.user_exists", + email: email, username: user.username, base_path: Discourse.base_path )) end - invite = Invite.with_deleted - .where(email: lower_email, invited_by_id: invited_by.id) - .order('created_at DESC') - .first + if email.present? + invite = Invite + .with_deleted + .where(email: email, invited_by_id: invited_by.id) + .order('created_at DESC') + .first - if invite && (invite.expired? || invite.deleted_at) - invite.destroy - invite = nil + if invite && (invite.expired? || invite.deleted_at) + invite.destroy + invite = nil + end + end + + emailed_status = if opts[:skip_email] || invite&.emailed_status == emailed_status_types[:not_required] + emailed_status_types[:not_required] + elsif opts[:emailed_status].present? + opts[:emailed_status] + elsif email.present? + emailed_status_types[:pending] + else + emailed_status_types[:not_required] end if invite - if invite.emailed_status == Invite.emailed_status_types[:not_required] - emailed_status = invite.emailed_status - end - invite.update_columns( created_at: Time.zone.now, updated_at: Time.zone.now, - expires_at: SiteSetting.invite_expiry_days.days.from_now, + expires_at: opts[:expires_at] || SiteSetting.invite_expiry_days.days.from_now, emailed_status: emailed_status ) else - create_args = { - invited_by: invited_by, - email: lower_email, - emailed_status: emailed_status - } + create_args = opts.slice(:invite_key, :email, :moderator, :custom_message, :max_redemptions_allowed) + create_args[:invited_by] = invited_by + create_args[:email] = email + create_args[:emailed_status] = emailed_status + create_args[:expires_at] = opts[:expires_at] || SiteSetting.invite_expiry_days.days.from_now - create_args[:moderator] = true if opts[:moderator] - create_args[:custom_message] = custom_message if custom_message invite = Invite.create!(create_args) end - if topic && !invite.topic_invites.pluck(:topic_id).include?(topic.id) - invite.topic_invites.create!(invite_id: invite.id, topic_id: topic.id) - # to correct association - topic.reload + topic_id = opts[:topic]&.id || opts[:topic_id] + if topic_id.present? + invite.topic_invites.find_or_create_by!(topic_id: topic_id) end + group_ids = opts[:group_ids] if group_ids.present? - group_ids = group_ids - invite.invited_groups.pluck(:group_id) - group_ids.each do |group_id| - invite.invited_groups.create!(group_id: group_id) + invite.invited_groups.find_or_create_by!(group_id: group_id) end end if emailed_status == emailed_status_types[:pending] - invite.update_column(:emailed_status, Invite.emailed_status_types[:sending]) + invite.update_column(:emailed_status, emailed_status_types[:sending]) Jobs.enqueue(:invite_email, invite_id: invite.id) end invite.reload + end + + def redeem(email: nil, username: nil, name: nil, password: nil, user_custom_fields: nil, ip_address: nil) + if !expired? && !destroyed? && link_valid? + raise UserExists.new I18n.t("invite_link.email_taken") if is_invite_link? && UserEmail.exists?(email: email) + email = self.email if email.blank? && !is_invite_link? + InviteRedeemer.new(invite: self, email: email, username: username, name: name, password: password, user_custom_fields: user_custom_fields, ip_address: ip_address).redeem + end + end + + def self.redeem_from_email(email) + invite = Invite.find_by(email: Email.downcase(email)) + InviteRedeemer.new(invite: invite, email: invite.email).redeem if invite invite end - def self.generate_multiple_use_invite_link(invited_by:, max_redemptions_allowed: 5, expires_at: 1.month.from_now, group_ids: nil) - Invite.transaction do - create_args = { - invited_by: invited_by, - max_redemptions_allowed: max_redemptions_allowed.to_i, - expires_at: expires_at, - emailed_status: emailed_status_types[:not_required] - } - invite = Invite.create!(create_args) - - if group_ids.present? - now = Time.zone.now - invited_groups = group_ids.map { |group_id| { group_id: group_id, invite_id: invite.id, created_at: now, updated_at: now } } - InvitedGroup.insert_all(invited_groups) - end - - "#{Discourse.base_url}/invites/#{invite.invite_key}" - end - end - - # redeem multiple use invite link - def redeem_invite_link(email: nil, username: nil, name: nil, password: nil, user_custom_fields: nil, ip_address: nil) - DistributedMutex.synchronize("redeem_invite_link_#{self.id}") do - reload - if is_invite_link? && !expired? && !redeemed? && !destroyed? && link_valid? - raise UserExists.new I18n.t("invite_link.email_taken") if UserEmail.exists?(email: email) - InviteRedeemer.new(invite: self, email: email, username: username, name: name, password: password, user_custom_fields: user_custom_fields, ip_address: ip_address).redeem - end - end - end - def self.find_user_by_email(email) User.with_email(Email.downcase(email)).where(staged: false).first end - def self.get_group_ids(group_names) - group_ids = [] - if group_names - group_names = group_names.split(',') - group_names.each { |group_name| - group_detail = Group.find_by_name(group_name) - group_ids.push(group_detail.id) if group_detail - } - end - group_ids - end - - def self.find_all_pending_invites_from(inviter, offset = 0, limit = SiteSetting.invites_per_page) - Invite.single_use_invites + def self.pending(inviter) + Invite.distinct .joins("LEFT JOIN invited_users ON invites.id = invited_users.invite_id") .joins("LEFT JOIN users ON invited_users.user_id = users.id") - .where('invited_users.user_id IS NULL') .where(invited_by_id: inviter.id) - .where('invites.email IS NOT NULL') + .where('redemption_count < max_redemptions_allowed') .order('invites.updated_at DESC') - .limit(limit) - .offset(offset) end - def self.find_pending_invites_from(inviter, offset = 0) - find_all_pending_invites_from(inviter, offset) - end - - def self.find_pending_invites_count(inviter) - find_all_pending_invites_from(inviter, 0, nil).reorder(nil).count - end - - def self.find_all_redeemed_invites_from(inviter, offset = 0, limit = SiteSetting.invites_per_page) - InvitedUser.includes(:invite) + def self.redeemed_users(inviter) + InvitedUser + .includes(:invite) .includes(user: :user_stat) .where('invited_users.user_id IS NOT NULL') .where('invites.invited_by_id = ?', inviter.id) .order('user_stats.time_read DESC, invited_users.redeemed_at DESC') - .limit(limit) - .offset(offset) .references('invite') .references('user') .references('user_stat') end - def self.find_redeemed_invites_from(inviter, offset = 0) - find_all_redeemed_invites_from(inviter, offset) - end - - def self.find_redeemed_invites_count(inviter) - find_all_redeemed_invites_from(inviter, 0, nil).reorder(nil).count - end - - def self.find_all_links_invites_from(inviter, offset = 0, limit = SiteSetting.invites_per_page) - Invite.multiple_use_invites - .includes(invited_groups: :group) - .where(invited_by_id: inviter.id) - .order('invites.updated_at DESC') - .limit(limit) - .offset(offset) - end - - def self.find_links_invites_from(inviter, offset = 0) - find_all_links_invites_from(inviter, offset) - end - - def self.find_links_invites_count(inviter) - find_all_links_invites_from(inviter, 0, nil).reorder(nil).count - end - - def self.filter_by(email_or_username) - if email_or_username - where( - '(LOWER(invites.email) LIKE :filter) or (LOWER(users.username) LIKE :filter)', - filter: "%#{email_or_username.downcase}%" - ) - else - all - end - end - def self.invalidate_for_email(email) i = Invite.find_by(email: Email.downcase(email)) if i @@ -307,38 +205,11 @@ class Invite < ActiveRecord::Base i end - def self.redeem_from_email(email) - invite = Invite.single_use_invites.find_by(email: Email.downcase(email)) - InviteRedeemer.new(invite: invite, email: invite.email).redeem if invite - invite - end - def resend_invite self.update_columns(updated_at: Time.zone.now, invalidated_at: nil, expires_at: SiteSetting.invite_expiry_days.days.from_now) Jobs.enqueue(:invite_email, invite_id: self.id) end - def self.resend_all_invites_from(user_id) - Invite.single_use_invites - .left_outer_joins(:invited_users) - .where('invited_users.user_id IS NULL AND invites.email IS NOT NULL AND invited_by_id = ?', user_id) - .group('invites.id') - .find_each do |invite| - invite.resend_invite - end - end - - def self.rescind_all_expired_invites_from(user) - Invite.single_use_invites - .includes(:invited_users) - .where('invited_users.user_id IS NULL AND invites.email IS NOT NULL AND invited_by_id = ? AND invites.expires_at < ?', - user.id, Time.zone.now) - .references('invited_users') - .find_each do |invite| - invite.trash!(user) - end - end - def limit_invites_per_day RateLimiter.new(invited_by, "invites-per-day", SiteSetting.max_invites_per_day, 1.day.to_i) end @@ -348,12 +219,10 @@ class Invite < ActiveRecord::Base end def ensure_max_redemptions_allowed - if self.max_redemptions_allowed.nil? || self.max_redemptions_allowed == 1 - self.max_redemptions_allowed ||= 1 - else - if !self.max_redemptions_allowed.between?(2, SiteSetting.invite_link_max_redemptions_limit) - errors.add(:max_redemptions_allowed, I18n.t("invite_link.max_redemptions_limit", max_limit: SiteSetting.invite_link_max_redemptions_limit)) - end + if self.max_redemptions_allowed.nil? + self.max_redemptions_allowed = 1 + elsif !self.max_redemptions_allowed.between?(1, SiteSetting.invite_link_max_redemptions_limit) + errors.add(:max_redemptions_allowed, I18n.t("invite_link.max_redemptions_limit", max_limit: SiteSetting.invite_link_max_redemptions_limit)) end end diff --git a/app/models/invite_redeemer.rb b/app/models/invite_redeemer.rb index e95e0041395..3b9edabd213 100644 --- a/app/models/invite_redeemer.rb +++ b/app/models/invite_redeemer.rb @@ -110,7 +110,7 @@ InviteRedeemer = Struct.new(:invite, :email, :username, :name, :password, :user_ def get_invited_user result = get_existing_user - result ||= InviteRedeemer.create_user_from_invite(invite: invite, email: email, username: username, name: name, password: password, user_custom_fields: user_custom_fields, ip_address: ip_address) + result ||= InviteRedeemer.create_user_from_invite(email: email, invite: invite, username: username, name: name, password: password, user_custom_fields: user_custom_fields, ip_address: ip_address) result.send_welcome_message = false result end @@ -164,7 +164,8 @@ InviteRedeemer = Struct.new(:invite, :email, :username, :name, :password, :user_ end def delete_duplicate_invites - Invite.single_use_invites + Invite + .where('invites.max_redemptions_allowed = 1') .joins("LEFT JOIN invited_users ON invites.id = invited_users.invite_id") .where('invited_users.user_id IS NULL') .where('invites.email = ? AND invites.id != ?', email, invite.id) diff --git a/app/models/topic.rb b/app/models/topic.rb index 2ad0fccdc37..dd9d2871d14 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -1055,8 +1055,11 @@ class Topic < ActiveRecord::Base !!invite_to_topic(invited_by, target_user, group_ids, guardian) end elsif is_email && guardian.can_invite_via_email?(self) - !!Invite.invite_by_email( - username_or_email, invited_by, self, group_ids, custom_message + !!Invite.generate(invited_by, + email: username_or_email, + topic: self, + group_ids: group_ids, + custom_message: custom_message ) end end diff --git a/app/serializers/invite_serializer.rb b/app/serializers/invite_serializer.rb index f66f1c02c7c..460273161ef 100644 --- a/app/serializers/invite_serializer.rb +++ b/app/serializers/invite_serializer.rb @@ -1,7 +1,18 @@ # frozen_string_literal: true class InviteSerializer < ApplicationSerializer - attributes :id, :email, :updated_at, :expired + attributes :id, + :link, + :email, + :redemption_count, + :max_redemptions_allowed, + :custom_message, + :updated_at, + :expires_at, + :expired + + has_many :topics, embed: :object, serializer: BasicTopicSerializer + has_many :groups, embed: :object, serializer: BasicGroupSerializer def include_email? options[:show_emails] && !object.redeemed? diff --git a/app/serializers/invited_serializer.rb b/app/serializers/invited_serializer.rb index 68ced8ba108..e31fd0f08c9 100644 --- a/app/serializers/invited_serializer.rb +++ b/app/serializers/invited_serializer.rb @@ -1,18 +1,12 @@ # frozen_string_literal: true class InvitedSerializer < ApplicationSerializer - attributes :invites, :can_see_invite_details + attributes :invites, :can_see_invite_details, :counts def invites - serializer = if object.type == "pending" - InviteSerializer - else - InvitedUserSerializer - end - ActiveModel::ArraySerializer.new( object.invite_list, - each_serializer: serializer, + each_serializer: object.type == "pending" ? InviteSerializer : InvitedUserSerializer, scope: scope, root: false, show_emails: object.show_emails @@ -23,7 +17,7 @@ class InvitedSerializer < ApplicationSerializer scope.can_see_invite_details?(object.inviter) end - def read_attribute_for_serialization(attr) - object.respond_to?(attr) ? object.public_send(attr) : public_send(attr) + def counts + object.counts end end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 0fe379cd7f0..4d3f9bdd418 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1439,47 +1439,44 @@ en: notification_level_when_replying: "When I post in a topic, set that topic to" invited: - search: "type to search invites..." title: "Invites" - user: "Invited User" + pending_tab: "Pending" + pending_tab_with_count: "Pending (%{count})" + redeemed_tab: "Redeemed" + redeemed_tab_with_count: "Redeemed (%{count})" + invited_via: "Invited Via" + invited_via_link: "link (%{count} / %{max} redeemed)" + groups: "Groups" sent: "Last Sent" + expires_at: "Expires" + edit: "Edit" + remove: "Remove" + copy_link: "Get Link" + reinvite: "Send Email" + reinvited: "Invite re-sent" + removed: "Removed" + search: "type to search invites..." + user: "Invited User" none: "No invites to display." truncated: one: "Showing the first invite." other: "Showing the first %{count} invites." redeemed: "Redeemed Invites" - redeemed_tab: "Redeemed" - redeemed_tab_with_count: "Redeemed (%{count})" redeemed_at: "Redeemed" pending: "Pending Invites" - pending_tab: "Pending" - pending_tab_with_count: "Pending (%{count})" topics_entered: "Topics Viewed" posts_read_count: "Posts Read" expired: "This invite has expired." - rescind: "Remove" - rescinded: "Invite removed" - rescind_all: "Remove Expired Invites" - rescinded_all: "All Expired Invites removed!" - rescind_all_confirm: "Are you sure you want to remove all expired invites?" - reinvite: "Resend Invite" - reinvite_all: "Resend all Invites" + remove_all: "Remove Expired Invites" + removed_all: "All Expired Invites removed!" + remove_all_confirm: "Are you sure you want to remove all expired invites?" + reinvite_all: "Resend All Invites" reinvite_all_confirm: "Are you sure you want to resend all invites?" - reinvited: "Invite re-sent" - reinvited_all: "All Invites re-sent!" + reinvited_all: "All Invites Sent!" time_read: "Read Time" days_visited: "Days Visited" account_age_days: "Account age in days" - source: "Invited Via" - links_tab: "Links" - links_tab_with_count: "Links (%{count})" - link_url: "Link" - link_created_at: "Created" - link_redemption_stats: "Redemptions" - link_groups: Groups - link_expires_at: Expires create: "Invite" - copy_link: "Show Link" generate_link: "Create Invite Link" link_generated: "Here's your invite link!" valid_for: "Invite link is only valid for this email address: %{email}" @@ -1491,12 +1488,47 @@ en: error: "There was an error generating Invite link" max_redemptions_allowed_label: "How many people are allowed to register using this link?" expires_at: "When will this invite link expire?" + + invite: + new_title: "Create Invite" + edit_title: "Edit Invite" + show_link: "Invite Link" + + instructions: "Share this link to instantly grant access to this site:" + copy_link: "copy link" + expires_at_time: "Your invite expires in %{time}." + + show_advanced: "Show Advanced Options" + hide_advanced: "Hide Advanced Options" + + type_email: "Automatically send invitation link via email" + type_link: "Manually share an invite link to people" + + email: "Email address of invited person:" + max_redemptions_allowed: "Number of times the invite can be used before expiring:" + + add_to_groups: "Include invited people to groups:" + invite_to_topic: "Invite people to topic:" + expires_at: "Set an expiration date for invite:" + custom_message: "Personalize your invites by adding a custom message:" + + send_invite_email: "Send Invite Email" + save_invite: "Save Invite" + + invite_saved: "Invite was saved." + bulk_invite: none: "No invitations to display on this page." + text: "Bulk Invite" + instructions: | +

Invite a list of users to get your community going quickly. Prepare a CSV file containing at least one row per email address of users you want to invite. The following comma separated information can be provided if you want to add people to groups or send them to a specific topic the first time they sign in.

+
john@smith.com,first_group_name;second_group_name,42
+

Every email address in your uploaded CSV file will be sent an invitation, and you will be able to manage it later.

+ + progress: "Uploaded %{progress}%..." success: "File uploaded successfully, you will be notified via message when the process is complete." error: "Sorry, file should be CSV format." - confirmation_message: "You’re about to email invites to everyone in the uploaded file." password: title: "Password" diff --git a/config/routes.rb b/config/routes.rb index 3801d5455d1..db8379d1d02 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -478,9 +478,7 @@ Discourse::Application.routes.draw do get "#{root_path}/:username/summary" => "users#summary", constraints: { username: RouteFormat.username } put "#{root_path}/:username/notification_level" => "users#notification_level", constraints: { username: RouteFormat.username } get "#{root_path}/:username/invited" => "users#invited", constraints: { username: RouteFormat.username } - get "#{root_path}/:username/invited_count" => "users#invited_count", constraints: { username: RouteFormat.username } get "#{root_path}/:username/invited/:filter" => "users#invited", constraints: { username: RouteFormat.username } - get "#{root_path}/:username/invite_links" => "users#invite_links", constraints: { username: RouteFormat.username } post "#{root_path}/action/send_activation_email" => "users#send_activation_email" get "#{root_path}/:username/summary" => "users#show", constraints: { username: RouteFormat.username } get "#{root_path}/:username/activity/topics.rss" => "list#user_topics_feed", format: :rss, constraints: { username: RouteFormat.username } @@ -822,12 +820,12 @@ Discourse::Application.routes.draw do resources :invites, except: [:show] get "/invites/:id" => "invites#show", constraints: { format: :html } + put "/invites/:id" => "invites#update" post "invites/upload_csv" => "invites#upload_csv" - post "invites/rescind-all" => "invites#rescind_all_invites" + post "invites/destroy-all-expired" => "invites#destroy_all_expired" post "invites/reinvite" => "invites#resend_invite" post "invites/reinvite-all" => "invites#resend_all_invites" - post "invites/link" => "invites#create_invite_link" delete "invites" => "invites#destroy" put "invites/show/:id" => "invites#perform_accept_invitation", as: 'perform_accept_invite' diff --git a/lib/guardian.rb b/lib/guardian.rb index 81b6084a446..779148222a2 100644 --- a/lib/guardian.rb +++ b/lib/guardian.rb @@ -400,19 +400,11 @@ class Guardian SiteSetting.enable_local_logins end - def can_send_invite_links?(user) - user.staff? - end - - def can_send_multiple_invites?(user) - user.staff? - end - def can_resend_all_invites?(user) user.staff? end - def can_rescind_all_invites?(user) + def can_destroy_all_invites?(user) user.staff? end diff --git a/lib/wizard/builder.rb b/lib/wizard/builder.rb index 014e514a4e0..4dfee9af9c8 100644 --- a/lib/wizard/builder.rb +++ b/lib/wizard/builder.rb @@ -276,10 +276,10 @@ class Wizard users = JSON.parse(updater.fields[:invite_list]) users.each do |u| - args = {} + args = { email: u['email'] } args[:moderator] = true if u['role'] == 'moderator' begin - Invite.create_invite_by_email(u['email'], @wizard.user, args) + Invite.generate(@wizard.user, args) rescue => e updater.errors.add(:invite_list, e.message.concat("
")) end diff --git a/spec/models/invite_spec.rb b/spec/models/invite_spec.rb index 07325d79829..f7168d3e833 100644 --- a/spec/models/invite_spec.rb +++ b/spec/models/invite_spec.rb @@ -82,29 +82,28 @@ describe Invite do context 'email' do it 'enqueues a job to email the invite' do expect do - Invite.invite_by_email(iceking, inviter, topic) + Invite.generate(inviter, email: iceking, topic: topic) end.to change { Jobs::InviteEmail.jobs.size } end end context 'links' do it 'does not enqueue a job to email the invite' do - expect do - Invite.generate_single_use_invite_link(iceking, inviter, topic) - end.not_to change { Jobs::InviteEmail.jobs.size } + expect { Invite.generate(inviter, email: iceking, topic: topic, skip_email: true) } + .not_to change { Jobs::InviteEmail.jobs.size } end end context 'destroyed' do it "can invite the same user after their invite was destroyed" do - Invite.invite_by_email(iceking, inviter, topic).destroy! - invite = Invite.invite_by_email(iceking, inviter, topic) + Invite.generate(inviter, email: iceking, topic: topic).destroy! + invite = Invite.generate(inviter, email: iceking, topic: topic) expect(invite).to be_present end end context 'after created' do - let(:invite) { Invite.invite_by_email(iceking, inviter, topic) } + let(:invite) { Invite.generate(inviter, email: iceking, topic: topic) } it 'belongs to the topic' do expect(topic.invites).to eq([invite]) @@ -115,7 +114,7 @@ describe Invite do fab!(:coding_horror) { Fabricate(:coding_horror) } let(:new_invite) do - Invite.invite_by_email(iceking, coding_horror, topic) + Invite.generate(coding_horror, email: iceking, topic: topic) end it 'returns a different invite' do @@ -132,9 +131,7 @@ describe Invite do iceking@ADVENTURETIME.ooo ICEKING@adventuretime.ooo }.each do |email| - expect(Invite.invite_by_email( - email, inviter, topic - )).to eq(invite) + expect(Invite.generate(inviter, email: email, topic: topic)).to eq(invite) end end @@ -142,9 +139,7 @@ describe Invite do freeze_time invite.update!(created_at: 10.days.ago) - resend_invite = Invite.invite_by_email( - 'iceking@adventuretime.ooo', inviter, topic - ) + resend_invite = Invite.generate(inviter, email: 'iceking@adventuretime.ooo', topic: topic) expect(resend_invite.created_at).to eq_time(Time.zone.now) end @@ -153,10 +148,7 @@ describe Invite do SiteSetting.invite_expiry_days = 1 invite.update!(expires_at: 2.days.ago) - new_invite = Invite.invite_by_email( - 'iceking@adventuretime.ooo', inviter, topic - ) - + new_invite = Invite.generate(inviter, email: 'iceking@adventuretime.ooo', topic: topic) expect(new_invite).not_to eq(invite) expect(new_invite).not_to be_expired end @@ -166,7 +158,7 @@ describe Invite do fab!(:another_topic) { Fabricate(:topic, user: topic.user) } it 'should be the same invite' do - new_invite = Invite.invite_by_email(iceking, inviter, another_topic) + new_invite = Invite.generate(inviter, email: iceking, topic: another_topic) expect(new_invite).to eq(invite) expect(another_topic.invites).to eq([invite]) expect(invite.topics).to match_array([topic, another_topic]) @@ -186,20 +178,20 @@ describe Invite do it 'correctly marks invite emailed_status for email invites' do expect(invite.emailed_status).to eq(Invite.emailed_status_types[:sending]) - Invite.invite_by_email(iceking, inviter, topic) + Invite.generate(inviter, email: iceking, topic: topic) expect(invite.reload.emailed_status).to eq(Invite.emailed_status_types[:sending]) end it 'does not mark emailed_status as sending after generating invite link' do expect(invite.emailed_status).to eq(Invite.emailed_status_types[:sending]) - Invite.generate_single_use_invite_link(iceking, inviter, topic) + Invite.generate(inviter, email: iceking, topic: topic, emailed_status: Invite.emailed_status_types[:not_required]) expect(invite.reload.emailed_status).to eq(Invite.emailed_status_types[:not_required]) - Invite.invite_by_email(iceking, inviter, topic) + Invite.generate(inviter, email: iceking, topic: topic) expect(invite.reload.emailed_status).to eq(Invite.emailed_status_types[:not_required]) - Invite.generate_single_use_invite_link(iceking, inviter, topic) + Invite.generate(inviter, email: iceking, topic: topic, emailed_status: Invite.emailed_status_types[:not_required]) expect(invite.reload.emailed_status).to eq(Invite.emailed_status_types[:not_required]) end end @@ -208,32 +200,23 @@ describe Invite do context 'invite links' do let(:inviter) { Fabricate(:user) } - it 'with single use can exist' do - Invite.generate_multiple_use_invite_link(invited_by: inviter, max_redemptions_allowed: 1) - invite_link = Invite.last - expect(invite_link.is_invite_link?).to eq(true) - end - - it "has sane defaults" do - Invite.generate_multiple_use_invite_link(invited_by: inviter) - invite_link = Invite.last - expect(invite_link.max_redemptions_allowed).to eq(5) - expect(invite_link.expires_at.to_date).to eq(1.month.from_now.to_date) - expect(invite_link.emailed_status).to eq(Invite.emailed_status_types[:not_required]) - expect(invite_link.is_invite_link?).to eq(true) + it "can be created" do + invite = Invite.generate(inviter, max_redemptions_allowed: 5) + expect(invite.max_redemptions_allowed).to eq(5) + expect(invite.expires_at.to_date).to eq(SiteSetting.invite_expiry_days.days.from_now.to_date) + expect(invite.emailed_status).to eq(Invite.emailed_status_types[:not_required]) + expect(invite.is_invite_link?).to eq(true) end it 'checks for max_redemptions_allowed range' do SiteSetting.invite_link_max_redemptions_limit = 1000 - expect do - Invite.generate_multiple_use_invite_link(invited_by: inviter, max_redemptions_allowed: 1001) - end.to raise_error(ActiveRecord::RecordInvalid) + expect { Invite.generate(inviter, max_redemptions_allowed: 1001) } + .to raise_error(ActiveRecord::RecordInvalid) end it 'does not enqueue a job to email the invite' do - expect do - Invite.generate_multiple_use_invite_link(invited_by: inviter) - end.not_to change { Jobs::InviteEmail.jobs.size } + expect { Invite.generate(inviter) } + .not_to change { Jobs::InviteEmail.jobs.size } end end end @@ -242,17 +225,16 @@ describe Invite do fab!(:topic) { Fabricate(:topic, category_id: nil, archetype: 'private_message') } fab!(:coding_horror) { Fabricate(:coding_horror) } - it "works" do - expect do - Invite.invite_by_email(coding_horror.email, topic.user, topic) - end.to raise_error(Invite::UserExists) + it "raises the right error" do + expect { Invite.generate(topic.user, email: coding_horror.email, topic: topic) } + .to raise_error(Invite::UserExists) end end context 'a staged user' do it 'creates an invite for a staged user' do Fabricate(:staged, email: 'staged@account.com') - invite = Invite.invite_by_email('staged@account.com', Fabricate(:coding_horror)) + invite = Invite.generate(Fabricate(:coding_horror), email: 'staged@account.com') expect(invite).to be_valid expect(invite.email).to eq('staged@account.com') @@ -424,33 +406,28 @@ describe Invite do fab!(:invite_link) { Fabricate(:invite, email: nil, max_redemptions_allowed: 5, expires_at: 1.month.from_now, emailed_status: Invite.emailed_status_types[:not_required]) } it 'works correctly' do - user = invite_link.redeem_invite_link(email: 'foo@example.com') + user = invite_link.redeem(email: 'foo@example.com') expect(user.is_a?(User)).to eq(true) expect(user.send_welcome_message).to eq(true) expect(user.trust_level).to eq(SiteSetting.default_invitee_trust_level) expect(user.active).to eq(false) - invite_link.reload - expect(invite_link.redemption_count).to eq(1) + expect(invite_link.reload.redemption_count).to eq(1) end it 'returns error if user with that email already exists' do user = Fabricate(:user) - expect do - invite_link.redeem_invite_link(email: user.email) - end.to raise_error(Invite::UserExists) + expect { invite_link.redeem(email: user.email) }.to raise_error(Invite::UserExists) end end end - describe '.find_all_pending_invites_from' do + describe '.pending' do context 'with user that has invited' do it 'returns invites' do inviter = Fabricate(:user) invite = Fabricate(:invite, invited_by: inviter) - invites = Invite.find_all_pending_invites_from(inviter) - - expect(invites).to include invite + expect(Invite.pending(inviter)).to include(invite) end end @@ -459,107 +436,46 @@ describe Invite do user = Fabricate(:user) Fabricate(:invite) - invites = Invite.find_all_pending_invites_from(user) - - expect(invites).to be_empty + expect(Invite.pending(user)).to be_empty end end - end - describe '.find_pending_invites_from' do it 'returns pending invites only' do inviter = Fabricate(:user) - redeemed_invite = Fabricate( - :invite, - invited_by: inviter, - email: 'redeemed@example.com' - ) - Fabricate(:invited_user, invite: redeemed_invite, user: Fabricate(:user)) - pending_invite = Fabricate( - :invite, - invited_by: inviter, - email: 'pending@example.com' - ) + redeemed_invite = Fabricate(:invite, invited_by: inviter, email: 'redeemed@example.com') + redeemed_invite.redeem - invites = Invite.find_pending_invites_from(inviter) + pending_invite = Fabricate(:invite, invited_by: inviter, email: 'pending@example.com') + pending_link_invite = Fabricate(:invite, invited_by: inviter, max_redemptions_allowed: 5) - expect(invites.length).to eq(1) - expect(invites.first).to eq pending_invite - - expect(Invite.find_pending_invites_count(inviter)).to eq(1) + expect(Invite.pending(inviter)).to contain_exactly(pending_invite, pending_link_invite) end end - describe '.find_redeemed_invites_from' do + describe '.redeemed_users' do it 'returns redeemed invites only' do inviter = Fabricate(:user) - Fabricate( - :invite, - invited_by: inviter, - email: 'pending@example.com' - ) - redeemed_invite = Fabricate( - :invite, - invited_by: inviter, - email: 'redeemed@example.com' - ) + Fabricate(:invite, invited_by: inviter, email: 'pending@example.com') + + redeemed_invite = Fabricate(:invite, invited_by: inviter, email: 'redeemed@example.com') Fabricate(:invited_user, invite: redeemed_invite, user: Fabricate(:user)) - invites = Invite.find_redeemed_invites_from(inviter) - - expect(invites.length).to eq(1) - expect(invites.first).to eq redeemed_invite.invited_users.first - - expect(Invite.find_redeemed_invites_count(inviter)).to eq(1) + expect(Invite.redeemed_users(inviter)).to contain_exactly(redeemed_invite.invited_users.first) end it 'returns redeemed invites for invite links' do inviter = Fabricate(:user) - invite_link = Fabricate( - :invite, - invited_by: inviter, - max_redemptions_allowed: 50 - ) - Fabricate(:invited_user, invite: invite_link, user: Fabricate(:user)) - Fabricate(:invited_user, invite: invite_link, user: Fabricate(:user)) - Fabricate(:invited_user, invite: invite_link, user: Fabricate(:user)) + invite_link = Fabricate(:invite, invited_by: inviter, max_redemptions_allowed: 50) - invites = Invite.find_redeemed_invites_from(inviter) - expect(invites.length).to eq(3) - expect(Invite.find_redeemed_invites_count(inviter)).to eq(3) - end - end + redeemed = [ + Fabricate(:invited_user, invite: invite_link, user: Fabricate(:user)), + Fabricate(:invited_user, invite: invite_link, user: Fabricate(:user)), + Fabricate(:invited_user, invite: invite_link, user: Fabricate(:user)) + ] - describe '.find_links_invites_from' do - it 'returns invite links only' do - inviter = Fabricate(:user) - Fabricate( - :invite, - invited_by: inviter, - email: 'pending@example.com' - ) - - invite_link_1 = Fabricate( - :invite, - invited_by: inviter, - max_redemptions_allowed: 5 - ) - - invite_link_2 = Fabricate( - :invite, - invited_by: inviter, - max_redemptions_allowed: 50 - ) - - invites = Invite.find_links_invites_from(inviter) - - expect(invites.length).to eq(2) - expect(invites.first).to eq(invite_link_2) - expect(invites.first.max_redemptions_allowed).to eq(50) - - expect(Invite.find_links_invites_count(inviter)).to eq(2) + expect(Invite.redeemed_users(inviter)).to match_array(redeemed) end end @@ -605,46 +521,6 @@ describe Invite do end end - describe '.resend_all_invites_from' do - it 'resends all non-redeemed invites by a user' do - SiteSetting.invite_expiry_days = 30 - user = Fabricate(:user) - new_invite = Fabricate(:invite, invited_by: user) - expired_invite = Fabricate(:invite, invited_by: user) - expired_invite.update!(expires_at: 2.days.ago) - redeemed_invite = Fabricate(:invite, invited_by: user) - Fabricate(:invited_user, invite: redeemed_invite, user: Fabricate(:user)) - redeemed_invite.update!(expires_at: 5.days.ago) - - Invite.resend_all_invites_from(user.id) - new_invite.reload - expired_invite.reload - redeemed_invite.reload - - expect(new_invite.expires_at.to_date).to eq(30.days.from_now.to_date) - expect(expired_invite.expires_at.to_date).to eq(30.days.from_now.to_date) - expect(redeemed_invite.expires_at.to_date).to eq(5.days.ago.to_date) - end - end - - describe '.rescind_all_expired_invites_from' do - it 'removes all expired invites sent by a user' do - SiteSetting.invite_expiry_days = 1 - user = Fabricate(:user) - invite_1 = Fabricate(:invite, invited_by: user) - invite_2 = Fabricate(:invite, invited_by: user) - expired_invite = Fabricate(:invite, invited_by: user) - expired_invite.update!(expires_at: 2.days.ago) - Invite.rescind_all_expired_invites_from(user) - invite_1.reload - invite_2.reload - expired_invite.reload - expect(invite_1.deleted_at).to eq(nil) - expect(invite_2.deleted_at).to eq(nil) - expect(expired_invite.deleted_at).to be_present - end - end - describe '#emailed_status_types' do context "verify enum sequence" do before do diff --git a/spec/requests/api/invites_spec.rb b/spec/requests/api/invites_spec.rb index bf2b18f3c90..5084ae7f0c1 100644 --- a/spec/requests/api/invites_spec.rb +++ b/spec/requests/api/invites_spec.rb @@ -33,31 +33,4 @@ describe 'invites' do end end end - - path '/invites/link.json' do - post 'Generate an invite link, but do not send an email' do - tags 'Invites' - consumes 'application/json' - parameter name: 'Api-Key', in: :header, type: :string, required: true - parameter name: 'Api-Username', in: :header, type: :string, required: true - - parameter name: :request_body, in: :body, schema: { - type: :object, - properties: { - email: { type: :string }, - group_names: { type: :string }, - custom_message: { type: :string }, - }, required: ['email'] - } - - produces 'application/json' - response '200', 'success response' do - schema type: :string, example: "http://discourse.example.com/invites/token_value" - - let(:request_body) { { email: 'not-a-user-yet@example.com' } } - run_test! - end - end - end - end diff --git a/spec/requests/invites_controller_spec.rb b/spec/requests/invites_controller_spec.rb index 01101545501..735f8c94f1d 100644 --- a/spec/requests/invites_controller_spec.rb +++ b/spec/requests/invites_controller_spec.rb @@ -118,11 +118,10 @@ describe InvitesController do it "fails for normal user if invite email already exists" do user = sign_in(trust_level_4) - invite = Invite.invite_by_email("invite@example.com", user) + invite = Invite.generate(user, email: "invite@example.com") post "/invites.json", params: { email: invite.email } expect(response.status).to eq(422) - json = response.parsed_body - expect(json["failed"]).to be_present + expect(response.parsed_body["failed"]).to be_present end it "allows admins to invite to groups" do @@ -147,7 +146,7 @@ describe InvitesController do it "does not allow admins to send multiple invites to same email" do user = sign_in(admin) - invite = Invite.invite_by_email("invite@example.com", user) + invite = Invite.generate(user, email: "invite@example.com") post "/invites.json", params: { email: invite.email } expect(response.status).to eq(422) end @@ -156,17 +155,14 @@ describe InvitesController do sign_in(admin) post "/invites.json", params: { email: "test@mailinator.com" } expect(response.status).to eq(422) - json = response.parsed_body - expect(json["errors"]).to be_present + expect(response.parsed_body["errors"]).to be_present end end - end - describe "#create_invite_link" do describe 'single use invite link' do it 'requires you to be logged in' do - post "/invites/link.json", params: { - email: 'jake@adventuretime.ooo' + post "/invites.json", params: { + email: 'jake@adventuretime.ooo', skip_email: true } expect(response.status).to eq(403) end @@ -176,29 +172,23 @@ describe InvitesController do it "fails if you can't invite to the forum" do sign_in(Fabricate(:user)) - post "/invites/link.json", params: { email: email } - expect(response.status).to eq(422) + post "/invites.json", params: { email: email, skip_email: true } + expect(response.status).to eq(403) end it "fails for normal user if invite email already exists" do user = sign_in(trust_level_4) - invite = Invite.invite_by_email("invite@example.com", user) - - post "/invites/link.json", params: { - email: invite.email - } + invite = Invite.generate(user, email: "invite@example.com") + post "/invites.json", params: { email: invite.email, skip_email: true } expect(response.status).to eq(422) end - it "returns the right response when topic_id is invalid" do + it "fails when topic_id is invalid" do sign_in(trust_level_4) - post "/invites/link.json", params: { - email: email, topic_id: -9999 - } - - expect(response.status).to eq(422) + post "/invites.json", params: { email: email, skip_email: true, topic_id: -9999 } + expect(response.status).to eq(400) end it "verifies that inviter is authorized to invite new user to a group-private topic" do @@ -207,19 +197,19 @@ describe InvitesController do group_private_topic = Fabricate(:topic, category: private_category) sign_in(trust_level_4) - post "/invites/link.json", params: { - email: email, topic_id: group_private_topic.id + post "/invites.json", params: { + email: email, skip_email: true, topic_id: group_private_topic.id } - expect(response.status).to eq(422) + expect(response.status).to eq(403) end it "allows admins to invite to groups" do group = Fabricate(:group) sign_in(admin) - post "/invites/link.json", params: { - email: email, group_ids: [group.id] + post "/invites.json", params: { + email: email, skip_email: true, group_ids: [group.id] } expect(response.status).to eq(200) @@ -231,8 +221,8 @@ describe InvitesController do Fabricate(:group, name: "support") sign_in(admin) - post "/invites/link.json", params: { - email: email, group_names: "security,support" + post "/invites.json", params: { + email: email, skip_email: true, group_names: "security,support" } expect(response.status).to eq(200) @@ -243,34 +233,26 @@ describe InvitesController do describe 'multiple use invite link' do it 'requires you to be logged in' do - post "/invites/link.json", params: { + post "/invites.json", params: { max_redemptions_allowed: 5 } expect(response).to be_forbidden end context 'while logged in' do - it "fails for non-staff users" do - sign_in(trust_level_4) - post "/invites/link.json", params: { - max_redemptions_allowed: 5 - } - expect(response.status).to eq(422) - end - it "allows staff to invite to groups" do moderator = Fabricate(:moderator) sign_in(moderator) group = Fabricate(:group) group.add_owner(moderator) - post "/invites/link.json", params: { + post "/invites.json", params: { max_redemptions_allowed: 5, group_ids: [group.id] } expect(response.status).to eq(200) - expect(Invite.multiple_use_invites.last.invited_groups.count).to eq(1) + expect(Invite.last.invited_groups.count).to eq(1) end it "allows multiple group invite" do @@ -278,26 +260,47 @@ describe InvitesController do Fabricate(:group, name: "support") sign_in(admin) - post "/invites/link.json", params: { + post "/invites.json", params: { max_redemptions_allowed: 5, group_names: "security,support" } expect(response.status).to eq(200) - expect(Invite.multiple_use_invites.last.invited_groups.count).to eq(2) + expect(Invite.last.invited_groups.count).to eq(2) end end end end + context '#update' do + fab!(:invite) { Fabricate(:invite, invited_by: admin, email: 'test@example.com') } + + before do + sign_in(admin) + end + + it 'updating email address resends invite email' do + put "/invites/#{invite.id}", params: { email: 'test2@example.com' } + + expect(response.status).to eq(200) + expect(Jobs::InviteEmail.jobs.size).to eq(1) + end + + it 'updating does not resend invite email' do + put "/invites/#{invite.id}", params: { custom_message: "new message" } + + expect(response.status).to eq(200) + expect(invite.reload.custom_message).to eq("new message") + expect(Jobs::InviteEmail.jobs.size).to eq(0) + end + end + context '#perform_accept_invitation' do context 'with an invalid invite id' do it "redirects to the root and doesn't change the session" do put "/invites/show/doesntexist.json" - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["success"]).to eq(false) - expect(json["message"]).to eq(I18n.t('invite.not_found_json')) + expect(response.status).to eq(404) + expect(response.parsed_body["message"]).to eq(I18n.t('invite.not_found_json')) expect(session[:current_user_id]).to be_blank end end @@ -307,20 +310,15 @@ describe InvitesController do it "responds with error message" do invite.update_attribute(:email, "John Doe ") put "/invites/show/#{invite.invite_key}.json" - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["success"]).to eq(false) - expect(json["message"]).to eq(I18n.t('invite.error_message')) + expect(response.status).to eq(412) + expect(response.parsed_body["message"]).to eq(I18n.t('invite.error_message')) expect(session[:current_user_id]).to be_blank end end context 'with a deleted invite' do fab!(:topic) { Fabricate(:topic) } - - let(:invite) do - Invite.invite_by_email("iceking@adventuretime.ooo", topic.user, topic) - end + let(:invite) { Invite.generate(topic.user, email: "iceking@adventuretime.ooo", topic: topic) } before do invite.destroy! @@ -329,10 +327,8 @@ describe InvitesController do it "redirects to the root" do put "/invites/show/#{invite.invite_key}.json" - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["success"]).to eq(false) - expect(json["message"]).to eq(I18n.t('invite.not_found_json')) + expect(response.status).to eq(404) + expect(response.parsed_body["message"]).to eq(I18n.t('invite.not_found_json')) expect(session[:current_user_id]).to be_blank end end @@ -343,19 +339,15 @@ describe InvitesController do it "response is not successful" do put "/invites/show/#{invite_link.invite_key}.json" - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["success"]).to eq(false) - expect(json["message"]).to eq(I18n.t('invite.not_found_json')) + expect(response.status).to eq(404) + expect(response.parsed_body["message"]).to eq(I18n.t('invite.not_found_json')) expect(session[:current_user_id]).to be_blank end end context 'with a valid invite id' do fab!(:topic) { Fabricate(:topic) } - let(:invite) do - Invite.invite_by_email("iceking@adventuretime.ooo", topic.user, topic) - end + let(:invite) { Invite.generate(topic.user, email: "iceking@adventuretime.ooo", topic: topic) } it 'redeems the invite' do put "/invites/show/#{invite.invite_key}.json" @@ -387,9 +379,7 @@ describe InvitesController do it 'redirects to the first topic the user was invited to' do put "/invites/show/#{invite.invite_key}.json" expect(response.status).to eq(200) - json = response.parsed_body - expect(json["success"]).to eq(true) - expect(json["redirect_to"]).to eq(topic.relative_url) + expect(response.parsed_body["redirect_to"]).to eq(topic.relative_url) end context "if a timezone guess is provided" do @@ -406,10 +396,8 @@ describe InvitesController do context 'failure' do it "doesn't log in the user if there's a validation error" do put "/invites/show/#{invite.invite_key}.json", params: { password: "password" } - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["success"]).to eq(false) - expect(json["errors"]["password"]).to be_present + expect(response.status).to eq(412) + expect(response.parsed_body["errors"]["password"]).to be_present end end @@ -418,7 +406,6 @@ describe InvitesController do user.send_welcome_message = true put "/invites/show/#{invite.invite_key}.json" expect(response.status).to eq(200) - expect(response.parsed_body["success"]).to eq(true) expect(Jobs::SendSystemMessage.jobs.size).to eq(1) end @@ -474,7 +461,6 @@ describe InvitesController do end.to change { UserAuthToken.count }.by(1) expect(response.status).to eq(200) - expect(response.parsed_body["success"]).to eq(true) expect(Jobs::InvitePasswordInstructionsEmail.jobs.size).to eq(0) expect(Jobs::CriticalUserEmail.jobs.size).to eq(0) @@ -494,7 +480,6 @@ describe InvitesController do end.not_to change { UserAuthToken.count } expect(response.status).to eq(200) - expect(response.parsed_body["success"]).to eq(true) expect(response.parsed_body["message"]).to eq(I18n.t("invite.confirm_email")) invited_user = User.find_by_email(invite.email) @@ -527,7 +512,6 @@ describe InvitesController do end.not_to change { UserAuthToken.count } expect(response.status).to eq(200) - expect(response.parsed_body["success"]).to eq(true) expect(response.parsed_body["message"]).to eq(I18n.t("invite.confirm_email")) invite_link.reload @@ -553,9 +537,7 @@ describe InvitesController do context 'new registrations are disabled' do fab!(:topic) { Fabricate(:topic) } - let(:invite) do - Invite.invite_by_email("iceking@adventuretime.ooo", topic.user, topic) - end + let(:invite) { Invite.generate(topic.user, email: "iceking@adventuretime.ooo", topic: topic) } before { SiteSetting.allow_new_registrations = false } @@ -572,9 +554,7 @@ describe InvitesController do context 'user is already logged in' do fab!(:topic) { Fabricate(:topic) } - let(:invite) do - Invite.invite_by_email("iceking@adventuretime.ooo", topic.user, topic) - end + let(:invite) { Invite.generate(topic.user, email: "iceking@adventuretime.ooo", topic: topic) } let!(:user) { sign_in(Fabricate(:user)) } @@ -589,6 +569,26 @@ describe InvitesController do end end + context "#destroy_all" do + it 'removes all expired invites sent by a user' do + SiteSetting.invite_expiry_days = 1 + + user = Fabricate(:admin) + invite_1 = Fabricate(:invite, invited_by: user) + invite_2 = Fabricate(:invite, invited_by: user) + expired_invite = Fabricate(:invite, invited_by: user) + expired_invite.update!(expires_at: 2.days.ago) + + sign_in(user) + post "/invites/destroy-all-expired" + + expect(response.status).to eq(200) + expect(invite_1.reload.deleted_at).to eq(nil) + expect(invite_2.reload.deleted_at).to eq(nil) + expect(expired_invite.reload.deleted_at).to be_present + end + end + context '#resend_invite' do it 'requires you to be logged in' do post "/invites/reinvite.json", params: { email: 'first_name@example.com' } @@ -623,6 +623,28 @@ describe InvitesController do end end + context '#resend_all_invites' do + it 'resends all non-redeemed invites by a user' do + SiteSetting.invite_expiry_days = 30 + + user = Fabricate(:admin) + new_invite = Fabricate(:invite, invited_by: user) + expired_invite = Fabricate(:invite, invited_by: user) + expired_invite.update!(expires_at: 2.days.ago) + redeemed_invite = Fabricate(:invite, invited_by: user) + Fabricate(:invited_user, invite: redeemed_invite, user: Fabricate(:user)) + redeemed_invite.update!(expires_at: 5.days.ago) + + sign_in(user) + post "/invites/reinvite-all" + + expect(response.status).to eq(200) + expect(new_invite.reload.expires_at.to_date).to eq(30.days.from_now.to_date) + expect(expired_invite.reload.expires_at.to_date).to eq(30.days.from_now.to_date) + expect(redeemed_invite.reload.expires_at.to_date).to eq(5.days.ago.to_date) + end + end + context '#upload_csv' do it 'requires you to be logged in' do post "/invites/upload_csv.json" @@ -658,8 +680,7 @@ describe InvitesController do expect(response.status).to eq(422) expect(Jobs::BulkInvite.jobs.size).to eq(1) - json = response.parsed_body - expect(json["errors"][0]).to eq(I18n.t("bulk_invite.max_rows", max_bulk_invites: SiteSetting.max_bulk_invites)) + expect(response.parsed_body["errors"][0]).to eq(I18n.t("bulk_invite.max_rows", max_bulk_invites: SiteSetting.max_bulk_invites)) end end end diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index 4c02d4c1ac1..dca299f508c 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -1584,28 +1584,6 @@ describe UsersController do end end - describe "#invited_count" do - it "fails for anonymous users" do - user = Fabricate(:user) - get "/u/#{user.username}/invited_count.json" - expect(response.status).to eq(422) - end - - it "works for users who can see invites" do - inviter = Fabricate(:user, trust_level: 2) - sign_in(inviter) - invitee = Fabricate(:user) - _invite = Fabricate(:invite, invited_by: inviter) - Fabricate(:invited_user, invite: _invite, user: invitee) - get "/u/#{user.username}/invited_count.json" - expect(response.status).to eq(200) - - json = response.parsed_body - expect(json).to be_present - expect(json['counts']).to be_present - end - end - describe '#invited' do it 'fails for anonymous users' do user = Fabricate(:user) @@ -1616,10 +1594,14 @@ describe UsersController do it 'returns success' do user = Fabricate(:user, trust_level: 2) + Fabricate(:invite, invited_by: user) + sign_in(user) get "/u/#{user.username}/invited.json", params: { username: user.username } expect(response.status).to eq(200) + expect(response.parsed_body["counts"]["pending"]).to eq(1) + expect(response.parsed_body["counts"]["total"]).to eq(1) end it 'filters by all if viewing self' do @@ -1748,6 +1730,46 @@ describe UsersController do expect(response.status).to eq(422) end end + + context 'with permission to see invite links' do + it 'returns invites' do + inviter = sign_in(Fabricate(:admin)) + invite = Fabricate(:invite, invited_by: inviter, email: nil, max_redemptions_allowed: 5, expires_at: 1.month.from_now, emailed_status: Invite.emailed_status_types[:not_required]) + + get "/u/#{inviter.username}/invited/pending.json" + expect(response.status).to eq(200) + + invites = response.parsed_body['invites'] + expect(invites.size).to eq(1) + expect(invites.first).to include("id" => invite.id) + end + end + + context 'without permission to see invite links' do + it 'does not return invites' do + user = Fabricate(:user, trust_level: 2) + inviter = Fabricate(:admin) + Fabricate(:invite, invited_by: inviter, email: nil, max_redemptions_allowed: 5, expires_at: 1.month.from_now, emailed_status: Invite.emailed_status_types[:not_required]) + + get "/u/#{inviter.username}/invited/pending.json" + expect(response.status).to eq(403) + end + end + + context 'when local logins are disabled' do + it 'explains why invites are disabled to staff users' do + SiteSetting.enable_local_logins = false + inviter = sign_in(Fabricate(:admin)) + Fabricate(:invite, invited_by: inviter, email: nil, max_redemptions_allowed: 5, expires_at: 1.month.from_now, emailed_status: Invite.emailed_status_types[:not_required]) + + get "/u/#{inviter.username}/invited/pending.json" + expect(response.status).to eq(200) + + expect(response.parsed_body['error']).to include(I18n.t( + 'invite.disabled_errors.local_logins_disabled' + )) + end + end end context 'with redeemed invites' do @@ -1766,48 +1788,6 @@ describe UsersController do expect(invites[0]).to include('id' => invite.id) end end - - context 'with invite links' do - context 'with permission to see invite links' do - it 'returns invites' do - inviter = sign_in(Fabricate(:admin)) - invite = Fabricate(:invite, invited_by: inviter, email: nil, max_redemptions_allowed: 5, expires_at: 1.month.from_now, emailed_status: Invite.emailed_status_types[:not_required]) - - get "/u/#{inviter.username}/invite_links.json" - expect(response.status).to eq(200) - - invites = response.parsed_body['invites'] - expect(invites.size).to eq(1) - expect(invites.first).to include("id" => invite.id) - end - end - - context 'without permission to see invite links' do - it 'does not return invites' do - user = Fabricate(:user, trust_level: 2) - inviter = Fabricate(:admin) - Fabricate(:invite, invited_by: inviter, email: nil, max_redemptions_allowed: 5, expires_at: 1.month.from_now, emailed_status: Invite.emailed_status_types[:not_required]) - - get "/u/#{inviter.username}/invite_links.json" - expect(response.status).to eq(403) - end - end - - context 'when local logins are disabled' do - it 'explains why invites are disabled to staff users' do - SiteSetting.enable_local_logins = false - inviter = sign_in(Fabricate(:admin)) - Fabricate(:invite, invited_by: inviter, email: nil, max_redemptions_allowed: 5, expires_at: 1.month.from_now, emailed_status: Invite.emailed_status_types[:not_required]) - - get "/u/#{inviter.username}/invite_links.json" - expect(response.status).to eq(200) - - expect(response.parsed_body['error']).to include(I18n.t( - 'invite.disabled_errors.local_logins_disabled' - )) - end - end - end end end