summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-05-23 09:08:01 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2022-05-23 09:08:01 +0000
commitc71c2ba4c29ed3cc483e528a32f34816c98c39f4 (patch)
tree56a8a8355631b9d58544bb74816082529ce0044d
parent5534414cd55a3e85d8f60e1a0883ed32f190df6b (diff)
downloadgitlab-ce-c71c2ba4c29ed3cc483e528a32f34816c98c39f4.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--Gemfile.lock2
-rw-r--r--app/assets/javascripts/access_tokens/components/access_token_table_app.vue204
-rw-r--r--app/assets/javascripts/access_tokens/components/new_access_token_app.vue125
-rw-r--r--app/assets/javascripts/access_tokens/index.js68
-rw-r--r--app/assets/javascripts/pages/profiles/personal_access_tokens/index.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue20
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss16
-rw-r--r--app/assets/stylesheets/page_bundles/build.scss7
-rw-r--r--app/assets/stylesheets/pages/issuable.scss4
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss9
-rw-r--r--app/controllers/profiles/personal_access_tokens_controller.rb33
-rw-r--r--app/controllers/projects/google_cloud/base_controller.rb1
-rw-r--r--app/graphql/resolvers/concerns/issue_resolver_arguments.rb4
-rw-r--r--app/graphql/resolvers/tree_resolver.rb2
-rw-r--r--app/models/clusters/applications/runner.rb2
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml8
-rw-r--r--app/views/projects/_merge_request_merge_method_settings.html.haml58
-rw-r--r--app/views/shared/access_tokens/_form.html.haml3
-rw-r--r--config/feature_flags/development/access_token_ajax.yml8
-rw-r--r--doc/architecture/blueprints/ci_scale/index.md6
-rw-r--r--doc/user/group/saml_sso/index.md2
-rw-r--r--lib/api/entities/personal_access_token_with_details.rb13
-rw-r--r--locale/gitlab.pot29
-rw-r--r--qa/qa/page/component/access_tokens.rb12
-rw-r--r--qa/qa/tools/delete_test_snippets.rb7
-rw-r--r--spec/controllers/profiles/personal_access_tokens_controller_spec.rb65
-rw-r--r--spec/features/profiles/personal_access_tokens_spec.rb159
-rw-r--r--spec/features/projects/settings/access_tokens_spec.rb2
-rw-r--r--spec/frontend/access_tokens/components/access_token_table_app_spec.js228
-rw-r--r--spec/frontend/access_tokens/components/new_access_token_app_spec.js161
-rw-r--r--spec/frontend/access_tokens/index_spec.js214
-rw-r--r--spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js28
-rw-r--r--spec/graphql/resolvers/design_management/versions_resolver_spec.rb2
-rw-r--r--spec/lib/api/entities/personal_access_token_with_details_spec.rb29
-rw-r--r--spec/lib/gitlab/graphql/markdown_field_spec.rb4
-rw-r--r--spec/support/helpers/countries_controller_test_helper.rb9
-rw-r--r--spec/support/helpers/gitaly_setup.rb10
-rw-r--r--spec/support/shared_examples/features/access_tokens_shared_examples.rb2
38 files changed, 1397 insertions, 169 deletions
diff --git a/Gemfile.lock b/Gemfile.lock
index fbfb49aa8ee..7151b09bf01 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -916,7 +916,7 @@ GEM
orm_adapter (0.5.0)
os (1.1.1)
parallel (1.20.1)
- parser (3.0.3.2)
+ parser (3.1.2.0)
ast (~> 2.4.1)
parslet (1.8.2)
pastel (0.8.0)
diff --git a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue
new file mode 100644
index 00000000000..e936ad8aa14
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue
@@ -0,0 +1,204 @@
+<script>
+import { GlButton, GlIcon, GlLink, GlTable, GlTooltipDirective } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { __, s__, sprintf } from '~/locale';
+import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import UserDate from '~/vue_shared/components/user_date.vue';
+
+const FORM_SELECTOR = '#js-new-access-token-form';
+const SUCCESS_EVENT = 'ajax:success';
+
+export default {
+ FORM_SELECTOR,
+ SUCCESS_EVENT,
+ name: 'AccessTokenTableApp',
+ components: {
+ DomElementListener,
+ GlButton,
+ GlIcon,
+ GlLink,
+ 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'),
+ header: __('Active %{accessTokenTypePlural} (%{totalAccessTokens})'),
+ modalMessage: __(
+ 'Are you sure you want to revoke this %{accessTokenType}? This action cannot be undone.',
+ ),
+ revokeButton: __('Revoke'),
+ tokenValidity: __('Token valid until revoked'),
+ },
+ fields: [
+ {
+ key: 'name',
+ label: __('Token name'),
+ sortable: true,
+ tdClass: `gl-text-black-normal`,
+ thClass: `gl-text-black-normal`,
+ },
+ {
+ formatter(scopes) {
+ return scopes?.length ? scopes.join(', ') : __('no scopes selected');
+ },
+ key: 'scopes',
+ label: __('Scopes'),
+ sortable: true,
+ tdClass: `gl-text-black-normal`,
+ thClass: `gl-text-black-normal`,
+ },
+ {
+ key: 'createdAt',
+ label: s__('AccessTokens|Created'),
+ sortable: true,
+ tdClass: `gl-text-black-normal`,
+ thClass: `gl-text-black-normal`,
+ },
+ {
+ key: 'lastUsedAt',
+ label: __('Last Used'),
+ sortable: true,
+ tdClass: `gl-text-black-normal`,
+ thClass: `gl-text-black-normal`,
+ },
+ {
+ key: 'expiresAt',
+ label: __('Expires'),
+ sortable: true,
+ tdClass: `gl-text-black-normal`,
+ thClass: `gl-text-black-normal`,
+ },
+ {
+ key: 'role',
+ label: __('Role'),
+ tdClass: `gl-text-black-normal`,
+ thClass: `gl-text-black-normal`,
+ sortable: true,
+ },
+ {
+ key: 'action',
+ label: __('Action'),
+ thClass: `gl-text-black-normal`,
+ },
+ ],
+ inject: [
+ 'accessTokenType',
+ 'accessTokenTypePlural',
+ 'initialActiveAccessTokens',
+ 'noActiveTokensMessage',
+ 'showRole',
+ ],
+ data() {
+ return {
+ activeAccessTokens: this.initialActiveAccessTokens,
+ };
+ },
+ computed: {
+ filteredFields() {
+ return this.showRole
+ ? this.$options.fields
+ : this.$options.fields.filter((field) => field.key !== 'role');
+ },
+ header() {
+ return sprintf(this.$options.i18n.header, {
+ accessTokenTypePlural: this.accessTokenTypePlural,
+ totalAccessTokens: this.activeAccessTokens.length,
+ });
+ },
+ modalMessage() {
+ return sprintf(this.$options.i18n.modalMessage, {
+ accessTokenType: this.accessTokenType,
+ });
+ },
+ },
+ methods: {
+ onSuccess(event) {
+ const [{ active_access_tokens: activeAccessTokens }] = event.detail;
+ this.activeAccessTokens = convertObjectPropsToCamelCase(activeAccessTokens, { deep: true });
+ },
+ 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>
+ <dom-element-listener :selector="$options.FORM_SELECTOR" @[$options.SUCCESS_EVENT]="onSuccess">
+ <div>
+ <hr />
+ <h5>{{ header }}</h5>
+
+ <gl-table
+ data-testid="active-tokens"
+ :empty-text="noActiveTokensMessage"
+ :fields="filteredFields"
+ :items="activeAccessTokens"
+ :sort-compare="sortingChanged"
+ show-empty
+ >
+ <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" /><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, expired, expiresSoon } }">
+ <template v-if="expiresAt">
+ <span v-if="expired" class="text-danger">{{ $options.i18n.expired }}</span>
+ <time-ago-tooltip v-else :class="{ 'text-warning': expiresSoon }" :time="expiresAt" />
+ </template>
+ <span v-else v-gl-tooltip :title="$options.i18n.tokenValidity">{{
+ $options.i18n.emptyField
+ }}</span>
+ </template>
+
+ <template #cell(action)="{ item: { revokePath, expiresAt } }">
+ <gl-button
+ variant="danger"
+ :category="expiresAt ? 'primary' : 'secondary'"
+ :aria-label="$options.i18n.revokeButton"
+ :data-confirm="modalMessage"
+ data-confirm-btn-variant="danger"
+ data-qa-selector="revoke_button"
+ data-method="put"
+ :href="revokePath"
+ icon="remove"
+ />
+ </template>
+ </gl-table>
+ </div>
+ </dom-element-listener>
+</template>
diff --git a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
new file mode 100644
index 00000000000..69a4fedabae
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
@@ -0,0 +1,125 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { createAlert, VARIANT_INFO } from '~/flash';
+import { __, n__, sprintf } from '~/locale';
+import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
+import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue';
+
+const ERROR_EVENT = 'ajax:error';
+const FORM_SELECTOR = '#js-new-access-token-form';
+const SUCCESS_EVENT = 'ajax:success';
+
+export default {
+ ERROR_EVENT,
+ FORM_SELECTOR,
+ SUCCESS_EVENT,
+ name: 'NewAccessTokenApp',
+ components: { DomElementListener, GlAlert, InputCopyToggleVisibility },
+ i18n: {
+ alertInfoMessage: __('Your new %{accessTokenType} has been created.'),
+ copyButtonTitle: __('Copy %{accessTokenType}'),
+ description: __("Make sure you save it - you won't be able to access it again."),
+ label: __('Your new %{accessTokenType}'),
+ },
+ inject: ['accessTokenType'],
+ data() {
+ return { errors: null, infoAlert: null, newToken: null };
+ },
+ computed: {
+ alertInfoMessage() {
+ return sprintf(this.$options.i18n.alertInfoMessage, {
+ accessTokenType: this.accessTokenType,
+ });
+ },
+ alertDangerTitle() {
+ return n__(
+ 'The form contains the following error:',
+ 'The form contains the following errors:',
+ this.errors?.length ?? 0,
+ );
+ },
+ copyButtonTitle() {
+ return sprintf(this.$options.i18n.copyButtonTitle, { accessTokenType: this.accessTokenType });
+ },
+ label() {
+ return sprintf(this.$options.i18n.label, { accessTokenType: this.accessTokenType });
+ },
+ },
+ mounted() {
+ /** @type {HTMLFormElement} */
+ this.form = document.querySelector(this.$options.FORM_SELECTOR);
+
+ /** @type {HTMLInputElement} */
+ this.submitButton = this.form.querySelector('input[type=submit]');
+ },
+ methods: {
+ beforeDisplayResults() {
+ this.infoAlert?.dismiss();
+ this.$refs.container.scrollIntoView(false);
+
+ this.errors = null;
+ this.newToken = null;
+ },
+ onError(event) {
+ this.beforeDisplayResults();
+
+ const [{ errors }] = event.detail;
+ this.errors = errors;
+
+ this.submitButton.classList.remove('disabled');
+ },
+ onSuccess(event) {
+ this.beforeDisplayResults();
+
+ const [{ new_token: newToken }] = event.detail;
+ this.newToken = newToken;
+
+ this.infoAlert = createAlert({ message: this.alertInfoMessage, variant: VARIANT_INFO });
+
+ this.form.reset();
+ },
+ },
+};
+</script>
+
+<template>
+ <dom-element-listener
+ :selector="$options.FORM_SELECTOR"
+ @[$options.ERROR_EVENT]="onError"
+ @[$options.SUCCESS_EVENT]="onSuccess"
+ >
+ <div ref="container">
+ <template v-if="newToken">
+ <!--
+ After issue https://gitlab.com/gitlab-org/gitlab/-/issues/360921 is
+ closed remove the `initial-visibility` and `input-class` props.
+ -->
+ <input-copy-toggle-visibility
+ data-testid="new-access-token"
+ :copy-button-title="copyButtonTitle"
+ :label="label"
+ :value="newToken"
+ initial-visibility
+ input-class="qa-created-access-token"
+ qa-selector="created_access_token_field"
+ >
+ <template #description>
+ {{ $options.i18n.description }}
+ </template>
+ </input-copy-toggle-visibility>
+ <hr />
+ </template>
+
+ <template v-if="errors">
+ <gl-alert :title="alertDangerTitle" variant="danger" @dismiss="errors = null">
+ <ul class="m-0">
+ <li v-for="error in errors" :key="error">
+ {{ error }}
+ </li>
+ </ul>
+ </gl-alert>
+ <hr />
+ </template>
+ </div>
+ </dom-element-listener>
+</template>
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js
index fb5c5521ce9..a7a03523e7f 100644
--- a/app/assets/javascripts/access_tokens/index.js
+++ b/app/assets/javascripts/access_tokens/index.js
@@ -3,12 +3,57 @@ import Vue from 'vue';
import createFlash from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { parseRailsFormFields } from '~/lib/utils/forms';
-import { __ } from '~/locale';
+import { __, sprintf } from '~/locale';
+import AccessTokenTableApp from './components/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';
import { FEED_TOKEN, INCOMING_EMAIL_TOKEN, STATIC_OBJECT_TOKEN } from './constants';
+export const initAccessTokenTableApp = () => {
+ const el = document.querySelector('#js-access-token-table-app');
+
+ if (!el) {
+ return null;
+ }
+
+ const {
+ accessTokenType,
+ accessTokenTypePlural,
+ initialActiveAccessTokens: initialActiveAccessTokensJson,
+ noActiveTokensMessage: noTokensMessage,
+ } = el.dataset;
+
+ // Default values
+ const noActiveTokensMessage =
+ noTokensMessage ||
+ sprintf(__('This user has no active %{accessTokenTypePlural}.'), { accessTokenTypePlural });
+ const showRole = 'showRole' in el.dataset;
+
+ const initialActiveAccessTokens = convertObjectPropsToCamelCase(
+ JSON.parse(initialActiveAccessTokensJson),
+ {
+ deep: true,
+ },
+ );
+
+ return new Vue({
+ el,
+ name: 'AccessTokenTableRoot',
+ provide: {
+ accessTokenType,
+ accessTokenTypePlural,
+ initialActiveAccessTokens,
+ noActiveTokensMessage,
+ showRole,
+ },
+ render(h) {
+ return h(AccessTokenTableApp);
+ },
+ });
+};
+
export const initExpiresAtField = () => {
const el = document.querySelector('.js-access-tokens-expires-at');
@@ -33,6 +78,27 @@ export const initExpiresAtField = () => {
});
};
+export const initNewAccessTokenApp = () => {
+ const el = document.querySelector('#js-new-access-token-app');
+
+ if (!el) {
+ return null;
+ }
+
+ const { accessTokenType } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'NewAccessTokenRoot',
+ provide: {
+ accessTokenType,
+ },
+ render(h) {
+ return h(NewAccessTokenApp);
+ },
+ });
+};
+
export const initProjectsField = () => {
const el = document.querySelector('.js-access-tokens-projects');
diff --git a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
index 37e9b7e99d4..3fae9809e51 100644
--- a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
+++ b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
@@ -1,5 +1,13 @@
-import { initExpiresAtField, initProjectsField, initTokensApp } from '~/access_tokens';
+import {
+ initAccessTokenTableApp,
+ initExpiresAtField,
+ initNewAccessTokenApp,
+ initProjectsField,
+ initTokensApp,
+} from '~/access_tokens';
+initAccessTokenTableApp();
initExpiresAtField();
+initNewAccessTokenApp();
initProjectsField();
initTokensApp();
diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
index 69548f0e7a8..11fcc697602 100644
--- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
+++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
@@ -52,6 +52,20 @@ export default {
return {};
},
},
+ /*
+ `inputClass` prop should be removed after https://gitlab.com/gitlab-org/gitlab/-/issues/357848
+ is implemented.
+ */
+ inputClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ qaSelector: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
},
data() {
return {
@@ -73,6 +87,9 @@ export default {
displayedValue() {
return this.computedValueIsVisible ? this.value : '*'.repeat(this.value.length || 20);
},
+ classInput() {
+ return `gl-font-monospace! gl-cursor-default! ${this.inputClass}`.trimEnd();
+ },
},
methods: {
handleToggleVisibilityButtonClick() {
@@ -98,7 +115,8 @@ export default {
<gl-form-group v-bind="$attrs">
<gl-form-input-group
:value="displayedValue"
- input-class="gl-font-monospace! gl-cursor-default!"
+ :input-class="classInput"
+ :data-qa-selector="qaSelector"
select-on-click
readonly
v-bind="formInputGroupProps"
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index dd9581c4692..217c6a7567c 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -24,10 +24,6 @@
&:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper {
padding-right: $gutter-collapsed-width;
}
-
- .merge-request-tabs-holder.affix {
- right: $gutter-collapsed-width;
- }
}
}
@@ -81,14 +77,6 @@
.content-wrapper {
padding-right: $gutter-width;
}
-
- &:not(.with-overlay) .merge-request-tabs-holder.affix {
- right: $gutter-width;
- }
-
- &.with-overlay .merge-request-tabs-holder.affix {
- right: $gutter-collapsed-width;
- }
}
}
@@ -110,10 +98,6 @@
}
}
-.with-performance-bar .right-sidebar.affix {
- top: calc(#{$header-height} + #{$performance-bar-height});
-}
-
@mixin maintain-sidebar-dimensions {
display: block;
width: $gutter-width;
diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss
index d55d6b27576..9213754d419 100644
--- a/app/assets/stylesheets/page_bundles/build.scss
+++ b/app/assets/stylesheets/page_bundles/build.scss
@@ -44,13 +44,6 @@
}
}
- &.affix-top {
- position: absolute;
- right: 0;
- left: 0;
- top: 0;
- }
-
.controllers {
@include build-controllers(15px, center, false, 0, inline, 0);
}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 5062a7bb8fa..451b4022d41 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -263,10 +263,6 @@
}
}
- &.affix-top .issuable-sidebar {
- height: 100%;
- }
-
&.right-sidebar-expanded {
&:not(.right-sidebar-merge-requests) {
width: $gutter-width;
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 543ae1df1af..fbfb1398889 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -247,12 +247,6 @@ $tabs-holder-z-index: 250;
}
}
-.merge-request-tabs-holder.affix .merge-request-tabs-container,
-.epic-tabs-holder.affix .epic-tabs-container {
- padding-left: $gl-padding;
- padding-right: $gl-padding;
-}
-
.with-performance-bar {
.merge-request-tabs-holder,
.epic-tabs-holder {
@@ -331,8 +325,7 @@ $tabs-holder-z-index: 250;
}
.limit-container-width:not(.container-limited) {
- .merge-request-tabs-holder:not(.affix) .merge-request-tabs-container,
- .epic-tabs-holder:not(.affix) .epic-tabs-container {
+ .merge-request-tabs-holder .merge-request-tabs-container {
max-width: $limited-layout-width - ($gl-padding * 2);
}
}
diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb
index ad2e384077a..5c5d4008d00 100644
--- a/app/controllers/profiles/personal_access_tokens_controller.rb
+++ b/app/controllers/profiles/personal_access_tokens_controller.rb
@@ -23,12 +23,21 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
@personal_access_token = result.payload[:personal_access_token]
- if result.success?
- PersonalAccessToken.redis_store!(current_user.id, @personal_access_token.token)
- redirect_to profile_personal_access_tokens_path, notice: _("Your new personal access token has been created.")
+ if Feature.enabled?(:access_token_ajax)
+ if result.success?
+ render json: { new_token: @personal_access_token.token,
+ active_access_tokens: active_personal_access_tokens }, status: :ok
+ else
+ render json: { errors: result.errors }, status: :unprocessable_entity
+ end
else
- set_index_vars
- render :index
+ if result.success?
+ PersonalAccessToken.redis_store!(current_user.id, @personal_access_token.token)
+ redirect_to profile_personal_access_tokens_path, notice: _("Your new personal access token has been created.")
+ else
+ set_index_vars
+ render :index
+ end
end
end
@@ -52,14 +61,20 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
def set_index_vars
@scopes = Gitlab::Auth.available_scopes_for(current_user)
-
- @inactive_personal_access_tokens = finder(state: 'inactive').execute
@active_personal_access_tokens = active_personal_access_tokens
- @new_personal_access_token = PersonalAccessToken.redis_getdel(current_user.id)
+ if Feature.disabled?(:access_token_ajax)
+ @new_personal_access_token = PersonalAccessToken.redis_getdel(current_user.id)
+ end
end
def active_personal_access_tokens
- finder(state: 'active', sort: 'expires_at_asc').execute
+ tokens = finder(state: 'active', sort: 'expires_at_asc').execute
+
+ if Feature.enabled?(:access_token_ajax)
+ ::API::Entities::PersonalAccessTokenWithDetails.represent(tokens)
+ else
+ tokens
+ end
end
end
diff --git a/app/controllers/projects/google_cloud/base_controller.rb b/app/controllers/projects/google_cloud/base_controller.rb
index 0d65431d870..980e9bdcdad 100644
--- a/app/controllers/projects/google_cloud/base_controller.rb
+++ b/app/controllers/projects/google_cloud/base_controller.rb
@@ -2,6 +2,7 @@
class Projects::GoogleCloud::BaseController < Projects::ApplicationController
feature_category :five_minute_production_app
+ urgency :low
before_action :admin_project_google_cloud!
before_action :google_oauth2_enabled!
diff --git a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
index de44dbb26d7..ea737f54a95 100644
--- a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
+++ b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
@@ -66,7 +66,6 @@ module IssueResolverArguments
description: 'Filter for confidential issues. If "false", excludes confidential issues. If "true", returns only confidential issues.'
argument :not, Types::Issues::NegatedIssueFilterInputType,
description: 'Negated arguments.',
- prepare: ->(negated_args, ctx) { negated_args.to_h },
required: false
argument :crm_contact_id, GraphQL::Types::String,
required: false,
@@ -85,6 +84,7 @@ module IssueResolverArguments
# Will need to be made group & namespace aware with
# https://gitlab.com/gitlab-org/gitlab-foss/issues/54520
+ args[:not] = args[:not].to_h if args[:not].present?
args[:iids] ||= [args.delete(:iid)].compact if args[:iid]
args[:attempt_project_search_optimizations] = true if args[:search].present?
@@ -98,6 +98,8 @@ module IssueResolverArguments
end
def ready?(**args)
+ args[:not] = args[:not].to_h if args[:not].present?
+
params_not_mutually_exclusive(args, mutually_exclusive_assignee_username_args)
params_not_mutually_exclusive(args, mutually_exclusive_milestone_args)
params_not_mutually_exclusive(args.fetch(:not, {}), mutually_exclusive_milestone_args)
diff --git a/app/graphql/resolvers/tree_resolver.rb b/app/graphql/resolvers/tree_resolver.rb
index f02eb226810..553f9aa6cd9 100644
--- a/app/graphql/resolvers/tree_resolver.rb
+++ b/app/graphql/resolvers/tree_resolver.rb
@@ -16,7 +16,6 @@ module Resolvers
description: 'Used to get a recursive tree. Default is false.'
argument :ref, GraphQL::Types::String,
required: false,
- default_value: :head,
description: 'Commit ref to get the tree for. Default value is HEAD.'
alias_method :repository, :object
@@ -24,6 +23,7 @@ module Resolvers
def resolve(**args)
return unless repository.exists?
+ args[:ref] ||= :head
repository.tree(args[:ref], args[:path], recursive: args[:recursive])
end
end
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index e62b6fa5fc5..bed0eab5a58 100644
--- a/app/models/clusters/applications/runner.rb
+++ b/app/models/clusters/applications/runner.rb
@@ -3,7 +3,7 @@
module Clusters
module Applications
class Runner < ApplicationRecord
- VERSION = '0.39.0'
+ VERSION = '0.41.0'
self.table_name = 'clusters_applications_runners'
diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml
index 887d07f7a20..5be862257b4 100644
--- a/app/views/profiles/personal_access_tokens/index.html.haml
+++ b/app/views/profiles/personal_access_tokens/index.html.haml
@@ -1,3 +1,4 @@
+- ajax = Feature.enabled?(:access_token_ajax)
- breadcrumb_title s_('AccessTokens|Access Tokens')
- page_title s_('AccessTokens|Personal Access Tokens')
- type = _('personal access token')
@@ -15,19 +16,24 @@
= s_('AccessTokens|They are the only accepted password when you have Two-Factor Authentication (2FA) enabled.')
.col-lg-8
+ #js-new-access-token-app{ data: { access_token_type: type } }
- if @new_personal_access_token
= render 'shared/access_tokens/created_container',
type: type,
new_token_value: @new_personal_access_token
= render 'shared/access_tokens/form',
+ ajax: ajax,
type: type,
path: profile_personal_access_tokens_path,
token: @personal_access_token,
scopes: @scopes,
help_path: help_page_path('user/profile/personal_access_tokens.md', anchor: 'personal-access-token-scopes')
- = render 'shared/access_tokens/table',
+ - if ajax
+ #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_personal_access_tokens.to_json } }
+ - else
+ = render 'shared/access_tokens/table',
type: type,
type_plural: type_plural,
active_tokens: @active_personal_access_tokens,
diff --git a/app/views/projects/_merge_request_merge_method_settings.html.haml b/app/views/projects/_merge_request_merge_method_settings.html.haml
index cb660750632..f205fe2b9bf 100644
--- a/app/views/projects/_merge_request_merge_method_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_method_settings.html.haml
@@ -1,38 +1,34 @@
- form = local_assigns.fetch(:form)
+- labelMerge = s_('ProjectSettings|Merge commit')
+- everyMergeCommit = s_('ProjectSettings|Every merge creates a merge commit.')
+
+- labelRebase = s_('ProjectSettings|Merge commit with semi-linear history')
+- rebaseUpToDate = s_('ProjectSettings|Merging is only allowed when the source branch is up-to-date with its target.')
+- rebaseSemiLinear = s_('ProjectSettings|When semi-linear merge is not possible, the user is given the option to rebase.')
+
+- labelFastForward = s_('ProjectSettings|Fast-forward merge')
+- noMergeCommit = s_('ProjectSettings|No merge commits are created.')
+- ffOnly = s_('ProjectSettings|Fast-forward merges only.')
+- ffConflictRebase = s_('ProjectSettings|When there is a merge conflict, the user is given the option to rebase.')
+- ffTrains = s_('ProjectSettings|If merge trains are enabled, merging is only possible if the branch can be rebased without conflicts.')
+- ffTrainsHelp = link_to s_('ProjectSettings|What are merge trains?'), help_page_path('ci/pipelines/merge_trains.md', anchor: 'enable-merge-trains'), target: '_blank', rel: 'noopener noreferrer'
+
.form-group
%b= s_('ProjectSettings|Merge method')
%p.text-secondary
= s_('ProjectSettings|Determine what happens to the commit history when you merge a merge request.')
= link_to s_('ProjectSettings|How do they differ?'), help_page_path('user/project/merge_requests/methods/index.md'), target: '_blank', rel: 'noopener noreferrer'
- .form-check.mb-2
- = form.radio_button :merge_method, :merge, class: "js-merge-method-radio form-check-input"
- = label_tag :project_merge_method_merge, class: 'form-check-label' do
- = s_('ProjectSettings|Merge commit')
- .text-secondary
- = s_('ProjectSettings|Every merge creates a merge commit.')
-
- .form-check.mb-2
- = form.radio_button :merge_method, :rebase_merge, class: "js-merge-method-radio form-check-input"
- = label_tag :project_merge_method_rebase_merge, class: 'form-check-label' do
- = s_('ProjectSettings|Merge commit with semi-linear history')
- .text-secondary
- = s_('ProjectSettings|Every merge creates a merge commit.')
- %br
- = s_('ProjectSettings|Merging is only allowed when the source branch is up-to-date with its target.')
- %br
- = s_('ProjectSettings|When semi-linear merge is not possible, the user is given the option to rebase.')
-
- .form-check.mb-2
- = form.radio_button :merge_method, :ff, class: "js-merge-method-radio form-check-input", data: { qa_selector: 'merge_ff_radio' }
- = label_tag :project_merge_method_ff, class: 'form-check-label' do
- = s_('ProjectSettings|Fast-forward merge')
- .text-secondary
- = s_('ProjectSettings|No merge commits are created.')
- %br
- = s_('ProjectSettings|Fast-forward merges only.')
- %br
- = s_('ProjectSettings|When there is a merge conflict, the user is given the option to rebase.')
- %div
- = s_('ProjectSettings|If merge trains are enabled, merging is only possible if the branch can be rebased without conflicts.')
- = link_to s_('ProjectSettings|What are merge trains?'), help_page_path('ci/pipelines/merge_trains.md', anchor: 'enable-merge-trains'), target: '_blank', rel: 'noopener noreferrer'
+ = form.gitlab_ui_radio_component :merge_method,
+ :merge,
+ labelMerge,
+ help_text: everyMergeCommit
+ = form.gitlab_ui_radio_component :merge_method,
+ :rebase_merge,
+ labelRebase,
+ help_text: (everyMergeCommit + "<br />" + rebaseUpToDate + "<br />" + rebaseSemiLinear).html_safe
+ = form.gitlab_ui_radio_component :merge_method,
+ :ff,
+ labelFastForward,
+ help_text: (noMergeCommit + "<br />" + ffOnly + "<br />" + ffConflictRebase + "<br />" + ffTrains + " " + ffTrainsHelp).html_safe,
+ radio_options: { data: { qa_selector: 'merge_ff_radio' } }
diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml
index d4106ba4e5d..665b7794b79 100644
--- a/app/views/shared/access_tokens/_form.html.haml
+++ b/app/views/shared/access_tokens/_form.html.haml
@@ -1,3 +1,4 @@
+- ajax = local_assigns.fetch(:ajax, false)
- title = local_assigns.fetch(:title, _('Add a %{type}') % { type: type })
- prefix = local_assigns.fetch(:prefix, :personal_access_token)
- help_path = local_assigns.fetch(:help_path)
@@ -10,7 +11,7 @@
%p.profile-settings-content
= _("Enter the name of your application, and we'll return a unique %{type}.") % { type: type }
-= gitlab_ui_form_for token, as: prefix, url: path, method: :post, html: { class: 'js-requires-input' } do |f|
+= gitlab_ui_form_for token, as: prefix, url: path, method: :post, html: { id: 'js-new-access-token-form', class: 'js-requires-input' }, remote: ajax do |f|
= form_errors(token)
diff --git a/config/feature_flags/development/access_token_ajax.yml b/config/feature_flags/development/access_token_ajax.yml
new file mode 100644
index 00000000000..ea7c2f7a083
--- /dev/null
+++ b/config/feature_flags/development/access_token_ajax.yml
@@ -0,0 +1,8 @@
+---
+name: access_token_ajax
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/84373
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/359956
+milestone: '15.0'
+type: development
+group: group::authentication and authorization
+default_enabled: false
diff --git a/doc/architecture/blueprints/ci_scale/index.md b/doc/architecture/blueprints/ci_scale/index.md
index be21f824392..1c3aee2f860 100644
--- a/doc/architecture/blueprints/ci_scale/index.md
+++ b/doc/architecture/blueprints/ci_scale/index.md
@@ -37,7 +37,7 @@ to sustain future growth.
### We are running out of the capacity to store primary keys
The primary key in `ci_builds` table is an integer generated in a sequence.
-Historically, Rails used to use [integer](https://www.postgresql.org/docs/9.1/datatype-numeric.html)
+Historically, Rails used to use [integer](https://www.postgresql.org/docs/14/datatype-numeric.html)
type when creating primary keys for a table. We did use the default when we
[created the `ci_builds` table in 2012](https://gitlab.com/gitlab-org/gitlab/-/blob/046b28312704f3131e72dcd2dbdacc5264d4aa62/db/ci/migrate/20121004165038_create_builds.rb).
[The behavior of Rails has changed](https://github.com/rails/rails/pull/26266)
@@ -55,8 +55,8 @@ that have the same problem.
Primary keys problem will be tackled by our Database Team.
-**Status**: As of October 2021 the primary keys in CI tables have been migrated
-to big integers.
+**Status**: In October 2021, the primary keys in CI tables were migrated
+to big integers. See the [related Epic](https://gitlab.com/groups/gitlab-org/-/epics/5657) for more details.
### The table is too large
diff --git a/doc/user/group/saml_sso/index.md b/doc/user/group/saml_sso/index.md
index c61818efe61..7c784b0d50e 100644
--- a/doc/user/group/saml_sso/index.md
+++ b/doc/user/group/saml_sso/index.md
@@ -201,7 +201,7 @@ For NameID, the following settings are recommended:
- **NameID** set to `Basic Information > Primary email`.
When selecting **Verify SAML Configuration** on the GitLab SAML SSO page, disregard the warning about the NameID format
-"persistent" recommended.
+"persistent" being recommended.
See the [troubleshooting page](../../../administration/troubleshooting/group_saml_scim.md#google-workspace) for an example configuration.
diff --git a/lib/api/entities/personal_access_token_with_details.rb b/lib/api/entities/personal_access_token_with_details.rb
new file mode 100644
index 00000000000..5654bd4a1e1
--- /dev/null
+++ b/lib/api/entities/personal_access_token_with_details.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module API
+ module Entities
+ class PersonalAccessTokenWithDetails < Entities::PersonalAccessToken
+ expose :expired?, as: :expired
+ expose :expires_soon?, as: :expires_soon
+ expose :revoke_path do |token|
+ Gitlab::Routing.url_helpers.revoke_profile_personal_access_token_path(token)
+ end
+ end
+ end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 65db4ed450f..557f6cb43c3 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1929,6 +1929,9 @@ msgstr ""
msgid "AccessTokens|Static object token"
msgstr ""
+msgid "AccessTokens|The last time a token was used"
+msgstr ""
+
msgid "AccessTokens|They are the only accepted password when you have Two-Factor Authentication (2FA) enabled."
msgstr ""
@@ -2016,6 +2019,9 @@ msgstr ""
msgid "Active"
msgstr ""
+msgid "Active %{accessTokenTypePlural} (%{totalAccessTokens})"
+msgstr ""
+
msgid "Active %{type} (%{token_length})"
msgstr ""
@@ -4926,6 +4932,9 @@ msgstr ""
msgid "Are you sure you want to retry this migration?"
msgstr ""
+msgid "Are you sure you want to revoke this %{accessTokenType}? This action cannot be undone."
+msgstr ""
+
msgid "Are you sure you want to revoke this %{type}? This action cannot be undone."
msgstr ""
@@ -10039,6 +10048,9 @@ msgstr ""
msgid "Copy"
msgstr ""
+msgid "Copy %{accessTokenType}"
+msgstr ""
+
msgid "Copy %{http_label} clone URL"
msgstr ""
@@ -37848,6 +37860,11 @@ msgstr[1] ""
msgid "The fork relationship has been removed."
msgstr ""
+msgid "The form contains the following error:"
+msgid_plural "The form contains the following errors:"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "The form contains the following warning:"
msgstr ""
@@ -39084,6 +39101,9 @@ msgstr ""
msgid "This user has an unconfirmed email address. You may force a confirmation."
msgstr ""
+msgid "This user has no active %{accessTokenTypePlural}."
+msgstr ""
+
msgid "This user has no active %{type}."
msgstr ""
@@ -39766,6 +39786,9 @@ msgstr ""
msgid "Token name"
msgstr ""
+msgid "Token valid until revoked"
+msgstr ""
+
msgid "Tokens|Scopes set the permission levels granted to the token."
msgstr ""
@@ -43969,6 +43992,12 @@ msgstr ""
msgid "Your name"
msgstr ""
+msgid "Your new %{accessTokenType}"
+msgstr ""
+
+msgid "Your new %{accessTokenType} has been created."
+msgstr ""
+
msgid "Your new %{type}"
msgstr ""
diff --git a/qa/qa/page/component/access_tokens.rb b/qa/qa/page/component/access_tokens.rb
index 6a9249621e1..ec8bbe8ff97 100644
--- a/qa/qa/page/component/access_tokens.rb
+++ b/qa/qa/page/component/access_tokens.rb
@@ -22,10 +22,22 @@ module QA
element :api_label, '#{scope}_label' # rubocop:disable QA/ElementWithPattern, Lint/InterpolationCheck
end
+ base.view 'app/assets/javascripts/access_tokens/components/new_access_token_app.vue' do
+ element :created_access_token
+ end
+
+ # This element will be removed once `access_token_ajax` feature flag is removed
+ # and this work is completed: https://gitlab.com/gitlab-org/gitlab/-/issues/357848
base.view 'app/views/shared/access_tokens/_created_container.html.haml' do
element :created_access_token
end
+ base.view 'app/assets/javascripts/access_tokens/components/access_token_table_app.vue' do
+ element :revoke_button
+ end
+
+ # This element will be removed once `access_token_ajax` feature flag is removed
+ # and this work is completed: https://gitlab.com/gitlab-org/gitlab/-/issues/357848
base.view 'app/views/shared/access_tokens/_table.html.haml' do
element :revoke_button
end
diff --git a/qa/qa/tools/delete_test_snippets.rb b/qa/qa/tools/delete_test_snippets.rb
index 5da962b14f3..e590077f81c 100644
--- a/qa/qa/tools/delete_test_snippets.rb
+++ b/qa/qa/tools/delete_test_snippets.rb
@@ -12,7 +12,7 @@ module QA
class DeleteTestSnippets
include Support::API
- ITEMS_PER_PAGE = '100'
+ ITEMS_PER_PAGE = '1'
def initialize(delete_before: (Date.today - 1).to_s, dry_run: false)
raise ArgumentError, "Please provide GITLAB_ADDRESS" unless ENV['GITLAB_ADDRESS']
@@ -69,6 +69,11 @@ module QA
to_delete
end
snippet_ids.concat(snippets.map { |snippet| snippet['id'] })
+
+ if (page_no + 1) == 1000
+ puts "Stopping at page 1000 to avoid timeout, total number of pages: #{pages}"
+ break
+ end
end
snippet_ids.uniq
diff --git a/spec/controllers/profiles/personal_access_tokens_controller_spec.rb b/spec/controllers/profiles/personal_access_tokens_controller_spec.rb
index 3859af66292..6b852daeda2 100644
--- a/spec/controllers/profiles/personal_access_tokens_controller_spec.rb
+++ b/spec/controllers/profiles/personal_access_tokens_controller_spec.rb
@@ -39,30 +39,19 @@ RSpec.describe Profiles::PersonalAccessTokensController do
describe '#index' do
let!(:active_personal_access_token) { create(:personal_access_token, user: user) }
- let!(:inactive_personal_access_token) { create(:personal_access_token, :revoked, user: user) }
- let!(:impersonation_personal_access_token) { create(:personal_access_token, :impersonation, user: user) }
- let(:token_value) { 's3cr3t' }
before do
- PersonalAccessToken.redis_store!(user.id, token_value)
+ # Impersonation and inactive personal tokens are ignored
+ create(:personal_access_token, :impersonation, user: user)
+ create(:personal_access_token, :revoked, user: user)
get :index
end
- it "retrieves active personal access tokens" do
- expect(assigns(:active_personal_access_tokens)).to include(active_personal_access_token)
- end
-
- it "retrieves inactive personal access tokens" do
- expect(assigns(:inactive_personal_access_tokens)).to include(inactive_personal_access_token)
- end
-
- it "does not retrieve impersonation personal access tokens" do
- expect(assigns(:active_personal_access_tokens)).not_to include(impersonation_personal_access_token)
- expect(assigns(:inactive_personal_access_tokens)).not_to include(impersonation_personal_access_token)
- end
+ it "only includes details of the active personal access token" do
+ active_personal_access_tokens_detail = ::API::Entities::PersonalAccessTokenWithDetails
+ .represent([active_personal_access_token])
- it "retrieves newly created personal access token value" do
- expect(assigns(:new_personal_access_token)).to eql(token_value)
+ expect(assigns(:active_personal_access_tokens).to_json).to eq(active_personal_access_tokens_detail.to_json)
end
it "sets PAT name and scopes" do
@@ -77,4 +66,44 @@ RSpec.describe Profiles::PersonalAccessTokensController do
)
end
end
+
+ context 'access_token_ajax feature flag disabled' do
+ before do
+ stub_feature_flags(access_token_ajax: false)
+ PersonalAccessToken.redis_store!(user.id, token_value)
+ get :index
+ end
+
+ describe '#index' do
+ let!(:active_personal_access_token) { create(:personal_access_token, user: user) }
+ let!(:inactive_personal_access_token) { create(:personal_access_token, :revoked, user: user) }
+ let!(:impersonation_personal_access_token) { create(:personal_access_token, :impersonation, user: user) }
+ let(:token_value) { 's3cr3t' }
+
+ it "retrieves active personal access tokens" do
+ expect(assigns(:active_personal_access_tokens)).to include(active_personal_access_token)
+ end
+
+ it "does not retrieve impersonation tokens or inactive personal access tokens" do
+ expect(assigns(:active_personal_access_tokens)).not_to include(impersonation_personal_access_token)
+ expect(assigns(:active_personal_access_tokens)).not_to include(inactive_personal_access_token)
+ end
+
+ it "retrieves newly created personal access token value" do
+ expect(assigns(:new_personal_access_token)).to eql(token_value)
+ end
+
+ it "sets PAT name and scopes" do
+ name = 'My PAT'
+ scopes = 'api,read_user'
+
+ get :index, params: { name: name, scopes: scopes }
+
+ expect(assigns(:personal_access_token)).to have_attributes(
+ name: eq(name),
+ scopes: contain_exactly(:api, :read_user)
+ )
+ end
+ end
+ end
end
diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb
index 8cbc0491441..01e2571ff3e 100644
--- a/spec/features/profiles/personal_access_tokens_spec.rb
+++ b/spec/features/profiles/personal_access_tokens_spec.rb
@@ -7,28 +7,17 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do
let(:pat_create_service) { double('PersonalAccessTokens::CreateService', execute: ServiceResponse.error(message: 'error', payload: { personal_access_token: PersonalAccessToken.new })) }
def active_personal_access_tokens
- find(".table.active-tokens")
- end
-
- def no_personal_access_tokens_message
- find(".settings-message")
+ find("[data-testid='active-tokens']")
end
def created_personal_access_token
- find("#created-personal-access-token").value
+ find("[data-testid='new-access-token'] input").value
end
def feed_token_description
"Your feed token authenticates you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar. It is visible in those feed URLs."
end
- def disallow_personal_access_token_saves!
- allow(PersonalAccessTokens::CreateService).to receive(:new).and_return(pat_create_service)
-
- errors = ActiveModel::Errors.new(PersonalAccessToken.new).tap { |e| e.add(:name, "cannot be nil") }
- allow_any_instance_of(PersonalAccessToken).to receive(:errors).and_return(errors)
- end
-
before do
stub_feature_flags(bootstrap_confirmation_modals: false)
sign_in(user)
@@ -51,6 +40,7 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do
check "read_user"
click_on "Create personal access token"
+ wait_for_all_requests
expect(active_personal_access_tokens).to have_text(name)
expect(active_personal_access_tokens).to have_text('in')
@@ -61,13 +51,16 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do
context "when creation fails" do
it "displays an error message" do
- disallow_personal_access_token_saves!
+ number_tokens_before = PersonalAccessToken.count
visit profile_personal_access_tokens_path
fill_in "Token name", with: 'My PAT'
- expect { click_on "Create personal access token" }.not_to change { PersonalAccessToken.count }
- expect(page).to have_content("Name cannot be nil")
- expect(page).not_to have_selector("#created-personal-access-token")
+ click_on "Create personal access token"
+ wait_for_all_requests
+
+ expect(number_tokens_before).to equal(PersonalAccessToken.count)
+ expect(page).to have_content(_("Scopes can't be blank"))
+ expect(page).not_to have_selector("[data-testid='new-access-tokens']")
end
end
end
@@ -103,29 +96,25 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do
visit profile_personal_access_tokens_path
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(active_personal_access_tokens).to have_text("This user has no active personal access tokens.")
end
it "removes expired tokens from 'active' section" do
personal_access_token.update!(expires_at: 5.days.ago)
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(active_personal_access_tokens).to have_text("This user has no active personal access tokens.")
end
context "when revocation fails" do
it "displays an error message" do
- visit profile_personal_access_tokens_path
-
allow_next_instance_of(PersonalAccessTokens::RevokeService) do |instance|
allow(instance).to receive(:revocation_permitted?).and_return(false)
end
+ visit profile_personal_access_tokens_path
accept_confirm { click_on "Revoke" }
expect(active_personal_access_tokens).to have_text(personal_access_token.name)
- expect(page).to have_content("Not permitted to revoke")
end
end
end
@@ -172,4 +161,126 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do
expect(find("#personal_access_token_scopes_api")).to be_checked
expect(find("#personal_access_token_scopes_read_user")).to be_checked
end
+
+ context 'access_token_ajax feature flag disabled' do
+ def active_personal_access_tokens
+ find(".table.active-tokens")
+ end
+
+ def no_personal_access_tokens_message
+ find(".settings-message")
+ end
+
+ def created_personal_access_token
+ find("#created-personal-access-token").value
+ end
+
+ def disallow_personal_access_token_saves!
+ allow_next_instance_of(PersonalAccessToken) do |pat|
+ pat.errors.add(:name, 'cannot be nil')
+ end
+
+ allow(PersonalAccessTokens::CreateService).to receive(:new).and_return(pat_create_service)
+ end
+
+ before do
+ stub_feature_flags(bootstrap_confirmation_modals: false)
+ stub_feature_flags(access_token_ajax: false)
+ sign_in(user)
+ end
+
+ describe "token creation" do
+ it "allows creation of a personal access token" do
+ name = 'My PAT'
+
+ visit profile_personal_access_tokens_path
+ fill_in "Token name", with: name
+
+ # Set date to 1st of next month
+ find_field("Expiration date").click
+ find(".pika-next").click
+ click_on "1"
+
+ # Scopes
+ check "read_api"
+ check "read_user"
+
+ click_on "Create personal access token"
+
+ expect(active_personal_access_tokens).to have_text(name)
+ expect(active_personal_access_tokens).to have_text('in')
+ expect(active_personal_access_tokens).to have_text('read_api')
+ expect(active_personal_access_tokens).to have_text('read_user')
+ expect(created_personal_access_token).not_to be_empty
+ end
+
+ context "when creation fails" do
+ it "displays an error message" do
+ disallow_personal_access_token_saves!
+ visit profile_personal_access_tokens_path
+ fill_in "Token name", with: 'My PAT'
+
+ expect { click_on "Create personal access token" }.not_to change { PersonalAccessToken.count }
+ expect(page).to have_content("Name cannot be nil")
+ expect(page).not_to have_selector("#created-personal-access-token")
+ end
+ end
+ end
+
+ describe 'active tokens' do
+ let!(:impersonation_token) { create(:personal_access_token, :impersonation, user: user) }
+ let!(:personal_access_token) { create(:personal_access_token, user: user) }
+
+ it 'only shows personal access tokens' do
+ visit profile_personal_access_tokens_path
+
+ expect(active_personal_access_tokens).to have_text(personal_access_token.name)
+ expect(active_personal_access_tokens).not_to have_text(impersonation_token.name)
+ end
+
+ context 'when User#time_display_relative is false' do
+ before do
+ user.update!(time_display_relative: false)
+ end
+
+ it 'shows absolute times for expires_at' do
+ visit profile_personal_access_tokens_path
+
+ expect(active_personal_access_tokens).to have_text(PersonalAccessToken.last.expires_at.strftime('%b %-d'))
+ end
+ end
+ end
+
+ describe "inactive tokens" do
+ let!(:personal_access_token) { create(:personal_access_token, user: user) }
+
+ it "allows revocation of an active token" do
+ visit profile_personal_access_tokens_path
+ 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.")
+ end
+
+ it "removes expired tokens from 'active' section" do
+ personal_access_token.update!(expires_at: 5.days.ago)
+ 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.")
+ end
+
+ context "when revocation fails" do
+ it "displays an error message" do
+ allow_next_instance_of(PersonalAccessTokens::RevokeService) do |instance|
+ allow(instance).to receive(:revocation_permitted?).and_return(false)
+ end
+ visit profile_personal_access_tokens_path
+
+ accept_confirm { click_on "Revoke" }
+ expect(active_personal_access_tokens).to have_text(personal_access_token.name)
+ end
+ end
+ end
+ end
end
diff --git a/spec/features/projects/settings/access_tokens_spec.rb b/spec/features/projects/settings/access_tokens_spec.rb
index 122bf267021..4bc543e080a 100644
--- a/spec/features/projects/settings/access_tokens_spec.rb
+++ b/spec/features/projects/settings/access_tokens_spec.rb
@@ -48,7 +48,7 @@ RSpec.describe 'Project > Settings > Access Tokens', :js do
it 'shows access token creation form and text' do
visit project_settings_access_tokens_path(personal_project)
- expect(page).to have_selector('#new_resource_access_token')
+ expect(page).to have_selector('#js-new-access-token-form')
expect(page).to have_text('Generate project access tokens scoped to this project for your applications that need access to the GitLab API.')
end
end
diff --git a/spec/frontend/access_tokens/components/access_token_table_app_spec.js b/spec/frontend/access_tokens/components/access_token_table_app_spec.js
new file mode 100644
index 00000000000..827bc1a6a4d
--- /dev/null
+++ b/spec/frontend/access_tokens/components/access_token_table_app_spec.js
@@ -0,0 +1,228 @@
+import { GlTable } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import AccessTokenTableApp from '~/access_tokens/components/access_token_table_app.vue';
+import { __, s__, sprintf } from '~/locale';
+import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
+
+describe('~/access_tokens/components/access_token_table_app', () => {
+ let wrapper;
+
+ const accessTokenType = 'personal access token';
+ const accessTokenTypePlural = 'personal access tokens';
+ const initialActiveAccessTokens = [];
+ const noActiveTokensMessage = 'This user has no active personal access tokens.';
+ const showRole = false;
+
+ const defaultActiveAccessTokens = [
+ {
+ name: 'a',
+ scopes: ['api'],
+ created_at: '2021-05-01T00:00:00.000Z',
+ last_used_at: null,
+ expired: false,
+ expires_soon: true,
+ expires_at: null,
+ revoked: false,
+ revoke_path: '/-/profile/personal_access_tokens/1/revoke',
+ role: 'Maintainer',
+ },
+ {
+ name: 'b',
+ scopes: ['api', 'sudo'],
+ created_at: '2022-04-21T00:00:00.000Z',
+ last_used_at: '2022-04-21T00:00:00.000Z',
+ expired: true,
+ expires_soon: false,
+ expires_at: new Date().toISOString(),
+ revoked: false,
+ revoke_path: '/-/profile/personal_access_tokens/2/revoke',
+ role: 'Maintainer',
+ },
+ ];
+
+ const createComponent = (props = {}) => {
+ wrapper = mount(AccessTokenTableApp, {
+ provide: {
+ accessTokenType,
+ accessTokenTypePlural,
+ initialActiveAccessTokens,
+ noActiveTokensMessage,
+ showRole,
+ ...props,
+ },
+ });
+ };
+
+ const triggerSuccess = async (activeAccessTokens = defaultActiveAccessTokens) => {
+ wrapper
+ .findComponent(DomElementListener)
+ .vm.$emit('ajax:success', { detail: [{ active_access_tokens: activeAccessTokens }] });
+ await nextTick();
+ };
+
+ const findTable = () => wrapper.findComponent(GlTable);
+ const findHeaders = () => findTable().findAll('th > :first-child');
+ const findCells = () => findTable().findAll('td');
+
+ afterEach(() => {
+ wrapper?.destroy();
+ });
+
+ it('should render the `GlTable` with default empty message', () => {
+ createComponent();
+
+ const cells = findCells();
+ expect(cells).toHaveLength(1);
+ expect(cells.at(0).text()).toBe(
+ sprintf(__('This user has no active %{accessTokenTypePlural}.'), { accessTokenTypePlural }),
+ );
+ });
+
+ it('should render the `GlTable` with custom empty message', () => {
+ const noTokensMessage = 'This group has no active access tokens.';
+ createComponent({ noActiveTokensMessage: noTokensMessage });
+
+ const cells = findCells();
+ expect(cells).toHaveLength(1);
+ expect(cells.at(0).text()).toBe(noTokensMessage);
+ });
+
+ it('should render an h5 element', () => {
+ createComponent();
+
+ expect(wrapper.find('h5').text()).toBe(
+ sprintf(__('Active %{accessTokenTypePlural} (%{totalAccessTokens})'), {
+ accessTokenTypePlural,
+ totalAccessTokens: initialActiveAccessTokens.length,
+ }),
+ );
+ });
+
+ it('should render the `GlTable` component with default 6 column headers', () => {
+ createComponent();
+
+ const headers = findHeaders();
+ expect(headers).toHaveLength(6);
+ [
+ __('Token name'),
+ __('Scopes'),
+ s__('AccessTokens|Created'),
+ __('Last Used'),
+ __('Expires'),
+ __('Action'),
+ ].forEach((text, index) => {
+ expect(headers.at(index).text()).toBe(text);
+ });
+ });
+
+ it('should render the `GlTable` component with 7 headers', () => {
+ createComponent({ showRole: true });
+
+ const headers = findHeaders();
+ expect(headers).toHaveLength(7);
+ [
+ __('Token name'),
+ __('Scopes'),
+ s__('AccessTokens|Created'),
+ __('Last Used'),
+ __('Expires'),
+ __('Role'),
+ __('Action'),
+ ].forEach((text, index) => {
+ expect(headers.at(index).text()).toBe(text);
+ });
+ });
+
+ 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('updates the table after a success AJAX event', async () => {
+ createComponent({ showRole: true });
+ await triggerSuccess();
+
+ const cells = findCells();
+ expect(cells).toHaveLength(14);
+
+ // First row
+ expect(cells.at(0).text()).toBe('a');
+ expect(cells.at(1).text()).toBe('api');
+ expect(cells.at(2).text()).not.toBe(__('Never'));
+ expect(cells.at(3).text()).toBe(__('Never'));
+ expect(cells.at(4).text()).toBe(__('Never'));
+ expect(cells.at(5).text()).toBe('Maintainer');
+ let anchor = cells.at(6).find('a');
+ expect(anchor.attributes()).toMatchObject({
+ 'aria-label': __('Revoke'),
+ 'data-qa-selector': __('revoke_button'),
+ href: '/-/profile/personal_access_tokens/1/revoke',
+ 'data-confirm': sprintf(
+ __(
+ 'Are you sure you want to revoke this %{accessTokenType}? This action cannot be undone.',
+ ),
+ { accessTokenType },
+ ),
+ });
+
+ expect(anchor.classes()).toContain('btn-danger-secondary');
+
+ // Second row
+ expect(cells.at(7).text()).toBe('b');
+ expect(cells.at(8).text()).toBe('api, sudo');
+ expect(cells.at(9).text()).not.toBe(__('Never'));
+ expect(cells.at(10).text()).not.toBe(__('Never'));
+ expect(cells.at(11).text()).toBe(__('Expired'));
+ expect(cells.at(12).text()).toBe('Maintainer');
+ anchor = cells.at(13).find('a');
+ expect(anchor.attributes('href')).toBe('/-/profile/personal_access_tokens/2/revoke');
+ expect(anchor.classes()).toEqual(['btn', 'btn-danger', 'btn-md', 'gl-button', 'btn-icon']);
+ });
+
+ it('sorts rows alphabetically', async () => {
+ createComponent({ showRole: true });
+ await triggerSuccess();
+
+ const cells = findCells();
+
+ // First and second rows
+ expect(cells.at(0).text()).toBe('a');
+ expect(cells.at(7).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(7).text()).toBe('a');
+ });
+
+ it('sorts rows by date', async () => {
+ createComponent({ showRole: true });
+ await triggerSuccess();
+
+ const cells = findCells();
+
+ // First and second rows
+ expect(cells.at(3).text()).toBe('Never');
+ expect(cells.at(10).text()).not.toBe('Never');
+
+ const headers = findHeaders();
+ await headers.at(3).trigger('click');
+
+ // First and second rows have swapped
+ expect(cells.at(3).text()).not.toBe('Never');
+ expect(cells.at(10).text()).toBe('Never');
+ });
+});
diff --git a/spec/frontend/access_tokens/components/new_access_token_app_spec.js b/spec/frontend/access_tokens/components/new_access_token_app_spec.js
new file mode 100644
index 00000000000..b750a955fb2
--- /dev/null
+++ b/spec/frontend/access_tokens/components/new_access_token_app_spec.js
@@ -0,0 +1,161 @@
+import { GlAlert } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import NewAccessTokenApp from '~/access_tokens/components/new_access_token_app.vue';
+import { createAlert, VARIANT_INFO } from '~/flash';
+import { __, sprintf } from '~/locale';
+import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
+import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue';
+
+jest.mock('~/flash');
+
+describe('~/access_tokens/components/new_access_token_app', () => {
+ let wrapper;
+
+ const accessTokenType = 'personal access token';
+
+ const createComponent = (provide = { accessTokenType }) => {
+ wrapper = shallowMount(NewAccessTokenApp, {
+ provide,
+ });
+ };
+
+ const triggerSuccess = async (newToken = 'new token') => {
+ wrapper
+ .find(DomElementListener)
+ .vm.$emit('ajax:success', { detail: [{ new_token: newToken }] });
+ await nextTick();
+ };
+
+ const triggerError = async (errors = ['1', '2']) => {
+ wrapper.find(DomElementListener).vm.$emit('ajax:error', { detail: [{ errors }] });
+ await nextTick();
+ };
+
+ beforeEach(() => {
+ // NewAccessTokenApp observes a form element
+ setHTMLFixture('<form id="js-new-access-token-form"><input type="submit"/></form>');
+
+ createComponent();
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
+ wrapper.destroy();
+ createAlert.mockClear();
+ });
+
+ it('should render nothing', () => {
+ expect(wrapper.findComponent(InputCopyToggleVisibility).exists()).toBe(false);
+ expect(wrapper.findComponent(GlAlert).exists()).toBe(false);
+ });
+
+ describe('on success', () => {
+ it('should render `InputCopyToggleVisibility` component', async () => {
+ const newToken = '12345';
+ await triggerSuccess(newToken);
+
+ expect(wrapper.findComponent(GlAlert).exists()).toBe(false);
+
+ const InputCopyToggleVisibilityComponent = wrapper.findComponent(InputCopyToggleVisibility);
+ expect(InputCopyToggleVisibilityComponent.props('value')).toBe(newToken);
+ expect(InputCopyToggleVisibilityComponent.props('copyButtonTitle')).toBe(
+ sprintf(__('Copy %{accessTokenType}'), { accessTokenType }),
+ );
+ expect(InputCopyToggleVisibilityComponent.props('initialVisibility')).toBe(true);
+ expect(InputCopyToggleVisibilityComponent.props('inputClass')).toBe(
+ 'qa-created-access-token',
+ );
+ expect(InputCopyToggleVisibilityComponent.props('qaSelector')).toBe(
+ 'created_access_token_field',
+ );
+ expect(InputCopyToggleVisibilityComponent.attributes('label')).toBe(
+ sprintf(__('Your new %{accessTokenType}'), { accessTokenType }),
+ );
+ });
+
+ it('should render an info alert', async () => {
+ await triggerSuccess();
+
+ expect(createAlert).toHaveBeenCalledWith({
+ message: sprintf(__('Your new %{accessTokenType} has been created.'), {
+ accessTokenType,
+ }),
+ variant: VARIANT_INFO,
+ });
+ });
+
+ it('should reset the form', async () => {
+ const resetSpy = jest.spyOn(wrapper.vm.form, 'reset');
+
+ await triggerSuccess();
+
+ expect(resetSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('on error', () => {
+ it('should render an error alert', async () => {
+ await triggerError(['first', 'second']);
+
+ expect(wrapper.findComponent(InputCopyToggleVisibility).exists()).toBe(false);
+
+ let GlAlertComponent = wrapper.findComponent(GlAlert);
+ expect(GlAlertComponent.props('title')).toBe(__('The form contains the following errors:'));
+ expect(GlAlertComponent.props('variant')).toBe('danger');
+ let itemEls = wrapper.findAll('li');
+ expect(itemEls).toHaveLength(2);
+ expect(itemEls.at(0).text()).toBe('first');
+ expect(itemEls.at(1).text()).toBe('second');
+
+ await triggerError(['one']);
+
+ GlAlertComponent = wrapper.findComponent(GlAlert);
+ expect(GlAlertComponent.props('title')).toBe(__('The form contains the following error:'));
+ expect(GlAlertComponent.props('variant')).toBe('danger');
+ itemEls = wrapper.findAll('li');
+ expect(itemEls).toHaveLength(1);
+ });
+
+ it('the error alert should be dismissible', async () => {
+ await triggerError();
+
+ const GlAlertComponent = wrapper.findComponent(GlAlert);
+ expect(GlAlertComponent.exists()).toBe(true);
+
+ GlAlertComponent.vm.$emit('dismiss');
+ await nextTick();
+
+ expect(wrapper.findComponent(GlAlert).exists()).toBe(false);
+ });
+ });
+
+ describe('before error or success', () => {
+ it('should scroll to the container', async () => {
+ const containerEl = wrapper.vm.$refs.container;
+ const scrollIntoViewSpy = jest.spyOn(containerEl, 'scrollIntoView');
+
+ await triggerSuccess();
+
+ expect(scrollIntoViewSpy).toHaveBeenCalledWith(false);
+ expect(scrollIntoViewSpy).toHaveBeenCalledTimes(1);
+
+ await triggerError();
+
+ expect(scrollIntoViewSpy).toHaveBeenCalledWith(false);
+ expect(scrollIntoViewSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('should dismiss the info alert', async () => {
+ const dismissSpy = jest.fn();
+ createAlert.mockReturnValue({ dismiss: dismissSpy });
+
+ await triggerSuccess();
+ await triggerError();
+
+ expect(dismissSpy).toHaveBeenCalled();
+ expect(dismissSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/spec/frontend/access_tokens/index_spec.js b/spec/frontend/access_tokens/index_spec.js
index 1d8ac7cec25..b6119f1d167 100644
--- a/spec/frontend/access_tokens/index_spec.js
+++ b/spec/frontend/access_tokens/index_spec.js
@@ -1,27 +1,118 @@
+/* eslint-disable vue/require-prop-types */
+/* eslint-disable vue/one-component-per-file */
import { createWrapper } from '@vue/test-utils';
import Vue from 'vue';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
-import { initExpiresAtField, initProjectsField } from '~/access_tokens';
+import {
+ initAccessTokenTableApp,
+ initExpiresAtField,
+ initNewAccessTokenApp,
+ initProjectsField,
+ initTokensApp,
+} from '~/access_tokens';
+import * as AccessTokenTableApp from '~/access_tokens/components/access_token_table_app.vue';
import * as ExpiresAtField from '~/access_tokens/components/expires_at_field.vue';
+import * as NewAccessTokenApp from '~/access_tokens/components/new_access_token_app.vue';
import * as ProjectsField from '~/access_tokens/components/projects_field.vue';
+import * as TokensApp from '~/access_tokens/components/tokens_app.vue';
+import { FEED_TOKEN, INCOMING_EMAIL_TOKEN, STATIC_OBJECT_TOKEN } from '~/access_tokens/constants';
+import { __, sprintf } from '~/locale';
describe('access tokens', () => {
- const FakeComponent = Vue.component('FakeComponent', {
- props: {
- inputAttrs: {
- type: Object,
- required: true,
- },
- },
- render: () => null,
- });
+ let wrapper;
- beforeEach(() => {
- window.gon = { features: { personalAccessTokensScopedToProjects: true } };
+ afterEach(() => {
+ wrapper?.destroy();
+ resetHTMLFixture();
});
- afterEach(() => {
- document.body.innerHTML = '';
+ describe('initAccessTokenTableApp', () => {
+ const accessTokenType = 'personal access token';
+ const accessTokenTypePlural = 'personal access tokens';
+ const initialActiveAccessTokens = [{ id: '1' }];
+
+ const FakeAccessTokenTableApp = Vue.component('FakeComponent', {
+ inject: [
+ 'accessTokenType',
+ 'accessTokenTypePlural',
+ 'initialActiveAccessTokens',
+ 'noActiveTokensMessage',
+ 'showRole',
+ ],
+ props: [
+ 'accessTokenType',
+ 'accessTokenTypePlural',
+ 'initialActiveAccessTokens',
+ 'noActiveTokensMessage',
+ 'showRole',
+ ],
+ render: () => null,
+ });
+ AccessTokenTableApp.default = FakeAccessTokenTableApp;
+
+ it('mounts the component and provides required values', () => {
+ setHTMLFixture(
+ `<div id="js-access-token-table-app"
+ data-access-token-type="${accessTokenType}"
+ data-access-token-type-plural="${accessTokenTypePlural}"
+ data-initial-active-access-tokens=${JSON.stringify(initialActiveAccessTokens)}
+ >
+ </div>`,
+ );
+
+ const vueInstance = initAccessTokenTableApp();
+
+ wrapper = createWrapper(vueInstance);
+ const component = wrapper.findComponent(FakeAccessTokenTableApp);
+
+ expect(component.exists()).toBe(true);
+
+ expect(component.props()).toMatchObject({
+ // Required value
+ accessTokenType,
+ accessTokenTypePlural,
+ initialActiveAccessTokens,
+
+ // Default values
+ noActiveTokensMessage: sprintf(__('This user has no active %{accessTokenTypePlural}.'), {
+ accessTokenTypePlural,
+ }),
+ showRole: false,
+ });
+ });
+
+ it('mounts the component and provides all values', () => {
+ const noActiveTokensMessage = 'This group has no active access tokens.';
+ setHTMLFixture(
+ `<div id="js-access-token-table-app"
+ data-access-token-type="${accessTokenType}"
+ data-access-token-type-plural="${accessTokenTypePlural}"
+ data-initial-active-access-tokens=${JSON.stringify(initialActiveAccessTokens)}
+ data-no-active-tokens-message="${noActiveTokensMessage}"
+ data-show-role
+ >
+ </div>`,
+ );
+
+ const vueInstance = initAccessTokenTableApp();
+
+ wrapper = createWrapper(vueInstance);
+ const component = wrapper.findComponent(FakeAccessTokenTableApp);
+
+ expect(component.exists()).toBe(true);
+ expect(component.props()).toMatchObject({
+ accessTokenType,
+ accessTokenTypePlural,
+ initialActiveAccessTokens,
+ noActiveTokensMessage,
+ showRole: true,
+ });
+ });
+
+ it('returns `null`', () => {
+ expect(initNewAccessTokenApp()).toBe(null);
+ });
});
describe.each`
@@ -30,33 +121,42 @@ describe('access tokens', () => {
${initProjectsField} | ${'js-access-tokens-projects'} | ${'projects'} | ${ProjectsField}
`('$initFunction', ({ initFunction, mountSelector, fieldName, expectedComponent }) => {
describe('when mount element exists', () => {
+ const FakeComponent = Vue.component('FakeComponent', {
+ props: ['inputAttrs'],
+ render: () => null,
+ });
+
const nameAttribute = `access_tokens[${fieldName}]`;
const idAttribute = `access_tokens_${fieldName}`;
beforeEach(() => {
- const mountEl = document.createElement('div');
- mountEl.classList.add(mountSelector);
-
- const input = document.createElement('input');
- input.setAttribute('name', nameAttribute);
- input.setAttribute('data-js-name', fieldName);
- input.setAttribute('id', idAttribute);
- input.setAttribute('placeholder', 'Foo bar');
- input.setAttribute('value', '1,2');
+ window.gon = { features: { personalAccessTokensScopedToProjects: true } };
- mountEl.appendChild(input);
-
- document.body.appendChild(mountEl);
+ setHTMLFixture(
+ `<div class="${mountSelector}">
+ <input
+ name="${nameAttribute}"
+ data-js-name="${fieldName}"
+ id="${idAttribute}"
+ placeholder="Foo bar"
+ value="1,2"
+ />
+ </div>`,
+ );
// Mock component so we don't have to deal with mocking Apollo
// eslint-disable-next-line no-param-reassign
expectedComponent.default = FakeComponent;
});
+ afterEach(() => {
+ delete window.gon;
+ });
+
it('mounts component and sets `inputAttrs` prop', async () => {
const vueInstance = await initFunction();
- const wrapper = createWrapper(vueInstance);
+ wrapper = createWrapper(vueInstance);
const component = wrapper.findComponent(FakeComponent);
expect(component.exists()).toBe(true);
@@ -75,4 +175,64 @@ describe('access tokens', () => {
});
});
});
+
+ describe('initNewAccessTokenApp', () => {
+ it('mounts the component and sets `accessTokenType` prop', () => {
+ const accessTokenType = 'personal access token';
+ setHTMLFixture(
+ `<div id="js-new-access-token-app" data-access-token-type="${accessTokenType}"></div>`,
+ );
+
+ const FakeNewAccessTokenApp = Vue.component('FakeComponent', {
+ inject: ['accessTokenType'],
+ props: ['accessTokenType'],
+ render: () => null,
+ });
+ NewAccessTokenApp.default = FakeNewAccessTokenApp;
+
+ const vueInstance = initNewAccessTokenApp();
+
+ wrapper = createWrapper(vueInstance);
+ const component = wrapper.findComponent(FakeNewAccessTokenApp);
+
+ expect(component.exists()).toBe(true);
+ expect(component.props('accessTokenType')).toEqual(accessTokenType);
+ });
+
+ it('returns `null`', () => {
+ expect(initNewAccessTokenApp()).toBe(null);
+ });
+ });
+
+ describe('initTokensApp', () => {
+ it('mounts the component and provides`tokenTypes` ', () => {
+ const tokensData = {
+ [FEED_TOKEN]: FEED_TOKEN,
+ [INCOMING_EMAIL_TOKEN]: INCOMING_EMAIL_TOKEN,
+ [STATIC_OBJECT_TOKEN]: STATIC_OBJECT_TOKEN,
+ };
+ setHTMLFixture(
+ `<div id="js-tokens-app" data-tokens-data=${JSON.stringify(tokensData)}></div>`,
+ );
+
+ const FakeTokensApp = Vue.component('FakeComponent', {
+ inject: ['tokenTypes'],
+ props: ['tokenTypes'],
+ render: () => null,
+ });
+ TokensApp.default = FakeTokensApp;
+
+ const vueInstance = initTokensApp();
+
+ wrapper = createWrapper(vueInstance);
+ const component = wrapper.findComponent(FakeTokensApp);
+
+ expect(component.exists()).toBe(true);
+ expect(component.props('tokenTypes')).toEqual(tokensData);
+ });
+
+ it('returns `null`', () => {
+ expect(initNewAccessTokenApp()).toBe(null);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js
index e636f58d868..6d176e1bf6a 100644
--- a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js
+++ b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js
@@ -236,4 +236,32 @@ describe('InputCopyToggleVisibility', () => {
expect(wrapper.findByText(description).exists()).toBe(true);
});
+
+ it('passes `inputClass` prop to `GlFormInputGroup`', () => {
+ createComponent();
+ expect(findFormInputGroup().props('inputClass')).toBe('gl-font-monospace! gl-cursor-default!');
+ wrapper.destroy();
+
+ createComponent({
+ propsData: {
+ inputClass: 'Foo bar',
+ },
+ });
+ expect(findFormInputGroup().props('inputClass')).toBe(
+ 'gl-font-monospace! gl-cursor-default! Foo bar',
+ );
+ });
+
+ it('passes `qaSelector` prop as an `data-qa-selector` attribute to `GlFormInputGroup`', () => {
+ createComponent();
+ expect(findFormInputGroup().attributes('data-qa-selector')).toBeUndefined();
+ wrapper.destroy();
+
+ createComponent({
+ propsData: {
+ qaSelector: 'Foo bar',
+ },
+ });
+ expect(findFormInputGroup().attributes('data-qa-selector')).toBe('Foo bar');
+ });
});
diff --git a/spec/graphql/resolvers/design_management/versions_resolver_spec.rb b/spec/graphql/resolvers/design_management/versions_resolver_spec.rb
index 8eab0222cf6..67e23c05a67 100644
--- a/spec/graphql/resolvers/design_management/versions_resolver_spec.rb
+++ b/spec/graphql/resolvers/design_management/versions_resolver_spec.rb
@@ -90,6 +90,8 @@ RSpec.describe Resolvers::DesignManagement::VersionsResolver do
end
context 'and they do not match' do
+ subject(:result) { resolve_versions(object) }
+
let(:params) do
{
earlier_or_equal_to_sha: first_version.sha,
diff --git a/spec/lib/api/entities/personal_access_token_with_details_spec.rb b/spec/lib/api/entities/personal_access_token_with_details_spec.rb
new file mode 100644
index 00000000000..a53d6febba1
--- /dev/null
+++ b/spec/lib/api/entities/personal_access_token_with_details_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Entities::PersonalAccessTokenWithDetails do
+ describe '#as_json' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:token) { create(:personal_access_token, user: user, expires_at: nil) }
+
+ let(:entity) { described_class.new(token) }
+
+ it 'returns token data' do
+ expect(entity.as_json).to eq({
+ id: token.id,
+ name: token.name,
+ revoked: false,
+ created_at: token.created_at,
+ scopes: ['api'],
+ user_id: user.id,
+ last_used_at: nil,
+ active: true,
+ expires_at: nil,
+ expired: false,
+ expires_soon: false,
+ revoke_path: Gitlab::Routing.url_helpers.revoke_profile_personal_access_token_path(token)
+ })
+ end
+ end
+end
diff --git a/spec/lib/gitlab/graphql/markdown_field_spec.rb b/spec/lib/gitlab/graphql/markdown_field_spec.rb
index ed3f19d8cf2..84494d3dd68 100644
--- a/spec/lib/gitlab/graphql/markdown_field_spec.rb
+++ b/spec/lib/gitlab/graphql/markdown_field_spec.rb
@@ -3,6 +3,7 @@ require 'spec_helper'
RSpec.describe Gitlab::Graphql::MarkdownField do
include Gitlab::Routing
+ include GraphqlHelpers
describe '.markdown_field' do
it 'creates the field with some default attributes' do
@@ -33,8 +34,7 @@ RSpec.describe Gitlab::Graphql::MarkdownField do
context 'resolving markdown' do
let_it_be(:note) { build(:note, note: '# Markdown!') }
let_it_be(:expected_markdown) { '<h1 data-sourcepos="1:1-1:11" dir="auto">Markdown!</h1>' }
- let_it_be(:query_type) { GraphQL::ObjectType.new }
- let_it_be(:schema) { GraphQL::Schema.define(query: query_type, mutation: nil)}
+ let_it_be(:schema) { empty_schema }
let_it_be(:query) { GraphQL::Query.new(schema, document: nil, context: {}, variables: {}) }
let_it_be(:context) { GraphQL::Query::Context.new(query: query, values: {}, object: nil) }
diff --git a/spec/support/helpers/countries_controller_test_helper.rb b/spec/support/helpers/countries_controller_test_helper.rb
new file mode 100644
index 00000000000..5d36a29bba7
--- /dev/null
+++ b/spec/support/helpers/countries_controller_test_helper.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module CountriesControllerTestHelper
+ def world_deny_list
+ ::World::DENYLIST + ::World::JH_MARKET
+ end
+end
+
+CountriesControllerTestHelper.prepend_mod
diff --git a/spec/support/helpers/gitaly_setup.rb b/spec/support/helpers/gitaly_setup.rb
index 264281ef94a..35fa69481a9 100644
--- a/spec/support/helpers/gitaly_setup.rb
+++ b/spec/support/helpers/gitaly_setup.rb
@@ -369,7 +369,7 @@ module GitalySetup
message += "- The `gitaly` binary does not exist: #{gitaly_binary}\n" unless File.exist?(gitaly_binary)
message += "- The `praefect` binary does not exist: #{praefect_binary}\n" unless File.exist?(praefect_binary)
- message += "- The `git` binary does not exist: #{git_binary}\n" unless File.exist?(git_binary)
+ message += "- No `git` binaries exist\n" if git_binaries.empty?
message += "\nCheck log/gitaly-test.log & log/praefect-test.log for errors.\n"
@@ -381,8 +381,8 @@ module GitalySetup
message
end
- def git_binary
- File.join(tmp_tests_gitaly_dir, "_build", "bin", "gitaly-git")
+ def git_binaries
+ Dir.glob(File.join(tmp_tests_gitaly_dir, "_build", "bin", "gitaly-git-v*"))
end
def gitaly_binary
@@ -392,8 +392,4 @@ module GitalySetup
def praefect_binary
File.join(tmp_tests_gitaly_dir, "_build", "bin", "praefect")
end
-
- def git_binary_exists?
- File.exist?(git_binary)
- end
end
diff --git a/spec/support/shared_examples/features/access_tokens_shared_examples.rb b/spec/support/shared_examples/features/access_tokens_shared_examples.rb
index 215d9d3e5a8..5fa96443608 100644
--- a/spec/support/shared_examples/features/access_tokens_shared_examples.rb
+++ b/spec/support/shared_examples/features/access_tokens_shared_examples.rb
@@ -51,7 +51,7 @@ RSpec.shared_examples 'resource access tokens creation disallowed' do |error_mes
it 'does not show access token creation form' do
visit resource_settings_access_tokens_path
- expect(page).not_to have_selector('#new_resource_access_token')
+ expect(page).not_to have_selector('#js-new-access-token-form')
end
it 'shows access token creation disabled text' do