diff --git a/app/assets/javascripts/discourse/components/small-action.js.es6 b/app/assets/javascripts/discourse/components/small-action.js.es6 index b66024987eb..8a17c41031c 100644 --- a/app/assets/javascripts/discourse/components/small-action.js.es6 +++ b/app/assets/javascripts/discourse/components/small-action.js.es6 @@ -3,7 +3,15 @@ import { autoUpdatingRelativeAge } from 'discourse/lib/formatter'; export function actionDescriptionHtml(actionCode, createdAt, username) { const dt = new Date(createdAt); const when = autoUpdatingRelativeAge(dt, { format: 'medium-with-ago' }); - const who = username ? `@${username}` : ""; + + var who = ""; + if (username) { + if (actionCode === "invited_group" || actionCode === "removed_group") { + who = `@${username}`; + } else { + who = `@${username}`; + } + } return I18n.t(`action_codes.${actionCode}`, { who, when }).htmlSafe(); } diff --git a/app/assets/javascripts/discourse/components/user-selector.js.es6 b/app/assets/javascripts/discourse/components/user-selector.js.es6 index aa1c4b7e81c..ab695e8b2b0 100644 --- a/app/assets/javascripts/discourse/components/user-selector.js.es6 +++ b/app/assets/javascripts/discourse/components/user-selector.js.es6 @@ -13,10 +13,13 @@ export default TextField.extend({ allowedUsers = this.get('allowedUsers') === 'true'; function excludedUsernames() { + // hack works around some issues with allowAny eventing + const usernames = self.get('single') ? [] : selected; + if (currentUser && self.get('excludeCurrentUser')) { - return selected.concat([currentUser.get('username')]); + return usernames.concat([currentUser.get('username')]); } - return selected; + return usernames; } this.$().val(this.get('usernames')).autocomplete({ diff --git a/app/assets/javascripts/discourse/controllers/invite.js.es6 b/app/assets/javascripts/discourse/controllers/invite.js.es6 index c00311a9f9f..6747f1449c3 100644 --- a/app/assets/javascripts/discourse/controllers/invite.js.es6 +++ b/app/assets/javascripts/discourse/controllers/invite.js.es6 @@ -121,6 +121,8 @@ export default Ember.Controller.extend(ModalFunctionality, { successMessage: function() { if (this.get('model.inviteLink')) { return I18n.t('user.invited.generated_link_message', {inviteLink: this.get('model.inviteLink'), invitedEmail: this.get('emailOrUsername')}); + } else if (this.get('hasGroups')) { + return I18n.t('topic.invite_private.success_group'); } else if (this.get('isMessage')) { return I18n.t('topic.invite_private.success'); } else if ( Discourse.Utilities.emailValid(this.get('emailOrUsername')) ) { @@ -171,7 +173,23 @@ export default Ember.Controller.extend(ModalFunctionality, { model.setProperties({ saving: true, error: false }); - return this.get('model').createInvite(this.get('emailOrUsername').trim(), groupNames, this.get('customMessage')).then(result => { + const onerror = function(e) { + if (e.jqXHR.responseJSON && e.jqXHR.responseJSON.errors) { + self.set("errorMessage", e.jqXHR.responseJSON.errors[0]); + } else { + self.set("errorMessage", self.get('isMessage') ? I18n.t('topic.invite_private.error') : I18n.t('topic.invite_reply.error')); + } + model.setProperties({ saving: false, error: true }); + }; + + if (this.get('hasGroups')) { + return this.get('model').createGroupInvite(this.get('emailOrUsername').trim()).then(result => { + model.setProperties({ saving: false, finished: true }); + }).catch(onerror); + + } else { + + return this.get('model').createInvite(this.get('emailOrUsername').trim(), groupNames, this.get('customMessage')).then(result => { model.setProperties({ saving: false, finished: true }); if (!this.get('invitingToTopic')) { Invite.findInvitedBy(this.currentUser, userInvitedController.get('filter')).then(invite_model => { @@ -181,14 +199,8 @@ export default Ember.Controller.extend(ModalFunctionality, { } else if (this.get('isMessage') && result && result.user) { this.get('model.details.allowed_users').pushObject(Ember.Object.create(result.user)); } - }).catch(function(e) { - if (e.jqXHR.responseJSON && e.jqXHR.responseJSON.errors) { - self.set("errorMessage", e.jqXHR.responseJSON.errors[0]); - } else { - self.set("errorMessage", self.get('isMessage') ? I18n.t('topic.invite_private.error') : I18n.t('topic.invite_reply.error')); - } - model.setProperties({ saving: false, error: true }); - }); + }).catch(onerror); + } }, generateInvitelink() { diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index 2c6d56a021f..0062c13bda5 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -256,6 +256,10 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, { return this.get('model.details').removeAllowedUser(user); }, + removeAllowedGroup(group) { + return this.get('model.details').removeAllowedGroup(group); + }, + deleteTopic() { this.deleteTopic(); }, diff --git a/app/assets/javascripts/discourse/models/topic-details.js.es6 b/app/assets/javascripts/discourse/models/topic-details.js.es6 index 3be04f6ec31..833af1e1988 100644 --- a/app/assets/javascripts/discourse/models/topic-details.js.es6 +++ b/app/assets/javascripts/discourse/models/topic-details.js.es6 @@ -63,6 +63,18 @@ const TopicDetails = RestModel.extend({ }); }, + removeAllowedGroup(group) { + const groups = this.get('allowed_groups'); + const name = group.name; + + return Discourse.ajax("/t/" + this.get('topic.id') + "/remove-allowed-group", { + type: 'PUT', + data: { name: name } + }).then(() => { + groups.removeObject(groups.findProperty('name', name)); + }); + }, + removeAllowedUser(user) { const users = this.get('allowed_users'); const username = user.get('username'); diff --git a/app/assets/javascripts/discourse/models/topic.js.es6 b/app/assets/javascripts/discourse/models/topic.js.es6 index 8e8fd7c8ef4..53cf24f2a8a 100644 --- a/app/assets/javascripts/discourse/models/topic.js.es6 +++ b/app/assets/javascripts/discourse/models/topic.js.es6 @@ -309,6 +309,13 @@ const Topic = RestModel.extend({ }); }, + createGroupInvite(group) { + return Discourse.ajax("/t/" + this.get('id') + "/invite-group", { + type: 'POST', + data: { group } + }); + }, + createInvite(user, group_names, custom_message) { return Discourse.ajax("/t/" + this.get('id') + "/invite", { type: 'POST', diff --git a/app/assets/javascripts/discourse/templates/modal/invite.hbs b/app/assets/javascripts/discourse/templates/modal/invite.hbs index 98b1eb7890d..e7215807c08 100644 --- a/app/assets/javascripts/discourse/templates/modal/invite.hbs +++ b/app/assets/javascripts/discourse/templates/modal/invite.hbs @@ -11,9 +11,22 @@ {{#if allowExistingMembers}} {{#if isPrivateTopic}} - {{user-selector single="true" allowAny=true excludeCurrentUser="true" usernames=emailOrUsername allowedUsers="true" topicId=topicId placeholderKey=placeholderKey}} + {{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}} + {{user-selector + single="true" + allowAny=true + excludeCurrentUser="true" + includeMentionableGroups="true" + hasGroups=hasGroups + usernames=emailOrUsername + placeholderKey=placeholderKey}} {{/if}} {{else}} {{text-field value=emailOrUsername placeholderKey="topic.invite_reply.email_placeholder"}} diff --git a/app/assets/javascripts/discourse/templates/topic.hbs b/app/assets/javascripts/discourse/templates/topic.hbs index a173827ec85..086fc13642f 100644 --- a/app/assets/javascripts/discourse/templates/topic.hbs +++ b/app/assets/javascripts/discourse/templates/topic.hbs @@ -113,6 +113,7 @@ toggleWiki="toggleWiki" toggleSummary="toggleSummary" removeAllowedUser="removeAllowedUser" + removeAllowedGroup="removeAllowedGroup" showInvite="showInvite" topVisibleChanged="topVisibleChanged" currentPostChanged="currentPostChanged" diff --git a/app/assets/javascripts/discourse/widgets/post-small-action.js.es6 b/app/assets/javascripts/discourse/widgets/post-small-action.js.es6 index 4f56c01ddb5..882cbc9746b 100644 --- a/app/assets/javascripts/discourse/widgets/post-small-action.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post-small-action.js.es6 @@ -20,7 +20,9 @@ const icons = { 'visible.disabled': 'eye-slash', 'split_topic': 'sign-out', 'invited_user': 'plus-circle', + 'invited_group': 'plus-circle', 'removed_user': 'minus-circle', + 'removed_group': 'minus-circle', 'public_topic': 'comment', 'private_topic': 'envelope' }; diff --git a/app/assets/javascripts/discourse/widgets/private-message-map.js.es6 b/app/assets/javascripts/discourse/widgets/private-message-map.js.es6 index 7d19c8ed4e1..7d311b70b3a 100644 --- a/app/assets/javascripts/discourse/widgets/private-message-map.js.es6 +++ b/app/assets/javascripts/discourse/widgets/private-message-map.js.es6 @@ -3,12 +3,33 @@ import { createWidget } from 'discourse/widgets/widget'; import { h } from 'virtual-dom'; import { avatarFor } from 'discourse/widgets/post'; +createWidget('pm-remove-group-link', { + tagName: 'a.remove-invited', + + html() { + return iconNode('times'); + }, + + click() { + bootbox.confirm(I18n.t("private_message_info.remove_allowed_group", {name: this.attrs.name}), (confirmed) => { + if (confirmed) { this.sendWidgetAction('removeAllowedGroup', this.attrs); } + }); + } +}); + createWidget('pm-map-user-group', { tagName: 'div.user.group', html(attrs) { - const link = h('a', { attributes: { href: Discourse.getURL(`/groups/${attrs.name}`) } }, attrs.name); - return [iconNode('users'), ' ', link]; + const link = h('a', { attributes: { href: Discourse.getURL(`/groups/${attrs.group.name}`) } }, attrs.group.name); + const result = [iconNode('users'), ' ', link]; + + if (attrs.canRemoveAllowedUsers) { + result.push(' '); + result.push(this.attach('pm-remove-group-link', attrs.group)); + } + + return result; } }); @@ -51,12 +72,12 @@ export default createWidget('private-message-map', { const participants = []; if (attrs.allowedGroups.length) { - participants.push(attrs.allowedGroups.map(ag => this.attach('pm-map-user-group', ag))); + participants.push(attrs.allowedGroups.map(ag => this.attach('pm-map-user-group', {group: ag, canRemoveAllowedUsers: attrs.canRemoveAllowedUsers}))); } if (attrs.allowedUsers.length) { - participants.push(attrs.allowedUsers.map(ag => { - return this.attach('pm-map-user', { user: ag, canRemoveAllowedUsers: attrs.canRemoveAllowedUsers }); + participants.push(attrs.allowedUsers.map(au => { + return this.attach('pm-map-user', { user: au, canRemoveAllowedUsers: attrs.canRemoveAllowedUsers }); })); } diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 3ffc9a86a56..24f7626e3e2 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -375,6 +375,34 @@ class TopicsController < ApplicationController end end + def remove_allowed_group + params.require(:name) + topic = Topic.find_by(id: params[:topic_id]) + guardian.ensure_can_remove_allowed_users!(topic) + + if topic.remove_allowed_group(current_user, params[:name]) + render json: success_json + else + render json: failed_json, status: 422 + end + end + + def invite_group + group = Group.find_by(name: params[:group]) + raise Discourse::NotFound unless group + + topic = Topic.find_by(id: params[:topic_id]) + + if topic.private_message? + guardian.ensure_can_send_private_message!(group) + topic.invite_group(current_user, group) + + render json: success_json + else + render json: failed_json, status: 422 + end + end + def invite username_or_email = params[:user] ? fetch_username : fetch_email diff --git a/app/models/topic.rb b/app/models/topic.rb index 77f2e0afd75..e793f7c7422 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -577,6 +577,19 @@ class Topic < ActiveRecord::Base changed_to_category(cat) end + def remove_allowed_group(removed_by, name) + if group = Group.find_by(name: name) + group_user = topic_allowed_groups.find_by(group_id: group.id) + if group_user + group_user.destroy + add_small_action(removed_by, "removed_group", group.name) + return true + end + end + + false + end + def remove_allowed_user(removed_by, username) if user = User.find_by(username: username) topic_user = topic_allowed_users.find_by(user_id: user.id) @@ -590,6 +603,19 @@ class Topic < ActiveRecord::Base false end + def invite_group(user, group) + TopicAllowedGroup.create!(topic_id: id, group_id: group.id) + + last_post = posts.order('post_number desc').where('not hidden AND posts.deleted_at IS NULL').first + if last_post + # ensure all the notifications are out + PostAlerter.new.after_save_post(last_post) + add_small_action(user, "invited_group", group.name) + end + + true + end + # Invite a user to the topic by username or email. Returns success/failure def invite(invited_by, username_or_email, group_ids=nil, custom_message=nil) if private_message? diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index e53038c639a..3d142ef2c0e 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -144,7 +144,9 @@ en: private_topic: "made this topic private %{when}" split_topic: "split this topic %{when}" invited_user: "invited %{who} %{when}" + invited_group: "invited %{who} %{when}" removed_user: "removed %{who} %{when}" + removed_group: "removed %{who} %{when}" autoclosed: enabled: 'closed %{when}' disabled: 'opened %{when}' @@ -908,6 +910,7 @@ en: title: "Message" invite: "Invite Others..." remove_allowed_user: "Do you really want to remove {{name}} from this message?" + remove_allowed_group: "Do you really want to remove {{name}} from this message?" email: 'Email' username: 'Username' @@ -1448,6 +1451,7 @@ en: email_or_username_placeholder: "email address or username" action: "Invite" success: "We've invited that user to participate in this message." + success_group: "We've invited that group to participate in this message." error: "Sorry, there was an error inviting that user." group_name: "group name" diff --git a/config/routes.rb b/config/routes.rb index 10694913693..ee654ac8a64 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -547,6 +547,7 @@ Discourse::Application.routes.draw do put "t/:topic_id/make-banner" => "topics#make_banner", constraints: {topic_id: /\d+/} put "t/:topic_id/remove-banner" => "topics#remove_banner", constraints: {topic_id: /\d+/} put "t/:topic_id/remove-allowed-user" => "topics#remove_allowed_user", constraints: {topic_id: /\d+/} + put "t/:topic_id/remove-allowed-group" => "topics#remove_allowed_group", constraints: {topic_id: /\d+/} put "t/:topic_id/recover" => "topics#recover", constraints: {topic_id: /\d+/} get "t/:topic_id/:post_number" => "topics#show", constraints: {topic_id: /\d+/, post_number: /\d+/} get "t/:topic_id/last" => "topics#show", post_number: 99999999, constraints: {topic_id: /\d+/} @@ -557,6 +558,7 @@ Discourse::Application.routes.draw do get "t/:topic_id/posts" => "topics#posts", constraints: {topic_id: /\d+/}, format: :json post "t/:topic_id/timings" => "topics#timings", constraints: {topic_id: /\d+/} post "t/:topic_id/invite" => "topics#invite", constraints: {topic_id: /\d+/} + post "t/:topic_id/invite-group" => "topics#invite_group", constraints: {topic_id: /\d+/} post "t/:topic_id/move-posts" => "topics#move_posts", constraints: {topic_id: /\d+/} post "t/:topic_id/merge-topic" => "topics#merge_topic", constraints: {topic_id: /\d+/} post "t/:topic_id/change-owner" => "topics#change_post_owners", constraints: {topic_id: /\d+/} diff --git a/spec/controllers/topics_controller_spec.rb b/spec/controllers/topics_controller_spec.rb index e66a8085c5e..ecfd606346e 100644 --- a/spec/controllers/topics_controller_spec.rb +++ b/spec/controllers/topics_controller_spec.rb @@ -950,6 +950,35 @@ describe TopicsController do end end + describe 'invite_group' do + let :admins do + Group[:admins] + end + + let! :admin do + log_in :admin + end + + before do + admins.alias_level = Group::ALIAS_LEVELS[:everyone] + admins.save! + end + + it "disallows inviting a group to a topic" do + topic = Fabricate(:topic) + xhr :post, :invite_group, topic_id: topic.id, group: 'admins' + expect(response.status).to eq(422) + end + + it "allows inviting a group to a PM" do + topic = Fabricate(:private_message_topic) + xhr :post, :invite_group, topic_id: topic.id, group: 'admins' + + expect(response.status).to eq(200) + expect(topic.allowed_groups.first.id).to eq(admins.id) + end + end + describe 'invite' do describe "group invites" do diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index e1f48980ffd..75fc7beb581 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -372,6 +372,25 @@ describe Topic do context 'existing user' do let(:walter) { Fabricate(:walter_white) } + context 'by group name' do + + it 'can add admin to allowed groups' do + admins = Group[:admins] + admins.alias_level = Group::ALIAS_LEVELS[:everyone] + admins.save + + expect(topic.invite_group(topic.user, admins)).to eq(true) + + expect(topic.allowed_groups.include?(admins)).to eq(true) + + expect(topic.remove_allowed_group(topic.user, 'admins')).to eq(true) + topic.reload + + expect(topic.allowed_groups.include?(admins)).to eq(false) + end + + end + context 'by username' do it 'adds and removes walter to the allowed users' do