From 17c22156c5fa5663aae65178ed38cbeef9a80b7e Mon Sep 17 00:00:00 2001 From: David Alexander Date: Mon, 14 Mar 2016 09:13:35 -0400 Subject: Initial implementation of user access request to projects --- .../projects/project_members_controller.rb | 31 ++++++++++- app/helpers/projects_helper.rb | 12 +++-- app/mailers/emails/projects.rb | 42 +++++++++++++++ app/models/ability.rb | 2 +- app/models/member.rb | 60 ++++++++++++++++++++-- app/models/members/project_member.rb | 18 +++++++ app/models/project_team.rb | 6 +++ app/services/notification_service.rb | 12 +++++ app/views/layouts/nav/_project.html.haml | 18 +++++++ ...project_request_access_accepted_email.html.haml | 4 ++ .../project_request_access_accepted_email.text.erb | 3 ++ .../project_request_access_denied_email.html.haml | 4 ++ .../project_request_access_denied_email.text.erb | 3 ++ .../projects/project_members/_pending.html.haml | 21 ++++++++ .../project_members/_project_member.html.haml | 15 +++++- app/views/projects/project_members/index.html.haml | 3 +- config/routes.rb | 2 + .../20160314114439_add_membership_request.rb | 5 ++ db/schema.rb | 1 + 19 files changed, 248 insertions(+), 14 deletions(-) create mode 100644 app/views/notify/project_request_access_accepted_email.html.haml create mode 100644 app/views/notify/project_request_access_accepted_email.text.erb create mode 100644 app/views/notify/project_request_access_denied_email.html.haml create mode 100644 app/views/notify/project_request_access_denied_email.text.erb create mode 100644 app/views/projects/project_members/_pending.html.haml create mode 100644 db/migrate/20160314114439_add_membership_request.rb diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index cdea5f0b776..ba5ef30be38 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -1,10 +1,10 @@ class Projects::ProjectMembersController < Projects::ApplicationController # Authorize - before_action :authorize_admin_project_member!, except: [:leave, :index] + before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access] def index @project_members = @project.project_members - @project_members = @project_members.non_invite unless can?(current_user, :admin_project, @project) + @project_members = @project_members.non_pending unless can?(current_user, :admin_project, @project) if params[:search].present? users = @project.users.search(params[:search]).to_a @@ -93,6 +93,33 @@ class Projects::ProjectMembersController < Projects::ApplicationController end end + def request_access + redirect_path = namespace_project_path(@project.namespace, @project) + # current_user + # @project + @project_member = ProjectMember.new(source: @project, access_level: ProjectMember::DEVELOPER, user_id: current_user.id, created_by_id: current_user.id, requested: true) + @project_member.save! + + + redirect_to redirect_path, notice: 'Your request for access has been queued for review.' + end + + def approval + @project_member = @project.project_members.find(params[:id]) + + return render_403 unless can?(current_user, :update_project_member, @project_member) + + @project_member.requested = nil + @project_member.save! + + respond_to do |format| + format.html do + redirect_to namespace_project_project_members_path(@project.namespace, @project) + end + format.js { render nothing: true } + end + end + def apply_import source_project = Project.find(params[:source_project_id]) diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 5e5d170a9f3..a015b5e6a02 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -1,12 +1,18 @@ module ProjectsHelper 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 + if !member.user "You are going to revoke the invitation for #{member.invite_email} to join #{project.name} project team. Are you sure?" + elsif member.request? + "You are going to deny #{member.user.name}'s request to join #{project.name} project team. Are you sure?" + else + "You are going to remove #{member.user.name} from #{project.name} project team. Are you sure?" end end + def approve_for_project_team_message(project, member) + "You are going to approve #{member.user.name}'s request for #{member.human_access} access to the #{project.name} project team. Are you sure?" + end + def link_to_project(project) link_to [project.namespace.becomes(Namespace), project], title: h(project.name) do title = content_tag(:span, project.name, class: 'project-name') diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index fdf1e9f5afc..6662c407c2c 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -11,6 +11,48 @@ module Emails subject: subject("Access to project was granted")) end + def project_member_requested_access(project_member_id) + @project_member = ProjectMember.find project_member_id + @project = @project_member.project + @target_url = namespace_project_url(@project.namespace, @project) + + project_admins = ProjectMember.in_project(@project) + .where(access_level: [Gitlab::Access::OWNER, Gitlab::Access::MASTER]) + .pluck(:notification_email) + + project_admins.each do |address| + mail(to: address, + subject: subject("Request to join project: #{@project.name_with_namespace}")) + end + end + + def project_request_access_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) + @current_user = @project_member.created_by + + mail(to: @project_member.created_by.notification_email, + subject: subject('Request for access granted')) + end + + def project_request_access_declined_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) + @current_user = @project_member.created_by + + mail(to: @project_member.created_by.notification_email, + subject: subject('Request for access declined')) + end + + def project_member_invited_email(project_member_id, token) @project_member = ProjectMember.find project_member_id @project = @project_member.project diff --git a/app/models/ability.rb b/app/models/ability.rb index aea946f9224..b3db26f989e 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -153,7 +153,7 @@ class Ability RequestStore.store[key] ||= begin # Push abilities on the users team role - rules.push(*project_team_rules(project.team, user)) + rules.push(*project_team_rules(project.team, user)) unless project.team.pending?(user) if project.owner == user || (project.group && project.group.has_owner?(user)) || diff --git a/app/models/member.rb b/app/models/member.rb index d3060f07fc0..2210e7dd66a 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -27,7 +27,12 @@ class Member < ActiveRecord::Base } scope :invite, -> { where(user_id: nil) } - scope :non_invite, -> { where("user_id IS NOT NULL") } + scope :non_invite, -> { where('user_id IS NOT NULL') } + scope :request, -> { where(requested: true) } + scope :non_request, -> { where(requested: nil) } + scope :pending, -> { where("user_id IS NULL OR requested") } + scope :non_pending, -> { self.non_invite.non_request } + scope :guests, -> { where(access_level: GUEST) } scope :reporters, -> { where(access_level: REPORTER) } scope :developers, -> { where(access_level: DEVELOPER) } @@ -35,11 +40,16 @@ class Member < ActiveRecord::Base 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 :create_notification_setting, unless: :invite? - after_create :post_create_hook, unless: :invite? - after_update :post_update_hook, unless: :invite? - after_destroy :post_destroy_hook, unless: :invite? + after_create :send_request_access, if: :request? + + after_create :create_notification_setting, unless: :pending? + after_create :post_create_hook, unless: :pending? + + after_update :post_update_hook, unless: :pending? + + after_destroy :post_destroy_hook, unless: :pending? delegate :name, :username, :email, to: :user, prefix: true @@ -96,10 +106,38 @@ class Member < ActiveRecord::Base end end + def pending? + request? || invite? + end + + def request? + self.requested + end + def invite? self.invite_token.present? end + def accept_request_access! + return false unless request? + + self.request = false + saved = self.save + + after_accept_request_access if saved + + saved + end + + def decline_request_access! + return false unless request? + + destroyed = self.destroy + after_decline_request_access if destroyed + + destroyed + end + def accept_invite!(new_user) return false unless invite? @@ -153,6 +191,10 @@ class Member < ActiveRecord::Base private + def send_request_access + # override in subclass + end + def send_invite # override in subclass end @@ -169,6 +211,14 @@ class Member < ActiveRecord::Base system_hook_service.execute_hooks_for(self, :destroy) end + def after_accept_request_access + post_create_hook + end + + def after_decline_request_access + # override in subclass + end + def after_accept_invite post_create_hook end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 46955b430f3..9db8db8450d 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -107,6 +107,12 @@ class ProjectMember < Member user.todos.where(project_id: source_id).destroy_all if user end + def send_request_access + notification_service.request_access_project_member(self) + + super + end + def send_invite notification_service.invite_project_member(self, @raw_invite_token) @@ -136,6 +142,18 @@ class ProjectMember < Member super end + def after_accept_request_access + notification_service.accept_project_request_access(self) + + super + end + + def after_decline_request_access + notification_service.decline_project_request_access(self) + + super + end + def after_accept_invite notification_service.accept_project_invite(self) diff --git a/app/models/project_team.rb b/app/models/project_team.rb index e29e854860a..769b73666ce 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -115,6 +115,12 @@ class ProjectTeam false end + def pending?(user) + project.project_members.each do |member| + return member.pending? if member.user_id == user.id + end + end + def guest?(user) max_member_access(user.id) == Gitlab::Access::GUEST end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 875a3f4fab6..e7676861e9b 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -173,6 +173,18 @@ class NotificationService end end + def request_access_project_member(project_member) + mailer.project_member_requested_access(project_member.id).deliver_later + end + + def accept_project_request_access(project_member) + mailer.project_request_access_accepted_email(project_member.id).deliver_later + end + + def decline_project_request_access(project_member) + mailer.project_request_access_declined_email(project_member.id).deliver_later + end + def invite_project_member(project_member, token) mailer.project_member_invited_email(project_member.id, token).deliver_later end diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 53d1fcc30a6..1336191bc5e 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -8,6 +8,19 @@ = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right = render 'layouts/nav/project_settings' + + - if access + %li + = link_to leave_namespace_project_project_members_path(@project.namespace, @project), + data: { confirm: leave_project_message(@project) }, method: :delete, title: 'Leave project' do + Leave Project + - else + = link_to request_access_namespace_project_project_members_path(@project.namespace, @project), + class: 'btn btn-gray', style: 'margin-left: 10px', method: :post, title: 'Request access' do + Request Access + + + %li.divider - if can_edit %li @@ -18,6 +31,11 @@ = link_to leave_namespace_project_project_members_path(@project.namespace, @project), data: { confirm: leave_project_message(@project) }, method: :delete, title: 'Leave project' do Leave Project + - else + %li + = link_to request_access_namespace_project_project_members_path(@project.namespace, @project), + class: 'btn btn-gray', style: 'margin-left: 10px', method: :post, title: 'Request access' do + Request Access %div{ class: nav_control_class } %ul.nav-links.scrolling-tabs diff --git a/app/views/notify/project_request_access_accepted_email.html.haml b/app/views/notify/project_request_access_accepted_email.html.haml new file mode 100644 index 00000000000..dfdf82e70a5 --- /dev/null +++ b/app/views/notify/project_request_access_accepted_email.html.haml @@ -0,0 +1,4 @@ +%p + Your request to join project + #{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)} + has been granted with #{@project_member.human_access} access. diff --git a/app/views/notify/project_request_access_accepted_email.text.erb b/app/views/notify/project_request_access_accepted_email.text.erb new file mode 100644 index 00000000000..9fb68874494 --- /dev/null +++ b/app/views/notify/project_request_access_accepted_email.text.erb @@ -0,0 +1,3 @@ +Your request to join project <%= @project.name_with_namespace %> has been granted with <%= @project_member.human_access %> access. + +<%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/notify/project_request_access_denied_email.html.haml b/app/views/notify/project_request_access_denied_email.html.haml new file mode 100644 index 00000000000..8ad75b96cf9 --- /dev/null +++ b/app/views/notify/project_request_access_denied_email.html.haml @@ -0,0 +1,4 @@ +%p + Your request to join project + #{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)} + has been denied. diff --git a/app/views/notify/project_request_access_denied_email.text.erb b/app/views/notify/project_request_access_denied_email.text.erb new file mode 100644 index 00000000000..a9c57e4cab4 --- /dev/null +++ b/app/views/notify/project_request_access_denied_email.text.erb @@ -0,0 +1,3 @@ +Your request to join project <%= @project.name_with_namespace %> has been denied. + +<%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/projects/project_members/_pending.html.haml b/app/views/projects/project_members/_pending.html.haml new file mode 100644 index 00000000000..88ac36937ac --- /dev/null +++ b/app/views/projects/project_members/_pending.html.haml @@ -0,0 +1,21 @@ +.panel.panel-default + .panel-heading + %strong #{@project.name} + candidates + %small + (#{members.count}) + .controls + = form_tag namespace_project_project_members_path(@project.namespace, @project), method: :get, class: 'form-inline member-search-form' do + .form-group + = search_field_tag :search, params[:search], { placeholder: 'Find existing member by name', class: 'form-control', spellcheck: false } + = button_tag class: 'btn', title: 'Search' do + = icon("search") + %ul.content-list + - members.each do |project_member| + = render 'project_member', member: project_member + +:javascript + $('form.member-search-form').on('submit', function (event) { + event.preventDefault(); + Turbolinks.visit(this.action + '?' + $(this).serialize()); + }); diff --git a/app/views/projects/project_members/_project_member.html.haml b/app/views/projects/project_members/_project_member.html.haml index 268f140d7db..3faf5dba8a2 100644 --- a/app/views/projects/project_members/_project_member.html.haml +++ b/app/views/projects/project_members/_project_member.html.haml @@ -13,6 +13,9 @@ - if user.blocked? %label.label.label-danger %strong Blocked + - if member.request? + %span.label.label-info + Pending Approval - else = image_tag avatar_icon(member.invite_email, 24), class: "avatar s24", alt: '' %strong @@ -27,7 +30,6 @@ - if can?(current_user, :admin_project_member, @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 can?(current_user, :admin_project_member, @project) .pull-right %strong= member.human_access @@ -35,10 +37,19 @@ = button_tag class: "btn-xs btn-grouped inline btn js-toggle-button", title: 'Edit access level', type: 'button' do = icon('pencil') + - if member.request? +   + = link_to approval_namespace_project_project_member_path(@project.namespace, @project, member), + class: "btn-xs btn btn-success", + title: 'Grant access', type: 'button' do + %i.fa.fa-check.fa-inverse - if can?(current_user, :destroy_project_member, member)   - - if current_user == user + - if member.request? + = link_to namespace_project_project_member_path(@project.namespace, @project, member), data: { confirm: remove_from_project_team_message(@project, member) }, method: :delete, class: "btn-xs btn btn-remove", title: 'Deny access' do + %i.fa.fa-times.fa-inverse + - elsif current_user == user = link_to leave_namespace_project_project_members_path(@project.namespace, @project), data: { confirm: leave_project_message(@project) }, method: :delete, class: "btn-xs btn btn-remove", title: 'Leave project' do = icon("sign-out") Leave diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 15dc064e7ea..d5a19799c49 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -12,8 +12,9 @@ %p.light Users with access to this project are listed below. = render "new_project_member" + = render "pending", members: @project_members.request - = render "team", members: @project_members + = render "team", members: @project_members.non_request - if @group = render "group_members", members: @group_members diff --git a/config/routes.rb b/config/routes.rb index 95fbe7dd9df..fb35bf9dcf0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -768,6 +768,7 @@ Rails.application.routes.draw do resources :project_members, except: [:new, :edit], constraints: { id: /[a-zA-Z.\/0-9_\-#%+]+/ } do collection do delete :leave + post :request_access # Used for import team # from another project @@ -777,6 +778,7 @@ Rails.application.routes.draw do member do post :resend_invite + post :approval end end diff --git a/db/migrate/20160314114439_add_membership_request.rb b/db/migrate/20160314114439_add_membership_request.rb new file mode 100644 index 00000000000..319b750e6c6 --- /dev/null +++ b/db/migrate/20160314114439_add_membership_request.rb @@ -0,0 +1,5 @@ +class AddMembershipRequest < ActiveRecord::Migration + def change + add_column :members, :requested, :boolean + end +end diff --git a/db/schema.rb b/db/schema.rb index 3dccbbd50ba..b59552fbbe7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -536,6 +536,7 @@ ActiveRecord::Schema.define(version: 20160610301627) do t.string "invite_email" t.string "invite_token" t.datetime "invite_accepted_at" + t.boolean "requested" end add_index "members", ["access_level"], name: "index_members_on_access_level", using: :btree -- cgit v1.2.1