From 17c22156c5fa5663aae65178ed38cbeef9a80b7e Mon Sep 17 00:00:00 2001
From: David Alexander <davidpaulalexander@gmail.com>
Date: Mon, 14 Mar 2016 09:13:35 -0400
Subject: [PATCH] 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 ++++++
 ...ct_request_access_accepted_email.html.haml |  4 ++
 ...ect_request_access_accepted_email.text.erb |  3 +
 ...ject_request_access_denied_email.html.haml |  4 ++
 ...oject_request_access_denied_email.text.erb |  3 +
 .../project_members/_pending.html.haml        | 21 +++++++
 .../project_members/_project_member.html.haml | 15 ++++-
 .../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?
+          &nbsp;
+          = 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)
         &nbsp;
-        - 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
-- 
GitLab