diff options
Diffstat (limited to 'app/assets/javascripts/access_tokens/components')
4 files changed, 364 insertions, 5 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> |