Commit 2f6397ac authored by peterhegman's avatar peterhegman

Add project level access tokens feature

Project level access tokens can be added from
"Settings" -> "Access Tokens". A bot user is created with an access
token and added to the project. Self-managed only feature.

Change `project-access-tokens` to `access-tokens` to make them more
flexible
parent 23346d01
<script>
import { GlDatepicker } from '@gitlab/ui';
export default {
name: 'ExpiresAtField',
components: { GlDatepicker },
};
</script>
<template>
<gl-datepicker :target="null" :min-date="new Date()">
<slot></slot>
</gl-datepicker>
</template>
import Vue from 'vue';
import ExpiresAtField from './components/expires_at_field.vue';
const initExpiresAtField = () => {
// eslint-disable-next-line no-new
new Vue({
el: document.querySelector('.js-access-tokens-expires-at'),
components: { ExpiresAtField },
});
};
export default initExpiresAtField;
import DueDateSelectors from '~/due_date_select';
import initExpiresAtField from '~/access_tokens';
document.addEventListener('DOMContentLoaded', () => new DueDateSelectors());
document.addEventListener('DOMContentLoaded', initExpiresAtField);
import DueDateSelectors from '~/due_date_select';
import initExpiresAtField from '~/access_tokens';
document.addEventListener('DOMContentLoaded', () => new DueDateSelectors());
document.addEventListener('DOMContentLoaded', initExpiresAtField);
import initExpiresAtField from '~/access_tokens';
document.addEventListener('DOMContentLoaded', initExpiresAtField);
......@@ -674,6 +674,7 @@ module ProjectsHelper
services#edit
hooks#index
hooks#edit
access_tokens#index
hook_logs#show
repository#show
ci_cd#show
......
- add_to_breadcrumbs "Users", admin_users_path
- add_to_breadcrumbs 'Users', admin_users_path
- breadcrumb_title @user.name
- page_title "Impersonation Tokens", @user.name, "Users"
- page_title 'Impersonation Tokens', @user.name, 'Users'
- type = s_('Profiles|impersonation')
= render 'admin/users/head'
.row.prepend-top-default
.col-lg-12
- if @new_impersonation_token
= render "shared/personal_access_tokens_created_container", new_token_value: @new_impersonation_token,
container_title: 'Your New Impersonation Token',
clipboard_button_title: _('Copy impersonation token')
= render 'shared/access_tokens/created_container',
type: type,
new_token_value: @new_impersonation_token
= render "shared/personal_access_tokens_form", path: admin_user_impersonation_tokens_path, impersonation: true, token: @impersonation_token, scopes: @scopes
= render 'shared/access_tokens/form',
type: type,
title: _('Add an impersonation token'),
path: admin_user_impersonation_tokens_path,
impersonation: true,
token: @impersonation_token,
scopes: @scopes
= render "shared/personal_access_tokens_table", impersonation: true, active_tokens: @active_impersonation_tokens, inactive_tokens: @inactive_impersonation_tokens
= render 'shared/access_tokens/table',
type: type,
impersonation: true,
active_tokens: @active_impersonation_tokens,
revoke_route_helper: ->(token) { revoke_admin_user_impersonation_token_path(token.user, token) }
......@@ -364,6 +364,11 @@
= link_to project_hooks_path(@project), title: _('Webhooks'), data: { qa_selector: 'webhooks_settings_link' } do
%span
= _('Webhooks')
- if project_access_token_available?(@project)
= nav_link(controller: [:access_tokens]) do
= link_to project_settings_access_tokens_path(@project), title: _('Access Tokens'), data: { qa_selector: 'access_tokens_settings_link' } do
%span
= _('Access Tokens')
= nav_link(controller: :repository) do
= link_to project_settings_repository_path(@project), title: _('Repository') do
%span
......
- breadcrumb_title s_('AccessTokens|Access Tokens')
- page_title s_('AccessTokens|Personal Access Tokens')
- @content_class = "limit-container-width" unless fluid_layout
- type = s_('Profiles|personal access')
- @content_class = 'limit-container-width' unless fluid_layout
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
......@@ -14,11 +15,20 @@
.col-lg-8
- if @new_personal_access_token
= render "shared/personal_access_tokens_created_container", new_token_value: @new_personal_access_token
= render 'shared/access_tokens/created_container',
type: type,
new_token_value: @new_personal_access_token
= render "shared/personal_access_tokens_form", path: profile_personal_access_tokens_path, impersonation: false, token: @personal_access_token, scopes: @scopes
= render 'shared/access_tokens/form',
type: type,
path: profile_personal_access_tokens_path,
token: @personal_access_token,
scopes: @scopes
= render "shared/personal_access_tokens_table", impersonation: false, active_tokens: @active_personal_access_tokens, inactive_tokens: @inactive_personal_access_tokens
= render 'shared/access_tokens/table',
type: type,
active_tokens: @active_personal_access_tokens,
revoke_route_helper: ->(token) { revoke_profile_personal_access_token_path(token) }
%hr
.row.prepend-top-default
......@@ -30,7 +40,7 @@
%p
= s_('AccessTokens|It cannot be used to access any other data.')
.col-lg-8.feed-token-reset
= label_tag :feed_token, s_('AccessTokens|Feed token'), class: "label-bold"
= label_tag :feed_token, s_('AccessTokens|Feed token'), class: 'label-bold'
= text_field_tag :feed_token, current_user.feed_token, class: 'form-control js-select-on-focus', readonly: true
%p.form-text.text-muted
- reset_link = link_to s_('AccessTokens|reset it'), [:reset, :feed_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.') }
......@@ -48,7 +58,7 @@
%p
= s_('AccessTokens|It cannot be used to access any other data.')
.col-lg-8.incoming-email-token-reset
= label_tag :incoming_email_token, s_('AccessTokens|Incoming email token'), class: "label-bold"
= label_tag :incoming_email_token, s_('AccessTokens|Incoming email token'), class: 'label-bold'
= text_field_tag :incoming_email_token, current_user.incoming_email_token, class: 'form-control js-select-on-focus', readonly: true
%p.form-text.text-muted
- reset_link = link_to s_('AccessTokens|reset it'), [:reset, :incoming_email_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any issue email addresses currently in use will stop working.') }
......
- breadcrumb_title s_('AccessTokens|Access Tokens')
- page_title _('Project Access Tokens')
- type = _('project access')
- @content_class = 'limit-container-width' unless fluid_layout
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
= page_title
%p
= _('You can generate an access token scoped to this project for each application that needs access to the GitLab API.')
%p
= _('You can also use project access tokens to authenticate against Git over HTTP.')
.col-lg-8
- if @new_project_access_token
= render 'shared/access_tokens/created_container',
type: type,
new_token_value: @new_project_access_token
= render 'shared/access_tokens/form',
type: type,
path: project_settings_access_tokens_path(@project),
token: @project_access_token,
scopes: @scopes,
prefix: :project_access_token
= render 'shared/access_tokens/table',
active_tokens: @active_project_access_tokens,
type: type,
revoke_route_helper: ->(token) { revoke_namespace_project_settings_access_token_path(id: token) },
no_active_tokens_message: _('This project has no active access tokens.')
- container_title = local_assigns.fetch(:container_title, _('Your New Personal Access Token'))
- clipboard_button_title = local_assigns.fetch(:clipboard_button_title, _('Copy personal access token'))
.created-personal-access-token-container
%h5.prepend-top-0
= container_title
= _('Your new %{type} token') % { type: type }
.form-group
.input-group
= text_field_tag 'created-personal-access-token', new_token_value, readonly: true, class: "qa-created-personal-access-token form-control js-select-on-focus", 'aria-describedby' => "created-token-help-block"
= text_field_tag 'created-personal-access-token', new_token_value, readonly: true, class: 'qa-created-access-token form-control js-select-on-focus', 'aria-describedby' => 'created-token-help-block'
%span.input-group-append
= clipboard_button(text: new_token_value, title: clipboard_button_title, placement: "left", class: "input-group-text btn-default btn-clipboard")
= clipboard_button(text: new_token_value, title: _('Copy %{type} token') % { type: type }, placement: 'left', class: 'input-group-text btn-default btn-clipboard')
%span#created-token-help-block.form-text.text-muted.text-danger
= _("Make sure you save it - you won't be able to access it again.")
......
- type = impersonation ? s_('Profiles|impersonation') : s_('Profiles|personal access')
- title = local_assigns.fetch(:title, _('Add a %{type} token') % { type: type })
- prefix = local_assigns.fetch(:prefix, :personal_access_token)
%h5.prepend-top-0
= _('Add a %{type} token') % { type: type }
= title
%p.profile-settings-content
= _("Pick a name for the application, and we'll give you a unique %{type} token.") % { type: type }
= form_for token, url: path, method: :post, html: { class: 'js-requires-input' } do |f|
= form_for token, as: prefix, url: path, method: :post, html: { class: 'js-requires-input' } do |f|
= form_errors(token)
.row
.form-group.col-md-6
= f.label :name, _('Name'), class: 'label-bold'
= f.text_field :name, class: "form-control", required: true, data: { qa_selector: 'personal_access_token_name_field' }
= f.text_field :name, class: 'form-control', required: true, data: { qa_selector: 'access_token_name_field' }
.row
.form-group.col-md-6
......@@ -21,11 +22,13 @@
= render_if_exists 'personal_access_tokens/callout_max_personal_access_token_lifetime'
= f.text_field :expires_at, class: "datepicker form-control", placeholder: 'YYYY-MM-DD', data: { qa_selector: 'expiry_date_field' }
.js-access-tokens-expires-at
%expires-at-field
= f.text_field :expires_at, class: 'datepicker form-control', placeholder: 'YYYY-MM-DD', autocomplete: 'off', data: { qa_selector: 'expiry_date_field' }
.form-group
= f.label :scopes, _('Scopes'), class: 'label-bold'
= render 'shared/tokens/scopes_form', prefix: 'personal_access_token', token: token, scopes: scopes
= render 'shared/tokens/scopes_form', prefix: prefix, token: token, scopes: scopes
.prepend-top-default
= f.submit _('Create %{type} token') % { type: type }, class: "btn btn-success", data: { qa_selector: 'create_token_button' }
= f.submit _('Create %{type} token') % { type: type }, class: 'btn btn-success', data: { qa_selector: 'create_token_button' }
- type = impersonation ? s_('Profiles|Impersonation') : s_('Profiles|Personal Access')
- no_active_tokens_message = local_assigns.fetch(:no_active_tokens_message, _('This user has no active %{type} tokens.') % { type: type })
- impersonation = local_assigns.fetch(:impersonation, false)
%hr
%h5
= _('Active %{type} Tokens (%{token_length})') % { type: type, token_length: active_tokens.length }
= _('Active %{type} tokens (%{token_length})') % { type: type, token_length: active_tokens.length }
- if impersonation
%p.profile-settings-content
= _("To see all the user's personal access tokens you must impersonate them first.")
......@@ -28,9 +30,8 @@
In #{distance_of_time_in_words_to_now(token.expires_at)}
- else
%span.token-never-expires-label= _('Never')
%td= token.scopes.present? ? token.scopes.join(", ") : _('<no scopes selected>')
- path = impersonation ? revoke_admin_user_impersonation_token_path(token.user, token) : revoke_profile_personal_access_token_path(token)
%td= link_to _('Revoke'), path, method: :put, class: "btn btn-danger float-right qa-revoke-button", data: { confirm: _('Are you sure you want to revoke this %{type} Token? This action cannot be undone.') % { type: type } }
%td= token.scopes.present? ? token.scopes.join(', ') : _('<no scopes selected>')
%td= link_to _('Revoke'), revoke_route_helper.call(token), method: :put, class: 'btn btn-danger float-right qa-revoke-button', data: { confirm: _('Are you sure you want to revoke this %{type} token? This action cannot be undone.') % { type: type } }
- else
.settings-message.text-center
= _('This user has no active %{type} Tokens.') % { type: type }
= no_active_tokens_message
......@@ -67,7 +67,7 @@
class: 'btn btn-default align-self-center mr-sm-2',
title: _('Resend invite')
- if user != current_user && member.can_update?
- if user != current_user && member.can_update? && !user&.project_bot?
= form_for member, remote: true, html: { class: "js-edit-member-form form-group #{'d-sm-flex' unless force_mobile_view}" } do |f|
= f.hidden_field :access_level
.member-form-control.dropdown{ class: [("mr-sm-2 d-sm-inline-block" unless force_mobile_view)] }
......@@ -117,7 +117,7 @@
method: :delete,
data: { confirm: leave_confirmation_message(member.source) },
class: "btn btn-remove align-self-center m-0 #{'ml-sm-2' unless force_mobile_view}"
- else
- elsif !user&.project_bot?
= link_to member,
method: :delete,
data: { confirm: remove_member_message(member), qa_selector: 'delete_member_button' },
......
......@@ -1111,7 +1111,7 @@ msgstr ""
msgid "Active"
msgstr ""
msgid "Active %{type} Tokens (%{token_length})"
msgid "Active %{type} tokens (%{token_length})"
msgstr ""
msgid "Active Sessions"
......@@ -1215,6 +1215,9 @@ msgstr ""
msgid "Add an existing issue"
msgstr ""
msgid "Add an impersonation token"
msgstr ""
msgid "Add an issue"
msgstr ""
......@@ -2644,7 +2647,7 @@ msgstr ""
msgid "Are you sure you want to reset the health check token?"
msgstr ""
msgid "Are you sure you want to revoke this %{type} Token? This action cannot be undone."
msgid "Are you sure you want to revoke this %{type} token? This action cannot be undone."
msgstr ""
msgid "Are you sure you want to revoke this nickname?"
......@@ -6000,6 +6003,9 @@ msgstr ""
msgid "Copy %{proxy_url}"
msgstr ""
msgid "Copy %{type} token"
msgstr ""
msgid "Copy Account ID to clipboard"
msgstr ""
......@@ -6045,9 +6051,6 @@ msgstr ""
msgid "Copy file path"
msgstr ""
msgid "Copy impersonation token"
msgstr ""
msgid "Copy key"
msgstr ""
......@@ -6063,9 +6066,6 @@ msgstr ""
msgid "Copy link to chart"
msgstr ""
msgid "Copy personal access token"
msgstr ""
msgid "Copy reference"
msgstr ""
......@@ -15895,9 +15895,6 @@ msgstr ""
msgid "Profiles|Give your individual key a title"
msgstr ""
msgid "Profiles|Impersonation"
msgstr ""
msgid "Profiles|Include private contributions on my profile"
msgstr ""
......@@ -15943,9 +15940,6 @@ msgstr ""
msgid "Profiles|Path"
msgstr ""
msgid "Profiles|Personal Access"
msgstr ""
msgid "Profiles|Position and size your new avatar"
msgstr ""
......@@ -16147,6 +16141,9 @@ msgstr ""
msgid "Project '%{project_name}' will be deleted on %{date}"
msgstr ""
msgid "Project Access Tokens"
msgstr ""
msgid "Project Audit Events"
msgstr ""
......@@ -21830,6 +21827,9 @@ msgstr ""
msgid "This project does not have billing enabled. To create a cluster, <a href=%{linkToBilling} target=\"_blank\" rel=\"noopener noreferrer\">enable billing <i class=\"fa fa-external-link\" aria-hidden=\"true\"></i></a> and try again."
msgstr ""
msgid "This project has no active access tokens."
msgstr ""
msgid "This project is archived and cannot be commented on."
msgstr ""
......@@ -21875,7 +21875,7 @@ msgstr ""
msgid "This user cannot be unlocked manually from GitLab"
msgstr ""
msgid "This user has no active %{type} Tokens."
msgid "This user has no active %{type} tokens."
msgstr ""
msgid "This user has no identities"
......@@ -24327,6 +24327,9 @@ msgstr ""
msgid "You can also upload existing files from your computer using the instructions below."
msgstr ""
msgid "You can also use project access tokens to authenticate against Git over HTTP."
msgstr ""
msgid "You can always edit this later"
msgstr ""
......@@ -24354,6 +24357,9 @@ msgstr ""
msgid "You can filter by 'days to merge' by clicking on the columns in the chart."
msgstr ""
msgid "You can generate an access token scoped to this project for each application that needs access to the GitLab API."
msgstr ""
msgid "You can get started by cloning the repository or start adding files to it with one of the following options."
msgstr ""
......@@ -24726,9 +24732,6 @@ msgstr ""
msgid "Your Groups"
msgstr ""
msgid "Your New Personal Access Token"
msgstr ""
msgid "Your Personal Access Tokens will expire in %{days_to_expire} days or less"
msgstr ""
......@@ -24837,6 +24840,9 @@ msgstr ""
msgid "Your name"
msgstr ""
msgid "Your new %{type} token"
msgstr ""
msgid "Your new SCIM token"
msgstr ""
......@@ -25961,6 +25967,9 @@ msgid_plural "projects"
msgstr[0] ""
msgstr[1] ""
msgid "project access"
msgstr ""
msgid "project avatar"
msgstr ""
......
......@@ -6,9 +6,9 @@ module QA
module Page
module Profile
class PersonalAccessTokens < Page::Base
view 'app/views/shared/_personal_access_tokens_form.html.haml' do
view 'app/views/shared/access_tokens/_form.html.haml' do
element :expiry_date_field
element :personal_access_token_name_field
element :access_token_name_field
element :create_token_button
end
......@@ -16,15 +16,15 @@ module QA
element :api_radio, 'qa-#{scope}-radio' # rubocop:disable QA/ElementWithPattern, Lint/InterpolationCheck
end
view 'app/views/shared/_personal_access_tokens_created_container.html.haml' do
element :created_personal_access_token
view 'app/views/shared/access_tokens/_created_container.html.haml' do
element :created_access_token
end
view 'app/views/shared/_personal_access_tokens_table.html.haml' do
view 'app/views/shared/access_tokens/_table.html.haml' do
element :revoke_button
end
def fill_token_name(name)
fill_element(:personal_access_token_name_field, name)
fill_element(:access_token_name_field, name)
end
def check_api
......@@ -36,7 +36,7 @@ module QA
end
def created_access_token
find_element(:created_personal_access_token, wait: 30).value
find_element(:created_access_token, wait: 30).value
end
def fill_expiry_date(date)
......
......@@ -70,7 +70,7 @@ describe 'Admin > Users > Impersonation Tokens', :js do
accept_confirm { click_on "Revoke" }
expect(page).to have_selector(".settings-message")
expect(no_personal_access_tokens_message).to have_text("This user has no active Impersonation Tokens.")
expect(no_personal_access_tokens_message).to have_text("This user has no active impersonation tokens.")
end
it "removes expired tokens from 'active' section" do
......@@ -79,7 +79,7 @@ describe 'Admin > Users > Impersonation Tokens', :js do
visit admin_user_impersonation_tokens_path(user_id: user.username)
expect(page).to have_selector(".settings-message")
expect(no_personal_access_tokens_message).to have_text("This user has no active Impersonation Tokens.")
expect(no_personal_access_tokens_message).to have_text("This user has no active impersonation tokens.")
end
end
end
......@@ -86,7 +86,7 @@ describe 'Profile > Personal Access Tokens', :js do
accept_confirm { click_on "Revoke" }
expect(page).to have_selector(".settings-message")
expect(no_personal_access_tokens_message).to have_text("This user has no active Personal Access Tokens.")
expect(no_personal_access_tokens_message).to have_text("This user has no active personal access tokens.")
end
it "removes expired tokens from 'active' section" do
......@@ -94,7 +94,7 @@ describe 'Profile > Personal Access Tokens', :js do
visit profile_personal_access_tokens_path
expect(page).to have_selector(".settings-message")
expect(no_personal_access_tokens_message).to have_text("This user has no active Personal Access Tokens.")
expect(no_personal_access_tokens_message).to have_text("This user has no active personal access tokens.")
end
context "when revocation fails" do
......
......@@ -34,7 +34,7 @@ describe 'Project members list' do
expect(second_row).to be_blank
end
it 'update user acess level', :js do
it 'update user access level', :js do
project.add_developer(user2)
visit_members_page
......@@ -86,6 +86,30 @@ describe 'Project members list' do
end
end
context 'project bots' do
let(:project_bot) { create(:user, :project_bot, name: 'project_bot') }
before do
project.add_maintainer(project_bot)
end
it 'does not show member form used to change roles or "Expiration date"' do
project_member = project.project_members.find_by(user_id: project_bot.id)
visit_members_page
expect(page).not_to have_selector("#edit_project_member_#{project_member.id}")
end
it 'does not show remove user button' do
project_member = project.project_members.find_by(user_id: project_bot.id)
visit_members_page
expect(page).not_to have_selector("#project_member_#{project_member.id} .btn-remove")
end
end
def add_user(id, role)
page.within ".invite-users-form" do
select2(id, from: "#user_ids", multiple: true)
......
# frozen_string_literal: true
require 'spec_helper'
describe 'Project > Settings > Access Tokens', :js do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
before_all do
project.add_maintainer(user)
end
before do
sign_in(user)
end
def create_project_access_token
bot_user = create(:user, :project_bot)
project.add_maintainer(bot_user)
create(:personal_access_token, user: bot_user)
end
def active_project_access_tokens
find('.table.active-tokens')
end
def no_project_access_tokens_message
find('.settings-message')
end
def created_project_access_token
find('#created-personal-access-token').value
end
describe 'token creation' do
it 'allows creation of a project access token' do
name = 'My project access token'
visit project_settings_access_tokens_path(project)
fill_in 'Name', with: name
# Set date to 1st of next month
find_field('Expires at').click
find('.pika-next').click
click_on '1'
# Scopes
check 'api'
check 'read_api'
click_on 'Create project access token'
expect(active_project_access_tokens).to have_text(name)
expect(active_project_access_tokens).to have_text('In')
expect(active_project_access_tokens).to have_text('api')
expect(active_project_access_tokens).to have_text('read_api')
expect(created_project_access_token).not_to be_empty
end
end
describe 'active tokens' do
let!(:project_access_token) { create_project_access_token }
it 'shows active project access tokens' do
visit project_settings_access_tokens_path(project)
expect(active_project_access_tokens).to have_text(project_access_token.name)
end
end
describe 'inactive tokens' do
let!(:project_access_token) { create_project_access_token }
no_active_tokens_text = 'This project has no active access tokens.'
it 'allows revocation of an active token' do
visit project_settings_access_tokens_path(project)
accept_confirm { click_on 'Revoke' }
expect(page).to have_selector('.settings-message')
expect(no_project_access_tokens_message).to have_text(no_active_tokens_text)
end
it 'removes expired tokens from active section' do
project_access_token.update(expires_at: 5.days.ago)
visit project_settings_access_tokens_path(project)
expect(page).to have_selector('.settings-message')
expect(no_project_access_tokens_message).to have_text(no_active_tokens_text)
end
end
end
......@@ -86,6 +86,7 @@ RSpec.shared_context 'project navbar structure' do
_('Members'),
_('Integrations'),
_('Webhooks'),
_('Access Tokens'),
_('Repository'),
_('CI / CD'),
_('Operations'),
......
......@@ -214,4 +214,30 @@ describe 'layouts/nav/sidebar/_project' do
end
end
end
describe 'project access tokens' do
context 'self-managed instance' do
before do
allow(Gitlab).to receive(:com?).and_return(false)
end
it 'displays "Access Tokens" nav item' do
render
expect(rendered).to have_link('Access Tokens', href: project_settings_access_tokens_path(project))
end
end
context 'gitlab.com' do
before do
allow(Gitlab).to receive(:com?).and_return(true)
end
it 'does not display "Access Tokens" nav item' do
render
expect(rendered).not_to have_link('Access Tokens', href: project_settings_access_tokens_path(project))
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