Merge pull request #3375 from techAPJ/patch-2

FEATURE: invite existing users to private topic
This commit is contained in:
Robin Ward 2015-04-16 11:13:42 -04:00
commit 2459f52c71
9 changed files with 74 additions and 21 deletions

View File

@ -7,7 +7,8 @@ export default TextField.extend({
var self = this, var self = this,
selected = [], selected = [],
currentUser = this.currentUser, currentUser = this.currentUser,
includeGroups = this.get('includeGroups') === 'true'; includeGroups = this.get('includeGroups') === 'true',
allowedUsers = this.get('allowedUsers') === 'true';
function excludedUsernames() { function excludedUsernames() {
if (currentUser && self.get('excludeCurrentUser')) { if (currentUser && self.get('excludeCurrentUser')) {
@ -27,7 +28,8 @@ export default TextField.extend({
term: term.replace(/[^a-zA-Z0-9_]/, ''), term: term.replace(/[^a-zA-Z0-9_]/, ''),
topicId: self.get('topicId'), topicId: self.get('topicId'),
exclude: excludedUsernames(), exclude: excludedUsernames(),
includeGroups: includeGroups includeGroups,
allowedUsers
}); });
}, },

View File

@ -15,11 +15,15 @@ export default ObjectController.extend(ModalFunctionality, {
disabled: function() { disabled: function() {
if (this.get('saving')) return true; if (this.get('saving')) return true;
if (this.blank('emailOrUsername')) return true; if (this.blank('emailOrUsername')) return true;
// when inviting to forum, email must be valid
if (!this.get('invitingToTopic') && !Discourse.Utilities.emailValid(this.get('emailOrUsername'))) return true; if (!this.get('invitingToTopic') && !Discourse.Utilities.emailValid(this.get('emailOrUsername'))) return true;
// normal users (not admin) can't invite users to private topic via email
if (!this.get('isAdmin') && this.get('isPrivateTopic') && Discourse.Utilities.emailValid(this.get('emailOrUsername'))) return true;
// when invting to private topic via email, group name must be specified
if (this.get('isPrivateTopic') && this.blank('groupNames') && Discourse.Utilities.emailValid(this.get('emailOrUsername'))) return true;
if (this.get('model.details.can_invite_to')) return false; if (this.get('model.details.can_invite_to')) return false;
if (this.get('isPrivateTopic') && this.blank('groupNames')) return true;
return false; return false;
}.property('emailOrUsername', 'invitingToTopic', 'isPrivateTopic', 'groupNames', 'saving'), }.property('isAdmin', 'emailOrUsername', 'invitingToTopic', 'isPrivateTopic', 'groupNames', 'saving'),
buttonTitle: function() { buttonTitle: function() {
return this.get('saving') ? I18n.t('topic.inviting') : I18n.t('topic.invite_reply.action'); return this.get('saving') ? I18n.t('topic.inviting') : I18n.t('topic.invite_reply.action');
@ -31,20 +35,23 @@ export default ObjectController.extend(ModalFunctionality, {
return this.get('model') !== Discourse.User.current(); return this.get('model') !== Discourse.User.current();
}.property('model'), }.property('model'),
topicId: Ember.computed.alias('model.id'),
// Is Private Topic? (i.e. visible only to specific group members) // Is Private Topic? (i.e. visible only to specific group members)
isPrivateTopic: Em.computed.and('invitingToTopic', 'model.category.read_restricted'), isPrivateTopic: Em.computed.and('invitingToTopic', 'model.category.read_restricted'),
// Is Private Message?
isMessage: Em.computed.equal('model.archetype', 'private_message'), isMessage: Em.computed.equal('model.archetype', 'private_message'),
// Allow Existing Members? (username autocomplete) // Allow Existing Members? (username autocomplete)
allowExistingMembers: function() { allowExistingMembers: function() {
return this.get('invitingToTopic') && !this.get('isPrivateTopic'); return this.get('invitingToTopic');
}.property('invitingToTopic', 'isPrivateTopic'), }.property('invitingToTopic'),
// Show Groups? (add invited user to private group) // Show Groups? (add invited user to private group)
showGroups: function() { showGroups: function() {
return this.get('isAdmin') && (Discourse.Utilities.emailValid(this.get('emailOrUsername')) || this.get('isPrivateTopic') || !this.get('invitingToTopic')) && !Discourse.SiteSettings.enable_sso; return this.get('isAdmin') && (Discourse.Utilities.emailValid(this.get('emailOrUsername')) || this.get('isPrivateTopic') || !this.get('invitingToTopic')) && !Discourse.SiteSettings.enable_sso && !this.get('isMessage');
}.property('isAdmin', 'emailOrUsername', 'isPrivateTopic', 'invitingToTopic'), }.property('isAdmin', 'emailOrUsername', 'isPrivateTopic', 'isMessage', 'invitingToTopic'),
// Instructional text for the modal. // Instructional text for the modal.
inviteInstructions: function() { inviteInstructions: function() {
@ -55,7 +62,12 @@ export default ObjectController.extend(ModalFunctionality, {
// inviting to a message // inviting to a message
return I18n.t('topic.invite_private.email_or_username'); return I18n.t('topic.invite_private.email_or_username');
} else if (this.get('invitingToTopic')) { } else if (this.get('invitingToTopic')) {
// when inviting to topic, display instructions based on provided entity // inviting to a private/public topic
if (this.get('isPrivateTopic') && !this.get('isAdmin')) {
// inviting to a private topic and is not admin
return I18n.t('topic.invite_reply.to_username');
} else {
// when inviting to a topic, display instructions based on provided entity
if (this.blank('emailOrUsername')) { if (this.blank('emailOrUsername')) {
return I18n.t('topic.invite_reply.to_topic_blank'); return I18n.t('topic.invite_reply.to_topic_blank');
} else if (Discourse.Utilities.emailValid(this.get('emailOrUsername'))) { } else if (Discourse.Utilities.emailValid(this.get('emailOrUsername'))) {
@ -63,6 +75,7 @@ export default ObjectController.extend(ModalFunctionality, {
} else { } else {
return I18n.t('topic.invite_reply.to_topic_username'); return I18n.t('topic.invite_reply.to_topic_username');
} }
}
} else { } else {
// inviting to forum // inviting to forum
return I18n.t('topic.invite_reply.to_forum'); return I18n.t('topic.invite_reply.to_forum');

View File

@ -6,7 +6,7 @@ var cache = {},
currentTerm, currentTerm,
oldSearch; oldSearch;
function performSearch(term, topicId, includeGroups, resultsFn) { function performSearch(term, topicId, includeGroups, allowedUsers, resultsFn) {
var cached = cache[term]; var cached = cache[term];
if (cached) { if (cached) {
resultsFn(cached); resultsFn(cached);
@ -17,7 +17,8 @@ function performSearch(term, topicId, includeGroups, resultsFn) {
oldSearch = $.ajax(Discourse.getURL('/users/search/users'), { oldSearch = $.ajax(Discourse.getURL('/users/search/users'), {
data: { term: term, data: { term: term,
topic_id: topicId, topic_id: topicId,
include_groups: includeGroups } include_groups: includeGroups,
topic_allowed_users: allowedUsers }
}); });
var returnVal = CANCELLED_STATUS; var returnVal = CANCELLED_STATUS;
@ -75,6 +76,7 @@ function organizeResults(r, options) {
export default function userSearch(options) { export default function userSearch(options) {
var term = options.term || "", var term = options.term || "",
includeGroups = options.includeGroups, includeGroups = options.includeGroups,
allowedUsers = options.allowedUsers,
topicId = options.topicId; topicId = options.topicId;
@ -101,7 +103,7 @@ export default function userSearch(options) {
resolve(CANCELLED_STATUS); resolve(CANCELLED_STATUS);
}, 5000); }, 5000);
debouncedSearch(term, topicId, includeGroups, function(r) { debouncedSearch(term, topicId, includeGroups, allowedUsers, function(r) {
clearTimeout(clearPromise); clearTimeout(clearPromise);
resolve(organizeResults(r, options)); resolve(organizeResults(r, options));
}); });

View File

@ -10,7 +10,11 @@
{{else}} {{else}}
<label>{{inviteInstructions}}</label> <label>{{inviteInstructions}}</label>
{{#if allowExistingMembers}} {{#if allowExistingMembers}}
{{user-selector single="true" allowAny=true excludeCurrentUser="true" usernames=emailOrUsername includeGroups="true" placeholderKey=placeholderKey}} {{#if isPrivateTopic}}
{{user-selector single="true" allowAny=true excludeCurrentUser="true" usernames=emailOrUsername allowedUsers="true" topicId=topicId placeholderKey=placeholderKey}}
{{else}}
{{user-selector single="true" allowAny=true excludeCurrentUser="true" usernames=emailOrUsername placeholderKey=placeholderKey}}
{{/if}}
{{else}} {{else}}
{{text-field value=emailOrUsername placeholderKey="topic.invite_reply.email_placeholder"}} {{text-field value=emailOrUsername placeholderKey="topic.invite_reply.email_placeholder"}}
{{/if}} {{/if}}

View File

@ -446,8 +446,9 @@ class UsersController < ApplicationController
term = params[:term].to_s.strip term = params[:term].to_s.strip
topic_id = params[:topic_id] topic_id = params[:topic_id]
topic_id = topic_id.to_i if topic_id topic_id = topic_id.to_i if topic_id
topic_allowed_users = params[:topic_allowed_users] || false
results = UserSearch.new(term, topic_id: topic_id, searching_user: current_user).search results = UserSearch.new(term, topic_id: topic_id, topic_allowed_users: topic_allowed_users, searching_user: current_user).search
user_fields = [:username, :upload_avatar_template, :uploaded_avatar_id] user_fields = [:username, :upload_avatar_template, :uploaded_avatar_id]
user_fields << :name if SiteSetting.enable_names? user_fields << :name if SiteSetting.enable_names?

View File

@ -5,6 +5,7 @@ class UserSearch
@term = term @term = term
@term_like = "#{term.downcase}%" @term_like = "#{term.downcase}%"
@topic_id = opts[:topic_id] @topic_id = opts[:topic_id]
@topic_allowed_users = opts[:topic_allowed_users]
@searching_user = opts[:searching_user] @searching_user = opts[:searching_user]
@limit = opts[:limit] || 20 @limit = opts[:limit] || 20
end end
@ -36,6 +37,18 @@ class UserSearch
users = users.not_suspended users = users.not_suspended
end end
# Only show users who have access to private topic
if @topic_id && @topic_allowed_users == "true"
allowed_user_ids = []
topic = Topic.find_by(id: @topic_id)
if topic.category && topic.category.read_restricted
users = users.includes(:secure_categories)
.where("users.admin = TRUE OR categories.id = ?", topic.category.id)
.references(:categories)
end
end
users.order("CASE WHEN last_seen_at IS NULL THEN 0 ELSE 1 END DESC, last_seen_at DESC, username ASC") users.order("CASE WHEN last_seen_at IS NULL THEN 0 ELSE 1 END DESC, last_seen_at DESC, username ASC")
.limit(@limit) .limit(@limit)
end end

View File

@ -1109,6 +1109,7 @@ en:
to_topic_blank: "Enter the username or email address of the person you'd like to invite to this topic." to_topic_blank: "Enter the username or email address of the person you'd like to invite to this topic."
to_topic_email: "You've entered an email address. We'll email an invitation that allows your friend to immediately reply to this topic." to_topic_email: "You've entered an email address. We'll email an invitation that allows your friend to immediately reply to this topic."
to_topic_username: "You've entered a username. We'll send a notification to that user with a link inviting them to this topic." to_topic_username: "You've entered a username. We'll send a notification to that user with a link inviting them to this topic."
to_username: "Enter the username of the person you'd like to invite. We'll send a notification to that user with a link inviting them to this topic."
email_placeholder: 'name@example.com' email_placeholder: 'name@example.com'
success_email: "We mailed out an invitation to <b>{{emailOrUsername}}</b>. We'll notify you when the invitation is redeemed. Check the invitations tab on your user page to keep track of your invites." success_email: "We mailed out an invitation to <b>{{emailOrUsername}}</b>. We'll notify you when the invitation is redeemed. Check the invitations tab on your user page to keep track of your invites."

View File

@ -214,14 +214,12 @@ class Guardian
return false unless ( SiteSetting.enable_local_logins && (!SiteSetting.must_approve_users? || is_staff?) ) return false unless ( SiteSetting.enable_local_logins && (!SiteSetting.must_approve_users? || is_staff?) )
return true if is_admin? return true if is_admin?
return false if ! can_see?(object) return false if ! can_see?(object)
return false if group_ids.present? return false if group_ids.present?
if object.is_a?(Topic) && object.category if object.is_a?(Topic) && object.category
if object.category.groups.any? if object.category.groups.any?
return true if object.category.groups.all? { |g| can_edit_group?(g) } return true if object.category.groups.all? { |g| can_edit_group?(g) }
end end
return false if object.category.read_restricted
end end
user.has_trust_level?(TrustLevel[2]) user.has_trust_level?(TrustLevel[2])

View File

@ -1170,6 +1170,25 @@ describe UsersController do
expect(json["users"].map { |u| u["username"] }).to include(user.username) expect(json["users"].map { |u| u["username"] }).to include(user.username)
end end
it "searches only for users who have access to private topic" do
privileged_user = Fabricate(:user, trust_level: 4, username: "joecabit", name: "Lawrence Tierney")
privileged_group = Fabricate(:group)
privileged_group.add(privileged_user)
privileged_group.save
category = Fabricate(:category)
category.set_permissions(privileged_group => :readonly)
category.save
private_topic = Fabricate(:topic, category: category)
xhr :post, :search_users, term: user.name.split(" ").last, topic_id: private_topic.id, topic_allowed_users: "true"
expect(response).to be_success
json = JSON.parse(response.body)
expect(json["users"].map { |u| u["username"] }).to_not include(user.username)
expect(json["users"].map { |u| u["username"] }).to include(privileged_user.username)
end
context "when `enable_names` is true" do context "when `enable_names` is true" do
before do before do
SiteSetting.enable_names = true SiteSetting.enable_names = true