Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-06-20 14:10:13 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-06-20 14:10:13 +0300
commit0ea3fcec397b69815975647f5e2aa5fe944a8486 (patch)
tree7979381b89d26011bcf9bdc989a40fcc2f1ed4ff /app/assets/javascripts/access_tokens
parent72123183a20411a36d607d70b12d57c484394c8e (diff)
Add latest changes from gitlab-org/gitlab@15-1-stable-eev15.1.0-rc42
Diffstat (limited to 'app/assets/javascripts/access_tokens')
-rw-r--r--app/assets/javascripts/access_tokens/components/access_token_table_app.vue168
-rw-r--r--app/assets/javascripts/access_tokens/components/constants.js61
-rw-r--r--app/assets/javascripts/access_tokens/components/expires_at_field.vue10
-rw-r--r--app/assets/javascripts/access_tokens/components/new_access_token_app.vue130
-rw-r--r--app/assets/javascripts/access_tokens/index.js71
5 files changed, 433 insertions, 7 deletions
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..944a2ef7f64
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue
@@ -0,0 +1,168 @@
+<script>
+import { GlButton, GlIcon, GlLink, GlPagination, GlTable, GlTooltipDirective } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { __, 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';
+import { EVENT_SUCCESS, FIELDS, FORM_SELECTOR, INITIAL_PAGE, PAGE_SIZE } from './constants';
+
+export default {
+ EVENT_SUCCESS,
+ FORM_SELECTOR,
+ PAGE_SIZE,
+ name: 'AccessTokenTableApp',
+ components: {
+ DomElementListener,
+ GlButton,
+ 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'),
+ 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'),
+ },
+ inject: [
+ 'accessTokenType',
+ 'accessTokenTypePlural',
+ 'initialActiveAccessTokens',
+ 'noActiveTokensMessage',
+ 'showRole',
+ ],
+ data() {
+ return {
+ activeAccessTokens: this.initialActiveAccessTokens,
+ currentPage: INITIAL_PAGE,
+ };
+ },
+ computed: {
+ filteredFields() {
+ return this.showRole ? FIELDS : 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,
+ });
+ },
+ showPagination() {
+ return this.activeAccessTokens.length > PAGE_SIZE;
+ },
+ },
+ methods: {
+ onSuccess(event) {
+ const [{ active_access_tokens: activeAccessTokens }] = event.detail;
+ this.activeAccessTokens = convertObjectPropsToCamelCase(activeAccessTokens, { deep: true });
+ this.currentPage = INITIAL_PAGE;
+ },
+ 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.EVENT_SUCCESS]="onSuccess">
+ <div>
+ <hr />
+ <h5>{{ header }}</h5>
+
+ <gl-table
+ data-testid="active-tokens"
+ :empty-text="noActiveTokensMessage"
+ :fields="filteredFields"
+ :items="activeAccessTokens"
+ :per-page="$options.PAGE_SIZE"
+ :current-page="currentPage"
+ :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>
+ <gl-pagination
+ v-if="showPagination"
+ v-model="currentPage"
+ :per-page="$options.PAGE_SIZE"
+ :total-items="activeAccessTokens.length"
+ :prev-text="__('Prev')"
+ :next-text="__('Next')"
+ :label-next-page="__('Go to next page')"
+ :label-prev-page="__('Go to previous page')"
+ align="center"
+ />
+ </div>
+ </dom-element-listener>
+</template>
diff --git a/app/assets/javascripts/access_tokens/components/constants.js b/app/assets/javascripts/access_tokens/components/constants.js
new file mode 100644
index 00000000000..84e50bc099f
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/components/constants.js
@@ -0,0 +1,61 @@
+import { __, s__ } from '~/locale';
+
+export const EVENT_ERROR = 'ajax:error';
+export const EVENT_SUCCESS = 'ajax:success';
+export const FORM_SELECTOR = '#js-new-access-token-form';
+
+export const INITIAL_PAGE = 1;
+export const PAGE_SIZE = 100;
+
+export const 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`,
+ },
+];
diff --git a/app/assets/javascripts/access_tokens/components/expires_at_field.vue b/app/assets/javascripts/access_tokens/components/expires_at_field.vue
index 561b2617c5f..147de529eea 100644
--- a/app/assets/javascripts/access_tokens/components/expires_at_field.vue
+++ b/app/assets/javascripts/access_tokens/components/expires_at_field.vue
@@ -21,17 +21,17 @@ export default {
required: false,
default: () => ({}),
},
+ minDate: {
+ type: Date,
+ required: false,
+ default: () => new Date(),
+ },
maxDate: {
type: Date,
required: false,
default: () => null,
},
},
- data() {
- return {
- minDate: new Date(),
- };
- },
};
</script>
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..904052688f3
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
@@ -0,0 +1,130 @@
+<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';
+import { EVENT_ERROR, EVENT_SUCCESS, FORM_SELECTOR } from './constants';
+
+export default {
+ EVENT_ERROR,
+ EVENT_SUCCESS,
+ FORM_SELECTOR,
+ 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}'),
+ },
+ tokenInputId: 'new-access-token',
+ 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 });
+ },
+ formInputGroupProps() {
+ return {
+ id: this.$options.tokenInputId,
+ class: 'qa-created-access-token',
+ 'data-qa-selector': 'created_access_token_field',
+ name: this.$options.tokenInputId,
+ };
+ },
+ label() {
+ return sprintf(this.$options.i18n.label, { accessTokenType: this.accessTokenType });
+ },
+ },
+ mounted() {
+ /** @type {HTMLFormElement} */
+ this.form = document.querySelector(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.EVENT_ERROR]="onError"
+ @[$options.EVENT_SUCCESS]="onSuccess"
+ >
+ <div ref="container">
+ <template v-if="newToken">
+ <!--
+ After issue https://gitlab.com/gitlab-org/gitlab/-/issues/360921 is
+ closed remove the `initial-visibility`.
+ -->
+ <input-copy-toggle-visibility
+ :copy-button-title="copyButtonTitle"
+ :label="label"
+ :label-for="$options.tokenInputId"
+ :value="newToken"
+ initial-visibility
+ :form-input-group-props="formInputGroupProps"
+ >
+ <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 c59bd445539..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');
@@ -17,7 +62,7 @@ export const initExpiresAtField = () => {
}
const { expiresAt: inputAttrs } = parseRailsFormFields(el);
- const { maxDate } = el.dataset;
+ const { minDate, maxDate } = el.dataset;
return new Vue({
el,
@@ -25,6 +70,7 @@ export const initExpiresAtField = () => {
return h(ExpiresAtField, {
props: {
inputAttrs,
+ minDate: minDate ? new Date(minDate) : undefined,
maxDate: maxDate ? new Date(maxDate) : undefined,
},
});
@@ -32,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');