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
This commit is contained in:
parent
fae2fc0b5e
commit
7f3240ea31
|
@ -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 });
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -22,4 +22,10 @@ export default DiscourseRoute.extend({
|
|||
searchTerm: "",
|
||||
});
|
||||
},
|
||||
|
||||
actions: {
|
||||
triggerRefresh() {
|
||||
this.refresh();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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")}}
|
||||
<div class="input-group">
|
||||
<label for="invite_link">{{i18n "user.invited.invite.instructions"}}</label>
|
||||
{{input name="invite_link"
|
||||
|
@ -10,17 +10,13 @@
|
|||
|
||||
<p>{{i18n "user.invited.invite.expires_at_time" time=expiresAtRelative}}</p>
|
||||
|
||||
{{#unless showOnly}}
|
||||
<p>
|
||||
{{#if showAdvanced}}
|
||||
{{d-icon "caret-down"}}
|
||||
<a href {{action (mut showAdvanced) false}}>{{i18n "user.invited.invite.hide_advanced"}}</a>
|
||||
{{else}}
|
||||
{{d-icon "caret-right"}}
|
||||
<a href {{action (mut showAdvanced) true}}>{{i18n "user.invited.invite.show_advanced"}}</a>
|
||||
{{/if}}
|
||||
</p>
|
||||
{{/unless}}
|
||||
<p>
|
||||
{{#if showAdvanced}}
|
||||
<a href {{action (mut showAdvanced) false}}>{{d-icon "caret-down"}} {{i18n "user.invited.invite.hide_advanced"}}</a>
|
||||
{{else}}
|
||||
<a href {{action (mut showAdvanced) true}}>{{d-icon "caret-right"}} {{i18n "user.invited.invite.show_advanced"}}</a>
|
||||
{{/if}}
|
||||
</p>
|
||||
|
||||
{{#if showAdvanced}}
|
||||
<div class="input-group">
|
||||
|
@ -43,7 +39,7 @@
|
|||
type="number"
|
||||
value=buffered.max_redemptions_allowed
|
||||
min="1"
|
||||
max=siteSettings.invite_link_max_redemptions_limit
|
||||
max=maxRedemptionsAllowedLimit
|
||||
}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
@ -64,16 +60,16 @@
|
|||
<label>{{i18n "user.invited.invite.add_to_groups"}}</label>
|
||||
{{group-chooser
|
||||
content=allGroups
|
||||
value=groupIds
|
||||
value=buffered.groupIds
|
||||
labelProperty="name"
|
||||
onChange=(action (mut groupIds))
|
||||
onChange=(action (mut buffered.groupIds))
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
{{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}}
|
||||
|
||||
<div class="modal-footer">
|
||||
{{#unless showOnly}}
|
||||
{{#if isEmail}}
|
||||
{{d-button
|
||||
icon=(if isEmail "envelope" "link")
|
||||
label=saveLabel
|
||||
icon="envelope"
|
||||
label=(if changed "user.invited.invite.send_invite_email" "user.invited.reinvite")
|
||||
class="btn-primary"
|
||||
action=(action "saveInvite")
|
||||
action=(action "saveInvite" true)
|
||||
disabled=disabled
|
||||
}}
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
|
||||
{{d-button
|
||||
label="close"
|
||||
icon="link"
|
||||
label="user.invited.invite.save_invite"
|
||||
class="btn-primary"
|
||||
action=(action "saveInvite")
|
||||
disabled=disabled
|
||||
}}
|
||||
|
||||
{{d-button
|
||||
label="cancel"
|
||||
class="btn-flat"
|
||||
action=(route-action "closeModal")
|
||||
}}
|
||||
</div>
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
<nav>
|
||||
<ul class="nav nav-pills">
|
||||
{{nav-item route="userInvited.show" routeParam="pending" i18nLabel=pendingLabel}}
|
||||
{{nav-item route="userInvited.show" routeParam="expired" i18nLabel=expiredLabel}}
|
||||
{{nav-item route="userInvited.show" routeParam="redeemed" i18nLabel=redeemedLabel}}
|
||||
</ul>
|
||||
</nav>
|
||||
|
@ -79,6 +80,35 @@
|
|||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else if inviteExpired}}
|
||||
<table class="table user-invite-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{i18n "user.invited.invited_via"}}</th>
|
||||
<th>{{i18n "user.invited.sent"}}</th>
|
||||
<th>{{i18n "user.invited.expires_at"}}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each model.invites as |invite|}}
|
||||
<tr>
|
||||
<td>
|
||||
{{#if invite.email}}
|
||||
{{invite.email}}
|
||||
{{else}}
|
||||
{{i18n "user.invited.invited_via_link" count=invite.redemption_count max=invite.max_redemptions_allowed}}
|
||||
{{/if}}
|
||||
</td>
|
||||
<td>{{raw-date invite.updated_at}}</td>
|
||||
<td>{{raw-date invite.expires_at}}</td>
|
||||
<td class="actions">
|
||||
{{d-button icon="trash-alt" class="cancel" action=(action "destroyInvite" invite) title=(if invite.destroyed "user.invited.removed" "user.invited.remove")}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<table class="table user-invite-list">
|
||||
<thead>
|
||||
|
@ -122,10 +152,6 @@
|
|||
<td class="actions">
|
||||
{{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}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
|
|
|
@ -843,7 +843,7 @@
|
|||
|
||||
.radio-group {
|
||||
input[type="radio"] {
|
||||
display: table-cell;
|
||||
display: inline;
|
||||
vertical-align: middle;
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue