Commit 782e6e37 authored by Andrew Fontaine's avatar Andrew Fontaine Committed by Enrique Alcántara

Migrate Deploy Keys Confirmation Modal to GitLab UI

parent c186df87
<script> <script>
import { GlLoadingIcon } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
export default { export default {
components: { components: {
GlLoadingIcon, GlButton,
}, },
props: { props: {
deployKey: { deployKey: {
...@@ -15,10 +15,20 @@ export default { ...@@ -15,10 +15,20 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
btnCssClass: { category: {
type: String, type: String,
required: false, required: false,
default: 'btn-default', default: 'tertiary',
},
variant: {
type: String,
required: false,
default: 'default',
},
icon: {
type: String,
required: false,
default: '',
}, },
}, },
data() { data() {
...@@ -39,13 +49,14 @@ export default { ...@@ -39,13 +49,14 @@ export default {
</script> </script>
<template> <template>
<button <gl-button
:class="[{ disabled: isLoading }, btnCssClass]" :category="category"
:disabled="isLoading" :variant="variant"
:icon="icon"
:loading="isLoading"
class="btn" class="btn"
@click="doAction" @click="doAction"
> >
<slot></slot> <slot></slot>
<gl-loading-icon v-if="isLoading" :inline="true" /> </gl-button>
</button>
</template> </template>
...@@ -6,10 +6,12 @@ import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; ...@@ -6,10 +6,12 @@ import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import DeployKeysService from '../service'; import DeployKeysService from '../service';
import DeployKeysStore from '../store'; import DeployKeysStore from '../store';
import ConfirmModal from './confirm_modal.vue';
import KeysPanel from './keys_panel.vue'; import KeysPanel from './keys_panel.vue';
export default { export default {
components: { components: {
ConfirmModal,
KeysPanel, KeysPanel,
NavigationTabs, NavigationTabs,
GlLoadingIcon, GlLoadingIcon,
...@@ -30,6 +32,9 @@ export default { ...@@ -30,6 +32,9 @@ export default {
currentTab: 'enabled_keys', currentTab: 'enabled_keys',
isLoading: false, isLoading: false,
store: new DeployKeysStore(), store: new DeployKeysStore(),
removeKey: () => {},
cancel: () => {},
confirmModalVisible: false,
}; };
}, },
scopes: { scopes: {
...@@ -61,16 +66,16 @@ export default { ...@@ -61,16 +66,16 @@ export default {
this.service = new DeployKeysService(this.endpoint); this.service = new DeployKeysService(this.endpoint);
eventHub.$on('enable.key', this.enableKey); eventHub.$on('enable.key', this.enableKey);
eventHub.$on('remove.key', this.disableKey); eventHub.$on('remove.key', this.confirmRemoveKey);
eventHub.$on('disable.key', this.disableKey); eventHub.$on('disable.key', this.confirmRemoveKey);
}, },
mounted() { mounted() {
this.fetchKeys(); this.fetchKeys();
}, },
beforeDestroy() { beforeDestroy() {
eventHub.$off('enable.key', this.enableKey); eventHub.$off('enable.key', this.enableKey);
eventHub.$off('remove.key', this.disableKey); eventHub.$off('remove.key', this.confirmRemoveKey);
eventHub.$off('disable.key', this.disableKey); eventHub.$off('disable.key', this.confirmRemoveKey);
}, },
methods: { methods: {
onChangeTab(tab) { onChangeTab(tab) {
...@@ -97,19 +102,20 @@ export default { ...@@ -97,19 +102,20 @@ export default {
.then(this.fetchKeys) .then(this.fetchKeys)
.catch(() => new Flash(s__('DeployKeys|Error enabling deploy key'))); .catch(() => new Flash(s__('DeployKeys|Error enabling deploy key')));
}, },
disableKey(deployKey, callback) { confirmRemoveKey(deployKey, callback) {
if ( const hideModal = () => {
// eslint-disable-next-line no-alert this.confirmModalVisible = false;
window.confirm(s__('DeployKeys|You are going to remove this deploy key. Are you sure?')) callback?.();
) { };
this.removeKey = () => {
this.service this.service
.disableKey(deployKey.id) .disableKey(deployKey.id)
.then(this.fetchKeys) .then(this.fetchKeys)
.then(callback) .then(hideModal)
.catch(() => new Flash(s__('DeployKeys|Error removing deploy key'))); .catch(() => new Flash(s__('DeployKeys|Error removing deploy key')));
} else { };
callback(); this.cancel = hideModal;
} this.confirmModalVisible = true;
}, },
}, },
}; };
...@@ -117,6 +123,7 @@ export default { ...@@ -117,6 +123,7 @@ export default {
<template> <template>
<div class="gl-mb-3 deploy-keys"> <div class="gl-mb-3 deploy-keys">
<confirm-modal :visible="confirmModalVisible" @remove="removeKey" @cancel="cancel" />
<gl-loading-icon <gl-loading-icon
v-if="isLoading && !hasKeys" v-if="isLoading && !hasKeys"
:label="s__('DeployKeys|Loading deploy keys')" :label="s__('DeployKeys|Loading deploy keys')"
...@@ -124,8 +131,12 @@ export default { ...@@ -124,8 +131,12 @@ export default {
/> />
<template v-else-if="hasKeys"> <template v-else-if="hasKeys">
<div class="top-area scrolling-tabs-container inner-page-scroll-tabs"> <div class="top-area scrolling-tabs-container inner-page-scroll-tabs">
<div class="fade-left"><gl-icon name="chevron-lg-left" :size="12" /></div> <div class="fade-left">
<div class="fade-right"><gl-icon name="chevron-lg-right" :size="12" /></div> <gl-icon name="chevron-lg-left" :size="12" />
</div>
<div class="fade-right">
<gl-icon name="chevron-lg-right" :size="12" />
</div>
<navigation-tabs :tabs="tabs" scope="deployKeys" @onChangeTab="onChangeTab" /> <navigation-tabs :tabs="tabs" scope="deployKeys" @onChangeTab="onChangeTab" />
</div> </div>
......
<script>
import { GlModal } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlModal,
},
props: {
visible: {
type: Boolean,
required: false,
default: false,
},
},
i18n: {
body: __(
'Are you sure you want to remove this deploy key? If anything is still using this key, it will stop working.',
),
},
modalOptions: {
title: __('Do you want to remove this deploy key?'),
actionPrimary: {
text: __('Remove deploy key'),
attributes: [{ variant: 'danger' }],
},
actionSecondary: {
text: __('Cancel'),
attributes: [{ category: 'tertiary' }],
},
static: true,
modalId: 'confirm-remove-deploy-key',
},
};
</script>
<template>
<gl-modal
v-bind="$options.modalOptions"
:visible="visible"
@primary="$emit('remove')"
@secondary="$emit('cancel')"
@hidden="$emit('cancel')"
>
{{ $options.i18n.body }}
</gl-modal>
</template>
<script> <script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { GlIcon, GlLink, GlTooltipDirective, GlButton } from '@gitlab/ui';
import { head, tail } from 'lodash'; import { head, tail } from 'lodash';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago'; import timeagoMixin from '~/vue_shared/mixins/timeago';
...@@ -9,7 +9,9 @@ import actionBtn from './action_btn.vue'; ...@@ -9,7 +9,9 @@ import actionBtn from './action_btn.vue';
export default { export default {
components: { components: {
actionBtn, actionBtn,
GlButton,
GlIcon, GlIcon,
GlLink,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -123,15 +125,15 @@ export default { ...@@ -123,15 +125,15 @@ export default {
<div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Project usage') }}</div> <div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Project usage') }}</div>
<div class="table-mobile-content deploy-project-list"> <div class="table-mobile-content deploy-project-list">
<template v-if="projects.length > 0"> <template v-if="projects.length > 0">
<a <gl-link
v-gl-tooltip v-gl-tooltip
:title="projectTooltipTitle(firstProject)" :title="projectTooltipTitle(firstProject)"
class="label deploy-project-label" class="label deploy-project-label"
> >
<span> {{ firstProject.project.full_name }} </span> <span> {{ firstProject.project.full_name }} </span>
<gl-icon :name="firstProject.can_push ? 'lock-open' : 'lock'" /> <gl-icon :name="firstProject.can_push ? 'lock-open' : 'lock'" />
</a> </gl-link>
<a <gl-link
v-if="isExpandable" v-if="isExpandable"
v-gl-tooltip v-gl-tooltip
:title="restProjectsTooltip" :title="restProjectsTooltip"
...@@ -139,8 +141,8 @@ export default { ...@@ -139,8 +141,8 @@ export default {
@click="toggleExpanded" @click="toggleExpanded"
> >
<span>{{ restProjectsLabel }}</span> <span>{{ restProjectsLabel }}</span>
</a> </gl-link>
<a <gl-link
v-for="deployKeysProject in restProjects" v-for="deployKeysProject in restProjects"
v-else-if="isExpanded" v-else-if="isExpanded"
:key="deployKeysProject.project.full_path" :key="deployKeysProject.project.full_path"
...@@ -151,7 +153,7 @@ export default { ...@@ -151,7 +153,7 @@ export default {
> >
<span> {{ deployKeysProject.project.full_name }} </span> <span> {{ deployKeysProject.project.full_name }} </span>
<gl-icon :name="deployKeysProject.can_push ? 'lock-open' : 'lock'" /> <gl-icon :name="deployKeysProject.can_push ? 'lock-open' : 'lock'" />
</a> </gl-link>
</template> </template>
<span v-else class="text-secondary">{{ __('None') }}</span> <span v-else class="text-secondary">{{ __('None') }}</span>
</div> </div>
...@@ -166,41 +168,43 @@ export default { ...@@ -166,41 +168,43 @@ export default {
</div> </div>
<div class="table-section section-15 table-button-footer deploy-key-actions"> <div class="table-section section-15 table-button-footer deploy-key-actions">
<div class="btn-group table-action-buttons"> <div class="btn-group table-action-buttons">
<action-btn v-if="!isEnabled" :deploy-key="deployKey" type="enable"> <action-btn v-if="!isEnabled" :deploy-key="deployKey" type="enable" category="secondary">
{{ __('Enable') }} {{ __('Enable') }}
</action-btn> </action-btn>
<a <gl-button
v-if="deployKey.can_edit" v-if="deployKey.can_edit"
v-gl-tooltip v-gl-tooltip
:href="editDeployKeyPath" :href="editDeployKeyPath"
:title="__('Edit')" :title="__('Edit')"
class="btn btn-default text-secondary" :aria-label="__('Edit')"
data-container="body" data-container="body"
> icon="pencil"
<gl-icon name="pencil" /> category="secondary"
</a> />
<action-btn <action-btn
v-if="isRemovable" v-if="isRemovable"
v-gl-tooltip v-gl-tooltip
:deploy-key="deployKey" :deploy-key="deployKey"
:title="__('Remove')" :title="__('Remove')"
btn-css-class="btn-danger" :aria-label="__('Remove')"
category="primary"
variant="danger"
icon="remove"
type="remove" type="remove"
data-container="body" data-container="body"
> />
<gl-icon name="remove" />
</action-btn>
<action-btn <action-btn
v-else-if="isEnabled" v-else-if="isEnabled"
v-gl-tooltip v-gl-tooltip
:deploy-key="deployKey" :deploy-key="deployKey"
:title="__('Disable')" :title="__('Disable')"
btn-css-class="btn-warning" :aria-label="__('Disable')"
type="disable" type="disable"
data-container="body" data-container="body"
> icon="cancel"
<gl-icon name="cancel" /> category="primary"
</action-btn> variant="danger"
/>
</div> </div>
</div> </div>
</div> </div>
......
---
title: Use GlModal for Confirmation of Deploy Key Delete
merge_request: 59697
author:
type: changed
...@@ -151,6 +151,24 @@ Adding a public deploy key does not immediately expose any repository to it. Pub ...@@ -151,6 +151,24 @@ Adding a public deploy key does not immediately expose any repository to it. Pub
deploy keys enable access from other systems, but access is not given to any project deploy keys enable access from other systems, but access is not given to any project
until a project maintainer chooses to make use of it. until a project maintainer chooses to make use of it.
## How to disable deploy keys
[Project maintainers and owners](../../permissions.md#project-members-permissions)
can remove or disable a deploy key for a project repository:
1. Navigate to the project's **Settings > Repository** page.
1. Expand the **Deploy keys** section.
1. Select the **{remove}** or **{cancel}** button.
NOTE:
If anything relies on the removed deploy key, it will stop working once removed.
If the key is **publicly accessible**, it will be removed from the project, but still available under **Publicly accessible deploy keys**.
If the key is **privately accessible** and only in use by this project, it will deleted.
If the key is **privately accessible** and in use by other projects, it will be removed from the project, but still available under **Privately accesible deploy keys**.
## Troubleshooting ## Troubleshooting
### Deploy key cannot push to a protected branch ### Deploy key cannot push to a protected branch
......
...@@ -96,9 +96,8 @@ RSpec.describe 'Projects > Audit Events', :js do ...@@ -96,9 +96,8 @@ RSpec.describe 'Projects > Audit Events', :js do
visit project_deploy_keys_path(project) visit project_deploy_keys_path(project)
accept_confirm do click_button 'Remove'
find('[data-testid="remove-icon"]').click click_button 'Remove deploy key'
end
visit project_audit_events_path(project) visit project_audit_events_path(project)
......
...@@ -4265,6 +4265,9 @@ msgstr "" ...@@ -4265,6 +4265,9 @@ msgstr ""
msgid "Are you sure you want to remove the license?" msgid "Are you sure you want to remove the license?"
msgstr "" msgstr ""
msgid "Are you sure you want to remove this deploy key? If anything is still using this key, it will stop working."
msgstr ""
msgid "Are you sure you want to remove this identity?" msgid "Are you sure you want to remove this identity?"
msgstr "" msgstr ""
...@@ -10816,9 +10819,6 @@ msgstr "" ...@@ -10816,9 +10819,6 @@ msgstr ""
msgid "DeployKeys|Read access only" msgid "DeployKeys|Read access only"
msgstr "" msgstr ""
msgid "DeployKeys|You are going to remove this deploy key. Are you sure?"
msgstr ""
msgid "DeployTokens|Active Deploy Tokens (%{active_tokens})" msgid "DeployTokens|Active Deploy Tokens (%{active_tokens})"
msgstr "" msgstr ""
...@@ -11483,6 +11483,9 @@ msgstr "" ...@@ -11483,6 +11483,9 @@ msgstr ""
msgid "Do not display offers from third parties within GitLab" msgid "Do not display offers from third parties within GitLab"
msgstr "" msgstr ""
msgid "Do you want to remove this deploy key?"
msgstr ""
msgid "Dockerfile" msgid "Dockerfile"
msgstr "" msgstr ""
...@@ -26460,6 +26463,9 @@ msgstr "" ...@@ -26460,6 +26463,9 @@ msgstr ""
msgid "Remove child epic from an epic" msgid "Remove child epic from an epic"
msgstr "" msgstr ""
msgid "Remove deploy key"
msgstr ""
msgid "Remove description history" msgid "Remove description history"
msgstr "" msgstr ""
......
...@@ -22,7 +22,8 @@ RSpec.describe 'Project deploy keys', :js do ...@@ -22,7 +22,8 @@ RSpec.describe 'Project deploy keys', :js do
page.within(find('.qa-deploy-keys-settings')) do page.within(find('.qa-deploy-keys-settings')) do
expect(page).to have_selector('.deploy-key', count: 1) expect(page).to have_selector('.deploy-key', count: 1)
accept_confirm { find('[data-testid="remove-icon"]').click } click_button 'Remove'
click_button 'Remove deploy key'
wait_for_requests wait_for_requests
......
...@@ -117,7 +117,8 @@ RSpec.describe 'Projects > Settings > Repository settings' do ...@@ -117,7 +117,8 @@ RSpec.describe 'Projects > Settings > Repository settings' do
project.deploy_keys << private_deploy_key project.deploy_keys << private_deploy_key
visit project_settings_repository_path(project) visit project_settings_repository_path(project)
accept_confirm { find('.deploy-key', text: private_deploy_key.title).find('[data-testid="remove-icon"]').click } click_button 'Remove'
click_button 'Remove deploy key'
expect(page).not_to have_content(private_deploy_key.title) expect(page).not_to have_content(private_deploy_key.title)
end end
......
import { GlLoadingIcon } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import actionBtn from '~/deploy_keys/components/action_btn.vue'; import actionBtn from '~/deploy_keys/components/action_btn.vue';
import eventHub from '~/deploy_keys/eventhub'; import eventHub from '~/deploy_keys/eventhub';
...@@ -8,13 +8,16 @@ describe('Deploy keys action btn', () => { ...@@ -8,13 +8,16 @@ describe('Deploy keys action btn', () => {
const deployKey = data.enabled_keys[0]; const deployKey = data.enabled_keys[0];
let wrapper; let wrapper;
const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findButton = () => wrapper.findComponent(GlButton);
beforeEach(() => { beforeEach(() => {
wrapper = shallowMount(actionBtn, { wrapper = shallowMount(actionBtn, {
propsData: { propsData: {
deployKey, deployKey,
type: 'enable', type: 'enable',
category: 'primary',
variant: 'confirm',
icon: 'edit',
}, },
slots: { slots: {
default: 'Enable', default: 'Enable',
...@@ -26,10 +29,18 @@ describe('Deploy keys action btn', () => { ...@@ -26,10 +29,18 @@ describe('Deploy keys action btn', () => {
expect(wrapper.text()).toBe('Enable'); expect(wrapper.text()).toBe('Enable');
}); });
it('passes the button props on', () => {
expect(findButton().props()).toMatchObject({
category: 'primary',
variant: 'confirm',
icon: 'edit',
});
});
it('sends eventHub event with btn type', () => { it('sends eventHub event with btn type', () => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
wrapper.trigger('click'); findButton().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => { return wrapper.vm.$nextTick().then(() => {
expect(eventHub.$emit).toHaveBeenCalledWith('enable.key', deployKey, expect.anything()); expect(eventHub.$emit).toHaveBeenCalledWith('enable.key', deployKey, expect.anything());
...@@ -37,18 +48,10 @@ describe('Deploy keys action btn', () => { ...@@ -37,18 +48,10 @@ describe('Deploy keys action btn', () => {
}); });
it('shows loading spinner after click', () => { it('shows loading spinner after click', () => {
wrapper.trigger('click'); findButton().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(findLoadingIcon().exists()).toBe(true);
});
});
it('disables button after click', () => {
wrapper.trigger('click');
return wrapper.vm.$nextTick().then(() => { return wrapper.vm.$nextTick().then(() => {
expect(wrapper.attributes('disabled')).toBe('disabled'); expect(findButton().props('loading')).toBe(true);
}); });
}); });
}); });
...@@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; ...@@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { TEST_HOST } from 'spec/test_constants'; import { TEST_HOST } from 'spec/test_constants';
import deployKeysApp from '~/deploy_keys/components/app.vue'; import deployKeysApp from '~/deploy_keys/components/app.vue';
import ConfirmModal from '~/deploy_keys/components/confirm_modal.vue';
import eventHub from '~/deploy_keys/eventhub'; import eventHub from '~/deploy_keys/eventhub';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
...@@ -36,6 +37,7 @@ describe('Deploy keys app component', () => { ...@@ -36,6 +37,7 @@ describe('Deploy keys app component', () => {
const findLoadingIcon = () => wrapper.find('.gl-spinner'); const findLoadingIcon = () => wrapper.find('.gl-spinner');
const findKeyPanels = () => wrapper.findAll('.deploy-keys .gl-tabs-nav li'); const findKeyPanels = () => wrapper.findAll('.deploy-keys .gl-tabs-nav li');
const findModal = () => wrapper.findComponent(ConfirmModal);
it('renders loading icon while waiting for request', () => { it('renders loading icon while waiting for request', () => {
mock.onGet(TEST_ENDPOINT).reply(() => new Promise()); mock.onGet(TEST_ENDPOINT).reply(() => new Promise());
...@@ -94,11 +96,16 @@ describe('Deploy keys app component', () => { ...@@ -94,11 +96,16 @@ describe('Deploy keys app component', () => {
const key = data.public_keys[0]; const key = data.public_keys[0];
return mountComponent() return mountComponent()
.then(() => { .then(() => {
jest.spyOn(window, 'confirm').mockReturnValue(true);
jest.spyOn(wrapper.vm.service, 'getKeys').mockImplementation(() => {}); jest.spyOn(wrapper.vm.service, 'getKeys').mockImplementation(() => {});
jest.spyOn(wrapper.vm.service, 'disableKey').mockImplementation(() => Promise.resolve()); jest.spyOn(wrapper.vm.service, 'disableKey').mockImplementation(() => Promise.resolve());
eventHub.$emit('disable.key', key); eventHub.$emit('disable.key', key, () => {});
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findModal().props('visible')).toBe(true);
findModal().vm.$emit('remove');
return wrapper.vm.$nextTick(); return wrapper.vm.$nextTick();
}) })
...@@ -112,11 +119,16 @@ describe('Deploy keys app component', () => { ...@@ -112,11 +119,16 @@ describe('Deploy keys app component', () => {
const key = data.public_keys[0]; const key = data.public_keys[0];
return mountComponent() return mountComponent()
.then(() => { .then(() => {
jest.spyOn(window, 'confirm').mockReturnValue(true);
jest.spyOn(wrapper.vm.service, 'getKeys').mockImplementation(() => {}); jest.spyOn(wrapper.vm.service, 'getKeys').mockImplementation(() => {});
jest.spyOn(wrapper.vm.service, 'disableKey').mockImplementation(() => Promise.resolve()); jest.spyOn(wrapper.vm.service, 'disableKey').mockImplementation(() => Promise.resolve());
eventHub.$emit('remove.key', key); eventHub.$emit('remove.key', key, () => {});
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findModal().props('visible')).toBe(true);
findModal().vm.$emit('remove');
return wrapper.vm.$nextTick(); return wrapper.vm.$nextTick();
}) })
......
import { GlModal } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import ConfirmModal from '~/deploy_keys/components/confirm_modal.vue';
describe('~/deploy_keys/components/confirm_modal.vue', () => {
let wrapper;
let modal;
beforeEach(() => {
wrapper = mount(ConfirmModal, { propsData: { modalId: 'test', visible: true } });
modal = extendedWrapper(wrapper.findComponent(GlModal));
});
it('emits a remove event if the primary button is clicked', () => {
modal.findByText('Remove deploy key').trigger('click');
expect(wrapper.emitted('remove')).toEqual([[]]);
});
it('emits a cancel event if the secondary button is clicked', () => {
modal.findByText('Cancel').trigger('click');
expect(wrapper.emitted('cancel')).toEqual([[]]);
});
it('displays the warning about removing the deploy key', () => {
expect(modal.text()).toContain('Are you sure you want to remove this deploy key?');
});
});
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