From de30af9302b205c4105cf996d50c7149d612430f Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Wed, 6 Nov 2013 12:56:26 -0500 Subject: [PATCH] Support for inviting to a forum from a user's invite page. --- .../controllers/invite_controller.js | 82 +++++++++++++++---- .../controllers/invite_private_controller.js | 36 ++++---- .../controllers/user_invited_controller.js | 36 ++++++++ .../javascripts/discourse/models/topic.js | 11 ++- .../javascripts/discourse/models/user.js | 16 +++- .../discourse/routes/topic_route.js | 7 +- .../discourse/routes/user_invited_route.js | 13 +++ .../templates/modal/invite.js.handlebars | 3 +- .../templates/user/invited.js.handlebars | 4 + .../discourse/views/modal/invite_view.js | 10 ++- app/assets/stylesheets/desktop/user.scss | 4 + app/controllers/application_controller.rb | 2 +- app/controllers/invites_controller.rb | 14 +++- app/mailers/invite_mailer.rb | 18 ++-- app/models/invite.rb | 24 ++++++ app/models/topic.rb | 22 +---- app/serializers/current_user_serializer.rb | 11 ++- config/locales/client.en.yml | 5 +- config/locales/server.en.yml | 13 +++ lib/guardian.rb | 7 +- spec/components/guardian_spec.rb | 22 ++++- spec/controllers/invites_controller_spec.rb | 31 ++++++- 22 files changed, 307 insertions(+), 84 deletions(-) diff --git a/app/assets/javascripts/discourse/controllers/invite_controller.js b/app/assets/javascripts/discourse/controllers/invite_controller.js index e619914e3..4a8134d08 100644 --- a/app/assets/javascripts/discourse/controllers/invite_controller.js +++ b/app/assets/javascripts/discourse/controllers/invite_controller.js @@ -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; } diff --git a/app/assets/javascripts/discourse/controllers/invite_private_controller.js b/app/assets/javascripts/discourse/controllers/invite_private_controller.js index 12386b4ce..620e8a724 100644 --- a/app/assets/javascripts/discourse/controllers/invite_private_controller.js +++ b/app/assets/javascripts/discourse/controllers/invite_private_controller.js @@ -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; + } } }); diff --git a/app/assets/javascripts/discourse/controllers/user_invited_controller.js b/app/assets/javascripts/discourse/controllers/user_invited_controller.js index 877871bfb..db07e01ea 100644 --- a/app/assets/javascripts/discourse/controllers/user_invited_controller.js +++ b/app/assets/javascripts/discourse/controllers/user_invited_controller.js @@ -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; diff --git a/app/assets/javascripts/discourse/models/topic.js b/app/assets/javascripts/discourse/models/topic.js index cfe6afc1b..f998e3bca 100644 --- a/app/assets/javascripts/discourse/models/topic.js +++ b/app/assets/javascripts/discourse/models/topic.js @@ -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 } }); }, diff --git a/app/assets/javascripts/discourse/models/user.js b/app/assets/javascripts/discourse/models/user.js index b3fee4385..09a7ee209 100644 --- a/app/assets/javascripts/discourse/models/user.js +++ b/app/assets/javascripts/discourse/models/user.js @@ -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} + }); } }); diff --git a/app/assets/javascripts/discourse/routes/topic_route.js b/app/assets/javascripts/discourse/routes/topic_route.js index 3da8d1e25..c4031d9d6 100644 --- a/app/assets/javascripts/discourse/routes/topic_route.js +++ b/app/assets/javascripts/discourse/routes/topic_route.js @@ -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() { diff --git a/app/assets/javascripts/discourse/routes/user_invited_route.js b/app/assets/javascripts/discourse/routes/user_invited_route.js index 2c72df220..98afbdaad 100644 --- a/app/assets/javascripts/discourse/routes/user_invited_route.js +++ b/app/assets/javascripts/discourse/routes/user_invited_route.js @@ -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(); + } } }); \ No newline at end of file diff --git a/app/assets/javascripts/discourse/templates/modal/invite.js.handlebars b/app/assets/javascripts/discourse/templates/modal/invite.js.handlebars index b26089d98..2f60eed7f 100644 --- a/app/assets/javascripts/discourse/templates/modal/invite.js.handlebars +++ b/app/assets/javascripts/discourse/templates/modal/invite.js.handlebars @@ -9,7 +9,8 @@ {{#if finished}} {{{successMessage}}} {{else}} - + + {{textField value=email placeholderKey="topic.invite_reply.email_placeholder"}} {{/if}} diff --git a/app/assets/javascripts/discourse/templates/user/invited.js.handlebars b/app/assets/javascripts/discourse/templates/user/invited.js.handlebars index af8965883..ad8851e07 100644 --- a/app/assets/javascripts/discourse/templates/user/invited.js.handlebars +++ b/app/assets/javascripts/discourse/templates/user/invited.js.handlebars @@ -2,6 +2,10 @@

{{i18n user.invited.title}}

+ {{#if canInviteToForum}} + + {{/if}} + {{#if showSearch}}
{{textField value=searchTerm placeholderKey="user.invited.search"}} diff --git a/app/assets/javascripts/discourse/views/modal/invite_view.js b/app/assets/javascripts/discourse/views/modal/invite_view.js index 735bdb30f..4783a5be2 100644 --- a/app/assets/javascripts/discourse/views/modal/invite_view.js +++ b/app/assets/javascripts/discourse/views/modal/invite_view.js @@ -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; } diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index 73cc0588a..e6c85335c 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -108,6 +108,10 @@ border: 1px solid #ddd; margin-bottom: 10px; + .btn.right { + float: right + } + h2 { margin-bottom: 10px; } diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2a339818d..6b82abab6 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 238fe8b17..945a28c23 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -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) diff --git a/app/mailers/invite_mailer.rb b/app/mailers/invite_mailer.rb index 5d33b96b0..0465b1db0 100644 --- a/app/mailers/invite_mailer.rb +++ b/app/mailers/invite_mailer.rb @@ -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 diff --git a/app/models/invite.rb b/app/models/invite.rb index e64ecaaff..72deddc9d 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -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 diff --git a/app/models/topic.rb b/app/models/topic.rb index eec938f5b..560c0b5af 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -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) diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb index 58a2d4365..74ef7cbbb 100644 --- a/app/serializers/current_user_serializer.rb +++ b/app/serializers/current_user_serializer.rb @@ -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 diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 33a56aff7..f21bf92fb 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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 {{email}}. 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?" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index e8080081d..016ebc881 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -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: | diff --git a/lib/guardian.rb b/lib/guardian.rb index 0c44e075f..040b4086c 100644 --- a/lib/guardian.rb +++ b/lib/guardian.rb @@ -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 diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb index e9e60c173..7071afac0 100644 --- a/spec/components/guardian_spec.rb +++ b/spec/components/guardian_spec.rb @@ -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 diff --git a/spec/controllers/invites_controller_spec.rb b/spec/controllers/invites_controller_spec.rb index 79152e2ac..2bc0d4026 100644 --- a/spec/controllers/invites_controller_spec.rb +++ b/spec/controllers/invites_controller_spec.rb @@ -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") }