mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
FEATURE: multiple use invite links (#9813)
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import I18n from "I18n";
|
||||
import Component from "@ember/component";
|
||||
import Group from "discourse/models/group";
|
||||
import { readOnly } from "@ember/object/computed";
|
||||
import { action } from "@ember/object";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
import Invite from "discourse/models/invite";
|
||||
|
||||
export default Component.extend({
|
||||
inviteModel: readOnly("panel.model.inviteModel"),
|
||||
userInvitedShow: readOnly("panel.model.userInvitedShow"),
|
||||
isStaff: readOnly("currentUser.staff"),
|
||||
maxRedemptionAllowed: 5,
|
||||
inviteExpiresAt: moment()
|
||||
.add(1, "month")
|
||||
.format("YYYY-MM-DD"),
|
||||
|
||||
willDestroyElement() {
|
||||
this._super(...arguments);
|
||||
|
||||
this.reset();
|
||||
},
|
||||
|
||||
@discourseComputed("isStaff", "inviteModel.saving", "maxRedemptionAllowed")
|
||||
disabled(isStaff, saving, canInviteTo, maxRedemptionAllowed) {
|
||||
if (saving) return true;
|
||||
if (!isStaff) return true;
|
||||
if (maxRedemptionAllowed < 2) return true;
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
groupFinder(term) {
|
||||
return Group.findAll({ term, ignore_automatic: true });
|
||||
},
|
||||
|
||||
errorMessage: I18n.t("user.invited.invite_link.error"),
|
||||
|
||||
reset() {
|
||||
this.set("maxRedemptionAllowed", 5);
|
||||
|
||||
this.inviteModel.setProperties({
|
||||
groupNames: null,
|
||||
error: false,
|
||||
saving: false,
|
||||
finished: false,
|
||||
inviteLink: null
|
||||
});
|
||||
},
|
||||
|
||||
@action
|
||||
generateMultipleUseInviteLink() {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupNames = this.get("inviteModel.groupNames");
|
||||
const maxRedemptionAllowed = this.maxRedemptionAllowed;
|
||||
const inviteExpiresAt = this.inviteExpiresAt;
|
||||
const userInvitedController = this.userInvitedShow;
|
||||
const model = this.inviteModel;
|
||||
model.setProperties({ saving: true, error: false });
|
||||
|
||||
return model
|
||||
.generateMultipleUseInviteLink(
|
||||
groupNames,
|
||||
maxRedemptionAllowed,
|
||||
inviteExpiresAt
|
||||
)
|
||||
.then(result => {
|
||||
model.setProperties({
|
||||
saving: false,
|
||||
finished: true,
|
||||
inviteLink: result
|
||||
});
|
||||
|
||||
if (userInvitedController) {
|
||||
Invite.findInvitedBy(
|
||||
this.currentUser,
|
||||
userInvitedController.filter
|
||||
).then(inviteModel => {
|
||||
userInvitedController.setProperties({
|
||||
model: inviteModel,
|
||||
totalInvites: inviteModel.invites.length
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
if (e.jqXHR.responseJSON && e.jqXHR.responseJSON.errors) {
|
||||
this.set("errorMessage", e.jqXHR.responseJSON.errors[0]);
|
||||
} else {
|
||||
this.set("errorMessage", I18n.t("user.invited.invite_link.error"));
|
||||
}
|
||||
model.setProperties({ saving: false, error: true });
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,16 +1,18 @@
|
||||
import I18n from "I18n";
|
||||
import { isEmpty } from "@ember/utils";
|
||||
import { alias, notEmpty } from "@ember/object/computed";
|
||||
import { alias, notEmpty, or, readOnly } from "@ember/object/computed";
|
||||
import Controller from "@ember/controller";
|
||||
import discourseComputed from "discourse-common/utils/decorators";
|
||||
import getUrl from "discourse-common/lib/get-url";
|
||||
import DiscourseURL from "discourse/lib/url";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { emailValid } from "discourse/lib/utilities";
|
||||
import PasswordValidation from "discourse/mixins/password-validation";
|
||||
import UsernameValidation from "discourse/mixins/username-validation";
|
||||
import NameValidation from "discourse/mixins/name-validation";
|
||||
import UserFieldsValidation from "discourse/mixins/user-fields-validation";
|
||||
import { findAll as findLoginMethods } from "discourse/models/login-method";
|
||||
import EmberObject from "@ember/object";
|
||||
|
||||
export default Controller.extend(
|
||||
PasswordValidation,
|
||||
@@ -18,7 +20,7 @@ export default Controller.extend(
|
||||
NameValidation,
|
||||
UserFieldsValidation,
|
||||
{
|
||||
invitedBy: alias("model.invited_by"),
|
||||
invitedBy: readOnly("model.invited_by"),
|
||||
email: alias("model.email"),
|
||||
accountUsername: alias("model.username"),
|
||||
passwordRequired: notEmpty("accountPassword"),
|
||||
@@ -26,6 +28,21 @@ export default Controller.extend(
|
||||
errorMessage: null,
|
||||
userFields: null,
|
||||
inviteImageUrl: getUrl("/images/envelope.svg"),
|
||||
isInviteLink: readOnly("model.is_invite_link"),
|
||||
submitDisabled: or(
|
||||
"emailValidation.failed",
|
||||
"usernameValidation.failed",
|
||||
"passwordValidation.failed",
|
||||
"nameValidation.failed",
|
||||
"userFieldsValidation.failed"
|
||||
),
|
||||
rejectedEmails: null,
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
|
||||
this.rejectedEmails = [];
|
||||
},
|
||||
|
||||
@discourseComputed
|
||||
welcomeTitle() {
|
||||
@@ -44,21 +61,6 @@ export default Controller.extend(
|
||||
return findLoginMethods().length > 0;
|
||||
},
|
||||
|
||||
@discourseComputed(
|
||||
"usernameValidation.failed",
|
||||
"passwordValidation.failed",
|
||||
"nameValidation.failed",
|
||||
"userFieldsValidation.failed"
|
||||
)
|
||||
submitDisabled(
|
||||
usernameFailed,
|
||||
passwordFailed,
|
||||
nameFailed,
|
||||
userFieldsFailed
|
||||
) {
|
||||
return usernameFailed || passwordFailed || nameFailed || userFieldsFailed;
|
||||
},
|
||||
|
||||
@discourseComputed
|
||||
fullnameRequired() {
|
||||
return (
|
||||
@@ -66,6 +68,35 @@ export default Controller.extend(
|
||||
);
|
||||
},
|
||||
|
||||
@discourseComputed("email", "rejectedEmails.[]")
|
||||
emailValidation(email, rejectedEmails) {
|
||||
// If blank, fail without a reason
|
||||
if (isEmpty(email)) {
|
||||
return EmberObject.create({
|
||||
failed: true
|
||||
});
|
||||
}
|
||||
|
||||
if (rejectedEmails.includes(email)) {
|
||||
return EmberObject.create({
|
||||
failed: true,
|
||||
reason: I18n.t("user.email.invalid")
|
||||
});
|
||||
}
|
||||
|
||||
if (emailValid(email)) {
|
||||
return EmberObject.create({
|
||||
ok: true,
|
||||
reason: I18n.t("user.email.ok")
|
||||
});
|
||||
}
|
||||
|
||||
return EmberObject.create({
|
||||
failed: true,
|
||||
reason: I18n.t("user.email.invalid")
|
||||
});
|
||||
},
|
||||
|
||||
actions: {
|
||||
submit() {
|
||||
const userFields = this.userFields;
|
||||
@@ -80,6 +111,7 @@ export default Controller.extend(
|
||||
url: `/invites/show/${this.get("model.token")}.json`,
|
||||
type: "PUT",
|
||||
data: {
|
||||
email: this.email,
|
||||
username: this.accountUsername,
|
||||
name: this.accountName,
|
||||
password: this.accountPassword,
|
||||
@@ -97,6 +129,14 @@ export default Controller.extend(
|
||||
DiscourseURL.redirectTo(result.redirect_to);
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
result.errors &&
|
||||
result.errors.email &&
|
||||
result.errors.email.length > 0 &&
|
||||
result.values
|
||||
) {
|
||||
this.rejectedEmails.pushObject(result.values.email);
|
||||
}
|
||||
if (
|
||||
result.errors &&
|
||||
result.errors.password &&
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import I18n from "I18n";
|
||||
import { equal, reads, gte } from "@ember/object/computed";
|
||||
import { equal, reads } from "@ember/object/computed";
|
||||
import Controller from "@ember/controller";
|
||||
import Invite from "discourse/models/invite";
|
||||
import discourseDebounce from "discourse/lib/debounce";
|
||||
@@ -35,21 +35,30 @@ export default Controller.extend({
|
||||
}, INPUT_DELAY),
|
||||
|
||||
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.currentUser.get("staff")
|
||||
this.currentUser.staff
|
||||
);
|
||||
},
|
||||
|
||||
canInviteToForum: reads("currentUser.can_invite_to_forum"),
|
||||
|
||||
canBulkInvite: reads("currentUser.admin"),
|
||||
canSendInviteLink: reads("currentUser.staff"),
|
||||
|
||||
showSearch: gte("totalInvites", 10),
|
||||
@discourseComputed("totalInvites", "inviteLinks")
|
||||
showSearch(totalInvites, inviteLinks) {
|
||||
return totalInvites >= 10 && !inviteLinks;
|
||||
},
|
||||
|
||||
@discourseComputed("invitesCount.total", "invitesCount.pending")
|
||||
pendingLabel(invitesCountTotal, invitesCountPending) {
|
||||
@@ -73,6 +82,17 @@ 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
|
||||
});
|
||||
} else {
|
||||
return I18n.t("user.invited.links_tab");
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
rescind(invite) {
|
||||
invite.rescind();
|
||||
|
||||
@@ -10,7 +10,7 @@ const Invite = EmberObject.extend({
|
||||
rescind() {
|
||||
ajax("/invites", {
|
||||
type: "DELETE",
|
||||
data: { email: this.email }
|
||||
data: { id: this.id }
|
||||
});
|
||||
this.set("rescinded", true);
|
||||
},
|
||||
@@ -42,7 +42,14 @@ Invite.reopenClass({
|
||||
if (!isNone(search)) data.search = search;
|
||||
data.offset = offset || 0;
|
||||
|
||||
return ajax(userPath(`${user.username_lower}/invited.json`), {
|
||||
let path;
|
||||
if (filter === "links") {
|
||||
path = userPath(`${user.username_lower}/invite_links.json`);
|
||||
} else {
|
||||
path = userPath(`${user.username_lower}/invited.json`);
|
||||
}
|
||||
|
||||
return ajax(path, {
|
||||
data
|
||||
}).then(result => {
|
||||
result.invites = result.invites.map(i => Invite.create(i));
|
||||
|
||||
@@ -654,6 +654,17 @@ const User = RestModel.extend({
|
||||
});
|
||||
},
|
||||
|
||||
generateMultipleUseInviteLink(
|
||||
group_names,
|
||||
max_redemptions_allowed,
|
||||
expires_at
|
||||
) {
|
||||
return ajax("/invites/link", {
|
||||
type: "POST",
|
||||
data: { group_names, max_redemptions_allowed, expires_at }
|
||||
});
|
||||
},
|
||||
|
||||
@observes("muted_category_ids")
|
||||
updateMutedCategories() {
|
||||
this.set("mutedCategories", Category.findByIds(this.muted_category_ids));
|
||||
|
||||
@@ -30,18 +30,51 @@ export default DiscourseRoute.extend({
|
||||
|
||||
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")
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
showModal("share-and-invite", {
|
||||
modalClass: "share-and-invite",
|
||||
panels: [
|
||||
{
|
||||
id: "invite",
|
||||
title: "user.invited.create",
|
||||
model: {
|
||||
inviteModel: this.currentUser,
|
||||
userInvitedShow: this.controllerFor("user-invited-show")
|
||||
}
|
||||
panels
|
||||
});
|
||||
},
|
||||
|
||||
editInvite(inviteKey) {
|
||||
const inviteLink = `${Discourse.BaseUrl}/invites/${inviteKey}`;
|
||||
this.currentUser.setProperties({ finished: true, inviteLink });
|
||||
const panels = [
|
||||
{
|
||||
id: "invite-link",
|
||||
title: "user.invited.generate_link",
|
||||
model: {
|
||||
inviteModel: this.currentUser,
|
||||
userInvitedShow: this.controllerFor("user-invited-show")
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
showModal("share-and-invite", {
|
||||
modalClass: "share-and-invite",
|
||||
panels
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<div class="control-group">
|
||||
{{d-icon "far-clock"}}
|
||||
{{input placeholder="--:--" type="time" value=time disabled=timeInputDisabled}}
|
||||
{{input placeholder="--:--" type="time" class="time-input" value=time disabled=timeInputDisabled}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<p>{{i18n "user.invited.link_generated"}}</p>
|
||||
<p>
|
||||
<input value={{link}} class="invite-link-input" style="width: 75%" type="text">
|
||||
<input value={{link}} class="invite-link-input" type="text">
|
||||
</p>
|
||||
<p>{{i18n "user.invited.valid_for" email=email}}</p>
|
||||
{{#if email}}
|
||||
<p>{{i18n "user.invited.valid_for" email=email}}</p>
|
||||
{{/if}}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
{{#if inviteModel.error}}
|
||||
<div class="alert alert-error">
|
||||
{{html-safe errorMessage}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="body">
|
||||
{{#if inviteModel.finished}}
|
||||
{{generated-invite-link link=inviteModel.inviteLink}}
|
||||
{{else}}
|
||||
|
||||
<div class="group-access-control">
|
||||
<label class="instructions">
|
||||
{{i18n "topic.automatically_add_to_groups"}}
|
||||
</label>
|
||||
{{group-selector
|
||||
fullWidthWrap=true
|
||||
groupFinder=groupFinder
|
||||
groupNames=inviteModel.groupNames
|
||||
placeholderKey="topic.invite_private.group_name"}}
|
||||
</div>
|
||||
|
||||
<div class="max-redemptions-allowed">
|
||||
<label class="instructions">
|
||||
{{i18n 'user.invited.invite_link.max_redemptions_allowed_label'}}
|
||||
</label>
|
||||
{{input type="number" value=maxRedemptionAllowed class="max-redemptions-allowed-input" min="2" max=siteSettings.invite_link_max_redemptions_limit}}
|
||||
</div>
|
||||
|
||||
<div class="invite-link-expires-at">
|
||||
<label class="instructions">
|
||||
{{i18n 'user.invited.invite_link.expires_at'}}
|
||||
</label>
|
||||
{{future-date-input
|
||||
includeDateTime=true
|
||||
includeMidFuture=true
|
||||
clearable=true
|
||||
onChangeInput=(action (mut inviteExpiresAt))
|
||||
}}
|
||||
</div>
|
||||
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
{{#if inviteModel.finished}}
|
||||
{{d-button
|
||||
class="btn-primary"
|
||||
action=(route-action "closeModal")
|
||||
label="close"}}
|
||||
{{else}}
|
||||
{{d-button
|
||||
icon="link"
|
||||
action=(action "generateMultipleUseInviteLink")
|
||||
class="btn-primary generate-invite-link"
|
||||
disabled=disabled
|
||||
label="user.invited.generate_link"}}
|
||||
{{/if}}
|
||||
</div>
|
||||
@@ -16,18 +16,30 @@
|
||||
<p>{{i18n "invites.invited_by"}}</p>
|
||||
<p>{{user-info user=invitedBy}}</p>
|
||||
|
||||
<p>
|
||||
{{html-safe yourEmailMessage}}
|
||||
{{#if externalAuthsEnabled}}
|
||||
{{i18n "invites.social_login_available"}}
|
||||
{{/if}}
|
||||
</p>
|
||||
{{#unless isInviteLink}}
|
||||
<p>
|
||||
{{html-safe yourEmailMessage}}
|
||||
{{#if externalAuthsEnabled}}
|
||||
{{i18n "invites.social_login_available"}}
|
||||
{{/if}}
|
||||
</p>
|
||||
{{/unless}}
|
||||
|
||||
<form>
|
||||
|
||||
{{#if isInviteLink}}
|
||||
<div class="input email-input">
|
||||
<label>{{i18n "user.email.title"}}</label>
|
||||
{{input type="email" value=email id="new-account-email" name="email" autofocus="autofocus"}}
|
||||
{{input-tip validation=emailValidation id="account-email-validation"}}
|
||||
<div class="instructions">{{i18n "user.email.instructions"}}</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="input username-input">
|
||||
<label>{{i18n "user.username.title"}}</label>
|
||||
{{input value=accountUsername id="new-account-username" name="username" maxlength=maxUsernameLength autocomplete="discourse"}}
|
||||
{{input-tip validation=usernameValidation id="username-validation"}}
|
||||
{{input-tip validation=usernameValidation id="username-validation"}}
|
||||
<div class="instructions">{{i18n "user.username.instructions"}}</div>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +54,7 @@
|
||||
<div class="input password-input">
|
||||
<label>{{i18n "invites.password_label"}}</label>
|
||||
{{password-field value=accountPassword type="password" id="new-account-password" capsLockOn=capsLockOn}}
|
||||
{{input-tip validation=passwordValidation}}
|
||||
{{input-tip validation=passwordValidation}}
|
||||
<div class="instructions">
|
||||
{{passwordInstructions}} {{i18n "invites.optional_description"}}
|
||||
<div class="caps-lock-warning {{unless capsLockOn "invisible"}}">
|
||||
|
||||
@@ -5,11 +5,14 @@
|
||||
<h2>{{i18n "user.invited.title"}}</h2>
|
||||
|
||||
{{#if model.can_see_invite_details}}
|
||||
<div class="admin-controls">
|
||||
<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>
|
||||
|
||||
@@ -17,7 +20,6 @@
|
||||
{{d-button icon="plus" action=(route-action "showInvite") label="user.invited.create"}}
|
||||
{{#if canBulkInvite}}
|
||||
{{csv-uploader uploading=uploading}}
|
||||
<a href="https://meta.discourse.org/t/sending-bulk-user-invites/16468" rel="noopener noreferrer" target="_blank" style="color:black;">{{d-icon "question-circle"}}</a>
|
||||
{{/if}}
|
||||
{{#if showBulkActionButtons}}
|
||||
{{#if rescindedAll}}
|
||||
@@ -54,15 +56,25 @@
|
||||
<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}}
|
||||
{{/if}}
|
||||
{{else}}
|
||||
{{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 invite.user}}
|
||||
{{#if inviteRedeemed}}
|
||||
<td>
|
||||
{{#link-to "user" invite.user}}{{avatar invite.user imageSize="tiny"}}{{/link-to}}
|
||||
{{#link-to "user" invite.user}}{{invite.user.username}}{{/link-to}}
|
||||
@@ -78,8 +90,11 @@
|
||||
/
|
||||
<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}}
|
||||
{{/if}}
|
||||
{{else}}
|
||||
{{else if invitePending}}
|
||||
<td>{{invite.email}}</td>
|
||||
<td>{{format-date invite.updated_at}}</td>
|
||||
<td>
|
||||
@@ -99,6 +114,19 @@
|
||||
{{d-button icon="sync" action=(action "reinvite") actionParam=invite label="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}}
|
||||
|
||||
@@ -145,6 +145,9 @@
|
||||
font-weight: normal;
|
||||
line-height: $line-height-medium;
|
||||
}
|
||||
.tip {
|
||||
padding-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.share-and-invite.modal .invite.modal-panel {
|
||||
.share-and-invite.modal .invite.modal-panel,
|
||||
.invite-link.modal-panel {
|
||||
.error-message,
|
||||
.success-message {
|
||||
margin-bottom: 8px;
|
||||
@@ -83,12 +84,30 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.max-redemptions-allowed {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.max-redemptions-allowed-input {
|
||||
width: 20%;
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.invite-link-expires-at .date-picker,
|
||||
.time-input {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.invite-user-input-wrapper {
|
||||
display: flex;
|
||||
div.ac-wrap {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.invite-link-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
|
||||
@@ -80,6 +80,10 @@
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.invite-controls .btn {
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
.user-invite-list {
|
||||
width: 100%;
|
||||
margin-top: 15px;
|
||||
|
||||
@@ -24,7 +24,8 @@ class InvitesController < ApplicationController
|
||||
store_preloaded("invite_info", MultiJson.dump(
|
||||
invited_by: UserNameSerializer.new(invite.invited_by, scope: guardian, root: false),
|
||||
email: invite.email,
|
||||
username: UserNameSuggester.suggest(invite.email))
|
||||
username: UserNameSuggester.suggest(invite.email),
|
||||
is_invite_link: invite.is_invite_link?)
|
||||
)
|
||||
|
||||
render layout: 'application'
|
||||
@@ -40,23 +41,30 @@ class InvitesController < ApplicationController
|
||||
|
||||
def perform_accept_invitation
|
||||
params.require(:id)
|
||||
params.permit(:username, :name, :password, :timezone, user_custom_fields: {})
|
||||
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(username: params[:username], name: params[:name], password: params[:password], user_custom_fields: params[:user_custom_fields], ip_address: request.remote_ip)
|
||||
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
|
||||
|
||||
response = { success: true }
|
||||
if user.present? && user.active?
|
||||
topic = invite.topics.first
|
||||
response[:redirect_to] = topic.present? ? path("#{topic.relative_url}") : path("/")
|
||||
else
|
||||
elsif user.present?
|
||||
response[:message] = I18n.t('invite.confirm_email')
|
||||
end
|
||||
|
||||
@@ -67,6 +75,8 @@ class InvitesController < ApplicationController
|
||||
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') }
|
||||
@@ -101,27 +111,44 @@ class InvitesController < ApplicationController
|
||||
end
|
||||
|
||||
def create_invite_link
|
||||
params.require(:email)
|
||||
params.permit(:email, :max_redemptions_allowed, :expires_at, :group_ids, :group_names, :topic_id)
|
||||
|
||||
is_single_invite = params[:email].present?
|
||||
unless is_single_invite
|
||||
guardian.ensure_can_send_invite_links!(current_user)
|
||||
end
|
||||
|
||||
groups = Group.lookup_groups(
|
||||
group_ids: params[:group_ids],
|
||||
group_names: params[:group_names]
|
||||
)
|
||||
guardian.ensure_can_invite_to_forum!(groups)
|
||||
|
||||
topic = Topic.find_by(id: params[:topic_id])
|
||||
guardian.ensure_can_invite_to!(topic) if topic.present?
|
||||
|
||||
group_ids = groups.map(&:id)
|
||||
|
||||
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
|
||||
if is_single_invite
|
||||
if params[:topic_id].present?
|
||||
topic = Topic.find_by(id: params[:topic_id])
|
||||
guardian.ensure_can_invite_to!(topic) if topic.present?
|
||||
end
|
||||
|
||||
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
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
# generate invite link
|
||||
if invite_link = Invite.generate_invite_link(params[:email], current_user, topic, group_ids)
|
||||
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
|
||||
)
|
||||
end
|
||||
if invite_link.present?
|
||||
render_json_dump(invite_link)
|
||||
else
|
||||
render json: failed_json, status: 422
|
||||
@@ -132,10 +159,10 @@ class InvitesController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
params.require(:email)
|
||||
params.require(:id)
|
||||
|
||||
invite = Invite.find_by(invited_by_id: current_user.id, email: params[:email])
|
||||
raise Discourse::InvalidParameters.new(:email) if invite.blank?
|
||||
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 body: nil
|
||||
|
||||
@@ -297,30 +297,44 @@ class UsersController < ApplicationController
|
||||
def invited
|
||||
guardian.ensure_can_invite_to_forum!
|
||||
|
||||
inviter = fetch_user_from_params(include_inactive: current_user.staff? || SiteSetting.show_inactive_accounts)
|
||||
offset = params[:offset].to_i || 0
|
||||
filter_by = params[:filter]
|
||||
filter_by = 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)
|
||||
else
|
||||
elsif filter_by == "redeemed"
|
||||
Invite.find_redeemed_invites_from(inviter, offset)
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
show_emails = guardian.can_see_invite_emails?(inviter)
|
||||
if params[:search].present?
|
||||
if params[:search].present? && invites.present?
|
||||
filter_sql = '(LOWER(users.username) LIKE :filter)'
|
||||
filter_sql = '(LOWER(invites.email) LIKE :filter) or (LOWER(users.username) LIKE :filter)' if show_emails
|
||||
invites = invites.where(filter_sql, filter: "%#{params[:search].downcase}%")
|
||||
end
|
||||
|
||||
render json: MultiJson.dump(InvitedSerializer.new(
|
||||
OpenStruct.new(invite_list: invites.to_a, show_emails: show_emails, inviter: inviter),
|
||||
OpenStruct.new(invite_list: invites.to_a, show_emails: show_emails, inviter: inviter, type: filter_by),
|
||||
scope: guardian,
|
||||
root: false
|
||||
))
|
||||
end
|
||||
|
||||
def invite_links
|
||||
guardian.ensure_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))
|
||||
end
|
||||
|
||||
def invited_count
|
||||
guardian.ensure_can_invite_to_forum!
|
||||
|
||||
@@ -328,8 +342,9 @@ class UsersController < ApplicationController
|
||||
|
||||
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,
|
||||
render json: { counts: { pending: pending_count, redeemed: redeemed_count, links: links_count,
|
||||
total: (pending_count.to_i + redeemed_count.to_i) } }
|
||||
end
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ module Jobs
|
||||
def make_anonymous
|
||||
anonymize_ips(@anonymize_ip) if @anonymize_ip
|
||||
|
||||
Invite.with_deleted.where(user_id: @user_id).destroy_all
|
||||
InvitedUser.where(user_id: @user_id).destroy_all
|
||||
EmailToken.where(user_id: @user_id).destroy_all
|
||||
EmailLog.where(user_id: @user_id).delete_all
|
||||
IncomingEmail.where("user_id = ? OR from_address = ?", @user_id, @prev_email).delete_all
|
||||
|
||||
@@ -5,6 +5,12 @@ class Invite < ActiveRecord::Base
|
||||
include RateLimiter::OnCreateRecord
|
||||
include Trashable
|
||||
|
||||
# TODO(2021-05-22): remove
|
||||
self.ignored_columns = %w{
|
||||
user_id
|
||||
redeemed_at
|
||||
}
|
||||
|
||||
BULK_INVITE_EMAIL_LIMIT = 200
|
||||
|
||||
rate_limit :limit_invites_per_day
|
||||
@@ -13,6 +19,8 @@ class Invite < ActiveRecord::Base
|
||||
belongs_to :topic
|
||||
belongs_to :invited_by, class_name: 'User'
|
||||
|
||||
has_many :invited_users
|
||||
has_many :users, through: :invited_users
|
||||
has_many :invited_groups
|
||||
has_many :groups, through: :invited_groups
|
||||
has_many :topic_invites
|
||||
@@ -22,15 +30,20 @@ class Invite < ActiveRecord::Base
|
||||
|
||||
before_create do
|
||||
self.invite_key ||= SecureRandom.hex
|
||||
self.expires_at ||= SiteSetting.invite_expiry_days.days.from_now
|
||||
end
|
||||
|
||||
before_validation do
|
||||
self.email = Email.downcase(email) unless email.nil?
|
||||
end
|
||||
|
||||
validate :ensure_max_redemptions_allowed
|
||||
validate :user_doesnt_already_exist
|
||||
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
|
||||
@@ -40,18 +53,26 @@ class Invite < ActiveRecord::Base
|
||||
return if email.blank?
|
||||
user = Invite.find_user_by_email(email)
|
||||
|
||||
if user && user.id != self.user_id
|
||||
if user && user.id != self.invited_users&.first&.user_id
|
||||
@email_already_exists = true
|
||||
errors.add(:email)
|
||||
end
|
||||
end
|
||||
|
||||
def is_invite_link?
|
||||
max_redemptions_allowed > 1
|
||||
end
|
||||
|
||||
def redeemed?
|
||||
redeemed_at.present?
|
||||
if is_invite_link?
|
||||
redemption_count >= max_redemptions_allowed
|
||||
else
|
||||
self.invited_users.count > 0
|
||||
end
|
||||
end
|
||||
|
||||
def expired?
|
||||
updated_at < SiteSetting.invite_expiry_days.days.ago
|
||||
expires_at < Time.zone.now
|
||||
end
|
||||
|
||||
# link_valid? indicates whether the invite link can be used to log in to the site
|
||||
@@ -61,7 +82,7 @@ class Invite < ActiveRecord::Base
|
||||
|
||||
def redeem(username: nil, name: nil, password: nil, user_custom_fields: nil, ip_address: nil)
|
||||
if !expired? && !destroyed? && link_valid?
|
||||
InviteRedeemer.new(self, username, name, password, user_custom_fields, ip_address).redeem
|
||||
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
|
||||
|
||||
@@ -74,8 +95,7 @@ class Invite < ActiveRecord::Base
|
||||
)
|
||||
end
|
||||
|
||||
# generate invite link
|
||||
def self.generate_invite_link(email, invited_by, topic = nil, group_ids = nil)
|
||||
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,
|
||||
@@ -123,6 +143,7 @@ class Invite < ActiveRecord::Base
|
||||
invite.update_columns(
|
||||
created_at: Time.zone.now,
|
||||
updated_at: Time.zone.now,
|
||||
expires_at: SiteSetting.invite_expiry_days.days.from_now,
|
||||
emailed_status: emailed_status
|
||||
)
|
||||
else
|
||||
@@ -160,6 +181,37 @@ class Invite < ActiveRecord::Base
|
||||
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
|
||||
@@ -176,30 +228,62 @@ class Invite < ActiveRecord::Base
|
||||
group_ids
|
||||
end
|
||||
|
||||
def self.find_all_invites_from(inviter, offset = 0, limit = SiteSetting.invites_per_page)
|
||||
Invite.where(invited_by_id: inviter.id)
|
||||
def self.find_all_pending_invites_from(inviter, offset = 0, limit = SiteSetting.invites_per_page)
|
||||
Invite.single_use_invites
|
||||
.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')
|
||||
.includes(user: :user_stat)
|
||||
.order("CASE WHEN invites.user_id IS NOT NULL THEN 0 ELSE 1 END, user_stats.time_read DESC, invites.redeemed_at DESC")
|
||||
.order('invites.updated_at DESC')
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.references('user_stats')
|
||||
end
|
||||
|
||||
def self.find_pending_invites_from(inviter, offset = 0)
|
||||
find_all_invites_from(inviter, offset).where('invites.user_id IS NULL').order('invites.updated_at DESC')
|
||||
end
|
||||
|
||||
def self.find_redeemed_invites_from(inviter, offset = 0)
|
||||
find_all_invites_from(inviter, offset).where('invites.user_id IS NOT NULL').order('invites.redeemed_at DESC')
|
||||
find_all_pending_invites_from(inviter, offset)
|
||||
end
|
||||
|
||||
def self.find_pending_invites_count(inviter)
|
||||
find_all_invites_from(inviter, 0, nil).where('invites.user_id IS NULL').reorder(nil).count
|
||||
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)
|
||||
.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_invites_from(inviter, 0, nil).where('invites.user_id IS NOT NULL').reorder(nil).count
|
||||
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)
|
||||
@@ -223,25 +307,32 @@ class Invite < ActiveRecord::Base
|
||||
end
|
||||
|
||||
def self.redeem_from_email(email)
|
||||
invite = Invite.find_by(email: Email.downcase(email))
|
||||
InviteRedeemer.new(invite).redeem if invite
|
||||
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)
|
||||
self.update_columns(updated_at: Time.zone.now, 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.where('invites.user_id IS NULL AND invites.email IS NOT NULL AND invited_by_id = ?', user_id).find_each do |invite|
|
||||
Invite.single_use_invites
|
||||
.joins(:invited_users)
|
||||
.where('invited_users.user_id IS NULL AND invites.email IS NOT NULL AND invited_by_id = ?', user_id)
|
||||
.find_each do |invite|
|
||||
invite.resend_invite
|
||||
end
|
||||
end
|
||||
|
||||
def self.rescind_all_expired_invites_from(user)
|
||||
Invite.where('invites.user_id IS NULL AND invites.email IS NOT NULL AND invited_by_id = ? AND invites.updated_at < ?',
|
||||
user.id, SiteSetting.invite_expiry_days.days.ago).find_each do |invite|
|
||||
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
|
||||
@@ -253,26 +344,37 @@ class Invite < ActiveRecord::Base
|
||||
def self.base_directory
|
||||
File.join(Rails.root, "public", "uploads", "csv", RailsMultisite::ConnectionManagement.current_db)
|
||||
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
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: invites
|
||||
#
|
||||
# id :integer not null, primary key
|
||||
# invite_key :string(32) not null
|
||||
# email :string
|
||||
# invited_by_id :integer not null
|
||||
# user_id :integer
|
||||
# redeemed_at :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# deleted_at :datetime
|
||||
# deleted_by_id :integer
|
||||
# invalidated_at :datetime
|
||||
# moderator :boolean default(FALSE), not null
|
||||
# custom_message :text
|
||||
# emailed_status :integer
|
||||
# id :integer not null, primary key
|
||||
# invite_key :string(32) not null
|
||||
# email :string
|
||||
# invited_by_id :integer not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# deleted_at :datetime
|
||||
# deleted_by_id :integer
|
||||
# invalidated_at :datetime
|
||||
# moderator :boolean default(FALSE), not null
|
||||
# custom_message :text
|
||||
# emailed_status :integer
|
||||
# max_redemptions_allowed :integer default(1), not null
|
||||
# redemption_count :integer default(0), not null
|
||||
# expires_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
InviteRedeemer = Struct.new(:invite, :username, :name, :password, :user_custom_fields, :ip_address) do
|
||||
InviteRedeemer = Struct.new(:invite, :email, :username, :name, :password, :user_custom_fields, :ip_address, keyword_init: true) do
|
||||
|
||||
def redeem
|
||||
Invite.transaction do
|
||||
@@ -14,8 +14,8 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password, :user_custom_f
|
||||
end
|
||||
|
||||
# extracted from User cause it is very specific to invites
|
||||
def self.create_user_from_invite(invite, username, name, password = nil, user_custom_fields = nil, ip_address = nil)
|
||||
user = User.where(staged: true).with_email(invite.email.strip.downcase).first
|
||||
def self.create_user_from_invite(email:, invite:, username: nil, name: nil, password: nil, user_custom_fields: nil, ip_address: nil)
|
||||
user = User.where(staged: true).with_email(email.strip.downcase).first
|
||||
user.unstage! if user
|
||||
|
||||
user ||= User.new
|
||||
@@ -23,11 +23,11 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password, :user_custom_f
|
||||
if username && UsernameValidator.new(username).valid_format? && User.username_available?(username)
|
||||
available_username = username
|
||||
else
|
||||
available_username = UserNameSuggester.suggest(invite.email)
|
||||
available_username = UserNameSuggester.suggest(email)
|
||||
end
|
||||
|
||||
user.attributes = {
|
||||
email: invite.email,
|
||||
email: email,
|
||||
username: available_username,
|
||||
name: name || available_username,
|
||||
active: false,
|
||||
@@ -63,7 +63,7 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password, :user_custom_f
|
||||
|
||||
user.save!
|
||||
|
||||
if invite.emailed_status != Invite.emailed_status_types[:not_required]
|
||||
if invite.emailed_status != Invite.emailed_status_types[:not_required] && email == invite.email
|
||||
user.email_tokens.create!(email: user.email)
|
||||
user.activate
|
||||
end
|
||||
@@ -86,35 +86,42 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password, :user_custom_f
|
||||
end
|
||||
|
||||
def invite_was_redeemed?
|
||||
# Return true if a row was updated
|
||||
mark_invite_redeemed == 1
|
||||
mark_invite_redeemed
|
||||
end
|
||||
|
||||
def mark_invite_redeemed
|
||||
count = Invite.where('id = ? AND redeemed_at IS NULL AND updated_at >= ?',
|
||||
invite.id, SiteSetting.invite_expiry_days.days.ago)
|
||||
.update_all('redeemed_at = CURRENT_TIMESTAMP')
|
||||
if !invite.is_invite_link? && InvitedUser.exists?(invite_id: invite.id)
|
||||
return false
|
||||
end
|
||||
|
||||
if count == 1
|
||||
existing_user = get_existing_user
|
||||
if existing_user.present? && InvitedUser.exists?(user_id: existing_user.id, invite_id: invite.id)
|
||||
return false
|
||||
end
|
||||
|
||||
@invited_user_record = InvitedUser.create!(invite_id: invite.id, redeemed_at: Time.zone.now)
|
||||
if invite.is_invite_link? && @invited_user_record.present?
|
||||
Invite.increment_counter(:redemption_count, invite.id)
|
||||
elsif @invited_user_record.present?
|
||||
delete_duplicate_invites
|
||||
end
|
||||
|
||||
count
|
||||
@invited_user_record.present?
|
||||
end
|
||||
|
||||
def get_invited_user
|
||||
result = get_existing_user
|
||||
result ||= InviteRedeemer.create_user_from_invite(invite, username, name, password, user_custom_fields, ip_address)
|
||||
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.send_welcome_message = false
|
||||
result
|
||||
end
|
||||
|
||||
def get_existing_user
|
||||
User.where(admin: false, staged: false).find_by_email(invite.email)
|
||||
User.where(admin: false, staged: false).find_by_email(email)
|
||||
end
|
||||
|
||||
def add_to_private_topics_if_invited
|
||||
topic_ids = Topic.where(archetype: Archetype::private_message).includes(:invites).where(invites: { email: invite.email }).pluck(:id)
|
||||
topic_ids = Topic.where(archetype: Archetype::private_message).includes(:invites).where(invites: { email: email }).pluck(:id)
|
||||
topic_ids.each do |id|
|
||||
TopicAllowedUser.create!(user_id: invited_user.id, topic_id: id) unless TopicAllowedUser.exists?(user_id: invited_user.id, topic_id: id)
|
||||
end
|
||||
@@ -129,9 +136,8 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password, :user_custom_f
|
||||
end
|
||||
|
||||
def send_welcome_message
|
||||
if Invite.where('email = ?', invite.email).update_all(['user_id = ?', invited_user.id]) == 1
|
||||
invited_user.send_welcome_message = true
|
||||
end
|
||||
@invited_user_record.update!(user_id: invited_user.id)
|
||||
invited_user.send_welcome_message = true
|
||||
end
|
||||
|
||||
def approve_account_if_needed
|
||||
@@ -155,6 +161,10 @@ InviteRedeemer = Struct.new(:invite, :username, :name, :password, :user_custom_f
|
||||
end
|
||||
|
||||
def delete_duplicate_invites
|
||||
Invite.where('invites.email = ? AND redeemed_at IS NULL AND invites.id != ?', invite.email, invite.id).delete_all
|
||||
Invite.single_use_invites
|
||||
.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)
|
||||
.delete_all
|
||||
end
|
||||
end
|
||||
|
||||
26
app/models/invited_user.rb
Normal file
26
app/models/invited_user.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class InvitedUser < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
belongs_to :invite
|
||||
|
||||
validates_presence_of :invite_id
|
||||
validates_uniqueness_of :invite_id, scope: :user_id, conditions: -> { where.not(user_id: nil) }
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: invited_users
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# user_id :integer
|
||||
# invite_id :integer not null
|
||||
# redeemed_at :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_invited_users_on_invite_id (invite_id)
|
||||
# index_invited_users_on_user_id_and_invite_id (user_id,invite_id) UNIQUE WHERE (user_id IS NOT NULL)
|
||||
#
|
||||
@@ -21,7 +21,6 @@ class User < ActiveRecord::Base
|
||||
has_many :user_archived_messages, dependent: :destroy
|
||||
has_many :email_change_requests, dependent: :destroy
|
||||
has_many :email_tokens, dependent: :destroy
|
||||
has_many :invites, dependent: :destroy
|
||||
has_many :topic_links, dependent: :destroy
|
||||
has_many :user_uploads, dependent: :destroy
|
||||
has_many :user_emails, dependent: :destroy
|
||||
@@ -37,6 +36,7 @@ class User < ActiveRecord::Base
|
||||
has_many :acting_group_histories, dependent: :destroy, foreign_key: :acting_user_id, class_name: 'GroupHistory'
|
||||
has_many :targeted_group_histories, dependent: :destroy, foreign_key: :target_user_id, class_name: 'GroupHistory'
|
||||
has_many :reviewable_scores, dependent: :destroy
|
||||
has_many :invites, foreign_key: :invited_by_id, dependent: :destroy
|
||||
|
||||
has_one :user_option, dependent: :destroy
|
||||
has_one :user_avatar, dependent: :destroy
|
||||
@@ -47,6 +47,7 @@ class User < ActiveRecord::Base
|
||||
has_one :single_sign_on_record, dependent: :destroy
|
||||
has_one :anonymous_user_master, class_name: 'AnonymousUser', dependent: :destroy
|
||||
has_one :anonymous_user_shadow, ->(record) { where(active: true) }, foreign_key: :master_user_id, class_name: 'AnonymousUser', dependent: :destroy
|
||||
has_one :invited_user, dependent: :destroy
|
||||
|
||||
# delete all is faster but bypasses callbacks
|
||||
has_many :bookmarks, dependent: :delete_all
|
||||
@@ -425,7 +426,7 @@ class User < ActiveRecord::Base
|
||||
end
|
||||
|
||||
def invited_by
|
||||
used_invite = invites.where("redeemed_at is not null").includes(:invited_by).first
|
||||
used_invite = Invite.joins(:invited_users).where("invited_users.user_id = ?", self.id).first
|
||||
used_invite.try(:invited_by)
|
||||
end
|
||||
|
||||
|
||||
9
app/serializers/invite_link_serializer.rb
Normal file
9
app/serializers/invite_link_serializer.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class InviteLinkSerializer < ApplicationSerializer
|
||||
attributes :id, :invite_key, :created_at, :max_redemptions_allowed, :redemption_count, :expires_at, :group_names
|
||||
|
||||
def group_names
|
||||
object.groups.pluck(:name).join(", ")
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class InviteSerializer < ApplicationSerializer
|
||||
|
||||
attributes :email, :updated_at, :redeemed_at, :expired, :user
|
||||
attributes :id, :email, :updated_at, :expired
|
||||
|
||||
def include_email?
|
||||
options[:show_emails] && !object.redeemed?
|
||||
@@ -11,11 +10,4 @@ class InviteSerializer < ApplicationSerializer
|
||||
def expired
|
||||
object.expired?
|
||||
end
|
||||
|
||||
def user
|
||||
ser = InvitedUserSerializer.new(object.user, scope: scope, root: false)
|
||||
ser.invited_by = object.invited_by
|
||||
ser.as_json
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -4,8 +4,15 @@ class InvitedSerializer < ApplicationSerializer
|
||||
attributes :invites, :can_see_invite_details
|
||||
|
||||
def invites
|
||||
serializer = if object.type == "pending"
|
||||
InviteSerializer
|
||||
else
|
||||
InvitedUserSerializer
|
||||
end
|
||||
|
||||
ActiveModel::ArraySerializer.new(
|
||||
object.invite_list,
|
||||
each_serializer: serializer,
|
||||
scope: scope,
|
||||
root: false,
|
||||
show_emails: object.show_emails
|
||||
|
||||
60
app/serializers/invited_user_record_serializer.rb
Normal file
60
app/serializers/invited_user_record_serializer.rb
Normal file
@@ -0,0 +1,60 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class InvitedUserRecordSerializer < BasicUserSerializer
|
||||
|
||||
attributes :topics_entered,
|
||||
:posts_read_count,
|
||||
:last_seen_at,
|
||||
:time_read,
|
||||
:days_visited,
|
||||
:days_since_created
|
||||
|
||||
attr_accessor :invited_by
|
||||
|
||||
def time_read
|
||||
object.user_stat.time_read
|
||||
end
|
||||
|
||||
def include_time_read?
|
||||
can_see_invite_details?
|
||||
end
|
||||
|
||||
def days_visited
|
||||
object.user_stat.days_visited
|
||||
end
|
||||
|
||||
def include_days_visited?
|
||||
can_see_invite_details?
|
||||
end
|
||||
|
||||
def topics_entered
|
||||
object.user_stat.topics_entered
|
||||
end
|
||||
|
||||
def include_topics_entered?
|
||||
can_see_invite_details?
|
||||
end
|
||||
|
||||
def posts_read_count
|
||||
object.user_stat.posts_read_count
|
||||
end
|
||||
|
||||
def include_posts_read_count?
|
||||
can_see_invite_details?
|
||||
end
|
||||
|
||||
def days_since_created
|
||||
((Time.now - object.created_at) / 60 / 60 / 24).ceil
|
||||
end
|
||||
|
||||
def include_days_since_created
|
||||
can_see_invite_details?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def can_see_invite_details?
|
||||
@can_see_invite_details ||= scope.can_see_invite_details?(invited_by)
|
||||
end
|
||||
|
||||
end
|
||||
@@ -1,54 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class InvitedUserSerializer < BasicUserSerializer
|
||||
class InvitedUserSerializer < ApplicationSerializer
|
||||
attributes :id, :redeemed_at, :user, :invite_source
|
||||
|
||||
attributes :topics_entered,
|
||||
:posts_read_count,
|
||||
:last_seen_at,
|
||||
:time_read,
|
||||
:days_visited,
|
||||
:days_since_created
|
||||
|
||||
attr_accessor :invited_by
|
||||
|
||||
def time_read
|
||||
object.user_stat.time_read
|
||||
def id
|
||||
object.invite.id
|
||||
end
|
||||
|
||||
def include_time_read?
|
||||
scope.can_see_invite_details?(invited_by)
|
||||
def user
|
||||
ser = InvitedUserRecordSerializer.new(object.user, scope: scope, root: false)
|
||||
ser.invited_by = object.invite.invited_by
|
||||
ser.as_json
|
||||
end
|
||||
|
||||
def days_visited
|
||||
object.user_stat.days_visited
|
||||
def invite_source
|
||||
object.invite.is_invite_link? ? "link" : "email"
|
||||
end
|
||||
|
||||
def include_days_visited?
|
||||
scope.can_see_invite_details?(invited_by)
|
||||
end
|
||||
|
||||
def topics_entered
|
||||
object.user_stat.topics_entered
|
||||
end
|
||||
|
||||
def include_topics_entered?
|
||||
scope.can_see_invite_details?(invited_by)
|
||||
end
|
||||
|
||||
def posts_read_count
|
||||
object.user_stat.posts_read_count
|
||||
end
|
||||
|
||||
def include_posts_read_count?
|
||||
scope.can_see_invite_details?(invited_by)
|
||||
end
|
||||
|
||||
def days_since_created
|
||||
((Time.now - object.created_at) / 60 / 60 / 24).ceil
|
||||
end
|
||||
|
||||
def include_days_since_created
|
||||
scope.can_see_invite_details?(invited_by)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -257,7 +257,7 @@ class UserMerger
|
||||
IncomingLink.where(user_id: @source_user.id).update_all(user_id: @target_user.id)
|
||||
IncomingLink.where(current_user_id: @source_user.id).update_all(current_user_id: @target_user.id)
|
||||
|
||||
Invite.with_deleted.where(user_id: @source_user.id).update_all(user_id: @target_user.id)
|
||||
InvitedUser.where(user_id: @source_user.id).update_all(user_id: @target_user.id)
|
||||
Invite.with_deleted.where(invited_by_id: @source_user.id).update_all(invited_by_id: @target_user.id)
|
||||
Invite.with_deleted.where(deleted_by_id: @source_user.id).update_all(deleted_by_id: @target_user.id)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user