FEATURE: Various improvements to invite system (#12023)

The user interface has been reorganized to show email and link invites
in the same screen. Staff has more control over creating and updating
invites. Bulk invite has also been improved with better explanations.

On the server side, many code paths for email and link invites have
been merged to avoid duplicated logic. The API returns better responses
with more appropriate HTTP status codes.
This commit is contained in:
Dan Ungureanu 2021-03-03 11:45:29 +02:00 committed by GitHub
parent 039d0d3641
commit c047640ad4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1296 additions and 1092 deletions

View File

@ -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) {}
},
});

View File

@ -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 = "";
},
});

View File

@ -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())
);
});
}),
});

View File

@ -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();
},
});

View File

@ -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();
},
}
);

View File

@ -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);
}
});
}
},
},
});

View File

@ -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" });
},
});

View File

@ -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 },
});
},

View File

@ -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 },
});

View File

@ -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,
});
},
},
});

View File

@ -0,0 +1 @@
{{d-button icon="copy" action=(action "copy")}}

View File

@ -0,0 +1,5 @@
{{yield (hash data=data
uploading=uploading
progress=progress
uploaded=uploaded
submitDisabled=submitDisabled)}}

View File

@ -1,7 +0,0 @@
<label class="btn" disabled={{uploadButtonDisabled}}>
{{d-icon "upload"}}&nbsp;{{uploadButtonText}}
<input class="hidden-upload-field" disabled={{uploading}} type="file" accept=".csv">
</label>
{{#if uploading}}
<span>{{i18n "upload_selector.uploading"}} {{uploadProgress}}%</span>
{{/if}}

View File

@ -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")}}
<input id="csv-file" disabled={{status.uploading}} type="file" accept=".csv">
{{/if}}
{{/d-modal-body}}
<div class="modal-footer">
{{#unless status.uploaded}}
{{d-button
icon=(if isEmail "envelope" "link")
translatedLabel=(if status.uploading (i18n "user.invited.bulk_invite.progress" progress=status.progress)
(i18n "user.invited.bulk_invite.text"))
class="btn-primary"
action=(action "submit" status.data)
disabled=status.submitDisabled
}}
{{/unless}}
{{d-button label="close"
class="btn-primary"
action=(route-action "closeModal")}}
</div>
{{/create-invite-uploader}}

View File

@ -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"))}}
<div class="input-group">
<label for="invite_link">{{i18n "user.invited.invite.instructions"}}</label>
{{input name="invite_link"
class="invite-link"
value=invite.link
readonly=true}}
{{copy-button selector="input.invite-link"}}
</div>
<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}}
{{#if showAdvanced}}
<div class="input-group">
<div class="radio-group">
{{radio-button id="invite-type-link" name="invite-type" value="link" selection=type}}
<label for="invite-type-link">{{i18n "user.invited.invite.type_link"}}</label>
</div>
<div class="radio-group">
{{radio-button id="invite-type-email" name="invite-type" value="email" selection=type}}
<label for="invite-type-email">{{i18n "user.invited.invite.type_email"}}</label>
</div>
</div>
{{#if isLink}}
<div class="input-group invite-max-redemptions">
<label for="invite-max-redemptions">{{i18n "user.invited.invite.max_redemptions_allowed"}}</label>
{{input
id="invite-max-redemptions"
type="number"
value=buffered.max_redemptions_allowed
min="1"
max=siteSettings.invite_link_max_redemptions_limit
}}
</div>
{{/if}}
{{#if isEmail}}
<div class="input-group">
<label for="invite-email">{{i18n "user.invited.invite.email"}}</label>
{{input
id="invite-email"
value=buffered.email
placeholderKey="topic.invite_reply.email_placeholder"
}}
</div>
{{/if}}
{{#if currentUser.staff}}
<div class="input-group">
<label>{{i18n "user.invited.invite.add_to_groups"}}</label>
{{group-chooser
content=allGroups
value=groupIds
labelProperty="name"
onChange=(action (mut groupIds))
}}
</div>
<div class="input-group">
{{choose-topic
selectedTopicId=topicId
topicTitle=topicTitle
additionalFilters="status:public"
label="user.invited.invite.invite_to_topic"
}}
</div>
<div class="input-group">
{{future-date-input
displayLabel=(i18n "user.invited.invite.expires_at")
includeDateTime=true
includeMidFuture=true
clearable=true
onChangeInput=(action (mut buffered.expires_at))
}}
</div>
{{#if isEmail}}
<div class="input-group">
<label for="invite-message">{{i18n "user.invited.invite.custom_message"}}</label>
{{textarea id="invite-message" value=buffered.custom_message}}
</div>
{{/if}}
{{/if}}
{{/if}}
{{/d-modal-body}}
<div class="modal-footer">
{{#unless showOnly}}
{{d-button
icon=(if isEmail "envelope" "link")
label=saveLabel
class="btn-primary"
action=(action "saveInvite")
disabled=disabled
}}
{{/unless}}
{{d-button
label="close"
class="btn-primary"
action=(route-action "closeModal")
}}
</div>

View File

@ -2,30 +2,25 @@
{{#if canInviteToForum}}
{{#load-more class="user-content" selector=".user-invite-list tr" action=(action "loadMore")}}
<section>
<h2>{{i18n "user.invited.title"}}</h2>
{{#if model.can_see_invite_details}}
<div class="admin-controls invite-controls">
<nav>
<ul class="nav nav-pills">
{{nav-item route="userInvited.show" routeParam="pending" i18nLabel=pendingLabel}}
{{nav-item route="userInvited.show" routeParam="redeemed" i18nLabel=redeemedLabel}}
{{#if canSendInviteLink}}
{{nav-item route="userInvited.show" routeParam="links" i18nLabel=linksLabel}}
{{/if}}
</ul>
</nav>
<div class="pull-right">
{{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}}
<table class="table user-invite-list">
<tbody>
<tr>
{{#if inviteRedeemed}}
{{#if inviteRedeemed}}
<table class="table user-invite-list">
<thead>
<tr>
<th>{{i18n "user.invited.user"}}</th>
<th>{{i18n "user.invited.redeemed_at"}}</th>
{{#if model.can_see_invite_details}}
@ -56,25 +51,13 @@
<th>{{i18n "user.invited.posts_read_count"}}</th>
<th>{{i18n "user.invited.time_read"}}</th>
<th>{{i18n "user.invited.days_visited"}}</th>
{{#if canSendInviteLink}}
<th>{{i18n "user.invited.source"}}</th>
{{/if}}
<th>{{i18n "user.invited.invited_via"}}</th>
{{/if}}
{{else if invitePending}}
<th colspan="1">{{i18n "user.invited.user"}}</th>
<th colspan="6">{{i18n "user.invited.sent"}}</th>
{{else if inviteLinks}}
<th>{{i18n "user.invited.link_url"}}</th>
<th>{{i18n "user.invited.link_created_at"}}</th>
<th>{{i18n "user.invited.link_redemption_stats"}}</th>
<th colspan="2">{{i18n "user.invited.link_groups"}}</th>
<th>{{i18n "user.invited.link_expires_at"}}</th>
<th></th>
{{/if}}
</tr>
{{#each model.invites as |invite|}}
<tr>
{{#if inviteRedeemed}}
</tr>
</thead>
<tbody>
{{#each model.invites as |invite|}}
<tr>
<td>
{{#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 @@
/
<span title={{i18n "user.invited.account_age_days"}}>{{html-safe invite.user.days_since_created}}</span>
</td>
{{#if canSendInviteLink}}
<td>{{html-safe invite.invite_source}}</td>
{{/if}}
<td>{{html-safe invite.invite_source}}</td>
{{/if}}
</tr>
{{/each}}
</tbody>
</table>
{{else}}
<table class="table user-invite-list">
<thead>
<tr>
<th>{{i18n "user.invited.invited_via"}}</th>
{{#if currentUser.staff}}
<th>{{i18n "user.invited.groups"}}</th>
{{/if}}
<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>
{{#if currentUser.staff}}
<td>
{{#each invite.groups as |g|}}
<a href="/g/{{g.name}}">{{g.name}}</a>
{{else}}
&mdash;
{{/each}}
</td>
{{/if}}
{{else if invitePending}}
<td>{{invite.email}}</td>
<td>{{format-date invite.updated_at}}</td>
<td>
{{#if invite.expired}}
<div>{{i18n "user.invited.expired"}}</div>
{{/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}}
</td>
<td>
{{#if invite.reinvited}}
<div>{{i18n "user.invited.reinvited"}}</div>
{{else}}
{{d-button icon="sync" action=(action "reinvite") actionParam=invite label="user.invited.reinvite"}}
<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>
{{else if inviteLinks}}
<td>{{d-button icon="link" action=(route-action "editInvite" invite.invite_key) label="user.invited.copy_link"}}</td>
<td>{{format-date invite.created_at}}</td>
<td>{{number invite.redemption_count}} / {{number invite.max_redemptions_allowed}}</td>
<td colspan="2">{{ invite.group_names }}</td>
<td>{{raw-date invite.expires_at leaveAgo="true"}}</td>
<td>
{{#if invite.rescinded}}
{{i18n "user.invited.rescinded"}}
{{else}}
{{d-button icon="times" action=(action "rescind") actionParam=invite label="user.invited.rescind"}}
{{/if}}
</td>
{{/if}}
</tr>
{{/each}}
</tbody>
</table>
{{conditional-loading-spinner condition=invitesLoading}}
</tr>
{{/each}}
</tbody>
</table>
{{/if}}
{{conditional-loading-spinner condition=invitesLoading}}
{{else}}
<div class="user-invite-none">
{{#if canBulkInvite}}

View File

@ -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 }] });
});

View File

@ -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;
}
}
}

View File

@ -107,3 +107,10 @@
min-width: 500px;
}
}
.create-invite-modal,
.create-invite-bulk-modal {
.modal-inner-container {
width: 700px;
}
}

View File

@ -90,6 +90,10 @@
tr {
td {
padding: 0.667em;
&.actions {
white-space: nowrap;
width: 100px;
}
}
}
}

View File

@ -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!(

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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?

View File

@ -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

View File

@ -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: |
<p>Invite a list of users to get your community going quickly. Prepare a <a href="https://en.wikipedia.org/wiki/Comma-separated_values">CSV file</a> 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.</p>
<pre>john@smith.com,first_group_name;second_group_name,42</pre>
<p>Every email address in your uploaded CSV file will be sent an invitation, and you will be able to manage it later.</p>
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: "Youre about to email invites to everyone in the uploaded file."
password:
title: "Password"

View File

@ -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'

View File

@ -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

View File

@ -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("<br>"))
end

View File

@ -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

View File

@ -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

View File

@ -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 <john.doe@example.com>")
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

View File

@ -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