diff --git a/app/assets/javascripts/discourse/app/controllers/group-add-members.js b/app/assets/javascripts/discourse/app/controllers/group-add-members.js index 0637ac79939..283a9753f7c 100644 --- a/app/assets/javascripts/discourse/app/controllers/group-add-members.js +++ b/app/assets/javascripts/discourse/app/controllers/group-add-members.js @@ -4,42 +4,101 @@ import Controller from "@ember/controller"; import { extractError } from "discourse/lib/ajax-error"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import { action } from "@ember/object"; +import { emailValid } from "discourse/lib/utilities"; +import I18n from "I18n"; export default Controller.extend(ModalFunctionality, { loading: false, setAsOwner: false, + notifyUsers: false, + usernamesAndEmails: null, + usernames: null, + emails: null, - @discourseComputed("model.usernames", "loading") - disableAddButton(usernames, loading) { - return loading || !usernames || !(usernames.length > 0); + onShow() { + this.setProperties({ + usernamesAndEmails: "", + usernames: [], + emails: [], + setAsOwner: false, + notifyUsers: false + }); + }, + + @discourseComputed("usernamesAndEmails", "loading") + disableAddButton(usernamesAndEmails, loading) { + return loading || !usernamesAndEmails || !(usernamesAndEmails.length > 0); + }, + + @discourseComputed("usernamesAndEmails") + emailsPresent() { + this._splitEmailsAndUsernames(); + return this.emails.length; + }, + + @discourseComputed("usernamesAndEmails") + notifyUsersDisabled() { + this._splitEmailsAndUsernames(); + return this.usernames.length === 0 && this.emails.length > 0; + }, + + @discourseComputed("model.name", "model.full_name") + title(name, fullName) { + return I18n.t("groups.add_members.title", { group_name: fullName || name }); }, @action addMembers() { this.set("loading", true); - const usernames = this.model.usernames; - if (isEmpty(usernames)) { + if (this.emailsPresent) { + this.set("setAsOwner", false); + } + + if (this.notifyUsersDisabled) { + this.set("notifyUsers", false); + } + + if (isEmpty(this.usernamesAndEmails)) { return; } - let promise; - if (this.setAsOwner) { - promise = this.model.addOwners(usernames, true); - } else { - promise = this.model.addMembers(usernames, true); - } + const promise = this.setAsOwner + ? this.model.addOwners(this.usernames, true, this.notifyUsers) + : this.model.addMembers( + this.usernames, + true, + this.notifyUsers, + this.emails + ); promise .then(() => { + let queryParams = {}; + + if (this.usernames) { + queryParams.filter = this.usernames; + } + this.transitionToRoute("group.members", this.get("model.name"), { - queryParams: { filter: usernames } + queryParams }); - this.model.set("usernames", null); this.send("closeModal"); }) .catch(error => this.flash(extractError(error), "error")) .finally(() => this.set("loading", false)); + }, + + _splitEmailsAndUsernames() { + let emails = []; + let usernames = []; + + this.usernamesAndEmails.split(",").forEach(u => { + emailValid(u) ? emails.push(u) : usernames.push(u); + }); + + this.set("emails", emails.join(",")); + this.set("usernames", usernames.join(",")); } }); diff --git a/app/assets/javascripts/discourse/app/controllers/group-bulk-add.js b/app/assets/javascripts/discourse/app/controllers/group-bulk-add.js deleted file mode 100644 index cf4f86916cf..00000000000 --- a/app/assets/javascripts/discourse/app/controllers/group-bulk-add.js +++ /dev/null @@ -1,51 +0,0 @@ -import discourseComputed from "discourse-common/utils/decorators"; -import { isEmpty } from "@ember/utils"; -import Controller from "@ember/controller"; -import { extractError } from "discourse/lib/ajax-error"; -import ModalFunctionality from "discourse/mixins/modal-functionality"; -import { ajax } from "discourse/lib/ajax"; - -export default Controller.extend(ModalFunctionality, { - loading: false, - - @discourseComputed("input", "loading", "result") - disableAddButton(input, loading, result) { - return loading || isEmpty(input) || input.length <= 0 || result; - }, - - actions: { - cancel() { - this.set("result", null); - }, - - add() { - this.setProperties({ - loading: true, - result: null - }); - - const users = this.input - .split("\n") - .uniq() - .reject(x => x.length === 0); - - ajax("/admin/groups/bulk", { - data: { users, group_id: this.get("model.id") }, - type: "PUT" - }) - .then(result => { - this.set("result", result); - - if (result.users_not_added) { - this.set("result.invalidUsers", result.users_not_added.join(", ")); - } - }) - .catch(error => { - this.flash(extractError(error), "error"); - }) - .finally(() => { - this.set("loading", false); - }); - } - } -}); diff --git a/app/assets/javascripts/discourse/app/models/group.js b/app/assets/javascripts/discourse/app/models/group.js index af514bda0a4..889272fe30a 100644 --- a/app/assets/javascripts/discourse/app/models/group.js +++ b/app/assets/javascripts/discourse/app/models/group.js @@ -113,10 +113,10 @@ const Group = RestModel.extend({ }).then(() => this.findMembers(params, true)); }, - addMembers(usernames, filter) { + addMembers(usernames, filter, notifyUsers, emails = []) { return ajax(`/groups/${this.id}/members.json`, { type: "PUT", - data: { usernames } + data: { usernames, emails, notify_users: notifyUsers } }).then(response => { if (filter) { this._filterMembers(response); @@ -126,10 +126,10 @@ const Group = RestModel.extend({ }); }, - addOwners(usernames, filter) { + addOwners(usernames, filter, notifyUsers) { return ajax(`/admin/groups/${this.id}/owners.json`, { type: "PUT", - data: { group: { usernames } } + data: { group: { usernames, notify_users: notifyUsers } } }).then(response => { if (filter) { this._filterMembers(response); diff --git a/app/assets/javascripts/discourse/app/routes/group-index.js b/app/assets/javascripts/discourse/app/routes/group-index.js index cdd8310709b..a67229c893b 100644 --- a/app/assets/javascripts/discourse/app/routes/group-index.js +++ b/app/assets/javascripts/discourse/app/routes/group-index.js @@ -28,11 +28,6 @@ export default DiscourseRoute.extend({ showModal("group-add-members", { model: this.modelFor("group") }); }, - @action - showBulkAddModal() { - showModal("group-bulk-add", { model: this.modelFor("group") }); - }, - @action didTransition() { this.controllerFor("group-index").set("filterInput", this._params.filter); diff --git a/app/assets/javascripts/discourse/app/templates/group-index.hbs b/app/assets/javascripts/discourse/app/templates/group-index.hbs index 07d46d374b1..c21bc1b5abe 100644 --- a/app/assets/javascripts/discourse/app/templates/group-index.hbs +++ b/app/assets/javascripts/discourse/app/templates/group-index.hbs @@ -13,14 +13,8 @@ {{#if canManageGroup}} {{d-button icon="plus" action=(route-action "showAddMembersModal") - label="groups.add_members.title" + label="groups.manage.add_members" class="group-members-add"}} - {{#if currentUser.admin}} - {{d-button icon="plus" - action=(route-action "showBulkAddModal") - label="admin.groups.bulk_add.title" - class="group-bulk-add"}} - {{/if}} {{/if}} diff --git a/app/assets/javascripts/discourse/app/templates/mobile/group-index.hbs b/app/assets/javascripts/discourse/app/templates/mobile/group-index.hbs index 7ec577917c9..0df396bd288 100644 --- a/app/assets/javascripts/discourse/app/templates/mobile/group-index.hbs +++ b/app/assets/javascripts/discourse/app/templates/mobile/group-index.hbs @@ -16,7 +16,7 @@ {{else}} {{d-button icon="plus" - label="groups.add_members.title" + label="groups.manage.add_members" class="group-members-add" action=(route-action "showAddMembersModal") }} diff --git a/app/assets/javascripts/discourse/app/templates/modal/group-add-members.hbs b/app/assets/javascripts/discourse/app/templates/modal/group-add-members.hbs index ae13193ea11..4d07bdbe368 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/group-add-members.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal/group-add-members.hbs @@ -1,14 +1,18 @@ -{{#d-modal-body title="groups.add_members.title"}} +{{#d-modal-body rawTitle=title}}
+

+ {{i18n "groups.add_members.description"}} +

{{user-selector class="input-xxlarge" - usernames=model.usernames - placeholderKey="groups.selector_placeholder" + usernames=usernamesAndEmails + allowEmails=true + placeholderKey="groups.add_members.input_placeholder" id="group-add-members-user-selector"}}
@@ -18,11 +22,21 @@ {{input type="checkbox" class="inline" checked=setAsOwner - disabled=bulkAdd}} + disabled=emailsPresent}} {{i18n "admin.groups.add_members.as_owner"}} {{/if}} + +
+ +
{{/d-modal-body}} diff --git a/app/assets/javascripts/discourse/app/templates/modal/group-bulk-add.hbs b/app/assets/javascripts/discourse/app/templates/modal/group-bulk-add.hbs deleted file mode 100644 index 7e47e247dfc..00000000000 --- a/app/assets/javascripts/discourse/app/templates/modal/group-bulk-add.hbs +++ /dev/null @@ -1,38 +0,0 @@ -{{#d-modal-body title="admin.groups.bulk_add.title"}} - {{#if result}} - {{#if result.message}} -
- {{result.message}} -
- {{/if}} - - {{#if result.invalidUsers}} -
- {{i18n "admin.groups.bulk_add.complete_users_not_added"}} - {{result.invalidUsers}} -
- {{/if}} - -
- {{i18n "cancel"}} -
- {{else}} -
-
- - - {{textarea value=input}} -
-
- {{/if}} -{{/d-modal-body}} - - diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index aa5ddd5d59f..1eabd40e785 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -99,6 +99,10 @@ class Admin::GroupsController < Admin::AdminController end group.group_users.where(user_id: user.id).update_all(owner: true) group_action_logger.log_make_user_group_owner(user) + + if group_params[:notify_users] == "true" || group_params[:notify_users] == true + group.notify_added_to_group(user, owner: true) + end end group.restore_user_count! @@ -176,7 +180,8 @@ class Admin::GroupsController < Admin::AdminController :membership_request_template, :owner_usernames, :usernames, - :publish_read_state + :publish_read_state, + :notify_users ] custom_fields = DiscoursePluginRegistry.editable_group_custom_fields permitted << { custom_fields: custom_fields } unless custom_fields.blank? diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index b15dc18a061..3e4ecdb1fb9 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -298,8 +298,7 @@ class GroupsController < ApplicationController def add_members group = Group.find(params[:id]) group.public_admission ? ensure_logged_in : guardian.ensure_can_edit!(group) - - users = users_from_params + users = users_from_params.to_a if group.public_admission if !guardian.can_log_group_changes?(group) && current_user != users.first @@ -311,17 +310,33 @@ class GroupsController < ApplicationController end end - if (usernames = group.users.where(id: users.pluck(:id)).pluck(:username)).present? + emails = [] + if params[:emails] + params[:emails].split(",").each do |email| + existing_user = User.find_by_email(email) + existing_user.present? ? users.push(existing_user) : emails.push(email) + end + end + + if users.empty? && emails.empty? + raise Discourse::InvalidParameters.new( + 'usernames or emails must be present' + ) + end + + if (usernames = group.users.where(id: users.map(&:id)).pluck(:username)).present? render_json_error(I18n.t( "groups.errors.member_already_exist", username: usernames.sort.join(", "), count: usernames.size )) else - users.each do |user| + uniq_users = users.uniq + uniq_users.each do |user| begin group.add(user) GroupActionLogger.new(current_user, group).log_add_user_to_group(user) + group.notify_added_to_group(user) if params[:notify_users]&.to_s == "true" rescue ActiveRecord::RecordNotUnique # Under concurrency, we might attempt to insert two records quickly and hit a DB # constraint. In this case we can safely ignore the error and act as if the user @@ -329,8 +344,13 @@ class GroupsController < ApplicationController end end + emails.each do |email| + Invite.invite_by_email(email, current_user, nil, [group.id]) + end + render json: success_json.merge!( - usernames: users.map(&:username) + usernames: uniq_users.map(&:username), + emails: emails ) end end @@ -401,6 +421,9 @@ class GroupsController < ApplicationController params[:user_emails] = params[:user_email] if params[:user_email].present? users = users_from_params + raise Discourse::InvalidParameters.new( + 'user_ids or usernames or user_emails must be present' + ) if users.empty? if group.public_exit if !guardian.can_log_group_changes?(group) && current_user != users.first @@ -605,9 +628,7 @@ class GroupsController < ApplicationController users = User.with_email(params[:user_emails].split(",")) raise Discourse::InvalidParameters.new(:user_emails) if users.blank? else - raise Discourse::InvalidParameters.new( - 'user_ids or usernames or user_emails must be present' - ) + users = [] end users end diff --git a/app/models/group.rb b/app/models/group.rb index ded3acbe37f..ce3dd234ffb 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -800,6 +800,16 @@ class Group < ActiveRecord::Base end end + def notify_added_to_group(user, owner: false) + SystemMessage.create_from_system_user( + user, + :user_added_to_group, + group_name: self.full_name.presence || self.name, + group_path: "/g/#{self.name}", + membership_level: owner ? "an owner" : "a member" + ) + end + protected def name_format_validator diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 0fdccece691..4ec15414a52 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -626,9 +626,11 @@ en: member_added: "Added" member_requested: "Requested at" add_members: - title: "Add Members" - description: "Manage the membership of this group" - usernames: "Usernames" + title: "Add members to %{group_name}" + description: "You can also paste in a comma separated list." + usernames: "Enter usernames or email addresses" + input_placeholder: "Usernames or emails" + notify_users: "Notify users" requests: title: "Requests" reason: "Reason" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 9bf1ce3b970..129d66c64d6 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -2912,6 +2912,11 @@ en: ``` text %{logs} ``` + user_added_to_group: + title: "Added to Group" + subject_template: "You have been added as %{membership_level} of the %{group_name} group" + text_body_template: | + You have been added as a %{membership_level} of the [%{group_name}](%{base_url}%{group_path}) group. csv_export_succeeded: title: "CSV Export Succeeded" diff --git a/spec/requests/admin/groups_controller_spec.rb b/spec/requests/admin/groups_controller_spec.rb index c8cedd06bac..d51f3e74408 100644 --- a/spec/requests/admin/groups_controller_spec.rb +++ b/spec/requests/admin/groups_controller_spec.rb @@ -122,6 +122,32 @@ RSpec.describe Admin::GroupsController do expect(response.status).to eq(422) expect(response.parsed_body["errors"]).to eq(["You cannot modify an automatic group"]) end + + it 'does not notify users when the param is not present' do + put "/admin/groups/#{group.id}/owners.json", params: { + group: { + usernames: user.username + } + } + expect(response.status).to eq(200) + + topic = Topic.find_by(title: "You have been added as an owner of the #{group.name} group", archetype: "private_message") + expect(topic.nil?).to eq(true) + end + + it 'notifies users when the param is present' do + put "/admin/groups/#{group.id}/owners.json", params: { + group: { + usernames: user.username, + notify_users: true + } + } + expect(response.status).to eq(200) + + topic = Topic.find_by(title: "You have been added as an owner of the #{group.name} group", archetype: "private_message") + expect(topic.nil?).to eq(false) + expect(topic.topic_users.map(&:user_id)).to include(-1, user.id) + end end describe '#remove_owner' do diff --git a/spec/requests/groups_controller_spec.rb b/spec/requests/groups_controller_spec.rb index ba268e505ef..0129bef8592 100644 --- a/spec/requests/groups_controller_spec.rb +++ b/spec/requests/groups_controller_spec.rb @@ -1053,6 +1053,28 @@ describe GroupsController do expect(response.status).to eq(403) end + it "does not notify users when the param is not present" do + user2 = Fabricate(:user) + + expect { + put "/groups/#{group.id}/members.json", params: { usernames: user2.username } + }.to change { Topic.where(archetype: "private_message").count }.by(0) + + expect(response.status).to eq(200) + end + + it "notifies users when the param is present" do + user2 = Fabricate(:user) + + expect { + put "/groups/#{group.id}/members.json", params: { usernames: user2.username, notify_users: true } + }.to change { Topic.where(archetype: "private_message").count }.by(1) + + expect(response.status).to eq(200) + + expect(Topic.last.topic_users.map(&:user_id)).to include(Discourse::SYSTEM_USER_ID, user2.id) + end + context "is able to add several members to a group" do fab!(:user1) { Fabricate(:user) } fab!(:user2) { Fabricate(:user, username: "UsEr2") } @@ -1132,6 +1154,53 @@ describe GroupsController do end end + it "return a 400 if no user or emails are present" do + [ + { usernames: "nouserwiththisusername", emails: "" }, + { usernames: "", emails: "" } + ].each do |params| + put "/groups/#{group.id}/members.json", params: params + expect(response.status).to eq(400) + body = response.parsed_body + + expect(body["error_type"]).to eq("invalid_parameters") + end + end + + it "will send invites to each email with group_id set" do + emails = ["something@gmail.com", "anotherone@yahoo.com"] + put "/groups/#{group.id}/members.json", params: { emails: emails.join(",") } + + expect(response.status).to eq(200) + body = response.parsed_body + + expect(body["emails"]).to eq(emails) + + emails.each do |email| + invite = Invite.find_by(email: email) + expect(invite.groups).to eq([group]) + end + end + + it "will find users by email, and invite the correct user" do + new_user = Fabricate(:user) + expect(new_user.group_ids.include?(group.id)).to eq(false) + + put "/groups/#{group.id}/members.json", params: { emails: new_user.email } + + expect(new_user.reload.group_ids.include?(group.id)).to eq(true) + end + + it "will invite the user if their username and email are both invited" do + new_user = Fabricate(:user) + put "/groups/#{group.id}/members.json", params: { usernames: new_user.username, emails: new_user.email } + + expect(response.status).to eq(200) + body = response.parsed_body + + expect(new_user.reload.group_ids.include?(group.id)).to eq(true) + end + context 'public group' do fab!(:other_user) { Fabricate(:user) }