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, {
/**
Can we submit the form?
@property disabled
**/
disabled: function() {
if (this.get('saving')) return true;
if (this.blank('email')) return true;
@ -16,30 +21,79 @@ Discourse.InviteController = Discourse.ObjectController.extend(Discourse.ModalFu
return false;
}.property('email', 'saving'),
/**
The current text for the invite button
@property buttonTitle
**/
buttonTitle: function() {
if (this.get('saving')) return I18n.t('topic.inviting');
return I18n.t('topic.invite_reply.action');
}.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() {
return I18n.t('topic.invite_reply.success', { email: this.get('email') });
}.property('email'),
actions: {
createInvite: function() {
if (this.get('disabled')) return;
/**
Reset the modal to allow a new user to be invited.
var inviteController = this;
this.set('saving', true);
this.set('error', false);
this.get('model').inviteUser(this.get('email')).then(function() {
// Success
inviteController.set('saving', false);
return inviteController.set('finished', true);
}, function() {
// Failure
inviteController.set('error', true);
return inviteController.set('saving', false);
@method reset
**/
reset: function() {
this.setProperties({
email: null,
error: false,
saving: false,
finished: 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;
}

View file

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

View file

@ -8,6 +8,11 @@
**/
Discourse.UserInvitedController = Ember.ArrayController.extend({
/**
Observe the search term box with a debouncer and change the results.
@observes searchTerm
**/
_searchTermChanged: Discourse.debounce(function() {
var self = this;
Discourse.Invite.findInvitedBy(self.get('user'), this.get('searchTerm')).then(function (invites) {
@ -15,20 +20,51 @@ Discourse.UserInvitedController = Ember.ArrayController.extend({
});
}, 250).observes('searchTerm'),
/**
The maximum amount of invites that will be displayed in the view
@property maxInvites
**/
maxInvites: function() {
return Discourse.SiteSettings.invites_shown;
}.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() {
if (Em.isNone(this.get('searchTerm')) && this.get('model.length') === 0) { return false; }
return true;
}.property('searchTerm', 'model.length'),
/**
Were the results limited by our `maxInvites`
@property truncated
**/
truncated: function() {
return this.get('model.length') === Discourse.SiteSettings.invites_shown;
}.property('model.length'),
actions: {
/**
Rescind a given invite
@method rescive
@param {Discourse.Invite} invite the invite to rescind.
**/
rescind: function(invite) {
invite.rescind();
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", {
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.
@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
**/
isAllowedToUploadAFile: function(type) {
return this.get('staff') ||
this.get('trust_level') > 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() {
Discourse.Route.showModal(this, 'invite', this.modelFor('topic'));
this.controllerFor('invite').setProperties({
email: null,
error: false,
saving: false,
finished: false
});
this.controllerFor('invite').reset();
},
showPrivateInvite: function() {

View file

@ -22,6 +22,19 @@ Discourse.UserInvitedRoute = Discourse.Route.extend({
searchTerm: ''
});
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}}
{{{successMessage}}}
{{else}}
<label>{{i18n topic.invite_reply.email}}</label>
<label>{{inviteInstructions}}</label>
{{textField value=email placeholderKey="topic.invite_reply.email_placeholder"}}
{{/if}}
</div>

View file

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

View file

@ -8,12 +8,18 @@
**/
Discourse.InviteView = Discourse.ModalBodyView.extend({
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) {
// 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;
}

View file

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

View file

@ -193,7 +193,7 @@ class ApplicationController < ActionController::Base
end
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)
store_preloaded("topicTrackingStates", MultiJson.dump(serializer))
end

View file

@ -3,7 +3,7 @@ class InvitesController < ApplicationController
skip_before_filter :check_xhr
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
invite = Invite.where(invite_key: params[:id]).first
@ -27,6 +27,18 @@ class InvitesController < ApplicationController
redirect_to "/"
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
params.require(:email)

View file

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

View file

@ -44,6 +44,30 @@ class Invite < ActiveRecord::Base
InviteRedeemer.new(self).redeem unless expired? || destroyed?
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
# == Schema Information

View file

@ -443,28 +443,8 @@ class Topic < ActiveRecord::Base
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)
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?
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
Invite.invite_by_email(email, invited_by, self)
end
def email_already_exists_for?(invite)

View file

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

View file

@ -339,6 +339,7 @@ en:
time_read: "Read Time"
days_visited: "Days Visited"
account_age_days: "Account age in days"
create: "Invite Friends to this Forum"
password:
title: "Password"
@ -736,7 +737,9 @@ en:
title: 'Invite Friends to Reply'
action: 'Email Invite'
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'
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?"

View file

@ -813,6 +813,19 @@ en:
[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:
subject_template: "[%{site_name}] Email Deliverability Test"
text_body_template: |

View file

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

View file

@ -181,6 +181,26 @@ describe Guardian do
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
let(:topic) { Fabricate(:topic) }
let(:user) { topic.user }
@ -198,7 +218,7 @@ describe Guardian do
Guardian.new(moderator).can_invite_to?(topic).should be_true
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)
Guardian.new(coding_horror).can_invite_to?(topic).should be_false
end

View file

@ -35,13 +35,39 @@ describe InvitesController do
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
context '.show' do
context 'with an invalid invite id' do
before do
get :show, id: "doesn't exist"
end
@ -53,7 +79,6 @@ describe InvitesController do
it "should not change the session" do
session[:current_user_id].should be_blank
end
end
context 'with a deleted invite' do
@ -71,10 +96,8 @@ describe InvitesController do
it "should not change the session" do
session[:current_user_id].should be_blank
end
end
context 'with a valid invite id' do
let(:topic) { Fabricate(:topic) }
let(:invite) { topic.invite_by_email(topic.user, "iceking@adventuretime.ooo") }