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:
parent
039d0d3641
commit
c047640ad4
|
@ -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) {}
|
||||
},
|
||||
});
|
|
@ -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 = "";
|
||||
},
|
||||
});
|
|
@ -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())
|
||||
);
|
||||
});
|
||||
}),
|
||||
});
|
|
@ -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();
|
||||
},
|
||||
});
|
|
@ -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();
|
||||
},
|
||||
}
|
||||
);
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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" });
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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 },
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
{{d-button icon="copy" action=(action "copy")}}
|
|
@ -0,0 +1,5 @@
|
|||
{{yield (hash data=data
|
||||
uploading=uploading
|
||||
progress=progress
|
||||
uploaded=uploaded
|
||||
submitDisabled=submitDisabled)}}
|
|
@ -1,7 +0,0 @@
|
|||
<label class="btn" disabled={{uploadButtonDisabled}}>
|
||||
{{d-icon "upload"}} {{uploadButtonText}}
|
||||
<input class="hidden-upload-field" disabled={{uploading}} type="file" accept=".csv">
|
||||
</label>
|
||||
{{#if uploading}}
|
||||
<span>{{i18n "upload_selector.uploading"}} {{uploadProgress}}%</span>
|
||||
{{/if}}
|
|
@ -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}}
|
|
@ -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>
|
|
@ -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}}
|
||||
—
|
||||
{{/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}}
|
||||
|
|
|
@ -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 }] });
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -107,3 +107,10 @@
|
|||
min-width: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
.create-invite-modal,
|
||||
.create-invite-bulk-modal {
|
||||
.modal-inner-container {
|
||||
width: 700px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -90,6 +90,10 @@
|
|||
tr {
|
||||
td {
|
||||
padding: 0.667em;
|
||||
&.actions {
|
||||
white-space: nowrap;
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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!(
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: "You’re about to email invites to everyone in the uploaded file."
|
||||
|
||||
password:
|
||||
title: "Password"
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in New Issue