Support for inviting to a forum from a user's invite page.

This commit is contained in:
Robin Ward
2013-11-06 12:56:26 -05:00
parent 8d47215ea5
commit de30af9302
22 changed files with 307 additions and 84 deletions

View File

@ -9,6 +9,11 @@
**/ **/
Discourse.InviteController = Discourse.ObjectController.extend(Discourse.ModalFunctionality, { Discourse.InviteController = Discourse.ObjectController.extend(Discourse.ModalFunctionality, {
/**
Can we submit the form?
@property disabled
**/
disabled: function() { disabled: function() {
if (this.get('saving')) return true; if (this.get('saving')) return true;
if (this.blank('email')) return true; if (this.blank('email')) return true;
@ -16,30 +21,79 @@ Discourse.InviteController = Discourse.ObjectController.extend(Discourse.ModalFu
return false; return false;
}.property('email', 'saving'), }.property('email', 'saving'),
/**
The current text for the invite button
@property buttonTitle
**/
buttonTitle: function() { buttonTitle: function() {
if (this.get('saving')) return I18n.t('topic.inviting'); if (this.get('saving')) return I18n.t('topic.inviting');
return I18n.t('topic.invite_reply.action'); return I18n.t('topic.invite_reply.action');
}.property('saving'), }.property('saving'),
/**
We are inviting to a topic if the model isn't the current user. The current user would
mean we are inviting to the forum in general.
@property invitingToTopic
**/
invitingToTopic: function() {
return this.get('model') !== Discourse.User.current();
}.property('model'),
/**
Instructional text for the modal.
@property inviteInstructions
**/
inviteInstructions: function() {
if (this.get('invitingToTopic')) {
return I18n.t('topic.invite_reply.to_topic');
} else {
return I18n.t('topic.invite_reply.to_forum');
}
}.property('invitingToTopic'),
/**
The "success" text for when the invite was created.
@property successMessage
**/
successMessage: function() { successMessage: function() {
return I18n.t('topic.invite_reply.success', { email: this.get('email') }); return I18n.t('topic.invite_reply.success', { email: this.get('email') });
}.property('email'), }.property('email'),
actions: { /**
createInvite: function() { Reset the modal to allow a new user to be invited.
if (this.get('disabled')) return;
var inviteController = this; @method reset
this.set('saving', true); **/
this.set('error', false); reset: function() {
this.get('model').inviteUser(this.get('email')).then(function() { this.setProperties({
// Success email: null,
inviteController.set('saving', false); error: false,
return inviteController.set('finished', true); saving: false,
}, function() { finished: false
// Failure });
inviteController.set('error', true); },
return inviteController.set('saving', false);
actions: {
/**
Create the invite and update the modal accordingly.
@method createInvite
**/
createInvite: function() {
if (this.get('disabled')) { return; }
var self = this;
this.setProperties({ saving: true, error: false });
this.get('model').createInvite(this.get('email')).then(function() {
self.setProperties({ saving: false, finished: true });
}).fail(function() {
self.setProperties({ saving: false, error: true });
}); });
return false; return false;
} }

View File

@ -8,7 +8,6 @@
@module Discourse @module Discourse
**/ **/
Discourse.InvitePrivateController = Discourse.ObjectController.extend(Discourse.ModalFunctionality, { Discourse.InvitePrivateController = Discourse.ObjectController.extend(Discourse.ModalFunctionality, {
modalClass: 'invite', modalClass: 'invite',
onShow: function(){ onShow: function(){
@ -26,28 +25,25 @@ Discourse.InvitePrivateController = Discourse.ObjectController.extend(Discourse.
return I18n.t('topic.invite_private.action'); return I18n.t('topic.invite_private.action');
}.property('saving'), }.property('saving'),
actions: {
invite: function() { invite: function() {
if (this.get('disabled')) return; if (this.get('disabled')) return;
var invitePrivateController = this; var self = this;
this.set('saving', true); this.setProperties({saving: true, error: false});
this.set('error', false);
// Invite the user to the private message // Invite the user to the private message
this.get('content').inviteUser(this.get('emailOrUsername')).then(function(result) { this.get('model').createInvite(this.get('emailOrUsername')).then(function(result) {
// Success self.setProperties({saving: true, finished: true});
invitePrivateController.set('saving', false);
invitePrivateController.set('finished', true);
if(result && result.user) { if(result && result.user) {
invitePrivateController.get('content.details.allowed_users').pushObject(result.user); self.get('model.details.allowed_users').pushObject(result.user);
} }
}, function() { }).fail(function() {
// Failure self.setProperties({error: true, saving: false});
invitePrivateController.set('error', true);
invitePrivateController.set('saving', false);
}); });
return false; return false;
} }
}
}); });

View File

@ -8,6 +8,11 @@
**/ **/
Discourse.UserInvitedController = Ember.ArrayController.extend({ Discourse.UserInvitedController = Ember.ArrayController.extend({
/**
Observe the search term box with a debouncer and change the results.
@observes searchTerm
**/
_searchTermChanged: Discourse.debounce(function() { _searchTermChanged: Discourse.debounce(function() {
var self = this; var self = this;
Discourse.Invite.findInvitedBy(self.get('user'), this.get('searchTerm')).then(function (invites) { Discourse.Invite.findInvitedBy(self.get('user'), this.get('searchTerm')).then(function (invites) {
@ -15,20 +20,51 @@ Discourse.UserInvitedController = Ember.ArrayController.extend({
}); });
}, 250).observes('searchTerm'), }, 250).observes('searchTerm'),
/**
The maximum amount of invites that will be displayed in the view
@property maxInvites
**/
maxInvites: function() { maxInvites: function() {
return Discourse.SiteSettings.invites_shown; return Discourse.SiteSettings.invites_shown;
}.property(), }.property(),
/**
Can the currently logged in user invite users to the site
@property canInviteToForum
**/
canInviteToForum: function() {
return Discourse.User.currentProp('can_invite_to_forum');
}.property(),
/**
Should the search filter input box be displayed?
@property showSearch
**/
showSearch: function() { showSearch: function() {
if (Em.isNone(this.get('searchTerm')) && this.get('model.length') === 0) { return false; } if (Em.isNone(this.get('searchTerm')) && this.get('model.length') === 0) { return false; }
return true; return true;
}.property('searchTerm', 'model.length'), }.property('searchTerm', 'model.length'),
/**
Were the results limited by our `maxInvites`
@property truncated
**/
truncated: function() { truncated: function() {
return this.get('model.length') === Discourse.SiteSettings.invites_shown; return this.get('model.length') === Discourse.SiteSettings.invites_shown;
}.property('model.length'), }.property('model.length'),
actions: { actions: {
/**
Rescind a given invite
@method rescive
@param {Discourse.Invite} invite the invite to rescind.
**/
rescind: function(invite) { rescind: function(invite) {
invite.rescind(); invite.rescind();
return false; return false;

View File

@ -196,11 +196,16 @@ Discourse.Topic = Discourse.Model.extend({
}); });
}, },
// Invite a user to this topic /**
inviteUser: function(user) { Invite a user to this topic
@method createInvite
@param {String} emailOrUsername The email or username of the user to be invited
**/
createInvite: function(emailOrUsername) {
return Discourse.ajax("/t/" + this.get('id') + "/invite", { return Discourse.ajax("/t/" + this.get('id') + "/invite", {
type: 'POST', type: 'POST',
data: { user: user } data: { user: emailOrUsername }
}); });
}, },

View File

@ -282,13 +282,27 @@ Discourse.User = Discourse.Model.extend({
Determines whether the current user is allowed to upload a file. Determines whether the current user is allowed to upload a file.
@method isAllowedToUploadAFile @method isAllowedToUploadAFile
@param {string} type The type of the upload (image, attachment) @param {String} type The type of the upload (image, attachment)
@returns true if the current user is allowed to upload a file @returns true if the current user is allowed to upload a file
**/ **/
isAllowedToUploadAFile: function(type) { isAllowedToUploadAFile: function(type) {
return this.get('staff') || return this.get('staff') ||
this.get('trust_level') > 0 || this.get('trust_level') > 0 ||
Discourse.SiteSettings['newuser_max_' + type + 's'] > 0; Discourse.SiteSettings['newuser_max_' + type + 's'] > 0;
},
/**
Invite a user to the site
@method createInvite
@param {String} email The email address of the user to invite to the site
@returns {Promise} the result of the server call
**/
createInvite: function(email) {
return Discourse.ajax('/invites', {
type: 'POST',
data: {email: email}
});
} }
}); });

View File

@ -36,12 +36,7 @@ Discourse.TopicRoute = Discourse.Route.extend({
showInvite: function() { showInvite: function() {
Discourse.Route.showModal(this, 'invite', this.modelFor('topic')); Discourse.Route.showModal(this, 'invite', this.modelFor('topic'));
this.controllerFor('invite').setProperties({ this.controllerFor('invite').reset();
email: null,
error: false,
saving: false,
finished: false
});
}, },
showPrivateInvite: function() { showPrivateInvite: function() {

View File

@ -22,6 +22,19 @@ Discourse.UserInvitedRoute = Discourse.Route.extend({
searchTerm: '' searchTerm: ''
}); });
this.controllerFor('user').set('indexStream', false); this.controllerFor('user').set('indexStream', false);
},
actions: {
/**
Shows the invite modal to invite users to the forum.
@method showInvite
**/
showInvite: function() {
Discourse.Route.showModal(this, 'invite', Discourse.User.current());
this.controllerFor('invite').reset();
}
} }
}); });

View File

@ -9,7 +9,8 @@
{{#if finished}} {{#if finished}}
{{{successMessage}}} {{{successMessage}}}
{{else}} {{else}}
<label>{{i18n topic.invite_reply.email}}</label>
<label>{{inviteInstructions}}</label>
{{textField value=email placeholderKey="topic.invite_reply.email_placeholder"}} {{textField value=email placeholderKey="topic.invite_reply.email_placeholder"}}
{{/if}} {{/if}}
</div> </div>

View File

@ -2,6 +2,10 @@
<h2>{{i18n user.invited.title}}</h2> <h2>{{i18n user.invited.title}}</h2>
{{#if canInviteToForum}}
<button {{action showInvite}} class='btn right'>{{i18n user.invited.create}}</button>
{{/if}}
{{#if showSearch}} {{#if showSearch}}
<form> <form>
{{textField value=searchTerm placeholderKey="user.invited.search"}} {{textField value=searchTerm placeholderKey="user.invited.search"}}

View File

@ -8,12 +8,18 @@
**/ **/
Discourse.InviteView = Discourse.ModalBodyView.extend({ Discourse.InviteView = Discourse.ModalBodyView.extend({
templateName: 'modal/invite', templateName: 'modal/invite',
title: I18n.t('topic.invite_reply.title'),
title: function() {
if (this.get('controller.invitingToTopic')) {
return I18n.t('topic.invite_reply.title');
} else {
return I18n.t('user.invited.create');
}
}.property('controller.invitingToTopic'),
keyUp: function(e) { keyUp: function(e) {
// Add the invitee if they hit enter // Add the invitee if they hit enter
if (e.keyCode === 13) { this.get('controller').createInvite(); } if (e.keyCode === 13) { this.get('controller').send('createInvite'); }
return false; return false;
} }

View File

@ -108,6 +108,10 @@
border: 1px solid #ddd; border: 1px solid #ddd;
margin-bottom: 10px; margin-bottom: 10px;
.btn.right {
float: right
}
h2 { h2 {
margin-bottom: 10px; margin-bottom: 10px;
} }

View File

@ -193,7 +193,7 @@ class ApplicationController < ActionController::Base
end end
def preload_current_user_data def preload_current_user_data
store_preloaded("currentUser", MultiJson.dump(CurrentUserSerializer.new(current_user, root: false))) store_preloaded("currentUser", MultiJson.dump(CurrentUserSerializer.new(current_user, scope: guardian, root: false)))
serializer = ActiveModel::ArraySerializer.new(TopicTrackingState.report([current_user.id]), each_serializer: TopicTrackingStateSerializer) serializer = ActiveModel::ArraySerializer.new(TopicTrackingState.report([current_user.id]), each_serializer: TopicTrackingStateSerializer)
store_preloaded("topicTrackingStates", MultiJson.dump(serializer)) store_preloaded("topicTrackingStates", MultiJson.dump(serializer))
end end

View File

@ -3,7 +3,7 @@ class InvitesController < ApplicationController
skip_before_filter :check_xhr skip_before_filter :check_xhr
skip_before_filter :redirect_to_login_if_required skip_before_filter :redirect_to_login_if_required
before_filter :ensure_logged_in, only: [:destroy] before_filter :ensure_logged_in, only: [:destroy, :create]
def show def show
invite = Invite.where(invite_key: params[:id]).first invite = Invite.where(invite_key: params[:id]).first
@ -27,6 +27,18 @@ class InvitesController < ApplicationController
redirect_to "/" redirect_to "/"
end end
def create
params.require(:email)
guardian.ensure_can_invite_to_forum!
if Invite.invite_by_email(params[:email], current_user)
render json: success_json
else
render json: failed_json, status: 422
end
end
def destroy def destroy
params.require(:email) params.require(:email)

View File

@ -8,11 +8,19 @@ class InviteMailer < ActionMailer::Base
first_topic = invite.topics.order(:created_at).first first_topic = invite.topics.order(:created_at).first
# If they were invited to a topic # If they were invited to a topic
if first_topic.present?
build_email(invite.email, build_email(invite.email,
template: 'invite_mailer', template: 'invite_mailer',
invitee_name: invite.invited_by.username, invitee_name: invite.invited_by.username,
invite_link: "#{Discourse.base_url}/invites/#{invite.invite_key}", invite_link: "#{Discourse.base_url}/invites/#{invite.invite_key}",
topic_title: first_topic.try(:title)) topic_title: first_topic.try(:title))
else
build_email(invite.email,
template: 'invite_forum_mailer',
invitee_name: invite.invited_by.username,
invite_link: "#{Discourse.base_url}/invites/#{invite.invite_key}")
end
end end
end end

View File

@ -44,6 +44,30 @@ class Invite < ActiveRecord::Base
InviteRedeemer.new(self).redeem unless expired? || destroyed? InviteRedeemer.new(self).redeem unless expired? || destroyed?
end 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.invite_by_email(email, invited_by, topic=nil)
lower_email = Email.downcase(email)
invite = Invite.with_deleted.where('invited_by_id = ? and email = ?', invited_by.id, lower_email).first
if invite.blank?
invite = Invite.create(invited_by: invited_by, email: lower_email)
unless invite.valid?
topic.grant_permission_to_user(lower_email) if topic.present? && topic.email_already_exists_for?(invite)
return
end
end
# Recover deleted invites if we invite them again
invite.recover! if invite.deleted_at.present?
topic.topic_invites.create(invite_id: invite.id) if topic.present?
Jobs.enqueue(:invite_email, invite_id: invite.id)
invite
end
end end
# == Schema Information # == Schema Information

View File

@ -443,28 +443,8 @@ class Topic < ActiveRecord::Base
end end
end end
# Invite a user by email and return the invite. Return the previously existing invite
# if already exists. Returns nil if the invite can't be created.
def invite_by_email(invited_by, email) def invite_by_email(invited_by, email)
lower_email = Email.downcase(email) Invite.invite_by_email(email, invited_by, self)
invite = Invite.with_deleted.where('invited_by_id = ? and email = ?', invited_by.id, lower_email).first
if invite.blank?
invite = Invite.create(invited_by: invited_by, email: lower_email)
unless invite.valid?
grant_permission_to_user(lower_email) if email_already_exists_for?(invite)
return
end
end
# Recover deleted invites if we invite them again
invite.recover if invite.deleted_at.present?
topic_invites.create(invite_id: invite.id)
Jobs.enqueue(:invite_email, invite_id: invite.id)
invite
end end
def email_already_exists_for?(invite) def email_already_exists_for?(invite)

View File

@ -14,7 +14,8 @@ class CurrentUserSerializer < BasicUserSerializer
:external_links_in_new_tab, :external_links_in_new_tab,
:dynamic_favicon, :dynamic_favicon,
:trust_level, :trust_level,
:can_edit :can_edit,
:can_invite_to_forum
def include_site_flagged_posts_count? def include_site_flagged_posts_count?
object.staff? object.staff?
@ -36,4 +37,12 @@ class CurrentUserSerializer < BasicUserSerializer
true true
end end
def can_invite_to_forum
true
end
def include_can_invite_to_forum?
scope.can_invite_to_forum?
end
end end

View File

@ -339,6 +339,7 @@ en:
time_read: "Read Time" time_read: "Read Time"
days_visited: "Days Visited" days_visited: "Days Visited"
account_age_days: "Account age in days" account_age_days: "Account age in days"
create: "Invite Friends to this Forum"
password: password:
title: "Password" title: "Password"
@ -736,7 +737,9 @@ en:
title: 'Invite Friends to Reply' title: 'Invite Friends to Reply'
action: 'Email Invite' action: 'Email Invite'
help: 'send invitations to friends so they can reply to this topic with a single click' help: 'send invitations to friends so they can reply to this topic with a single click'
email: "We'll send your friend a brief email allowing them to immediately reply to this topic by clicking a link, no login required." to_topic: "We'll send your friend a brief email allowing them to immediately reply to this topic by clicking a link, no login required."
to_forum: "We'll send your friend a brief email allowing them to join the forum by clicking a link."
email_placeholder: 'email address' email_placeholder: 'email address'
success: "Thanks! We mailed out an invitation to <b>{{email}}</b>. We'll let you know when they redeem your invitation. Check the invitations tab on your user page to keep track of who you've invited." success: "Thanks! We mailed out an invitation to <b>{{email}}</b>. We'll let you know when they redeem your invitation. Check the invitations tab on your user page to keep track of who you've invited."
error: "Sorry, we couldn't invite that person. Perhaps they are already a user?" error: "Sorry, we couldn't invite that person. Perhaps they are already a user?"

View File

@ -813,6 +813,19 @@ en:
[1]: %{invite_link} [1]: %{invite_link}
invite_forum_mailer:
subject_template: "[%{site_name}] %{invitee_name} invited you to join %{site_name}"
text_body_template: |
%{invitee_name} invited you to %{site_name}.
If you're interested, click the link below to join:
[Visit %{site_name}][1]
You were invited by a trusted user, so you'll be able to join immediately, without needing to log in.
[1]: %{invite_link}
test_mailer: test_mailer:
subject_template: "[%{site_name}] Email Deliverability Test" subject_template: "[%{site_name}] Email Deliverability Test"
text_body_template: | text_body_template: |

View File

@ -191,15 +191,18 @@ class Guardian
is_me?(user) is_me?(user)
end end
def can_invite_to?(object) def can_invite_to_forum?
authenticated? && authenticated? &&
can_see?(object) &&
( (
(!SiteSetting.must_approve_users? && @user.has_trust_level?(:regular)) || (!SiteSetting.must_approve_users? && @user.has_trust_level?(:regular)) ||
is_staff? is_staff?
) )
end end
def can_invite_to?(object)
can_see?(object) && can_invite_to_forum?
end
def can_see_deleted_posts? def can_see_deleted_posts?
is_staff? is_staff?
end end

View File

@ -181,6 +181,26 @@ describe Guardian do
end end
end end
describe 'can_invite_to_forum?' do
let(:user) { Fabricate.build(:user) }
let(:moderator) { Fabricate.build(:moderator) }
it "doesn't allow anonymous users to invite" do
Guardian.new.can_invite_to_forum?.should be_false
end
it 'returns true when the site requires approving users and is mod' do
SiteSetting.expects(:must_approve_users?).returns(true)
Guardian.new(moderator).can_invite_to_forum?.should be_true
end
it 'returns false when the site requires approving users and is regular' do
SiteSetting.expects(:must_approve_users?).returns(true)
Guardian.new(user).can_invite_to_forum?.should be_false
end
end
describe 'can_invite_to?' do describe 'can_invite_to?' do
let(:topic) { Fabricate(:topic) } let(:topic) { Fabricate(:topic) }
let(:user) { topic.user } let(:user) { topic.user }
@ -198,7 +218,7 @@ describe Guardian do
Guardian.new(moderator).can_invite_to?(topic).should be_true Guardian.new(moderator).can_invite_to?(topic).should be_true
end end
it 'returns true when the site requires approving users and is regular' do it 'returns false when the site requires approving users and is regular' do
SiteSetting.expects(:must_approve_users?).returns(true) SiteSetting.expects(:must_approve_users?).returns(true)
Guardian.new(coding_horror).can_invite_to?(topic).should be_false Guardian.new(coding_horror).can_invite_to?(topic).should be_false
end end

View File

@ -35,13 +35,39 @@ describe InvitesController do
end end
end
context '.create' do
it 'requires you to be logged in' do
lambda {
post :create, email: 'jake@adventuretime.ooo'
}.should raise_error(Discourse::NotLoggedIn)
end
context 'while logged in' do
let!(:user) { log_in }
let(:email) { 'jake@adventuretime.ooo' }
it "fails if you can't invite to the forum" do
Guardian.any_instance.stubs(:can_invite_to_forum?).returns(false)
Invite.expects(:invite_by_email).never
post :create, email: email
response.should_not be_success
end
it "delegates to Invite#invite_by_email and returns success if you can invite" do
Guardian.any_instance.stubs(:can_invite_to_forum?).returns(true)
Invite.expects(:invite_by_email).with(email, user).returns(Invite.new)
post :create, email: email
response.should be_success
end
end
end end
context '.show' do context '.show' do
context 'with an invalid invite id' do context 'with an invalid invite id' do
before do before do
get :show, id: "doesn't exist" get :show, id: "doesn't exist"
end end
@ -53,7 +79,6 @@ describe InvitesController do
it "should not change the session" do it "should not change the session" do
session[:current_user_id].should be_blank session[:current_user_id].should be_blank
end end
end end
context 'with a deleted invite' do context 'with a deleted invite' do
@ -71,10 +96,8 @@ describe InvitesController do
it "should not change the session" do it "should not change the session" do
session[:current_user_id].should be_blank session[:current_user_id].should be_blank
end end
end end
context 'with a valid invite id' do context 'with a valid invite id' do
let(:topic) { Fabricate(:topic) } let(:topic) { Fabricate(:topic) }
let(:invite) { topic.invite_by_email(topic.user, "iceking@adventuretime.ooo") } let(:invite) { topic.invite_by_email(topic.user, "iceking@adventuretime.ooo") }