From ff3caad4caf46049c8f202849a05fb15a2aaccb8 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 14:39:10 +0200 Subject: [PATCH 01/25] Rename manage_group ability to admin_group for consistency with project. --- app/controllers/groups/application_controller.rb | 2 +- app/controllers/groups/milestones_controller.rb | 2 +- app/models/ability.rb | 12 ++++++------ app/views/dashboard/groups/index.html.haml | 2 +- app/views/groups/group_members/index.html.haml | 2 +- app/views/groups/milestones/_milestone.html.haml | 2 +- app/views/groups/milestones/show.html.haml | 2 +- app/views/groups/projects.html.haml | 2 +- app/views/layouts/nav/_group.html.haml | 2 +- lib/api/group_members.rb | 6 +++--- lib/api/groups.rb | 2 +- 11 files changed, 18 insertions(+), 18 deletions(-) diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb index a73b8fa212a..469a6813ee2 100644 --- a/app/controllers/groups/application_controller.rb +++ b/app/controllers/groups/application_controller.rb @@ -13,7 +13,7 @@ class Groups::ApplicationController < ApplicationController end def authorize_admin_group! - unless can?(current_user, :manage_group, group) + unless can?(current_user, :admin_group, group) return render_404 end end diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index c46b8fff88f..546ff2cc71f 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -51,6 +51,6 @@ class Groups::MilestonesController < ApplicationController end def authorize_group_milestone! - return render_404 unless can?(current_user, :manage_group, group) + return render_404 unless can?(current_user, :admin_group, group) end end diff --git a/app/models/ability.rb b/app/models/ability.rb index d2b39f667f2..85a15596f8d 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -198,11 +198,11 @@ class Ability ]) end - # Only group owner and administrators can manage group + # Only group owner and administrators can admin group if group.has_owner?(user) || user.admin? rules.push(*[ - :manage_group, - :manage_namespace + :admin_group, + :admin_namespace ]) end @@ -212,11 +212,11 @@ class Ability def namespace_abilities(user, namespace) rules = [] - # Only namespace owner and administrators can manage it + # Only namespace owner and administrators can admin it if namespace.owner == user || user.admin? rules.push(*[ :create_projects, - :manage_namespace + :admin_namespace ]) end @@ -254,7 +254,7 @@ class Ability rules = [] target_user = subject.user group = subject.group - can_manage = group_abilities(user, group).include?(:manage_group) + can_manage = group_abilities(user, group).include?(:admin_group) if can_manage && (user != target_user) rules << :modify_group_member rules << :destroy_group_member diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml index 165db214d75..0cb7f764fab 100644 --- a/app/views/dashboard/groups/index.html.haml +++ b/app/views/dashboard/groups/index.html.haml @@ -17,7 +17,7 @@ - group = group_member.group %li .pull-right - - if can?(current_user, :manage_group, group) + - if can?(current_user, :admin_group, group) = link_to edit_group_path(group), class: "btn-sm btn btn-grouped" do %i.fa.fa-cogs Settings diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 0d501fe7bd3..c0c9cd170ad 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -16,7 +16,7 @@ = search_field_tag :search, params[:search], { placeholder: 'Find existing member by name', class: 'form-control search-text-input input-mn-300' } = button_tag 'Search', class: 'btn' - - if current_user && current_user.can?(:manage_group, @group) + - if current_user && current_user.can?(:admin_group, @group) .pull-right = button_tag class: 'btn btn-new js-toggle-button', type: 'button' do Add members diff --git a/app/views/groups/milestones/_milestone.html.haml b/app/views/groups/milestones/_milestone.html.haml index 94fc43a581e..30093d2d05d 100644 --- a/app/views/groups/milestones/_milestone.html.haml +++ b/app/views/groups/milestones/_milestone.html.haml @@ -1,6 +1,6 @@ %li{class: "milestone milestone-#{milestone.closed? ? 'closed' : 'open'}", id: dom_id(milestone.milestones.first) } .pull-right - - if can?(current_user, :manage_group, @group) + - if can?(current_user, :admin_group, @group) - if milestone.closed? = link_to 'Reopen Milestone', group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: {state_event: :activate }), method: :put, class: "btn btn-sm btn-grouped btn-reopen" - else diff --git a/app/views/groups/milestones/show.html.haml b/app/views/groups/milestones/show.html.haml index fea70f5cbc3..fb32f2caa4c 100644 --- a/app/views/groups/milestones/show.html.haml +++ b/app/views/groups/milestones/show.html.haml @@ -6,7 +6,7 @@ Open Milestone #{@group_milestone.title} .pull-right - - if can?(current_user, :manage_group, @group) + - if can?(current_user, :admin_group, @group) - if @group_milestone.active? = link_to 'Close Milestone', group_milestone_path(@group, @group_milestone.safe_title, title: @group_milestone.title, milestone: {state_event: :close }), method: :put, class: "btn btn-sm btn-close" - else diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml index dd1fa3840d5..0d547984cc9 100644 --- a/app/views/groups/projects.html.haml +++ b/app/views/groups/projects.html.haml @@ -2,7 +2,7 @@ .panel-heading %strong= @group.name projects: - - if can? current_user, :manage_group, @group + - if can? current_user, :admin_group, @group .panel-head-actions = link_to new_project_path(namespace_id: @group.id), class: "btn btn-sm btn-success" do %i.fa.fa-plus diff --git a/app/views/layouts/nav/_group.html.haml b/app/views/layouts/nav/_group.html.haml index 32fe0e37df8..f0d92b7a12c 100644 --- a/app/views/layouts/nav/_group.html.haml +++ b/app/views/layouts/nav/_group.html.haml @@ -30,7 +30,7 @@ %span Members - - if can?(current_user, :manage_group, @group) + - if can?(current_user, :admin_group, @group) = nav_link(html_options: { class: "#{"active" if group_settings_page?} separate-item" }) do = link_to edit_group_path(@group), title: 'Settings', class: "tab no-highlight" do %i.fa.fa-cogs diff --git a/lib/api/group_members.rb b/lib/api/group_members.rb index ed54c7f6ff0..ab5b2d6d9a7 100644 --- a/lib/api/group_members.rb +++ b/lib/api/group_members.rb @@ -24,7 +24,7 @@ module API # POST /groups/:id/members post ":id/members" do group = find_group(params[:id]) - authorize! :manage_group, group + authorize! :admin_group, group required_attributes! [:user_id, :access_level] unless validate_access_level?(params[:access_level]) @@ -50,7 +50,7 @@ module API # PUT /groups/:id/members/:user_id put ':id/members/:user_id' do group = find_group(params[:id]) - authorize! :manage_group, group + authorize! :admin_group, group required_attributes! [:access_level] group_member = group.group_members.find_by(user_id: params[:user_id]) @@ -74,7 +74,7 @@ module API # DELETE /groups/:id/members/:user_id delete ":id/members/:user_id" do group = find_group(params[:id]) - authorize! :manage_group, group + authorize! :admin_group, group member = group.group_members.find_by(user_id: params[:user_id]) if member.nil? diff --git a/lib/api/groups.rb b/lib/api/groups.rb index a92abd4b690..8cb9f920975 100644 --- a/lib/api/groups.rb +++ b/lib/api/groups.rb @@ -61,7 +61,7 @@ module API # DELETE /groups/:id delete ":id" do group = find_group(params[:id]) - authorize! :manage_group, group + authorize! :admin_group, group group.destroy end From e6569defe61f114beb051a1ebf8186f03eed1f5c Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 14:40:26 +0200 Subject: [PATCH 02/25] Use through-relation instead of manually mapping. --- lib/api/group_members.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/api/group_members.rb b/lib/api/group_members.rb index ab5b2d6d9a7..3a3987ab36e 100644 --- a/lib/api/group_members.rb +++ b/lib/api/group_members.rb @@ -9,8 +9,7 @@ module API # GET /groups/:id/members get ":id/members" do group = find_group(params[:id]) - members = group.group_members - users = (paginate members).collect(&:user) + users = group.users present users, with: Entities::GroupMember, group: group end From 1e97864958b5ee7cb92a6d45ceab6b6e476c5e7e Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 14:41:21 +0200 Subject: [PATCH 03/25] Add invite data to member. --- .../20150406133311_add_invite_data_to_member.rb | 12 ++++++++++++ db/schema.rb | 7 ++++++- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20150406133311_add_invite_data_to_member.rb diff --git a/db/migrate/20150406133311_add_invite_data_to_member.rb b/db/migrate/20150406133311_add_invite_data_to_member.rb new file mode 100644 index 00000000000..3452fd45c4f --- /dev/null +++ b/db/migrate/20150406133311_add_invite_data_to_member.rb @@ -0,0 +1,12 @@ +class AddInviteDataToMember < ActiveRecord::Migration + def change + add_column :members, :created_by_id, :integer + add_column :members, :invite_email, :string + add_column :members, :invite_token, :string + add_column :members, :invite_accepted_at, :datetime + + change_column :members, :user_id, :integer, null: true + + add_index :members, :invite_token, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 48f1b2ac2cc..11371f168b9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -163,15 +163,20 @@ ActiveRecord::Schema.define(version: 20150411180045) do t.integer "access_level", null: false t.integer "source_id", null: false t.string "source_type", null: false - t.integer "user_id", null: false + t.integer "user_id" t.integer "notification_level", null: false t.string "type" t.datetime "created_at" t.datetime "updated_at" + t.integer "created_by_id" + t.string "invite_email" + t.string "invite_token" + t.datetime "invite_accepted_at" end add_index "members", ["access_level"], name: "index_members_on_access_level", using: :btree add_index "members", ["created_at", "id"], name: "index_members_on_created_at_and_id", using: :btree + add_index "members", ["invite_token"], name: "index_members_on_invite_token", unique: true, using: :btree add_index "members", ["source_id", "source_type"], name: "index_members_on_source_id_and_source_type", using: :btree add_index "members", ["type"], name: "index_members_on_type", using: :btree add_index "members", ["user_id"], name: "index_members_on_user_id", using: :btree From 2b2bd402dcee427a43de771036b348e156fea6c9 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 14:46:09 +0200 Subject: [PATCH 04/25] Track who created a group or project member. --- app/controllers/admin/groups_controller.rb | 2 +- .../groups/group_members_controller.rb | 2 +- .../projects/project_members_controller.rb | 2 +- app/models/group.rb | 16 ++++++----- app/models/member.rb | 5 ++++ app/models/members/project_member.rb | 3 +- app/models/project_team.rb | 28 +++++++++---------- app/services/projects/create_service.rb | 2 +- app/services/projects/fork_service.rb | 2 +- lib/api/group_members.rb | 2 +- spec/requests/api/repositories_spec.rb | 2 -- 11 files changed, 35 insertions(+), 31 deletions(-) diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index 9d9adaa467f..22d045fc388 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -41,7 +41,7 @@ class Admin::GroupsController < Admin::ApplicationController end def members_update - @group.add_users(params[:user_ids].split(','), params[:access_level]) + @group.add_users(params[:user_ids].split(','), params[:access_level], current_user) redirect_to [:admin, @group], notice: 'Users were successfully added.' end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 2df51c97a22..93fc4edb3bb 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -22,7 +22,7 @@ class Groups::GroupMembersController < Groups::ApplicationController end def create - @group.add_users(params[:user_ids].split(','), params[:access_level]) + @group.add_users(params[:user_ids].split(','), params[:access_level], current_user) redirect_to group_group_members_path(@group), notice: 'Users were successfully added.' end diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 4ab15db01f7..e534db7af84 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -69,7 +69,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController def apply_import giver = Project.find(params[:source_project_id]) - status = @project.team.import(giver) + status = @project.team.import(giver, current_user) notice = status ? "Successfully imported" : "Import failed" redirect_to(namespace_project_project_members_path(project.namespace, project), diff --git a/app/models/group.rb b/app/models/group.rb index da9621a2a1a..eec961f6012 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -46,19 +46,21 @@ class Group < Namespace @owners ||= group_members.owners.map(&:user) end - def add_users(user_ids, access_level) + def add_users(user_ids, access_level, current_user = nil) user_ids.compact.each do |user_id| - user = self.group_members.find_or_initialize_by(user_id: user_id) - user.update_attributes(access_level: access_level) + member = self.group_members.find_or_initialize_by(user_id: user_id) + member.access_level = access_level + member.created_by ||= current_user + member.save end end - def add_user(user, access_level) - self.group_members.create(user_id: user.id, access_level: access_level) + def add_user(user, access_level, current_user = nil) + add_users([user], access_level, current_user) end - def add_owner(user) - self.add_user(user, Gitlab::Access::OWNER) + def add_owner(user, current_user = nil) + self.add_user(user, Gitlab::Access::OWNER, current_user) end def has_owner?(user) diff --git a/app/models/member.rb b/app/models/member.rb index fe3d2f40e87..d92e69b2ce6 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -11,6 +11,10 @@ # type :string(255) # created_at :datetime # updated_at :datetime +# created_by_id :integer +# invite_email :string +# invite_token :string +# invite_accepted_at :datetime # class Member < ActiveRecord::Base @@ -18,6 +22,7 @@ class Member < ActiveRecord::Base include Notifiable include Gitlab::Access + belongs_to :created_by, class_name: "User" belongs_to :user belongs_to :source, polymorphic: true diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 6b13e0ff30b..2205041cd51 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -55,7 +55,7 @@ class ProjectMember < Member # :master # ) # - def add_users_into_projects(project_ids, user_ids, access) + def add_users_into_projects(project_ids, user_ids, access, current_user = nil) access_level = if roles_hash.has_key?(access) roles_hash[access] elsif roles_hash.values.include?(access.to_i) @@ -69,6 +69,7 @@ class ProjectMember < Member user_ids.each do |user_id| member = ProjectMember.new(access_level: access_level, user_id: user_id) member.source_id = project_id + member.created_by ||= current_user member.save end end diff --git a/app/models/project_team.rb b/app/models/project_team.rb index d4a07caf9ef..313e459d701 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -12,12 +12,12 @@ class ProjectTeam # @team << [@users, :master] # def <<(args) - users = args.first + users, access, current_user = *args if users.respond_to?(:each) - add_users(users, args.second) + add_users(users, access, current_user) else - add_user(users, args.second) + add_user(users, access, current_user) end end @@ -43,22 +43,19 @@ class ProjectTeam member end - def add_user(user, access) - add_users_ids([user.id], access) - end - - def add_users(users, access) - add_users_ids(users.map(&:id), access) - end - - def add_users_ids(user_ids, access) + def add_users(users, access, current_user = nil) ProjectMember.add_users_into_projects( [project.id], - user_ids, - access + users, + access, + current_user ) end + def add_user(user, access, current_user = nil) + add_users([user], access, current_user) + end + # Remove all users from project team def truncate ProjectMember.truncate_team(project) @@ -88,7 +85,7 @@ class ProjectTeam @masters ||= fetch_members(:masters) end - def import(source_project) + def import(source_project, current_user = nil) target_project = project source_members = source_project.project_members.to_a @@ -103,6 +100,7 @@ class ProjectTeam new_member = member.dup new_member.id = nil new_member.source = target_project + new_member.created_by = current_user new_member end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 7ffd0b3882a..a7afcf8f64b 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -83,7 +83,7 @@ module Projects system_hook_service.execute_hooks_for(@project, :create) unless @project.group - @project.team << [current_user, :master] + @project.team << [current_user, :master, current_user] end @project.update_column(:last_activity_at, @project.created_at) diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb index 4ec98696a65..1e4deb6ed39 100644 --- a/app/services/projects/fork_service.rb +++ b/app/services/projects/fork_service.rb @@ -38,7 +38,7 @@ module Projects #First save the DB entries as they can be rolled back if the repo fork fails project.build_forked_project_link(forked_to_project_id: project.id, forked_from_project_id: @from_project.id) if project.save - project.team << [@current_user, :master] + project.team << [@current_user, :master, @current_user] end #Now fork the repo diff --git a/lib/api/group_members.rb b/lib/api/group_members.rb index 3a3987ab36e..ab9b7c602b5 100644 --- a/lib/api/group_members.rb +++ b/lib/api/group_members.rb @@ -34,7 +34,7 @@ module API render_api_error!("Already exists", 409) end - group.add_users([params[:user_id]], params[:access_level]) + group.add_users([params[:user_id]], params[:access_level], current_user) member = group.group_members.find_by(user_id: params[:user_id]) present member.user, with: Entities::GroupMember, group: group end diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb index 729970153d1..09a79553f72 100644 --- a/spec/requests/api/repositories_spec.rb +++ b/spec/requests/api/repositories_spec.rb @@ -11,8 +11,6 @@ describe API::API, api: true do let!(:master) { create(:project_member, user: user, project: project, access_level: ProjectMember::MASTER) } let!(:guest) { create(:project_member, user: user2, project: project, access_level: ProjectMember::GUEST) } - before { project.team << [user, :reporter] } - describe "GET /projects/:id/repository/tags" do it "should return an array of project tags" do get api("/projects/#{project.id}/repository/tags", user) From 629fa7fbe3a8a2f06d7c053ddcbcd78beed24e9c Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:09:37 +0200 Subject: [PATCH 05/25] Add invite logic to Member. --- app/models/member.rb | 82 +++++++++++++++++++++++++++- app/models/members/group_member.rb | 23 ++------ app/models/members/project_member.rb | 33 +++++------ spec/models/members_spec.rb | 79 +++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 38 deletions(-) diff --git a/app/models/member.rb b/app/models/member.rb index d92e69b2ce6..ec79f531740 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -26,16 +26,94 @@ class Member < ActiveRecord::Base belongs_to :user belongs_to :source, polymorphic: true - validates :user, presence: true + validates :user, presence: true, unless: :invite? validates :source, presence: true - validates :user_id, uniqueness: { scope: [:source_type, :source_id], message: "already exists in source" } + validates :user_id, uniqueness: { scope: [:source_type, :source_id], + message: "already exists in source", + allow_nil: true } validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true + validates :invite_email, presence: { if: :invite? }, + email: { strict_mode: true, allow_nil: true }, + uniqueness: { scope: [:source_type, :source_id], allow_nil: true } + scope :invite, -> { where(user_id: nil) } + scope :non_invite, -> { where("user_id IS NOT NULL") } scope :guests, -> { where(access_level: GUEST) } scope :reporters, -> { where(access_level: REPORTER) } scope :developers, -> { where(access_level: DEVELOPER) } scope :masters, -> { where(access_level: MASTER) } scope :owners, -> { where(access_level: OWNER) } + before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? } + after_create :send_invite, if: :invite? + after_create :post_create_hook, unless: :invite? + after_update :post_update_hook, unless: :invite? + after_destroy :post_destroy_hook, unless: :invite? + delegate :name, :username, :email, to: :user, prefix: true + + def invite? + self.invite_token.present? + end + + def accept_invite!(new_user) + self.invite_token = nil + self.invite_accepted_at = Time.now.utc + + self.user = new_user + + saved = self.save + + after_accept_invite if saved + + saved + end + + def generate_invite_token + raw, enc = Devise.token_generator.generate(self.class, :invite_token) + @raw_invite_token = raw + self.invite_token = enc + end + + def generate_invite_token! + generate_invite_token && save(validate: false) + end + + def resend_invite + return unless invite? + + generate_invite_token! unless @raw_invite_token + + send_invite + end + + private + + def send_invite + # override in subclass + end + + def post_create_hook + system_hook_service.execute_hooks_for(self, :create) + end + + def post_update_hook + # override in subclass + end + + def post_destroy_hook + system_hook_service.execute_hooks_for(self, :destroy) + end + + def after_accept_invite + post_create_hook + end + + def system_hook_service + SystemHooksService.new + end + + def notification_service + NotificationService.new + end end diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 28d0b4483b4..ccbbab6afc5 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -27,10 +27,6 @@ class GroupMember < Member scope :with_group, ->(group) { where(source_id: group.id) } scope :with_user, ->(user) { where(user_id: user.id) } - after_create :post_create_hook - after_update :notify_update - after_destroy :post_destroy_hook - def self.access_level_roles Gitlab::Access.options_with_owner end @@ -43,26 +39,19 @@ class GroupMember < Member access_level end + private + def post_create_hook notification_service.new_group_member(self) - system_hook_service.execute_hooks_for(self, :create) + + super end - def notify_update + def post_update_hook if access_level_changed? notification_service.update_group_member(self) end - end - def post_destroy_hook - system_hook_service.execute_hooks_for(self, :destroy) - end - - def system_hook_service - SystemHooksService.new - end - - def notification_service - NotificationService.new + super end end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 2205041cd51..6c5d161940d 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -27,10 +27,6 @@ class ProjectMember < Member validates_format_of :source_type, with: /\AProject\z/ default_scope { where(source_type: SOURCE_TYPE) } - after_create :post_create_hook - after_update :post_update_hook - after_destroy :post_destroy_hook - scope :in_project, ->(project) { where(source_id: project.id) } scope :in_projects, ->(projects) { where(source_id: projects.pluck(:id)) } scope :with_user, ->(user) { where(user_id: user.id) } @@ -110,41 +106,40 @@ class ProjectMember < Member access_level end + def project + source + end + def owner? project.owner == user end + private + def post_create_hook unless owner? event_service.join_project(self.project, self.user) notification_service.new_project_member(self) end - system_hook_service.execute_hooks_for(self, :create) + super end def post_update_hook - notification_service.update_project_member(self) if self.access_level_changed? + if access_level_changed? + notification_service.update_project_member(self) + end + + super end def post_destroy_hook event_service.leave_project(self.project, self.user) - system_hook_service.execute_hooks_for(self, :destroy) + + super end def event_service EventCreateService.new end - - def notification_service - NotificationService.new - end - - def system_hook_service - SystemHooksService.new - end - - def project - source - end end diff --git a/spec/models/members_spec.rb b/spec/models/members_spec.rb index dfd3f7feb6b..3f14a111443 100644 --- a/spec/models/members_spec.rb +++ b/spec/models/members_spec.rb @@ -11,10 +11,89 @@ describe Member do it { is_expected.to validate_presence_of(:user) } it { is_expected.to validate_presence_of(:source) } it { is_expected.to validate_inclusion_of(:access_level).in_array(Gitlab::Access.values) } + + context "when an invite email is provided" do + + let(:member) { build(:project_member, invite_email: "user@example.com", user: nil) } + + it "doesn't require a user" do + expect(member).to be_valid + end + + it "requires a valid invite email" do + member.invite_email = "nope" + + expect(member).not_to be_valid + end + + it "requires a unique invite email scoped to this source" do + create(:project_member, source: member.source, invite_email: member.invite_email) + + expect(member).not_to be_valid + end + + it "is valid otherwise" do + expect(member).to be_valid + end + end + + context "when an invite email is not provided" do + + let(:member) { build(:project_member) } + + it "requires a user" do + member.user = nil + + expect(member).not_to be_valid + end + + it "is valid otherwise" do + expect(member).to be_valid + end + end end describe "Delegate methods" do it { is_expected.to respond_to(:user_name) } it { is_expected.to respond_to(:user_email) } end + + describe "#accept_invite!" do + + let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } + let(:user) { create(:user) } + + it "resets the invite token" do + member.accept_invite!(user) + + expect(member.invite_token).to be_nil + end + + it "sets the invite accepted timestamp" do + member.accept_invite!(user) + + expect(member.invite_accepted_at).not_to be_nil + end + + it "sets the user" do + member.accept_invite!(user) + + expect(member.user).to eq(user) + end + + it "calls #after_accept_invite" do + expect(member).to receive(:after_accept_invite) + + member.accept_invite!(user) + end + end + + describe "#generate_invite_token" do + + let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } + + it "sets the invite token" do + expect { member.generate_invite_token }.to change { member.invite_token} + end + end end From cee98f22478b5ec27f91da04fe564a9760f79a38 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:13:00 +0200 Subject: [PATCH 06/25] Reference project members by id instead of user_id. --- app/controllers/projects/project_members_controller.rb | 8 ++------ app/views/admin/projects/show.html.haml | 2 +- .../projects/project_members/_project_member.html.haml | 4 ++-- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index e534db7af84..99ee3ebf92e 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -41,12 +41,12 @@ class Projects::ProjectMembersController < Projects::ApplicationController end def update - @project_member = @project.project_members.find_by(user_id: member) + @project_member = @project.project_members.find(params[:id]) @project_member.update_attributes(member_params) end def destroy - @project_member = @project.project_members.find_by(user_id: member) + @project_member = @project.project_members.find(params[:id]) @project_member.destroy respond_to do |format| @@ -78,10 +78,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController protected - def member - @member ||= User.find_by(username: params[:id]) - end - def member_params params.require(:project_member).permit(:user_id, :access_level) end diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index b0b23132560..be31551dc96 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -131,7 +131,7 @@ %span.light Owner - else %span.light= project_member.human_access - = link_to namespace_project_project_member_path(@project.namespace, @project, user), data: { confirm: remove_from_project_team_message(@project, user)}, method: :delete, remote: true, class: "btn btn-sm btn-remove" do + = link_to namespace_project_project_member_path(@project.namespace, @project, project_member), data: { confirm: remove_from_project_team_message(@project, user)}, method: :delete, remote: true, class: "btn btn-sm btn-remove" do %i.fa.fa-times .panel-footer = paginate @project_members, param_name: 'project_members_page', theme: 'gitlab' diff --git a/app/views/projects/project_members/_project_member.html.haml b/app/views/projects/project_members/_project_member.html.haml index 4f053977215..67d703a4121 100644 --- a/app/views/projects/project_members/_project_member.html.haml +++ b/app/views/projects/project_members/_project_member.html.haml @@ -25,12 +25,12 @@ = link_to leave_namespace_project_project_members_path(@project.namespace, @project), data: { confirm: "Leave project?"}, method: :delete, class: "btn-xs btn btn-remove", title: 'Leave project' do %i.fa.fa-minus.fa-inverse - else - = link_to namespace_project_project_member_path(@project.namespace, @project, user), data: { confirm: remove_from_project_team_message(@project, user) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from team' do + = link_to namespace_project_project_member_path(@project.namespace, @project, member), data: { confirm: remove_from_project_team_message(@project, user) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from team' do %i.fa.fa-minus.fa-inverse .edit-member.hide.js-toggle-content %br - = form_for member, as: :project_member, url: namespace_project_project_member_path(@project.namespace, @project, member.user), remote: true do |f| + = form_for member, as: :project_member, url: namespace_project_project_member_path(@project.namespace, @project, member), remote: true do |f| .prepend-top-10 = f.select :access_level, options_for_select(ProjectMember.access_roles, member.access_level), {}, class: 'form-control' .prepend-top-10 From 2a1d4255da396d37579e25dd88d5a9ea76116c22 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:14:14 +0200 Subject: [PATCH 07/25] Correctly render invite members without user. --- app/helpers/groups_helper.rb | 8 +++-- app/helpers/projects_helper.rb | 8 +++-- app/views/admin/groups/show.html.haml | 13 +++++--- app/views/admin/projects/show.html.haml | 11 +++++-- app/views/admin/users/show.html.haml | 4 +-- .../group_members/_group_member.html.haml | 31 ++++++++++++------ .../project_members/_project_member.html.haml | 32 +++++++++++++------ 7 files changed, 74 insertions(+), 33 deletions(-) diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 2d0d0b494f6..52d88133d5e 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -1,6 +1,10 @@ module GroupsHelper - def remove_user_from_group_message(group, user) - "Are you sure you want to remove \"#{user.name}\" from \"#{group.name}\"?" + def remove_user_from_group_message(group, member) + if member.user + "Are you sure you want to remove \"#{member.user.name}\" from \"#{group.name}\"?" + else + "Are you sure you want to cancel invitation for \"#{member.invite_email}\" to join \"#{group.name}\"?" + end end def leave_group_message(group) diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index ebbd2bfd77d..06e5d3b7383 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -1,6 +1,10 @@ module ProjectsHelper - def remove_from_project_team_message(project, user) - "You are going to remove #{user.name} from #{project.name} project team. Are you sure?" + def remove_from_project_team_message(project, member) + if member.user + "You are going to remove #{member.user.name} from #{project.name} project team. Are you sure?" + else + "You are going to cancel invitation for #{member.invite_email} to join #{project.name} project team. Are you sure?" + end end def link_to_project(project) diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index 7d292118075..404b918c245 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -74,13 +74,18 @@ %ul.well-list.group-users-list - @members.each do |member| - user = member.user - %li{class: dom_class(member), id: dom_id(user)} + %li{class: dom_class(member), id: (dom_id(user) if user)} .list-item-name - %strong - = link_to user.name, admin_user_path(user) + - if user + %strong + = link_to user.name, admin_user_path(user) + - else + %strong + = member.invite_email + (invited) %span.pull-right.light = member.human_access - = link_to group_group_member_path(@group, member), data: { confirm: remove_user_from_group_message(@group, user) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do + = link_to group_group_member_path(@group, member), data: { confirm: remove_user_from_group_message(@group, member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do %i.fa.fa-minus.fa-inverse .panel-footer = paginate @members, param_name: 'members_page', theme: 'gitlab' diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index be31551dc96..78684c692c7 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -124,14 +124,19 @@ - user = project_member.user %li.project_member .list-item-name - %strong - = link_to user.name, admin_user_path(user) + - if user + %strong + = link_to user.name, admin_user_path(user) + - else + %strong + = project_member.invite_email + (invited) .pull-right - if project_member.owner? %span.light Owner - else %span.light= project_member.human_access - = link_to namespace_project_project_member_path(@project.namespace, @project, project_member), data: { confirm: remove_from_project_team_message(@project, user)}, method: :delete, remote: true, class: "btn btn-sm btn-remove" do + = link_to namespace_project_project_member_path(@project.namespace, @project, project_member), data: { confirm: remove_from_project_team_message(@project, project_member)}, method: :delete, remote: true, class: "btn btn-sm btn-remove" do %i.fa.fa-times .panel-footer = paginate @project_members, param_name: 'project_members_page', theme: 'gitlab' diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 0a2934d3bda..3524f04c5ed 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -182,7 +182,7 @@ .pull-right %span.light= group_member.human_access - unless group_member.owner? - = link_to group_group_member_path(group, group_member), data: { confirm: remove_user_from_group_message(group, @user) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do + = link_to group_group_member_path(group, group_member), data: { confirm: remove_user_from_group_message(group, group_member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do %i.fa.fa-times.fa-inverse - else .nothing-here-block This user has no groups. @@ -221,7 +221,7 @@ %span.light= member.human_access - if member.respond_to? :project - = link_to namespace_project_project_member_path(project.namespace, project, @user), data: { confirm: remove_from_project_team_message(project, @user) }, remote: true, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from project' do + = link_to namespace_project_project_member_path(project.namespace, project, member), data: { confirm: remove_from_project_team_message(project, member) }, remote: true, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from project' do %i.fa.fa-times #ssh-keys.tab-pane = render 'profiles/keys/key_table', admin: true diff --git a/app/views/groups/group_members/_group_member.html.haml b/app/views/groups/group_members/_group_member.html.haml index 2462c952090..670d5e02e61 100644 --- a/app/views/groups/group_members/_group_member.html.haml +++ b/app/views/groups/group_members/_group_member.html.haml @@ -1,17 +1,28 @@ - user = member.user -- return unless user +- return unless user || member.invite? - show_roles = true if show_roles.nil? %li{class: "#{dom_class(member)} js-toggle-container", id: dom_id(member)} %span{class: ("list-item-name" if show_controls)} - = image_tag avatar_icon(user.email, 16), class: "avatar s16", alt: '' - %strong= user.name - %span.cgray= user.username - - if user == current_user - %span.label.label-success It's you - - if user.blocked? - %label.label.label-danger - %strong Blocked + - if member.user + = image_tag avatar_icon(user.email, 16), class: "avatar s16", alt: '' + %strong= user.name + %span.cgray= user.username + - if user == current_user + %span.label.label-success It's you + - if user.blocked? + %label.label.label-danger + %strong Blocked + - else + = image_tag avatar_icon(member.invite_email, 16), class: "avatar s16", alt: '' + %strong + = member.invite_email + %span.cgray + invited + - if member.created_by + by + = link_to member.created_by.name, user_path(member.created_by) + = time_ago_with_tooltip(member.created_at) - if show_roles %span.pull-right @@ -27,7 +38,7 @@ = link_to leave_group_group_members_path(@group), data: { confirm: leave_group_message(@group.name)}, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from group' do %i.fa.fa-minus.fa-inverse - else - = link_to group_group_member_path(@group, member), data: { confirm: remove_user_from_group_message(@group, user) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do + = link_to group_group_member_path(@group, member), data: { confirm: remove_user_from_group_message(@group, member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do %i.fa.fa-minus.fa-inverse .edit-member.hide.js-toggle-content diff --git a/app/views/projects/project_members/_project_member.html.haml b/app/views/projects/project_members/_project_member.html.haml index 67d703a4121..4be531deddd 100644 --- a/app/views/projects/project_members/_project_member.html.haml +++ b/app/views/projects/project_members/_project_member.html.haml @@ -1,16 +1,28 @@ - user = member.user -- return unless user +- return unless user || member.invite? %li{class: "#{dom_class(member)} js-toggle-container project_member_row access-#{member.human_access.downcase}", id: dom_id(member)} %span.list-item-name - = image_tag avatar_icon(user.email, 16), class: "avatar s16", alt: '' - %strong= user.name - %span.cgray= user.username - - if user == current_user - %span.label.label-success It's you - - if user.blocked? - %label.label.label-danger - %strong Blocked + - if member.user + = image_tag avatar_icon(user.email, 16), class: "avatar s16", alt: '' + %strong + = link_to user.name, user_path(user) + %span.cgray= user.username + - if user == current_user + %span.label.label-success It's you + - if user.blocked? + %label.label.label-danger + %strong Blocked + - else + = image_tag avatar_icon(member.invite_email, 16), class: "avatar s16", alt: '' + %strong + = member.invite_email + %span.cgray + invited + - if member.created_by + by + = link_to member.created_by.name, user_path(member.created_by) + = time_ago_with_tooltip(member.created_at) - if current_user_can_admin_project - unless @project.personal? && user == current_user @@ -25,7 +37,7 @@ = link_to leave_namespace_project_project_members_path(@project.namespace, @project), data: { confirm: "Leave project?"}, method: :delete, class: "btn-xs btn btn-remove", title: 'Leave project' do %i.fa.fa-minus.fa-inverse - else - = link_to namespace_project_project_member_path(@project.namespace, @project, member), data: { confirm: remove_from_project_team_message(@project, user) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from team' do + = link_to namespace_project_project_member_path(@project.namespace, @project, member), data: { confirm: remove_from_project_team_message(@project, member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from team' do %i.fa.fa-minus.fa-inverse .edit-member.hide.js-toggle-content From 90dafe31c4b1bb54bcd0476a873c4f5cdb62e981 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:14:59 +0200 Subject: [PATCH 08/25] Only show invited users to people who can admin group. --- app/controllers/groups/group_members_controller.rb | 1 + app/controllers/projects/project_members_controller.rb | 2 ++ 2 files changed, 3 insertions(+) diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 93fc4edb3bb..f8aa4b0bbd5 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -11,6 +11,7 @@ class Groups::GroupMembersController < Groups::ApplicationController def index @project = @group.projects.find(params[:project_id]) if params[:project_id] @members = @group.group_members + @members = @members.non_invite unless can?(current_user, :admin_group, @group) if params[:search].present? users = @group.users.search(params[:search]).to_a diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 99ee3ebf92e..148768e8fa1 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -6,6 +6,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController def index @project_members = @project.project_members + @project_members = @project_members.non_invite unless can?(current_user, :admin_project, @project) if params[:search].present? users = @project.users.search(params[:search]).to_a @@ -17,6 +18,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController @group = @project.group if @group @group_members = @group.group_members + @group_members = @group_members.non_invite unless can?(current_user, :admin_group, @group) if params[:search].present? users = @group.users.search(params[:search]).to_a From 39f389ae0fff5706b73b03c8fb5fc4e3dd7da638 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:22:31 +0200 Subject: [PATCH 09/25] Add invites controller. --- app/controllers/confirmations_controller.rb | 4 +- app/controllers/invites_controller.rb | 57 +++++++++++++++++++ app/models/member.rb | 7 +++ app/views/invites/show.html.haml | 28 +++++++++ .../group_invite_accepted_email.html.haml | 7 +++ .../group_invite_accepted_email.text.erb | 3 + .../group_member_invited_email.html.haml | 12 ++++ .../group_member_invited_email.text.erb | 3 + .../project_invite_accepted_email.html.haml | 7 +++ .../project_invite_accepted_email.text.erb | 3 + .../project_member_invited_email.html.haml | 11 ++++ .../project_member_invited_email.text.erb | 3 + config/routes.rb | 9 +++ 13 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 app/controllers/invites_controller.rb create mode 100644 app/views/invites/show.html.haml create mode 100644 app/views/notify/group_invite_accepted_email.html.haml create mode 100644 app/views/notify/group_invite_accepted_email.text.erb create mode 100644 app/views/notify/group_member_invited_email.html.haml create mode 100644 app/views/notify/group_member_invited_email.text.erb create mode 100644 app/views/notify/project_invite_accepted_email.html.haml create mode 100644 app/views/notify/project_invite_accepted_email.text.erb create mode 100644 app/views/notify/project_member_invited_email.html.haml create mode 100644 app/views/notify/project_member_invited_email.text.erb diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb index bc98eab133c..af1faca93f6 100644 --- a/app/controllers/confirmations_controller.rb +++ b/app/controllers/confirmations_controller.rb @@ -4,11 +4,11 @@ class ConfirmationsController < Devise::ConfirmationsController def after_confirmation_path_for(resource_name, resource) if signed_in?(resource_name) - signed_in_root_path(resource) + after_sign_in_path_for(resource) else sign_in(resource) if signed_in?(resource_name) - signed_in_root_path(resource) + after_sign_in_path_for(resource) else new_session_path(resource_name) end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb new file mode 100644 index 00000000000..8b1f6eeba1b --- /dev/null +++ b/app/controllers/invites_controller.rb @@ -0,0 +1,57 @@ +class InvitesController < ApplicationController + before_filter :member + + respond_to :html + + layout 'navless' + + def show + + end + + def accept + if member.accept_invite!(current_user) + case member.source + when Project + project = member.source + source = "project #{project.name_with_namespace}" + path = namespace_project_path(project.namespace, project) + when Group + group = member.source + source = "group #{group.name}" + path = group_path(group) + else + source = "who knows what" + path = dashboard_path + end + + redirect_to path, notice: "You have been granted #{member.human_access} access to #{source}." + else + redirect_to :back, alert: "The invite could not be accepted." + end + end + + private + + def member + return @member if defined?(@member) + + @token = params[:id] + if member = Member.find_by_invite_token(@token) + @member = member + else + render_404 + end + end + + def authenticate_user! + return if current_user + + notice = "To accept this invitation, sign in" + notice << " or create an account" if current_application_settings.signup_enabled? + notice << "." + + store_location_for :user, request.fullpath + redirect_to new_user_session_path, notice: notice + end +end diff --git a/app/models/member.rb b/app/models/member.rb index ec79f531740..d8cc65ca6cb 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -52,11 +52,18 @@ class Member < ActiveRecord::Base delegate :name, :username, :email, to: :user, prefix: true + def self.find_by_invite_token(invite_token) + invite_token = Devise.token_generator.digest(self, :invite_token, invite_token) + find_by(invite_token: invite_token) + end + def invite? self.invite_token.present? end def accept_invite!(new_user) + return false unless invite? + self.invite_token = nil self.invite_accepted_at = Time.now.utc diff --git a/app/views/invites/show.html.haml b/app/views/invites/show.html.haml new file mode 100644 index 00000000000..e2657ac6df2 --- /dev/null +++ b/app/views/invites/show.html.haml @@ -0,0 +1,28 @@ +%h3.page-title Invitation + +%p + You have been invited + - if inviter = @member.created_by + by + = link_to inviter.name, user_url(inviter) + to join + - case @member.source + - when Project + - project = @member.source + project + %strong + = link_to project.name_with_namespace, namespace_project_url(project.namespace, project) + - when Group + - group = @member.source + group + %strong + = link_to group.name, group_url(group) + as #{@member.human_access}. + +- if @member.source.users.include?(current_user) + %p + However, you are already a member of this #{@member.source.is_a?(Group) ? "group" : "project"}. + Sign in using a different account to accept the invitation. +- else + .actions + = link_to "Accept invitation", accept_invite_url(@token), method: :post, class: "btn btn-success" diff --git a/app/views/notify/group_invite_accepted_email.html.haml b/app/views/notify/group_invite_accepted_email.html.haml new file mode 100644 index 00000000000..d50d182102b --- /dev/null +++ b/app/views/notify/group_invite_accepted_email.html.haml @@ -0,0 +1,7 @@ +%p + #{@group_member.invite_email}, now known as + #{link_to @group_member.user.name, user_url(@group_member.user)}, + has accepted your invitation to join group + = link_to @group.name, group_url(@group) + as #{@group_member.human_access}. + diff --git a/app/views/notify/group_invite_accepted_email.text.erb b/app/views/notify/group_invite_accepted_email.text.erb new file mode 100644 index 00000000000..0072f5252da --- /dev/null +++ b/app/views/notify/group_invite_accepted_email.text.erb @@ -0,0 +1,3 @@ +<%= @group_member.invite_email %>, now known as <%= @group_member.user.name %>, has accepted your invitation to join group <%= @group.name %> as <%= @group_member.human_access %>. + +<%= group_url(@group) %> diff --git a/app/views/notify/group_member_invited_email.html.haml b/app/views/notify/group_member_invited_email.html.haml new file mode 100644 index 00000000000..39798a2c0e6 --- /dev/null +++ b/app/views/notify/group_member_invited_email.html.haml @@ -0,0 +1,12 @@ +%p + You have been invited + - if inviter = @group_member.created_by + by + = link_to inviter.name, user_url(inviter) + to join group + = link_to @group.name, group_url(@group) + as #{@group_member.human_access}. + +%p + = link_to 'Accept invitation', invite_url(@token) + diff --git a/app/views/notify/group_member_invited_email.text.erb b/app/views/notify/group_member_invited_email.text.erb new file mode 100644 index 00000000000..dec0cf0d01e --- /dev/null +++ b/app/views/notify/group_member_invited_email.text.erb @@ -0,0 +1,3 @@ +You have been invited <%= "by #{@group_member.created_by.name} " if @group_member.created_by %>to join group <%= @group.name %> as <%= @group_member.human_access %>. + +Accept invitation: <%= invite_url(@token) %> diff --git a/app/views/notify/project_invite_accepted_email.html.haml b/app/views/notify/project_invite_accepted_email.html.haml new file mode 100644 index 00000000000..0c01eef7621 --- /dev/null +++ b/app/views/notify/project_invite_accepted_email.html.haml @@ -0,0 +1,7 @@ +%p + #{@project_member.invite_email}, now known as + #{link_to @project_member.user.name, user_url(@project_member.user)}, + has accepted your invitation to join project + = link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project) + as #{@project_member.human_access}. + diff --git a/app/views/notify/project_invite_accepted_email.text.erb b/app/views/notify/project_invite_accepted_email.text.erb new file mode 100644 index 00000000000..e90217c1ab2 --- /dev/null +++ b/app/views/notify/project_invite_accepted_email.text.erb @@ -0,0 +1,3 @@ +<%= @project_member.invite_email %>, now known as <%= @project_member.user.name %>, has accepted your invitation to join project <%= @group.name_with_namespace %> as <%= @project_member.human_access %>. + +<%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/notify/project_member_invited_email.html.haml b/app/views/notify/project_member_invited_email.html.haml new file mode 100644 index 00000000000..f44adc244c9 --- /dev/null +++ b/app/views/notify/project_member_invited_email.html.haml @@ -0,0 +1,11 @@ +%p + You have been invited + - if inviter = @project_member.created_by + by + = link_to inviter.name, user_url(inviter) + to join project + = link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project) + as #{@project_member.human_access}. + +%p + = link_to 'Accept invitation', invite_url(@token) diff --git a/app/views/notify/project_member_invited_email.text.erb b/app/views/notify/project_member_invited_email.text.erb new file mode 100644 index 00000000000..55b16ef6c56 --- /dev/null +++ b/app/views/notify/project_member_invited_email.text.erb @@ -0,0 +1,3 @@ +You have been invited <%= "by #{@project_member.created_by.name} " if @project_member.created_by %>to join project <%= @project.name_with_namespace %> as <%= @project_member.human_access %>. + +Accept invitation: <%= invite_url(@token) %> diff --git a/config/routes.rb b/config/routes.rb index de21f418329..bd2a791f94a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -53,6 +53,15 @@ Gitlab::Application.routes.draw do end get '/s/:username' => 'snippets#user_index', as: :user_snippets, constraints: { username: /.*/ } + # + # Invites + # + + resources :invites, only: [:show], constraints: { id: /[A-Za-z0-9_-]+/ } do + member do + post :accept + end + end # # Import From 717fa136a5146d45660d3d97f5671352a47a0601 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:23:38 +0200 Subject: [PATCH 10/25] Add emails around invitation. --- app/mailers/emails/groups.rb | 21 ++++++++++++++++++++- app/mailers/emails/projects.rb | 25 ++++++++++++++++++++++--- app/models/members/group_member.rb | 12 ++++++++++++ app/models/members/project_member.rb | 12 ++++++++++++ app/services/notification_service.rb | 16 ++++++++++++++++ 5 files changed, 82 insertions(+), 4 deletions(-) diff --git a/app/mailers/emails/groups.rb b/app/mailers/emails/groups.rb index 626eb593d51..9db609006e0 100644 --- a/app/mailers/emails/groups.rb +++ b/app/mailers/emails/groups.rb @@ -5,8 +5,27 @@ module Emails @group = @group_member.group @target_url = group_url(@group) @current_user = @group_member.user - mail(to: @group_member.user.email, + mail(to: @group_member.user.notification_email, subject: subject("Access to group was granted")) end + + def group_member_invited_email(group_member_id, token) + @group_member = GroupMember.find group_member_id + @group = @group_member.group + @token = token + @target_url = group_url(@group) + mail(to: @group_member.invite_email, + subject: "Invite to join group #{@group.name}") + end + + def group_invite_accepted_email(group_member_id) + @group_member = GroupMember.find group_member_id + return if @group_member.created_by.nil? + + @group = @group_member.group + @target_url = group_url(@group) + mail(to: @group_member.created_by.notification_email, + subject: subject("Invite accepted")) + end end end diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index 20a863c3742..64c35618a52 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -1,14 +1,33 @@ module Emails module Projects - def project_access_granted_email(user_project_id) - @project_member = ProjectMember.find user_project_id + def project_access_granted_email(project_member_id) + @project_member = ProjectMember.find project_member_id @project = @project_member.project @target_url = namespace_project_url(@project.namespace, @project) @current_user = @project_member.user - mail(to: @project_member.user.email, + mail(to: @project_member.user.notification_email, subject: subject("Access to project was granted")) end + def project_member_invited_email(project_member_id, token) + @project_member = ProjectMember.find project_member_id + @project = @project_member.project + @token = token + @target_url = namespace_project_url(@project.namespace, @project) + mail(to: @project_member.invite_email, + subject: "Invite to join project #{@project.name_with_namespace}") + end + + def project_invite_accepted_email(project_member_id) + @project_member = ProjectMember.find project_member_id + return if @project_member.created_by.nil? + + @project = @project_member.project + @target_url = namespace_project_url(@project.namespace, @project) + mail(to: @project_member.created_by.notification_email, + subject: subject("Invite accepted")) + end + def project_was_moved_email(project_id, user_id) @current_user = @user = User.find user_id @project = Project.find project_id diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index ccbbab6afc5..68b7c169f72 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -41,6 +41,12 @@ class GroupMember < Member private + def send_invite + notification_service.invite_group_member(self, @raw_invite_token) + + super + end + def post_create_hook notification_service.new_group_member(self) @@ -54,4 +60,10 @@ class GroupMember < Member super end + + def after_accept_invite + notification_service.accept_group_invite(self) + + super + end end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 6c5d161940d..a63d5af1219 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -116,6 +116,12 @@ class ProjectMember < Member private + def send_invite + notification_service.invite_project_member(self, @raw_invite_token) + + super + end + def post_create_hook unless owner? event_service.join_project(self.project, self.user) @@ -139,6 +145,12 @@ class ProjectMember < Member super end + def after_accept_invite + notification_service.accept_project_invite(self) + + super + end + def event_service EventCreateService.new end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 42547f6f481..a97abe289f5 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -165,6 +165,14 @@ class NotificationService end end + def invite_project_member(project_member, token) + mailer.project_member_invited_email(project_member.id, token) + end + + def accept_project_invite(project_member) + mailer.project_invite_accepted_email(project_member.id) + end + def new_project_member(project_member) mailer.project_access_granted_email(project_member.id) end @@ -173,6 +181,14 @@ class NotificationService mailer.project_access_granted_email(project_member.id) end + def invite_group_member(group_member, token) + mailer.group_member_invited_email(group_member.id, token) + end + + def accept_group_invite(group_member) + mailer.group_invite_accepted_email(group_member.id) + end + def new_group_member(group_member) mailer.group_access_granted_email(group_member.id) end From 87dd3f215f8c087d85cde7ac880c5480fd501303 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:25:32 +0200 Subject: [PATCH 11/25] Let members be invited from Add members pane. --- app/assets/javascripts/users_select.js.coffee | 38 ++++++++++++------- .../projects/project_members_controller.rb | 3 +- app/helpers/selects_helper.rb | 2 + app/models/group.rb | 20 ++++++++-- app/models/members/project_member.rb | 22 +++++++++-- app/views/admin/groups/show.html.haml | 2 +- .../group_members/_new_group_member.html.haml | 5 ++- .../_new_project_member.html.haml | 5 ++- 8 files changed, 72 insertions(+), 25 deletions(-) diff --git a/app/assets/javascripts/users_select.js.coffee b/app/assets/javascripts/users_select.js.coffee index ccd85f2455d..aeeed9ca3cc 100644 --- a/app/assets/javascripts/users_select.js.coffee +++ b/app/assets/javascripts/users_select.js.coffee @@ -8,6 +8,7 @@ class @UsersSelect @groupId = $(select).data('group-id') showNullUser = $(select).data('null-user') showAnyUser = $(select).data('any-user') + showEmailUser = $(select).data('email-user') firstUser = $(select).data('first-user') $(select).select2 @@ -19,20 +20,6 @@ class @UsersSelect data = { results: users } if query.term.length == 0 - anyUser = { - name: 'Any', - avatar: null, - username: 'none', - id: null - } - - nullUser = { - name: 'Unassigned', - avatar: null, - username: 'none', - id: 0 - } - if firstUser # Move current user to the front of the list for obj, index in data.results @@ -40,11 +27,34 @@ class @UsersSelect data.results.splice(index, 1) data.results.unshift(obj) break + if showNullUser + nullUser = { + name: 'Unassigned', + avatar: null, + username: 'none', + id: 0 + } data.results.unshift(nullUser) + if showAnyUser + anyUser = { + name: 'Any', + avatar: null, + username: 'none', + id: null + } data.results.unshift(anyUser) + if showEmailUser && data.results.length == 0 && query.term.match(/^[^@]+@[^@]+$/) + emailUser = { + name: "Invite \"#{query.term}\"", + avatar: null, + username: query.term, + id: query.term + } + data.results.unshift(emailUser) + query.callback(data) initSelection: (element, callback) => diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 148768e8fa1..91aba9a7068 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -36,8 +36,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController end def create - users = User.where(id: params[:user_ids].split(',')) - @project.team << [users, params[:access_level]] + @project.team.add_users(params[:user_ids].split(','), params[:access_level], current_user) redirect_to namespace_project_project_members_path(@project.namespace, @project) end diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb index 54e0f4f9b3e..bec8f2f1aa7 100644 --- a/app/helpers/selects_helper.rb +++ b/app/helpers/selects_helper.rb @@ -8,6 +8,7 @@ module SelectsHelper null_user = opts[:null_user] || false any_user = opts[:any_user] || false + email_user = opts[:email_user] || false first_user = opts[:first_user] && current_user ? current_user.username : false html = { @@ -15,6 +16,7 @@ module SelectsHelper 'data-placeholder' => placeholder, 'data-null-user' => null_user, 'data-any-user' => any_user, + 'data-email-user' => email_user, 'data-first-user' => first_user } diff --git a/app/models/group.rb b/app/models/group.rb index eec961f6012..2de397f90f7 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -47,10 +47,24 @@ class Group < Namespace end def add_users(user_ids, access_level, current_user = nil) - user_ids.compact.each do |user_id| - member = self.group_members.find_or_initialize_by(user_id: user_id) - member.access_level = access_level + users = user_ids.map do |user_id| + (user_id if user_id.is_a?(User)) || + User.find_by(id: user_id) || + User.find_by(email: user_id) || + user_id + end + + users.compact.each do |user| + if user.is_a?(User) + member = self.group_members.find_or_initialize_by(user_id: user.id) + else + member = self.group_members.build + member.invite_email = user + end + member.created_by ||= current_user + member.access_level = access_level + member.save end end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index a63d5af1219..3b3b1bd4680 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -60,12 +60,28 @@ class ProjectMember < Member raise "Non valid access" end + users = user_ids.map do |user_id| + (user_id if user_id.is_a?(User)) || + User.find_by(id: user_id) || + User.find_by(email: user_id) || + user_id + end + ProjectMember.transaction do project_ids.each do |project_id| - user_ids.each do |user_id| - member = ProjectMember.new(access_level: access_level, user_id: user_id) - member.source_id = project_id + project = Project.find(project_id) + + users.each do |user| + if user.is_a?(User) + member = project.project_members.find_or_initialize_by(user_id: user.id) + else + member = project.project_members.build + member.invite_email = user + end + member.created_by ||= current_user + member.access_level = access_level + member.save end end diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index 404b918c245..14996dcd6a2 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -60,7 +60,7 @@ = form_tag members_update_admin_group_path(@group), id: "new_project_member", class: "bulk_import", method: :put do %div - = users_select_tag(:user_ids, multiple: true) + = users_select_tag(:user_ids, multiple: true, email_user: true) %div.prepend-top-10 = select_tag :access_level, options_for_select(GroupMember.access_level_roles), class: "project-access-select select2" %hr diff --git a/app/views/groups/group_members/_new_group_member.html.haml b/app/views/groups/group_members/_new_group_member.html.haml index a52b8197384..3361d7e2a8d 100644 --- a/app/views/groups/group_members/_new_group_member.html.haml +++ b/app/views/groups/group_members/_new_group_member.html.haml @@ -1,7 +1,10 @@ = form_for @group_member, url: group_group_members_path(@group), html: { class: 'form-horizontal users-group-form' } do |f| .form-group = f.label :user_ids, "People", class: 'control-label' - .col-sm-10= users_select_tag(:user_ids, multiple: true, class: 'input-large', scope: :all) + .col-sm-10 + = users_select_tag(:user_ids, multiple: true, class: 'input-large', scope: :all, email_user: true) + .help-block + Search for existing users or invite new ones using their email address. .form-group = f.label :access_level, "Group Access", class: 'control-label' diff --git a/app/views/projects/project_members/_new_project_member.html.haml b/app/views/projects/project_members/_new_project_member.html.haml index 5daae2708e6..d708b01a114 100644 --- a/app/views/projects/project_members/_new_project_member.html.haml +++ b/app/views/projects/project_members/_new_project_member.html.haml @@ -1,7 +1,10 @@ = form_for @project_member, as: :project_member, url: namespace_project_project_members_path(@project.namespace, @project), html: { class: 'form-horizontal users-project-form' } do |f| .form-group = f.label :user_ids, "People", class: 'control-label' - .col-sm-10= users_select_tag(:user_ids, multiple: true, class: 'input-large', scope: :all) + .col-sm-10 + = users_select_tag(:user_ids, multiple: true, class: 'input-large', scope: :all, email_user: true) + .help-block + Search for existing users or invite new ones using their email address. .form-group = f.label :access_level, "Project Access", class: 'control-label' From 453340d4496ce0500985d6432c32e8d31be350a7 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:26:03 +0200 Subject: [PATCH 12/25] Let membership invitations be resent. --- app/controllers/groups/group_members_controller.rb | 11 +++++++++++ .../projects/project_members_controller.rb | 13 +++++++++++++ .../groups/group_members/_group_member.html.haml | 4 ++++ .../project_members/_project_member.html.haml | 4 ++++ config/routes.rb | 5 +++++ 5 files changed, 37 insertions(+) diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index f8aa4b0bbd5..1a4b13b9d73 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -47,6 +47,17 @@ class Groups::GroupMembersController < Groups::ApplicationController end end + def resend_invite + @group_member = @group.group_members.find(params[:id]) + if @group_member.invite? + @group_member.resend_invite + + redirect_to group_group_members_path(@group), notice: 'Invite was successfully resent.' + else + redirect_to group_group_members_path(@group), alert: 'The invite has already been accepted.' + end + end + def leave @group_member = @group.group_members.where(user_id: current_user.id).first diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 91aba9a7068..e55e8e4c504 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -59,6 +59,19 @@ class Projects::ProjectMembersController < Projects::ApplicationController end end + def resend_invite + @project_member = @project.project_members.find(params[:id]) + if @project_member.invite? + @project_member.resend_invite + + redirect_to namespace_project_project_members_path(@project.namespace, + @project), notice: 'Invite was successfully resent.' + else + redirect_to namespace_project_project_members_path(@project.namespace, + @project), alert: 'The invite has already been accepted.' + end + end + def leave @project.project_members.find_by(user_id: current_user).destroy diff --git a/app/views/groups/group_members/_group_member.html.haml b/app/views/groups/group_members/_group_member.html.haml index 670d5e02e61..56b1948a474 100644 --- a/app/views/groups/group_members/_group_member.html.haml +++ b/app/views/groups/group_members/_group_member.html.haml @@ -24,6 +24,10 @@ = link_to member.created_by.name, user_path(member.created_by) = time_ago_with_tooltip(member.created_at) + - if show_controls && can?(current_user, :admin_group, @group) + = link_to resend_invite_group_group_member_path(@group, member), method: :post, class: "btn-xs btn", title: 'Resend invite' do + Resend invite + - if show_roles %span.pull-right %strong= member.human_access diff --git a/app/views/projects/project_members/_project_member.html.haml b/app/views/projects/project_members/_project_member.html.haml index 4be531deddd..635e4d70941 100644 --- a/app/views/projects/project_members/_project_member.html.haml +++ b/app/views/projects/project_members/_project_member.html.haml @@ -24,6 +24,10 @@ = link_to member.created_by.name, user_path(member.created_by) = time_ago_with_tooltip(member.created_at) + - if current_user_can_admin_project + = link_to resend_invite_namespace_project_project_member_path(@project.namespace, @project, member), method: :post, class: "btn-xs btn", title: 'Resend invite' do + Resend invite + - if current_user_can_admin_project - unless @project.personal? && user == current_user .pull-right diff --git a/config/routes.rb b/config/routes.rb index bd2a791f94a..50d11fde57d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -260,6 +260,7 @@ Gitlab::Application.routes.draw do scope module: :groups do resources :group_members, only: [:index, :create, :update, :destroy] do + post :resend_invite, on: :member delete :leave, on: :collection end @@ -486,6 +487,10 @@ Gitlab::Application.routes.draw do get :import post :apply_import end + + member do + post :resend_invite + end end resources :notes, only: [:index, :create, :destroy, :update], constraints: { id: /\d+/ } do From 2a45eb6a912a35b71e3cbe7aacbda3323730d9da Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:26:18 +0200 Subject: [PATCH 13/25] Use notice flash type rather than nonexistent info. --- app/controllers/groups/group_members_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 1a4b13b9d73..0018cd34bbe 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -39,7 +39,7 @@ class Groups::GroupMembersController < Groups::ApplicationController if can?(current_user, :destroy_group_member, @group_member) # May fail if last owner. @group_member.destroy respond_to do |format| - format.html { redirect_to group_group_members_path(@group), notice: 'User was successfully removed from group.' } + format.html { redirect_to group_group_members_path(@group), notice: 'User was successfully removed from group.' } format.js { render nothing: true } end else @@ -63,7 +63,7 @@ class Groups::GroupMembersController < Groups::ApplicationController if can?(current_user, :destroy_group_member, @group_member) @group_member.destroy - redirect_to(dashboard_groups_path, info: "You left #{group.name} group.") + redirect_to(dashboard_groups_path, notice: "You left #{group.name} group.") else return render_403 end From 1c0824f451a3fe8f328f0360c94cd251ff664ed6 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:26:53 +0200 Subject: [PATCH 14/25] Correctly import invited members. --- app/models/project_team.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 313e459d701..56e49af2324 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -93,7 +93,7 @@ class ProjectTeam source_members.reject! do |member| # Skip if user already present in team - target_user_ids.include?(member.user_id) + !member.invite? && target_user_ids.include?(member.user_id) end source_members.map! do |member| From 405df435587c674dda049868cb2009b00c243dd8 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:27:06 +0200 Subject: [PATCH 15/25] Use "Sign out" instead of "Logout" for consistency. --- app/views/layouts/_head_panel.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/layouts/_head_panel.html.haml b/app/views/layouts/_head_panel.html.haml index b1c2e1a7b19..d58582c107a 100644 --- a/app/views/layouts/_head_panel.html.haml +++ b/app/views/layouts/_head_panel.html.haml @@ -39,7 +39,7 @@ = link_to profile_path, title: "Profile settings", class: 'has_bottom_tooltip', 'data-original-title' => 'Profile settings"' do %i.fa.fa-user %li - = link_to destroy_user_session_path, class: "logout", method: :delete, title: "Logout", class: 'has_bottom_tooltip', 'data-original-title' => 'Logout' do + = link_to destroy_user_session_path, class: "logout", method: :delete, title: "Sign out", class: 'has_bottom_tooltip', 'data-original-title' => 'Sign out' do %i.fa.fa-sign-out %li.hidden-xs = link_to current_user, class: "profile-pic has_bottom_tooltip", id: 'profile-pic', 'data-original-title' => 'Your profile' do From 8949af0c9f2aa90b979043a0a6ee264ac0c036b9 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 15:40:23 +0200 Subject: [PATCH 16/25] Add feature tests for inviting members. --- features/groups.feature | 7 +++++++ features/project/team_management.feature | 6 ++++++ features/steps/groups.rb | 17 +++++++++++++++++ features/steps/project/team_management.rb | 16 ++++++++++++++++ 4 files changed, 46 insertions(+) diff --git a/features/groups.feature b/features/groups.feature index 65d06a0daf9..415e43d6ae7 100644 --- a/features/groups.feature +++ b/features/groups.feature @@ -55,6 +55,13 @@ Feature: Groups When I select "Mike" as "Reporter" Then I should see "Mike" in team list as "Reporter" + @javascript + Scenario: Invite user to group + When I visit group "Owned" members page + And I click link "Add members" + When I select "sjobs@apple.com" as "Reporter" + Then I should see "sjobs@apple.com" in team list as invited "Reporter" + # Leave @javascript diff --git a/features/project/team_management.feature b/features/project/team_management.feature index 22393622bb9..6cda225ea7b 100644 --- a/features/project/team_management.feature +++ b/features/project/team_management.feature @@ -17,6 +17,12 @@ Feature: Project Team Management And I select "Mike" as "Reporter" Then I should see "Mike" in team list as "Reporter" + @javascript + Scenario: Invite user to project + Given I click link "Add members" + And I select "sjobs@apple.com" as "Reporter" + Then I should see "sjobs@apple.com" in team list as invited "Reporter" + @javascript Scenario: Update user access Given I should see "Sam" in team list as "Developer" diff --git a/features/steps/groups.rb b/features/steps/groups.rb index ec5213e4b93..228b83e5fd0 100644 --- a/features/steps/groups.rb +++ b/features/steps/groups.rb @@ -31,6 +31,23 @@ class Spinach::Features::Groups < Spinach::FeatureSteps end end + step 'I select "sjobs@apple.com" as "Reporter"' do + within ".users-group-form" do + select2("sjobs@apple.com", from: "#user_ids", multiple: true) + select "Reporter", from: "access_level" + end + + click_button "Add users to group" + end + + step 'I should see "sjobs@apple.com" in team list as invited "Reporter"' do + within '.well-list' do + page.should have_content('sjobs@apple.com') + page.should have_content('invited') + page.should have_content('Reporter') + end + end + step 'I should see group "Owned" projects list' do Group.find_by(name: "Owned").projects.each do |project| page.should have_link project.name diff --git a/features/steps/project/team_management.rb b/features/steps/project/team_management.rb index 0eefe2b5688..e95621071c4 100644 --- a/features/steps/project/team_management.rb +++ b/features/steps/project/team_management.rb @@ -35,6 +35,22 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps end end + step 'I select "sjobs@apple.com" as "Reporter"' do + within ".users-project-form" do + select2("sjobs@apple.com", from: "#user_ids", multiple: true) + select "Reporter", from: "access_level" + end + click_button "Add users to project" + end + + step 'I should see "sjobs@apple.com" in team list as invited "Reporter"' do + within ".access-reporter" do + page.should have_content('sjobs@apple.com') + page.should have_content('invited') + page.should have_content('Reporter') + end + end + step 'I should see "Sam" in team list as "Developer"' do within ".access-developer" do page.should have_content('Sam') From e50556fcc8d81dae5978bcb3e1dd5104789aab0f Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Apr 2015 16:37:02 +0200 Subject: [PATCH 17/25] Let invites be declined. --- app/controllers/invites_controller.rb | 27 +++++++++++++++++++ app/mailers/emails/groups.rb | 13 +++++++++ app/mailers/emails/projects.rb | 13 +++++++++ app/models/member.rb | 14 ++++++++++ app/models/members/group_member.rb | 6 +++++ app/models/members/project_member.rb | 6 +++++ app/services/notification_service.rb | 8 ++++++ app/views/invites/show.html.haml | 1 + .../group_invite_declined_email.html.haml | 6 +++++ .../group_invite_declined_email.text.erb | 3 +++ .../group_member_invited_email.html.haml | 2 ++ .../group_member_invited_email.text.erb | 1 + .../project_invite_accepted_email.text.erb | 2 +- .../project_invite_declined_email.html.haml | 6 +++++ .../project_invite_declined_email.text.erb | 3 +++ .../project_member_invited_email.html.haml | 2 ++ .../project_member_invited_email.text.erb | 1 + config/routes.rb | 1 + spec/models/members_spec.rb | 17 ++++++++++++ 19 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 app/views/notify/group_invite_declined_email.html.haml create mode 100644 app/views/notify/group_invite_declined_email.text.erb create mode 100644 app/views/notify/project_invite_declined_email.html.haml create mode 100644 app/views/notify/project_invite_declined_email.text.erb diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 8b1f6eeba1b..1b236a48e90 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -1,5 +1,6 @@ class InvitesController < ApplicationController before_filter :member + skip_before_filter :authenticate_user!, only: :decline respond_to :html @@ -31,6 +32,32 @@ class InvitesController < ApplicationController end end + def decline + if member.decline_invite! + case member.source + when Project + project = member.source + source = "project #{project.name_with_namespace}" + when Group + group = member.source + source = "group #{group.name}" + else + source = "who knows what" + end + + path = + if current_user + dashboard_path + else + new_user_session_path + end + + redirect_to path, notice: "You have declined the invite to join #{source}." + else + redirect_to :back, alert: "The invite could not be declined." + end + end + private def member diff --git a/app/mailers/emails/groups.rb b/app/mailers/emails/groups.rb index 9db609006e0..f3da3990c5f 100644 --- a/app/mailers/emails/groups.rb +++ b/app/mailers/emails/groups.rb @@ -27,5 +27,18 @@ module Emails mail(to: @group_member.created_by.notification_email, subject: subject("Invite accepted")) end + + def group_invite_declined_email(group_id, invite_email, access_level, created_by_id) + return if created_by_id.nil? + + @group = Group.find(group_id) + @created_by = User.find(created_by_id) + @access_level = access_level + @invite_email = invite_email + @target_url = group_url(@group) + + mail(to: @created_by.notification_email, + subject: subject("Invite declined")) + end end end diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index 64c35618a52..bbfb7a3e409 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -28,6 +28,19 @@ module Emails subject: subject("Invite accepted")) end + def project_invite_declined_email(project_id, invite_email, access_level, created_by_id) + return if created_by_id.nil? + + @project = Project.find(project_id) + @created_by = User.find(created_by_id) + @access_level = access_level + @invite_email = invite_email + @target_url = namespace_project_url(@project.namespace, @project) + + mail(to: @created_by.notification_email, + subject: subject("Invite declined")) + end + def project_was_moved_email(project_id, user_id) @current_user = @user = User.find user_id @project = Project.find project_id diff --git a/app/models/member.rb b/app/models/member.rb index d8cc65ca6cb..2421222eaa2 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -76,6 +76,16 @@ class Member < ActiveRecord::Base saved end + def decline_invite! + return false unless invite? + + destroyed = self.destroy + + after_decline_invite if destroyed + + destroyed + end + def generate_invite_token raw, enc = Devise.token_generator.generate(self.class, :invite_token) @raw_invite_token = raw @@ -116,6 +126,10 @@ class Member < ActiveRecord::Base post_create_hook end + def after_decline_invite + # override in subclass + end + def system_hook_service SystemHooksService.new end diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 68b7c169f72..84c91372b3f 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -66,4 +66,10 @@ class GroupMember < Member super end + + def after_decline_invite + notification_service.decline_group_invite(self) + + super + end end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 3b3b1bd4680..8af7499dd82 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -167,6 +167,12 @@ class ProjectMember < Member super end + def after_decline_invite + notification_service.decline_project_invite(self) + + super + end + def event_service EventCreateService.new end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index a97abe289f5..203e654c18f 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -173,6 +173,10 @@ class NotificationService mailer.project_invite_accepted_email(project_member.id) end + def decline_project_invite(project_member) + mailer.project_invite_declined_email(project_member.project.id, project_member.invite_email, project_member.access_level, project_member.created_by_id) + end + def new_project_member(project_member) mailer.project_access_granted_email(project_member.id) end @@ -189,6 +193,10 @@ class NotificationService mailer.group_invite_accepted_email(group_member.id) end + def decline_group_invite(group_member) + mailer.group_invite_declined_email(group_member.group.id, group_member.invite_email, group_member.access_level, group_member.created_by_id) + end + def new_group_member(group_member) mailer.group_access_granted_email(group_member.id) end diff --git a/app/views/invites/show.html.haml b/app/views/invites/show.html.haml index e2657ac6df2..ab0ecffe4d2 100644 --- a/app/views/invites/show.html.haml +++ b/app/views/invites/show.html.haml @@ -26,3 +26,4 @@ - else .actions = link_to "Accept invitation", accept_invite_url(@token), method: :post, class: "btn btn-success" + = link_to "Decline", decline_invite_url(@token), method: :post, class: "btn btn-danger prepend-left-10" diff --git a/app/views/notify/group_invite_declined_email.html.haml b/app/views/notify/group_invite_declined_email.html.haml new file mode 100644 index 00000000000..80f39c3f1ea --- /dev/null +++ b/app/views/notify/group_invite_declined_email.html.haml @@ -0,0 +1,6 @@ +%p + #{@invite_email} + has declined your invitation to join group + = link_to @group.name, group_url(@group) + as #{Gitlab::Access.options_with_owner.key(@access_level)}. + diff --git a/app/views/notify/group_invite_declined_email.text.erb b/app/views/notify/group_invite_declined_email.text.erb new file mode 100644 index 00000000000..31cdac0e7fb --- /dev/null +++ b/app/views/notify/group_invite_declined_email.text.erb @@ -0,0 +1,3 @@ +<%= @invite_email %> has declined your invitation to join group <%= @group.name %> as <%= Gitlab::Access.options_with_owner.key(@access_level) %>. + +<%= group_url(@group) %> diff --git a/app/views/notify/group_member_invited_email.html.haml b/app/views/notify/group_member_invited_email.html.haml index 39798a2c0e6..163e88bfea3 100644 --- a/app/views/notify/group_member_invited_email.html.haml +++ b/app/views/notify/group_member_invited_email.html.haml @@ -9,4 +9,6 @@ %p = link_to 'Accept invitation', invite_url(@token) + or + = link_to 'decline', decline_invite_url(@token) diff --git a/app/views/notify/group_member_invited_email.text.erb b/app/views/notify/group_member_invited_email.text.erb index dec0cf0d01e..28ce4819b14 100644 --- a/app/views/notify/group_member_invited_email.text.erb +++ b/app/views/notify/group_member_invited_email.text.erb @@ -1,3 +1,4 @@ You have been invited <%= "by #{@group_member.created_by.name} " if @group_member.created_by %>to join group <%= @group.name %> as <%= @group_member.human_access %>. Accept invitation: <%= invite_url(@token) %> +Decline invitation: <%= decline_invite_url(@token) %> diff --git a/app/views/notify/project_invite_accepted_email.text.erb b/app/views/notify/project_invite_accepted_email.text.erb index e90217c1ab2..0bbe274931e 100644 --- a/app/views/notify/project_invite_accepted_email.text.erb +++ b/app/views/notify/project_invite_accepted_email.text.erb @@ -1,3 +1,3 @@ -<%= @project_member.invite_email %>, now known as <%= @project_member.user.name %>, has accepted your invitation to join project <%= @group.name_with_namespace %> as <%= @project_member.human_access %>. +<%= @project_member.invite_email %>, now known as <%= @project_member.user.name %>, has accepted your invitation to join project <%= @project.name_with_namespace %> as <%= @project_member.human_access %>. <%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/notify/project_invite_declined_email.html.haml b/app/views/notify/project_invite_declined_email.html.haml new file mode 100644 index 00000000000..dad9f505b9e --- /dev/null +++ b/app/views/notify/project_invite_declined_email.html.haml @@ -0,0 +1,6 @@ +%p + #{@invite_email} + has declined your invitation to join project + = link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project) + as #{Gitlab::Access.options_with_owner.key(@access_level)}. + diff --git a/app/views/notify/project_invite_declined_email.text.erb b/app/views/notify/project_invite_declined_email.text.erb new file mode 100644 index 00000000000..06a0013cff5 --- /dev/null +++ b/app/views/notify/project_invite_declined_email.text.erb @@ -0,0 +1,3 @@ +<%= @invite_email %> has declined your invitation to join project <%= @project.name_with_namespace %> as <%= Gitlab::Access.options_with_owner.key(@access_level) %>. + +<%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/notify/project_member_invited_email.html.haml b/app/views/notify/project_member_invited_email.html.haml index f44adc244c9..79eb89616de 100644 --- a/app/views/notify/project_member_invited_email.html.haml +++ b/app/views/notify/project_member_invited_email.html.haml @@ -9,3 +9,5 @@ %p = link_to 'Accept invitation', invite_url(@token) + or + = link_to 'decline', decline_invite_url(@token) diff --git a/app/views/notify/project_member_invited_email.text.erb b/app/views/notify/project_member_invited_email.text.erb index 55b16ef6c56..e0706272115 100644 --- a/app/views/notify/project_member_invited_email.text.erb +++ b/app/views/notify/project_member_invited_email.text.erb @@ -1,3 +1,4 @@ You have been invited <%= "by #{@project_member.created_by.name} " if @project_member.created_by %>to join project <%= @project.name_with_namespace %> as <%= @project_member.human_access %>. Accept invitation: <%= invite_url(@token) %> +Decline invitation: <%= decline_invite_url(@token) %> diff --git a/config/routes.rb b/config/routes.rb index 50d11fde57d..d90268c2180 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -60,6 +60,7 @@ Gitlab::Application.routes.draw do resources :invites, only: [:show], constraints: { id: /[A-Za-z0-9_-]+/ } do member do post :accept + match :decline, via: [:get, :post] end end diff --git a/spec/models/members_spec.rb b/spec/models/members_spec.rb index 3f14a111443..c3e47ab7e9a 100644 --- a/spec/models/members_spec.rb +++ b/spec/models/members_spec.rb @@ -88,6 +88,23 @@ describe Member do end end + describe "#decline_invite!" do + + let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } + + it "destroys the member" do + member.decline_invite! + + expect(member).to be_destroyed + end + + it "calls #after_decline_invite" do + expect(member).to receive(:after_decline_invite) + + member.decline_invite! + end + end + describe "#generate_invite_token" do let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } From 1b5c483d89d9a20a92e98fc37879389ee2c8dd00 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 12:25:16 +0200 Subject: [PATCH 18/25] Consistently use "invitation" rather than "invite" in text. --- app/controllers/groups/group_members_controller.rb | 4 ++-- app/controllers/invites_controller.rb | 6 +++--- .../projects/project_members_controller.rb | 8 ++++---- app/helpers/groups_helper.rb | 2 +- app/helpers/projects_helper.rb | 2 +- app/mailers/emails/groups.rb | 11 ++++++----- app/mailers/emails/projects.rb | 11 +++++++---- 7 files changed, 24 insertions(+), 20 deletions(-) diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 0018cd34bbe..359eeb4d27a 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -52,9 +52,9 @@ class Groups::GroupMembersController < Groups::ApplicationController if @group_member.invite? @group_member.resend_invite - redirect_to group_group_members_path(@group), notice: 'Invite was successfully resent.' + redirect_to group_group_members_path(@group), notice: 'The invitation was successfully resent.' else - redirect_to group_group_members_path(@group), alert: 'The invite has already been accepted.' + redirect_to group_group_members_path(@group), alert: 'The invitation has already been accepted.' end end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 1b236a48e90..00d274a7f1d 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -28,7 +28,7 @@ class InvitesController < ApplicationController redirect_to path, notice: "You have been granted #{member.human_access} access to #{source}." else - redirect_to :back, alert: "The invite could not be accepted." + redirect_to :back, alert: "The invitation could not be accepted." end end @@ -52,9 +52,9 @@ class InvitesController < ApplicationController new_user_session_path end - redirect_to path, notice: "You have declined the invite to join #{source}." + redirect_to path, notice: "You have declined the invitation to join #{label}." else - redirect_to :back, alert: "The invite could not be declined." + redirect_to :back, alert: "The invitation could not be declined." end end diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index e55e8e4c504..9831a2089be 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -60,15 +60,15 @@ class Projects::ProjectMembersController < Projects::ApplicationController end def resend_invite + redirect_path = namespace_project_project_members_path(@project.namespace, @project) + @project_member = @project.project_members.find(params[:id]) if @project_member.invite? @project_member.resend_invite - redirect_to namespace_project_project_members_path(@project.namespace, - @project), notice: 'Invite was successfully resent.' + redirect_to redirect_path, notice: 'The invitation was successfully resent.' else - redirect_to namespace_project_project_members_path(@project.namespace, - @project), alert: 'The invite has already been accepted.' + redirect_to redirect_path, alert: 'The invitation has already been accepted.' end end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 52d88133d5e..add0a776a63 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -3,7 +3,7 @@ module GroupsHelper if member.user "Are you sure you want to remove \"#{member.user.name}\" from \"#{group.name}\"?" else - "Are you sure you want to cancel invitation for \"#{member.invite_email}\" to join \"#{group.name}\"?" + "Are you sure you want to revoke the invitation for \"#{member.invite_email}\" to join \"#{group.name}\"?" end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 06e5d3b7383..c2a7732e6f0 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -3,7 +3,7 @@ module ProjectsHelper if member.user "You are going to remove #{member.user.name} from #{project.name} project team. Are you sure?" else - "You are going to cancel invitation for #{member.invite_email} to join #{project.name} project team. Are you sure?" + "You are going to revoke the invitation for #{member.invite_email} to join #{project.name} project team. Are you sure?" end end diff --git a/app/mailers/emails/groups.rb b/app/mailers/emails/groups.rb index f3da3990c5f..07b8f6d735a 100644 --- a/app/mailers/emails/groups.rb +++ b/app/mailers/emails/groups.rb @@ -14,8 +14,9 @@ module Emails @group = @group_member.group @token = token @target_url = group_url(@group) + @current_user = @group_member.user mail(to: @group_member.invite_email, - subject: "Invite to join group #{@group.name}") + subject: "Invitation to join group #{@group.name}") end def group_invite_accepted_email(group_member_id) @@ -24,21 +25,21 @@ module Emails @group = @group_member.group @target_url = group_url(@group) + @current_user = @group_member.created_by mail(to: @group_member.created_by.notification_email, - subject: subject("Invite accepted")) + subject: subject("Invitation accepted")) end def group_invite_declined_email(group_id, invite_email, access_level, created_by_id) return if created_by_id.nil? @group = Group.find(group_id) - @created_by = User.find(created_by_id) + @current_user = @created_by = User.find(created_by_id) @access_level = access_level @invite_email = invite_email @target_url = group_url(@group) - mail(to: @created_by.notification_email, - subject: subject("Invite declined")) + subject: subject("Invitation declined")) end end end diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index bbfb7a3e409..7baeead3fb5 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -14,8 +14,9 @@ module Emails @project = @project_member.project @token = token @target_url = namespace_project_url(@project.namespace, @project) + @current_user = @project_member.user mail(to: @project_member.invite_email, - subject: "Invite to join project #{@project.name_with_namespace}") + subject: "Invitation to join project #{@project.name_with_namespace}") end def project_invite_accepted_email(project_member_id) @@ -24,21 +25,23 @@ module Emails @project = @project_member.project @target_url = namespace_project_url(@project.namespace, @project) + @current_user = @project_member.created_by + mail(to: @project_member.created_by.notification_email, - subject: subject("Invite accepted")) + subject: subject("Invitation accepted")) end def project_invite_declined_email(project_id, invite_email, access_level, created_by_id) return if created_by_id.nil? @project = Project.find(project_id) - @created_by = User.find(created_by_id) + @current_user = @created_by = User.find(created_by_id) @access_level = access_level @invite_email = invite_email @target_url = namespace_project_url(@project.namespace, @project) mail(to: @created_by.notification_email, - subject: subject("Invite declined")) + subject: subject("Invitation declined")) end def project_was_moved_email(project_id, user_id) From 1c0b58a7994f2942799c5ccdbdd37a48aa034f97 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 12:25:38 +0200 Subject: [PATCH 19/25] Remove duplication from InvitesController. --- app/controllers/invites_controller.rb | 45 +++++++++++++-------------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 00d274a7f1d..2a98fb0b48b 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -12,21 +12,9 @@ class InvitesController < ApplicationController def accept if member.accept_invite!(current_user) - case member.source - when Project - project = member.source - source = "project #{project.name_with_namespace}" - path = namespace_project_path(project.namespace, project) - when Group - group = member.source - source = "group #{group.name}" - path = group_path(group) - else - source = "who knows what" - path = dashboard_path - end + label, path = source_info(member.source) - redirect_to path, notice: "You have been granted #{member.human_access} access to #{source}." + redirect_to path, notice: "You have been granted #{member.human_access} access to #{label}." else redirect_to :back, alert: "The invitation could not be accepted." end @@ -34,16 +22,7 @@ class InvitesController < ApplicationController def decline if member.decline_invite! - case member.source - when Project - project = member.source - source = "project #{project.name_with_namespace}" - when Group - group = member.source - source = "group #{group.name}" - else - source = "who knows what" - end + label, _ = source_info(member.source) path = if current_user @@ -81,4 +60,22 @@ class InvitesController < ApplicationController store_location_for :user, request.fullpath redirect_to new_user_session_path, notice: notice end + + def source_info(source) + case source + when Project + project = member.source + label = "project #{project.name_with_namespace}" + path = namespace_project_path(project.namespace, project) + when Group + group = member.source + label = "group #{group.name}" + path = group_path(group) + else + label = "who knows what" + path = dashboard_path + end + + [label, path] + end end From a8b462bbfe3647574e17c483aad27281c2adc5f2 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 12:25:47 +0200 Subject: [PATCH 20/25] Clear up InvitesController member lookup logic. --- app/controllers/invites_controller.rb | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 2a98fb0b48b..3ac8654fcce 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -43,11 +43,10 @@ class InvitesController < ApplicationController return @member if defined?(@member) @token = params[:id] - if member = Member.find_by_invite_token(@token) - @member = member - else - render_404 - end + + @member = Member.find_by_invite_token(@token) + + render_404 unless @member end def authenticate_user! From 0013ea5130494326f37fb1d6988a369fa2c91d22 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 12:33:27 +0200 Subject: [PATCH 21/25] Remove duplication between Group and ProjectMember. --- .../projects/project_members_controller.rb | 3 +- app/models/group.rb | 21 +--------- app/models/member.rb | 36 +++++++++++++++-- app/models/members/project_member.rb | 19 +-------- .../{members_spec.rb => member_spec.rb} | 39 ++++++++++++++++++- 5 files changed, 75 insertions(+), 43 deletions(-) rename spec/models/{members_spec.rb => member_spec.rb} (71%) diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 9831a2089be..3da44cbc9d6 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -52,8 +52,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController respond_to do |format| format.html do - redirect_to namespace_project_project_members_path(@project.namespace, - @project) + redirect_to namespace_project_project_members_path(@project.namespace, @project) end format.js { render nothing: true } end diff --git a/app/models/group.rb b/app/models/group.rb index 2de397f90f7..1386a9eccc9 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -47,25 +47,8 @@ class Group < Namespace end def add_users(user_ids, access_level, current_user = nil) - users = user_ids.map do |user_id| - (user_id if user_id.is_a?(User)) || - User.find_by(id: user_id) || - User.find_by(email: user_id) || - user_id - end - - users.compact.each do |user| - if user.is_a?(User) - member = self.group_members.find_or_initialize_by(user_id: user.id) - else - member = self.group_members.build - member.invite_email = user - end - - member.created_by ||= current_user - member.access_level = access_level - - member.save + user_ids.each do |user_id| + Member.add_user(self.group_members, user_id, access_level, current_user) end end diff --git a/app/models/member.rb b/app/models/member.rb index 2421222eaa2..9340c442172 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -52,9 +52,39 @@ class Member < ActiveRecord::Base delegate :name, :username, :email, to: :user, prefix: true - def self.find_by_invite_token(invite_token) - invite_token = Devise.token_generator.digest(self, :invite_token, invite_token) - find_by(invite_token: invite_token) + class << self + def find_by_invite_token(invite_token) + invite_token = Devise.token_generator.digest(self, :invite_token, invite_token) + find_by(invite_token: invite_token) + end + + # This method is used to find users that have been entered into the "Add members" field. + # These can be the User objects directly, their IDs, their emails, or new emails to be invited. + def user_for_id(user_id) + return user_id if user_id.is_a?(User) + + user = User.find_by(id: user_id) + user ||= User.find_by(email: user_id) + user ||= user_id + user + end + + def add_user(members, user_id, access_level, current_user = nil) + user = user_for_id(user_id) + + # `user` can be either a User object or an email to be invited + if user.is_a?(User) + member = members.find_or_initialize_by(user_id: user.id) + else + member = members.build + member.invite_email = user + end + + member.created_by ||= current_user + member.access_level = access_level + + member.save + end end def invite? diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 8af7499dd82..ce704a60d63 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -60,29 +60,14 @@ class ProjectMember < Member raise "Non valid access" end - users = user_ids.map do |user_id| - (user_id if user_id.is_a?(User)) || - User.find_by(id: user_id) || - User.find_by(email: user_id) || - user_id - end + users = user_ids.map { |user_id| Member.user_for_id(user_id) } ProjectMember.transaction do project_ids.each do |project_id| project = Project.find(project_id) users.each do |user| - if user.is_a?(User) - member = project.project_members.find_or_initialize_by(user_id: user.id) - else - member = project.project_members.build - member.invite_email = user - end - - member.created_by ||= current_user - member.access_level = access_level - - member.save + Member.add_user(project.project_members, user, access_level, current_user) end end end diff --git a/spec/models/members_spec.rb b/spec/models/member_spec.rb similarity index 71% rename from spec/models/members_spec.rb rename to spec/models/member_spec.rb index c3e47ab7e9a..a27931cd4e4 100644 --- a/spec/models/members_spec.rb +++ b/spec/models/member_spec.rb @@ -58,6 +58,43 @@ describe Member do it { is_expected.to respond_to(:user_email) } end + describe ".add_user" do + let!(:user) { create(:user) } + let(:project) { create(:project) } + + context "when called with a user id" do + it "adds the user as a member" do + Member.add_user(project.project_members, user.id, ProjectMember::MASTER) + + expect(project.users).to include(user) + end + end + + context "when called with a user object" do + it "adds the user as a member" do + Member.add_user(project.project_members, user, ProjectMember::MASTER) + + expect(project.users).to include(user) + end + end + + context "when called with a known user email" do + it "adds the user as a member" do + Member.add_user(project.project_members, user.email, ProjectMember::MASTER) + + expect(project.users).to include(user) + end + end + + context "when called with an unknown user email" do + it "adds a member invite" do + Member.add_user(project.project_members, "user@example.com", ProjectMember::MASTER) + + expect(project.project_members.invite.pluck(:invite_email)).to include("user@example.com") + end + end + end + describe "#accept_invite!" do let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } @@ -89,7 +126,6 @@ describe Member do end describe "#decline_invite!" do - let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } it "destroys the member" do @@ -106,7 +142,6 @@ describe Member do end describe "#generate_invite_token" do - let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } it "sets the invite token" do From cb5362e7ce0496e4fa7bec4ae629fd24e93d721e Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 15:16:00 +0200 Subject: [PATCH 22/25] Follow newline guidelines. --- app/controllers/groups/group_members_controller.rb | 7 +++++-- app/controllers/invites_controller.rb | 7 +++++-- app/controllers/projects/project_members_controller.rb | 1 + app/mailers/emails/groups.rb | 7 +++++++ app/mailers/emails/projects.rb | 6 ++++++ app/models/members/project_member.rb | 1 + spec/models/member_spec.rb | 3 --- 7 files changed, 25 insertions(+), 7 deletions(-) diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 359eeb4d27a..265cf4f0f4a 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -48,13 +48,16 @@ class Groups::GroupMembersController < Groups::ApplicationController end def resend_invite + redirect_path = group_group_members_path(@group) + @group_member = @group.group_members.find(params[:id]) + if @group_member.invite? @group_member.resend_invite - redirect_to group_group_members_path(@group), notice: 'The invitation was successfully resent.' + redirect_to redirect_path, notice: 'The invitation was successfully resent.' else - redirect_to group_group_members_path(@group), alert: 'The invitation has already been accepted.' + redirect_to redirect_path, alert: 'The invitation has already been accepted.' end end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 3ac8654fcce..1f97ff16c55 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -43,10 +43,13 @@ class InvitesController < ApplicationController return @member if defined?(@member) @token = params[:id] - @member = Member.find_by_invite_token(@token) - render_404 unless @member + unless @member + render_404 and return + end + + @member end def authenticate_user! diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 3da44cbc9d6..72967a26ff1 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -62,6 +62,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController redirect_path = namespace_project_project_members_path(@project.namespace, @project) @project_member = @project.project_members.find(params[:id]) + if @project_member.invite? @project_member.resend_invite diff --git a/app/mailers/emails/groups.rb b/app/mailers/emails/groups.rb index 07b8f6d735a..1c43f95dc8c 100644 --- a/app/mailers/emails/groups.rb +++ b/app/mailers/emails/groups.rb @@ -3,8 +3,10 @@ module Emails def group_access_granted_email(group_member_id) @group_member = GroupMember.find(group_member_id) @group = @group_member.group + @target_url = group_url(@group) @current_user = @group_member.user + mail(to: @group_member.user.notification_email, subject: subject("Access to group was granted")) end @@ -13,8 +15,10 @@ module Emails @group_member = GroupMember.find group_member_id @group = @group_member.group @token = token + @target_url = group_url(@group) @current_user = @group_member.user + mail(to: @group_member.invite_email, subject: "Invitation to join group #{@group.name}") end @@ -24,8 +28,10 @@ module Emails return if @group_member.created_by.nil? @group = @group_member.group + @target_url = group_url(@group) @current_user = @group_member.created_by + mail(to: @group_member.created_by.notification_email, subject: subject("Invitation accepted")) end @@ -37,6 +43,7 @@ module Emails @current_user = @created_by = User.find(created_by_id) @access_level = access_level @invite_email = invite_email + @target_url = group_url(@group) mail(to: @created_by.notification_email, subject: subject("Invitation declined")) diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index 7baeead3fb5..2584e9d48b1 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -3,8 +3,10 @@ module Emails def project_access_granted_email(project_member_id) @project_member = ProjectMember.find project_member_id @project = @project_member.project + @target_url = namespace_project_url(@project.namespace, @project) @current_user = @project_member.user + mail(to: @project_member.user.notification_email, subject: subject("Access to project was granted")) end @@ -13,8 +15,10 @@ module Emails @project_member = ProjectMember.find project_member_id @project = @project_member.project @token = token + @target_url = namespace_project_url(@project.namespace, @project) @current_user = @project_member.user + mail(to: @project_member.invite_email, subject: "Invitation to join project #{@project.name_with_namespace}") end @@ -24,6 +28,7 @@ module Emails return if @project_member.created_by.nil? @project = @project_member.project + @target_url = namespace_project_url(@project.namespace, @project) @current_user = @project_member.created_by @@ -38,6 +43,7 @@ module Emails @current_user = @created_by = User.find(created_by_id) @access_level = access_level @invite_email = invite_email + @target_url = namespace_project_url(@project.namespace, @project) mail(to: @created_by.notification_email, diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index ce704a60d63..0a3b4d2182b 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -80,6 +80,7 @@ class ProjectMember < Member def truncate_teams(project_ids) ProjectMember.transaction do members = ProjectMember.where(source_id: project_ids) + members.each do |member| member.destroy end diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index a27931cd4e4..56d030a03b3 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -13,7 +13,6 @@ describe Member do it { is_expected.to validate_inclusion_of(:access_level).in_array(Gitlab::Access.values) } context "when an invite email is provided" do - let(:member) { build(:project_member, invite_email: "user@example.com", user: nil) } it "doesn't require a user" do @@ -38,7 +37,6 @@ describe Member do end context "when an invite email is not provided" do - let(:member) { build(:project_member) } it "requires a user" do @@ -96,7 +94,6 @@ describe Member do end describe "#accept_invite!" do - let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } let(:user) { create(:user) } From c1531fe1d801b175d8ecf7646592b1f5e9a8e508 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 18:04:29 +0200 Subject: [PATCH 23/25] Add spinach tests around accepting and declining invitations. --- app/models/member.rb | 2 + db/schema.rb | 3 +- features/invites.feature | 45 ++++++++++++++++++++++ features/steps/invites.rb | 80 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 features/invites.feature create mode 100644 features/steps/invites.rb diff --git a/app/models/member.rb b/app/models/member.rb index 9340c442172..d151c7b2390 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -22,6 +22,8 @@ class Member < ActiveRecord::Base include Notifiable include Gitlab::Access + attr_accessor :raw_invite_token + belongs_to :created_by, class_name: "User" belongs_to :user belongs_to :source, polymorphic: true diff --git a/db/schema.rb b/db/schema.rb index 11371f168b9..70ef1bb8ea6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20150411180045) do +ActiveRecord::Schema.define(version: 20150413192223) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -481,6 +481,7 @@ ActiveRecord::Schema.define(version: 20150411180045) do t.string "bitbucket_access_token" t.string "bitbucket_access_token_secret" t.string "location" + t.string "public_email", default: "", null: false end add_index "users", ["admin"], name: "index_users_on_admin", using: :btree diff --git a/features/invites.feature b/features/invites.feature new file mode 100644 index 00000000000..dc8eefaeaed --- /dev/null +++ b/features/invites.feature @@ -0,0 +1,45 @@ +Feature: Invites + Background: + Given "John Doe" is owner of group "Owned" + And "John Doe" has invited "user@example.com" to group "Owned" + + Scenario: Viewing invitation when signed out + When I visit the invitation page + Then I should be redirected to the sign in page + And I should see a notice telling me to sign in + + Scenario: Signing in to view invitation + When I visit the invitation page + And I sign in as "Mary Jane" + Then I should be redirected to the invitation page + + Scenario: Viewing invitation when signed in + Given I sign in as "Mary Jane" + And I visit the invitation page + Then I should see the invitation details + And I should see an "Accept invitation" button + And I should see a "Decline" button + + Scenario: Viewing invitation as an existing member + Given I sign in as "John Doe" + And I visit the invitation page + Then I should see a message telling me I'm already a member + + Scenario: Accepting the invitation + Given I sign in as "Mary Jane" + And I visit the invitation page + And I click the "Accept invitation" button + Then I should be redirected to the group page + And I should see a notice telling me I have access + + Scenario: Declining the application when signed in + Given I sign in as "Mary Jane" + And I visit the invitation page + And I click the "Decline" button + Then I should be redirected to the dashboard + And I should see a notice telling me I have declined + + Scenario: Declining the application when signed out + When I visit the invitation's decline page + Then I should be redirected to the sign in page + And I should see a notice telling me I have declined diff --git a/features/steps/invites.rb b/features/steps/invites.rb new file mode 100644 index 00000000000..d051cc3edc8 --- /dev/null +++ b/features/steps/invites.rb @@ -0,0 +1,80 @@ +class Spinach::Features::Invites < Spinach::FeatureSteps + include SharedAuthentication + include SharedUser + include SharedGroup + + step '"John Doe" has invited "user@example.com" to group "Owned"' do + user = User.find_by(name: "John Doe") + group = Group.find_by(name: "Owned") + group.add_user("user@example.com", Gitlab::Access::DEVELOPER, user) + end + + step 'I visit the invitation page' do + group = Group.find_by(name: "Owned") + invite = group.group_members.invite.last + invite.generate_invite_token! + @raw_invite_token = invite.raw_invite_token + visit invite_path(@raw_invite_token) + end + + step 'I should be redirected to the sign in page' do + expect(current_path).to eq(new_user_session_path) + end + + step 'I should see a notice telling me to sign in' do + expect(page).to have_content "To accept this invitation, sign in" + end + + step 'I should be redirected to the invitation page' do + expect(current_path).to eq(invite_path(@raw_invite_token)) + end + + step 'I should see the invitation details' do + expect(page).to have_content("You have been invited by John Doe to join group Owned as Developer.") + end + + step "I should see a message telling me I'm already a member" do + expect(page).to have_content("However, you are already a member of this group.") + end + + step 'I should see an "Accept invitation" button' do + expect(page).to have_link("Accept invitation") + end + + step 'I should see a "Decline" button' do + expect(page).to have_link("Decline") + end + + step 'I click the "Accept invitation" button' do + page.click_link "Accept invitation" + end + + step 'I should be redirected to the group page' do + group = Group.find_by(name: "Owned") + expect(current_path).to eq(group_path(group)) + end + + step 'I should see a notice telling me I have access' do + expect(page).to have_content("You have been granted Developer access to group Owned.") + end + + step 'I click the "Decline" button' do + page.click_link "Decline" + end + + step 'I should be redirected to the dashboard' do + expect(current_path).to eq(dashboard_path) + end + + step 'I should see a notice telling me I have declined' do + expect(page).to have_content("You have declined the invitation to join group Owned.") + end + + step "I visit the invitation's decline page" do + group = Group.find_by(name: "Owned") + invite = group.group_members.invite.last + invite.generate_invite_token! + @raw_invite_token = invite.raw_invite_token + visit decline_invite_path(@raw_invite_token) + end +end From 88c2639356e6e4b4b05d96584768e07a9ce4b048 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 14 Apr 2015 23:06:21 +0200 Subject: [PATCH 24/25] Remove superfluous info from invite accepted/declined mails. --- app/views/notify/group_invite_accepted_email.html.haml | 3 +-- app/views/notify/group_invite_accepted_email.text.erb | 2 +- app/views/notify/group_invite_declined_email.html.haml | 3 +-- app/views/notify/group_invite_declined_email.text.erb | 2 +- app/views/notify/project_invite_accepted_email.html.haml | 3 +-- app/views/notify/project_invite_accepted_email.text.erb | 2 +- app/views/notify/project_invite_declined_email.html.haml | 3 +-- app/views/notify/project_invite_declined_email.text.erb | 2 +- 8 files changed, 8 insertions(+), 12 deletions(-) diff --git a/app/views/notify/group_invite_accepted_email.html.haml b/app/views/notify/group_invite_accepted_email.html.haml index d50d182102b..55efad384a7 100644 --- a/app/views/notify/group_invite_accepted_email.html.haml +++ b/app/views/notify/group_invite_accepted_email.html.haml @@ -2,6 +2,5 @@ #{@group_member.invite_email}, now known as #{link_to @group_member.user.name, user_url(@group_member.user)}, has accepted your invitation to join group - = link_to @group.name, group_url(@group) - as #{@group_member.human_access}. + #{link_to @group.name, group_url(@group)}. diff --git a/app/views/notify/group_invite_accepted_email.text.erb b/app/views/notify/group_invite_accepted_email.text.erb index 0072f5252da..f8b70f7a5a6 100644 --- a/app/views/notify/group_invite_accepted_email.text.erb +++ b/app/views/notify/group_invite_accepted_email.text.erb @@ -1,3 +1,3 @@ -<%= @group_member.invite_email %>, now known as <%= @group_member.user.name %>, has accepted your invitation to join group <%= @group.name %> as <%= @group_member.human_access %>. +<%= @group_member.invite_email %>, now known as <%= @group_member.user.name %>, has accepted your invitation to join group <%= @group.name %>. <%= group_url(@group) %> diff --git a/app/views/notify/group_invite_declined_email.html.haml b/app/views/notify/group_invite_declined_email.html.haml index 80f39c3f1ea..f9525d84fac 100644 --- a/app/views/notify/group_invite_declined_email.html.haml +++ b/app/views/notify/group_invite_declined_email.html.haml @@ -1,6 +1,5 @@ %p #{@invite_email} has declined your invitation to join group - = link_to @group.name, group_url(@group) - as #{Gitlab::Access.options_with_owner.key(@access_level)}. + #{link_to @group.name, group_url(@group)}. diff --git a/app/views/notify/group_invite_declined_email.text.erb b/app/views/notify/group_invite_declined_email.text.erb index 31cdac0e7fb..6c19a288d15 100644 --- a/app/views/notify/group_invite_declined_email.text.erb +++ b/app/views/notify/group_invite_declined_email.text.erb @@ -1,3 +1,3 @@ -<%= @invite_email %> has declined your invitation to join group <%= @group.name %> as <%= Gitlab::Access.options_with_owner.key(@access_level) %>. +<%= @invite_email %> has declined your invitation to join group <%= @group.name %>. <%= group_url(@group) %> diff --git a/app/views/notify/project_invite_accepted_email.html.haml b/app/views/notify/project_invite_accepted_email.html.haml index 0c01eef7621..7e58d30b10a 100644 --- a/app/views/notify/project_invite_accepted_email.html.haml +++ b/app/views/notify/project_invite_accepted_email.html.haml @@ -2,6 +2,5 @@ #{@project_member.invite_email}, now known as #{link_to @project_member.user.name, user_url(@project_member.user)}, has accepted your invitation to join project - = link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project) - as #{@project_member.human_access}. + #{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)}. diff --git a/app/views/notify/project_invite_accepted_email.text.erb b/app/views/notify/project_invite_accepted_email.text.erb index 0bbe274931e..fcbe752114d 100644 --- a/app/views/notify/project_invite_accepted_email.text.erb +++ b/app/views/notify/project_invite_accepted_email.text.erb @@ -1,3 +1,3 @@ -<%= @project_member.invite_email %>, now known as <%= @project_member.user.name %>, has accepted your invitation to join project <%= @project.name_with_namespace %> as <%= @project_member.human_access %>. +<%= @project_member.invite_email %>, now known as <%= @project_member.user.name %>, has accepted your invitation to join project <%= @project.name_with_namespace %>. <%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/notify/project_invite_declined_email.html.haml b/app/views/notify/project_invite_declined_email.html.haml index dad9f505b9e..c2d7e6f6e3a 100644 --- a/app/views/notify/project_invite_declined_email.html.haml +++ b/app/views/notify/project_invite_declined_email.html.haml @@ -1,6 +1,5 @@ %p #{@invite_email} has declined your invitation to join project - = link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project) - as #{Gitlab::Access.options_with_owner.key(@access_level)}. + #{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)}. diff --git a/app/views/notify/project_invite_declined_email.text.erb b/app/views/notify/project_invite_declined_email.text.erb index 06a0013cff5..484687fa51c 100644 --- a/app/views/notify/project_invite_declined_email.text.erb +++ b/app/views/notify/project_invite_declined_email.text.erb @@ -1,3 +1,3 @@ -<%= @invite_email %> has declined your invitation to join project <%= @project.name_with_namespace %> as <%= Gitlab::Access.options_with_owner.key(@access_level) %>. +<%= @invite_email %> has declined your invitation to join project <%= @project.name_with_namespace %>. <%= namespace_project_url(@project.namespace, @project) %> From 3d0c0bd922dadbf09c46f2b8acb58d44565394ce Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Wed, 15 Apr 2015 12:45:29 +0200 Subject: [PATCH 25/25] Add changelog item. --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index f133cb24780..bd3a5f3b1e7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,7 @@ Please view this file on the master branch, on stable branches it's out of date. v 7.10.0 (unreleased) + - Allow users to be invited by email to join a group or project. - Fix broken file browsing with a submodule that contains a relative link (Stan Hu) - Fix persistent XSS vulnerability around profile website URLs. - Fix project import URL regex to prevent arbitary local repos from being imported.