Commit 17c22156 authored by David Alexander's avatar David Alexander Committed by Rémy Coutable

Initial implementation of user access request to projects

parent 0c0ef7df
class Projects::ProjectMembersController < Projects::ApplicationController class Projects::ProjectMembersController < Projects::ApplicationController
# Authorize # Authorize
before_action :authorize_admin_project_member!, except: [:leave, :index] before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access]
def index def index
@project_members = @project.project_members @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? if params[:search].present?
users = @project.users.search(params[:search]).to_a users = @project.users.search(params[:search]).to_a
...@@ -93,6 +93,33 @@ class Projects::ProjectMembersController < Projects::ApplicationController ...@@ -93,6 +93,33 @@ class Projects::ProjectMembersController < Projects::ApplicationController
end end
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 def apply_import
source_project = Project.find(params[:source_project_id]) source_project = Project.find(params[:source_project_id])
......
module ProjectsHelper module ProjectsHelper
def remove_from_project_team_message(project, member) def remove_from_project_team_message(project, member)
if member.user if !member.user
"You are going to remove #{member.user.name} from #{project.name} project team. Are you sure?"
else
"You are going to revoke the 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?"
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
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) def link_to_project(project)
link_to [project.namespace.becomes(Namespace), project], title: h(project.name) do link_to [project.namespace.becomes(Namespace), project], title: h(project.name) do
title = content_tag(:span, project.name, class: 'project-name') title = content_tag(:span, project.name, class: 'project-name')
......
...@@ -11,6 +11,48 @@ module Emails ...@@ -11,6 +11,48 @@ module Emails
subject: subject("Access to project was granted")) subject: subject("Access to project was granted"))
end 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) def project_member_invited_email(project_member_id, token)
@project_member = ProjectMember.find project_member_id @project_member = ProjectMember.find project_member_id
@project = @project_member.project @project = @project_member.project
......
...@@ -153,7 +153,7 @@ class Ability ...@@ -153,7 +153,7 @@ class Ability
RequestStore.store[key] ||= begin RequestStore.store[key] ||= begin
# Push abilities on the users team role # 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 || if project.owner == user ||
(project.group && project.group.has_owner?(user)) || (project.group && project.group.has_owner?(user)) ||
......
...@@ -27,7 +27,12 @@ class Member < ActiveRecord::Base ...@@ -27,7 +27,12 @@ class Member < ActiveRecord::Base
} }
scope :invite, -> { where(user_id: nil) } 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 :guests, -> { where(access_level: GUEST) }
scope :reporters, -> { where(access_level: REPORTER) } scope :reporters, -> { where(access_level: REPORTER) }
scope :developers, -> { where(access_level: DEVELOPER) } scope :developers, -> { where(access_level: DEVELOPER) }
...@@ -35,11 +40,16 @@ class Member < ActiveRecord::Base ...@@ -35,11 +40,16 @@ class Member < ActiveRecord::Base
scope :owners, -> { where(access_level: OWNER) } scope :owners, -> { where(access_level: OWNER) }
before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? } before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? }
after_create :send_invite, if: :invite? after_create :send_invite, if: :invite?
after_create :create_notification_setting, unless: :invite? after_create :send_request_access, if: :request?
after_create :post_create_hook, unless: :invite?
after_update :post_update_hook, unless: :invite? after_create :create_notification_setting, unless: :pending?
after_destroy :post_destroy_hook, unless: :invite? 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 delegate :name, :username, :email, to: :user, prefix: true
...@@ -96,10 +106,38 @@ class Member < ActiveRecord::Base ...@@ -96,10 +106,38 @@ class Member < ActiveRecord::Base
end end
end end
def pending?
request? || invite?
end
def request?
self.requested
end
def invite? def invite?
self.invite_token.present? self.invite_token.present?
end 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) def accept_invite!(new_user)
return false unless invite? return false unless invite?
...@@ -153,6 +191,10 @@ class Member < ActiveRecord::Base ...@@ -153,6 +191,10 @@ class Member < ActiveRecord::Base
private private
def send_request_access
# override in subclass
end
def send_invite def send_invite
# override in subclass # override in subclass
end end
...@@ -169,6 +211,14 @@ class Member < ActiveRecord::Base ...@@ -169,6 +211,14 @@ class Member < ActiveRecord::Base
system_hook_service.execute_hooks_for(self, :destroy) system_hook_service.execute_hooks_for(self, :destroy)
end end
def after_accept_request_access
post_create_hook
end
def after_decline_request_access
# override in subclass
end
def after_accept_invite def after_accept_invite
post_create_hook post_create_hook
end end
......
...@@ -107,6 +107,12 @@ class ProjectMember < Member ...@@ -107,6 +107,12 @@ class ProjectMember < Member
user.todos.where(project_id: source_id).destroy_all if user user.todos.where(project_id: source_id).destroy_all if user
end end
def send_request_access
notification_service.request_access_project_member(self)
super
end
def send_invite def send_invite
notification_service.invite_project_member(self, @raw_invite_token) notification_service.invite_project_member(self, @raw_invite_token)
...@@ -136,6 +142,18 @@ class ProjectMember < Member ...@@ -136,6 +142,18 @@ class ProjectMember < Member
super super
end 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 def after_accept_invite
notification_service.accept_project_invite(self) notification_service.accept_project_invite(self)
......
...@@ -115,6 +115,12 @@ class ProjectTeam ...@@ -115,6 +115,12 @@ class ProjectTeam
false false
end end
def pending?(user)
project.project_members.each do |member|
return member.pending? if member.user_id == user.id
end
end
def guest?(user) def guest?(user)
max_member_access(user.id) == Gitlab::Access::GUEST max_member_access(user.id) == Gitlab::Access::GUEST
end end
......
...@@ -173,6 +173,18 @@ class NotificationService ...@@ -173,6 +173,18 @@ class NotificationService
end end
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) def invite_project_member(project_member, token)
mailer.project_member_invited_email(project_member.id, token).deliver_later mailer.project_member_invited_email(project_member.id, token).deliver_later
end end
......
...@@ -8,6 +8,19 @@ ...@@ -8,6 +8,19 @@
= icon('caret-down') = icon('caret-down')
%ul.dropdown-menu.dropdown-menu-align-right %ul.dropdown-menu.dropdown-menu-align-right
= render 'layouts/nav/project_settings' = 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 %li.divider
- if can_edit - if can_edit
%li %li
...@@ -18,6 +31,11 @@ ...@@ -18,6 +31,11 @@
= link_to leave_namespace_project_project_members_path(@project.namespace, @project), = link_to leave_namespace_project_project_members_path(@project.namespace, @project),
data: { confirm: leave_project_message(@project) }, method: :delete, title: 'Leave project' do data: { confirm: leave_project_message(@project) }, method: :delete, title: 'Leave project' do
Leave Project 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 } %div{ class: nav_control_class }
%ul.nav-links.scrolling-tabs %ul.nav-links.scrolling-tabs
......
%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.
Your request to join project <%= @project.name_with_namespace %> has been granted with <%= @project_member.human_access %> access.
<%= namespace_project_url(@project.namespace, @project) %>
%p
Your request to join project
#{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)}
has been denied.
Your request to join project <%= @project.name_with_namespace %> has been denied.
<%= namespace_project_url(@project.namespace, @project) %>
.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());
});
...@@ -13,6 +13,9 @@ ...@@ -13,6 +13,9 @@
- if user.blocked? - if user.blocked?
%label.label.label-danger %label.label.label-danger
%strong Blocked %strong Blocked
- if member.request?
%span.label.label-info
Pending Approval
- else - else
= image_tag avatar_icon(member.invite_email, 24), class: "avatar s24", alt: '' = image_tag avatar_icon(member.invite_email, 24), class: "avatar s24", alt: ''
%strong %strong
...@@ -27,7 +30,6 @@ ...@@ -27,7 +30,6 @@
- if can?(current_user, :admin_project_member, @project) - 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 = 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 Resend invite
- if can?(current_user, :admin_project_member, @project) - if can?(current_user, :admin_project_member, @project)
.pull-right .pull-right
%strong= member.human_access %strong= member.human_access
...@@ -35,10 +37,19 @@ ...@@ -35,10 +37,19 @@
= button_tag class: "btn-xs btn-grouped inline btn js-toggle-button", = button_tag class: "btn-xs btn-grouped inline btn js-toggle-button",
title: 'Edit access level', type: 'button' do title: 'Edit access level', type: 'button' do
= icon('pencil') = 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) - if can?(current_user, :destroy_project_member, member)
&nbsp; &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 = 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") = icon("sign-out")
Leave Leave
......
...@@ -12,8 +12,9 @@ ...@@ -12,8 +12,9 @@
%p.light %p.light
Users with access to this project are listed below. Users with access to this project are listed below.
= render "new_project_member" = render "new_project_member"
= render "pending", members: @project_members.request
= render "team", members: @project_members = render "team", members: @project_members.non_request
- if @group - if @group
= render "group_members", members: @group_members = render "group_members", members: @group_members
......
...@@ -768,6 +768,7 @@ Rails.application.routes.draw do ...@@ -768,6 +768,7 @@ Rails.application.routes.draw do
resources :project_members, except: [:new, :edit], constraints: { id: /[a-zA-Z.\/0-9_\-#%+]+/ } do resources :project_members, except: [:new, :edit], constraints: { id: /[a-zA-Z.\/0-9_\-#%+]+/ } do
collection do collection do
delete :leave delete :leave
post :request_access
# Used for import team # Used for import team
# from another project # from another project
...@@ -777,6 +778,7 @@ Rails.application.routes.draw do ...@@ -777,6 +778,7 @@ Rails.application.routes.draw do
member do member do
post :resend_invite post :resend_invite
post :approval
end end
end end
......
class AddMembershipRequest < ActiveRecord::Migration
def change
add_column :members, :requested, :boolean
end
end
...@@ -536,6 +536,7 @@ ActiveRecord::Schema.define(version: 20160610301627) do ...@@ -536,6 +536,7 @@ ActiveRecord::Schema.define(version: 20160610301627) do
t.string "invite_email" t.string "invite_email"
t.string "invite_token" t.string "invite_token"
t.datetime "invite_accepted_at" t.datetime "invite_accepted_at"
t.boolean "requested"
end end
add_index "members", ["access_level"], name: "index_members_on_access_level", using: :btree add_index "members", ["access_level"], name: "index_members_on_access_level", using: :btree
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment