Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
		
							parent
							
								
									dfd048606f
								
							
						
					
					
						commit
						7d0b125e88
					
				|  | @ -7,7 +7,7 @@ export const FORM_SELECTOR = '#js-new-access-token-form'; | |||
| export const INITIAL_PAGE = 1; | ||||
| export const PAGE_SIZE = 100; | ||||
| 
 | ||||
| export const FIELDS = [ | ||||
| const BASE_FIELDS = [ | ||||
|   { | ||||
|     key: 'name', | ||||
|     label: __('Token name'), | ||||
|  | @ -31,19 +31,35 @@ export const FIELDS = [ | |||
|     label: __('Last Used'), | ||||
|     sortable: true, | ||||
|   }, | ||||
| ]; | ||||
| 
 | ||||
| const ROLE_FIELD = { | ||||
|   key: 'role', | ||||
|   label: __('Role'), | ||||
|   sortable: true, | ||||
| }; | ||||
| 
 | ||||
| export const FIELDS = [ | ||||
|   ...BASE_FIELDS, | ||||
|   { | ||||
|     key: 'expiresAt', | ||||
|     label: __('Expires'), | ||||
|     sortable: true, | ||||
|   }, | ||||
|   { | ||||
|     key: 'role', | ||||
|     label: __('Role'), | ||||
|     sortable: true, | ||||
|   }, | ||||
|   ROLE_FIELD, | ||||
|   { | ||||
|     key: 'action', | ||||
|     label: __('Action'), | ||||
|     tdClass: 'gl-py-3!', | ||||
|   }, | ||||
| ]; | ||||
| 
 | ||||
| export const INACTIVE_TOKENS_TABLE_FIELDS = [ | ||||
|   ...BASE_FIELDS, | ||||
|   { | ||||
|     key: 'expiresAt', | ||||
|     label: __('Expired'), | ||||
|     sortable: true, | ||||
|   }, | ||||
|   ROLE_FIELD, | ||||
| ]; | ||||
|  |  | |||
|  | @ -0,0 +1,125 @@ | |||
| <script> | ||||
| import { GlIcon, GlLink, GlPagination, GlTable, GlTooltipDirective } from '@gitlab/ui'; | ||||
| import { helpPagePath } from '~/helpers/help_page_helper'; | ||||
| import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; | ||||
| import { __ } from '~/locale'; | ||||
| import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; | ||||
| import UserDate from '~/vue_shared/components/user_date.vue'; | ||||
| import { INACTIVE_TOKENS_TABLE_FIELDS, INITIAL_PAGE, PAGE_SIZE } from './constants'; | ||||
| 
 | ||||
| export default { | ||||
|   PAGE_SIZE, | ||||
|   name: 'InactiveAccessTokenTableApp', | ||||
|   components: { | ||||
|     GlIcon, | ||||
|     GlLink, | ||||
|     GlPagination, | ||||
|     GlTable, | ||||
|     TimeAgoTooltip, | ||||
|     UserDate, | ||||
|   }, | ||||
|   directives: { | ||||
|     GlTooltip: GlTooltipDirective, | ||||
|   }, | ||||
|   lastUsedHelpLink: helpPagePath('/user/profile/personal_access_tokens.md', { | ||||
|     anchor: 'view-the-last-time-a-token-was-used', | ||||
|   }), | ||||
|   i18n: { | ||||
|     emptyField: __('Never'), | ||||
|     expired: __('Expired'), | ||||
|     revoked: __('Revoked'), | ||||
|   }, | ||||
|   INACTIVE_TOKENS_TABLE_FIELDS, | ||||
|   inject: [ | ||||
|     'accessTokenType', | ||||
|     'accessTokenTypePlural', | ||||
|     'initialInactiveAccessTokens', | ||||
|     'noInactiveTokensMessage', | ||||
|   ], | ||||
|   data() { | ||||
|     return { | ||||
|       inactiveAccessTokens: convertObjectPropsToCamelCase(this.initialInactiveAccessTokens, { | ||||
|         deep: true, | ||||
|       }), | ||||
|       currentPage: INITIAL_PAGE, | ||||
|     }; | ||||
|   }, | ||||
|   computed: { | ||||
|     showPagination() { | ||||
|       return this.inactiveAccessTokens.length > PAGE_SIZE; | ||||
|     }, | ||||
|   }, | ||||
|   methods: { | ||||
|     sortingChanged(aRow, bRow, key) { | ||||
|       if (['createdAt', 'lastUsedAt', 'expiresAt'].includes(key)) { | ||||
|         // Transform `null` value to the latest possible date | ||||
|         // https://stackoverflow.com/a/11526569/18428169 | ||||
|         const maxEpoch = 8640000000000000; | ||||
|         const a = new Date(aRow[key] ?? maxEpoch).getTime(); | ||||
|         const b = new Date(bRow[key] ?? maxEpoch).getTime(); | ||||
|         return a - b; | ||||
|       } | ||||
| 
 | ||||
|       // For other columns the default sorting works OK | ||||
|       return false; | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|   <div> | ||||
|     <gl-table | ||||
|       data-testid="inactive-access-tokens" | ||||
|       :empty-text="noInactiveTokensMessage" | ||||
|       :fields="$options.INACTIVE_TOKENS_TABLE_FIELDS" | ||||
|       :items="inactiveAccessTokens" | ||||
|       :per-page="$options.PAGE_SIZE" | ||||
|       :current-page="currentPage" | ||||
|       :sort-compare="sortingChanged" | ||||
|       show-empty | ||||
|       stacked="sm" | ||||
|       class="gl-overflow-x-auto" | ||||
|     > | ||||
|       <template #cell(createdAt)="{ item: { createdAt } }"> | ||||
|         <user-date :date="createdAt" /> | ||||
|       </template> | ||||
| 
 | ||||
|       <template #head(lastUsedAt)="{ label }"> | ||||
|         <span>{{ label }}</span> | ||||
|         <gl-link :href="$options.lastUsedHelpLink" | ||||
|           ><gl-icon name="question-o" class="gl-ml-2" /><span class="gl-sr-only">{{ | ||||
|             s__('AccessTokens|The last time a token was used') | ||||
|           }}</span></gl-link | ||||
|         > | ||||
|       </template> | ||||
| 
 | ||||
|       <template #cell(lastUsedAt)="{ item: { lastUsedAt } }"> | ||||
|         <time-ago-tooltip v-if="lastUsedAt" :time="lastUsedAt" /> | ||||
|         <template v-else> {{ $options.i18n.emptyField }}</template> | ||||
|       </template> | ||||
| 
 | ||||
|       <template #cell(expiresAt)="{ item: { expiresAt, revoked } }"> | ||||
|         <span v-if="revoked" v-gl-tooltip :title="$options.i18n.tokenValidity">{{ | ||||
|           $options.i18n.revoked | ||||
|         }}</span> | ||||
|         <template v-else> | ||||
|           <span>{{ $options.i18n.expired }}</span> | ||||
|           <time-ago-tooltip :time="expiresAt" /> | ||||
|         </template> | ||||
|       </template> | ||||
|     </gl-table> | ||||
|     <gl-pagination | ||||
|       v-if="showPagination" | ||||
|       v-model="currentPage" | ||||
|       :per-page="$options.PAGE_SIZE" | ||||
|       :total-items="inactiveAccessTokens.length" | ||||
|       :prev-text="__('Prev')" | ||||
|       :next-text="__('Next')" | ||||
|       :label-next-page="__('Go to next page')" | ||||
|       :label-prev-page="__('Go to previous page')" | ||||
|       align="center" | ||||
|       class="gl-mt-5" | ||||
|     /> | ||||
|   </div> | ||||
| </template> | ||||
|  | @ -5,6 +5,7 @@ import { parseRailsFormFields } from '~/lib/utils/forms'; | |||
| import { __, sprintf } from '~/locale'; | ||||
| import Translate from '~/vue_shared/translate'; | ||||
| import AccessTokenTableApp from './components/access_token_table_app.vue'; | ||||
| import InactiveAccessTokenTableApp from './components/inactive_access_token_table_app.vue'; | ||||
| import ExpiresAtField from './components/expires_at_field.vue'; | ||||
| import NewAccessTokenApp from './components/new_access_token_app.vue'; | ||||
| import TokensApp from './components/tokens_app.vue'; | ||||
|  | @ -50,6 +51,44 @@ export const initAccessTokenTableApp = () => { | |||
|   }); | ||||
| }; | ||||
| 
 | ||||
| export const initInactiveAccessTokenTableApp = () => { | ||||
|   const el = document.querySelector('#js-inactive-access-token-table-app'); | ||||
| 
 | ||||
|   if (!el) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   const { | ||||
|     accessTokenType, | ||||
|     accessTokenTypePlural, | ||||
|     initialInactiveAccessTokens: initialInactiveAccessTokensJson, | ||||
|     noInactiveTokensMessage: noTokensMessage, | ||||
|   } = el.dataset; | ||||
| 
 | ||||
|   // Default values
 | ||||
|   const noInactiveTokensMessage = | ||||
|     noTokensMessage || | ||||
|     sprintf(__('This resource has no inactive %{accessTokenTypePlural}.'), { | ||||
|       accessTokenTypePlural, | ||||
|     }); | ||||
| 
 | ||||
|   const initialInactiveAccessTokens = JSON.parse(initialInactiveAccessTokensJson); | ||||
| 
 | ||||
|   return new Vue({ | ||||
|     el, | ||||
|     name: 'InactiveAccessTokenTableRoot', | ||||
|     provide: { | ||||
|       accessTokenType, | ||||
|       accessTokenTypePlural, | ||||
|       initialInactiveAccessTokens, | ||||
|       noInactiveTokensMessage, | ||||
|     }, | ||||
|     render(h) { | ||||
|       return h(InactiveAccessTokenTableApp); | ||||
|     }, | ||||
|   }); | ||||
| }; | ||||
| 
 | ||||
| export const initExpiresAtField = () => { | ||||
|   const el = document.querySelector('.js-access-tokens-expires-at'); | ||||
| 
 | ||||
|  |  | |||
|  | @ -63,7 +63,7 @@ export default { | |||
|     <gl-icon | ||||
|       v-if="upstreamPipeline" | ||||
|       :class="$options.arrowStyles" | ||||
|       name="long-arrow" | ||||
|       name="arrow-right" | ||||
|       data-testid="upstream-arrow-icon" | ||||
|     /> | ||||
|     <legacy-pipeline-stages | ||||
|  | @ -75,7 +75,7 @@ export default { | |||
|     <gl-icon | ||||
|       v-if="hasDownstreamPipelines" | ||||
|       :class="$options.arrowStyles" | ||||
|       name="long-arrow" | ||||
|       name="arrow-right" | ||||
|       data-testid="downstream-arrow-icon" | ||||
|     /> | ||||
|     <legacy-linked-pipelines-mini-list | ||||
|  |  | |||
|  | @ -122,7 +122,7 @@ export default { | |||
|       <gl-icon | ||||
|         v-if="upstreamPipeline" | ||||
|         :class="$options.arrowStyles" | ||||
|         name="long-arrow" | ||||
|         name="arrow-right" | ||||
|         data-testid="upstream-arrow-icon" | ||||
|       /> | ||||
|       <pipeline-stages | ||||
|  | @ -134,7 +134,7 @@ export default { | |||
|       <gl-icon | ||||
|         v-if="hasDownstreamPipelines" | ||||
|         :class="$options.arrowStyles" | ||||
|         name="long-arrow" | ||||
|         name="arrow-right" | ||||
|         data-testid="downstream-arrow-icon" | ||||
|       /> | ||||
|       <downstream-pipelines | ||||
|  |  | |||
|  | @ -78,7 +78,7 @@ export default { | |||
|     <transition name="issuable-header-slide"> | ||||
|       <div | ||||
|         v-if="show" | ||||
|         class="issue-sticky-header gl-fixed gl-z-3 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-py-3" | ||||
|         class="issue-sticky-header gl-fixed gl-z-3 gl-bg-default gl-border-b gl-py-3" | ||||
|         data-testid="issue-sticky-header" | ||||
|       > | ||||
|         <div class="issue-sticky-header-text gl-flex gl-items-center gl-gap-2 gl-mx-auto"> | ||||
|  |  | |||
|  | @ -160,7 +160,7 @@ export default { | |||
|     @disappear="setStickyHeaderVisible(true)" | ||||
|   > | ||||
|     <div | ||||
|       class="issue-sticky-header merge-request-sticky-header gl-fixed gl-bg-white gl-hidden md:gl-flex gl-flex-col gl-justify-end gl-border-b" | ||||
|       class="issue-sticky-header merge-request-sticky-header gl-fixed gl-bg-default gl-hidden md:gl-flex gl-flex-col gl-justify-end gl-border-b" | ||||
|       :class="{ 'gl-invisible': !isStickyHeaderVisible }" | ||||
|     > | ||||
|       <div | ||||
|  |  | |||
|  | @ -1,5 +1,6 @@ | |||
| import { | ||||
|   initAccessTokenTableApp, | ||||
|   initInactiveAccessTokenTableApp, | ||||
|   initExpiresAtField, | ||||
|   initNewAccessTokenApp, | ||||
| } from '~/access_tokens'; | ||||
|  | @ -7,3 +8,7 @@ import { | |||
| initAccessTokenTableApp(); | ||||
| initExpiresAtField(); | ||||
| initNewAccessTokenApp(); | ||||
| 
 | ||||
| if (gon.features.retainResourceAccessTokenUserAfterRevoke) { | ||||
|   initInactiveAccessTokenTableApp(); | ||||
| } | ||||
|  |  | |||
|  | @ -1,5 +1,6 @@ | |||
| import { | ||||
|   initAccessTokenTableApp, | ||||
|   initInactiveAccessTokenTableApp, | ||||
|   initExpiresAtField, | ||||
|   initNewAccessTokenApp, | ||||
| } from '~/access_tokens'; | ||||
|  | @ -7,3 +8,7 @@ import { | |||
| initAccessTokenTableApp(); | ||||
| initExpiresAtField(); | ||||
| initNewAccessTokenApp(); | ||||
| 
 | ||||
| if (gon.features.retainResourceAccessTokenUserAfterRevoke) { | ||||
|   initInactiveAccessTokenTableApp(); | ||||
| } | ||||
|  |  | |||
|  | @ -87,7 +87,7 @@ export default { | |||
|       <transition name="issuable-header-slide"> | ||||
|         <div | ||||
|           v-if="stickyTitleVisible" | ||||
|           class="issue-sticky-header gl-fixed gl-z-3 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-py-3" | ||||
|           class="issue-sticky-header gl-fixed gl-z-3 gl-bg-default gl-border-b gl-py-3" | ||||
|           data-testid="header" | ||||
|         > | ||||
|           <div class="issue-sticky-header-text gl-flex gl-items-baseline gl-mx-auto gl-gap-3"> | ||||
|  |  | |||
|  | @ -98,7 +98,7 @@ export default { | |||
|     <transition name="issuable-header-slide"> | ||||
|       <div | ||||
|         v-if="isStickyHeaderShowing" | ||||
|         class="issue-sticky-header gl-fixed gl-bg-white gl-border-b gl-z-3 gl-py-2" | ||||
|         class="issue-sticky-header gl-fixed gl-bg-default gl-border-b gl-z-3 gl-py-2" | ||||
|         data-testid="work-item-sticky-header" | ||||
|       > | ||||
|         <div | ||||
|  |  | |||
|  | @ -56,7 +56,7 @@ $diff-file-header: 41px; | |||
|     } | ||||
| 
 | ||||
|     &:hover { | ||||
|       background-color: $gray-50; | ||||
|       background-color: var(--gl-background-color-strong); | ||||
|     } | ||||
| 
 | ||||
|     svg { | ||||
|  |  | |||
|  | @ -242,7 +242,7 @@ span.idiff { | |||
|     flex-wrap: wrap; | ||||
|     align-items: center; | ||||
|     justify-content: space-between; | ||||
|     background-color: $gray-10; | ||||
|     background-color: var(--gl-background-color-subtle); | ||||
|     border-bottom: 1px solid $border-color; | ||||
|     padding: $gl-padding-8 $gl-padding; | ||||
|     margin: 0; | ||||
|  |  | |||
|  | @ -997,8 +997,8 @@ | |||
|   height: var(--mr-review-bar-height); | ||||
|   padding-left: $super-sidebar-width; | ||||
|   padding-right: $right-sidebar-collapsed-width; | ||||
|   background: var(--white, $white); | ||||
|   border-top: 1px solid var(--border-color, $border-color); | ||||
|   background: var(--gl-background-color-default); | ||||
|   border-top: 1px solid var(--gl-border-color-default); | ||||
|   @apply gl-transition-padding; | ||||
| 
 | ||||
|   @media (max-width: map-get($grid-breakpoints, sm)-1) { | ||||
|  |  | |||
|  | @ -7,6 +7,9 @@ module AccessTokensActions | |||
|     before_action -> { check_permission(:read_resource_access_tokens) }, only: [:index] | ||||
|     before_action -> { check_permission(:destroy_resource_access_tokens) }, only: [:revoke] | ||||
|     before_action -> { check_permission(:create_resource_access_tokens) }, only: [:create] | ||||
|     before_action do | ||||
|       push_frontend_feature_flag(:retain_resource_access_token_user_after_revoke, resource.root_ancestor) | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   # rubocop:disable Gitlab/ModuleWithInstanceVariables | ||||
|  | @ -70,6 +73,9 @@ module AccessTokensActions | |||
| 
 | ||||
|     @scopes = Gitlab::Auth.available_scopes_for(resource) | ||||
|     @active_access_tokens = active_access_tokens | ||||
|     if Feature.enabled?(:retain_resource_access_token_user_after_revoke, resource.root_ancestor) # rubocop:disable Style/GuardClause | ||||
|       @inactive_access_tokens = inactive_access_tokens | ||||
|     end | ||||
|   end | ||||
|   # rubocop:enable Gitlab/ModuleWithInstanceVariables | ||||
| 
 | ||||
|  |  | |||
|  | @ -14,6 +14,15 @@ module RenderAccessTokens | |||
|     represent(tokens) | ||||
|   end | ||||
| 
 | ||||
|   def inactive_access_tokens | ||||
|     tokens = finder(state: 'inactive', sort: 'updated_at_desc').execute.preload_users | ||||
| 
 | ||||
|     # We don't call `add_pagination_headers` as this overrides the | ||||
|     # pagination of active tokens. | ||||
| 
 | ||||
|     represent(tokens) | ||||
|   end | ||||
| 
 | ||||
|   def add_pagination_headers(relation) | ||||
|     Gitlab::Pagination::OffsetHeaderBuilder.new( | ||||
|       request_context: self, | ||||
|  |  | |||
|  | @ -3,6 +3,7 @@ | |||
| - type = _('group access token') | ||||
| - type_plural = _('group access tokens') | ||||
| - @force_desktop_expanded_sidebar = true | ||||
| - shared_card_component_classes = "gl-new-card gl-border-b-0 gl-rounded-bottom-left-none gl-rounded-bottom-right-none" | ||||
| 
 | ||||
| .settings-section.js-search-settings-section | ||||
|   .settings-sticky-header | ||||
|  | @ -25,7 +26,7 @@ | |||
| 
 | ||||
|   #js-new-access-token-app{ data: { access_token_type: type } } | ||||
| 
 | ||||
|   = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card gl-border-b-0 gl-rounded-bottom-left-none gl-rounded-bottom-right-none js-toggle-container js-token-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body' }) do |c| | ||||
|   = render Pajamas::CardComponent.new(card_options: { class: "#{shared_card_component_classes} js-toggle-container js-token-card" }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body' }) do |c| | ||||
|     - c.with_header do | ||||
|       .gl-new-card-title-wrapper | ||||
|         %h3.gl-new-card-title | ||||
|  | @ -54,3 +55,15 @@ | |||
|             help_path: help_page_path('user/group/settings/group_access_tokens', anchor: 'scopes-for-a-group-access-token') | ||||
| 
 | ||||
|   #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This group has no active access tokens.'), show_role: true } } | ||||
| 
 | ||||
|   - if Feature.enabled?(:retain_resource_access_token_user_after_revoke, @group.root_ancestor) | ||||
|     = render Pajamas::CardComponent.new(card_options: { class: shared_card_component_classes }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0 gl-bg-gray-10 gl-border-b gl-rounded-bottom-base' }) do |c| | ||||
|       - c.with_header do | ||||
|         .gl-new-card-title-wrapper | ||||
|           %h3.gl-new-card-title | ||||
|             = _('Inactive group access tokens') | ||||
|           .gl-new-card-count | ||||
|             = sprite_icon('token', css_class: 'gl-mr-2') | ||||
|             %span.js-token-count= @inactive_access_tokens.size | ||||
|       - c.with_body do | ||||
|         #js-inactive-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_inactive_access_tokens: @inactive_access_tokens.to_json, no_inactive_tokens_message: _('This group has no inactive access tokens.')} } | ||||
|  |  | |||
|  | @ -3,6 +3,7 @@ | |||
| - type = _('project access token') | ||||
| - type_plural = _('project access tokens') | ||||
| - @force_desktop_expanded_sidebar = true | ||||
| - shared_card_component_classes = "gl-new-card gl-border-b-0 gl-rounded-bottom-left-none gl-rounded-bottom-right-none" | ||||
| 
 | ||||
| .settings-section.js-search-settings-section | ||||
|   .settings-sticky-header | ||||
|  | @ -24,7 +25,7 @@ | |||
| 
 | ||||
|   #js-new-access-token-app{ data: { access_token_type: type } } | ||||
| 
 | ||||
|   = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card gl-border-b-0 gl-rounded-bottom-left-none gl-rounded-bottom-right-none js-toggle-container js-token-card' }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body' }) do |c| | ||||
|   = render Pajamas::CardComponent.new(card_options: { class: "#{shared_card_component_classes} js-toggle-container js-token-card" }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body' }) do |c| | ||||
|     - c.with_header do | ||||
|       .gl-new-card-title-wrapper | ||||
|         %h3.gl-new-card-title | ||||
|  | @ -42,3 +43,15 @@ | |||
|           = render_if_exists 'projects/settings/access_tokens/form', type: type | ||||
| 
 | ||||
|   #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json, no_active_tokens_message: _('This project has no active access tokens.'), show_role: true } } | ||||
| 
 | ||||
|   - if Feature.enabled?(:retain_resource_access_token_user_after_revoke, @project.root_ancestor) | ||||
|     = render Pajamas::CardComponent.new(card_options: { class: shared_card_component_classes }, header_options: { class: 'gl-new-card-header' }, body_options: { class: 'gl-new-card-body gl-px-0 gl-bg-gray-10 gl-border-b gl-rounded-bottom-base' }) do |c| | ||||
|       - c.with_header do | ||||
|         .gl-new-card-title-wrapper | ||||
|           %h3.gl-new-card-title | ||||
|             = _('Inactive project access tokens') | ||||
|           .gl-new-card-count | ||||
|             = sprite_icon('token', css_class: 'gl-mr-2') | ||||
|             %span.js-token-count= @inactive_access_tokens.size | ||||
|       - c.with_body do | ||||
|         #js-inactive-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_inactive_access_tokens: @inactive_access_tokens.to_json, no_inactive_tokens_message: _('This project has no inactive access tokens.')} } | ||||
|  |  | |||
|  | @ -0,0 +1,10 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class AddMemberRoleIdToGroupGroupLinks < Gitlab::Database::Migration[2.2] | ||||
|   milestone '17.3' | ||||
|   enable_lock_retries! | ||||
| 
 | ||||
|   def change | ||||
|     add_column :group_group_links, :member_role_id, :bigint, if_not_exists: true | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1,16 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class AddIndexToGroupGroupLinksOnMemberRoleId < Gitlab::Database::Migration[2.2] | ||||
|   milestone '17.3' | ||||
|   disable_ddl_transaction! | ||||
| 
 | ||||
|   INDEX_NAME = 'index_group_group_links_on_member_role_id' | ||||
| 
 | ||||
|   def up | ||||
|     add_concurrent_index :group_group_links, :member_role_id, name: INDEX_NAME | ||||
|   end | ||||
| 
 | ||||
|   def down | ||||
|     remove_concurrent_index_by_name :group_group_links, INDEX_NAME | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1,16 @@ | |||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class AddFkToMemberRoleOnGroupGroupLinks < Gitlab::Database::Migration[2.2] | ||||
|   milestone '17.3' | ||||
|   disable_ddl_transaction! | ||||
| 
 | ||||
|   def up | ||||
|     add_concurrent_foreign_key :group_group_links, :member_roles, column: :member_role_id, on_delete: :nullify | ||||
|   end | ||||
| 
 | ||||
|   def down | ||||
|     with_lock_retries do | ||||
|       remove_foreign_key :group_group_links, column: :member_role_id | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | @ -0,0 +1 @@ | |||
| ee3dc82029ae3d9f7ce57e49bba39841872391ae74f7671e5794849a0c4adeb5 | ||||
|  | @ -0,0 +1 @@ | |||
| 6469c09bf160ff6c05e242b63882add76a467642df48424a2a7f3a1baefacb01 | ||||
|  | @ -0,0 +1 @@ | |||
| ba845f9c8129c6006e7f5c7406befd09c2e4520ed334be658a6bb8334ede42a0 | ||||
|  | @ -10982,7 +10982,8 @@ CREATE TABLE group_group_links ( | |||
|     shared_group_id bigint NOT NULL, | ||||
|     shared_with_group_id bigint NOT NULL, | ||||
|     expires_at date, | ||||
|     group_access smallint DEFAULT 30 NOT NULL | ||||
|     group_access smallint DEFAULT 30 NOT NULL, | ||||
|     member_role_id bigint | ||||
| ); | ||||
| 
 | ||||
| CREATE SEQUENCE group_group_links_id_seq | ||||
|  | @ -27440,6 +27441,8 @@ CREATE INDEX index_group_deploy_tokens_on_deploy_token_id ON group_deploy_tokens | |||
| 
 | ||||
| CREATE UNIQUE INDEX index_group_deploy_tokens_on_group_and_deploy_token_ids ON group_deploy_tokens USING btree (group_id, deploy_token_id); | ||||
| 
 | ||||
| CREATE INDEX index_group_group_links_on_member_role_id ON group_group_links USING btree (member_role_id); | ||||
| 
 | ||||
| CREATE UNIQUE INDEX index_group_group_links_on_shared_group_and_shared_with_group ON group_group_links USING btree (shared_group_id, shared_with_group_id); | ||||
| 
 | ||||
| CREATE INDEX index_group_group_links_on_shared_with_group_and_group_access ON group_group_links USING btree (shared_with_group_id, group_access); | ||||
|  | @ -32210,6 +32213,9 @@ ALTER TABLE ONLY duo_workflows_workflows | |||
| ALTER TABLE ONLY members | ||||
|     ADD CONSTRAINT fk_2f85abf8f1 FOREIGN KEY (member_namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE; | ||||
| 
 | ||||
| ALTER TABLE ONLY group_group_links | ||||
|     ADD CONSTRAINT fk_2fbc7071a3 FOREIGN KEY (member_role_id) REFERENCES member_roles(id) ON DELETE SET NULL; | ||||
| 
 | ||||
| ALTER TABLE ONLY zoekt_replicas | ||||
|     ADD CONSTRAINT fk_3035f4b498 FOREIGN KEY (zoekt_enabled_namespace_id) REFERENCES zoekt_enabled_namespaces(id) ON DELETE CASCADE; | ||||
| 
 | ||||
|  |  | |||
|  | @ -254,11 +254,18 @@ malicious-job: | |||
| ``` | ||||
| 
 | ||||
| To help reduce the risk of accidentally leaking secrets through scripts like in `accidental-leak-job`, | ||||
| all variables containing sensitive information should be [masked in job logs](#mask-a-cicd-variable). | ||||
| all variables containing sensitive information should always be [masked in job logs](#mask-a-cicd-variable). | ||||
| You can also [limit a variable to protected branches and tags only](#protect-a-cicd-variable). | ||||
| 
 | ||||
| Alternatively, use the GitLab [integration with HashiCorp Vault](../secrets/index.md) | ||||
| to store and retrieve secrets. | ||||
| Alternatively, use one of the native GitLab integrations to connect with third party | ||||
| secrets manager providers to store and retrieve secrets: | ||||
| 
 | ||||
| - [HashiCorp Vault](../secrets/index.md) | ||||
| - [Azure Key Vault](../secrets/azure_key_vault.md) | ||||
| - [Google Secret Manager](../secrets/gcp_secret_manager.md) | ||||
| 
 | ||||
| You can also use [OpenID Connect (OIDC) authentication](../secrets/id_token_authentication.md) | ||||
| for secrets managers which do not have a native integration. | ||||
| 
 | ||||
| Malicious scripts like in `malicious-job` must be caught during the review process. | ||||
| Reviewers should never trigger a pipeline when they find code like this, because | ||||
|  | @ -272,8 +279,7 @@ valid [secrets file](../../administration/backup_restore/troubleshooting_backup_ | |||
| 
 | ||||
| WARNING: | ||||
| Masking a CI/CD variable is not a guaranteed way to prevent malicious users from | ||||
| accessing variable values. The masking feature is "best-effort" and there to | ||||
| help when a variable is accidentally revealed. To make variables more secure, | ||||
| accessing variable values. To ensure security of sensitive information, | ||||
| consider using [external secrets](../secrets/index.md) and [file type variables](#use-file-type-cicd-variables) | ||||
| to prevent commands such as `env`/`printenv` from printing secret variables. | ||||
| 
 | ||||
|  | @ -295,9 +301,10 @@ To mask a variable: | |||
| The method used to mask variables [limits what can be included in a masked variable](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/13784#note_106756757). | ||||
| The value of the variable must: | ||||
| 
 | ||||
| - Be a single line. | ||||
| - Be a single line with no spaces. | ||||
| - Be 8 characters or longer. | ||||
| - Not match the name of an existing predefined or custom CI/CD variable. | ||||
| - Not include non-alpha-numeric characters other than `@`, `_`, `-`, `:`, or `+`. | ||||
| 
 | ||||
| Additionally, if [variable expansion](#prevent-cicd-variable-expansion) is enabled, | ||||
| the value can contain only: | ||||
|  | @ -514,7 +521,7 @@ test-job: | |||
| Variables from `dotenv` reports [take precedence](#cicd-variable-precedence) over | ||||
| certain types of new variable definitions such as job defined variables. | ||||
| 
 | ||||
| You can also [pass `dotenv` variables to downstream pipelines](../pipelines/downstream_pipelines.md#pass-dotenv-variables-created-in-a-job) | ||||
| You can also [pass `dotenv` variables to downstream pipelines](../pipelines/downstream_pipelines.md#pass-dotenv-variables-created-in-a-job). | ||||
| 
 | ||||
| #### Control which jobs receive `dotenv` variables | ||||
| 
 | ||||
|  | @ -914,7 +921,7 @@ export CI_PROJECT_TITLE="GitLab" | |||
| 
 | ||||
| WARNING: | ||||
| Debug logging can be a serious security risk. The output contains the content of | ||||
| all variables and other secrets available to the job. The output is uploaded to the | ||||
| all variables available to the job. The output is uploaded to the | ||||
| GitLab server and visible in job logs. | ||||
| 
 | ||||
| You can use debug logging to help troubleshoot problems with pipeline configuration | ||||
|  | @ -1022,22 +1029,3 @@ WARNING: | |||
| If you add `CI_DEBUG_TRACE` as a local variable to runners, debug logs generate and are visible | ||||
| to all users with access to job logs. The permission levels are not checked by the runner, | ||||
| so you should only use the variable in GitLab itself. | ||||
| 
 | ||||
| ## Known issues and workarounds | ||||
| 
 | ||||
| These are some known issues with CI/CD variables, and where applicable, known workarounds. | ||||
| 
 | ||||
| ### "argument list too long" | ||||
| 
 | ||||
| This issue occurs when the combined length of all CI/CD variables defined for a job exceeds the limit imposed by the | ||||
| shell where the job executes. This includes the names and values of pre-defined and user defined variables. This limit | ||||
| is typically referred to as `ARG_MAX`, and is shell and operating system dependent. This issue also occurs when the | ||||
| content of a single [File-type](#use-file-type-cicd-variables) variable exceeds `ARG_MAX`. | ||||
| 
 | ||||
| For more information, see [issue 392406](https://gitlab.com/gitlab-org/gitlab/-/issues/392406#note_1414219596). | ||||
| 
 | ||||
| As a workaround you can either: | ||||
| 
 | ||||
| - Use [File-type](#use-file-type-cicd-variables) CI/CD variables for large environment variables where possible. | ||||
| - If a single large variable is larger than `ARG_MAX`, try using [Secure Files](../secure_files/index.md), or | ||||
|   bring the file to the job through some other mechanism. | ||||
|  |  | |||
|  | @ -141,6 +141,11 @@ The token generated when you create an agent for Kubernetes. Use **agent access | |||
| - secret token | ||||
| - authentication token | ||||
| 
 | ||||
| ## agnostic | ||||
| 
 | ||||
| Instead of **agnostic**, use **platform-independent** or **vendor-neutral**. | ||||
| ([Vale](../testing/vale.md) rule: [`SubstitutionWarning.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/.vale/gitlab/SubstitutionWarning.yml)) | ||||
| 
 | ||||
| ## AI, artificial intelligence | ||||
| 
 | ||||
| Use **AI**. Do not spell out **artificial intelligence**. | ||||
|  |  | |||
|  | @ -153,7 +153,8 @@ module Gitlab | |||
|           target_project_id: target_project_id, | ||||
|           remove_source_branch: true, | ||||
|           assignee_ids: usernames_to_ids(change.assignees), | ||||
|           reviewer_ids: usernames_to_ids(change.reviewers) | ||||
|           reviewer_ids: usernames_to_ids(change.reviewers), | ||||
|           squash: true | ||||
|         }) | ||||
|       end | ||||
| 
 | ||||
|  |  | |||
|  | @ -124,7 +124,8 @@ module Gitlab | |||
|       end | ||||
| 
 | ||||
|       def print_change_details(change, branch_name) | ||||
|         base_message = "Merge request URL: #{change.mr_web_url || '(known after create)'}, on branch #{branch_name}." | ||||
|         base_message = "Merge request URL: #{change.mr_web_url || '(known after create)'}, on branch #{branch_name}. " \ | ||||
|                        "Squash commits enabled." | ||||
|         base_message << " CI skipped." if change.push_options.ci_skip | ||||
| 
 | ||||
|         @logger.puts base_message.yellowish | ||||
|  |  | |||
|  | @ -343,7 +343,8 @@ RSpec.describe ::Gitlab::Housekeeper::GitlabClient do | |||
|             target_project_id: 456, | ||||
|             remove_source_branch: true, | ||||
|             assignee_ids: [assignee_id], | ||||
|             reviewer_ids: [reviewer_id] | ||||
|             reviewer_ids: [reviewer_id], | ||||
|             squash: true | ||||
|           }, | ||||
|           headers: { | ||||
|             'Content-Type' => 'application/json', | ||||
|  |  | |||
|  | @ -43,12 +43,16 @@ module Gitlab | |||
| 
 | ||||
|         module MigratorOverrides | ||||
|           def current_version | ||||
|             migrations | ||||
|               .sort_by(&:version) | ||||
|               .reverse | ||||
|             reverse_sorted_migrations | ||||
|               .find { |m| migrated.include?(m.version) } | ||||
|               .try(:version) || 0 | ||||
|           end | ||||
| 
 | ||||
|           private | ||||
| 
 | ||||
|           def reverse_sorted_migrations | ||||
|             @reverse_sorted_migrations ||= migrations.sort_by(&:version).reverse | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         def self.patch! | ||||
|  |  | |||
|  | @ -27427,6 +27427,12 @@ msgstr "" | |||
| msgid "Inactive" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Inactive group access tokens" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Inactive project access tokens" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "Incident" | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -54310,6 +54316,9 @@ msgstr "" | |||
| msgid "This group has no active access tokens." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "This group has no inactive access tokens." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "This group has no projects yet" | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -54603,6 +54612,9 @@ msgstr "" | |||
| msgid "This project has no active access tokens." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "This project has no inactive access tokens." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "This project is %{strongStart}NOT%{strongEnd} a fork, and has the following:" | ||||
| msgstr "" | ||||
| 
 | ||||
|  | @ -54675,6 +54687,9 @@ msgstr "" | |||
| msgid "This resource has no comments to summarize" | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "This resource has no inactive %{accessTokenTypePlural}." | ||||
| msgstr "" | ||||
| 
 | ||||
| msgid "This runner will only run on pipelines triggered on protected branches" | ||||
| msgstr "" | ||||
| 
 | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ | |||
| source 'https://rubygems.org' | ||||
| 
 | ||||
| gem 'gitlab-qa', '~> 14', '>= 14.12.0', require: 'gitlab/qa' | ||||
| gem 'gitlab_quality-test_tooling', '~> 1.30.0', require: false | ||||
| gem 'gitlab_quality-test_tooling', '~> 1.31.0', require: false | ||||
| gem 'gitlab-utils', path: '../gems/gitlab-utils' | ||||
| gem 'activesupport', '~> 7.0.8.4' # This should stay in sync with the root's Gemfile | ||||
| gem 'allure-rspec', '~> 2.24.5' | ||||
|  |  | |||
|  | @ -148,7 +148,7 @@ GEM | |||
|       rainbow (>= 3, < 4) | ||||
|       table_print (= 1.5.7) | ||||
|       zeitwerk (>= 2, < 3) | ||||
|     gitlab_quality-test_tooling (1.30.0) | ||||
|     gitlab_quality-test_tooling (1.31.0) | ||||
|       activesupport (>= 7.0, < 7.2) | ||||
|       amatch (~> 0.4.1) | ||||
|       gitlab (~> 4.19) | ||||
|  | @ -405,7 +405,7 @@ DEPENDENCIES | |||
|   gitlab-cng! | ||||
|   gitlab-qa (~> 14, >= 14.12.0) | ||||
|   gitlab-utils! | ||||
|   gitlab_quality-test_tooling (~> 1.30.0) | ||||
|   gitlab_quality-test_tooling (~> 1.31.0) | ||||
|   googleauth (~> 1.9.0) | ||||
|   influxdb-client (~> 3.1) | ||||
|   junit_merge (~> 0.1.2) | ||||
|  |  | |||
|  | @ -0,0 +1,180 @@ | |||
| import { GlPagination, GlTable } from '@gitlab/ui'; | ||||
| import { mountExtended } from 'helpers/vue_test_utils_helper'; | ||||
| import InactiveAccessTokenTableApp from '~/access_tokens/components/inactive_access_token_table_app.vue'; | ||||
| import { PAGE_SIZE } from '~/access_tokens/components/constants'; | ||||
| import { __, s__, sprintf } from '~/locale'; | ||||
| 
 | ||||
| describe('~/access_tokens/components/inactive_access_token_table_app', () => { | ||||
|   let wrapper; | ||||
| 
 | ||||
|   const accessTokenType = 'access token'; | ||||
|   const accessTokenTypePlural = 'access tokens'; | ||||
|   const information = undefined; | ||||
|   const noInactiveTokensMessage = 'This resource has no inactive access tokens.'; | ||||
| 
 | ||||
|   const defaultInactiveAccessTokens = [ | ||||
|     { | ||||
|       name: 'a', | ||||
|       scopes: ['api'], | ||||
|       created_at: '2023-05-01T00:00:00.000Z', | ||||
|       last_used_at: null, | ||||
|       expired: true, | ||||
|       expires_at: '2024-05-01T00:00:00.000Z', | ||||
|       revoked: true, | ||||
|       role: 'Maintainer', | ||||
|     }, | ||||
|     { | ||||
|       name: 'b', | ||||
|       scopes: ['api', 'sudo'], | ||||
|       created_at: '2024-04-21T00:00:00.000Z', | ||||
|       last_used_at: '2024-04-21T00:00:00.000Z', | ||||
|       expired: true, | ||||
|       expires_at: new Date().toISOString(), | ||||
|       revoked: false, | ||||
|       role: 'Maintainer', | ||||
|     }, | ||||
|   ]; | ||||
| 
 | ||||
|   const createComponent = (props = {}) => { | ||||
|     wrapper = mountExtended(InactiveAccessTokenTableApp, { | ||||
|       provide: { | ||||
|         accessTokenType, | ||||
|         accessTokenTypePlural, | ||||
|         information, | ||||
|         initialInactiveAccessTokens: defaultInactiveAccessTokens, | ||||
|         noInactiveTokensMessage, | ||||
|         ...props, | ||||
|       }, | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   const findTable = () => wrapper.findComponent(GlTable); | ||||
|   const findHeaders = () => findTable().findAll('th > div > span'); | ||||
|   const findCells = () => findTable().findAll('td'); | ||||
|   const findPagination = () => wrapper.findComponent(GlPagination); | ||||
| 
 | ||||
|   it('should render an empty table with a default message', () => { | ||||
|     createComponent({ initialInactiveAccessTokens: [] }); | ||||
| 
 | ||||
|     const cells = findCells(); | ||||
|     expect(cells).toHaveLength(1); | ||||
|     expect(cells.at(0).text()).toBe( | ||||
|       sprintf(__('This resource has no inactive %{accessTokenTypePlural}.'), { | ||||
|         accessTokenTypePlural, | ||||
|       }), | ||||
|     ); | ||||
|   }); | ||||
| 
 | ||||
|   it('should render an empty table with a custom message', () => { | ||||
|     const noTokensMessage = 'This group has no inactive access tokens.'; | ||||
|     createComponent({ initialInactiveAccessTokens: [], noInactiveTokensMessage: noTokensMessage }); | ||||
| 
 | ||||
|     const cells = findCells(); | ||||
|     expect(cells).toHaveLength(1); | ||||
|     expect(cells.at(0).text()).toBe(noTokensMessage); | ||||
|   }); | ||||
| 
 | ||||
|   describe('table headers', () => { | ||||
|     it('has expected columns', () => { | ||||
|       createComponent(); | ||||
| 
 | ||||
|       const headers = findHeaders(); | ||||
|       expect(headers.wrappers.map((header) => header.text())).toStrictEqual([ | ||||
|         __('Token name'), | ||||
|         __('Scopes'), | ||||
|         s__('AccessTokens|Created'), | ||||
|         'Last Used', | ||||
|         __('Expired'), | ||||
|         __('Role'), | ||||
|       ]); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   it('`Last Used` header should contain a link and an assistive message', () => { | ||||
|     createComponent(); | ||||
| 
 | ||||
|     const headers = wrapper.findAll('th'); | ||||
|     const lastUsed = headers.at(3); | ||||
|     const anchor = lastUsed.find('a'); | ||||
|     const assistiveElement = lastUsed.find('.gl-sr-only'); | ||||
|     expect(anchor.exists()).toBe(true); | ||||
|     expect(anchor.attributes('href')).toBe( | ||||
|       '/help/user/profile/personal_access_tokens.md#view-the-last-time-a-token-was-used', | ||||
|     ); | ||||
|     expect(assistiveElement.text()).toBe(s__('AccessTokens|The last time a token was used')); | ||||
|   }); | ||||
| 
 | ||||
|   it('sorts rows alphabetically', async () => { | ||||
|     createComponent(); | ||||
| 
 | ||||
|     const cells = findCells(); | ||||
| 
 | ||||
|     // First and second rows
 | ||||
|     expect(cells.at(0).text()).toBe('a'); | ||||
|     expect(cells.at(6).text()).toBe('b'); | ||||
| 
 | ||||
|     const headers = findHeaders(); | ||||
|     await headers.at(0).trigger('click'); | ||||
|     await headers.at(0).trigger('click'); | ||||
| 
 | ||||
|     // First and second rows have swapped
 | ||||
|     expect(cells.at(0).text()).toBe('b'); | ||||
|     expect(cells.at(6).text()).toBe('a'); | ||||
|   }); | ||||
| 
 | ||||
|   it('sorts rows by last used date', async () => { | ||||
|     createComponent(); | ||||
| 
 | ||||
|     const cells = findCells(); | ||||
| 
 | ||||
|     // First and second rows
 | ||||
|     expect(cells.at(0).text()).toBe('a'); | ||||
|     expect(cells.at(6).text()).toBe('b'); | ||||
| 
 | ||||
|     const headers = findHeaders(); | ||||
|     await headers.at(3).trigger('click'); | ||||
| 
 | ||||
|     // First and second rows have swapped
 | ||||
|     expect(cells.at(0).text()).toBe('b'); | ||||
|     expect(cells.at(6).text()).toBe('a'); | ||||
|   }); | ||||
| 
 | ||||
|   it('sorts rows by expiry date', async () => { | ||||
|     createComponent(); | ||||
| 
 | ||||
|     const cells = findCells(); | ||||
|     const headers = findHeaders(); | ||||
|     await headers.at(4).trigger('click'); | ||||
| 
 | ||||
|     // First and second rows have swapped
 | ||||
|     expect(cells.at(0).text()).toBe('b'); | ||||
|     expect(cells.at(6).text()).toBe('a'); | ||||
|   }); | ||||
| 
 | ||||
|   it('shows Revoked in expiry column when revoked', () => { | ||||
|     createComponent(); | ||||
| 
 | ||||
|     const cells = findCells(); | ||||
| 
 | ||||
|     // First and second rows
 | ||||
|     expect(cells.at(4).text()).toBe('Revoked'); | ||||
|     expect(cells.at(10).text()).toBe('Expired just now'); | ||||
|   }); | ||||
| 
 | ||||
|   describe('pagination', () => { | ||||
|     it('does not show pagination component', () => { | ||||
|       createComponent({ | ||||
|         initialInactiveAccessTokens: Array(PAGE_SIZE).fill(defaultInactiveAccessTokens[0]), | ||||
|       }); | ||||
| 
 | ||||
|       expect(findPagination().exists()).toBe(false); | ||||
|     }); | ||||
| 
 | ||||
|     it('shows the pagination component', () => { | ||||
|       createComponent({ | ||||
|         initialInactiveAccessTokens: Array(PAGE_SIZE + 1).fill(defaultInactiveAccessTokens[0]), | ||||
|       }); | ||||
|       expect(findPagination().exists()).toBe(true); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  | @ -5,9 +5,11 @@ import { | |||
|   initAccessTokenTableApp, | ||||
|   initExpiresAtField, | ||||
|   initNewAccessTokenApp, | ||||
|   initInactiveAccessTokenTableApp, | ||||
|   initTokensApp, | ||||
| } from '~/access_tokens'; | ||||
| import AccessTokenTableApp from '~/access_tokens/components/access_token_table_app.vue'; | ||||
| import InactiveAccessTokenTableApp from '~/access_tokens/components/inactive_access_token_table_app.vue'; | ||||
| import ExpiresAtField from '~/access_tokens/components/expires_at_field.vue'; | ||||
| import NewAccessTokenApp from '~/access_tokens/components/new_access_token_app.vue'; | ||||
| import TokensApp from '~/access_tokens/components/tokens_app.vue'; | ||||
|  | @ -173,4 +175,81 @@ describe('access tokens', () => { | |||
|       expect(initNewAccessTokenApp()).toBe(null); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('initInactiveAccessTokenTableApp', () => { | ||||
|     const accessTokenType = 'group access token'; | ||||
|     const accessTokenTypePlural = 'group access tokens'; | ||||
|     const initialInactiveAccessTokens = [ | ||||
|       { | ||||
|         name: 'a', | ||||
|         scopes: ['api'], | ||||
|         created_at: '2023-05-01T00:00:00.000Z', | ||||
|         last_used_at: null, | ||||
|         expired: false, | ||||
|         expires_at: null, | ||||
|         revoked: true, | ||||
|         role: 'Maintainer', | ||||
|       }, | ||||
|     ]; | ||||
| 
 | ||||
|     it('mounts the component and provides required values', () => { | ||||
|       setHTMLFixture( | ||||
|         `<div id="js-inactive-access-token-table-app"
 | ||||
|         data-access-token-type="${accessTokenType}" | ||||
|         data-access-token-type-plural="${accessTokenTypePlural}" | ||||
|         data-initial-inactive-access-tokens=${JSON.stringify(initialInactiveAccessTokens)} | ||||
|         > | ||||
|         </div>`, | ||||
|       ); | ||||
| 
 | ||||
|       const vueInstance = initInactiveAccessTokenTableApp(); | ||||
|       wrapper = createWrapper(vueInstance); | ||||
|       const component = wrapper.findComponent({ name: 'InactiveAccessTokenTableRoot' }); | ||||
| 
 | ||||
|       expect(component.exists()).toBe(true); | ||||
|       expect(wrapper.findComponent(InactiveAccessTokenTableApp).vm).toMatchObject({ | ||||
|         // Required value
 | ||||
|         accessTokenType, | ||||
|         accessTokenTypePlural, | ||||
|         initialInactiveAccessTokens, | ||||
| 
 | ||||
|         // Default values
 | ||||
|         noInactiveTokensMessage: sprintf( | ||||
|           __('This resource has no inactive %{accessTokenTypePlural}.'), | ||||
|           { | ||||
|             accessTokenTypePlural, | ||||
|           }, | ||||
|         ), | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     it('mounts the component and provides all values', () => { | ||||
|       const noInactiveTokensMessage = 'This group has no inactive access tokens.'; | ||||
|       setHTMLFixture( | ||||
|         `<div id="js-inactive-access-token-table-app"
 | ||||
|           data-access-token-type="${accessTokenType}" | ||||
|           data-access-token-type-plural="${accessTokenTypePlural}" | ||||
|           data-initial-inactive-access-tokens=${JSON.stringify(initialInactiveAccessTokens)} | ||||
|           data-no-inactive-tokens-message="${noInactiveTokensMessage}" | ||||
|           > | ||||
|         </div>`, | ||||
|       ); | ||||
| 
 | ||||
|       const vueInstance = initInactiveAccessTokenTableApp(); | ||||
|       wrapper = createWrapper(vueInstance); | ||||
|       const component = wrapper.findComponent({ name: 'InactiveAccessTokenTableRoot' }); | ||||
| 
 | ||||
|       expect(component.exists()).toBe(true); | ||||
|       expect(component.findComponent(InactiveAccessTokenTableApp).vm).toMatchObject({ | ||||
|         accessTokenType, | ||||
|         accessTokenTypePlural, | ||||
|         initialInactiveAccessTokens, | ||||
|         noInactiveTokensMessage, | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     it('returns `null`', () => { | ||||
|       expect(initInactiveAccessTokenTableApp()).toBe(null); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -85,7 +85,7 @@ describe('Legacy Pipeline Mini Graph', () => { | |||
|     it('should render an upstream arrow icon only', () => { | ||||
|       expect(findDownstreamArrowIcon().exists()).toBe(false); | ||||
|       expect(findUpstreamArrowIcon().exists()).toBe(true); | ||||
|       expect(findUpstreamArrowIcon().props('name')).toBe('long-arrow'); | ||||
|       expect(findUpstreamArrowIcon().props('name')).toBe('arrow-right'); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|  | @ -116,7 +116,7 @@ describe('Legacy Pipeline Mini Graph', () => { | |||
|     it('should render a downstream arrow icon only', () => { | ||||
|       expect(findUpstreamArrowIcon().exists()).toBe(false); | ||||
|       expect(findDownstreamArrowIcon().exists()).toBe(true); | ||||
|       expect(findDownstreamArrowIcon().props('name')).toBe('long-arrow'); | ||||
|       expect(findDownstreamArrowIcon().props('name')).toBe('arrow-right'); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|  |  | |||
|  | @ -40,6 +40,7 @@ RSpec.describe Groups::Settings::AccessTokensController, feature_category: :syst | |||
|     it_behaves_like 'feature unavailable' | ||||
|     it_behaves_like 'GET resource access tokens available' | ||||
|     it_behaves_like 'GET access tokens are paginated and ordered' | ||||
|     it_behaves_like 'GET access tokens includes inactive tokens' | ||||
|   end | ||||
| 
 | ||||
|   describe 'POST /:namespace/-/settings/access_tokens' do | ||||
|  |  | |||
|  | @ -41,6 +41,7 @@ RSpec.describe Projects::Settings::AccessTokensController, feature_category: :sy | |||
|     it_behaves_like 'feature unavailable' | ||||
|     it_behaves_like 'GET resource access tokens available' | ||||
|     it_behaves_like 'GET access tokens are paginated and ordered' | ||||
|     it_behaves_like 'GET access tokens includes inactive tokens' | ||||
|   end | ||||
| 
 | ||||
|   describe 'POST /:namespace/:project/-/settings/access_tokens' do | ||||
|  |  | |||
|  | @ -129,6 +129,21 @@ RSpec.shared_examples 'inactive resource access tokens' do |no_active_tokens_tex | |||
|     find("[data-testid='active-tokens']") | ||||
|   end | ||||
| 
 | ||||
|   def inactive_access_tokens | ||||
|     find("[data-testid='inactive-access-tokens']") | ||||
|   end | ||||
| 
 | ||||
|   context 'when feature flag is disabled' do | ||||
|     before do | ||||
|       stub_feature_flags(retain_resource_access_token_user_after_revoke: false) | ||||
|     end | ||||
| 
 | ||||
|     it 'does not show inactive tokens' do | ||||
|       visit resource_settings_access_tokens_path | ||||
|       expect(page).to have_no_selector("[data-testid='inactive-access-tokens']") | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   it 'allows revocation of an active token' do | ||||
|     visit resource_settings_access_tokens_path | ||||
|     accept_gl_confirm(button_text: 'Revoke') { click_on 'Revoke' } | ||||
|  | @ -141,6 +156,15 @@ RSpec.shared_examples 'inactive resource access tokens' do |no_active_tokens_tex | |||
|     visit resource_settings_access_tokens_path | ||||
| 
 | ||||
|     expect(active_access_tokens).to have_text(no_active_tokens_text) | ||||
|     expect(inactive_access_tokens).to have_text(resource_access_token.name) | ||||
|   end | ||||
| 
 | ||||
|   it 'removes revoked tokens from active section' do | ||||
|     resource_access_token.revoke! | ||||
|     visit resource_settings_access_tokens_path | ||||
| 
 | ||||
|     expect(active_access_tokens).to have_text(no_active_tokens_text) | ||||
|     expect(inactive_access_tokens).to have_text(resource_access_token.name) | ||||
|   end | ||||
| 
 | ||||
|   context 'when resource access token creation is not allowed' do | ||||
|  |  | |||
|  | @ -58,7 +58,7 @@ RSpec.shared_examples 'GET access tokens are paginated and ordered' do | |||
|     end | ||||
|   end | ||||
| 
 | ||||
|   context "when tokens returned are ordered" do | ||||
|   context "when active tokens returned are ordered" do | ||||
|     let(:expires_1_day_from_now) { 1.day.from_now.to_date } | ||||
|     let(:expires_2_day_from_now) { 2.days.from_now.to_date } | ||||
| 
 | ||||
|  | @ -95,6 +95,33 @@ RSpec.shared_examples 'GET access tokens are paginated and ordered' do | |||
|   end | ||||
| end | ||||
| 
 | ||||
| RSpec.shared_examples 'GET access tokens includes inactive tokens' do | ||||
|   context "when inactive tokens returned are ordered" do | ||||
|     let(:one_day_ago) { 1.day.ago.to_date } | ||||
|     let(:two_days_ago) { 2.days.ago.to_date } | ||||
| 
 | ||||
|     before do | ||||
|       create(:personal_access_token, :revoked, user: access_token_user, name: "Token1").update!(updated_at: one_day_ago) | ||||
|       create(:personal_access_token, :expired, user: access_token_user, | ||||
|         name: "Token2").update!(updated_at: two_days_ago) | ||||
|     end | ||||
| 
 | ||||
|     it "orders token list descending on updated_at" do | ||||
|       get_access_tokens | ||||
| 
 | ||||
|       first_token = assigns(:inactive_access_tokens).first.as_json | ||||
|       expect(first_token['name']).to eq("Token1") | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   context "when there are no inactive tokens" do | ||||
|     it "returns an empty array" do | ||||
|       get_access_tokens | ||||
|       expect(assigns(:inactive_access_tokens)).to eq([]) | ||||
|     end | ||||
|   end | ||||
| end | ||||
| 
 | ||||
| RSpec.shared_examples 'POST resource access tokens available' do | ||||
|   def created_token | ||||
|     PersonalAccessToken.order(:created_at).last | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue