Commit e06cb80e authored by Luke Bennett's avatar Luke Bennett Committed by Luke Bennett

Simplify admin instance licenses page

Displays all licenses on the admin license
page.
Allows for downloading and delete previous
licenses.
parent 9b58a431
......@@ -718,3 +718,8 @@ $compare-branches-sticky-header-height: 68px;
- Issue: https://gitlab.com/gitlab-org/design.gitlab.com/issues/242
*/
$enable-validation-icons: false;
/*
Licenses
*/
$license-header-cell-width: 150px;
import LicenseCard from './license_card.vue';
import SkeletonLicenseCard from './skeleton_license_card.vue';
export { LicenseCard, SkeletonLicenseCard };
<script>
import { mapState, mapActions } from 'vuex';
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
import LicenseCardBody from './license_card_body.vue';
export default {
name: 'LicenseCard',
components: {
LicenseCardBody,
GlDropdown,
GlDropdownItem,
},
props: {
license: {
type: Object,
required: false,
default() {
return { licensee: {} };
},
},
isCurrentLicense: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
...mapState(['currentActiveUserCount', 'deleteQueue', 'downloadLicensePath']),
isRemoving() {
return this.deleteQueue.includes(this.license.id);
},
},
methods: {
...mapActions(['fetchDeleteLicense']),
capitalizeFirstCharacter,
confirmDeleteLicense(...args) {
window.confirm(__('Are you sure you want to permanently delete this license?')); // eslint-disable-line no-alert
this.fetchDeleteLicense(...args);
},
},
};
</script>
<template>
<div class="card license-card mb-5">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h4>
{{
sprintf(__('GitLab Enterprise Edition %{plan}'), {
plan: capitalizeFirstCharacter(license.plan),
})
}}
</h4>
<gl-dropdown right class="js-manage-license" :text="__('Manage')" :disabled="isRemoving">
<gl-dropdown-item
v-if="isCurrentLicense"
class="js-download-license"
:href="downloadLicensePath"
>
{{ __('Download license') }}
</gl-dropdown-item>
<gl-dropdown-item
class="js-delete-license text-danger"
@click="confirmDeleteLicense(license)"
>
{{ __('Delete license') }}
</gl-dropdown-item>
</gl-dropdown>
</div>
</div>
<license-card-body
:license="license"
:is-removing="isRemoving"
:current-active-user-count="currentActiveUserCount"
/>
</div>
</template>
<script>
import { GlLink } from '@gitlab/ui';
import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import { Cell, HeaderCell, InfoCell, DateCell } from '../cells';
export default {
name: 'LicenseCardBody',
components: {
Icon,
Cell,
HeaderCell,
InfoCell,
DateCell,
GlLink,
},
props: {
license: {
type: Object,
required: false,
default() {
return {
licensee: {},
};
},
},
isRemoving: {
type: Boolean,
required: false,
default: false,
},
currentActiveUserCount: {
type: Number,
required: true,
},
},
data() {
return {
info: {
currentActiveUserCount: __(
"Users with a Guest role or those who don't belong to any projects or groups don't count towards seats in use.",
),
historicalMax: __(`This is the maximum number of users that have existed at the same time since the license started.
This is the minimum number of seats you will need to buy when you renew your license.`),
overage: __(`GitLab allows you to continue using your license even if you exceed the number of seats you purchased.
You will be required to pay for these seats when you renew your license.`),
},
};
},
methods: {
licenseeValue(key) {
return this.license.licensee[key] || __('Unknown');
},
},
};
</script>
<template>
<div class="card-body license-card-body p-0">
<div
v-if="isRemoving"
class="p-5 d-flex justify-content-center align-items-center license-card-loading"
>
<icon name="spinner" /><span class="ml-2">{{ __('Removing license…') }}</span>
</div>
<div v-else class="license-table js-license-table">
<div class="license-row d-flex">
<header-cell :title="__('Usage')" icon="monitor" />
<cell :title="__('Seats in license')" :value="license.userLimit || __('Unlimited')" />
<info-cell
:title="__('Seats currently in use')"
:value="currentActiveUserCount"
:popover-content="info.currentActiveUserCount"
/>
<info-cell
:title="__('Max seats used')"
:value="license.historicalMax"
:popover-content="info.historicalMax"
/>
<info-cell
:title="__('Users outside of license')"
:value="license.overage"
:popover-content="info.overage"
/>
</div>
<div class="license-row d-flex">
<header-cell :title="__('Validity')" icon="calendar" />
<date-cell :title="__('Start date')" :value="license.startsAt" />
<date-cell :title="__('End date')" :value="license.expiresAt" :is-expirable="true" />
<date-cell :title="__('Uploaded on')" :value="license.createdAt" />
</div>
<div class="license-row d-flex">
<header-cell :title="__('Registration')" icon="user" />
<cell :title="__('Licensed to')" :value="licenseeValue('Name')" />
<cell :title="__('Email address')" :value="licenseeValue('Email')" />
<cell :title="__('Company')" :value="licenseeValue('Company')" />
</div>
</div>
</div>
</template>
<script>
import { GlSkeletonLoading } from '@gitlab/ui';
import { Cell, SkeletonCell, SkeletonHeaderCell } from '../cells';
export default {
name: 'SkeletonLicenseCard',
components: {
GlSkeletonLoading,
Cell,
SkeletonCell,
SkeletonHeaderCell,
},
};
</script>
<template>
<div class="card license-card skeleton-license-card">
<div class="card-header d-flex justify-content-between align-items-center py-3">
<gl-skeleton-loading class="w-75 skeleton-bar" :lines="1" />
</div>
<div class="card-body p-0">
<div class="license-table">
<div class="license-row d-flex">
<skeleton-header-cell />
<skeleton-cell />
<skeleton-cell />
<skeleton-cell />
<skeleton-cell />
</div>
<div class="license-row d-flex">
<skeleton-header-cell />
<skeleton-cell />
<skeleton-cell />
<skeleton-cell />
</div>
<div class="license-row d-flex">
<skeleton-header-cell />
<skeleton-cell />
<skeleton-cell />
<skeleton-cell />
</div>
</div>
</div>
</div>
</template>
<script>
import _ from 'underscore';
import { GlSkeletonLoading } from '@gitlab/ui';
export default {
name: 'Cell',
components: {
GlSkeletonLoading,
},
props: {
title: {
type: String,
required: false,
default: null,
},
value: {
type: [String, Number],
required: false,
default: null,
},
isFlexible: {
type: Boolean,
required: false,
default: true,
},
},
computed: {
valueClass() {
return { number: _.isNumber(this.value) };
},
flexClass() {
return { 'flex-grow-1': this.isFlexible };
},
},
};
</script>
<template>
<div class="license-cell p-3 text-nowrap flex-shrink-0" :class="flexClass">
<span class="title d-flex align-items-center justify-content-start">
<slot name="title">
<span>{{ title }}</span>
</slot>
</span>
<div class="value mt-2" :class="valueClass">
<slot name="value">
<span>{{ value }}</span>
</slot>
</div>
</div>
</template>
<script>
import { dateInWords } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
import Cell from './cell.vue';
export default {
name: 'DateCell',
components: {
Cell,
},
props: {
title: {
type: String,
required: false,
default: null,
},
value: {
type: [String, Date],
required: false,
default: null,
},
dateNow: {
type: Date,
required: false,
default() {
return new Date();
},
},
isExpirable: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
dateInWordsValue() {
return dateInWords(this.dateValue);
},
dateValue() {
return new Date(this.value);
},
isExpired() {
return this.isExpirable && this.dateValue < this.dateNow;
},
valueClass() {
return { 'text-danger': this.isExpired };
},
fallbackValue() {
return this.isExpirable ? this.dateInWords || __('Never') : this.dateInWords;
},
},
};
</script>
<template>
<cell :title="title" :value="fallbackValue">
<div v-if="value" slot="value" :class="valueClass">
{{ dateInWordsValue }}
<span v-if="isExpired"> - {{ __('Expired') }} </span>
</div>
</cell>
</template>
<script>
import Icon from '~/vue_shared/components/icon.vue';
import Cell from './cell.vue';
export default {
name: 'HeaderCell',
components: {
Icon,
Cell,
},
props: {
title: {
type: String,
required: true,
},
icon: {
type: String,
required: true,
},
},
};
</script>
<template>
<cell class="license-header-cell" :is-flexible="false">
<template slot="title">
<icon class="icon" :name="icon" />
<span class="ml-2 font-weight-bold">{{ title }}</span>
</template>
</cell>
</template>
import Cell from './cell.vue';
import HeaderCell from './header_cell.vue';
import InfoCell from './info_cell.vue';
import DateCell from './date_cell.vue';
import SkeletonCell from './skeleton_cell.vue';
import SkeletonHeaderCell from './skeleton_header_cell.vue';
export { Cell, HeaderCell, InfoCell, DateCell, SkeletonCell, SkeletonHeaderCell };
<script>
import { GlPopover } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import Cell from './cell.vue';
export default {
name: 'InfoCell',
components: {
Icon,
GlPopover,
Cell,
},
props: {
title: {
type: String,
required: true,
default: null,
},
value: {
type: [Number, String],
required: false,
default: null,
},
popoverContent: {
type: String,
required: false,
default: null,
},
},
data() {
return {
popoverTarget: null,
};
},
mounted() {
this.popoverTarget = this.$refs.popoverTarget;
},
};
</script>
<template>
<cell class="license-info-cell" :value="value">
<template slot="title">
<span class="mr-2 text">{{ title }}</span>
<button ref="popoverTarget" type="button" class="btn-link information-target">
<icon name="information" css-classes="icon d-block" />
</button>
<gl-popover
placement="bottom"
:target="popoverTarget"
:content="popoverContent"
triggers="hover"
/>
</template>
</cell>
</template>
<script>
import { GlSkeletonLoading } from '@gitlab/ui';
import Cell from './cell.vue';
export default {
name: 'SkeletonCell',
components: {
Cell,
GlSkeletonLoading,
},
};
</script>
<template>
<cell>
<gl-skeleton-loading slot="title" class="w-75 skeleton-bar" :lines="1" />
<gl-skeleton-loading slot="value" class="w-50 skeleton-bar" :lines="1" />
</cell>
</template>
<script>
import { GlSkeletonLoading } from '@gitlab/ui';
import Cell from './cell.vue';
export default {
name: 'SkeletonHeaderCell',
components: {
Cell,
GlSkeletonLoading,
},
};
</script>
<template>
<cell class="license-header-cell" :is-flexible="false">
<gl-skeleton-loading slot="title" class="w-75 skeleton-bar" :lines="1" />
</cell>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
import { GlButton } from '@gitlab/ui';
import { LicenseCard, SkeletonLicenseCard } from './cards';
export default {
name: 'LicenseCardsList',
components: {
LicenseCard,
SkeletonLicenseCard,
GlButton,
},
computed: {
...mapState(['licenses', 'isLoadingLicenses', 'newLicensePath']),
...mapGetters(['hasLicenses']),
},
};
</script>
<template>
<div>
<div class="d-flex justify-content-between align-items-center">
<h4>{{ __('Instance license') }}</h4>
<gl-button class="my-3 js-add-license" variant="success" :href="newLicensePath">
{{ __('Add license') }}
</gl-button>
</div>
<ul class="license-list list-unstyled">
<li v-if="isLoadingLicenses">
<skeleton-license-card />
</li>
<li v-for="(license, index) in licenses" v-else-if="hasLicenses" :key="license.id">
<license-card :license="license" :is-current-license="index === 0" />
</li>
<li v-else>
<strong>
{{ __('No licenses found.') }}
</strong>
</li>
</ul>
</div>
</template>
import Vue from 'vue';
import { mapActions } from 'vuex';
import store from './store';
import LicenseCardsList from './components/license_cards_list.vue';
export default function mountInstanceLicenseApp(mountElement) {
if (!mountElement) return undefined;
const {
currentActiveUserCount,
licensesPath,
deleteLicensePath,
newLicensePath,
downloadLicensePath,
} = mountElement.dataset;
return new Vue({
el: mountElement,
store,
created() {
this.setInitialData({
licensesPath,
deleteLicensePath,
newLicensePath,
downloadLicensePath,
currentActiveUserCount: parseInt(currentActiveUserCount, 10),
});
this.fetchLicenses();
},
methods: {
...mapActions(['setInitialData', 'fetchLicenses']),
},
render(createElement) {
return createElement(LicenseCardsList);
},
});
}
import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import * as types from './mutation_types';
import flashMessage from './flash_message';
export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
export const requestLicenses = ({ commit }) => commit(types.REQUEST_LICENSES);
export const receiveLicensesSuccess = ({ commit }, licenses) =>
commit(types.RECEIVE_LICENSES_SUCCESS, licenses);
export const receiveLicensesError = ({ commit }) => commit(types.RECEIVE_LICENSES_ERROR);
export const fetchLicenses = ({ state, dispatch }) => {
dispatch('requestLicenses');
return axios
.get(state.licensesPath)
.then(({ data }) =>
dispatch('receiveLicensesSuccess', convertObjectPropsToCamelCase(data, { deep: true })),
)
.catch(({ response }) => {
flashMessage('fetchLicenses', response.status);
dispatch('receiveLicensesError');
});
};
export const requestDeleteLicense = ({ commit }, license) =>
commit(types.REQUEST_DELETE_LICENSE, license);
export const receiveDeleteLicenseSuccess = ({ commit }, license) =>
commit(types.RECEIVE_DELETE_LICENSE_SUCCESS, license);
export const receiveDeleteLicenseError = ({ commit }, license) =>
commit(types.RECEIVE_DELETE_LICENSE_ERROR, license);
export const fetchDeleteLicense = ({ state, dispatch }, { id }) => {
dispatch('requestDeleteLicense', { id });
return axios
.delete(state.deleteLicensePath.replace(':id', id))
.then(() => dispatch('receiveDeleteLicenseSuccess', { id }))
.catch(({ response }) => {
flashMessage('fetchDeleteLicense', response.status);
dispatch('receiveDeleteLicenseError', { id });
});
};
export default () => {};
import createFlash from '~/flash';
import { __ } from '~/locale';
const FLASH_MESSAGES = {
fetchLicenses: {
403: __('Fetching licenses failed. You are not permitted to perform this action.'),
404: __('Fetching licenses failed. The request endpoint was not found.'),
default: __('Fetching licenses failed.'),
},
fetchDeleteLicense: {
403: __('Deleting the license failed. You are not permitted to perform this action.'),
404: __('Deleting the license failed. The license was not found.'),
default: __('Deleting the license failed.'),
},
};
export default function flashMessage(action, status) {
const messages = FLASH_MESSAGES[action];
createFlash(messages[status] || messages.default);
}
export const hasLicenses = state => state.licenses.length > 0;
export default () => {};
import Vue from 'vue';
import Vuex from 'vuex';
import createState from './state';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
Vue.use(Vuex);
export const createStore = () =>
new Vuex.Store({
state: createState(),
actions,
getters,
mutations,
});
export default createStore();
export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
export const REQUEST_LICENSES = 'REQUEST_LICENSES';
export const RECEIVE_LICENSES_SUCCESS = 'RECEIVE_LICENSES_SUCCESS';
export const RECEIVE_LICENSES_ERROR = 'RECEIVE_LICENSES_ERROR';
export const REQUEST_DELETE_LICENSE = 'REQUEST_DELETE_LICENSE';
export const RECEIVE_DELETE_LICENSE_SUCCESS = 'RECEIVE_DELETE_LICENSE_SUCCESS';
export const RECEIVE_DELETE_LICENSE_ERROR = 'RECEIVE_DELETE_LICENSE_ERROR';
import * as types from './mutation_types';
export default {
[types.SET_INITIAL_DATA](state, data) {
Object.assign(state, data);
},
[types.REQUEST_LICENSES](state) {
state.isLoadingLicenses = true;
},
[types.RECEIVE_LICENSES_SUCCESS](state, licenses = []) {
state.isLoadingLicenses = false;
state.licenses = licenses;
},
[types.RECEIVE_LICENSES_ERROR](state) {
state.isLoadingLicenses = false;
},
[types.REQUEST_DELETE_LICENSE](state, { id }) {
if (state.deleteQueue.includes(id)) return;
state.deleteQueue.push(id);
},
[types.RECEIVE_DELETE_LICENSE_SUCCESS](state, { id }) {
const queueIndex = state.deleteQueue.indexOf(id);
const licenseIndex = state.licenses.findIndex(license => id === license.id);
if (queueIndex !== -1) state.deleteQueue.splice(queueIndex, 1);
if (licenseIndex !== -1) state.licenses.splice(licenseIndex, 1);
},
[types.RECEIVE_DELETE_LICENSE_ERROR](state, { id }) {
const queueIndex = state.deleteQueue.indexOf(id);
if (queueIndex !== -1) state.deleteQueue.splice(queueIndex, 1);
},
};
export default () => ({
licenses: [],
deleteQueue: [],
isLoadingLicenses: false,
licensesPath: '',
deleteLicensePath: '',
newLicensePath: '',
downloadLicensePath: '',
currentActiveUserCount: null,
});
import mountInstanceLicenseApp from 'ee/licenses';
document.addEventListener('DOMContentLoaded', () => {
const mountElement = document.getElementById('instance-license-mount-element');
mountInstanceLicenseApp(mountElement);
});
......@@ -5,3 +5,64 @@
color: $gl-gray-light;
}
}
.license-card-body {
overflow-x: scroll;
@include media-breakpoint-up(lg) {
overflow-x: hidden;
}
}
.license-table {
min-width: max-content;
.license-row:first-child .license-cell {
border-top: 0;
}
.license-cell:last-child {
border-right: 0;
}
}
.license-cell {
border-color: $gray-200;
border-style: solid;
border-width: 1px 1px 0 0;
flex-basis: 0;
.title {
color: $gray-700;
line-height: $gl-line-height;
}
.value {
color: $gray-900;
&.number {
font-size: 1.25rem;
}
}
}
.license-header-cell {
flex-basis: initial;
width: $license-header-cell-width;
.title {
color: $gray-900;
}
}
.skeleton-license-card {
.skeleton-bar {
max-height: $gl-line-height;
.skeleton-line-1,
.skeleton-line-1::after {
height: 100%;
width: 100%;
}
}
}
......@@ -110,5 +110,11 @@ module LicenseHelper
!Gitlab::CurrentSettings.should_check_namespace_plan? && show_promotions? && show_callout?('promote_advanced_search_dismissed') && !License.feature_available?(:elastic_search)
end
def license_app_data
{ data: { current_active_user_count: current_active_user_count,
licenses_path: api_v4_licenses_path, delete_license_path: api_v4_license_path(id: ':id'),
new_license_path: new_admin_license_path, download_license_path: download_admin_license_path } }
end
extend self
end
- page_title "License"
%h3.page-title
Your License
- if current_license.trial?
= render "upload_buy_license"
- else
= link_to 'Upload New License', new_admin_license_path, class: "btn btn-success float-right"
%hr
- if Feature.enabled?(:licenses_app)
#instance-license-mount-element{ license_app_data }
- else
%h3.page-title
Your License
- if current_license.trial?
= render "upload_buy_license"
- else
= link_to 'Upload New License', new_admin_license_path, class: "btn btn-success float-right"
.row
.col-md-6
.card
.card-header
Licensed to
%ul.content-list
- @license.licensee.each do |label, value|
%li
%span.light #{label}:
%strong= value
%hr
.card.js-license-info-panel
.card-header
Details
%ul.content-list
%li
%span.light Plan:
%strong= @license.plan.capitalize
%li
%span.light Uploaded:
%strong= time_ago_with_tooltip @license.created_at
%li
%span.light Started:
%strong= time_ago_with_tooltip @license.starts_at
%li
%span.light
- if @license.expired?
Expired:
- else
Expires:
- if @license.will_expire? && @license.active?
- if @license.trial?
%strong.has-tooltip{ title: @license.expires_at.to_formatted_s(:long), data: { placement: 'top' } }
Free trial will expire in #{pluralize(@license.remaining_days, 'day')}
.row
.col-md-6
.card
.card-header
Licensed to
%ul.content-list
- @license.licensee.each do |label, value|
%li
%span.light #{label}:
%strong= value
.card.js-license-info-panel
.card-header
Details
%ul.content-list
%li
%span.light Plan:
%strong= @license.plan.capitalize
%li
%span.light Uploaded:
%strong= time_ago_with_tooltip @license.created_at
%li
%span.light Started:
%strong= time_ago_with_tooltip @license.starts_at
%li
%span.light
- if @license.expired?
Expired:
- else
Expires:
- if @license.will_expire? && @license.active?
- if @license.trial?
%strong.has-tooltip{ title: @license.expires_at.to_formatted_s(:long), data: { placement: 'top' } }
Free trial will expire in #{pluralize(@license.remaining_days, 'day')}
- else
%strong= time_ago_with_tooltip(@license.expires_at)
- else
%strong= time_ago_with_tooltip(@license.expires_at)
- else
%strong Never
%strong Never
- if @license.expired?
%span.badge.badge-danger.float-right
%strong Expired
- if @license.expired?
%span.badge.badge-danger.float-right
%strong Expired
.col-md-6
.card.border-info
.card-header.bg-info.text-white
Download license
.card-body
%p Your license will be included in your GitLab backup and will survive upgrades, so in normal usage you should never need to re-upload your <code>.gitlab-license</code> file.
%p Still, we recommend keeping a backup saved somewhere. Otherwise, if you ever need it and have lost it, you will need to request GitLab Inc. to send it to you again.
%br
= link_to 'Download license', download_admin_license_path, class: "btn btn-info"
.col-md-6
.card.border-info
.card-header.bg-info.text-white
Download license
.card-body
%p Your license will be included in your GitLab backup and will survive upgrades, so in normal usage you should never need to re-upload your <code>.gitlab-license</code> file.
%p Still, we recommend keeping a backup saved somewhere. Otherwise, if you ever need it and have lost it, you will need to request GitLab Inc. to send it to you again.
%br
= link_to 'Download license', download_admin_license_path, class: "btn btn-info"
.card.border-danger
.card-header.bg-danger.text-white
Remove license
.card-body
%p If you remove this license, GitLab will fall back on the previous license, if any.
%p If there is no previous license or if the previous license has expired, some GitLab functionality will be blocked until a new, valid license is uploaded.
%br
= link_to 'Remove license', admin_license_path, data: { confirm: "Are you sure you want to remove the license?" }, method: :delete, class: "btn btn-remove qa-remove-license-link"
.card.border-danger
.card-header.bg-danger.text-white
Remove license
.card-body
%p If you remove this license, GitLab will fall back on the previous license, if any.
%p If there is no previous license or if the previous license has expired, some GitLab functionality will be blocked until a new, valid license is uploaded.
%br
= link_to 'Remove license', admin_license_path, data: { confirm: "Are you sure you want to remove the license?" }, method: :delete, class: "btn btn-remove qa-remove-license-link"
= render "breakdown", license: @license
= render "breakdown", license: @license
- if @previous_licenses.any?
%h4 License History
- if @previous_licenses.any?
%h4 License History
.card#license_history
%table.table
%thead.card-header
%tr
- @license.licensee.keys.each do |label|
%th= label
%th Plan
%th Uploaded at
%th Started at
%th Expired at
%th Active users
%tbody
- @previous_licenses.each do |license|
.card#license_history
%table.table
%thead.card-header
%tr
- @license.licensee.keys.each do |label|
%td= license.licensee[label]
%td
%span
= license.plan.capitalize
%td
%span
= license.created_at
%td
%span
= license.starts_at
%td
%span
= license.expires_at || "Never"
%td
%span
- if license.restricted?(:active_user_count)
#{license.restrictions[:active_user_count]} users
- else
Unlimited
%th= label
%th Plan
%th Uploaded at
%th Started at
%th Expired at
%th Active users
%tbody
- @previous_licenses.each do |license|
%tr
- @license.licensee.keys.each do |label|
%td= license.licensee[label]
%td
%span
= license.plan.capitalize
%td
%span
= license.created_at
%td
%span
= license.starts_at
%td
%span
= license.expires_at || "Never"
%td
%span
- if license.restricted?(:active_user_count)
#{license.restrictions[:active_user_count]} users
- else
Unlimited
---
title: Simplify admin instance licenses page
merge_request: 9785
author:
type: other
......@@ -4,6 +4,7 @@ describe "Admin uploads license" do
set(:admin) { create(:admin) }
before do
stub_feature_flags(licenses_app: false)
sign_in(admin)
end
......
......@@ -4,6 +4,7 @@ describe "Admin views license" do
set(:admin) { create(:admin) }
before do
stub_feature_flags(licenses_app: false)
sign_in(admin)
end
......
# frozen_string_literal: true
require "spec_helper"
describe "Licenses app", :js do
let(:admin) { create(:admin) }
let!(:licenses) do
[
create(:license, data: build(:gitlab_license, restrictions: { active_user_count: 2000 }).export),
create(:license, data: build(:gitlab_license, expires_at: Date.today - 10, restrictions: { active_user_count: 2000, plan: 'ultimate' }).export)
]
end
def visit_page
visit(admin_license_path)
find('.js-license-table', match: :first)
end
def assert_usage_row(row, license)
header, seats_in_license, seats_in_use, historical_max, overage = row.find_all('.license-cell').to_a
expect(header).to have_content 'Usage'
expect(seats_in_license).to have_content 'Seats in license'
expect(seats_in_license).to have_content license.restrictions[:active_user_count]
expect(seats_in_use).to have_content 'Seats currently in use'
expect(seats_in_use).to have_content User.active.count
expect(historical_max).to have_content 'Max seats used'
expect(historical_max).to have_content license.historical_max
expect(overage).to have_content 'Users outside of license'
expect(overage).to have_content license.overage
end
def assert_validity_row(row, license)
header, starts_at, expires_at, created_at = row.find_all('.license-cell').to_a
expect(header).to have_content 'Validity'
expect(starts_at).to have_content 'Start date'
expect(starts_at).to have_content license.starts_at.strftime('%B %-d, %Y')
expect(expires_at).to have_content 'End date'
expect(expires_at).to have_content license.expires_at.strftime('%B %-d, %Y')
if license.expired?
expect(expires_at).to have_content 'Expired'
else
expect(expires_at).not_to have_content 'Expired'
end
expect(created_at).to have_content 'Uploaded on'
expect(created_at).to have_content license.created_at.strftime('%B %-d, %Y')
end
def assert_registration_row(row, license)
header, name, email, company = row.find_all('.license-cell').to_a
expect(header).to have_content 'Registration'
expect(name).to have_content 'Licensed to'
expect(name).to have_content license.licensee['Name'] || 'Unknown'
expect(email).to have_content 'Email address'
expect(email).to have_content license.licensee['Email'] || 'Unknown'
expect(company).to have_content 'Company'
expect(company).to have_content license.licensee['Company'] || 'Unknown'
end
def assert_license_card(card, license)
top_row, middle_row, bottom_row = card.find_all('.license-row').to_a
assert_usage_row(top_row, license)
assert_validity_row(middle_row, license)
assert_registration_row(bottom_row, license)
end
before do
stub_feature_flags(licenses_app: true)
sign_in(admin)
end
it 'renders a list of licenses' do
visit_page
licenses.each_with_index do |license, index|
assert_license_card(find_all('.license-table')[index], licenses.reverse[index])
end
end
it 'deletes a license' do
visit_page
license_card = find('.license-card', match: :first)
current_id = License.current.id
license_card.find('.js-manage-license').click
page.accept_alert 'Are you sure you want to permanently delete this license?' do
license_card.find('.js-delete-license').click
end
expect(license_card).not_to have_selector('.license-card-loading')
expect(License.find_by(id: current_id)).to be_nil
end
end
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`InstanceCardsList renders a list of license cards 1`] = `
<div>
<div
class="d-flex justify-content-between align-items-center"
>
<h4>
Instance license
</h4>
<glbutton-stub
class="my-3 js-add-license"
href="/newLicensePath"
variant="success"
>
Add license
</glbutton-stub>
</div>
<ul
class="license-list list-unstyled"
>
<li>
<licensecard-stub
iscurrentlicense="true"
license="[object Object]"
/>
</li>
<li>
<licensecard-stub
license="[object Object]"
/>
</li>
</ul>
</div>
`;
exports[`InstanceCardsList renders a message when there are no licenses 1`] = `
<div>
<div
class="d-flex justify-content-between align-items-center"
>
<h4>
Instance license
</h4>
<glbutton-stub
class="my-3 js-add-license"
href="/newLicensePath"
variant="success"
>
Add license
</glbutton-stub>
</div>
<ul
class="license-list list-unstyled"
>
<li>
<strong>
No licenses found.
</strong>
</li>
</ul>
</div>
`;
exports[`InstanceCardsList renders a skeleton loading card if loading licenses 1`] = `
<div>
<div
class="d-flex justify-content-between align-items-center"
>
<h4>
Instance license
</h4>
<glbutton-stub
class="my-3 js-add-license"
href="/newLicensePath"
variant="success"
>
Add license
</glbutton-stub>
</div>
<ul
class="license-list list-unstyled"
>
<li>
<skeletonlicensecard-stub />
</li>
</ul>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LicenseCardBody renders a license card body 1`] = `
<div
class="card-body license-card-body p-0"
>
<div
class="license-table js-license-table"
>
<div
class="license-row d-flex"
>
<headercell-stub
icon="monitor"
title="Usage"
/>
<cell-stub
isflexible="true"
title="Seats in license"
value="10"
/>
<infocell-stub
popovercontent="Users with a Guest role or those who don't belong to any projects or groups don't count towards seats in use."
title="Seats currently in use"
value="10"
/>
<infocell-stub
popovercontent="This is the maximum number of users that have existed at the same time since the license started. This is the minimum number of seats you will need to buy when you renew your license."
title="Max seats used"
value="20"
/>
<infocell-stub
popovercontent="GitLab allows you to continue using your license even if you exceed the number of seats you purchased. You will be required to pay for these seats when you renew your license."
title="Users outside of license"
value="5"
/>
</div>
<div
class="license-row d-flex"
>
<headercell-stub
icon="calendar"
title="Validity"
/>
<datecell-stub
datenow="2017/10/10"
title="Start date"
value="2013/10/10"
/>
<datecell-stub
datenow="2017/10/10"
isexpirable="true"
title="End date"
value="2015/10/10"
/>
<datecell-stub
datenow="2017/10/10"
title="Uploaded on"
/>
</div>
<div
class="license-row d-flex"
>
<headercell-stub
icon="user"
title="Registration"
/>
<cell-stub
isflexible="true"
title="Licensed to"
value="Jon Dough"
/>
<cell-stub
isflexible="true"
title="Email address"
value="email@address.tanuki"
/>
<cell-stub
isflexible="true"
title="Company"
value="TanukiVille"
/>
</div>
</div>
</div>
`;
exports[`LicenseCardBody renders a loading state if isRemoving 1`] = `
<div
class="card-body license-card-body p-0"
>
<div
class="p-5 d-flex justify-content-center align-items-center license-card-loading"
>
<icon-stub
cssclasses=""
name="spinner"
size="16"
/>
<span
class="ml-2"
>
Removing license…
</span>
</div>
</div>
`;
exports[`LicenseCardBody renders fallback licensee values 1`] = `
<div
class="card-body license-card-body p-0"
licensee="[object Object]"
>
<div
class="license-table js-license-table"
>
<div
class="license-row d-flex"
>
<headercell-stub
icon="monitor"
title="Usage"
/>
<cell-stub
isflexible="true"
title="Seats in license"
value="10"
/>
<infocell-stub
popovercontent="Users with a Guest role or those who don't belong to any projects or groups don't count towards seats in use."
title="Seats currently in use"
value="10"
/>
<infocell-stub
popovercontent="This is the maximum number of users that have existed at the same time since the license started. This is the minimum number of seats you will need to buy when you renew your license."
title="Max seats used"
value="20"
/>
<infocell-stub
popovercontent="GitLab allows you to continue using your license even if you exceed the number of seats you purchased. You will be required to pay for these seats when you renew your license."
title="Users outside of license"
value="5"
/>
</div>
<div
class="license-row d-flex"
>
<headercell-stub
icon="calendar"
title="Validity"
/>
<datecell-stub
datenow="2017/10/10"
title="Start date"
value="2013/10/10"
/>
<datecell-stub
datenow="2017/10/10"
isexpirable="true"
title="End date"
value="2015/10/10"
/>
<datecell-stub
datenow="2017/10/10"
title="Uploaded on"
/>
</div>
<div
class="license-row d-flex"
>
<headercell-stub
icon="user"
title="Registration"
/>
<cell-stub
isflexible="true"
title="Licensed to"
value="Jon Dough"
/>
<cell-stub
isflexible="true"
title="Email address"
value="email@address.tanuki"
/>
<cell-stub
isflexible="true"
title="Company"
value="TanukiVille"
/>
</div>
</div>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LicenseCard renders license card with a delete button and license body 1`] = `
<div
class="card license-card mb-5"
>
<div
class="card-header"
>
<div
class="d-flex justify-content-between align-items-center"
>
<h4>
GitLab Enterprise Edition Super duper
</h4>
<gldropdown-stub
class="js-manage-license"
right=""
text="Manage"
>
<!---->
<gldropdownitem-stub
class="js-delete-license text-danger"
>
Delete license
</gldropdownitem-stub>
</gldropdown-stub>
</div>
</div>
<licensecardbody-stub
currentactiveusercount="10"
license="[object Object]"
/>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SkeletonLicenseCard renders a skeleton license card 1`] = `
<div
class="card license-card skeleton-license-card"
>
<div
class="card-header d-flex justify-content-between align-items-center py-3"
>
<glskeletonloading-stub
class="w-75 skeleton-bar"
lines="1"
/>
</div>
<div
class="card-body p-0"
>
<div
class="license-table"
>
<div
class="license-row d-flex"
>
<skeletonheadercell-stub />
<skeletoncell-stub />
<skeletoncell-stub />
<skeletoncell-stub />
<skeletoncell-stub />
</div>
<div
class="license-row d-flex"
>
<skeletonheadercell-stub />
<skeletoncell-stub />
<skeletoncell-stub />
<skeletoncell-stub />
</div>
<div
class="license-row d-flex"
>
<skeletonheadercell-stub />
<skeletoncell-stub />
<skeletoncell-stub />
<skeletoncell-stub />
</div>
</div>
</div>
</div>
`;
import { shallowMount } from '@vue/test-utils';
import LicenseCardBody from 'ee/licenses/components/cards/license_card_body.vue';
describe('LicenseCardBody', () => {
let wrapper;
const defaultProps = {
license: {
userLimit: 10,
historicalMax: 20,
overage: 5,
startsAt: '2013/10/10',
expiresAt: '2015/10/10',
licensee: {
Name: 'Jon Dough',
Email: 'email@address.tanuki',
Company: 'TanukiVille',
},
},
isRemoving: false,
currentActiveUserCount: 10,
};
function createComponent(props) {
const propsData = Object.assign({}, defaultProps, props);
wrapper = shallowMount(LicenseCardBody, {
propsData,
});
}
beforeEach(() => {
jest.spyOn(global.Date.prototype, 'toString').mockReturnValue('2017/10/10');
});
afterEach(() => {
if (wrapper) wrapper.destroy();
global.Date.prototype.toString.mockRestore();
});
it('renders a license card body', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
it('renders a loading state if isRemoving', () => {
createComponent({ isRemoving: true });
expect(wrapper.element).toMatchSnapshot();
});
it('renders fallback licensee values', () => {
createComponent({ licensee: {} });
expect(wrapper.element).toMatchSnapshot();
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { LicenseCard } from 'ee/licenses/components/cards';
describe('LicenseCard', () => {
let wrapper;
let actions;
const defaultProps = {
license: {
id: 1,
plan: 'super duper',
},
isCurrentLicense: false,
};
const defaultState = {
currentActiveUserCount: 10,
deleteQueue: [],
downloadLicensePath: '/downloadLicensePath',
};
const localVue = createLocalVue();
localVue.use(Vuex);
function createStore(newState) {
const state = Object.assign({}, defaultState, newState);
actions = { fetchDeleteLicense: jest.fn() };
return new Vuex.Store({ state, actions });
}
function createComponent(state, props) {
const propsData = Object.assign({}, defaultProps, props);
wrapper = shallowMount(LicenseCard, {
store: createStore(state),
propsData,
localVue,
});
}
afterEach(() => {
if (wrapper) wrapper.destroy();
});
it('renders license card with a delete button and license body', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
});
import { shallowMount } from '@vue/test-utils';
import { SkeletonLicenseCard } from 'ee/licenses/components/cards';
describe('SkeletonLicenseCard', () => {
let wrapper;
function createComponent() {
wrapper = shallowMount(SkeletonLicenseCard);
}
afterEach(() => {
if (wrapper) wrapper.destroy();
});
it('renders a skeleton license card', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Cell renders a number value and title through props 1`] = `
<div
class="license-cell p-3 text-nowrap flex-shrink-0 flex-grow-1"
>
<span
class="title d-flex align-items-center justify-content-start"
>
<span>
title
</span>
</span>
<div
class="value mt-2 number"
>
<span>
100
</span>
</div>
</div>
`;
exports[`Cell renders a string value and title through props 1`] = `
<div
class="license-cell p-3 text-nowrap flex-shrink-0 flex-grow-1"
>
<span
class="title d-flex align-items-center justify-content-start"
>
<span>
title
</span>
</span>
<div
class="value mt-2"
>
<span>
value
</span>
</div>
</div>
`;
exports[`Cell renders an inflexible variant 1`] = `
<div
class="license-cell p-3 text-nowrap flex-shrink-0"
>
<span
class="title d-flex align-items-center justify-content-start"
>
<span>
title
</span>
</span>
<div
class="value mt-2"
>
<span>
value
</span>
</div>
</div>
`;
exports[`Cell renders value and title slots that override props 1`] = `
<div
class="license-cell p-3 text-nowrap flex-shrink-0 flex-grow-1"
>
<span
class="title d-flex align-items-center justify-content-start"
>
<h1>
tanuki
</h1>
</span>
<div
class="value mt-2"
>
<marquee>
party
</marquee>
</div>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DateCell renders a date value that represents a date in words and title through props 1`] = `
<cell-stub
isflexible="true"
title="title"
>
<div
class=""
>
March 6, 2018
<!---->
</div>
</cell-stub>
`;
exports[`DateCell renders a fallback value if isExpirable and no value 1`] = `
<cell-stub
isflexible="true"
title="title"
value="Never"
>
<!---->
</cell-stub>
`;
exports[`DateCell renders a string value that represents a date in words and title through props 1`] = `
<cell-stub
isflexible="true"
title="title"
>
<div
class=""
>
October 24, 2018
<!---->
</div>
</cell-stub>
`;
exports[`DateCell renders an expired warning if isExpirable and date value is before now 1`] = `
<cell-stub
isflexible="true"
title="title"
value="Never"
>
<div
class="text-danger"
>
October 24, 2018
<span>
- Expired
</span>
</div>
</cell-stub>
`;
exports[`DateCell renders date value with no warning if isExpirable and date value is after now 1`] = `
<cell-stub
isflexible="true"
title="title"
value="Never"
>
<div
class=""
>
October 24, 2018
<!---->
</div>
</cell-stub>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`HeaderCell renders an inflexible cell with a title with an icon through props 1`] = `
<cell-stub
class="license-header-cell"
>
<template>
<icon-stub
class="icon"
cssclasses=""
name="retry"
size="16"
/>
<span
class="ml-2 font-weight-bold"
>
title
</span>
</template>
</cell-stub>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`InfoCell renders a number value 1`] = `
<cell-stub
class="license-info-cell"
isflexible="true"
value="100"
>
<template>
<span
class="mr-2 text"
>
title
</span>
<button
class="btn-link information-target"
type="button"
>
<icon-stub
cssclasses="icon d-block"
name="information"
size="16"
/>
</button>
<glpopover-stub
content="popoverContent"
placement="bottom"
triggers="hover"
/>
</template>
</cell-stub>
`;
exports[`InfoCell renders a title and string value with an info popover through props 1`] = `
<cell-stub
class="license-info-cell"
isflexible="true"
value="value"
>
<template>
<span
class="mr-2 text"
>
title
</span>
<button
class="btn-link information-target"
type="button"
>
<icon-stub
cssclasses="icon d-block"
name="information"
size="16"
/>
</button>
<glpopover-stub
content="popoverContent"
placement="bottom"
triggers="hover"
/>
</template>
</cell-stub>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SkeletonCell renders a skeleton cell with a title and value loading bar 1`] = `
<cell-stub
isflexible="true"
>
<glskeletonloading-stub
class="w-75 skeleton-bar"
lines="1"
/>
<glskeletonloading-stub
class="w-50 skeleton-bar"
lines="1"
/>
</cell-stub>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SkeletonHeaderCell renders a skeleton cell with a single title loading bar 1`] = `
<cell-stub
class="license-header-cell"
>
<glskeletonloading-stub
class="w-75 skeleton-bar"
lines="1"
/>
</cell-stub>
`;
import { shallowMount } from '@vue/test-utils';
import { Cell } from 'ee/licenses/components/cells';
describe('Cell', () => {
let wrapper;
const defaultProps = {
title: 'title',
value: 'value',
};
function createComponent(props, slots) {
const propsData = Object.assign({}, defaultProps, props);
wrapper = shallowMount(Cell, {
propsData,
slots,
});
}
afterEach(() => {
if (wrapper) wrapper.destroy();
});
it('renders a string value and title through props', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
it('renders a number value and title through props', () => {
createComponent({ value: 100 });
expect(wrapper.element).toMatchSnapshot();
});
it('renders value and title slots that override props', () => {
createComponent(null, { title: '<h1>tanuki</h1>', value: '<marquee>party</marquee>' });
expect(wrapper.element).toMatchSnapshot();
});
it('renders an inflexible variant', () => {
createComponent({ isFlexible: false });
expect(wrapper.element).toMatchSnapshot();
});
});
import { shallowMount } from '@vue/test-utils';
import { DateCell } from 'ee/licenses/components/cells';
describe('DateCell', () => {
let wrapper;
const defaultProps = {
title: 'title',
value: '2018/10/24',
};
function createComponent(props) {
const propsData = Object.assign({}, defaultProps, props);
wrapper = shallowMount(DateCell, {
propsData,
});
}
afterEach(() => {
if (wrapper) wrapper.destroy();
});
it('renders a string value that represents a date in words and title through props', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
it('renders a date value that represents a date in words and title through props', () => {
createComponent({ value: new Date('2018/03/06') });
expect(wrapper.element).toMatchSnapshot();
});
it('renders an expired warning if isExpirable and date value is before now', () => {
createComponent({ isExpirable: true });
expect(wrapper.element).toMatchSnapshot();
});
it('renders date value with no warning if isExpirable and date value is after now', () => {
createComponent({ isExpirable: true, dateNow: new Date('2017/10/10') });
expect(wrapper.element).toMatchSnapshot();
});
it('renders a fallback value if isExpirable and no value', () => {
createComponent({ isExpirable: true, value: undefined });
expect(wrapper.element).toMatchSnapshot();
});
});
import { shallowMount } from '@vue/test-utils';
import { HeaderCell } from 'ee/licenses/components/cells';
describe('HeaderCell', () => {
let wrapper;
function createComponent() {
wrapper = shallowMount(HeaderCell, {
propsData: {
title: 'title',
icon: 'retry',
},
});
}
afterEach(() => {
if (wrapper) wrapper.destroy();
});
it('renders an inflexible cell with a title with an icon through props', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
});
import { shallowMount } from '@vue/test-utils';
import { InfoCell } from 'ee/licenses/components/cells';
describe('InfoCell', () => {
let wrapper;
const defaultProps = {
title: 'title',
value: 'value',
popoverContent: 'popoverContent',
};
function createComponent(props, slots) {
const propsData = Object.assign({}, defaultProps, props);
wrapper = shallowMount(InfoCell, {
propsData,
slots,
});
}
afterEach(() => {
if (wrapper) wrapper.destroy();
});
it('renders a title and string value with an info popover through props', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
it('renders a number value', () => {
createComponent({ value: 100 });
expect(wrapper.element).toMatchSnapshot();
});
});
import { shallowMount } from '@vue/test-utils';
import { SkeletonCell } from 'ee/licenses/components/cells';
describe('SkeletonCell', () => {
let wrapper;
function createComponent() {
wrapper = shallowMount(SkeletonCell);
}
afterEach(() => {
if (wrapper) wrapper.destroy();
});
it('renders a skeleton cell with a title and value loading bar', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
});
import { shallowMount } from '@vue/test-utils';
import { SkeletonHeaderCell } from 'ee/licenses/components/cells';
describe('SkeletonHeaderCell', () => {
let wrapper;
function createComponent() {
wrapper = shallowMount(SkeletonHeaderCell);
}
afterEach(() => {
if (wrapper) wrapper.destroy();
});
it('renders a skeleton cell with a single title loading bar', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import InstanceCardsList from 'ee/licenses/components/license_cards_list.vue';
import * as getters from 'ee/licenses/store/getters';
import createState from 'ee/licenses/store/state';
describe('InstanceCardsList', () => {
const newLicensePath = '/newLicensePath';
let wrapper;
const localVue = createLocalVue();
localVue.use(Vuex);
function createStore(store) {
const state = Object.assign(createState(), store, {
newLicensePath,
});
return new Vuex.Store({ state, getters });
}
function createComponent(store) {
wrapper = shallowMount(InstanceCardsList, {
store: createStore(store),
localVue,
});
}
afterEach(() => {
if (wrapper) wrapper.destroy();
});
it('renders a list of license cards', () => {
createComponent({ licenses: [{ id: 1 }, { id: 2 }], isLoadingLicenses: false });
expect(wrapper.element).toMatchSnapshot();
});
it('renders a skeleton loading card if loading licenses', () => {
createComponent({ isLoadingLicenses: true });
expect(wrapper.element).toMatchSnapshot();
});
it('renders a message when there are no licenses', () => {
createComponent({ licenses: [], isLoadingLicenses: false });
expect(wrapper.element).toMatchSnapshot();
});
});
......@@ -1221,6 +1221,9 @@ msgstr ""
msgid "Are you sure you want to lose your issue information?"
msgstr ""
msgid "Are you sure you want to permanently delete this license?"
msgstr ""
msgid "Are you sure you want to regenerate the public key? You will have to update the public key on the remote server before mirroring will work again."
msgstr ""
......@@ -2834,6 +2837,9 @@ msgstr ""
msgid "Commit…"
msgstr ""
msgid "Company"
msgstr ""
msgid "Compare"
msgstr ""
......@@ -3374,6 +3380,9 @@ msgstr ""
msgid "Delete comment"
msgstr ""
msgid "Delete license"
msgstr ""
msgid "Delete list"
msgstr ""
......@@ -3386,6 +3395,15 @@ msgstr ""
msgid "Deleted"
msgstr ""
msgid "Deleting the license failed."
msgstr ""
msgid "Deleting the license failed. The license was not found."
msgstr ""
msgid "Deleting the license failed. You are not permitted to perform this action."
msgstr ""
msgid "Deny"
msgstr ""
......@@ -3679,6 +3697,9 @@ msgstr ""
msgid "Download asset"
msgstr ""
msgid "Download license"
msgstr ""
msgid "Download source code"
msgstr ""
......@@ -3772,6 +3793,9 @@ msgstr ""
msgid "Email"
msgstr ""
msgid "Email address"
msgstr ""
msgid "Email patch"
msgstr ""
......@@ -3868,6 +3892,9 @@ msgstr ""
msgid "Enabling this will only make licensed EE features available to projects if the project namespace's plan includes the feature or if the project is public."
msgstr ""
msgid "End date"
msgstr ""
msgid "Ends at (UTC)"
msgstr ""
......@@ -4273,6 +4300,9 @@ msgstr ""
msgid "Expiration date"
msgstr ""
msgid "Expired"
msgstr ""
msgid "Expired %{expiredOn}"
msgstr ""
......@@ -4519,6 +4549,15 @@ msgstr ""
msgid "Fetching incoming email"
msgstr ""
msgid "Fetching licenses failed."
msgstr ""
msgid "Fetching licenses failed. The request endpoint was not found."
msgstr ""
msgid "Fetching licenses failed. You are not permitted to perform this action."
msgstr ""
msgid "Fields on this page are now uneditable, you can configure"
msgstr ""
......@@ -5160,6 +5199,9 @@ msgstr ""
msgid "GitLab CI Linter has been moved"
msgstr ""
msgid "GitLab Enterprise Edition %{plan}"
msgstr ""
msgid "GitLab Geo"
msgstr ""
......@@ -5175,6 +5217,9 @@ msgstr ""
msgid "GitLab User"
msgstr ""
msgid "GitLab allows you to continue using your license even if you exceed the number of seats you purchased. You will be required to pay for these seats when you renew your license."
msgstr ""
msgid "GitLab metadata URL"
msgstr ""
......@@ -5836,6 +5881,9 @@ msgstr ""
msgid "Instance does not support multiple Kubernetes clusters"
msgstr ""
msgid "Instance license"
msgstr ""
msgid "Integrations"
msgstr ""
......@@ -6354,6 +6402,9 @@ msgstr ""
msgid "LicenseManagement|You are about to remove the license, %{name}, from this project."
msgstr ""
msgid "Licensed to"
msgstr ""
msgid "Licenses"
msgstr ""
......@@ -6461,6 +6512,9 @@ msgstr ""
msgid "Make sure you're logged into the account that owns the projects you'd like to import."
msgstr ""
msgid "Manage"
msgstr ""
msgid "Manage Git repositories with fine-grained access controls that keep your code secure. Perform code reviews and enhance collaboration with merge requests. Each project can also have an issue tracker and a wiki."
msgstr ""
......@@ -6539,6 +6593,9 @@ msgstr ""
msgid "Max access level"
msgstr ""
msgid "Max seats used"
msgstr ""
msgid "Maximum artifacts size (MB)"
msgstr ""
......@@ -7159,6 +7216,9 @@ msgstr ""
msgid "No license. All rights reserved"
msgstr ""
msgid "No licenses found."
msgstr ""
msgid "No matching results"
msgstr ""
......@@ -8702,6 +8762,9 @@ msgstr ""
msgid "Register and see your runners for this project."
msgstr ""
msgid "Registration"
msgstr ""
msgid "Registry"
msgstr ""
......@@ -8777,6 +8840,9 @@ msgstr ""
msgid "Removing group will cause all child projects and resources to be removed."
msgstr ""
msgid "Removing license…"
msgstr ""
msgid "Rename"
msgstr ""
......@@ -9266,6 +9332,12 @@ msgstr ""
msgid "SearchResults|Showing %{from} - %{to} of %{count} %{scope} for \"%{term}\""
msgstr ""
msgid "Seats currently in use"
msgstr ""
msgid "Seats in license"
msgstr ""
msgid "Secret"
msgstr ""
......@@ -10779,6 +10851,9 @@ msgstr ""
msgid "This is the author's first Merge Request to this project."
msgstr ""
msgid "This is the maximum number of users that have existed at the same time since the license started. This is the minimum number of seats you will need to buy when you renew your license."
msgstr ""
msgid "This issue is confidential"
msgstr ""
......@@ -11392,6 +11467,9 @@ msgstr ""
msgid "Unknown"
msgstr ""
msgid "Unlimited"
msgstr ""
msgid "Unlock"
msgstr ""
......@@ -11521,12 +11599,18 @@ msgstr ""
msgid "UploadLink|click to upload"
msgstr ""
msgid "Uploaded on"
msgstr ""
msgid "Upstream"
msgstr ""
msgid "Upvotes"
msgstr ""
msgid "Usage"
msgstr ""
msgid "Usage ping is not enabled"
msgstr ""
......@@ -11689,12 +11773,18 @@ msgstr ""
msgid "Users"
msgstr ""
msgid "Users outside of license"
msgstr ""
msgid "Users requesting access to"
msgstr ""
msgid "Users were successfully added."
msgstr ""
msgid "Users with a Guest role or those who don't belong to any projects or groups don't count towards seats in use."
msgstr ""
msgid "Validate"
msgstr ""
......@@ -11704,6 +11794,9 @@ msgstr ""
msgid "Validations failed."
msgstr ""
msgid "Validity"
msgstr ""
msgid "Value"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment