From 7f3240ea3152578a545ac4d8ed87465619b0e14c Mon Sep 17 00:00:00 2001 From: Dan Ungureanu Date: Sat, 6 Mar 2021 13:29:35 +0200 Subject: [PATCH] FEATURE: Various improvements to invite system (#12298) * FIX: Do not show expired invites under Pending tab * DEV: Controller action was renamed in previous commit * FEATURE: Add 'Expired' tab to invites * FEATURE: Refresh model after removing expired invites * FEATURE: Do not immediately add invite to the list Opening the 'create-invite' modal used to automatically generate an invite to reserve an invite link. If the user did not save it and closed the modal, the invite would be destroyed. This operations caused the invite list to change in the background and confuse users. * FEATURE: Sort redeemed users by creation time * UX: Improve show / hide advanced options link * FIX: Show redeemed users even if invites were trashed * UX: Change modal title when editing invite * UX: Remove Get Link button Users can get it from the edit modal * FEATURE: Add limit for invite links generated by regular users * FEATURE: Add option to skip email * UX: Show better error messages * FIX: Show "Invited by" even if invite was trashed Follow up to 1fdfa13a099d8e46edd0c481b3aaaafe40455ced. * FEATURE: Add button to save without sending email Follow up to c86379a465f28a3cc64a4a8c939cf32cf2931659. * DEV: Use a buffer to hold all changed data * FEATURE: Close modal after save * FEATURE: Rate limit resend invite email * FEATURE: Make the save buttons smarter * FEATURE: Do not always send email even for new invites --- .../app/controllers/create-invite.js | 90 ++++++++++--------- .../app/controllers/user-invited-show.js | 22 +++-- .../discourse/app/models/invite.js | 10 +++ .../discourse/app/routes/user-invited-show.js | 6 ++ .../app/templates/modal/create-invite.hbs | 50 ++++++----- .../app/templates/user-invited-show.hbs | 34 ++++++- app/assets/stylesheets/common/base/modal.scss | 2 +- app/controllers/invites_controller.rb | 30 +++++-- app/controllers/users_controller.rb | 6 +- app/models/invite.rb | 30 +++++-- app/models/invited_user.rb | 2 +- app/models/user.rb | 2 +- app/serializers/invited_serializer.rb | 2 +- config/locales/client.en.yml | 9 +- config/locales/server.en.yml | 1 + config/site_settings.yml | 5 ++ spec/models/invite_spec.rb | 13 ++- spec/models/user_spec.rb | 10 +++ spec/requests/invites_controller_spec.rb | 19 ++++ 19 files changed, 244 insertions(+), 99 deletions(-) diff --git a/app/assets/javascripts/discourse/app/controllers/create-invite.js b/app/assets/javascripts/discourse/app/controllers/create-invite.js index 008e2d76b9c..d01ed48acb6 100644 --- a/app/assets/javascripts/discourse/app/controllers/create-invite.js +++ b/app/assets/javascripts/discourse/app/controllers/create-invite.js @@ -7,7 +7,6 @@ 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, @@ -20,14 +19,9 @@ export default Controller.extend( 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)); @@ -36,7 +30,6 @@ export default Controller.extend( this.setProperties({ autogenerated: false, showAdvanced: false, - showOnly: false, }); this.setInvite(Invite.create()); @@ -54,38 +47,45 @@ export default Controller.extend( 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); + save(opts) { + const newRecord = + (this.autogenerated || !this.invite.id) && !opts.autogenerated; - const data = { - group_ids: this.groupIds, - topic_id: this.topicId, - expires_at: this.buffered.get("expires_at"), - }; + this.set("autogenerated", opts.autogenerated); - 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 data = { ...this.buffered.buffer }; + + if (data.groupIds !== undefined) { + data.group_ids = data.groupIds; + delete data.groupIds; + } + + if (data.topicId !== undefined) { + data.topic_id = data.topicId; + delete data.topicId; + delete data.topicTitle; + } + + if (this.type === "link") { + if (this.buffered.get("email")) { + data.email = ""; + data.custom_message = ""; + } + } else if (this.type === "email") { + if (this.buffered.get("max_redemptions_allowed") > 1) { + data.max_redemptions_allowed = 1; + } + + if (opts.sendEmail) { + data.send_email = true; + } else { + data.skip_email = true; + } } - const newRecord = !this.invite.id; return this.invite .save(data) .then(() => { @@ -96,10 +96,7 @@ export default Controller.extend( } if (!this.autogenerated) { - this.appEvents.trigger("modal-body:flash", { - text: I18n.t("user.invited.invite.invite_saved"), - messageClass: "success", - }); + this.send("closeModal"); } }) .catch((e) => @@ -113,6 +110,15 @@ export default Controller.extend( isLink: equal("type", "link"), isEmail: equal("type", "email"), + @discourseComputed( + "currentUser.staff", + "siteSettings.invite_link_max_redemptions_limit", + "siteSettings.invite_link_max_redemptions_limit_users" + ) + maxRedemptionsAllowedLimit(staff, staffLimit, usersLimit) { + return staff ? staffLimit : usersLimit; + }, + @discourseComputed("buffered.expires_at") expiresAtRelative(expires_at) { return moment.duration(moment(expires_at) - moment()).humanize(); @@ -127,18 +133,16 @@ export default Controller.extend( 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"; + @discourseComputed("buffered.hasBufferedChanges", "invite.email", "type") + changed(hasBufferedChanges, inviteEmail, type) { + return hasBufferedChanges || (inviteEmail ? "email" : "link") !== type; }, @action - saveInvite() { + saveInvite(sendEmail) { this.appEvents.trigger("modal-body:clearFlash"); - this.save(); + this.save({ sendEmail }); }, } ); 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 1d5df0fdb3a..3cffa2cbacd 100644 --- a/app/assets/javascripts/discourse/app/controllers/user-invited-show.js +++ b/app/assets/javascripts/discourse/app/controllers/user-invited-show.js @@ -42,6 +42,7 @@ export default Controller.extend({ }, inviteRedeemed: equal("filter", "redeemed"), + inviteExpired: equal("filter", "expired"), invitePending: equal("filter", "pending"), @discourseComputed("filter") @@ -72,6 +73,17 @@ export default Controller.extend({ } }, + @discourseComputed("invitesCount.total", "invitesCount.expired") + expiredLabel(invitesCountTotal, invitesCountExpired) { + if (invitesCountTotal > 0) { + return I18n.t("user.invited.expired_tab_with_count", { + count: invitesCountExpired, + }); + } else { + return I18n.t("user.invited.expired_tab"); + } + }, + @discourseComputed("invitesCount.total", "invitesCount.redeemed") redeemedLabel(invitesCountTotal, invitesCountRedeemed) { if (invitesCountTotal > 0) { @@ -87,7 +99,7 @@ export default Controller.extend({ createInvite() { const controller = showModal("create-invite"); controller.set("invites", this.model.invites); - controller.save(true); + controller.save({ autogenerated: true }); }, @action @@ -102,13 +114,6 @@ export default Controller.extend({ controller.setInvite(invite); }, - @action - showInvite(invite) { - const controller = showModal("create-invite"); - controller.set("showOnly", true); - controller.setInvite(invite); - }, - @action destroyInvite(invite) { invite.destroy(); @@ -122,6 +127,7 @@ export default Controller.extend({ Invite.destroyAllExpired() .then(() => { this.set("removedAll", true); + this.send("triggerRefresh"); }) .catch(popupAjaxError); } diff --git a/app/assets/javascripts/discourse/app/models/invite.js b/app/assets/javascripts/discourse/app/models/invite.js index 41f4e651ca5..34f44ff7808 100644 --- a/app/assets/javascripts/discourse/app/models/invite.js +++ b/app/assets/javascripts/discourse/app/models/invite.js @@ -1,5 +1,7 @@ import EmberObject from "@ember/object"; +import { alias } from "@ember/object/computed"; import { Promise } from "rsvp"; +import discourseComputed from "discourse-common/utils/decorators"; import User from "discourse/models/user"; import { ajax } from "discourse/lib/ajax"; import { isNone } from "@ember/utils"; @@ -30,6 +32,14 @@ const Invite = EmberObject.extend({ .then(() => this.set("reinvited", true)) .catch(popupAjaxError); }, + + @discourseComputed("groups") + groupIds(groups) { + return groups ? groups.map((group) => group.id) : []; + }, + + topicId: alias("topics.firstObject.id"), + topicTitle: alias("topics.firstObject.title"), }); Invite.reopenClass({ 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 c728db85912..fbc69250a9b 100644 --- a/app/assets/javascripts/discourse/app/routes/user-invited-show.js +++ b/app/assets/javascripts/discourse/app/routes/user-invited-show.js @@ -22,4 +22,10 @@ export default DiscourseRoute.extend({ searchTerm: "", }); }, + + actions: { + triggerRefresh() { + this.refresh(); + }, + }, }); diff --git a/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs b/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs index 1987d4b1853..abb68054348 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs @@ -1,4 +1,4 @@ -{{#d-modal-body title=(if showOnly "user.invited.invite.show_link" (if inviteId "user.invited.invite.edit_title" "user.invited.invite.new_title"))}} +{{#d-modal-body title=(if invite.id "user.invited.invite.edit_title" "user.invited.invite.new_title")}}
{{input name="invite_link" @@ -10,17 +10,13 @@

{{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}} + {{d-icon "caret-down"}} {{i18n "user.invited.invite.hide_advanced"}} + {{else}} + {{d-icon "caret-right"}} {{i18n "user.invited.invite.show_advanced"}} + {{/if}} +

{{#if showAdvanced}}
@@ -43,7 +39,7 @@ type="number" value=buffered.max_redemptions_allowed min="1" - max=siteSettings.invite_link_max_redemptions_limit + max=maxRedemptionsAllowedLimit }}
{{/if}} @@ -64,16 +60,16 @@ {{group-chooser content=allGroups - value=groupIds + value=buffered.groupIds labelProperty="name" - onChange=(action (mut groupIds)) + onChange=(action (mut buffered.groupIds)) }}
{{choose-topic - selectedTopicId=topicId - topicTitle=topicTitle + selectedTopicId=buffered.topicId + topicTitle=buffered.topicTitle additionalFilters="status:public" label="user.invited.invite.invite_to_topic" }} @@ -100,19 +96,27 @@ {{/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 3b3ab896a17..8dbbbe4899d 100644 --- a/app/assets/javascripts/discourse/app/templates/user-invited-show.hbs +++ b/app/assets/javascripts/discourse/app/templates/user-invited-show.hbs @@ -7,6 +7,7 @@ @@ -79,6 +80,35 @@ {{/each}} + {{else if inviteExpired}} + + + + + + + + + + + {{#each model.invites as |invite|}} + + + + + + + {{/each}} + +
{{i18n "user.invited.invited_via"}}{{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}} + {{raw-date invite.updated_at}}{{raw-date invite.expires_at}} + {{d-button icon="trash-alt" class="cancel" action=(action "destroyInvite" invite) title=(if invite.destroyed "user.invited.removed" "user.invited.remove")}} +
{{else}} @@ -122,10 +152,6 @@ {{/each}} diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss index d7717460ff9..777ff6d8486 100644 --- a/app/assets/stylesheets/common/base/modal.scss +++ b/app/assets/stylesheets/common/base/modal.scss @@ -843,7 +843,7 @@ .radio-group { input[type="radio"] { - display: table-cell; + display: inline; vertical-align: middle; margin-top: -1px; } diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 6cb2d1337d3..0c3c615f5bd 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -2,7 +2,7 @@ class InvitesController < ApplicationController - requires_login only: [:create, :destroy, :destroy_all, :resend_invite, :resend_all_invites, :upload_csv] + requires_login only: [:create, :destroy, :destroy_all_expired, :resend_invite, :resend_all_invites, :upload_csv] skip_before_action :check_xhr, except: [:perform_accept_invitation] skip_before_action :preload_json, except: [:show] @@ -73,7 +73,7 @@ class InvitesController < ApplicationController render json: failed_json, status: 422 end rescue Invite::UserExists, ActiveRecord::RecordInvalid => e - render json: { errors: [e.message] }, status: 422 + render_json_error(e.message) end end @@ -109,13 +109,31 @@ class InvitesController < ApplicationController new_email = params[:email].presence if old_email != new_email - invite.emailed_status = Invite.emailed_status_types[new_email ? :pending : :not_required] + invite.emailed_status = if new_email && !params[:skip_email] + Invite.emailed_status_types[:pending] + else + Invite.emailed_status_types[:not_required] + end end - - invite.email = new_email end - invite.update!(params.permit(:custom_message, :max_redemptions_allowed, :expires_at)) + if params[:send_email] + if invite.emailed_status != Invite.emailed_status_types[:pending] + begin + RateLimiter.new(current_user, "resend-invite-per-hour", 10, 1.hour).performed! + rescue RateLimiter::LimitExceeded + return render_json_error(I18n.t("rate_limiter.slow_down")) + end + end + + invite.emailed_status = Invite.emailed_status_types[:pending] + end + + begin + invite.update!(params.permit(:email, :custom_message, :max_redemptions_allowed, :expires_at)) + rescue ActiveRecord::RecordInvalid => e + return render_json_error(e.message) + end end if invite.emailed_status == Invite.emailed_status_types[:pending] diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index cf2ddc4bf73..efed469d0eb 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -407,6 +407,8 @@ class UsersController < ApplicationController invites = if filter == "pending" && guardian.can_see_invite_details?(inviter) Invite.includes(:topics, :groups).pending(inviter) + elsif filter == "expired" + Invite.expired(inviter) elsif filter == "redeemed" Invite.redeemed_users(inviter) else @@ -423,6 +425,7 @@ class UsersController < ApplicationController end pending_count = Invite.pending(inviter).reorder(nil).count.to_i + expired_count = Invite.expired(inviter).reorder(nil).count.to_i redeemed_count = Invite.redeemed_users(inviter).reorder(nil).count.to_i render json: MultiJson.dump(InvitedSerializer.new( @@ -433,8 +436,9 @@ class UsersController < ApplicationController type: filter, counts: { pending: pending_count, + expired: expired_count, redeemed: redeemed_count, - total: pending_count + redeemed_count + total: pending_count + expired_count } ), scope: guardian, diff --git a/app/models/invite.rb b/app/models/invite.rb index f33eeb667be..53d4293a5b0 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -55,7 +55,12 @@ class Invite < ActiveRecord::Base if user && user.id != self.invited_users&.first&.user_id @email_already_exists = true - errors.add(:email) + errors.add(:email, I18n.t( + "invite.user_exists", + email: email, + username: user.username, + base_path: Discourse.base_path + )) end end @@ -181,16 +186,26 @@ class Invite < ActiveRecord::Base .joins("LEFT JOIN users ON invited_users.user_id = users.id") .where(invited_by_id: inviter.id) .where('redemption_count < max_redemptions_allowed') + .where('expires_at > ?', Time.zone.now) .order('invites.updated_at DESC') end + def self.expired(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_by_id: inviter.id) + .where('redemption_count > max_redemptions_allowed OR expires_at < ?', Time.zone.now) + .order('invites.expires_at ASC') + end + def self.redeemed_users(inviter) InvitedUser - .includes(:invite) + .joins("LEFT JOIN invites ON invites.id = invited_users.invite_id") .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') + .order('invited_users.redeemed_at DESC') .references('invite') .references('user') .references('user_stat') @@ -221,8 +236,13 @@ class Invite < ActiveRecord::Base def ensure_max_redemptions_allowed 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)) + else + limit = invited_by&.staff? ? SiteSetting.invite_link_max_redemptions_limit + : SiteSetting.invite_link_max_redemptions_limit_users + + if !self.max_redemptions_allowed.between?(1, limit) + errors.add(:max_redemptions_allowed, I18n.t("invite_link.max_redemptions_limit", max_limit: limit)) + end end end diff --git a/app/models/invited_user.rb b/app/models/invited_user.rb index 078878c7e77..efb994372f5 100644 --- a/app/models/invited_user.rb +++ b/app/models/invited_user.rb @@ -2,7 +2,7 @@ class InvitedUser < ActiveRecord::Base belongs_to :user - belongs_to :invite + belongs_to :invite, -> { unscope(where: :deleted_at) } validates_presence_of :invite_id validates_uniqueness_of :invite_id, scope: :user_id, conditions: -> { where.not(user_id: nil) } diff --git a/app/models/user.rb b/app/models/user.rb index ac01dfad3f0..0d1f61b1237 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -446,7 +446,7 @@ class User < ActiveRecord::Base end def invited_by - used_invite = Invite.joins(:invited_users).where("invited_users.user_id = ?", self.id).first + used_invite = Invite.with_deleted.joins(:invited_users).where("invited_users.user_id = ?", self.id).first used_invite.try(:invited_by) end diff --git a/app/serializers/invited_serializer.rb b/app/serializers/invited_serializer.rb index e31fd0f08c9..a0f76017e44 100644 --- a/app/serializers/invited_serializer.rb +++ b/app/serializers/invited_serializer.rb @@ -6,7 +6,7 @@ class InvitedSerializer < ApplicationSerializer def invites ActiveModel::ArraySerializer.new( object.invite_list, - each_serializer: object.type == "pending" ? InviteSerializer : InvitedUserSerializer, + each_serializer: object.type == "pending" || object.type == "expired" ? InviteSerializer : InvitedUserSerializer, scope: scope, root: false, show_emails: object.show_emails diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 1674528572e..614c2cf8d09 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1442,6 +1442,8 @@ en: title: "Invites" pending_tab: "Pending" pending_tab_with_count: "Pending (%{count})" + expired_tab: "Expired" + expired_tab_with_count: "Expired (%{count})" redeemed_tab: "Redeemed" redeemed_tab_with_count: "Redeemed (%{count})" invited_via: "Invitation" @@ -1492,7 +1494,6 @@ en: 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" @@ -1501,8 +1502,8 @@ en: 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" + type_email: "Invite just one email address" + type_link: "Invite one or more people with a link" email: "Limit to email address:" max_redemptions_allowed: "Max number of uses:" @@ -1512,7 +1513,7 @@ en: expires_at: "Expire after:" custom_message: "Send email:" - send_invite_email: "Send Invite Email" + send_invite_email: "Save and Send Email" save_invite: "Save Invite" invite_saved: "Invite was saved." diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 58134ed5547..b2f4128f9c5 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1758,6 +1758,7 @@ en: max_post_deletions_per_day: "Maximum number of posts a user can delete per day. Set to 0 to disable post deletions." invite_link_max_redemptions_limit: "Maximum redemptions allowed for invite links can't be more than this value." + invite_link_max_redemptions_limit_users: "Maximum redemptions allowed for invite links generated by regular users can't be more than this value." alert_admins_if_errors_per_minute: "Number of errors per minute in order to trigger an admin alert. A value of 0 disables this feature. NOTE: requires restart." alert_admins_if_errors_per_hour: "Number of errors per hour in order to trigger an admin alert. A value of 0 disables this feature. NOTE: requires restart." diff --git a/config/site_settings.yml b/config/site_settings.yml index 8d6216c9d35..5a6ef69af44 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -1744,6 +1744,11 @@ rate_limits: max: 1000000 default: 5000 client: true + invite_link_max_redemptions_limit_users: + min: 2 + max: 1000000 + default: 10 + client: true developer: force_hostname: diff --git a/spec/models/invite_spec.rb b/spec/models/invite_spec.rb index f7168d3e833..a84427a3f97 100644 --- a/spec/models/invite_spec.rb +++ b/spec/models/invite_spec.rb @@ -449,6 +449,8 @@ describe Invite do pending_invite = Fabricate(:invite, invited_by: inviter, email: 'pending@example.com') pending_link_invite = Fabricate(:invite, invited_by: inviter, max_redemptions_allowed: 5) + expired_invite = Fabricate(:invite, invited_by: inviter, email: 'expired@example.com', expires_at: 1.day.ago) + expect(Invite.pending(inviter)).to contain_exactly(pending_invite, pending_link_invite) end end @@ -465,9 +467,18 @@ describe Invite do expect(Invite.redeemed_users(inviter)).to contain_exactly(redeemed_invite.invited_users.first) end + it 'returns redeemed invites even if trashed' do + inviter = Fabricate(:user) + redeemed_invite = Fabricate(:invite, invited_by: inviter, email: 'redeemed@example.com') + Fabricate(:invited_user, invite: redeemed_invite, user: Fabricate(:user)) + redeemed_invite.trash! + + 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) + invite_link = Fabricate(:invite, invited_by: inviter, max_redemptions_allowed: 5) redeemed = [ Fabricate(:invited_user, invite: invite_link, user: Fabricate(:user)), diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 1bfe16efdd8..77bf74e7012 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -2554,4 +2554,14 @@ describe User do expect(user.do_not_disturb?).to eq(false) end end + + describe "#invited_by" do + it 'returns even if invites was trashed' do + invite = Fabricate(:invite, invited_by: Fabricate(:user)) + Fabricate(:invited_user, invite: invite, user: user) + invite.trash! + + expect(user.invited_by).to eq(invite.invited_by) + end + end end diff --git a/spec/requests/invites_controller_spec.rb b/spec/requests/invites_controller_spec.rb index 132c63bccb8..de057c726ef 100644 --- a/spec/requests/invites_controller_spec.rb +++ b/spec/requests/invites_controller_spec.rb @@ -277,6 +277,11 @@ describe InvitesController do before do sign_in(admin) + RateLimiter.enable + end + + after do + RateLimiter.disable end it 'updating email address resends invite email' do @@ -293,6 +298,20 @@ describe InvitesController do expect(invite.reload.custom_message).to eq("new message") expect(Jobs::InviteEmail.jobs.size).to eq(0) end + + it 'can send invite email' do + user = Fabricate(:user, trust_level: SiteSetting.min_trust_level_to_allow_invite) + invite = Fabricate(:invite, invited_by: user, email: 'test@example.com') + + sign_in(user) + RateLimiter.enable + + expect { put "/invites/#{invite.id}", params: { send_email: true } } + .to change { RateLimiter.new(user, "resend-invite-per-hour", 10, 1.hour).remaining }.by(-1) + + expect(response.status).to eq(200) + expect(Jobs::InviteEmail.jobs.size).to eq(1) + end end context '#perform_accept_invitation' do
{{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}}