Commit e55ffeae authored by Etienne Baqué's avatar Etienne Baqué Committed by Olena Horal-Koretska

Added Deploy Keys option in protected branches

Updated AccessDropdown component.
Updated Protected Branches view.
parent 653e3b2e
......@@ -2,8 +2,8 @@
import { escape, find, countBy } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import { deprecatedCreateFlash as Flash } from '~/flash';
import { n__, s__, __ } from '~/locale';
import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVEL_NONE } from './constants';
import { n__, s__, __, sprintf } from '~/locale';
import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVELS, ACCESS_LEVEL_NONE } from './constants';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
export default class AccessDropdown {
......@@ -11,6 +11,7 @@ export default class AccessDropdown {
const { $dropdown, accessLevel, accessLevelsData, hasLicense = true } = options;
this.options = options;
this.hasLicense = hasLicense;
this.deployKeysOnProtectedBranchesEnabled = gon.features.deployKeysOnProtectedBranches;
this.groups = [];
this.accessLevel = accessLevel;
this.accessLevelsData = accessLevelsData.roles;
......@@ -18,6 +19,7 @@ export default class AccessDropdown {
this.$wrap = this.$dropdown.closest(`.${this.accessLevel}-container`);
this.usersPath = '/-/autocomplete/users.json';
this.groupsPath = '/-/autocomplete/project_groups.json';
this.deployKeysPath = '/-/autocomplete/deploy_keys_with_owners.json';
this.defaultLabel = this.$dropdown.data('defaultLabel');
this.setSelectedItems([]);
......@@ -146,6 +148,8 @@ export default class AccessDropdown {
obj.access_level = item.access_level;
} else if (item.type === LEVEL_TYPES.USER) {
obj.user_id = item.user_id;
} else if (item.type === LEVEL_TYPES.DEPLOY_KEY) {
obj.deploy_key_id = item.deploy_key_id;
} else if (item.type === LEVEL_TYPES.GROUP) {
obj.group_id = item.group_id;
}
......@@ -177,6 +181,9 @@ export default class AccessDropdown {
case LEVEL_TYPES.GROUP:
comparator = LEVEL_ID_PROP.GROUP;
break;
case LEVEL_TYPES.DEPLOY_KEY:
comparator = LEVEL_ID_PROP.DEPLOY_KEY;
break;
case LEVEL_TYPES.USER:
comparator = LEVEL_ID_PROP.USER;
break;
......@@ -218,6 +225,11 @@ export default class AccessDropdown {
group_id: selectedItem.id,
type: LEVEL_TYPES.GROUP,
};
} else if (selectedItem.type === LEVEL_TYPES.DEPLOY_KEY) {
itemToAdd = {
deploy_key_id: selectedItem.id,
type: LEVEL_TYPES.DEPLOY_KEY,
};
}
this.items.push(itemToAdd);
......@@ -233,11 +245,12 @@ export default class AccessDropdown {
return true;
}
if (item.type === LEVEL_TYPES.USER && item.user_id === itemToDelete.id) {
index = i;
} else if (item.type === LEVEL_TYPES.ROLE && item.access_level === itemToDelete.id) {
index = i;
} else if (item.type === LEVEL_TYPES.GROUP && item.group_id === itemToDelete.id) {
if (
(item.type === LEVEL_TYPES.USER && item.user_id === itemToDelete.id) ||
(item.type === LEVEL_TYPES.ROLE && item.access_level === itemToDelete.id) ||
(item.type === LEVEL_TYPES.DEPLOY_KEY && item.deploy_key_id === itemToDelete.id) ||
(item.type === LEVEL_TYPES.GROUP && item.group_id === itemToDelete.id)
) {
index = i;
}
......@@ -289,6 +302,10 @@ export default class AccessDropdown {
labelPieces.push(n__('1 user', '%d users', counts[LEVEL_TYPES.USER]));
}
if (counts[LEVEL_TYPES.DEPLOY_KEY] > 0) {
labelPieces.push(n__('1 deploy key', '%d deploy keys', counts[LEVEL_TYPES.DEPLOY_KEY]));
}
if (counts[LEVEL_TYPES.GROUP] > 0) {
labelPieces.push(n__('1 group', '%d groups', counts[LEVEL_TYPES.GROUP]));
}
......@@ -299,20 +316,31 @@ export default class AccessDropdown {
getData(query, callback) {
if (this.hasLicense) {
Promise.all([
this.getDeployKeys(query),
this.getUsers(query),
this.groupsData ? Promise.resolve(this.groupsData) : this.getGroups(),
])
.then(([usersResponse, groupsResponse]) => {
.then(([deployKeysResponse, usersResponse, groupsResponse]) => {
this.groupsData = groupsResponse;
callback(this.consolidateData(usersResponse.data, groupsResponse.data));
callback(
this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse.data),
);
})
.catch(() => Flash(__('Failed to load groups & users.')));
.catch(() => {
if (this.deployKeysOnProtectedBranchesEnabled) {
Flash(__('Failed to load groups, users and deploy keys.'));
} else {
Flash(__('Failed to load groups & users.'));
}
});
} else {
callback(this.consolidateData());
this.getDeployKeys(query)
.then(deployKeysResponse => callback(this.consolidateData(deployKeysResponse.data)))
.catch(() => Flash(__('Failed to load deploy keys.')));
}
}
consolidateData(usersResponse = [], groupsResponse = []) {
consolidateData(deployKeysResponse, usersResponse = [], groupsResponse = []) {
let consolidatedData = [];
// ID property is handled differently locally from the server
......@@ -328,6 +356,10 @@ export default class AccessDropdown {
// For Users
// In dropdown: `id`
// For submit: `user_id`
//
// For Deploy Keys
// In dropdown: `id`
// For submit: `deploy_key_id`
/*
* Build roles
......@@ -410,6 +442,38 @@ export default class AccessDropdown {
}
}
if (this.deployKeysOnProtectedBranchesEnabled) {
const deployKeys = deployKeysResponse.map(response => {
const {
id,
fingerprint,
title,
owner: { avatar_url, name, username },
} = response;
const shortFingerprint = `(${fingerprint.substring(0, 14)}...)`;
return {
id,
title: title.concat(' ', shortFingerprint),
avatar_url,
fullname: name,
username,
type: LEVEL_TYPES.DEPLOY_KEY,
};
});
if (this.accessLevel === ACCESS_LEVELS.PUSH) {
if (deployKeys.length) {
consolidatedData = consolidatedData.concat(
[{ type: 'divider' }],
[{ type: 'header', content: s__('AccessDropdown|Deploy Keys') }],
deployKeys,
);
}
}
}
return consolidatedData;
}
......@@ -433,6 +497,22 @@ export default class AccessDropdown {
});
}
getDeployKeys(query) {
if (this.deployKeysOnProtectedBranchesEnabled) {
return axios.get(this.buildUrl(gon.relative_url_root, this.deployKeysPath), {
params: {
search: query,
per_page: 20,
active: true,
project_id: gon.current_project_id,
push_code: true,
},
});
}
return Promise.resolve({ data: [] });
}
buildUrl(urlRoot, url) {
let newUrl;
if (urlRoot != null) {
......@@ -454,6 +534,9 @@ export default class AccessDropdown {
case LEVEL_TYPES.ROLE:
criteria = { access_level: item.id };
break;
case LEVEL_TYPES.DEPLOY_KEY:
criteria = { deploy_key_id: item.id };
break;
case LEVEL_TYPES.GROUP:
criteria = { group_id: item.id };
break;
......@@ -470,6 +553,10 @@ export default class AccessDropdown {
case LEVEL_TYPES.ROLE:
groupRowEl = this.roleRowHtml(item, isActive);
break;
case LEVEL_TYPES.DEPLOY_KEY:
groupRowEl =
this.accessLevel === ACCESS_LEVELS.PUSH ? this.deployKeyRowHtml(item, isActive) : '';
break;
case LEVEL_TYPES.GROUP:
groupRowEl = this.groupRowHtml(item, isActive);
break;
......@@ -495,6 +582,31 @@ export default class AccessDropdown {
`;
}
deployKeyRowHtml(key, isActive) {
const isActiveClass = isActive || '';
return `
<li>
<a href="#" class="${isActiveClass}">
<strong>${key.title}</strong>
<p>
${sprintf(
__('Owned by %{image_tag}'),
{
image_tag: `<img src="${key.avatar_url}" class="avatar avatar-inline s26" width="30">`,
},
false,
)}
<strong class="dropdown-menu-user-full-name gl-display-inline">${escape(
key.fullname,
)}</strong>
<span class="dropdown-menu-user-username gl-display-inline">${key.username}</span>
</p>
</a>
</li>
`;
}
groupRowHtml(group, isActive) {
const isActiveClass = isActive || '';
const avatarEl = group.avatar_url
......
export const LEVEL_TYPES = {
ROLE: 'role',
USER: 'user',
DEPLOY_KEY: 'deploy_key',
GROUP: 'group',
};
export const LEVEL_ID_PROP = {
ROLE: 'access_level',
USER: 'user_id',
DEPLOY_KEY: 'deploy_key_id',
GROUP: 'group_id',
};
export const ACCESS_LEVELS = {
MERGE: 'merge_access_levels',
PUSH: 'push_access_levels',
};
export const ACCESS_LEVEL_NONE = 0;
......@@ -7,12 +7,14 @@ export const LEVEL_TYPES = {
ROLE: 'role',
USER: 'user',
GROUP: 'group',
DEPLOY_KEY: 'deploy_key',
};
export const LEVEL_ID_PROP = {
ROLE: 'access_level',
USER: 'user_id',
GROUP: 'group_id',
DEPLOY_KEY: 'deploy_key_id',
};
export const ACCESS_LEVEL_NONE = 0;
......@@ -108,6 +108,10 @@ export default class ProtectedBranchCreate {
levelAttributes.push({
group_id: item.group_id,
});
} else if (item.type === LEVEL_TYPES.DEPLOY_KEY) {
levelAttributes.push({
deploy_key_id: item.deploy_key_id,
});
}
});
......
......@@ -62,7 +62,7 @@ class Projects::ProtectedRefsController < Projects::ApplicationController
end
def access_level_attributes
%i[access_level id _destroy]
%i[access_level id _destroy deploy_key_id]
end
end
......
......@@ -7,6 +7,7 @@ module Projects
before_action :define_variables, only: [:create_deploy_token]
before_action do
push_frontend_feature_flag(:ajax_new_deploy_token, @project)
push_frontend_feature_flag(:deploy_keys_on_protected_branches, @project)
end
def show
......@@ -125,6 +126,7 @@ module Projects
gon.push(protectable_tags_for_dropdown)
gon.push(protectable_branches_for_dropdown)
gon.push(access_levels_options)
gon.push(current_project_id: project.id) if project
end
end
end
......
- select_mode_for_dropdown = Feature.enabled?(:deploy_keys_on_protected_branches, @project) ? 'js-multiselect' : ''
- content_for :merge_access_levels do
.merge_access_levels-container
= dropdown_tag('Select',
......@@ -7,7 +9,7 @@
- content_for :push_access_levels do
.push_access_levels-container
= dropdown_tag('Select',
options: { toggle_class: 'js-allowed-to-push qa-allowed-to-push-select wide',
options: { toggle_class: "js-allowed-to-push qa-allowed-to-push-select #{select_mode_for_dropdown} wide",
dropdown_class: 'dropdown-menu-selectable qa-allowed-to-push-dropdown rspec-allowed-to-push-dropdown capitalize-header',
data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }})
......
---
name: deploy_keys_on_protected_branches
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35638
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/247866
group: group::progressive delivery
type: development
default_enabled: false
......@@ -33,12 +33,6 @@ module EE
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
def load_gon_index
super
gon.push(current_project_id: project.id) if project
end
def render_show
push_rule
......
= render 'shared/projects/protected_branches/update_protected_branch', protected_branch: protected_branch
......@@ -9,6 +9,7 @@ RSpec.describe 'Protected Branches', :js do
let(:project) { create(:project, :repository) }
before do
stub_feature_flags(deploy_keys_on_protected_branches: false)
sign_in(user)
end
......@@ -187,4 +188,14 @@ RSpec.describe 'Protected Branches', :js do
end
end
end
context 'when the users for protected branches feature is on' do
before do
stub_licensed_features(protected_refs_for_users: true)
end
include_examples 'when the deploy_keys_on_protected_branches FF is turned on' do
let(:all_dropdown_sections) { %w(Roles Users Deploy\ Keys) }
end
end
end
......@@ -19,6 +19,7 @@ describe('EE ProtectedBranchEdit', () => {
</div>`);
jest.spyOn(ProtectedBranchEdit.prototype, 'buildDropdowns').mockImplementation();
gon.features = { deployKeysOnProtectedBranches: false };
mock = new MockAdapter(axios);
});
......
......@@ -1024,6 +1024,11 @@ msgid_plural "%d days"
msgstr[0] ""
msgstr[1] ""
msgid "1 deploy key"
msgid_plural "%d deploy keys"
msgstr[0] ""
msgstr[1] ""
msgid "1 group"
msgid_plural "%d groups"
msgstr[0] ""
......@@ -1324,6 +1329,9 @@ msgstr ""
msgid "Access to Pages websites are controlled based on the user's membership to a given project. By checking this box, users will be required to be logged in to have access to all Pages websites in your instance."
msgstr ""
msgid "AccessDropdown|Deploy Keys"
msgstr ""
msgid "AccessDropdown|Groups"
msgstr ""
......@@ -10644,6 +10652,9 @@ msgstr ""
msgid "Failed to load branches. Please try again."
msgstr ""
msgid "Failed to load deploy keys."
msgstr ""
msgid "Failed to load emoji list."
msgstr ""
......@@ -10659,6 +10670,9 @@ msgstr ""
msgid "Failed to load groups & users."
msgstr ""
msgid "Failed to load groups, users and deploy keys."
msgstr ""
msgid "Failed to load labels. Please try again."
msgstr ""
......@@ -17916,6 +17930,9 @@ msgstr ""
msgid "Overwrite diverged branches"
msgstr ""
msgid "Owned by %{image_tag}"
msgstr ""
msgid "Owned by anyone"
msgstr ""
......
......@@ -9,6 +9,10 @@ RSpec.describe 'Protected Branches', :js do
let(:admin) { create(:admin) }
let(:project) { create(:project, :repository) }
before do
stub_feature_flags(deploy_keys_on_protected_branches: false)
end
context 'logged in as developer' do
before do
project.add_developer(user)
......@@ -163,4 +167,14 @@ RSpec.describe 'Protected Branches', :js do
include_examples "protected branches > access control > CE"
end
end
context 'when the users for protected branches feature is off' do
before do
stub_licensed_features(protected_refs_for_users: false)
end
include_examples 'when the deploy_keys_on_protected_branches FF is turned on' do
let(:all_dropdown_sections) { %w(Roles Deploy\ Keys) }
end
end
end
......@@ -14,6 +14,7 @@ describe('AccessDropdown', () => {
`);
const $dropdown = $('#dummy-dropdown');
$dropdown.data('defaultLabel', defaultLabel);
gon.features = { deployKeysOnProtectedBranches: true };
const options = {
$dropdown,
accessLevelsData: {
......@@ -37,6 +38,9 @@ describe('AccessDropdown', () => {
{ type: LEVEL_TYPES.GROUP },
{ type: LEVEL_TYPES.GROUP },
{ type: LEVEL_TYPES.GROUP },
{ type: LEVEL_TYPES.DEPLOY_KEY },
{ type: LEVEL_TYPES.DEPLOY_KEY },
{ type: LEVEL_TYPES.DEPLOY_KEY },
];
beforeEach(() => {
......@@ -49,7 +53,7 @@ describe('AccessDropdown', () => {
const label = dropdown.toggleLabel();
expect(label).toBe('1 role, 2 users, 3 groups');
expect(label).toBe('1 role, 2 users, 3 deploy keys, 3 groups');
expect($dropdownToggleText).not.toHaveClass('is-default');
});
......@@ -122,6 +126,21 @@ describe('AccessDropdown', () => {
expect($dropdownToggleText).not.toHaveClass('is-default');
});
});
describe('with users and deploy keys', () => {
beforeEach(() => {
const selectedTypes = [LEVEL_TYPES.DEPLOY_KEY, LEVEL_TYPES.USER];
dropdown.setSelectedItems(dummyItems.filter(item => selectedTypes.includes(item.type)));
$dropdownToggleText.addClass('is-default');
});
it('displays number of deploy keys', () => {
const label = dropdown.toggleLabel();
expect(label).toBe('2 users, 3 deploy keys');
expect($dropdownToggleText).not.toHaveClass('is-default');
});
});
});
describe('userRowHtml', () => {
......
# frozen_string_literal: true
RSpec.shared_examples 'when the deploy_keys_on_protected_branches FF is turned on' do
before do
stub_feature_flags(deploy_keys_on_protected_branches: true)
project.add_maintainer(user)
sign_in(user)
end
let(:dropdown_sections_minus_deploy_keys) { all_dropdown_sections - ['Deploy Keys'] }
context 'when deploy keys are enabled to this project' do
let!(:deploy_key_1) { create(:deploy_key, title: 'title 1', projects: [project]) }
let!(:deploy_key_2) { create(:deploy_key, title: 'title 2', projects: [project]) }
context 'when only one deploy key can push' do
before do
deploy_key_1.deploy_keys_projects.first.update!(can_push: true)
end
it "shows all dropdown sections in the 'Allowed to push' main dropdown, with only one deploy key" do
visit project_protected_branches_path(project)
find(".js-allowed-to-push").click
wait_for_requests
within('.qa-allowed-to-push-dropdown') do
dropdown_headers = page.all('.dropdown-header').map(&:text)
expect(dropdown_headers).to contain_exactly(*all_dropdown_sections)
expect(page).to have_content('title 1')
expect(page).not_to have_content('title 2')
end
end
it "shows all sections but not deploy keys in the 'Allowed to merge' main dropdown" do
visit project_protected_branches_path(project)
find(".js-allowed-to-merge").click
wait_for_requests
within('.qa-allowed-to-merge-dropdown') do
dropdown_headers = page.all('.dropdown-header').map(&:text)
expect(dropdown_headers).to contain_exactly(*dropdown_sections_minus_deploy_keys)
end
end
it "shows all sections in the 'Allowed to push' update dropdown" do
create(:protected_branch, :no_one_can_push, project: project, name: 'master')
visit project_protected_branches_path(project)
within(".js-protected-branch-edit-form") do
find(".js-allowed-to-push").click
wait_for_requests
dropdown_headers = page.all('.dropdown-header').map(&:text)
expect(dropdown_headers).to contain_exactly(*all_dropdown_sections)
end
end
end
context 'when no deploy key can push' do
it "just shows all sections but not deploy keys in the 'Allowed to push' dropdown" do
visit project_protected_branches_path(project)
find(".js-allowed-to-push").click
wait_for_requests
within('.qa-allowed-to-push-dropdown') do
dropdown_headers = page.all('.dropdown-header').map(&:text)
expect(dropdown_headers).to contain_exactly(*dropdown_sections_minus_deploy_keys)
end
end
it "just shows all sections but not deploy keys in the 'Allowed to push' update dropdown" do
create(:protected_branch, :no_one_can_push, project: project, name: 'master')
visit project_protected_branches_path(project)
within(".js-protected-branch-edit-form") do
find(".js-allowed-to-push").click
wait_for_requests
dropdown_headers = page.all('.dropdown-header').map(&:text)
expect(dropdown_headers).to contain_exactly(*dropdown_sections_minus_deploy_keys)
end
end
end
end
end
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