FEATURE: multiple use invite links (#9813)

This commit is contained in:
Arpit Jalan
2020-06-09 20:49:32 +05:30
committed by GitHub
parent 6b7a2d6d4d
commit 3094459cd9
48 changed files with 1280 additions and 351 deletions
@@ -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"}}
&nbsp;{{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}}
&nbsp;{{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}}