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}}
|
||||
|
||||
Reference in New Issue
Block a user